Merge comment-reduction: cut source comment volume ~42% tree-wide across 12 tracks

This commit is contained in:
2026-07-29 21:16:52 -04:00
209 changed files with 7178 additions and 13653 deletions
+90 -181
View File
@@ -1,25 +1,16 @@
// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers. // main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
// //
// This file is the entire contract between REAPER and the extension: // REAPER dlopen()s reaper_*.dll|dylib|so from UserPlugins/ and calls the exported
// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and // ReaperPluginEntry, handing over `rec` (rec->GetFunc resolves API pointers,
// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry // rec->Register plugs our callbacks in). Exactly ONE .cpp defines
// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro). // REAPERAPI_IMPLEMENT (this one) — that allocates storage for the global API
// * REAPER calls it, handing over `rec` — a small dispatch struct. // pointers every other TU gets `extern`. Never let a second TU define it.
// - 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...).
// //
// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates // This TU is ONLY pointers + entry + dispatch. Its own action family registers
// storage for those global pointers. Every other .cpp includes // through the data-driven table below (buildMainActionTable + action_registry) —
// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. // 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
// Since Q-W3 this TU is ONLY pointers + entry + dispatch; since Q-W6 its own // triples, called from entry.
// 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.
#define REAPERAPI_IMPLEMENT #define REAPERAPI_IMPLEMENT
#include "reaper_plugin.h" #include "reaper_plugin.h"
@@ -32,9 +23,9 @@
#include "core/capture/render_settings.h" // captureActionTable #include "core/capture/render_settings.h" // captureActionTable
#include "core/version/app_version.h" // appVersion #include "core/version/app_version.h" // appVersion
#include "ingest.h" #include "ingest.h"
#include "shell/actions/action_registry.h" // the Q-W6 registration table #include "shell/actions/action_registry.h" // the registration table
#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home) #include "shell/actions/bank_actions.h" // multi-bank action family
#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home) #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_batch.h" // batch + recapture action bodies
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert 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 #include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
@@ -46,20 +37,13 @@
namespace capture = reasampler::capture; namespace capture = reasampler::capture;
// Globals other files reference via `extern`. // Globals other files reference via `extern`.
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle REAPER_PLUGIN_HINSTANCE g_hInst = nullptr;
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct reaper_plugin_info_t* g_rec = nullptr;
// Retired command-id SUFFIXES. Kept ONLY to mirror-unregister them on unload so a // Retired command-id SUFFIXES: kept ONLY to mirror-unregister on unload so a user's
// user's stale keybindings are cleaned up. Never re-register these. Composed through // stale keybindings are cleaned up. Never re-register these. The four-mode WET ids,
// the channel prefix at unload (channelIdFor) so a beta unload clears beta-qualified // the removed master scope/realtime actions, and the removed per-action tail variants
// retired ids and a stable unload clears stable's — each channel cleans up only its // (tail is now a panel toggle, not a paired action).
// 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.
static const char* const kRetiredCaptureCmdSuffixes[] = { static const char* const kRetiredCaptureCmdSuffixes[] = {
"CAPTURE_TRACKS_WET", "CAPTURE_TRACKS_WET",
"CAPTURE_ITEMS_WET", "CAPTURE_ITEMS_WET",
@@ -70,35 +54,30 @@ static const char* const kRetiredCaptureCmdSuffixes[] = {
"CAPTURE_TRACK_TAIL", "CAPTURE_TRACK_TAIL",
}; };
// The persistence session (M4): owns the in-memory BankModel and bridges it to // Owns the in-memory BankModel and bridges it to project ext state. A timer tick
// project ext state. A timer tick drives g_session.poll() to detect project // drives g_session.poll() to detect project load / Save-As; capture adds Samples to
// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to // g_session.bank() (resolves to the active bank's index), and we serialize the book
// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize // back into the active project's ext state (the `banks` key) so it travels with the .rpp.
// 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.
static reasampler::ReaSamplerSession g_session; static reasampler::ReaSamplerSession g_session;
// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so // 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). // OnToggleAction's checked-state poll is a single int compare (no per-poll lookup).
static int g_cmdToggleBankPanel = 0; static int g_cmdToggleBankPanel = 0;
// --- Action handlers (the table's 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
// Each is a thin stateless routing shim: (session, per-row arg) -> the action body // plain data with flat function pointers.
// 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.
// Capture scope family: `arg` is the captureActionTable() row index — the table rows // `arg` is the captureActionTable() row index — the table rows below are built by
// below are built by iterating that pure taxonomy, so the routing stays 1:1 by // iterating that pure taxonomy, so the routing stays 1:1 by construction.
// construction (never a hand-kept parallel list).
static void RunCaptureScopeRow(int arg) { static void RunCaptureScopeRow(int arg) {
capture::RunCapture(g_session, capture::RunCapture(g_session,
capture::captureActionTable()[static_cast<std::size_t>(arg)]); capture::captureActionTable()[static_cast<std::size_t>(arg)]);
} }
static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); } static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); }
static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); } static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); }
// Insert: `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (CONTEXT.md // `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (never silent); 0
// §insert: conform is opt-in, never silent); 0 inserts at native length. // inserts at native length.
static void RunInsertSelected(int arg) { static void RunInsertSelected(int arg) {
capture::RunInsertSelected(g_session, arg != 0); 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 RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); } static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
static void RunShowVersion(int) { static void RunShowVersion(int) {
// On-demand version readout — the ONLY version output on any path (Phase V: no // On-demand only — no unconditional startup print (routine console chatter pops
// unconditional startup print; routine console chatter pops the console window). // the console window).
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); 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, Actions-list
// // phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix (channel prefix // mirror-unregister all iterate this data. The capture scope rows come first,
// composed at register — stable rebuilds the exact shipped id, e.g. // sourced from the pure captureActionTable() taxonomy; the rest are this TU's singles.
// "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.
static std::vector<reasampler::ActionTableRow> buildMainActionTable() { static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
using reasampler::ActionTableRow; using reasampler::ActionTableRow;
std::vector<ActionTableRow> rows; std::vector<ActionTableRow> rows;
@@ -135,40 +105,32 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase, rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
&RunCaptureScopeRow, static_cast<int>(i)}); &RunCaptureScopeRow, static_cast<int>(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}); 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", rows.push_back({"CAPTURE_ITEM_ASSIGN",
"capture selected item into bank + assign to active instance", "capture selected item into bank + assign to active instance",
&RunCaptureItemAssign}); &RunCaptureItemAssign});
// M6: place the panel's selected sample at the edit cursor. Two variants that // Two variants differing ONLY in InsertOptions — native length vs conform opt-in.
// differ ONLY in InsertOptions — native length vs the explicit conform opt-in.
rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor", rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor",
&RunInsertSelected, 0}); &RunInsertSelected, 0});
rows.push_back({"INSERT_SELECTED_CONFORM", rows.push_back({"INSERT_SELECTED_CONFORM",
"insert selected sample at edit cursor (conform to tempo)", "insert selected sample at edit cursor (conform to tempo)",
&RunInsertSelected, 1}); &RunInsertSelected, 1});
// M11: one action fires N captures (per selected item / per razor area); the // One action fires N captures (per selected item / per razor area); the original
// original selection is restored on every exit path. Bank-only, never places. // selection is restored on every exit path. Bank-only, never places.
rows.push_back({"CAPTURE_BATCH_ITEMS", rows.push_back({"CAPTURE_BATCH_ITEMS",
"batch capture selected items (one per item)", "batch capture selected items (one per item)",
&RunBatchCaptureItems}); &RunBatchCaptureItems});
rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)", rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)",
&RunBatchCaptureRazor}); &RunBatchCaptureRazor});
// M8: realtime sibling of the offline CAPTURE_TRACK scope — records the selected // Realtime sibling of the offline CAPTURE_TRACK scope, plus its cancel-in-flight
// track's own output into a hidden temp track, dialog-free — plus its // companion (stop + restore, non-destructive).
// cancel-in-flight companion (stop + restore, non-destructive).
rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)", rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)",
&RunCaptureRealtime}); &RunCaptureRealtime});
rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture", rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture",
&RunCancelRealtime}); &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", rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
&RunRecaptureFromSource}); &RunRecaptureFromSource});
// Phase V: on-demand version readout for bug reports.
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion}); rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
return rows; return rows;
@@ -180,74 +142,52 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
static void OnTimer() static void OnTimer()
{ {
// Advance any in-flight realtime capture FIRST, so a project switch is caught and // 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. // torn down/restored before session.poll() reacts to that switch. LOAD-BEARING:
// LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER // the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
// TEST — the cross-TU drive call is made only when a capture is in flight.
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
g_session.poll(); g_session.poll();
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view // persist stays MODEL-ONLY (loads the saved view model but does not apply
// model but deliberately does NOT apply visibility — that would couple persist // visibility, to avoid coupling persist to the view shell); poll() raises a
// to the view shell). Instead poll() raises a one-shot load signal; here — the // one-shot load signal that we drain here to reapply the SAVED active mode so a
// integration layer that already drives both persist and the view shell — we // project saved in Design mode parks Arrange tracks automatically. The same
// drain it and reapply the SAVED active mode's visibility/processing so opening a // signal re-arms the bank panel's new-content detector — notified BEFORE the
// project saved in Design mode parks the Arrange tracks automatically, no manual // reapply so re-arm and model restore ride the one load event (otherwise
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks // pre-existing tracks can be mis-detected as "new" and mass-tagged).
// 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.
if (g_session.consumeLoadSignal()) { if (g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded(); reasampler::bankPanelNotifyProjectLoaded();
// Reconcile the restored lane-ownership index against the live project's lanes // Reconcile lane ownership against the live project's lanes (P_LANENAME,
// FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth), // the cross-session source of truth) BEFORE reapplying visibility. Never
// so a saved lane-split project's managed/manual classification is correct // re-mints, never mass-tags.
// before the active mode's lane visibility is reapplied. Never re-mints, never
// mass-tags — it only records managed ownership recovered from lane names.
reasampler::reconcileManagedLanes(g_session.view(), nullptr); reasampler::reconcileManagedLanes(g_session.view(), nullptr);
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
} }
// Reflect a live bank change (capture / project load) in the docked grid. reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
// an actual change. No-op when the panel is closed.
reasampler::bankPanelRefresh();
} }
// --- 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
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on // but keeps the SAME project identity, so the timer's identity poll never re-reads
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's // ext state on undo/redo — the in-memory book/view would stay stale until
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view // close+reopen. REAPER's projectconfig fires BeginLoadProjectState on every
// would stay stale until close+reopen. REAPER's projectconfig extension fires // project-state (re)load INCLUDING undo/redo (isUndo == true for both); we hook it.
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
// (isUndo == true for both). We hook it to drive a session reload.
// //
// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as // TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would // GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request // reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which // finished restoring the <EXTSTATE> block. A normal project open also fires this
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns // (isUndo=false); ignored here so a normal open flows solely through the timer's
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling. // identity-transition Load path (no double load).
//
// 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.
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
{ {
if (isUndo) if (isUndo)
g_session.requestReload(); g_session.requestReload();
} }
// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores // Intentional no-ops: ReaSampler stores state via project EXT STATE, not this
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"), // extension's own project lines. The struct is registered ONLY for the
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own // BeginLoadProjectState undo/redo notification.
// 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.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/, static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
bool /*isUndo*/, project_config_extension_t* /*reg*/) 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*/, static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
project_config_extension_t* /*reg*/) 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. // 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 nullptr, // userData
}; };
// REAPER calls this for EVERY action fired anywhere; claim only our own id, // REAPER calls this for EVERY action fired anywhere; claim only our own id, return
// return false otherwise so REAPER keeps looking. This TU's own family dispatches // false otherwise so REAPER keeps looking. This TU's own family dispatches through
// through the registration table; the Q-W4 families claim their own ids after it. // the registration table; the other families claim their own ids after it.
static bool OnHookCommand(int command, int /*flag*/) static bool OnHookCommand(int command, int /*flag*/)
{ {
if (command == 0) return false; if (command == 0) return false;
if (reasampler::actionTableHandleCommand(command)) return true; 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; if (reasampler::designViewHandleCommand(command)) return true;
// Multi-bank action family (B3). Same contract: claims only its own ids.
if (reasampler::bankHandleCommand(command)) return true; if (reasampler::bankHandleCommand(command)) return true;
// S8 ingest action family (Media-Explorer import). Same contract.
if (reasampler::ingestHandleCommand(command)) return true; if (reasampler::ingestHandleCommand(command)) return true;
return false; return false;
} }
@@ -299,39 +234,30 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
{ {
if (!rec) if (!rec)
{ {
// rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every // rec == nullptr => REAPER is UNLOADING us.
// callback with the same strings prefixed '-' (per the contract).
if (g_rec) if (g_rec)
{ {
// Abort any in-flight realtime capture FIRST, while the API pointers are // 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, // still live, so we never leave a temp track, an armed track, or an
// an armed track, or an altered transport/cursor in the user's project on // altered transport/cursor in the user's project on unload.
// unload. Commit whatever was captured (best effort) before tearing down.
capture::AbortRealtimeCaptureForUnload(g_session); capture::AbortRealtimeCaptureForUnload(g_session);
g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-projectconfig", (void*)&g_projectConfig); g_rec->Register("-projectconfig", (void*)&g_projectConfig);
g_rec->Register("-toggleaction", (void*)&OnToggleAction); g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand); 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); reasampler::designViewUnregisterActions(g_rec);
// Tear down the multi-bank action family (B3) — same mirror-unregister.
reasampler::bankUnregisterActions(g_rec); reasampler::bankUnregisterActions(g_rec);
// Tear down the S8 ingest action family — same mirror-unregister.
reasampler::ingestUnregisterActions(g_rec); reasampler::ingestUnregisterActions(g_rec);
// Tear down this TU's own family from the registration table (reverse // This TU's own family, reverse table order; each '-command_id'
// table order; each '-command_id' re-presents the SAME interned, // re-presents the SAME interned pointer used at register.
// channel-qualified pointer used at register).
reasampler::unregisterActionTable(g_rec); reasampler::unregisterActionTable(g_rec);
// Retire the REMOVED command ids (command_id only — we never held a gaccel // Retire the REMOVED command ids (command_id only — we never held a gaccel
// for them this session). Clears stale user keybindings on unload. Composed // for them this session).
// per channel so a beta clears beta-qualified retired ids, stable its own.
for (const char* suffix : kRetiredCaptureCmdSuffixes) for (const char* suffix : kRetiredCaptureCmdSuffixes)
g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix)); g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix));
} }
// Destroy the docked window and release cached thumbnails before we drop // Before dropping the API pointers: DockWindowRemove/DestroyWindow need them live.
// the API pointers (DockWindowRemove/DestroyWindow need them live).
reasampler::bankPanelShutdown(); reasampler::bankPanelShutdown();
g_rec = nullptr; g_rec = nullptr;
return 0; return 0;
@@ -349,13 +275,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
g_hInst = hInstance; g_hInst = hInstance;
g_rec = rec; g_rec = rec;
// Point the bank panel at the live session BEFORE registering its action, so // Point the bank panel at the live session BEFORE registering its action, so a
// a toggle firing immediately has a session to read (M5). Does not open the // toggle firing immediately has a session to read. Does not open the window.
// window — only stores the session pointer.
reasampler::bankPanelInit(&g_session); 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<reasampler::ActionTableRow> rows = buildMainActionTable(); const std::vector<reasampler::ActionTableRow> rows = buildMainActionTable();
reasampler::registerActionTable(rec, rows.data(), rows.size()); reasampler::registerActionTable(rec, rows.data(), rows.size());
@@ -367,37 +290,23 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
if (g_cmdToggleBankPanel) if (g_cmdToggleBankPanel)
rec->Register("toggleaction", (void*)&OnToggleAction); rec->Register("toggleaction", (void*)&OnToggleAction);
// Register the Design View action family (D4): toggle/activate mode, tag/untag/ // Each family mints its own command_id + gaccel, shares g_session, and is routed
// show-both selected tracks. Each mints its own command_id + gaccel; the single // by the same hookcommand below. Registered before the hook so every id is
// hookcommand below routes them via designViewHandleCommand. Registered before // minted first.
// the hook so every id is minted first.
reasampler::designViewRegisterActions(rec, &g_session); 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); 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); 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); rec->Register("hookcommand", (void*)&OnHookCommand);
// Drive project-load / Save-As detection (M4 persist). The timer polls the // Drives project-load / Save-As detection: the timer polls the active project
// active project each tick; on a project load it reloads the bank from ext // each tick; on a project load it reloads the bank from ext state, on a Save-As
// state, on a Save-As it relocates the bank folder under the new .rpp. // it relocates the bank folder under the new .rpp.
rec->Register("timer", (void*)&OnTimer); rec->Register("timer", (void*)&OnTimer);
// Register the projectconfig hook so an UNDO/REDO state restore reloads the // An UNDO/REDO state restore reloads the session's book + view from the restored
// session's book + view from the restored ext state (R-B). The timer's identity // ext state. The timer's identity poll cannot see an undo (same project
// poll cannot see an undo (same project identity), so this hook owns undo/redo; it // identity), so this hook owns it (see OnBeginLoadProjectState).
// requests a deferred reload that the next timer tick drains (see the hook comment).
rec->Register("projectconfig", (void*)&g_projectConfig); rec->Register("projectconfig", (void*)&g_projectConfig);
return 1; // success — REAPER keeps us loaded return 1; // success — REAPER keeps us loaded
+13 -28
View File
@@ -5,14 +5,11 @@
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
// peaks implementation. // peaks — pure implementation. See peaks.h.
// //
// One linear pass per channel. The frame->bin partition is computed with integer // One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for
// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the // any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount)
// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That // — earlier bins absorb the remainder, no rounding drift, no dropped tail.
// 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.
namespace reasampler::audio { namespace reasampler::audio {
@@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t binCount) { std::size_t binCount) {
Envelope envelope(channelCount); Envelope envelope(channelCount);
if (channelCount == 0) { if (channelCount == 0) {
return envelope; // no channels -> no envelopes return envelope;
} }
// Never read past what the buffer actually holds, even if the caller's // Never read past what the buffer actually holds, even if frameCount overstates it.
// frameCount overstates the buffer (defensive: no OOB on a short buffer).
const std::size_t availableFrames = interleaved.size() / channelCount; const std::size_t availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames); const std::size_t frames = std::min(frameCount, availableFrames);
@@ -35,14 +31,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0} bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
for (std::size_t b = 0; b < binCount; ++b) { for (std::size_t b = 0; b < binCount; ++b) {
// Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount). // Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and
// Guard against size_t overflow in b*frames and (b+1)*frames: binCount is // unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB.
// 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.
if (frames > 0 && b >= SIZE_MAX / frames) { 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 begin = (b * frames) / binCount;
const std::size_t end = ((b + 1) * 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<int>(bins.size()); const int nbins = static_cast<int>(bins.size());
if (columnCount <= 0 || nbins == 0) return MinMax{}; if (columnCount <= 0 || nbins == 0) return MinMax{};
// Clamp col to [0, columnCount-1].
if (col < 0) col = 0; if (col < 0) col = 0;
if (col >= columnCount) col = columnCount - 1; if (col >= columnCount) col = columnCount - 1;
// Half-open bin range for this column, mirroring computeEnvelope's exact partition. // Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope // products: col*nbins can exceed int range for a large oversampled envelope.
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount; const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
const std::int64_t end64 = const std::int64_t end64 =
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount; (static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
// col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins.
const int colBinBegin = static_cast<int>(begin64); const int colBinBegin = static_cast<int>(begin64);
// When the column spans no full bin (more columns than bins), use the enclosing bin // When the column spans no full bin (more columns than bins), use the enclosing bin.
// so no column is left empty.
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1; const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins; const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
@@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
AudioSample linearThreshold) { AudioSample linearThreshold) {
if (channelCount == 0) return kNoFrameAboveThreshold; 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 availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames); const std::size_t frames = std::min(frameCount, availableFrames);
if (frames == 0) return kNoFrameAboveThreshold; if (frames == 0) return kNoFrameAboveThreshold;
// Scan backward: the first frame (from the end) whose loudest channel exceeds the // Scan backward; `f` runs frames..1 so `f-1` never wraps.
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
for (std::size_t f = frames; f > 0; --f) { for (std::size_t f = frames; f > 0; --f) {
const std::size_t frame = f - 1; const std::size_t frame = f - 1;
const std::size_t base = frame * channelCount; const std::size_t base = frame * channelCount;
+42 -76
View File
@@ -1,32 +1,20 @@
#pragma once #pragma once
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved // peaks — waveform min/max envelope (thumbnail) computation from raw interleaved PCM. We compute
// PCM. We compute our own thumbnails from the captured file rather than depending // our own thumbnails rather than depending on REAPER's peak API: we own the file format, so this
// on REAPER's peak API: we own the file format, so this is simpler, testable, and // is simpler, testable, and dependency-free.
// 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.
#include <cstddef> #include <cstddef>
#include <vector> #include <vector>
namespace reasampler::audio { namespace reasampler::audio {
// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer // REAPER's native audio buffer format (interleaved 32-bit float), consumed directly with no
// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so // lossy conversion. Named AudioSample rather than Sample to avoid colliding with bank_model's
// peaks consumes that directly with no lossy conversion. If a capture ever lands // metadata struct of the same short name.
// 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.
using AudioSample = float; using AudioSample = float;
// One bin of a channel's envelope: the extremes of every sample that fell in it. // One bin's extremes across the samples that fell in it. min <= max always; an empty bin
// min <= max always. For an empty bin (more bins than frames), both are 0. // (more bins than frames) is {0, 0}.
struct MinMax { struct MinMax {
AudioSample min = 0.0f; AudioSample min = 0.0f;
AudioSample max = 0.0f; AudioSample max = 0.0f;
@@ -37,83 +25,61 @@ struct MinMax {
// One channel's envelope: exactly `binCount` bins, in time order. // One channel's envelope: exactly `binCount` bins, in time order.
using ChannelEnvelope = std::vector<MinMax>; using ChannelEnvelope = std::vector<MinMax>;
// Per-channel envelopes: outer index is channel (channelCount entries, order // Per-channel envelopes: outer index is channel (channelCount entries, order preserved — never
// preserved — never mixed or folded), inner is that channel's bins. // mixed or folded), inner is that channel's bins.
using Envelope = std::vector<ChannelEnvelope>; using Envelope = std::vector<ChannelEnvelope>;
// Computes a per-channel min/max envelope from interleaved PCM. // Computes a per-channel min/max envelope from interleaved PCM.
// //
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. // interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. Size must be
// Size must be >= frameCount * channelCount; extra is ignored. // >= frameCount * channelCount; extra is ignored.
// channelCount channels per frame (the stride). Each channel is enveloped // channelCount channels per frame (the stride). Each channel is enveloped INDEPENDENTLY — no
// INDEPENDENTLY — no averaging, no stereo fold (precision // averaging, no stereo fold (channel count is preserved end to end).
// invariant: channel count preserved).
// frameCount frames (samples-per-channel) to consider. // frameCount frames (samples-per-channel) to consider.
// binCount requested bins per channel. Honored exactly for any frameCount. // binCount requested bins per channel. Honored exactly for any frameCount.
// //
// Frame->bin partition: frames are split into `binCount` contiguous spans as // Frame->bin partition: frames split into `binCount` contiguous spans as evenly as possible;
// evenly as possible; when frameCount does not divide evenly, the remainder is // when frameCount doesn't divide evenly, the remainder spreads one-frame-per-bin across the
// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the // earliest bins, so the tail is never dropped and no bin reads out of bounds.
// tail is never dropped and no bin reads out of bounds. When binCount > frameCount
// the trailing empty bins are {0, 0}.
// //
// Defined behavior for degenerate input (no UB, no throw): // Degenerate input (no UB, no throw): binCount == 0 -> empty bin vector per channel;
// binCount == 0 -> per channel: an empty bin vector. // channelCount == 0 -> empty envelope; frameCount == 0 -> binCount bins, all {0, 0}.
// channelCount == 0 -> an empty envelope (no channels).
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved, Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t channelCount, std::size_t channelCount,
std::size_t frameCount, std::size_t frameCount,
std::size_t binCount); std::size_t binCount);
// The merged min/max for display column `col` (0-based, of `columnCount` total columns) // Merged min/max for display column `col` (0-based, of `columnCount` total) of a pre-computed
// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects // ChannelEnvelope the true extremes of every bin projecting to that column. This is the
// to that column. This is the display-side collapse of an envelope computed at HIGHER // display-side collapse when the envelope was computed at a higher resolution than the drawn
// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a // width, so a steep transient split across adjacent bins (e.g. {0.9,1.0} then {-1.0,-0.9})
// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then // renders as one gap-free span instead of two separated dots.
// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots.
// //
// Bin->column mapping mirrors computeEnvelope's half-open partition: // Bin->column mapping mirrors computeEnvelope's half-open partition: column col owns bins
// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount). // [col*nbins/columnCount, (col+1)*nbins/columnCount). When that range is empty (more columns
// When that range is empty (more columns than bins), the enclosing bin // than bins), the enclosing bin fills the column instead. columnCount <= 0 or bins.empty()
// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is // returns {0, 0}; col is clamped to [0, columnCount-1].
// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to
// [0, columnCount-1]. Pure.
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col); MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range // Sentinel for "no frame in the scanned range peaked above threshold". SIZE_MAX is unambiguous
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous: // since no real frame index can reach it.
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
inline constexpr std::size_t kNoFrameAboveThreshold = inline constexpr std::size_t kNoFrameAboveThreshold =
static_cast<std::size_t>(-1); static_cast<std::size_t>(-1);
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max // Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (max |sample| across
// absolute value across all channels of that frame — NO stereo fold, just the // all channels of that frame — no stereo fold) exceeds `linearThreshold`. Returns
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index. // kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
// //
// This is the boundary primitive behind the realtime tail's decay-scan trim // This is the boundary primitive behind the realtime tail's decay-scan trim (see
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is // docs/product/capture-tail.md): the recorded tail is scanned back from the end for the last
// scanned back from the end for the last frame still above -72 dB, and the file is // frame still above -72 dB, and the file truncated one frame past it. Deliberately separate from
// truncated one frame past it. Deliberately a separate primitive from // computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), this answers
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), // "the last frame above a level" (a boundary); bending a bin-oriented envelope to a frame-exact
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented // question is a worse fit.
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
// //
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. // linearThreshold a LINEAR amplitude ratio (e.g. the -72 dB ratio from
// Must hold >= frameCount * channelCount; extra is ignored, and a // render_settings::autoTrimEndRatio), NOT dB. A frame counts as above when
// short buffer is clamped to what it actually holds (no OOB read). // its peak is STRICTLY greater than this.
// channelCount channels per frame (the stride). The per-frame test is the max
// |sample| over these channels — the frame is "above" if its
// loudest channel is above the threshold.
// frameCount frames to consider (the scan starts at the last of these).
// linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the
// -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB.
// A frame counts as above when its peak is STRICTLY > this.
//
// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above,
// and degenerate inputs) so the trim boundary math is locked outside the DAW.
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved, std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
std::size_t channelCount, std::size_t channelCount,
std::size_t frameCount, std::size_t frameCount,
+3 -5
View File
@@ -1,5 +1,5 @@
// batch_capture.cpp — pure logic for M11 batch capture. See header. // batch_capture.cpp — pure logic for batch capture. See header.
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp. // Unit-tested by tests/test_batch_capture.cpp.
#include "core/capture/batch_capture.h" #include "core/capture/batch_capture.h"
@@ -12,9 +12,7 @@ std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges)
units.reserve(ranges.size()); units.reserve(ranges.size());
int ordinal = 0; int ordinal = 0;
for (const BatchRange& r : ranges) { for (const BatchRange& r : ranges) {
// Drop empty/inverted ranges — the offline backend refuses end<=start too, so // Drop empty/inverted ranges — the offline backend refuses end<=start too.
// planning one would only manufacture a guaranteed per-unit failure. Ordinals
// count kept units so the reported numbering is contiguous.
if (!(r.endSeconds > r.startSeconds)) continue; if (!(r.endSeconds > r.startSeconds)) continue;
++ordinal; ++ordinal;
units.push_back({ordinal, r.startSeconds, r.endSeconds}); units.push_back({ordinal, r.startSeconds, r.endSeconds});
+30 -38
View File
@@ -1,29 +1,24 @@
#pragma once #pragma once
// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires // batch_capture — the REAPER-free logic behind batch capture (one action fires N
// N captures: one bank sample per selected item / per razor area). // captures: one bank sample per selected item / per razor area).
// //
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW // only. The batch shell reads the DAW state (selected items -> exact bounds;
// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas) // each track's P_RAZOREDITS -> areas) and hands the raw ranges here:
// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are
// unit-tested outside the DAW:
// //
// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered // 1. planCaptureUnits: an ordered list of (start,end) ranges -> an ordered
// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds. // list of CaptureUnit, each with a 1-based ordinal and validated bounds.
// Empty/inverted ranges are DROPPED (mirrors the offline backend's own // 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. // end>start guard); ordinals count only the kept units, so three valid
// Order is preserved: unit ordinals count only the KEPT units, so a batch of // items yield 1,2,3 regardless of dropped neighbors.
// three valid items yields ordinals 1,2,3 regardless of dropped neighbors. // 2. BatchOutcome: order-preserving aggregation of per-unit results into a
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary // summary (succeeded/failed counts + ordered failures) for one console
// (succeeded / failed counts + the ordered list of failures) so the shell can // line with no partial-corruption ambiguity.
// 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.
// //
// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a // Range is the only thing that varies per unit here. FX scope (item vs track) is
// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a // a per-action constant the shell already owns; item-batch uses item scope,
// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the // razor-batch uses track scope, passed through unchanged from the single-capture
// shell passes the scope straight through to each render, unchanged from the // path.
// single-capture path.
#include <cstddef> #include <cstddef>
#include <string> #include <string>
@@ -31,10 +26,10 @@
namespace reasampler::capture { namespace reasampler::capture {
// One capture in a batch: an exact source range plus its 1-based ordinal within the // One capture in a batch: an exact source range plus its 1-based ordinal within
// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's // the kept set. The ordinal disambiguates per-unit file stems (the offline
// unique tag is 1-second-granular, so a fast batch could otherwise collide N files // backend's unique tag is 1-second-granular, so a fast batch could otherwise
// onto one name) and labels a failure in the summary. // collide N files onto one name) and labels a failure in the summary.
struct CaptureUnit { struct CaptureUnit {
int ordinal = 0; // 1-based, counts kept units only int ordinal = 0; // 1-based, counts kept units only
double startSeconds = 0.0; // exact — no rounding 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 // 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 // razor area's [start, end]). Named BatchRange (not SourceRange) to avoid
// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share // collision with bank_model's SourceRange, which carries PPQ fields this planner
// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with // doesn't need.
// bank_model's SourceRange, which carries PPQ fields this planner does not need.
struct BatchRange { struct BatchRange {
double startSeconds = 0.0; double startSeconds = 0.0;
double endSeconds = 0.0; double endSeconds = 0.0;
}; };
// Validates + orders a batch's source ranges into capture units. Preserves input // 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 // order; drops every range with end <= start; assigns 1-based ordinals over the
// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected // kept units. An empty input yields an empty plan — the shell reports "nothing
// item / no razor area) yields an empty plan — the shell reports "nothing to batch" // to batch" and writes nothing.
// and writes nothing (the same no-op posture the single-capture path takes).
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges); std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
// The per-unit verdict the shell records after each render attempt, in unit order. // 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 std::string detail; // failure reason (empty on success) — for the summary
}; };
// Order-preserving aggregation of a batch's per-unit results. Built incrementally by // Order-preserving aggregation of a batch's per-unit results. Built incrementally
// the shell (record() after each unit) so a mid-batch failure is captured without // by the shell (record() after each unit) so a mid-batch failure doesn't abort
// aborting the remaining units (no partial corruption: each unit is independent, and // the remaining units each unit is independent.
// the selection is restored on every exit path by the shell's RAII guard).
class BatchOutcome { class BatchOutcome {
public: public:
// Records one unit's verdict. Order of calls IS the reported order. // Records one unit's verdict. Order of calls IS the reported order.
+11 -60
View File
@@ -6,25 +6,16 @@
namespace reasampler::capture { 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 normalizeSlashes(const std::string& path) {
std::string out = path; std::string out = path;
for (char& c : out) { for (char& c : out) {
if (c == '\\') c = '/'; if (c == '\\') c = '/';
} }
// Strip a single trailing slash so joins do not double up. Preserve a lone // Strip a trailing slash but preserve a lone "/" (root).
// "/" (root) — stripping it would turn root into empty.
if (out.size() > 1 && out.back() == '/') { if (out.size() > 1 && out.back() == '/') {
out.pop_back(); out.pop_back();
} }
#ifdef _WIN32 #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<char>(std::tolower(static_cast<unsigned char>(c))); for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
#endif #endif
return out; return out;
@@ -39,8 +30,7 @@ std::string sanitizeStem(const std::string& baseName) {
c == '-'; c == '-';
out.push_back(keep ? static_cast<char>(c) : '_'); out.push_back(keep ? static_cast<char>(c) : '_');
} }
// Collapse to a stable default if nothing usable survived (e.g. all spaces). // Collapse to a stable default if nothing alnum survived.
// A stem of only separators ('.', '_', '-') is also unhelpful as a name.
bool hasAlnum = false; bool hasAlnum = false;
for (unsigned char c : out) { for (unsigned char c : out) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || 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"; const std::string fileName = stem + ".wav";
// Precondition: the capture shell must resolve a non-empty project directory // Precondition: caller must resolve a non-empty project directory — an
// before calling this function. An empty projectDir would produce a bare // empty one would otherwise fall back to a bare relative path (forbidden).
// relative "reasampler_bank" path — the silent default-location fallback this // Assert in debug; leave absoluteDir empty in release so a caller that
// tool explicitly forbids. Assert in debug; leave absoluteDir empty in release // ignores it fails at the render/stat step, not silently onto CWD.
// so any caller that ignores the precondition fails loudly at the render/stat
// step rather than silently writing to CWD.
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty"); assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
BankPaths p; BankPaths p;
p.fileStem = stem; // stem only — REAPER appends extension p.fileStem = stem; // stem only — REAPER appends extension
p.fileName = fileName; p.fileName = fileName;
p.relativePath = std::string(kBankSubfolder) + "/" + 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{} p.absoluteDir = dir.empty() ? std::string{}
: dir + "/" + kBankSubfolder; : dir + "/" + kBankSubfolder;
return p; return p;
@@ -88,15 +73,12 @@ BankPaths deriveBankPaths(const std::string& projectDir,
std::string bankRelativeForName(const std::string& fileName) { std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {}; if (fileName.empty()) return {};
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so // Same expression deriveBankPaths uses, so the two spellings can't drift.
// the two spellings can never drift (Phase R spelling-consistency invariant).
return std::string(kBankSubfolder) + "/" + fileName; return std::string(kBankSubfolder) + "/" + fileName;
} }
std::string resolveBankFile(const std::string& projectDir, std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath) { 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()) { if (projectDir.empty() || relativePath.empty()) {
return {}; return {};
} }
@@ -109,10 +91,7 @@ std::string resolveBankFile(const std::string& projectDir,
} }
std::string projectDirOfRpp(const std::string& rppPath) { std::string projectDirOfRpp(const std::string& rppPath) {
// An unsaved project reports an empty .rpp path; keep it empty so downstream if (rppPath.empty()) return {}; // unsaved project: keep empty, no fallback
// 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 {};
std::string dir = std::filesystem::path(rppPath).parent_path().string(); std::string dir = std::filesystem::path(rppPath).parent_path().string();
return normalizeSlashes(dir); return normalizeSlashes(dir);
} }
@@ -128,9 +107,7 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
r.oldBankDir = oldDir + "/" + kBankSubfolder; r.oldBankDir = oldDir + "/" + kBankSubfolder;
r.newBankDir = newDir + "/" + kBankSubfolder; r.newBankDir = newDir + "/" + kBankSubfolder;
// A Save (in place) leaves the project dir unchanged — nothing to relocate. r.needed = (oldDir != newDir); // Save-in-place leaves the dir unchanged
// Only a Save-As to a different directory needs the bank moved.
r.needed = (oldDir != newDir);
return r; return r;
} }
@@ -139,42 +116,16 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastPath, const std::string& lastPath,
const std::string& currentGuid, const std::string& currentGuid,
const std::string& currentPath) { const std::string& currentPath) {
// 1. The GUID is the identity of record and is checked FIRST. A different // See capture_paths.h for the GUID-primary rationale and rule order.
// 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.
if (currentGuid != lastGuid) { if (currentGuid != lastGuid) {
return ProjectTransition::Load; 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) { 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) { if (currentPath != lastPath) {
return ProjectTransition::SaveAsRelocate; return ProjectTransition::SaveAsRelocate;
} }
// 4. Same object, same GUID, same path — Save in place / idle tick.
return ProjectTransition::NoOp; return ProjectTransition::NoOp;
} }
+51 -122
View File
@@ -1,16 +1,8 @@
#pragma once #pragma once
// capture_paths — the REAPER-free path arithmetic behind offline capture. // capture_paths — the REAPER-free path arithmetic behind offline capture. The
// // capture shell resolves the current project directory via REAPER APIs, then
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // hands the raw strings here. Forward-slash form throughout, no filesystem
// vendor/ includes. Standard library only. The capture shell resolves the // access; the bank subfolder name is a fixed constant.
// 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).
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
@@ -20,7 +12,7 @@
namespace reasampler::capture { namespace reasampler::capture {
// The project-relative bank subfolder. All captured wavs live here so the bank // 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"; inline constexpr const char* kBankSubfolder = "reasampler_bank";
// A resolved pair of paths for one capture: where REAPER must be told to write // A resolved pair of paths for one capture: where REAPER must be told to write
@@ -34,150 +26,87 @@ struct BankPaths {
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension) std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
}; };
// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent) // Normalizes a path to forward slashes and strips any trailing slash (does not
// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte // consult the filesystem). On Windows (_WIN32) also lowercases the result so
// format — so this module holds path arithmetic only, with no RIFF chunk knowledge. // paths differing only in casing compare equal; macOS/Linux preserve case.
// 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).
std::string normalizeSlashes(const std::string& path); std::string normalizeSlashes(const std::string& path);
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps // Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
// [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control) // [A-Za-z0-9._-], replaces every other byte with '_', and collapses to
// with '_', and collapses to "capture" if nothing usable remains. Deterministic: // "capture" if nothing usable remains. Deterministic.
// the same input always yields the same stem (feeds bit-identical file naming).
std::string sanitizeStem(const std::string& baseName); std::string sanitizeStem(const std::string& baseName);
// Derives the bank paths for one capture. // Derives the bank paths for one capture: baseName is the sanitized file-stem
// projectDir : absolute directory of the current .rpp (any slash style) // source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
// baseName : human base for the file stem (sanitized) // repeated captures don't collide. Produces "<stem>[_<tag>].wav".
// 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 "<stem>[_<tag>].wav". The relativePath is always project-relative and
// forward-slashed so it satisfies BankModel::add's relative-only invariant.
BankPaths deriveBankPaths(const std::string& projectDir, BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName, const std::string& baseName,
const std::string& uniqueTag); const std::string& uniqueTag);
// The project-relative index spelling for a bank file KNOWN ONLY by its file name — // The project-relative index spelling for a bank file known only by its file
// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder // name (bare entry, no directory) — the prune shell uses this to spell an
// entry the SAME way deriveBankPaths spelled it at capture time. By construction it // enumerated folder entry the SAME way deriveBankPaths spelled it at capture
// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder + // time; a divergence here could make a referenced file look like an orphan.
// "/" + 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.
std::string bankRelativeForName(const std::string& fileName); 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 // The index stores relative paths only; on project load the persist shell
// turn each entry's relativePath back into an absolute path against the CURRENT // turns each relativePath back into an absolute path against the current
// project directory (so a project opened from a new location still resolves its // project directory — the inverse of deriveBankPaths.
// bank). This is the inverse of the relativePath the capture path produced.
// // Returns "<projectDir>/<relativePath>" forward-slashed, or empty if either
// projectDir : absolute directory of the current .rpp (any slash style) // input is empty (no default-location fallback — an unsaved/unset project
// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav") // fails loudly rather than resolving against CWD).
//
// Returns "<projectDir>/<relativePath>" 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.
std::string resolveBankFile(const std::string& projectDir, std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath); const std::string& relativePath);
// The project directory that holds a .rpp: its parent directory, forward-slashed, // The project directory that holds a .rpp: parent directory, forward-slashed,
// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty // trailing slash stripped. Empty in -> empty out (an unsaved project reports
// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the // an empty .rpp path). Pure so the VST3 instrument resolves audio paths the
// no-default-location invariant). This is the M4 convention persist uses to place // same way persist does.
// 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.
std::string projectDirOfRpp(const std::string& rppPath); std::string projectDirOfRpp(const std::string& rppPath);
// A relocation plan for the physical bank folder on Save-As to a new project // 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 // 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 // project dir, which moved with the .rpp), so relocation is purely a folder
// folder move: copy/move the whole bank subfolder from the old project dir to // move. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
struct BankRelocation { struct BankRelocation {
std::string oldBankDir; // <oldProjectDir>/reasampler_bank std::string oldBankDir; // <oldProjectDir>/reasampler_bank
std::string newBankDir; // <newProjectDir>/reasampler_bank std::string newBankDir; // <newProjectDir>/reasampler_bank
bool needed = false; // false when old==new (Save in place, not Save-As) bool needed = false; // false when old==new (Save in place, not Save-As)
}; };
// Derives the relocation plan from the old and new project directories. // Derives the relocation plan: `needed` is true iff the normalized old/new
// oldProjectDir : project dir the bank currently sits under (any slash style) // project dirs differ (a genuine Save-As-to-new-dir); empty dirs/needed=false
// newProjectDir : project dir the .rpp was just saved to (any slash style) // when either input is empty.
// `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.
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir); 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, // What the persist timer must do on each tick. GUID is checked FIRST because
// layered GUID-PRIMARY: // two prior pointer-primary/GUID-only designs each broke a real case: a
// 1. the minted GUID — content-based identity of record, stored in ext state. // GUID-only check misreads a Save-As fork as the same project (fork and
// It is IMMUNE to REAPER recycling a closed project's ReaProject* address, // parent share a GUID on disk); a pointer-primary check misreads REAPER
// so it is checked FIRST. // recycling a closed project's ReaProject* address onto an unrelated project
// 2. sameProjectObject — did the same live ReaProject* stay active across the // (a different project, same recycled pointer, read as NoOp/SaveAsRelocate —
// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to // the bank never reloads). Checking GUID first catches recycling; the pointer
// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID // (sameProjectObject) then separates a forked sibling (Load) from a genuine
// onto a distinct object) vs a genuine Save-As (one object, new path). // Save-As (SaveAsRelocate).
//
// 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.
enum class ProjectTransition { enum class ProjectTransition {
NoOp, // same object, same GUID, same location — nothing to do NoOp, // same object, same GUID, same location — nothing to do
Load, // a different project is active — load ITS index from ext state Load, // a different project is active — load ITS index from ext state
SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank
}; };
// Classifies what a poll tick observed. // Classifies what a poll tick observed. sameProjectObject is passed as a bool
// sameProjectObject : true iff the SAME ReaProject* stayed active across the two // (not the raw pointer) to keep the classifier REAPER-free and testable;
// ticks (poll() computes `proj == lastProject_`). The pure // lastGuid/lastPath is the project persist last acted on, currentGuid/
// classifier takes the bool, not the raw pointer, to stay // currentPath the now-active project (both "" if unsaved/unwritten).
// REAPER-free and testable. // Evaluated in order: currentGuid!=lastGuid -> Load; !sameProjectObject ->
// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved) // Load (forked sibling); currentPath!=lastPath -> SaveAsRelocate (also covers
// lastPath : that project's .rpp path when last seen ("" if unsaved) // first save of an unsaved project); else NoOp.
// 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.
ProjectTransition classifyProjectTransition(bool sameProjectObject, ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastGuid, const std::string& lastGuid,
const std::string& lastPath, const std::string& lastPath,
+19 -44
View File
@@ -1,6 +1,5 @@
// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See // capture_realtime.cpp — pure logic for the realtime-record backend. See header.
// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp. // Unit-tested by tests/test_capture_realtime.cpp.
// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.)
#include "core/capture/capture_realtime.h" #include "core/capture/capture_realtime.h"
@@ -9,11 +8,8 @@ namespace reasampler::capture {
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) { RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
RecordModePlan p; RecordModePlan p;
// Stereo vs mono output recording, latency-compensated either way so the // REAPER's output-record modes are mono/stereo only; >2 channels still
// recorded file lines up with the source. A request asking for <= 1 channel // records stereo-out (a >2-channel realtime capture is out of scope).
// 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.)
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
: kRecModeStereoOutLatComp; : kRecModeStereoOutLatComp;
@@ -26,42 +22,32 @@ RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
} }
OutputTap outputTapForWetDry(double wetDry) { 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; return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
} }
Sample sampleFromRecordedCapture(const RecordedCapture& cap) { Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
Sample s; Sample s;
// Same id shape as the offline path: "cap-<tag>-<fileName>" would need the file // Relative path tail included so two same-tag captures (shouldn't happen) still differ.
// 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.
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath; s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
s.displayName = cap.displayName; s.displayName = cap.displayName;
s.relativePath = cap.relativePath; // project-relative (invariant) s.relativePath = cap.relativePath;
s.sourceMode = cap.sourceMode; s.sourceMode = cap.sourceMode;
s.sourceRange.startSeconds = cap.startSeconds; s.sourceRange.startSeconds = cap.startSeconds;
s.sourceRange.endSeconds = cap.endSeconds; 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.wetDry = cap.wetDry;
s.trackGuids = cap.trackGuids; s.trackGuids = cap.trackGuids;
s.channelCount = cap.channelCount; s.channelCount = cap.channelCount;
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
s.lengthSeconds = cap.endSeconds - cap.startSeconds; s.lengthSeconds = cap.endSeconds - cap.startSeconds;
s.captureTempo = cap.captureTempo; 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.captureTimeSigDenom = cap.captureTimeSigDenom;
s.tier = Tier::Scratch; // captures land in scratch by default s.tier = Tier::Scratch;
// contentHash set by the caller (capture_realtime.cpp) after the file is // contentHash is left empty: this mapping runs before the file exists on
// finalized and on disk the hash is over the finished file bytes. Left empty // disk; the shell patches the hash in after the move+trim.
// here because sampleFromRecordedCapture runs before the file exists (the // rootNote/loop left empty: a realtime record of wet output isn't a single
// mapping is pure / DAW-free); the shell patches it in after the move+trim. // played note, so no root note is derivable; loop points are a later action.
// 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.createdTimestamp = cap.createdTimestamp; s.createdTimestamp = cap.createdTimestamp;
return s; return s;
} }
@@ -72,20 +58,15 @@ RecordPhase advanceRecordPhase(RecordPhase current,
double rangeEndSeconds) { double rangeEndSeconds) {
switch (current) { switch (current) {
case RecordPhase::Recording: { case RecordPhase::Recording: {
// Transport stopped while we still expected to be recording -> the user // Stopped early (user or REAPER) -> finalize what was captured so far.
// (or REAPER) stopped early. Move to the flush wait and finalize whatever
// was captured up to the stop.
if (!inputs.transport.recording) return RecordPhase::Finalizing; if (!inputs.transport.recording) return RecordPhase::Finalizing;
// Reached the range end (latency-compensated play position). >= (not >) // >= (not >): a cursor landing exactly on the end completes.
// so a cursor landing exactly on the end completes.
if (inputs.transport.playPosition >= rangeEndSeconds) if (inputs.transport.playPosition >= rangeEndSeconds)
return RecordPhase::Finalizing; return RecordPhase::Finalizing;
// Self-defense (review §3): the transport is running but the play cursor // Self-defense: a stuck/looping transport that never reaches end would
// is not advancing to the end (stuck / looping). Without this the machine // otherwise stay in Recording forever, leaking the temp track + armed sink.
// stays in Recording forever, leaking the temp track + armed sink. Force
// the flush wait once wall-clock exceeds the nominal duration + margin.
const double ceiling = const double ceiling =
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds; (rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing; if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
@@ -94,22 +75,16 @@ RecordPhase advanceRecordPhase(RecordPhase current,
} }
case RecordPhase::Finalizing: { case RecordPhase::Finalizing: {
// The transport is stopped; wait for REAPER to flush/close the recorded // Moving the file before it's stable would race REAPER's flush and
// take on the audio thread. Finalize (move + Sample) only once the file // yield a truncated/missing capture.
// exists AND is stable (review §2) — moving it early races the flush and
// yields a truncated / missing capture.
if (inputs.fileReady) return RecordPhase::Done; 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) if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
return RecordPhase::Failed; return RecordPhase::Failed;
return RecordPhase::Finalizing; 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::Done:
case RecordPhase::Failed: case RecordPhase::Failed:
default: default:
+70 -150
View File
@@ -1,27 +1,11 @@
#pragma once #pragma once
// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8). // capture_realtime — the REAPER-free logic behind the realtime-record backend.
// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module // The shell drives the transport, temp track, send routing, and file move; the
// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp / // pure pieces split out here and unit-tested outside the DAW are: (1) record-
// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.) // mode bookkeeping — scope + FX-tap point -> I_RECMODE/I_RECMODE_FLAGS values
// // (bit MEANINGS transcribed verbatim from reaper_plugin_functions.h ~2197-2198;
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // the CHOICE of value per scope is this module's tested logic) — and (2) the
// vendor/ includes. Standard library only. The realtime shell drives the // recorded-file -> Sample mapping (mirrors OfflineRenderBackend's population).
// 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.
#include <cstdint> #include <cstdint>
#include <string> #include <string>
@@ -35,26 +19,17 @@ using model::Sample;
using model::Tier; using model::Tier;
using model::SourceMode; using model::SourceMode;
// --- I_RECMODE values (verbatim from SDK header ~2197) ----------------------- // 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
// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none, // comp, 7=midi overdub, 8=midi replace. We record a track's OUTPUT, latency-
// 3=stereo out w/latency compensation, 4=midi output, 5=mono out, // compensated, so the recorded file lines up sample-accurately with the source.
// 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.
inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp
inline constexpr int kRecModeMonoOutLatComp = 6; // mono 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 (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
// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode // pre-FX tap in the SDK — offline render has no pre-FX bit — so the realtime
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). // backend is the true pre-FX "dry" path.
//
// 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.
inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet) 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 kRecOutPreFx = 1; // &3==1: pre-FX (true dry)
inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader 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 // The concrete record-mode values a temp track must carry to capture the scoped
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags` // output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated);
// sets the &3 output-recording tap bits (higher bits are left at their default 0 // `recModeFlags` sets the &3 output-recording tap bits (we only own those bits).
// here — we only own the tap-point bits).
struct RecordModePlan { struct RecordModePlan {
int recMode = kRecModeStereoOutLatComp; int recMode = kRecModeStereoOutLatComp;
int recModeFlags = kRecOutPostFader; int recModeFlags = kRecOutPostFader;
}; };
// Maps (channelCount, tap) to the record-mode values. // Maps (channelCount, tap) to the record-mode values: channelCount <= 1 ->
// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp. // mono-out latency-comp, else stereo-out; tap -> the &3 bits. The shell applies
// tap -> the &3 output-recording bits. // these via SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
// 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).
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap); RecordModePlan recordModePlanFor(int channelCount, OutputTap tap);
// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any // Maps a wetDry value to the output tap point: 1.0 (fully wet) -> PostFader,
// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing // anything less -> PreFx (true dry — the realtime backend's distinguishing
// capability). Kept pure + separate from recordModePlanFor so the wet/dry -> // capability over offline render). PostFxPreFader is not reachable from wetDry.
// 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).
OutputTap outputTapForWetDry(double wetDry); OutputTap outputTapForWetDry(double wetDry);
// --- Recorded-file -> Sample mapping ---------------------------------------- // --- Recorded-file -> Sample mapping ----------------------------------------
//
// The inputs a finished realtime capture yields, gathered by the shell into a // 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 // pure struct so Sample population is a single tested transform (mirrors the
// the inline population in OfflineRenderBackend::capture). // inline population in OfflineRenderBackend::capture).
struct RecordedCapture { struct RecordedCapture {
// Project-relative path of the recorded file (relative-paths-only invariant; // Project-relative path of the recorded file (the shell resolves REAPER's
// the shell resolves REAPER's recorded absolute path back to project-relative). // absolute path back to project-relative).
std::string relativePath; std::string relativePath;
// The disambiguating tag that named the file (feeds the Sample id, so id and // The disambiguating tag that named the file (feeds the Sample id).
// file name stay consistent — same discipline as the offline path).
std::string uniqueTag; std::string uniqueTag;
// Echoed from the request (exact bounds — no re-measuring the file). // Echoed from the request (exact bounds — no re-measuring the file).
@@ -115,60 +83,41 @@ struct RecordedCapture {
int channelCount = 0; int channelCount = 0;
// TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer // Left at defaults here — capture_realtime_finalize.cpp calls
// populates these five fields before calling sampleFromRecordedCapture — the // stampCaptureSample(result.sample, ...) afterward, overwriting these five
// finalize path (capture_realtime_finalize.cpp) leaves them at their defaults // from the live project. Kept because the pure unit tests still assert them.
// 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.
int sampleRate = 0; // 0 when the project rate was unknown (as offline) int sampleRate = 0; // 0 when the project rate was unknown (as offline)
double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo) double captureTempo = 0.0; // BPM at capture time
// Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime). int captureTimeSigNum = 0; // 0/0 = unstamped
// 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out).
int captureTimeSigNum = 0;
int captureTimeSigDenom = 0; 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 // Builds the Sample for a finished realtime capture: exact request bounds,
// shape to OfflineRenderBackend's population: exact request bounds (no rounding), // scratch tier, empty content hash, lengthSeconds = end - start. PPQ/beats
// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start. // left 0 (deferred, as offline).
// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline).
Sample sampleFromRecordedCapture(const RecordedCapture& cap); 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 // A realtime record spans many timer ticks (CSurf_OnRecord starts the transport
// REAPER's audio thread and returns immediately — it does NOT block until the range // on REAPER's audio thread and returns immediately — it does not block until the
// completes). The completion decision — "given where the transport is now, should // range completes). The completion decision — keep waiting, stop-and-flush,
// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and // finalize, or give up — is pure and unit-tested without a DAW; the shell only
// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is // reads the transport/clock/file and applies the verdict.
// 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).
// //
// The lifecycle has TWO waits, not one: // Two waits, not one:
// 1. the RECORD wait (Recording): the transport is running; we wait for the play // 1. RECORD wait (Recording): transport running; wait for the play cursor to
// cursor to reach the range end OR the user stops early OR a wall-clock // reach the range end, OR the user stops early, OR a wall-clock safety
// safety ceiling trips (a started-but-never-advancing transport, §3 of review). // ceiling trips (a started-but-never-advancing transport).
// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes // 2. FLUSH wait (Finalizing): transport stopped but REAPER closes/flushes the
// the recorded take on the AUDIO thread — the file may not be fully written/closed // recorded take on the audio thread — the file may lag a tick or two.
// for a tick or two. We defer the file move until the file exists AND is stable // Defer the move until the file exists AND is stable, bounded by a flush
// (§2 of review), bounded by a flush ceiling so a file that never appears fails // ceiling so a file that never appears fails cleanly instead of hanging.
// cleanly rather than hanging.
// Where an in-progress capture is in its lifecycle. // Where an in-progress capture is in its lifecycle: Recording (live, transport
// Recording — live: transport running, shell keeps ticking. // running) and Finalizing (live-but-stopped, waiting for flush) are the two
// Finalizing — live-but-stopped: transport halted, shell stops the transport once // waits above; Done/Failed are terminal — the shell's verdict to act on.
// 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).
enum class RecordPhase { enum class RecordPhase {
Recording, Recording,
Finalizing, Finalizing,
@@ -176,63 +125,34 @@ enum class RecordPhase {
Failed Failed
}; };
// A distilled transport reading for the pure transition, so the state machine never // A distilled transport reading so the state machine never touches a REAPER
// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` // type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` is
// is GetPlayPositionEx (latency-compensated what-you-hear position). // GetPlayPositionEx (latency-compensated).
struct TransportReading { struct TransportReading {
bool recording = false; bool recording = false;
double playPosition = 0.0; double playPosition = 0.0;
}; };
// Everything the pure transition needs beyond the current phase, gathered by the // Everything the pure transition needs beyond the current phase, gathered by
// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling // the shell each tick (the shell only reads and reports; never decides).
// decision (the shell only reads and reports; it never decides a transition itself).
struct RecordTickInputs { struct RecordTickInputs {
TransportReading transport; TransportReading transport;
double elapsedSeconds = 0.0; // wall-clock since begin() — record ceiling
// Wall-clock seconds since begin() (the shell reads a steady clock). Drives the double finalizingSeconds = 0.0; // wall-clock in Finalizing — flush ceiling
// record safety ceiling: a transport that starts but never advances to the range bool fileReady = false; // recorded file exists+stable (Finalizing only)
// 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;
}; };
// --- Safety ceilings (named constants, review §2/§3) ------------------------- // Record ceiling margin added to nominal duration: generous enough that
// // pre-roll/count-in/latency never trips it, tight enough a stuck transport is
// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form // force-terminated within seconds.
// 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.
inline constexpr double kRecordMarginSeconds = 5.0; inline constexpr double kRecordMarginSeconds = 5.0;
// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the // Max wall-clock Finalizing waits for the file to flush/stabilize before
// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the // giving up (REAPER closes the take within a tick or two in practice).
// take on the audio thread within a tick or two in practice; this is a generous bound.
inline constexpr double kFinalizeFlushCeilingSeconds = 5.0; inline constexpr double kFinalizeFlushCeilingSeconds = 5.0;
// The pure transition: given the current phase, this tick's inputs, and the record // The pure transition (total + deterministic). Done/Failed are sticky — a late
// range end, return the next phase. Total + deterministic. // tick before teardown finishes cannot flip the verdict (the idempotence the
//
// 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
// shell's single-restore relies on). // shell's single-restore relies on).
RecordPhase advanceRecordPhase(RecordPhase current, RecordPhase advanceRecordPhase(RecordPhase current,
const RecordTickInputs& inputs, const RecordTickInputs& inputs,
+8 -11
View File
@@ -7,14 +7,14 @@ namespace reasampler::capture {
namespace { namespace {
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track). // 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 kBaseCurrentTrack = 0;
constexpr int kBaseNewTrack = 1; // add new track constexpr int kBaseNewTrack = 1;
// Tempo-conform bits, verbatim from the header doc-comment. // Tempo-conform bits, verbatim from the header doc-comment.
constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x constexpr int kMatchTempo1x = 8;
constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x constexpr int kMatchTempoHalf = 16;
constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x constexpr int kMatchTempoDbl = 32;
constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo constexpr int kDontPreservePitch = 64;
} // namespace } // namespace
@@ -24,8 +24,7 @@ int computeInsertMode(const InsertOptions& opts) {
switch (opts.conform) { switch (opts.conform) {
case TempoConform::None: case TempoConform::None:
// No tempo bits: native length, no stretch. (Also never &4.) return mode; // native length, no stretch; never &4
return mode;
case TempoConform::Ratio1x: case TempoConform::Ratio1x:
mode |= kMatchTempo1x; mode |= kMatchTempo1x;
break; break;
@@ -37,9 +36,7 @@ int computeInsertMode(const InsertOptions& opts) {
break; break;
} }
// Tempo bits are set (conform != None). Add the pitch-shift bit only when the // Reached only when a tempo bit is set (None already returned above).
// caller asked NOT to preserve pitch. When conform == None we already returned
// above, so this can never fire without a tempo bit present.
if (!opts.preservePitch) if (!opts.preservePitch)
mode |= kDontPreservePitch; mode |= kDontPreservePitch;
+15 -17
View File
@@ -1,34 +1,32 @@
#pragma once #pragma once
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing // insert_plan — the REAPER-free logic behind the `insert` shell: computing the
// the InsertMedia `mode` bitmask from a small options struct. // InsertMedia `mode` bitmask from a small options struct.
// //
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW // only. The InsertMedia bitfield is easy to get wrong and its bits are
// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to // load-bearing for the "no silent time-stretch" invariant, so it's factored here
// get wrong and its bits are load-bearing for the "no silent time-stretch" // and unit-tested. The REAPER-bound placement (InsertMedia call, edit-cursor
// invariant, so it is factored here and unit-tested. The REAPER-bound placement // movement, undo block) lives in insert.cpp and is DAW-verified.
// (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): // 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 // 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, // 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, // &16=try to match tempo 0.5x, &32=try to match tempo 2x,
// &64=don't preserve pitch when matching tempo, ... // &64=don't preserve pitch when matching tempo, ...
// We intentionally use only the base target (0/1) and the tempo-conform bits // We use only the base target (0/1) and the tempo-conform bits (&8/&16/&32/&64).
// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is // We NEVER set &4 (stretch/loop to fit time selection) — the silent-time-stretch
// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals). // path the tool forbids.
#include <cstdint> #include <cstdint>
namespace reasampler::capture { namespace reasampler::capture {
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3). // 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 // We expose only the two placement targets needed here; "add as takes" (3) is
// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's // out of scope. Both insert at the edit cursor — REAPER's convention for base
// convention for base modes 0/1 (the header names no explicit edit-cursor bit; // modes 0/1 (the header names no explicit edit-cursor bit; see the flagged
// see the flagged runtime assumption in insert.cpp). // runtime assumption in insert.cpp).
enum class InsertTarget { enum class InsertTarget {
NewTrack, // mode base 1: add a new track for the item NewTrack, // mode base 1: add a new track for the item
CurrentTrack, // mode base 0: add to the current/selected track CurrentTrack, // mode base 0: add to the current/selected track
+22 -59
View File
@@ -10,9 +10,7 @@
namespace reasampler::capture { namespace reasampler::capture {
double autoTrimEndRatio() { double autoTrimEndRatio() {
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is // Amplitude ratio = 10^(dB/20) (header ~3062). For -72 dB this is ~0.00025119.
// 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.
return std::pow(10.0, kAutoTrimThresholdDb / 20.0); return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
} }
@@ -20,8 +18,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
TailRenderSettings t; TailRenderSettings t;
switch (mode) { switch (mode) {
case TailMode::None: case TailMode::None:
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off, // Exact bounds — byte-identical to the pre-tail capture.
// disable-all normalize (the current default), no trim.
t.tailFlag = kTailFlagNone; t.tailFlag = kTailFlagNone;
t.tailMs = 0.0; t.tailMs = 0.0;
t.normalize = kNormalizeDisableAll; t.normalize = kNormalizeDisableAll;
@@ -29,12 +26,10 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
return t; return t;
case TailMode::Auto: case TailMode::Auto:
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence // Surgical normalize: only the trim-ending-silence bit set, every other
// bit (32768) — every other postprocessing bit clear. A fixed-threshold // postprocessing bit clear. A fixed-threshold trim scales/limits/fades
// trailing-silence trim is a pure boundary decision (it scales/limits/fades // nothing, so identical requests trim at the identical sample -> holds
// nothing), so it re-introduces none of the coloring the disable-all bit // the bit-identical-repeats invariant.
// guarded against, and two identical requests trim at the identical sample
// -> bit-identical repeats hold (spec §surgical normalize).
t.tailFlag = kTailFlagCustomBounds; t.tailFlag = kTailFlagCustomBounds;
t.tailMs = kMaxTailMs; t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd; t.normalize = kNormalizeTrimEnd;
@@ -42,10 +37,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
return t; return t;
case TailMode::Manual: case TailMode::Manual:
// Fixed tail, no trim -> keep the disable-all normalize exactly as the // Clamped to the cap regardless of source; negative floors to 0.
// 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.
t.tailFlag = kTailFlagCustomBounds; t.tailFlag = kTailFlagCustomBounds;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs); t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll; t.normalize = kNormalizeDisableAll;
@@ -60,14 +52,10 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
double manualTailMs) { double manualTailMs) {
switch (mode) { switch (mode) {
case TailMode::None: case TailMode::None:
// Exact no extra recording (byte-identical to today's realtime capture). return rangeEndSeconds; // exact, no extra recording
return rangeEndSeconds;
case TailMode::Auto: case TailMode::Auto:
// The 8 s runaway cap past the range end; the decay-trim shortens it later. return rangeEndSeconds + kMaxTailSeconds; // runaway cap; decay-trim shortens later
return rangeEndSeconds + kMaxTailSeconds;
case TailMode::Manual: 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; return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
} }
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail). // 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*/) { RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future // wetDry doesn't affect this mapping (seam for future dry work); FX scoping
// dry work (M10 null test), but it does not affect this mapping. FX scoping is // is handled by fxBypassPlanFor, not by these render bits.
// handled by fxBypassPlanFor, not by these render bits.
RenderSettingsChoice c; RenderSettingsChoice c;
switch (mode) { switch (mode) {
case SourceMode::MasterMix: case SourceMode::MasterMix:
case SourceMode::TimeSelection: case SourceMode::TimeSelection:
// Master IS the mix — wet-only; &(1|2)==0, no source bits. c.settings = kRenderMasterMix; // wet-only, no source bits
c.settings = kRenderMasterMix;
c.supported = true; c.supported = true;
return c; return c;
case SourceMode::SelectedTracks: case SourceMode::SelectedTracks:
// Selected tracks via master (&128) — wet (post-FX). Header ~3041.
c.settings = kRenderSelTracksViaMaster; c.settings = kRenderSelTracksViaMaster;
c.supported = true; c.supported = true;
return c; return c;
case SourceMode::SelectedItems: case SourceMode::SelectedItems:
// Selected media items, rendered to ONE file (single-file bit) so a // Single-file bit so a multi-item selection yields one bank entry.
// multi-item selection yields a single bank entry, not N wavs.
c.settings = kRenderSelItems | kRenderSingleFile; c.settings = kRenderSelItems | kRenderSingleFile;
c.supported = true; c.supported = true;
return c; return c;
case SourceMode::RazorArea: case SourceMode::RazorArea:
// Render razor edits to ONE file (same single-file rationale as items).
c.settings = kRenderRazorEdits | kRenderSingleFile; c.settings = kRenderRazorEdits | kRenderSingleFile;
c.supported = true; c.supported = true;
return c; return c;
case SourceMode::Realtime: case SourceMode::Realtime:
// Not an offline-render source — the realtime backend (M8) owns it.
c.settings = kRenderMasterMix; c.settings = kRenderMasterMix;
c.supported = false; c.supported = false; // not an offline-render source
return c; return c;
} }
// Unreachable for a valid enum; fail closed (unsupported) rather than render. // Unreachable for a valid enum; fail closed (unsupported) rather than render.
@@ -135,17 +117,13 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) {
FxBypassPlan p; FxBypassPlan p;
switch (scope) { switch (scope) {
case CaptureScope::Item: case CaptureScope::Item:
// Item = take/item FX ONLY. Bypass the item's own track FX, every // Take FX live in the item and are always rendered — bypass everything else.
// 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.)
p.bypassSelfFx = true; p.bypassSelfFx = true;
p.bypassAncestorFx = true; p.bypassAncestorFx = true;
p.bypassMaster = true; p.bypassMaster = true;
return p; return p;
case CaptureScope::Track: case CaptureScope::Track:
// Track = item FX + the selected track's OWN FX. Keep self FX; bypass // Keep self FX; bypass every ancestor (parent/folder) and the master.
// every ancestor (parent/folder) and the master. Parent/master GAIN
// still applies (I_FXEN is FX-only) — documented boundary.
p.bypassSelfFx = false; p.bypassSelfFx = false;
p.bypassAncestorFx = true; p.bypassAncestorFx = true;
p.bypassMaster = true; p.bypassMaster = true;
@@ -158,24 +136,19 @@ std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
std::vector<RazorRange> ranges; std::vector<RazorRange> ranges;
std::istringstream in(razorString); std::istringstream in(razorString);
// The string is space-separated TRIPLES: <start> <end> <envGuidString>.
// 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; std::string startTok, endTok, guidTok;
while (in >> startTok >> endTok >> guidTok) { while (in >> startTok >> endTok >> guidTok) {
// Envelope-lane areas carry a real GUID; skip them (razor captures track audio only). // Skip envelope-lane areas (real GUID); keep only track-audio (`""`).
// A track-audio area's GUID token is the empty quoted string `""`.
if (guidTok != "\"\"") continue; if (guidTok != "\"\"") continue;
// Parse the two time tokens. std::stod throws on garbage — guard so one // std::stod throws on garbage — guard so one malformed triple doesn't
// malformed triple does not abort the whole parse. // abort the whole parse.
double start = 0.0, end = 0.0; double start = 0.0, end = 0.0;
try { try {
std::size_t sp = 0, ep = 0; std::size_t sp = 0, ep = 0;
start = std::stod(startTok, &sp); start = std::stod(startTok, &sp);
end = std::stod(endTok, &ep); end = std::stod(endTok, &ep);
// Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse // Reject trailing garbage (e.g. "1.0x") — a partial parse is malformed.
// is a malformed area, not a valid range.
if (sp != startTok.size() || ep != endTok.size()) continue; if (sp != startTok.size() || ep != endTok.size()) continue;
} catch (...) { } catch (...) {
continue; continue;
@@ -197,23 +170,13 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
} }
const std::vector<CaptureActionDef>& captureActionTable() { const std::vector<CaptureActionDef>& captureActionTable() {
// Built once (function-local static): two SCOPE actions, item + track. Both // FOREVER-STABLE ids — never edit a shipped string. No master capture
// exact bounds by default; the tail mode a capture applies is read from the // action (its id was retired; do not reintroduce it).
// 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.
static const std::vector<CaptureActionDef> table = { static const std::vector<CaptureActionDef> 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_ITEM",
"capture selected item(s)", "item", "capture selected item(s)", "item",
CaptureScope::Item}, CaptureScope::Item},
// Track scope — item FX + the track's own FX.
{"CAPTURE_TRACK", {"CAPTURE_TRACK",
"capture selected track(s)", "track", "capture selected track(s)", "track",
CaptureScope::Track}, CaptureScope::Track},
+67 -148
View File
@@ -1,26 +1,9 @@
#pragma once #pragma once
// render_settings — the REAPER-free logic behind the capture action family. // render_settings — the REAPER-free logic behind the capture action family:
// // sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union,
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // razor-else-time inference, the FX-scope bypass plan, and the capture-action
// vendor/ includes. Standard library only. The capture shell (capture.cpp) and // table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from
// action layer (main.cpp) read the actual DAW state (time selection, selected // reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested.
// 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.
#include <string> #include <string>
#include <vector> #include <vector>
@@ -32,67 +15,44 @@ namespace reasampler::capture {
using model::SourceMode; using model::SourceMode;
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) -- // --- 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 kRenderMasterMix = 0; // (&(1|2))==0, no source bits
inline constexpr int kRenderSelItems = 32; // &32 selected media items inline constexpr int kRenderSelItems = 32; // &32 selected media items
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no // kRenderPreFaderStems (&8192) is deliberately NOT used REAPER offline render
// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-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 // mechanism (see fxBypassPlan below), not by any render bit. All capture actions
// fall outside a scope — NOT by any render bit. All capture actions render wet // render wet; the scope decides which FX remain enabled.
// (post the FX that remain enabled); the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ---------- // --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
// //
// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release // Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG,
// decay past the range end. Every offline capture renders custom-time-bounds, so // header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE
// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047). // (verbatim, header ~3051): &32768 = trim ending silence (Auto path);
// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside // &(4<<16) = disable all render postprocessing (None/Manual path).
// 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)
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all 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 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 // Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND
// region is inaudible decay, loud enough to not chase a reverb's infinite noise // ratio derives from this dB, never the reverse). Daniel-set.
// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from
// this dB, never the reverse.
inline constexpr double kAutoTrimThresholdDb = -72.0; inline constexpr double kAutoTrimThresholdDb = -72.0;
// Max tail rendered past the range end. The runaway guard: a non-decaying or // Runaway guard: max tail rendered past the range end, so a non-decaying or
// looping signal never crosses the trim threshold, so this caps the render. // looping signal doesn't render forever. Daniel-set; shared by offline+realtime.
// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths.
inline constexpr double kMaxTailSeconds = 8.0; inline constexpr double kMaxTailSeconds = 8.0;
inline constexpr double kMaxTailMs = 8000.0; inline constexpr double kMaxTailMs = 8000.0;
// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents // Derived linear amplitude ratio for RENDER_TRIMEND (header ~3062: an amplitude
// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20). // ratio, "0.5 means -6.02 dB", i.e. 10^(dB/20)) from kAutoTrimThresholdDb.
// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and // Function not constant: std::pow isn't constexpr before C++26.
// 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).
double autoTrimEndRatio(); double autoTrimEndRatio();
// The three tail states (docs/product/capture-tail.md §The three tail states): // The three tail states — see src/core/capture/CLAUDE.md.
// 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.
enum class TailMode { enum class TailMode {
None, None,
Auto, Auto,
@@ -100,9 +60,9 @@ enum class TailMode {
}; };
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS // 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 // the backend already sets. `trimEnd` is meaningful only when the trim-end
// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads // normalize bit is set (Auto). The backend reads these straight onto
// these four fields straight onto GetSetProjectInfo. // GetSetProjectInfo.
struct TailRenderSettings { struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1) int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
double tailMs = 0.0; // RENDER_TAILMS double tailMs = 0.0; // RENDER_TAILMS
@@ -110,54 +70,36 @@ struct TailRenderSettings {
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set) 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. // Maps a tail mode (+ requested manual tail ms, used only for Manual) to its
// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is // RENDER_* values. Manual is clamped to kMaxTailMs regardless of source.
// clamped to kMaxTailMs — the runaway guard applies whether the length came from
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs); TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
// The REALTIME record-window end (in project seconds) a tail mode records to, given // The realtime record-window end (project seconds): realtime does NOT drive
// the request's exact range end (docs/product/capture-tail.md §The realtime path). // RENDER_*, it records a generous window and trims later, so this is where the
// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so // transport actually stops. None -> exact rangeEndSeconds; Auto -> +8s runaway
// the window end is where the transport actually stops: // cap; Manual -> + clamp(manualTailMs, kMaxTailMs)/1000.
// None -> rangeEndSeconds (exact — no extra recording).
// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later).
// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim).
// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and
// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned
// end to the record time selection. Shared -72 dB / 8 s constants are the same ones
// the offline tail uses (single source of truth).
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds, double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
double manualTailMs); double manualTailMs);
// The RENDER_SETTINGS value for a given source mode. `supported` is false only // 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 { struct RenderSettingsChoice {
int settings = kRenderMasterMix; int settings = kRenderMasterMix;
bool supported = true; // false => not an offline-render source (e.g. Realtime) 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 // 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. // covers); FX scoping is orthogonal (done by fxBypassPlan). `wetDry` is
// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest // accepted but ignored — retained as the seam for future dry work. CONFIRMED
// as the seam for future dry work (M10 null test). // (SDK header ~3041): MasterMix/TimeSelection -> 0; SelectedTracks -> &128;
// // SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
// 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.
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); 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. // See src/core/capture/CLAUDE.md for the scope contract. There is NO master
// Item -> item/take FX ONLY (no track, no parent/folder, no master FX). // scope; the master track's FX/gain/pan are still NEUTRALIZED as part of the
// Track -> item FX + the selected track's OWN track FX (no parent/folder/master). // out-of-scope chain (bypassMaster below) — master is a bypass target only.
// 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.
enum class CaptureScope { enum class CaptureScope {
Item, Item,
Track, Track,
@@ -169,34 +111,28 @@ SourceMode sourceModeForScope(CaptureScope scope);
// --- Range inference: razor-else-time (orthogonal to scope) ------------------- // --- Range inference: razor-else-time (orthogonal to scope) -------------------
// //
// Every scope action infers its render range the same way: if a razor area is // Razor-present -> razor union; otherwise time selection. Razor is a range
// present, use the razor union; otherwise use the time selection. Razor is a // source, not a capture mode.
// range SOURCE, not a capture mode (the M7 four-mode model conflated them).
enum class RangeSource { enum class RangeSource {
Razor, // a razor area is present -> use its union bound Razor, // a razor area is present -> use its union bound
TimeSelection, // no razor -> use the time selection TimeSelection, // no razor -> use the time selection
}; };
// Picks the range source. Pure so the "razor wins when present" rule is tested // Picks the range source. Pure so "razor wins when present" is tested without
// without a DAW; the shell supplies whether any razor area was found. // a DAW; the shell supplies whether any razor area was found.
RangeSource inferRangeSource(bool hasRazorArea); RangeSource inferRangeSource(bool hasRazorArea);
// --- FX-bypass plan: which tracks' FX to bypass for a scope ------------------- // --- FX-bypass plan: which tracks' FX to bypass for a scope -------------------
// //
// Given a CaptureScope, returns three boolean flags: whether to bypass (a) the // Given a CaptureScope, returns three boolean flags: bypass (a) the captured
// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX, // track's OWN FX, (b) every ancestor (parent/folder) track's FX, (c) the
// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to // master FX. The caller (FxBypassGuard, shell) walks the ancestor chain via
// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and // GetParentTrack, clears I_FXEN on each flagged track (RAII restore), and also
// clears I_FXEN on each flagged track, snapshotting first (RAII restore). // neutralizes D_VOL/D_PAN/D_WIDTH/D_PANLAW/I_PANMODE to unity/center on the
// // same set (I_PANMODE is load-bearing: in pan mode 6, D_PAN/D_WIDTH are
// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan. // ignored entirely, so forcing it is what makes the other neutralizations
// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN // take effect) — I_FXEN alone doesn't touch a track's volume/pan. This plan
// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item // selects the set; the guard applies both the FX bypass and the neutralize.
// 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.
struct FxBypassPlan { struct FxBypassPlan {
bool bypassSelfFx = false; // the captured track's own FX bool bypassSelfFx = false; // the captured track's own FX
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX
@@ -213,38 +149,24 @@ struct RazorRange {
}; };
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated // Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
// TRIPLES of <start> <end> <envGuidString>. The envelope GUID is "" (an empty // TRIPLES of <start> <end> <envGuidString>, envGuid == `""` for a track-audio
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a // area vs a GUID for an envelope-lane area. Returns only track-audio ranges
// GUID like {…} for an envelope-lane area. // (envelope-lane triples skipped); malformed trailing tokens are ignored, not
// // fatal; a range with end <= start is dropped.
// 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).
std::vector<RazorRange> parseRazorEdits(const std::string& razorString); std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
// The union bound (min start, max end) of a set of razor ranges — the exact // 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. // window the offline render must cover. {0,0} for empty input ("no razor area").
// Returns {0,0} for an empty input (caller treats that as "no razor area").
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges); RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
// --- Capture-action taxonomy (the bindable set main.cpp registers) ----------- // --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
// //
// One row per bindable SCOPE action: item and track. The range each captures // One row per bindable scope action (item/track); range inference and tail
// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action // mode are read at fire time, not baked into the row. The row stores only the
// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads // channel-agnostic command-id SUFFIX + description PHRASE; the registering
// at fire time (see tail_control + bank_panel), so a single pair of actions covers // shell composes the full channel-qualified id/name via app_version.
// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
// //
// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the // commandSuffix is FOREVER-STABLE (user keybindings key off the composed id).
// 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).
struct CaptureActionDef { struct CaptureActionDef {
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix) const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
@@ -252,14 +174,11 @@ struct CaptureActionDef {
CaptureScope scope; // FX scope (item / track) CaptureScope scope; // FX scope (item / track)
}; };
// The capture-action table. Iterated by main.cpp to register the family and route // The capture-action table. Iterated by main.cpp to register the family and
// each fired command back to its definition. Kept here (pure) so the taxonomy is // route each fired command back to its definition.
// one testable list, not scattered registration code.
// //
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to capture // Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to
// the master you render a track. Razor is an inferred range, not a mode, and each // capture the master you render a track.
// 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.
const std::vector<CaptureActionDef>& captureActionTable(); const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler::capture } // namespace reasampler::capture
+6 -18
View File
@@ -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" #include "core/capture/tail_control.h"
@@ -19,14 +19,10 @@ TailMode cycleTailMode(TailMode current) {
} }
double clampManualMs(double manualMs) { 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); return std::clamp(manualMs, 0.0, kMaxTailMs);
} }
double adjustManualMs(double current, int notches, double stepMs) { 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); return clampManualMs(current + notches * stepMs);
} }
@@ -35,8 +31,8 @@ std::string tailToggleLabel(const TailSetting& setting) {
case TailMode::None: return "Tail: Off"; case TailMode::None: return "Tail: Off";
case TailMode::Auto: return "Tail: Auto"; case TailMode::Auto: return "Tail: Auto";
case TailMode::Manual: { case TailMode::Manual: {
// Append the CLAMPED length in seconds to one decimal so the readout can // Clamped so the readout can't show an over-cap value even if
// never show an over-cap value even if manualMs was stored past the cap. // manualMs was stored past the cap.
const double seconds = clampManualMs(setting.manualMs) / 1000.0; const double seconds = clampManualMs(setting.manualMs) / 1000.0;
char buf[32]; char buf[32];
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds); 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 return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
} }
// --------------------------------------------------------------------------- // --- JSON round-trip ---------------------------------------------------------
// JSON round-trip // manualMs round-trips exactly (json::numToStr uses the shortest %.17g-class
// --------------------------------------------------------------------------- // form for doubles); deserialize returns nullopt on any parse failure.
//
// 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.
namespace { namespace {
+19 -39
View File
@@ -1,13 +1,7 @@
#pragma once #pragma once
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode // 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 // toggle. The panel shell owns the SWELL window, LICE drawing, and click
// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length // hit-testing; the cycle order, manual-length clamp, and label text live here.
// 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.
#include <optional> #include <optional>
#include <string> #include <string>
@@ -16,54 +10,40 @@
namespace reasampler::capture { namespace reasampler::capture {
// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of // The Manual-mode starting length: 2s, a musically useful default (a bar of
// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a // reverb throw at moderate tempo), well under the 8s cap. Also the fallback
// project with no stored tail setting (older / never-adjusted) falls back to on load. // for a project with no stored tail setting.
inline constexpr double kDefaultManualTailMs = 2000.0; inline constexpr double kDefaultManualTailMs = 2000.0;
// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough // Fine-adjust step per scroll-wheel notch in Manual mode. Daniel-set.
// that a few notches cover the useful range, fine enough to dial a length precisely.
// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs.
inline constexpr double kManualStepMs = 250.0; inline constexpr double kManualStepMs = 250.0;
// The panel's current tail setting: the mode plus the length used ONLY when the // The panel's current tail setting: mode + the length used only when Manual.
// mode is Manual. Held as in-memory panel/session state (shell/panel), default // Default None so a capture with no explicit choice stays exact-bounds.
// None so a capture with no explicit choice stays exact-bounds / byte-identical to // `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest.
// 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.
struct TailSetting { struct TailSetting {
TailMode mode = TailMode::None; TailMode mode = TailMode::None;
double manualMs = kDefaultManualTailMs; double manualMs = kDefaultManualTailMs;
}; };
// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is // Cycles the tail mode: None -> Auto -> Manual -> None.
// 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.
TailMode cycleTailMode(TailMode current); TailMode cycleTailMode(TailMode current);
// The effective manual length a Manual capture uses: `manualMs` clamped to // The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs].
// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies). // Exposed so the panel can show the clamped value. Meaningful only for Manual.
// 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.
double clampManualMs(double manualMs); double clampManualMs(double manualMs);
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to // Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped
// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust // to [0, kMaxTailMs]. Meaningful only for TailMode::Manual.
// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler
// owns no arithmetic of its own. Meaningful only for TailMode::Manual.
double adjustManualMs(double current, int notches, double stepMs); 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 // The toggle's label, e.g. "Tail: Off", "Tail: Auto", or (Manual, clamped
// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" // length to one decimal) "Tail: Manual 2.0s".
// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are
// test-pinned, including the boundary lengths (0.0s, 8.0s).
std::string tailToggleLabel(const TailSetting& setting); std::string tailToggleLabel(const TailSetting& setting);
// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail // JSON round-trip of a TailSetting, for persist to store per-project. Pure/
// setting per-project alongside the bank and view model. Kept pure/testable here — // testable here, mirroring bank_model's serialize/deserialize; deserialize
// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a // returns nullopt on malformed input so the caller falls back to a default.
// compact object; deserialize returns std::nullopt on malformed input so the caller
// (persist) falls back to a default setting, exactly as an absent key does.
std::string serializeTailSetting(const TailSetting& setting); std::string serializeTailSetting(const TailSetting& setting);
std::optional<TailSetting> deserializeTailSetting(const std::string& json); std::optional<TailSetting> deserializeTailSetting(const std::string& json);
+23 -45
View File
@@ -1,7 +1,6 @@
// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor. // wav_codec — pure implementation. See wav_codec.h. The one RIFF chunk
// // traversal lives here (nextWavChunk); layout parse and content hash both
// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the // walk with it, so their view of the container cannot drift.
// content hash both walk with it, so their view of the container cannot drift.
#include "core/capture/wav_codec.h" #include "core/capture/wav_codec.h"
@@ -12,8 +11,7 @@ namespace reasampler::capture {
namespace { namespace {
// Little-endian readers. Bounds are checked by the caller before each read; these // Little-endian readers. Caller checks bounds before each read (off + N <= size).
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) { std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8)); return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
} }
@@ -28,7 +26,7 @@ bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char*
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; 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 kWaveFormatIeeeFloat = 0x0003;
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
@@ -37,19 +35,16 @@ constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kFnvPrime = 1099511628211ULL; constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
std::string fnvHex(std::uint64_t h) { std::string fnvHex(std::uint64_t h) {
// 16-digit lowercase hex (zero-padded) for a fixed-length string. char buf[17]; // 16 hex digits, zero-padded
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h)); std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
return std::string(buf); 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 // One sub-chunk of a RIFF/WAVE container: header at `headerOffset` (id(4) +
// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`. // size(4)), body at `bodyOffset`/`bodySize`. `bodyInBounds` false means the
// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk // declared body runs past the buffer — still reported, but must not be read.
// whose declared size lies past the end is still REPORTED (callers decide how to
// treat it) but its body must not be read.
struct WavChunkView { struct WavChunkView {
std::size_t headerOffset = 0; std::size_t headerOffset = 0;
std::size_t bodyOffset = 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 // 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 // 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 // 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 // would overrun the buffer, the chunk is still reported (return true) and `pos`
// parked past the end so the NEXT call returns false — exactly the process-then- // is parked past the end so the next call returns false.
// break shape the pre-consolidation walkers shared.
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos, bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
WavChunkView& out) { WavChunkView& out) {
if (pos + 8 > bytes.size()) return false; if (pos + 8 > bytes.size()) return false;
@@ -100,8 +94,8 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
std::uint32_t sampleRate = 0; std::uint32_t sampleRate = 0;
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
// Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A // Walk the sub-chunks after "WAVE" (offset 12). A malformed/truncated file
// malformed/truncated file is "invalid", never an OOB read. // is "invalid", never an OOB read.
std::size_t pos = 12; std::size_t pos = 12;
WavChunkView c; WavChunkView c;
while (nextWavChunk(bytes, pos, c)) { while (nextWavChunk(bytes, pos, c)) {
@@ -112,11 +106,9 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
channels = readU16LE(bytes, c.bodyOffset + 2); channels = readU16LE(bytes, c.bodyOffset + 2);
sampleRate = readU32LE(bytes, c.bodyOffset + 4); sampleRate = readU32LE(bytes, c.bodyOffset + 4);
bitsPerSample = readU16LE(bytes, c.bodyOffset + 14); bitsPerSample = readU16LE(bytes, c.bodyOffset + 14);
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading // WAVE_FORMAT_EXTENSIBLE: the real format lives in the SubFormat GUID's
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM // leading 2-byte tag at body offset 24, not in fmtTag itself. Body must
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to // reach offset 24+16; otherwise leave the tag at 0 (rejected).
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
if (fmtTag == kWaveFormatExtensible) { if (fmtTag == kWaveFormatExtensible) {
if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) { if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24); extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
@@ -124,16 +116,13 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
} }
haveFmt = true; haveFmt = true;
} else if (tagEquals(bytes, c.headerOffset, "data")) { } else if (tagEquals(bytes, c.headerOffset, "data")) {
// The data chunk: PCM starts at bodyOffset, declared length bodySize. // Reject if the declared body runs past the buffer (truncated/lying
// Reject if it runs past the buffer (truncated / lying header). // header), or if data arrived before fmt.
if (!c.bodyInBounds) return out; 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) is float only when its SubFormat sub-tag is
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from // also IEEE-float (0x0003) — PCM-integer-in-extensible must be rejected.
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
// float and must be rejected to prevent mis-decoding as float.
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
(fmtTag == kWaveFormatExtensible && (fmtTag == kWaveFormatExtensible &&
extensibleSubFormatTag == kWaveFormatIeeeFloat); extensibleSubFormatTag == kWaveFormatIeeeFloat);
@@ -279,12 +268,6 @@ std::string hashBytes(const std::uint8_t* data, std::size_t len) {
} }
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) { std::string hashWavContent(const std::vector<std::uint8_t>& 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)) { if (isRiffWave(bytes)) {
std::uint64_t h = kFnvOffsetBasis; std::uint64_t h = kFnvOffsetBasis;
auto feedByte = [&](std::uint8_t b) { auto feedByte = [&](std::uint8_t b) {
@@ -295,23 +278,18 @@ std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
bool haveFmt = false; bool haveFmt = false;
bool haveData = false; bool haveData = false;
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a feedByte(static_cast<std::uint8_t>('W')); // domain-separation prefix
// whole-file hash of different bytes that happen to be the same length.
feedByte(static_cast<std::uint8_t>('W'));
std::size_t pos = 12; std::size_t pos = 12;
WavChunkView c; WavChunkView c;
while (nextWavChunk(bytes, pos, c)) { while (nextWavChunk(bytes, pos, c)) {
if (tagEquals(bytes, c.headerOffset, "fmt ")) { 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) { if (c.bodyInBounds) {
for (std::uint32_t i = 0; i < c.bodySize; ++i) for (std::uint32_t i = 0; i < c.bodySize; ++i)
feedByte(bytes[c.bodyOffset + i]); feedByte(bytes[c.bodyOffset + i]);
haveFmt = true; haveFmt = true;
} }
} else if (tagEquals(bytes, c.headerOffset, "data")) { } else if (tagEquals(bytes, c.headerOffset, "data")) {
// Feed the entire PCM payload.
if (c.bodyInBounds) { if (c.bodyInBounds) {
for (std::uint32_t i = 0; i < c.bodySize; ++i) for (std::uint32_t i = 0; i < c.bodySize; ++i)
feedByte(bytes[c.bodyOffset + i]); feedByte(bytes[c.bodyOffset + i]);
+38 -103
View File
@@ -1,39 +1,9 @@
#pragma once #pragma once
// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e: // wav_codec — the pure owner of the WAV/RIFF byte format: chunk walker, layout
// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32 // parse, float32 build, size-field patch, and the WAV-aware content hash — one
// build + size-field patch + the WAV-aware content hash, in one tested module. // 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
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // w/ float subformat, float32 `data`); anything else parses as invalid.
// 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.
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
@@ -49,22 +19,20 @@ using audio::AudioSample;
// --- Layout parse ------------------------------------------------------------ // --- Layout parse ------------------------------------------------------------
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the // 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 // bytes are not a WAV we can safely trim; every other field is meaningful only
// is meaningful only when valid. // when valid.
struct WavLayout { struct WavLayout {
bool valid = false; bool valid = false;
std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride) std::uint16_t channelCount = 0; // from `fmt ` (interleave stride)
std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed) std::uint32_t sampleRate = 0;
// The `data` chunk: byte offset of its first PCM byte within the file, and its // The `data` chunk: PCM byte offset + declared length.
// declared PCM byte length. frameCount = dataByteLength / (channelCount * 4). // frameCount = dataByteLength / (channelCount * 4).
std::size_t dataByteOffset = 0; std::size_t dataByteOffset = 0;
std::size_t dataByteLength = 0; std::size_t dataByteLength = 0;
// Byte offset of the two little-endian uint32 size fields the truncate patch // Offsets of the two LE uint32 size fields the truncate patch rewrites.
// rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk
// size (the 4 bytes immediately before dataByteOffset).
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
std::size_t dataSizeFieldOffset = 0; std::size_t dataSizeFieldOffset = 0;
@@ -74,19 +42,15 @@ struct WavLayout {
} }
}; };
// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything // Parses a WAV byte buffer's header geometry; {valid=false} for anything not a
// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk, // canonical float32 RIFF/WAVE, or a `data` length running past the buffer.
// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only // Does not copy PCM, only locates it. Pure + total (no throw, no UB).
// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB).
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes); WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes);
// Copies `frameCount` interleaved float frames starting at `startFrame` out of the // Copies `frameCount` interleaved float frames starting at `startFrame` out of
// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). // the WAV's `data` region into a flat [f0c0,f0c1,...] buffer, clamped to frames
// Clamps to the frames the buffer actually holds — never reads past `data`. Returns // actually present; never reads past `data`. Reads little-endian via memcpy —
// empty for an invalid layout or an out-of-range start. The floats are read // target is x86/ARM-LE only, no big-endian byte-swap.
// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would
// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux
// on x86/ARM-LE) is little-endian and REAPER writes LE WAV.
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes, std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout, const WavLayout& layout,
std::size_t startFrame, std::size_t startFrame,
@@ -94,10 +58,8 @@ std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& byt
// --- Truncate plan + size-field patch --------------------------------------- // --- Truncate plan + size-field patch ---------------------------------------
// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte // The plan to truncate a parsed WAV to `keptFrames` frames. `valid` is false if
// length and the two size-field values to patch. `valid` is false if the layout is // the layout is invalid or keptFrames exceeds the file's frames (never grow).
// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller
// clamps beforehand; this guards it too).
struct WavTruncatePlan { struct WavTruncatePlan {
bool valid = false; bool valid = false;
@@ -109,64 +71,37 @@ struct WavTruncatePlan {
// the 8-byte "RIFF"+size prefix) // the 8-byte "RIFF"+size prefix)
}; };
// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV. // Computes the truncate plan to keep exactly `keptFrames` frames. The shell
// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure + // applies it: patch the two size fields (patchU32LE), then truncate to
// total. The shell applies it: patch the two size fields in the byte buffer // newFileByteLength.
// (patchU32LE), then truncate the file to newFileByteLength.
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size // Patches a little-endian uint32 into a byte buffer at `off`. Caller guarantees
// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size() // off + 4 <= bytes.size() (the plan's offsets came from a valid parse of the same
// (the plan's offsets came from a valid parse of the same buffer). // buffer).
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v); void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v);
// --- Float32 WAV build ------------------------------------------------------- // --- Float32 WAV build -------------------------------------------------------
// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved // Builds a minimal canonical float32 RIFF/WAVE byte buffer from interleaved
// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, // double samples (narrowed to float by cast). Round-trips through
// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels, // parseWavLayout/extractFloatFrames.
// `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.
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate, std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
std::size_t frameCount, std::size_t frameCount,
const std::vector<double>& interleaved); const std::vector<double>& interleaved);
// --- Content identity (dedup hashes) ----------------------------------------- // --- Content identity (dedup hashes) -----------------------------------------
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data` // Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
// and returns it as a 16-character lowercase hex string. Designed to fill // hex. Fills Sample::contentHash for the confirm-on-last-reference dedup guardrail.
// 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).
std::string hashBytes(const std::uint8_t* data, std::size_t len); 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 // WAV-aware content hash: hashes only the `fmt ` body + `data` payload, skipping
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all // other chunks. WHY: REAPER's offline renderer embeds a render-varying `bext`
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED). // timestamp chunk even with no BWF metadata requested, so two renders of
// // identical audio would otherwise hash differently and never dedup. Prefixed
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a // with tag byte 'W' so it can't collide with a same-size hashBytes result.
// `bext` chunk containing the origination date/time) even when the format config blob // Falls back to whole-file hashBytes (no prefix) for a file that doesn't parse.
// 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.
std::string hashWavContent(const std::vector<std::uint8_t>& bytes); std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::capture } // namespace reasampler::capture
+1 -1
View File
@@ -11,7 +11,7 @@
namespace reasampler::instrument::engine { 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); } double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
+15 -35
View File
@@ -1,19 +1,9 @@
// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain. // master_gain.h — dB<->linear<->knob-taper math for the post-mixer master gain.
// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns // One shared formula so the drawn needle, the persisted value, and the audio-thread
// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1) // multiply can't drift. Norm 0 = true zero gain (not an epsilon); persisted value is
// and the processor's stored/applied linear gain — so the drawn needle, the persisted value, // linear gain, the dB taper is a UI-side view of it. Unity (0 dB) sits at ~0.714 norm.
// and the audio-thread multiply can never drift. // RT: the processor applies the linear gain as one multiply over the summed output;
// // these functions themselves run on UI/state threads only.
// 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.
#pragma once #pragma once
@@ -21,38 +11,28 @@
namespace reasampler::instrument::engine { namespace reasampler::instrument::engine {
// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the // norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1. // and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
inline constexpr double kMasterGainMinDb = -60.0; inline constexpr double kMasterGainMinDb = -60.0;
inline constexpr double kMasterGainMaxDb = 24.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(); 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); double masterGainDbFromNorm(double norm);
// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at or below kMasterGainMinDb, // Anything at or below kMasterGainMinDb (including -inf) collapses to norm 0 — the finite
// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the // sweep only covers the range above the floor.
// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses
// to the same true-zero bottom. +24 -> 1. Pure.
double masterGainNormFromDb(double db); 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); double masterGainLinearFromNorm(double norm);
// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at // linear <= 0, or at/below the kMasterGainMinDb floor, collapses to norm 0 — values between
// or below the kMasterGainMinDb floor (e.g. 0.001 = -60 dB, or anything below) also maps to 0 // true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
// — 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.
double masterGainNormFromLinear(double linear); double masterGainNormFromLinear(double linear);
// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a // "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes // Writes at most `len` bytes including the terminator.
// including the terminator. Pure.
void formatMasterGainLabel(double norm, char* buf, std::size_t len); void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::instrument::engine } // namespace reasampler::instrument::engine
+84 -121
View File
@@ -1,24 +1,15 @@
// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2 // pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
// route-(b) rationale (WDL drags <windows.h>), 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.
// //
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input // 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 // `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 // 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 // one window — clamped to the filled span so it never lands in unwritten silence — refined
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the // by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic // sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned // splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB // with a raised-cosine, amplitude-complementary pair (in-phase content sums to unity gain).
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on // At unity ratio the delay is frozen mid-band and no splice ever fires.
// 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.
#include "core/instrument/engine/pitch_shift.h" #include "core/instrument/engine/pitch_shift.h"
@@ -55,17 +46,15 @@ void PitchShifter::configure(std::int64_t windowFrames) {
ringLen_ = 2 * window_; ringLen_ = 2 * window_;
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f); ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
// Geometry (all quarters of the window): // Geometry (all quarters of the window):
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when // - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the // the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so // past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade. // - maxLag_: alignment search half-range — one window/4 covers a full period of any tone
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any // down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
// 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).
// - dLow_/dHigh_: the 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
// - corrFrames_: the correlation segment length. At an up-splice the reference segment // delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists // the splice burst.
// between the tap and the writer — the cap expresses that safety rather than leaving
// it coincidental. 512 bounds the splice burst.
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1); fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
maxLag_ = window_ / 4; maxLag_ = window_ / 4;
dLow_ = window_ / 4; dLow_ = window_ / 4;
@@ -77,10 +66,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
void PitchShifter::reset() { void PitchShifter::reset() {
if (window_ > 1) { if (window_ > 1) {
// Zero the ring and seed the active tap one window behind the writer — the exact // Seed the active tap one window behind the writer — the exact middle of the safe
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there // band [dLow, dHigh], so unity holds it there forever with maximal drift room either
// forever and either shift direction has maximal drift room. No history is declared // direction. No history declared (filled_ = 0): follow with prime() or warm().
// (filled_ = 0): follow with prime() or warm() before streaming.
std::fill(ring_.begin(), ring_.end(), 0.0f); std::fill(ring_.begin(), ring_.end(), 0.0f);
writePos_ = 0; writePos_ = 0;
posA_ = static_cast<double>(ringLen_ - window_); posA_ = static_cast<double>(ringLen_ - window_);
@@ -104,15 +92,12 @@ void PitchShifter::reset() {
void PitchShifter::freezeTail() { void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return; if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true; tailFrozen_ = true;
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at // 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 // ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest- // it completes before tap B reaches the parked writer and reads lapped content mid-fade.
// 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.
if (fading_) { if (fading_) {
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the // Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then // freeze frame (no gain step). Compute tOld before overwriting fadeLen_.
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
const double tOld = const double tOld =
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_); static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
double dB = static_cast<double>(writePos_) - posB_; double dB = static_cast<double>(writePos_) - posB_;
@@ -133,8 +118,8 @@ void PitchShifter::freezeTail() {
void PitchShifter::prime(const AudioSample* src, std::int64_t count) { void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
if (window_ <= 1) return; // pass-through needs no priming if (window_ <= 1) return; // pass-through needs no priming
// Clamp to one window: the intended call primes exactly window() frames, and delay == // Clamp to one window: delay == count must stay inside the safe band so the seed itself
// count must stay inside the safe band so the seed does not itself trigger a splice. // never triggers a splice.
if (count < 0) count = 0; if (count < 0) count = 0;
if (count > window_) count = window_; if (count > window_) count = window_;
std::fill(ring_.begin(), ring_.end(), 0.0f); 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) { void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward // Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
// older content, -window_ = jump toward the writer), refined by a correlation search so // content, -window_ = toward the writer), refined by a correlation search so the relocated
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content. // read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, // (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ // sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
// multiply-adds, once per splice. // per splice.
const std::int64_t d = static_cast<std::int64_t>(delay); const std::int64_t d = static_cast<std::int64_t>(delay);
// GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the // An up-jump may only relocate into valid history. The deepest slot the search (plus the
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's // parabola's +/-1 probe and the interpolator's read-ahead) can touch is d + jump + maxLag + 2,
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search, // so the cap is filled_ - d - maxLag_ - 1 (one sample looser than that derived bound, not
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead), // extra margin — ring indexing wraps via modulo everywhere regardless). In steady state
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER // (filled_ == ringLen_) this exceeds window_ and the nominal jump is untouched; near a primed
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so // onset it shrinks the jump to what real history exists. The floor of 1 only fires on the
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is // degenerate reset-without-prime path.
// > 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.
std::int64_t jump = nominalJump; std::int64_t jump = nominalJump;
if (jump > 0) { if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1; 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 (++ia >= ringLen_) ia = 0;
if (++ic >= ringLen_) ic = 0; if (++ic >= ringLen_) ic = 0;
} }
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward // Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
// the higher-energy lag, so on a decaying tail every up-splice would prefer the // so on a decaying tail every up-splice would prefer the loudest candidate over the
// loudest candidate over the best-ALIGNED one — a small level step per splice that // best-aligned one. The reference segment's energy is constant across lags, so dividing
// the amplitude-complementary fade cannot hide. The reference segment's energy is // by sqrt(Ec) alone ranks identically to the full normalized form.
// 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).
return ec > 0.0 ? s / std::sqrt(ec) : 0.0; 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 // Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone // sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through // comb. A parabola through the scores at bestLag-1/bestLag/bestLag+1 locates the peak to a
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of // fraction of a sample; readTap()'s linear interpolation realizes it. The denominator is
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The // negative at a genuine peak — flat correlation (DC/silence) keeps the integer lag, benign.
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
// silence) keeps the integer lag, which is already benign there.
double frac = 0.0; double frac = 0.0;
{ {
const double sM = scoreAt(bestLag - 1); 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 < 0.0) p += len;
while (p >= len) p -= len; while (p >= len) p -= len;
posA_ = p; posA_ = p;
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and // Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4 // writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st = // writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale // and play stale read-ahead data. Cap the live fade at the drain headroom actually
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the // available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_ // drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal // so they always keep the full fade.
// 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.
// //
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL // Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
// ratio (there is no retreating write head), in EITHER shift direction — so the drain // either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity // applies at every ratio (including unity, since delay now drains at unity too).
// included: splices fire in the frozen tail because the delay now drains at unity too).
fadeLen_ = fadeFrames_; fadeLen_ = fadeFrames_;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0); const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
if (drainRate > 0.0) { if (drainRate > 0.0) {
@@ -316,16 +289,15 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
} }
fading_ = true; fading_ = true;
fadePos_ = 0; fadePos_ = 0;
// Record the decision for a linked follower channel (T1-01): the follower applies this // Record the decision for a linked follower channel — applied verbatim there so both
// verbatim so both channels share one lag and one splice schedule. // channels share one lag and one splice schedule.
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_}; lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
} }
void PitchShifter::applySplice(const SpliceEvent& ev) { void PitchShifter::applySplice(const SpliceEvent& ev) {
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no // Follower half of the 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, // correlation search of our own — the master's jump/fade derive from shared geometry +
// which match ours by the lockstep contract (identical configure/prime/ratio history); // ratio, which match ours by the lockstep contract (identical configure/prime/ratio history).
// the fade length likewise derives only from shared geometry + ratio.
posB_ = posA_; posB_ = posA_;
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac; double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
const double len = static_cast<double>(ringLen_); const double len = static_cast<double>(ringLen_);
@@ -347,24 +319,21 @@ AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& maste
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) { AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate) if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer; // Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
// 5 plain fields, negligible on the RT path).
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{}; const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices 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 // Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
// ring now holds valid history (capped at the ring length once it has wrapped). // ring keeps its all-real final two windows) and hold the write head; read/splice/fade
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write // below run unchanged over the frozen content.
// 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.
if (!tailFrozen_) { if (!tailFrozen_) {
ring_[static_cast<std::size_t>(writePos_)] = in; ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_; if (filled_ < ringLen_) ++filled_;
} }
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap. // 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 // Raised-cosine complementary gains (gNew + gOld == 1): correlation-aligned content is in
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge). // phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
double out = readTap(posA_); double out = readTap(posA_);
if (fading_) { if (fading_) {
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_); const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
@@ -372,22 +341,17 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
out = gNew * out + (1.0 - gNew) * readTap(posB_); out = gNew * out + (1.0 - gNew) * readTap(posB_);
if (++fadePos_ >= fadeLen_) fading_ = false; if (++fadePos_ >= fadeLen_) fading_ = false;
} else if (linked != nullptr) { } else if (linked != nullptr) {
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the // Follower: no trigger test, no search — splice exactly when and how the master did
// master channel did this frame. Lockstep state means our own trigger would have // this frame (lockstep means our own trigger would have fired the same frame anyway).
// fired on the same frame; applying the master's decision keeps the two rings
// sample-aligned (one shared lag, one shared schedule).
if (linkedEv.fired) { if (linkedEv.fired) {
applySplice(linkedEv); applySplice(linkedEv);
} else { } else {
// Self-healing fallback (review rider): the master not firing normally means this // Self-healing fallback: if the processor ever renders a mono block mid-note, this
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever // follower is skipped for that block while the master keeps advancing, and could
// renders a mono block mid-note, this follower channel is skipped for that block // never resync via the `linkedEv.fired` path alone. So also check this follower's
// while the master keeps advancing — its writePos_/filled_ falls behind and, with // own tap distance against the safe band and splice via its own search when it has
// only the `if (linkedEv.fired)` path above, could never resync. So check this // left [dLow_, dHigh_] — never triggers in the normal (non-mono-block) case, since
// follower's OWN tap distance against the safe band and splice via its own search // the master's trigger always fires first.
// 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.
double d = static_cast<double>(writePos_) - posA_; double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_); const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len; while (d < 0.0) d += len;
@@ -399,10 +363,10 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
} }
} }
} else { } else {
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band. // 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- // Up-shifts drain the delay toward 0 -> jump one window older; down-shifts grow it
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At // toward the ring length -> jump one window toward the writer. At unity the delay is
// unity the delay is frozen at window/2 and neither trigger ever fires. // frozen at window/2 and neither trigger ever fires.
double d = static_cast<double>(writePos_) - posA_; double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_); const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len; 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), // Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
// tap(s) by the shift ratio.
if (!tailFrozen_) { if (!tailFrozen_) {
++writePos_; ++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0; if (writePos_ >= ringLen_) writePos_ = 0;
+86 -144
View File
@@ -1,66 +1,34 @@
#pragma once #pragma once
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve" // pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES // Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts // chases the write head at the shift ratio; when it drifts out of its safe delay band it is
// out of its safe delay band it is relocated by a nominal window jump REFINED BY A // relocated by a nominal window jump, refined by a cross-correlation search so the new read
// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new // point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are
// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and // both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same // `readPos_ += ratio_` resample path).
// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
// //
// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the // Regression history — do not revert any of these:
// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head // - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs // crossfaded by write-head distance): that fixed offset gave the two taps a fixed relative
// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency- // phase, so near-anti-phase source frequencies (roughly half of them) nearly cancelled at
// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint // every crossfade midpoint — a repitched pure sine came out mangled while unity stayed clean.
// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine // Splices must be phase-aligned (snapped to the best waveform match), not just distance-fired.
// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity // - Hand-rolled, not WDL_SimplePitchShifter: its include chain pulls <windows.h> unconditionally,
// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump // which cannot enter this REAPER/VST3-free core (sampler_core_tests links neither SDK). Swap
// is snapped to the best waveform match within a bounded lag search — a pure sine's jump // to WDL, if ever wanted, happens at the shell, never in this pure core.
// lands on an integer period count and the output stays a single shifted tone. // - 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 // RT discipline: configure() sizes the ring once, off the audio thread. prime()/warm() only
// route (a) `WDL_SimplePitchShifter`. But its include chain // copy into the pre-sized ring (bounded, allocation-free). process() does no allocation and no
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 -> // locks; the correlation search is a bounded burst that fires once per splice cadence
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module // (window / |ratio-1| frames), never per frame.
// (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.
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
@@ -72,100 +40,76 @@ namespace reasampler::instrument::engine {
using audio::AudioSample; using audio::AudioSample;
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG // 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 // stereo contract. A stereo voice runs channel 0 as the master (full correlation search) and
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller // channel 1 as the follower: after the master's process() for a frame, the caller passes
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the // master.lastSplice() to the follower's processLinked() for the same frame, and the follower
// follower applies exactly this decision instead of running its own search. Both channels // applies exactly this decision instead of running its own search. Both channels therefore
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel // share one lag and one splice schedule — independent per-channel searches drew an inter-channel
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice: // offset of up to +/-maxLag at every splice, causing stereo image wander and comb coloration on
// stereo image wander at the splice cadence plus comb coloration on any mono sum. // a mono sum.
struct SpliceEvent { struct SpliceEvent {
bool fired = false; // a splice was scheduled on this frame 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 std::int64_t lag = 0; // correlation best integer lag
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5] double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen 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 per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked:
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice // channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned. // two rings stay sample-aligned.
// //
// The default-constructed shifter is INERT: with no configure() it passes input through // Default-constructed is inert: with no configure() it passes input through unchanged (ratio
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is // 1.0, empty ring), so a Varispeed voice that never touches it sees no behavior change.
// byte-identical to the pre-S16 engine.
class PitchShifter { class PitchShifter {
public: public:
// Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x // Sizes the delay ring for `windowFrames` (the ring is 2x that for splice/search headroom).
// that for splice/search headroom) and derive the fade/search geometry. `windowFrames` // <= 1 degrades to pass-through. Off the audio thread (allocates); resets all running state.
// <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by // A larger window means fewer splices and a deeper alignment search; a primed shifter has no
// zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running // added latency regardless of window size (see prime()).
// 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.
void configure(std::int64_t windowFrames); void configure(std::int64_t windowFrames);
// Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park // Pre-fills the ring with the first `count` frames of the upcoming source stream and parks
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then // the tap on src[0]; the caller then feeds process() the stream continuing at src[count].
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO // `count` is clamped to [0, window()]. If the playable source is shorter than one window,
// structural latency at every ratio, and splices always have `count` frames of real // prime only the real span and call freezeTail() immediately after — never pad with silence
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]. // and declare it valid; padded zeros are splice targets and reintroduce the onset gap.
// When the PLAYABLE source is shorter than one window, prime only the real span and call // RT-safe: bounded copy, no allocation. No-op when unconfigured; ratio is left untouched.
// 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.
void prime(const AudioSample* src, std::int64_t count); void prime(const AudioSample* src, std::int64_t count);
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and // Silence-prime: zero the ring, park the tap one window behind the writer, declare that
// declare that window of silence as valid history. Kept for callers with no access to the // window silence as valid history. Kept for callers with no access to the upcoming stream
// upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking // (a silence-primed up-shift plays ~a window of silence before speaking; the Voice path uses
// the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a // prime() instead). At unity a warmed shifter is a bit-exact window() delay.
// bit-exact window() delay. No-op when unconfigured.
void warm(); void warm();
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias. // 2^((note - root)/12) plus any per-frame pitch-envelope bias; 1.0 = no shift, no splices
// 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is // ever fire. Cheap enough to set per frame. Values <= 0 are ignored (kept at the last valid
// fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored // ratio) so a bad input never runs the tap backward or stalls it.
// (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it.
void setShiftRatio(double ratio); void setShiftRatio(double ratio);
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out). // Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured // pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write // writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
// head, reads the active tap (crossfading against the outgoing tap while a splice fade is // during a splice fade), then advances the write head and tap(s). When the active tap leaves
// live), then advances the write head by one and the tap(s) by the shift ratio. When the // its safe delay band, a correlation-aligned splice is scheduled.
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
AudioSample process(AudioSample in); AudioSample process(AudioSample in);
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process() // Follower-mode process: identical to process() except the splice decision isn't computed
// except the splice decision is NOT computed here — when `master.fired` is true this // here — when `master.fired` is true this frame splices with exactly the master's
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is // jump/lag/frac/fadeLen. The caller must process the master channel first each frame and
// considered. The caller must process the master channel FIRST each frame and pass its // pass its lastSplice() here; both shifters must be configured/primed/ratio'd identically so
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their // their ring state advances in lockstep. RT-safe: same guarantees as process().
// 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().
AudioSample processLinked(AudioSample in, const SpliceEvent& master); 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_; } const SpliceEvent& lastSplice() const { return lastSplice_; }
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame // Call once the source stream is exhausted — no real frame remains to feed process().
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore // Freezes the write head: subsequent process() calls ignore input and write nothing, but
// their input and write nothing, but read, splice, and crossfade exactly as before over // read/splice/crossfade as before over the ring's frozen (all-real) final two windows, so
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last // every late splice stays waveform-aligned against real content instead of a DC plateau.
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices // Idempotent; RT-safe (flag + bounded arithmetic); cleared by reset()/prime()/warm().
// 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().
void freezeTail(); void freezeTail();
bool tailFrozen() const { return tailFrozen_; } 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 // the writer (the caller just computed it for the trigger test). Records the decision in
// lastSplice_ for a linked follower channel. // lastSplice_ for a linked follower channel.
void splice(std::int64_t nominalJump, double delay); void splice(std::int64_t nominalJump, double delay);
// Apply a master channel's already-computed splice decision verbatim (no search) — // Applies 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_. // the follower half of the linked-lag contract. Mirrors it into lastSplice_.
void applySplice(const SpliceEvent& ev); void applySplice(const SpliceEvent& ev);
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger + // Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
// search), non-null = follower mode (splice iff linked->fired, with linked's decision). // 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 maxLag_ = 0; // correlation search half-range (window_/4)
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind // 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 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 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 std::int64_t filled_ = 0; // frames of valid history behind the writer; splice()
// + frames streamed, capped at ringLen_). splice() clamps // clamps its up-jump to this so it never lands in
// its up-jump to this so no splice lands in unwritten // unwritten silence
// silence — the GA2 onset-gap fix.
double ratio_ = 1.0; // current shift ratio (>0) double ratio_ = 1.0; // current shift ratio (>0)
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01): SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
// cleared at the top of every frame, set on a splice // at the top of every frame, set on a splice
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
// recycles the ring's frozen real tail, splices still // frozen real tail, drains toward the writer at ratio_
// aligned. With the writer parked, a tap drains toward it // (not ratio_-1) per frame — splice() scales the fade
// at ratio_ (not ratio_-1) per frame — splice() scales the // by that rate
// live fade by that rate.
}; };
} // namespace reasampler::instrument::engine } // namespace reasampler::instrument::engine
+104 -152
View File
@@ -1,16 +1,11 @@
// sampler_core — pure sampler engine implementation. See sampler_core.h for the // sampler_core — pure sampler engine implementation. See sampler_core.h for the contract.
// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape,
// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes.
// //
// DOCUMENTED HOT-PATH EXCEPTION to the Phase Q ~600-line file ceiling (Q-W2v, // Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays
// T4-14/T4-27 — Daniel-settled 2026-07-28): this TU deliberately STAYS WHOLE. // whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
// AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called // per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render
// per-voice-per-sample from Voice::advanceFrame, which is called per-sample from // — same-TU definition is what lets the compiler inline that stack (no LTO configured). A
// VoiceEngine::render — same-TU definition is what lets the compiler inline that // by-class TU split would put the hottest inner loop across TU boundaries. Do not split
// stack (the build configures NO LTO). A by-class TU split would put the hottest // this file further; the header is split instead (zone_params.h carries the value structs).
// 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).
#include "core/instrument/engine/sampler_core.h" #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) { double keyTrackedRatio(int note, int rootNote, double keyTrack) {
// Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields // keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double,
// (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument // so the argument to std::pow is bit-identical to pitchRatio(note, rootNote).
// 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.
const double semis = static_cast<double>(note - rootNote) * keyTrack; const double semis = static_cast<double>(note - rootNote) * keyTrack;
return std::pow(2.0, semis / 12.0); return std::pow(2.0, semis / 12.0);
} }
@@ -105,8 +97,7 @@ double AdsrEnvelope::tick() {
const double out = level_; const double out = level_;
++framesInStage_; ++framesInStage_;
if (framesInStage_ >= params_.attackFrames) { if (framesInStage_ >= params_.attackFrames) {
// S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight // holdFrames == 0 falls straight through Hold on the next tick to Decay.
// through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
stage_ = Stage::Hold; stage_ = Stage::Hold;
framesInStage_ = 0; framesInStage_ = 0;
level_ = 1.0; level_ = 1.0;
@@ -115,16 +106,13 @@ double AdsrEnvelope::tick() {
} }
case Stage::Hold: { case Stage::Hold: {
// S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the // holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0
// stage on this same tick (no frame consumed at 1.0 beyond what Attack already // beyond what Attack already emitted) so a zero-length hold emits no extra sample.
// emitted), so hold=0 is byte-identical to the pre-S15 envelope.
if (params_.holdFrames <= 0) { if (params_.holdFrames <= 0) {
stage_ = Stage::Decay; stage_ = Stage::Decay;
framesInStage_ = 0; 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; level_ = 1.0;
// Single re-dispatch into Decay (bounded: HoldDecay only; not a general recursion). // Single re-dispatch into Decay (bounded: Hold->Decay only, not general recursion).
return tick(); return tick();
} }
level_ = 1.0; 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, 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() { double PitchEnvelope::tick() {
@@ -263,10 +251,10 @@ double PitchEnvelope::tick() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void Voice::presizePreserveShifters(std::int64_t windowFrames) { void Voice::presizePreserveShifters(std::int64_t windowFrames) {
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs // Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. The // needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
// prime scratch (one window, reused per channel) is sized here for the same reason: start() // prime scratch is sized here for the same reason: start() assembles the first window
// assembles the first window of the upcoming source stream into it with zero allocation. // of the upcoming source into it with zero allocation.
shiftL_.configure(windowFrames); shiftL_.configure(windowFrames);
shiftR_.configure(windowFrames); shiftR_.configure(windowFrames);
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f); primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
@@ -282,20 +270,16 @@ bool Voice::sustainLoopUsable() const {
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack, const VelocityCurve& velocityCurve, double keyTrack, const VelocityCurve& velocityCurve,
bool declickTakeover) { bool declickTakeover) {
// Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT // Before any state reset, record the pre-cut reference (last rendered output) and mark
// REFERENCE — the last rendered output — and mark the compensation PENDING iff this // the compensation pending iff this start is a takeover/steal of a sounding voice and the
// start is a takeover/steal of a SOUNDING voice and the caller opted in. The ramp itself // caller opted in. The ramp is seeded on the first frame rendered after the restart, from
// is seeded on the FIRST frame rendered after the restart, from the DIFFERENCE between // the difference between this reference and the new voice's raw output that frame
// this reference and the new voice's raw output that frame (seedDeclick), so the // (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
// boundary frame reproduces the old level EXACTLY — whatever the new envelope does // new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
// (Gate attack, zero attack, Trigger's no-fade-in instant-unity onset) and whatever // restart whose new amplitude was instantly ~1 got zero compensation and kept the full
// value the new sample starts on. [Rev 1 seeded the OLD value here and gated the add by // click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
// (1 newAmp) in the epilogue: every restart whose new amplitude was instantly ~1 got // deliberately not zeroed here: a second same-block takeover (two steals with no frame
// ZERO compensation and kept the full click — exactly the DAW-reported mono-retrig case // rendered between) must record the same pre-cut reference, not a phantom 0.
// 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.
if (declickTakeover && active_) { if (declickTakeover && active_) {
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing. // 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_; 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; releasing_ = false;
amplitudeDone_ = false; amplitudeDone_ = false;
note_ = note; note_ = note;
// S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the // Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
// per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The // velocityGain_.
// 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.
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity)); velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed // Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
// read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is // amount both derive from it below).
// the pre-S-VIEW-6 pitchRatio bit-for-bit.
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack); baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
sample_ = &sample; sample_ = &sample;
@@ -328,25 +309,17 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
playMode_ = p.playMode; playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine; pitchEngine_ = p.pitchEngine;
// Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp // Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
// into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than // rather than starting a voice already off the end.
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size()); const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
std::int64_t start = sample.startFrame; std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0; if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start); readPos_ = static_cast<double>(start);
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset) 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 // Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr,
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length. // resolved to frames from stored seconds at reload time); 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. ---
if (playMode_ == PlayMode::Gate) { if (playMode_ == PlayMode::Gate) {
env_.configure(p.adsr); env_.configure(p.adsr);
env_.noteOn(); env_.noteOn();
@@ -366,38 +339,34 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
kDefaultFadeCurve); kDefaultFadeCurve);
} }
// --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). ---
pitchEnv_.configure(p.pitchEnv); pitchEnv_.configure(p.pitchEnv);
pitchEnv_.noteOn(); pitchEnv_.noteOn();
// --- Preserve engine (S16, GA2 onset fix): PRIME the ALREADY-SIZED per-channel shifters // Prime the already-sized per-channel shifters with the first window of the actual
// with the first window of the ACTUAL upcoming source stream (loop-unrolled under the // upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
// sustain-loop wrap rule, silence past the sample end — that silence IS the true // the sample end, since that silence is the true stream there). The tap parks on source
// stream there). The tap parks on source frame `start`, so the voice speaks on output // frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
// frame 0 at EVERY ratio (no ring-fill silence), and every splice has a full window // has a full window of real history to land in — a silence-warmed ring instead makes
// of real history to land in — the fix for the DAW onset zero-gaps (a silence-warmed // every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
// ring made every early splice jump into zeros). The rings and the prime scratch were // allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
// allocated off-thread by presizePreserveShifters (the engine calls it at // allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
// construction); this path is a bounded copy — NO allocation here. Varispeed voices // no per-frame shifter cost.
// never touch the shifters (advanceFrame checks configured()), so a Varispeed
// instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
const std::int64_t w = shiftL_.window(); const std::int64_t w = shiftL_.window();
const bool loopWrap = sustainLoopUsable(); const bool loopWrap = sustainLoopUsable();
const SampleLoop& loop = sample.loop; const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0; const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured(); 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 // The prime may only carry playable source. The per-frame feed stops at feedBound
// feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and // (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
// freezes the writer there (GA3) — but the prime used to pull a FULL window bounded // writer there — but a full window bounded only by frameCount would let a Trigger
// only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an // ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
// up-shifted tap could play it, transposed, before the voice freed), and a // transposed, before the voice freed), and a shorter-than-window sample would get
// shorter-than-window sample got zero padding declared as valid history (splices // zero padding declared as valid history (splices landing in silence). So bound the
// landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window // prime by the same playable span and, when that span is shorter than a window,
// material). So bound the prime by the same playable span and, when that span is // freeze the tail immediately after the prime that machinery then recycles the
// shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3 // real short tail. The sustain-loop path is unbounded by construction (the wrap
// machinery then recycles the real short tail, its designed behavior. The sustain- // keeps q inside the loop forever).
// loop path is unbounded by construction (the wrap keeps q inside the loop forever).
const std::int64_t primeBound = const std::int64_t primeBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? 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); (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
if (ch == 0) p = q; // capture the end position once from channel 0's walk 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 // 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). // playable span).
feedPos_ = p; feedPos_ = p;
if (!loopWrap && primeCount < w) { 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(); shiftL_.freezeTail();
if (stereoSample) shiftR_.freezeTail(); if (stereoSample) shiftR_.freezeTail();
} }
@@ -447,28 +416,26 @@ void Voice::retune(int note, int rootNote, double keyTrack) {
void Voice::release() { void Voice::release() {
if (!active_) return; if (!active_) return;
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length. if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
if (playMode_ == PlayMode::Trigger) return;
releasing_ = true; releasing_ = true;
env_.noteOff(); env_.noteOff();
} }
void Voice::hardStop() { void Voice::hardStop() {
// CC 120 (All Sounds Off): immediate silence regardless of play mode. Stops Trigger one-shots // Immediate silence regardless of play mode: stops Trigger one-shots that ignore
// that ignore release(), and short-circuits Gate release tails. RT-safe: no allocation. // release(), and short-circuits Gate release tails. RT-safe: no allocation.
active_ = false; active_ = false;
} }
double Voice::tickAmplitude() { double Voice::tickAmplitude() {
double amp; double amp;
if (playMode_ == PlayMode::Gate) { if (playMode_ == PlayMode::Gate) {
// AHDSR is wall-clock (one tick per output frame), independent of the read rate.
amp = env_.tick(); amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true; if (env_.finished()) amplitudeDone_ = true;
} else { } else {
// Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades // Anchored to the source offset so fades land on the same source frames under either
// land on the same source frames under either engine's read rate. The voice ALSO frees on // engine's read rate. The voice also frees on readPos_ >= playEnd_ in advanceFrame;
// readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders. // finished() here is the belt to that suspenders.
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_)); amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
if (trigEnv_.finished()) amplitudeDone_ = true; if (trigEnv_.finished()) amplitudeDone_ = true;
} }
@@ -476,34 +443,26 @@ double Voice::tickAmplitude() {
} }
void Voice::seedDeclick(double newOutL, double newOutR) { 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 // 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 // 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 // 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. // is impossible even if outCurrent rises while the weight is still significant. (An
// [Rev 1 stored the frozen difference (ref x₀); if outₙ rose while that residue was // earlier revision stored the frozen difference (ref x₀), which could exceed full scale
// still large the sum could exceed full scale. The ±2.0 clamp there was the only guard // if outₙ rose while that residue was still large.)
// 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.
(void)newOutL; (void)newOutR; // consumed only for the floor guard below (void)newOutL; (void)newOutR; // consumed only for the floor guard below
declickPending_ = false; declickPending_ = false;
declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state) declickWeight_ = 1.0; // one weight for both channels
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp // ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
// on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here. // if ref ≈ 0 there is nothing to blend.
// Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend.
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor || declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor); declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
} }
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap, // Shared read/advance for the mono and stereo paths: the read-head geometry is computed
// bracketing indices, interpolation partner) is computed ONCE and applied identically to // once and applied identically to every channel — only the PCM value read differs. The
// every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE // amplitude + pitch envelopes tick once per frame and scale all channels equally.
// 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.
if (!active_ || sample_ == nullptr) { if (!active_ || sample_ == nullptr) {
if (stereo) outR = 0.0f; if (stereo) outR = 0.0f;
return 0.0f; return 0.0f;
@@ -516,10 +475,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
const bool haveR = stereo && sample_->channelCount() == 2; const bool haveR = stereo && sample_->channelCount() == 2;
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm; const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
// Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a // Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A valid,
// valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap // non-zero-length loop wraps the read head back into [start, end); a zero-length loop is
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the // "no loop". Under Preserve the loop is over the source read (loop the source, shift the
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract). // output).
const SampleLoop& loop = sample_->loop; const SampleLoop& loop = sample_->loop;
const bool loopUsable = sustainLoopUsable(); const bool loopUsable = sustainLoopUsable();
if (loopUsable) { 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 frees once the read head reaches playEnd; the envelope also finishes at the
// trigger envelope also finishes at the same frame count; either latches the voice idle. // same count, either latches idle.
const bool triggerRanOff = const bool triggerRanOff =
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_); playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
// Ran off the sample end with no usable loop -> voice is done. Peer path of the // Ran off the sample end with no usable loop -> voice is done, except an in-flight
// epilogue: an in-flight takeover declick RINGS OUT here instead of hard-cutting — // takeover declick rings out here instead of hard-cutting — dropping it would
// dropping it would re-introduce a step on exactly the path the ramp exists for (a // re-introduce a step on exactly the path the ramp exists for (a restart whose new play
// restart whose new play span ends within the ~4 ms ramp). The voice stays active only // span ends within the ramp). With no declick (the common case) this is byte-identical
// until the ramp floors; with no declick (the common case, and the entire opt-out // to the plain idle-out.
// baseline) this is byte-identical to the plain idle-out.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) { if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
if (declickActive_) { if (declickActive_) {
@@ -561,41 +519,35 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
return 0.0f; 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 amp = tickAmplitude();
const double gain = amp * velocityGain_; const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick(); const double pitchEnvSemis = pitchEnv_.tick();
// The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0) // 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the pow
// this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read // entirely — no per-frame transcendental on the common path.
// (no per-frame transcendental), byte-identical to pre-S16.
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0); const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
double outL, outRlocal = 0.0; double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// PRESERVE: feed the shifters the SOURCE stream at unity rate (duration held) and // Feed the shifters the source stream at unity rate (duration held) and transpose the
// TRANSPOSE the output by 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to // output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the shift
// the shift amount, not the read rate — pitch bends, duration unchanged (S16 // amount, not the read rate. The feed runs one window ahead of readPos_ (the rings
// contract). The feed runs one window AHEAD of readPos_ (the rings were primed with // were primed with that window at start()), under the same sustain-loop wrap rule,
// that window at start()), under the SAME sustain-loop wrap rule as the anchor, and // reading integer source frames (nothing to interpolate). Past the last real frame
// reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so // the shifter's writer is frozen — it recycles the real tail it already holds.
// 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.
if (loopUsable) { if (loopUsable) {
const std::int64_t loopLen = loop.end - loop.start; const std::int64_t loopLen = loop.end - loop.start;
while (feedPos_ >= loop.end) feedPos_ -= loopLen; while (feedPos_ >= loop.end) feedPos_ -= loopLen;
} }
// GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one // feedPos_ runs one window ahead of readPos_; the last real source frame is
// window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the // playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
// user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_ // the source is exhausted — feeding the held last sample instead would give the
// reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here, // splice correlation a DC plateau it can't align on (periodic troughs at the splice
// a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic // cadence, growing toward the note end). Freezing the shifter's writer means no
// troughs at the splice cadence, growing toward the note end as the plateau displaced // padding ever enters the ring, so the splice machinery keeps recycling the frozen
// real ring history). Instead FREEZE the shifter's writer: no padding ever enters the // all-real tail — a continuous tone through the voice's own end. The sustain-loop
// ring, and the splice machinery keeps recycling the frozen all-real tail, every jump // path never gets here: the wrap above keeps feedPos_ < loop.end forever.
// 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.
const std::int64_t feedBound = const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount; ? playEnd_ : frameCount;
+226 -346
View File
@@ -1,142 +1,101 @@
#pragma once #pragma once
// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately // sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR
// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW // amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and
// and outside any plugin host. It owns the pure sampler engine: polyphonic voice // repitch/interpolation from a root note with loop-point-aware sustain.
// 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.
// //
// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL, // Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points)
// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell // enter as plain int/frame-index inputs; the core does no file I/O.
// (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.
#include <array> #include <array>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "core/audio/peaks.h" // AudioSample (float) #include "core/audio/peaks.h"
#include "core/instrument/engine/zone_params.h" // per-zone play params + mode enums (Q-W2v header split) #include "core/instrument/engine/zone_params.h"
#include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) #include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) #include "core/instrument/engine/velocity_curve.h"
namespace reasampler { 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 audio::AudioSample;
using instrument::engine::PitchShifter; using instrument::engine::PitchShifter;
using instrument::engine::VelocityCurve; using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint; using instrument::engine::VelocityPoint;
// The per-zone play-parameter VALUE STRUCTS + per-instance mode enums (ChannelMode / // Keymap — the performance map. A note+velocity resolves to at most one zone; a zone
// VoiceMode / MonoTrigger, AdsrParams / TriggerParams / PitchEnvParams / ZonePlayParams, // names which SampleData to play and the root note to repitch from. Tier-0 degenerate
// SampleLoop / SampleData, and their constants) live in zone_params.h (Q-W2v header // case: a single zone spanning [0,127] with the sample's own root. Tier-1: several
// split, T4-14/T4-17) so param-reading TUs stop recompiling on engine-class edits. // zones, each a key range with its own root.
// ---------------------------------------------------------------------------
// 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.
// //
// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built: // Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone
// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone // today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range,
// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and // sampleIndex) layers, and resolve() would gain the velocity dimension it already
// resolve() gains the velocity dimension it already receives but currently ignores // receives but currently ignores for selection — no signature change needed.
// 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.
// ---------------------------------------------------------------------------
// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with // A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root
// the root note to repitch from (defaults to the sample's own root, overridable in // note to repitch from (defaults to the sample's own root, overridable per zone).
// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a // velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127
// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention). // velocity range (0 is note-off by MIDI convention).
struct KeyZone { struct KeyZone {
int lowNote = 0; int lowNote = 0;
int highNote = 127; int highNote = 127;
int rootNote = 60; // repitch reference for this zone 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 // How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 =
// standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every // no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root)
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset // semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_.
// in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
double keyTrack = 1.0; double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp // Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
// gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror // (never per frame). Default flat y=1 — every velocity plays at unity.
// 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.
VelocityCurve velocityCurve = VelocityCurve::flat(); VelocityCurve velocityCurve = VelocityCurve::flat();
std::size_t sampleIndex = 0; // index into Keymap::samples std::size_t sampleIndex = 0; // index into Keymap::samples
}; };
// Result of resolving a (note, velocity). `matched == false` means the note falls in // `matched == false` means the note falls in no zone — a defined no-play result, not an
// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0. // error and not voice 0.
struct ZoneResolution { struct ZoneResolution {
bool matched = false; bool matched = false;
std::size_t zoneIndex = 0; // valid only when matched std::size_t zoneIndex = 0; // valid only when matched
}; };
// The keymap: the decoded samples plus the zones that map keys onto them. Owns // Decoded samples plus the zones that map keys onto them. Zones are tested first-match
// resolution. Pure: no host types. Zones are tested first-match in order, so an // in order, so an earlier zone wins an overlap (deterministic, documented).
// earlier zone wins an overlap (deterministic, documented).
struct Keymap { struct Keymap {
std::vector<SampleData> samples; std::vector<SampleData> samples;
std::vector<KeyZone> zones; std::vector<KeyZone> zones;
// Resolves (note, velocity) to a zone. First zone (in order) whose [low,high] // First zone (in order) whose [low,high] contains `note` wins. velocity is accepted
// contains `note` wins. velocity is accepted now (Tier-2 seam) but does not // (Tier-2 seam) but doesn't affect zone choice at Tier 0-1.
// affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains
// the note.
ZoneResolution resolve(int note, int velocity) const; ZoneResolution resolve(int note, int velocity) const;
// Convenience: build the Tier-0 degenerate keymap one sample mapped // The Tier-0 degenerate keymap: one sample mapped chromatically across the whole
// chromatically across the whole keyboard from its own root note. // keyboard from its own root note.
static Keymap singleSampleChromatic(SampleData sample); 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. Pure equal-temperament; no
// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0, // reference-frequency needed.
// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed.
double pitchRatio(int note, int rootNote); double pitchRatio(int note, int rootNote);
// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The // 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before
// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how // the ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
// far playback pitch tracks the keyboard around the root: // ((note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call); 0.0 means every
// keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) — // key plays the root pitch; 2.0 doubles the tracking rate. At the root note the offset is
// (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call). // 0 regardless of keyTrack. Both repitch engines derive from it via the voice's baseRatio_.
// 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_.
double keyTrackedRatio(int note, int rootNote, double keyTrack); double keyTrackedRatio(int note, int rootNote, double keyTrack);
// --------------------------------------------------------------------------- // AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based // noteOn() enters Attack; noteOff() enters Release from wherever it is.
// (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).
// //
// Segment math (all linear ramps): // Segment math:
// Attack: 0 -> 1 over attackFrames // 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 // Decay: 1 -> sustainLevel over decayFrames
// Sustain: hold sustainLevel until noteOff // Sustain: hold sustainLevel until noteOff
// Release: currentLevel -> 0 over releaseFrames // Release: currentLevel -> 0 over releaseFrames
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold // 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); // entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain) // during attack/hold/decay releases from the current partial level, not from sustainLevel.
// 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.
// ---------------------------------------------------------------------------
class AdsrEnvelope { class AdsrEnvelope {
public: public:
@@ -167,30 +126,22 @@ private:
double releaseFrom_ = 0.0; // level at the moment noteOff() was called double releaseFrom_ = 0.0; // level at the moment noteOff() was called
}; };
// --------------------------------------------------------------------------- // A stateless-shape amplitude function over the play span, evaluated at a source-frame
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams / // offset into the span (not output frames): under Varispeed a transposed voice consumes
// FadeCurve value structs are defined above with the other per-zone params. // 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.
// 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.
class TriggerEnvelope { class TriggerEnvelope {
public: public:
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the // `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength // fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
// (fadeOut anchored to the end). A zero/negative play length finishes immediately. // length finishes immediately.
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames, void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve); std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play // Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over // or past playLength. Pure over the offset so it composes with either pitch engine's
// the offset (no internal advance) so it composes with either pitch engine's read rate. // read rate.
double amplitudeAt(double sourceOffset); double amplitudeAt(double sourceOffset);
bool finished() const { return finished_; } bool finished() const { return finished_; }
@@ -203,21 +154,14 @@ private:
bool finished_ = false; bool finished_ = false;
}; };
// --------------------------------------------------------------------------- // tick() returns the current pitch offset in semitones (0 when disabled or past
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs // attack+decay), advancing one frame. The voice converts it to a ratio multiply
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above. // (Varispeed) or a shift-amount add (Preserve).
// ---------------------------------------------------------------------------
// 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.
class PitchEnvelope { class PitchEnvelope {
public: public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; } void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
void noteOn() { pos_ = 0; } void noteOn() { pos_ = 0; }
// Advance one frame, return this frame's pitch offset in semitones.
double tick(); double tick();
private: private:
@@ -225,29 +169,19 @@ private:
std::int64_t pos_ = 0; std::int64_t pos_ = 0;
}; };
// Takeover declick (Phase S GA fix, rev 2 — audible click when a sounding voice is // Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a
// restarted). A takeover restart HARD-CUTS the sounding tone: the read head and envelope // cross-sample legato restart, or a poly at-cap steal) hard-cuts the old tone in one
// restart in one frame, a step discontinuity that clicks. This is the same physics on EVERY // frame a step discontinuity that clicks. When the caller opts in (start()'s
// restart-of-a-sounding-voice path — the MONO Retrigger takeover/fallback, the mono // declickTakeover), start() records the last rendered output as a pre-cut reference, and
// cross-sample legato restart, and the POLY at-cap voice steal (the editor's preview is a // the first frame after the restart seeds a compensation equal to
// plain engine noteOn since the PreviewCard retirement, so a preview re-fire at cap is just // (reference - that frame's raw new output), summed in ungated and decaying by
// an at-cap steal). When the caller opts in (start()'s declickTakeover; the engine passes // kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless
// it on all of those restart paths when constructed with takeoverDeclick), // of the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
// the restart smooths the ACTUAL output discontinuity: start() records the last rendered // An earlier revision gated the compensation by (1 - newAmp): any restart whose new
// output as the pre-cut reference, and the FIRST frame rendered after the restart seeds a // amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
// compensation equal to (reference that frame's raw new output). The compensation is // compensation and kept the full click — the difference-seed has no such hole. Off by
// summed into the output UNGATED and decays by kDeclickDecay per frame, so the boundary // default so the bare core stays byte-identical to the pre-fix engine; the processor
// frame reproduces the old level EXACTLY — zero step whatever the new envelope's first // shell opts in.
// 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.
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation 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) 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 { class Voice {
public: public:
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference // Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it),
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched // repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from
// from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from // sample.play (frames, resolved from stored seconds at keymap build). Preserve shifters
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by // must already be pre-sized (presizePreserveShifters, off-thread) — start() only
// buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE + // reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio thread
// Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`. // inside process(); the warm silence pass settles the OLA taps before the first output
// The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) — // frame. Byte-identical to the bare engine when sample.play is default.
// start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio // `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is
// thread inside process(). The warm silence pass settles the OLA taps before the first // standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once
// output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play // here (off the per-frame path); defaults to flat y=1. `declickTakeover`: when true and
// is default (Gate + Varispeed + no pitch env). // this voice is currently active (a takeover/steal restart, not a fresh start), arms the
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio; // difference-seeded declick compensation on the first frame after the restart (see
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_. // kDeclickDecay above). A fresh start never declicks.
// `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.
void start(int note, int velocity, const SampleData& sample, int rootNote, void start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack = 1.0, double keyTrack = 1.0,
const VelocityCurve& velocityCurve = VelocityCurve::flat(), const VelocityCurve& velocityCurve = VelocityCurve::flat(),
bool declickTakeover = false); bool declickTakeover = false);
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the // Mono legato takeover: re-pitch this active voice to `note` without touching the
// amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack. // amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
// Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate, // engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. Caller
// Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees // guarantees the voice is playing the same SampleData the resolved zone names — a
// the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample // cross-sample takeover must restart the voice instead.
// takeover must restart the voice instead (see MonoTrigger).
void retune(int note, int rootNote, double keyTrack = 1.0); 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 // Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length). // ignores note-off and plays through to its play length).
void release(); void release();
// HARD STOP — CC 120 (All Sounds Off) semantics. Immediately silences this voice regardless // Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
// of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot // no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
// instantly (which release() cannot do). RT-safe: no allocation, no lock. // RT-safe: no allocation, no lock.
void hardStop(); void hardStop();
// True while this voice is producing (or about to produce) sound (including any // True while producing (or about to produce) sound, including any declick ring-out
// declick ring-out tail past the note's playable span). // tail past the note's playable span.
bool active() const { return active_; } bool active() const { return active_; }
// True while this voice is sounding a PLAYABLE NOTE — active AND the amplitude // True while sounding a playable note — active and the amplitude envelope hasn't
// envelope has not yet finished. A voice whose note has run to its end but is still // finished. A voice ringing out a declick tail past note end is active() but not
// ringing out a declick tail is active() but NOT soundingNote(). Use this to // soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
// distinguish "note is alive" (active) from "note occupies a voice slot" (soundingNote) // ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
// 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.
bool soundingNote() const { return active_ && !amplitudeDone_; } 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_; } int note() const { return note_; }
// Monotonic age counter — higher = started earlier relative to others. The voice // Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
// engine uses this for its stealing policy (oldest first). Set by the engine.
std::uint64_t startOrder() const { return startOrder_; } std::uint64_t startOrder() const { return startOrder_; }
void setStartOrder(std::uint64_t order) { startOrder_ = order; } void setStartOrder(std::uint64_t order) { startOrder_ = order; }
bool releasing() const { return releasing_; } bool releasing() const { return releasing_; }
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only // The 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 — // meaningful while active().
// 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).
PitchEngine pitchEngine() const { return pitchEngine_; } PitchEngine pitchEngine() const { return pitchEngine_; }
// The SampleData this voice is playing (nullptr when never started). The engine's mono // Identity only, never mutated through; the engine's mono legato path compares it
// legato path compares it against the new note's resolved sample — a same-sample takeover // against the new note's resolved sample to decide retune vs. restart.
// retunes; a cross-sample one restarts. Identity only; callers never mutate through it.
const SampleData* playingSample() const { return sample_; } const SampleData* playingSample() const { return sample_; }
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the // Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
// audio thread (this allocates; also sizes the prime scratch buffer). The engine calls it // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
// once at construction so start() — which runs on the audio thread inside process() — never // runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
// allocates: start() only prime()s the already-sized rings with the first window of source // Idempotent: a re-presize to the same window is a cheap no-op.
// (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.
void presizePreserveShifters(std::int64_t windowFrames); void presizePreserveShifters(std::int64_t windowFrames);
// Renders one frame's contribution, advancing the read head and envelope by one // Renders one frame's contribution, advancing the read head and envelope by one output
// output frame. Returns 0.0 (and goes idle) once the envelope finishes or the // frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
// sample runs out with no loop. The value is already velocity- and // with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
// envelope-scaled — the engine sums voices directly. This is the MONO path (channel // Mono path (channel 0 only).
// 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged.
AudioSample renderFrame(); AudioSample renderFrame();
// STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances // Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
// the read head + envelope by exactly one frame (the same single advance the mono path // envelope by exactly one frame (the envelope ticks once per frame, shared across both
// performs — the envelope ticks ONCE per frame, shared across both channels). For a mono // channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
// sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered). // on the same conditions as the mono path, writing 0 to both.
// 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.
void renderFrameStereo(AudioSample& l, AudioSample& r); void renderFrameStereo(AudioSample& l, AudioSample& r);
private: private:
// Shared read/advance for both render paths: computes the interpolated per-channel // 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 // 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 // the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and // whether the second channel is read (into `outR`). Returns the channel-0 value.
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
AudioSample advanceFrame(bool stereo, AudioSample& outR); AudioSample advanceFrame(bool stereo, AudioSample& outR);
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per // This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the // output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to // shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope // source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice. // advanceFrame frees the voice.
double tickAmplitude(); double tickAmplitude();
// True when the sustain loop applies to this voice: GATE mode with a valid, non-empty loop // True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
// inside the sample (S15 — Trigger one-shots never loop). The single source of truth for // sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
// the wrap rule shared by the output anchor (readPos_), the Preserve feed (feedPos_), and // by the output anchor, the Preserve feed, and the start()-time ring prime.
// the start()-time ring prime.
bool sustainLoopUsable() const; bool sustainLoopUsable() const;
bool active_ = false; bool active_ = false;
@@ -379,13 +289,13 @@ private:
int note_ = 0; int note_ = 0;
double velocityGain_ = 1.0; double velocityGain_ = 1.0;
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio 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 double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr; const SampleData* sample_ = nullptr;
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only // Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame // playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle). // readPos_ >= playEnd_).
PlayMode playMode_ = PlayMode::Gate; PlayMode playMode_ = PlayMode::Gate;
AdsrEnvelope env_; AdsrEnvelope env_;
TriggerEnvelope trigEnv_; TriggerEnvelope trigEnv_;
@@ -393,20 +303,17 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve // pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel // shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
// //
// GA2 onset fix: the shifter rings are PRIMED at start() with the first window of the // The shifter rings are primed at start() with the first window of the actual upcoming
// actual upcoming source (loop-unrolled, silence past the end) — output frame 0 is source // source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
// frame `start`, no ring-fill silence, and splices always land in real history. feedPos_ // silence, and splices always land in real history. feedPos_ is the integer source frame
// is the integer SOURCE frame the shifters are fed next; it runs exactly one window AHEAD // fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
// of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule. // sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
// GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end; // Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
// Trigger: playEnd_) the shifters' writers are FROZEN — no padding enters the rings and // splice machinery recycles the frozen real tail through the note end (see advanceFrame).
// the splice machinery recycles the frozen real tail through the note end (see // primeBuf_ is the presized scratch the prime stream is assembled into.
// advanceFrame). primeBuf_ is the presized scratch the prime stream is assembled into
// (never touched outside start()).
PitchEngine pitchEngine_ = PitchEngine::Varispeed; PitchEngine pitchEngine_ = PitchEngine::Varispeed;
PitchEnvelope pitchEnv_; PitchEnvelope pitchEnv_;
PitchShifter shiftL_; PitchShifter shiftL_;
@@ -414,27 +321,24 @@ private:
std::int64_t feedPos_ = 0; std::int64_t feedPos_ = 0;
std::vector<AudioSample> primeBuf_; std::vector<AudioSample> primeBuf_;
// Seeds the takeover compensation on the FIRST frame after a restart: the ramp is the // 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) — // 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 // applied ungated so the boundary frame reproduces the old level exactly.
// takeover-declick block above kDeclickDecay.
void seedDeclick(double newOutL, double newOutR); void seedDeclick(double newOutL, double newOutR);
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's most // lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start() // records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
// records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_; // restart calls seedDeclick to arm the bounded blend:
// the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND: // outₙ = outₙ*(1w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
// outₙ = outₙ*(1w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared // L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
// by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy) // Algebraically outₙ + w*(ref outₙ), so the boundary frame (w=1) is exactly `ref` and
// starts at 1.0 and decays by // every subsequent output is bounded by max(|ref|, |outₙ|) — mid-ramp overshoot is
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref outₙ)`, so the // impossible regardless of outₙ rising. (An earlier revision stored the frozen difference
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by // (ref x₀); when outₙ rose while that residue was still large, the sum could exceed
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising. // full scale by several dB.)
// [Rev 1 stored the frozen difference (ref x₀); when outₙ rose while that residue // lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
// was still large the sum could exceed full scale by up to ~+3.8 dB.] // between) must record the same pre-cut reference, not a phantom 0. The whole declick
// lastOut is NOT zeroed by start() — a second same-block takeover (no frame rendered // state is cleared on a fresh (non-takeover) start.
// 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 declickPending_ = false;
bool declickActive_ = false; bool declickActive_ = false;
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target) double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
@@ -446,50 +350,40 @@ private:
std::uint64_t startOrder_ = 0; std::uint64_t startOrder_ = 0;
}; };
// --------------------------------------------------------------------------- // The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with // voice stealing, note-off routing, and block rendering (sum of voices).
// bounded voice stealing, note-off routing, and block rendering (sum of voices).
// //
// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a // Voice-stealing policy (deterministic, documented): when all voices are busy and a new
// new note-on arrives, steal in this priority order: // note-on arrives, steal in this priority order:
// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut), // 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). // 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 // "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing // hardware-sampler policy.
// that, the note that has already had the most time.
// ---------------------------------------------------------------------------
class VoiceEngine { class VoiceEngine {
public: public:
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from // Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it // engine — held by reference, never copies PCM). Play params ride on each zone's
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R) // SampleData::play; the engine holds no instrument-wide ADSR.
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved // `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
// 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
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is // 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 // dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
// maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's // `preserveWindowFrames` is the OLA window every voice's Preserve shifters are
// Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so // pre-sized to at construction (off the audio thread), so note-on never allocates; 0
// note-on (which runs in process()) never allocates; 0 leaves them pass-through (a // leaves them pass-through. The processor derives it from the host sample rate.
// 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.
// //
// `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single // `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
// voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger` // (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 // (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a
// takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The // same-sample takeover without a re-attack). The engine's config is immutable — a
// engine's config is immutable — a mode/count change rebuilds the engine off-thread through // mode/count change rebuilds the engine off-thread through the processor's drain-slot
// the processor's drain-slot reload, so ringing tails survive the swap. // reload, so ringing tails survive the swap.
// //
// `takeoverDeclick` (Phase S GA fix): when TRUE, every RESTART of a SOUNDING voice — // `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
// the MONO Retrigger takeover, the retrigger fallback on note-off, the cross-sample // takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the
// legato restart, and the POLY at-cap voice STEAL — seeds the per-voice declick ramp // per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start()
// (see kDeclickDecay) so the hard cut of the old tone does not click. start() self-gates // self-gates on the voice being active, so a fresh start never ramps. Default false
// 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; the processor shell opts in.
// 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.
VoiceEngine(std::size_t maxVoices, const Keymap& keymap, VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly, VoiceMode voiceMode = VoiceMode::Poly,
@@ -507,42 +401,32 @@ public:
// the older tail to ring — matches hardware behavior). No-op if none match. // the older tail to ring — matches hardware behavior). No-op if none match.
void noteOff(int note); void noteOff(int note);
// CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice // CC 123 (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 // (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
// through their bounded play length). This is the mono stack's ONLY reset path — a phantom // stack's only reset path — a phantom entry left by a lost note-off would otherwise be
// entry left by a lost note-off would otherwise be resurrected by the fallback and sustain // resurrected by the fallback and sustain forever with no key held. RT-safe.
// forever with no key held. RT-safe (no allocation, bounded by maxVoices).
void allNotesOff(); void allNotesOff();
// CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no // CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
// release ramp), clears the MONO held stack, and silences even Trigger one-shots that would // stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
// ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior. // the softer "let gates release." RT-safe, callable from the audio thread.
// RT-safe (no allocation, bounded by maxVoices); callable from the audio thread.
void allSoundsOff(); void allSoundsOff();
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer // Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes — // to whatever is there — never allocates (the audio-thread entry point; the VST3
// this never touches memory it does not own and NEVER allocates). This is the // process callback passes the host's own output buffer). Voices that finish mid-block
// audio-thread entry point: the VST3 process callback passes the host's own output // go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
// 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.
void render(AudioSample* out, std::size_t frameCount); void render(AudioSample* out, std::size_t frameCount);
// REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two // Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
// buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there // sample plays dual-mono (same value both channels); a stereo sample plays its two
// (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no // channels. Mono and stereo render are independent output shapes over the same voice
// resize, no lock. A mono sample plays dual-mono (same value to both channels, centered); // pool — the active channel mode picks which one the process callback drives per block.
// 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.
void render(AudioSample* left, AudioSample* right, std::size_t frameCount); void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
// TEST / off-thread convenience: appends `frameCount` summed frames to `out` // Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
// (grows it — DO NOT call on the audio thread; it allocates). Delegates to the // do not call on the audio thread). Delegates to the real-time overload after sizing
// real-time overload after sizing the buffer, so both paths share one mix loop. // the buffer. Does not clear existing contents — appends.
// Does not clear existing contents — appends, matching the pre-S4 contract the
// unit tests rely on.
void render(std::vector<AudioSample>& out, std::size_t frameCount); void render(std::vector<AudioSample>& out, std::size_t frameCount);
// Count of currently active voices (for tests / diagnostics). // 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). // one per the documented policy. Always returns a valid index (maxVoices >= 1).
std::size_t allocateVoice(); 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. // (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const; std::size_t activePreserveVoices() const;
// --- MONO mode (Phase S): last-note priority over a held-note stack ------------ // Mono mode: last-note priority over a held-note stack. The stack holds every
// The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most // currently-held, zone-resolving note in press order (top = most recent = the sounding
// recent = the sounding note while the voice is gated). An out-of-zone note never joins // note). An out-of-zone note never joins (it cannot sound, so it must not later take
// (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing // the voice back on a fallback). Re-pressing a held note moves it to the top.
// a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no // Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread.
// allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback // Velocity is kept per held note so a retrigger fallback re-strikes at its original
// re-strikes the fallen-back-to note at ITS original velocity. // velocity.
struct HeldNote { std::uint8_t note; std::uint8_t 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 // Push to the stack and take the voice over (legato retune on a same-sample takeover,
// takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone // else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or
// or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8). // out-of-range (rejected before the stack, which stores uint8). The Preserve cap is
// The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter, // not applied in mono — a single voice runs at most one shifter, inherently within any
// inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover. // cap; applying it would wrongly drop a Preserve->Preserve takeover.
std::size_t monoNoteOn(int note, int velocity); 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 // Pop from the stack; if the released note was sounding, fall back to the most-recent
// most-recent still-held note (retrigger or legato per monoTrigger_), else release. // still-held note (retrigger or legato per monoTrigger_), else release.
void monoNoteOff(int note); void monoNoteOff(int note);
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent. // Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
void removeHeld(int note); void removeHeld(int note);
std::vector<Voice> voices_; std::vector<Voice> voices_;
const Keymap& keymap_; 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" std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
VoiceMode voiceMode_ = VoiceMode::Poly; VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; 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<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1 std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
std::size_t heldCount_ = 0; std::size_t heldCount_ = 0;
}; };
// NOTE (preview redesign): the Phase S PreviewCard — a dedicated preview voice isolated // The editor's preview trigger is a synthetic note-on at the loaded capture's root note
// from the MIDI pool — is RETIRED. The editor's preview trigger is now a synthetic note-on // through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
// at the loaded capture's root note through the SAME VoiceEngine host MIDI drives, so a // against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
// preview is a real voice: it counts against the voice count, can steal / be stolen, and // There is no dedicated preview voice isolated from the MIDI pool.
// 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.
} // namespace reasampler } // namespace reasampler
+28 -56
View File
@@ -2,20 +2,19 @@
#include "core/instrument/engine/velocity_curve.h" #include "core/instrument/engine/velocity_curve.h"
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort #include <algorithm>
#include <cmath> // std::fabs #include <cmath>
#include <utility> // std::move #include <utility>
namespace reasampler::instrument::engine { namespace reasampler::instrument::engine {
namespace { namespace {
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); } double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); } 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 // X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP
// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward). // (pixel y increases downward, so this axis is inverted relative to amp).
double velPerPixel(const VelocityCurve::Box& box) { double velPerPixel(const VelocityCurve::Box& box) {
const int w = std::max(0, box.width); const int w = std::max(0, box.width);
if (w <= 0) return 0.0; 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) { int ampToY(const VelocityCurve::Box& box, double amp) {
const int h = std::max(0, box.height); const int h = std::max(0, box.height);
if (h <= 1) return box.top; 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); const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5); return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
} }
@@ -44,19 +42,19 @@ int ampToY(const VelocityCurve::Box& box, double amp) {
VelocityCurve VelocityCurve::flat() { VelocityCurve VelocityCurve::flat() {
VelocityCurve c; VelocityCurve c;
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A) c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}};
return c; return c;
} }
VelocityCurve VelocityCurve::linear() { VelocityCurve VelocityCurve::linear() {
VelocityCurve c; VelocityCurve c;
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127 c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}};
return c; return c;
} }
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) { VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
// Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points // Stable sort so coincident-X points keep their wire order (eval stays well-defined for
// keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots. // duplicate-X knots).
for (VelocityPoint& p : pts) { for (VelocityPoint& p : pts) {
p.velocity = clampVelocity(p.velocity); p.velocity = clampVelocity(p.velocity);
p.amp = clampAmp(p.amp); p.amp = clampAmp(p.amp);
@@ -65,18 +63,16 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
[](const VelocityPoint& a, const VelocityPoint& b) { [](const VelocityPoint& a, const VelocityPoint& b) {
return a.velocity < b.velocity; 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(); 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) { if (pts.front().velocity > kVelMin) {
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp}); pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
} else { } else {
pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint pts.front().velocity = kVelMin;
} }
if (pts.back().velocity < kVelMax) { if (pts.back().velocity < kVelMax) {
pts.push_back(VelocityPoint{kVelMax, pts.back().amp}); pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
} else { } else {
pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint pts.back().velocity = kVelMax;
} }
VelocityCurve c; VelocityCurve c;
c.points_ = std::move(pts); c.points_ = std::move(pts);
@@ -85,19 +81,12 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
namespace { namespace {
// FritschCarlson monotone-cubic tangent for one interior knot i, given the secant slopes of the // Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the // so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range. // 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.
// 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 (FritschCarlson 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().
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) { 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 if (dPrev * dNext <= 0.0) return 0.0;
// 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.
const double w1 = 2.0 * spanNext + spanPrev; const double w1 = 2.0 * spanNext + spanPrev;
const double w2 = spanNext + 2.0 * spanPrev; const double w2 = spanNext + 2.0 * spanPrev;
return (w1 + w2) / (w1 / dPrev + w2 / dNext); return (w1 + w2) / (w1 / dPrev + w2 / dNext);
@@ -106,33 +95,23 @@ double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double
} // namespace } // namespace
double VelocityCurve::eval(double velocity) const { double VelocityCurve::eval(double velocity) const {
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity if (points_.empty()) return kAmpMax;
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp if (points_.size() == 1) return clampAmp(points_[0].amp);
const double v = clampVelocity(velocity); 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_.front().velocity) return clampAmp(points_.front().amp);
if (v >= points_.back().velocity) return clampAmp(points_.back().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) { for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
const VelocityPoint& a = points_[i]; const VelocityPoint& a = points_[i];
const VelocityPoint& b = points_[i + 1]; const VelocityPoint& b = points_[i + 1];
if (v >= a.velocity && v <= b.velocity) { if (v >= a.velocity && v <= b.velocity) {
const double span = b.velocity - a.velocity; const double span = b.velocity - a.velocity;
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment // Coincident-X neighbours (a step): zero-width segment, no interior to blend.
// has zero width so there is no interior to blend.
if (span <= 0.0) return clampAmp(b.amp); if (span <= 0.0) return clampAmp(b.amp);
// --- Monotone cubic Hermite (FritschCarlson) interpolation on segment [a,b] --------- // Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp]
// Curved (spline) response, not straight lines. The interpolant provably stays within // between the two knots (no overshoot), reproducing a straight line for collinear input.
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear const double d = (b.amp - a.amp) / span;
// 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
// 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; double mA = d;
if (i > 0) { if (i > 0) {
const VelocityPoint& prev = points_[i - 1]; const VelocityPoint& prev = points_[i - 1];
@@ -141,7 +120,7 @@ double VelocityCurve::eval(double velocity) const {
const double dPrev = (a.amp - prev.amp) / spanPrev; const double dPrev = (a.amp - prev.amp) / spanPrev;
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span); mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
} else { } else {
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent mA = 0.0;
} }
} }
double mB = d; double mB = d;
@@ -152,13 +131,10 @@ double VelocityCurve::eval(double velocity) const {
const double dNext = (next.amp - b.amp) / spanNext; const double dNext = (next.amp - b.amp) / spanNext;
mB = fritschCarlsonTangent(d, dNext, span, spanNext); mB = fritschCarlsonTangent(d, dNext, span, spanNext);
} else { } 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 t = (v - a.velocity) / span;
const double t2 = t * t; const double t2 = t * t;
const double t3 = t2 * 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) { std::size_t VelocityCurve::addPoint(double velocity, double amp) {
const VelocityPoint p{clampVelocity(velocity), clampAmp(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 // First index strictly greater, so a duplicate-X point lands immediately after the existing one.
// duplicate-X point lands immediately after the existing one (a later move can separate them).
std::size_t i = 0; std::size_t i = 0;
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i; while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p); points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
@@ -191,11 +166,10 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
double newAmp = clampAmp(amp); double newAmp = clampAmp(amp);
double newVel; double newVel;
if (isFirst) { if (isFirst) {
newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves newVel = kVelMin;
} else if (isLast) { } else if (isLast) {
newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves newVel = kVelMax;
} else { } 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 lo = points_[index - 1].velocity;
const double hi = points_[index + 1].velocity; const double hi = points_[index + 1].velocity;
newVel = std::clamp(clampVelocity(velocity), lo, hi); 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) { VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
// The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate // Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way.
// dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0),
// ampToY pins to box.top (amp 1).
VelocityPoint p; VelocityPoint p;
const int w = std::max(0, box.width); const int w = std::max(0, box.width);
const int h = std::max(0, box.height); const int h = std::max(0, box.height);
+39 -105
View File
@@ -1,43 +1,13 @@
// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO // velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on
// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the // in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box
// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future // rather than a Rect: this module sits below sampler_core in the link graph and must not gain
// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's // a transitive dependency on editor-layout types.
// 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.
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
// 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 { namespace reasampler::instrument::engine {
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into. // 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 kAmpMin = 0.0;
inline constexpr double kAmpMax = 1.0; inline constexpr double kAmpMax = 1.0;
// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped // A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so // through the named constructors / addPoint rather than pushing raw points.
// build curves through the named constructors / addPoint rather than pushing raw points.
struct VelocityPoint { struct VelocityPoint {
double velocity = 0.0; // X, [0,127] double velocity = 0.0; // X, [0,127]
double amp = 0.0; // Y, [0,1] double amp = 0.0; // Y, [0,1]
}; };
// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors // Pick radius (px) around a node's drawn point for the editor hit-test.
// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth.
inline constexpr int kCurveNodeGrabRadius = 6; inline constexpr int kCurveNodeGrabRadius = 6;
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by // An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
// a MONOTONE cubic Hermite spline (FritschCarlson slope limiting) through the knots — a genuine // spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each // overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope,
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within // so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the // 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable.
// FritschCarlson 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.
class VelocityCurve { class VelocityCurve {
public: 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(); 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(); static VelocityCurve linear();
// Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the // Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
// deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is // each point, stable-sorts by velocity, forces both endpoints present (synthesized if
// stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present // missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated
// (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list). // blob yields a well-formed curve, never an invariant-violating one.
// 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.
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts); static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
// The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant).
const std::vector<VelocityPoint>& points() const { return points_; } const std::vector<VelocityPoint>& points() const { return points_; }
std::size_t size() const { return points_.size(); } 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, // Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent // one-point curve returns that point's amp.
// points the amp follows a MONOTONE cubic Hermite spline (FritschCarlson 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.
double eval(double velocity) const; double eval(double velocity) const;
// --- Editing (for the S-VIEW-10 editor UI) -------------------------------------------------- // Inserted at a velocity duplicating an existing point lands immediately after it, so a
// Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the // subsequent move can separate them. Returns the inserted index.
// 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).
std::size_t addPoint(double velocity, double amp); std::size_t addPoint(double velocity, double amp);
// Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate // Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
// neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X // endpoints are pinned in X (only their amp moves); out-of-range index is a no-op.
// (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.
VelocityPoint movePoint(std::size_t index, double velocity, double amp); 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 // Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
// to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a // returning false.
// point was removed.
bool deletePoint(std::size_t index); bool deletePoint(std::size_t index);
// --- Editor hit-test + inverse map (mirror of envelope_edit) -------------------------------- // The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at
// The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall. // top). Passed explicitly rather than a Rect — see header preamble.
// 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.
struct Box { struct Box {
int left = 0; int left = 0;
int top = 0; int top = 0;
@@ -128,43 +73,32 @@ public:
int height = 0; int height = 0;
}; };
// Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the // Index of the first point within the pick radius on both axes, or -1 for a miss. First-match
// first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order // in point order for determinism.
// for determinism (mirror of nodeAtPoint).
int pointAtPixel(const Box& box, int x, int y) const; 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 // The one point->pixel mapping, exposed so drawing and hit-testing can never drift apart.
// 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).
struct CurvePixel { struct CurvePixel {
int x = 0; int x = 0;
int y = 0; int y = 0;
}; };
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p); static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
// The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands // Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click
// as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within // lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1.
// 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.
static VelocityPoint pointFromPixel(const Box& box, int x, int y); 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` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box. // Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp // width/height box or out-of-range index returns grabCurve unchanged.
// (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns
// `grabCurve` unchanged. Pure — mirror of resolveNodeDrag.
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index, static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
const Box& box, int dxPixels, int dyPixels); 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; bool equals(const VelocityCurve& other, double eps = 1e-9) const;
private: private:
// Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named // Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
// constructors + deserialize (see sample_map), which establish that invariant; the mutators // invariant, mutators preserve it.
// preserve it.
std::vector<VelocityPoint> points_; std::vector<VelocityPoint> points_;
}; };
+70 -120
View File
@@ -1,122 +1,88 @@
#pragma once #pragma once
// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the // zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by
// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor // the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h
// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec // so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member
// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes. // changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the
// 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. // engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "core/audio/peaks.h" // AudioSample (float) #include "core/audio/peaks.h"
namespace reasampler { 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; using audio::AudioSample;
// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 // Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders // stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
// 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.
enum class ChannelMode { Mono, Stereo }; enum class ChannelMode { Mono, Stereo };
// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's // POLY is the fixed-pool engine with bounded stealing; MONO is a single voice with last-note
// polyphonic engine (fixed pool + 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
// priority over a held-note stack (classic mono synth: a new note takes the voice over; the // the most-recent still-held one). Never a bank fact. Default Poly.
// 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.
enum class VoiceMode { Poly, Mono }; enum class VoiceMode { Poly, Mono };
// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). // How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps // mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
// the envelope running when a note is taken over while another is held — pitch moves without // re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM
// a re-attack (and the fallback on top-note release glides back the same way). Legato applies // streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
// 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.
enum class MonoTrigger { Retrigger, Legato }; enum class MonoTrigger { Retrigger, Legato };
// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. // Shared range so the engine, the component-state codec, and the editor control can't drift.
// 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.
inline constexpr int kMinVoiceCount = 1; inline constexpr int kMinVoiceCount = 1;
inline constexpr int kMaxVoiceCount = 32; inline constexpr int kMaxVoiceCount = 32;
inline constexpr int kDefaultVoiceCount = 16; inline constexpr int kDefaultVoiceCount = 16;
// --------------------------------------------------------------------------- // AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
// 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.
struct AdsrParams { struct AdsrParams {
std::int64_t attackFrames = 0; 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; std::int64_t decayFrames = 0;
double sustainLevel = 1.0; // 0..1 double sustainLevel = 1.0; // 0..1
std::int64_t releaseFrames = 0; std::int64_t releaseFrames = 0;
}; };
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's // GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, // note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone // honor the start point. Per-zone; default Gate so an instrument with no params set plays
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. // exactly as before.
enum class PlayMode { Gate, Trigger }; enum class PlayMode { Gate, Trigger };
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span // Playback covers [startFrame, playEnd), playEnd = startFrame +
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), // round(lengthFraction*(frames - startFrame)). Amplitude ramps 0->1 over fadeInFrames at the
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over // head and 1->0 over fadeOutFrames anchored to playEnd; unity between. Fades clamp so
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play // fadeIn + fadeOut <= play length. The voice frees when the head reaches playEnd.
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
struct TriggerParams { struct TriggerParams {
double lengthFraction = 1.0; // (0,1] of the post-start span to play 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 fadeInFrames = 0;
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd std::int64_t fadeOutFrames = 0;
}; };
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default // EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) // the build-time residual.
// so a third curve can join without a signature change.
enum class FadeCurve { EqualPower, Linear }; 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; inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration // VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances // PRESERVE: the read advances at the source rate while a PitchShifter transposes the output
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). // (an octave up keeps its length).
enum class PitchEngine { Varispeed, Preserve }; enum class PitchEngine { Varispeed, Preserve };
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" // Product default is Preserve, but applied at the state boundary (sample_map deserialize /
// directive). ONE constant to flip if Varispeed should be the default instead. This is the // editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's // defaults to Varispeed so "no params == the bare engine" holds for the core's own regression
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core // tests (an octave up still halves duration with no params set).
// 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.
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds // OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = // on big transpositions. Onset latency is zero — start() primes the ring with the first window
// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first // of real source, so output frame 0 is source frame 0 regardless of window size.
// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix).
// One knob, resolved at voice allocation.
inline constexpr double kPreserveWindowMs = 50.0; inline constexpr double kPreserveWindowMs = 50.0;
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always // AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to // to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames,
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack // then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop.
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
struct PitchEnvParams { struct PitchEnvParams {
bool enabled = false; bool enabled = false;
std::int64_t attackFrames = 0; std::int64_t attackFrames = 0;
@@ -124,69 +90,53 @@ struct PitchEnvParams {
double peakSemitones = 0.0; // signed depth at the peak 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 // Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 // Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope // default is layered on at (de)serialization, see kDefaultPitchEngine.
// 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.
struct ZonePlayParams { struct ZonePlayParams {
PlayMode playMode = PlayMode::Gate; PlayMode playMode = PlayMode::Gate;
AdsrParams adsr; // Gate: the AHDSR envelope AdsrParams adsr;
TriggerParams trigger; // Trigger: %-length + fades TriggerParams trigger;
PitchEngine pitchEngine = PitchEngine::Varispeed; PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv; // AD pitch modulation, off by default PitchEnvParams pitchEnv;
}; };
// --------------------------------------------------------------------------- // Sample data the core plays: plain decoded PCM + the bank intrinsics that govern playback.
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that // The shell decodes the on-disk WAV and fills this; the core never touches a file.
// 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) // [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
// is the "no sustain loop" marker — a held note past the sample end goes silent // marker — a held note past the sample end goes silent rather than looping a zero span.
// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false.
struct SampleLoop { struct SampleLoop {
bool hasLoop = false; bool hasLoop = false;
std::int64_t start = 0; // first looped frame (inclusive) std::int64_t start = 0;
std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end std::int64_t end = 0;
}; };
// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is // Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). // (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise // `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both // channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across
// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical // channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there.
// 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.
struct SampleData { struct SampleData {
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample) std::vector<AudioSample> frames;
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample std::vector<AudioSample> framesR; // empty for a mono sample
int sampleRate = 0; // frames per second (for reference; ratio is int sampleRate = 0; // ratio math is note-relative, so rate cancels for
// note-relative, so rate cancels for repitch). // repitch; still, 0 is invalid — every consumer must
// 0 is explicitly invalid — every consumer must
// receive a real rate before use. // receive a real rate before use.
int rootNote = 60; // MIDI note recorded at (plays at unity here) int rootNote = 60;
SampleLoop loop; // sustain loop, if any SampleLoop loop;
// 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 // Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
// an instrument-side per-zone override (the "start point" marker); S15 builds on it // Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
// (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::int64_t startFrame = 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; ZonePlayParams play;
// 2 iff a matching-length second channel exists; else 1. A framesR of a different // A framesR of a different length than frames is treated as absent — a malformed pair
// length than frames is treated as absent (mono) — a malformed pair never half-plays. // never half-plays.
int channelCount() const { int channelCount() const {
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
} }
}; };
} // namespace reasampler } // namespace reasampler
+7 -16
View File
@@ -10,20 +10,14 @@
namespace reasampler::instrument::map { namespace reasampler::instrument::map {
std::int64_t parseBankGeneration(const std::string& raw) { std::int64_t parseBankGeneration(const std::string& raw) {
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale // Whole-string, non-negative decimal parse, no exceptions/locale surprises (core/wire's
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading // guarded accumulate). Leading sign, non-digit, empty, or int64 overflow -> absent (0).
// '+' / '-', 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.
std::int64_t value = 0; std::int64_t value = 0;
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent; if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
return value; return value;
} }
std::string formatBankGeneration(std::int64_t generation) { 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); return std::to_string(generation);
} }
@@ -37,23 +31,20 @@ AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& re
AssignConsumeDecision d; AssignConsumeDecision d;
d.consumedGeneration = lastConsumed; // default: nothing changes 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) return d;
if (request->generation <= lastConsumed) 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 // Rule 2: new but not our target -> don't advance the marker, stay eligible.
// advance the marker (stay eligible if focus later lands here). No thundering herd.
if (!isFocusedTarget) return d; if (!isFocusedTarget) return d;
// The request is new AND we are the target: it will be consumed-as-seen either way, so // New and our target: consumed-as-seen either way.
// advance the marker to its generation so it is never re-evaluated.
d.consumedGeneration = request->generation; d.consumedGeneration = request->generation;
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker // Rule 3: unresolvable -> drop silently, marker already advanced above.
// advanced above, but no selection change.
if (!resolves) return d; if (!resolves) return d;
// Rule 4: new, target, resolvable -> apply the selection. // Rule 4: new, target, resolvable -> apply.
d.apply = true; d.apply = true;
d.bankId = request->bankId; d.bankId = request->bankId;
d.sampleId = request->sampleId; d.sampleId = request->sampleId;
+27 -74
View File
@@ -1,21 +1,11 @@
#pragma once #pragma once
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the // bank_sync — decision logic for bank-generation change-detection and the instrument-side
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO // assignment-request consume. The instrument polls two "reasampler" ext-state keys off the
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of // audio thread: the bank-generation counter (has the bank changed?) and the assignment
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a // request (should I switch to a just-ingested sample?). The shell reads the raw strings and
// host-facing shell. // owns cadence (a UI-thread timer, never `process`) + side effects (reloadInstrument,
// // setSelectedSampleId); this module owns only the yes/no decisions, so they're provable
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state // without a host. assignment_request.h owns the wire format; this owns the consume decision.
// 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.
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
@@ -27,41 +17,26 @@ namespace reasampler::instrument::map {
using wire::AssignmentRequest; using wire::AssignmentRequest;
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before // Default for a project with no bank_generation key yet (pre-existing project); the first
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value // real bump (>= 1) then reads as a change against this.
// 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).
inline constexpr std::int64_t kBankGenerationAbsent = 0; inline constexpr std::int64_t kBankGenerationAbsent = 0;
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a // Absent / empty / malformed / negative / overflowing all yield kBankGenerationAbsent (0),
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative // never a crash or spurious reload. Whole-string parse: trailing garbage rejects the value,
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp // so a torn/partial write is ignored until the next clean poll.
// 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).
std::int64_t parseBankGeneration(const std::string& raw); std::int64_t parseBankGeneration(const std::string& raw);
// Format a bank-generation counter for the ext-state stamp. The inverse of // Inverse of parseBankGeneration: plain decimal, no sign, no padding — byte-stable across
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so // writes of the same value.
// the stamp is byte-stable across writes of the same value.
std::string formatBankGeneration(std::int64_t generation); std::string formatBankGeneration(std::int64_t generation);
// Has the bank generation changed since the reader last saw `seen`? True when `current` // True when `current` differs from `seen` (not just increases — a project switch/reload can
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just // legitimately lower the value, and the reader should still re-read the bank).
// 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).
bool bankGenerationChanged(std::int64_t seen, std::int64_t current); bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
// The verdict of the S8 assignment-request consume decision (below). A pure value the // Verdict of the assignment-request consume decision below. apply and consumedGeneration
// processor shell acts on: apply the selection (or not) and advance the consumed marker // advancing are NOT the same event — a request naming an unresolvable sample is dropped
// (or not). Distinct booleans because the two are NOT the same event — a request may be // (consumed-as-seen) without applying, so the shell doesn't re-evaluate it every poll.
// 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.
struct AssignConsumeDecision { struct AssignConsumeDecision {
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
std::string bankId; // the request's bank (valid only when apply) 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) 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 // Rules, in order: (1) no request or generation <= lastConsumed -> no-op. (2) new but not
// is absent / malformed — nothing pending). // the target -> no-op, marker unchanged (stays eligible later). (3) new, target, but doesn't
// `lastConsumed` — the generation this instance last consumed (persisted in component // resolve -> drop silently, marker still advances (consumed-as-seen, never re-evaluated).
// state so a re-open does not re-apply a request the user already got, // (4) new, target, resolves -> apply + advance marker.
// 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.
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request, AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
std::int64_t lastConsumed, bool resolves, std::int64_t lastConsumed, bool resolves,
bool isFocusedTarget); bool isFocusedTarget);
+2 -3
View File
@@ -6,9 +6,8 @@ namespace reasampler::instrument::map {
std::optional<std::string> decodeGetProjExtState(int apiReturn, std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer) { const std::string& buffer) {
// REAPER returns the length of the stored value; 0 means the key is absent. Guard // 0 return means absent; guard the buffer too so a reused dirty buffer can't
// both the return AND the buffer: a caller that reused a dirty buffer must not // surface stale bytes as a value.
// surface stale bytes as a value when the API reported nothing.
if (apiReturn <= 0 || buffer.empty()) return std::nullopt; if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
return buffer; return buffer;
} }
+6 -27
View File
@@ -1,21 +1,8 @@
// bridge_marshal.hPURE marshalling helper for the REAPER VST-host bridge read. // bridge_marshal — pure GetProjExtState result decode 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.
// //
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h: // Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz); // int GetProjExtState(ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz)
// -- returns the length written (0 when the key is absent). // returns the length written (0 when the key is absent).
#pragma once #pragma once
@@ -26,18 +13,10 @@
namespace reasampler::instrument::map { namespace reasampler::instrument::map {
// Interpret a GetProjExtState result: the int return value (bytes the API reports for // Value only when the API reported non-empty AND the buffer is non-empty — REAPER
// the key) and the buffer it filled. Returns the value only when the API reported a // writes 0 and leaves the buffer untouched for an absent key, so stale buffer
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer // contents must never read as a hit.
// 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).
std::optional<std::string> decodeGetProjExtState(int apiReturn, std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer); 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 } // namespace reasampler::instrument::map
+103 -116
View File
@@ -1,8 +1,6 @@
// component_state_io — the ComponentState envelope + zones-payload binary codec. See // 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) // 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 + // Every wire format is FROZEN — byte-identical across revisions.
// 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.
#include "core/instrument/map/component_state_io.h" #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. // 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<std::uint64_t>(v); } std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
// Append the zones payload — the shared body of the performance blob and the component blob, // Append the zones payload — the shared body of the performance blob and the component
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion // blob, so both write zones identically. Always emits the CURRENT payload version (marker +
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail // version + extended records: loop/start tail + full play-params tail in SECONDS); the
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes // marker precedes the zone count so any reader can detect record shape independent of the
// the zone count so any reader can detect the record shape independently of the envelope version // envelope version (see sample_map.h).
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
// through EITHER envelope with no envelope bump.
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) { void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
putLE(out, kZonesFormatMarker); putLE(out, kZonesFormatMarker);
putLE(out, kZonesPayloadVersion); putLE(out, kZonesPayloadVersion);
@@ -49,7 +45,7 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
putLE(out, putLE(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride))); static_cast<std::uint32_t>(static_cast<std::int32_t>(*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); out.push_back(z.loopOverride ? 1 : 0);
if (z.loopOverride) { if (z.loopOverride) {
out.push_back(z.loopOverride->hasLoop ? 1 : 0); out.push_back(z.loopOverride->hasLoop ? 1 : 0);
@@ -59,9 +55,9 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
out.push_back(z.startPoint ? 1 : 0); out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putLE(out, asU64(*z.startPoint)); if (z.startPoint) putLE(out, asU64(*z.startPoint));
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine). // Play params (PAYLOAD v5): always present. Wall-clock times are SECONDS (doubles);
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames / // trigger %-length + fades stay source frames/fraction. Order matches the header's
// fraction. Order matches the header's v5 record spec. // v5 record spec.
const ZonePlaySeconds& pp = z.play; const ZonePlaySeconds& pp = z.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
@@ -78,10 +74,10 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
putLE(out, doubleToBits(pp.adsr.decaySeconds)); putLE(out, doubleToBits(pp.adsr.decaySeconds));
putLE(out, doubleToBits(pp.adsr.sustainLevel)); putLE(out, doubleToBits(pp.adsr.sustainLevel));
putLE(out, doubleToBits(pp.adsr.releaseSeconds)); 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)); putLE(out, doubleToBits(z.keyTrack));
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE // PAYLOAD v7: 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). // control-point count, then per point velocity + amp as doubles (endpoints included).
const std::vector<VelocityPoint>& pts = z.velocityCurve.points(); const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
putLE(out, static_cast<std::uint32_t>(pts.size())); putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& p : pts) { for (const VelocityPoint& p : pts) {
@@ -91,30 +87,30 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
} }
} }
// Read a zones payload from `r` into `map`. Shared by the performance parse and the component // Read a zones payload from `r` into `map`. Shared by the performance parse and the
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the // component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — // the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read // back-compat lift, overrides default absent). A truncated mid-zone read keeps the zones
// keeps the zones that parsed cleanly and drops the rest. // that parsed cleanly and drops the rest.
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame // `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames / // frame counts (holdFrames, pitchEnv A/D) to seconds at the read boundary: seconds = frames
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed. // / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed.
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { 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) std::uint32_t pv = 0; // payload version (0 = v1, no marker)
if (r.peekU32() == kZonesFormatMarker) { if (r.peekU32() == kZonesFormatMarker) {
r.u32(); // consume the marker r.u32(); // consume the marker
pv = r.u32(); // payload version pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start tail 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 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 keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last
const std::uint32_t count = r.u32(); const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) { 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 // z.play defaults to the product defaults (Gate + Preserve + tier-0 AHDSR seconds).
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1). // A v1/v2 payload (no play tail) lifts every zone to those defaults.
PerformanceZone z; PerformanceZone z;
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen); z.sampleId = r.str(idLen);
@@ -135,10 +131,10 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
if (hasStart) z.startPoint = r.i64(); if (hasStart) z.startPoint = r.i64();
} }
if (legacyV3Play) { if (legacyV3Play) {
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D) // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`) // A/D) were written as frames -> divide by `projectRate` to reach seconds.
// to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is. // Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are ABSENT
// A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr. // in v3 -> leave the seconds defaults on z.play.adsr.
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift"); 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 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; 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.sustainLevel = bitsToDouble(r.u64());
z.play.adsr.releaseSeconds = 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 v6: key-tracking scalar, appended after the v5 play tail. A pre-v6 payload
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an // (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. // already-saved instance repitches BIT-IDENTICALLY.
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
// PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A // PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1 // payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(),
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones. // Daniel-approved), the deliberate NON-back-compat behavior change for already-saved
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips // zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest. // read leaves the flat default and the mid-zone break below drops the rest.
if (curveTail) { if (curveTail) {
const std::uint32_t ptCount = r.u32(); const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts; std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge // 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 — the loop still // count can't trigger a giant allocation before the bounded reads fail.
// stops on r.ok, this only caps the speculative reserve.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0; const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16)); pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) { for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
@@ -211,15 +206,14 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes, PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate) { double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. // projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
// For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 // v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate.
// blob is encountered with an invalid rate — the calller guarantees a real rate before use.
PerformanceMap map; PerformanceMap map;
ByteReader r(bytes); ByteReader r(bytes);
const std::uint32_t version = r.u32(); const std::uint32_t version = r.u32();
if (!r.ok) return map; // no version tag -> empty 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. // no length prefix). Lift it to one full-keyboard zone playing that id.
if (version == kSelectionStateVersion) { if (version == kSelectionStateVersion) {
const std::string id = deserializeSelection(bytes); const std::string id = deserializeSelection(bytes);
@@ -238,33 +232,30 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
return map; return map;
} }
// --- Combined component state (v3, S10) -------------------------------------- // --- Combined component state --------------------------------------
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) { std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
std::vector<std::uint8_t> out; std::vector<std::uint8_t> out;
putLE(out, kComponentStateVersion); 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); out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE // v5 addition: last-consumed assignment generation, 8-byte LE two's-complement, follows
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that // the mode byte so a v4 reader stopping there is a strict prefix (see the v4 lift below).
// stops at the mode byte is a strict prefix (see the v4 lift below).
putLE(out, asU64(state.lastConsumedAssignGeneration)); putLE(out, asU64(state.lastConsumedAssignGeneration));
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows // v6 addition: preview-trigger velocity, 1 byte (MIDI 1..127) — a v5 blob is a strict
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift). // prefix up to this byte (see the v5 lift).
out.push_back(state.previewVelocity); out.push_back(state.previewVelocity);
// v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly, // v7 addition: voice count (1..32), voice mode (0 Poly/1 Mono), mono trigger
// 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the // (0 Retrigger/1 Legato) — one byte each, a v6 blob is a strict prefix up to here.
// velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift).
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount : state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
: state.voiceCount; : state.voiceCount;
out.push_back(static_cast<std::uint8_t>(vc)); out.push_back(static_cast<std::uint8_t>(vc));
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0); out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 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 // v8 addition: post-mixer LINEAR gain as a double (bit-cast to u64 LE) — a v7 blob is a
// (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to // strict prefix up to here. The WRITER never emits out-of-range: non-finite/negative
// here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or // falls back to unity; above the +24 dB cap clamps to the cap.
// negative falls back to unity; above the +24 dB cap clamps to the cap.
{ {
double g = state.masterGainLinear; double g = state.masterGainLinear;
const double maxLin = masterGainMaxLinear(); const double maxLin = masterGainMaxLinear();
@@ -272,16 +263,14 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
if (g > maxLin) g = maxLin; if (g > maxLin) g = maxLin;
putLE(out, doubleToBits(g)); putLE(out, doubleToBits(g));
} }
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag, // v9 addition: channel-mode-EXPLICIT flag, 1 byte — a v8 blob is a strict prefix up to
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the // here. 0 = implicit (shell may auto-default from the loaded capture's channel count);
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's // 1 = user deliberately toggled the mode (never fought).
// channel count); 1 = the user deliberately toggled the mode (never fought).
out.push_back(state.channelModeExplicit ? 1 : 0); out.push_back(state.channelModeExplicit ? 1 : 0);
// v10 envelope addition (pS self-contained playback): the instance-owned sample-refs // v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see // to here. Wire shape per kSelectionZonesRefsV10Version: entry count, then per entry id
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per // + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always // channelCount, displayName (length-prefixed; display-only).
// written), channelCount, displayName (length-prefixed; display-only).
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size())); putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
for (const SampleRefEntry& e : state.sampleRefs) { for (const SampleRefEntry& e : state.sampleRefs) {
putLE(out, static_cast<std::uint32_t>(e.sampleId.size())); putLE(out, static_cast<std::uint32_t>(e.sampleId.size()));
@@ -312,17 +301,17 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes, ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate) { double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. // projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
// For v5 and later blobs it is unused. See readZonesPayload for the guard. // v5+. See readZonesPayload for the guard.
ComponentState out; ComponentState out;
ByteReader r(bytes); ByteReader r(bytes);
const std::uint32_t version = r.u32(); 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. // 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 // * v1 (original single-selection: version 1 + id-to-end): restore {id, one
// zone} so the old pick survives as BOTH the selection and a one-zone map. // 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 // * v2 (zones-only): restore {"", zones} — that instance had zones but no separate
// single-capture selection. // single-capture selection.
if (version == kSelectionStateVersion) { if (version == kSelectionStateVersion) {
out.selectionId = deserializeSelection(bytes); out.selectionId = deserializeSelection(bytes);
@@ -337,20 +326,20 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
} }
if (version == kPerformanceStateVersion) { if (version == kPerformanceStateVersion) {
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag 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 — // BACK-COMPAT: a v3 blob ({selection, zones}, no channel mode) restores as MONO — the id
// the id length + id + zones body starts right after the version tag (no mode byte). // length + id + zones body starts right after the version tag (no mode byte).
if (version == kSelectionZonesV3Version) { if (version == kSelectionZonesV3Version) {
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate); 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): // BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration // the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
// defaults to 0, so a first assign still applies for a pre-marker instance. // a first assign still applies for a pre-marker instance.
if (version == kSelectionZonesModeV4Version) { if (version == kSelectionZonesModeV4Version) {
const std::uint8_t modeByte = r.u8(); const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
@@ -359,12 +348,12 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate); 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 // BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte):
// byte): mode byte, then the 8-byte marker, then the id + zones body — no 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 // previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
// pre-S-VIEW-4 instance restores at the mid default. // already-saved instance restores at the mid default.
if (version == kSelectionZonesModeMarkerV5Version) { if (version == kSelectionZonesModeMarkerV5Version) {
const std::uint8_t modeByte = r.u8(); const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
@@ -375,7 +364,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate); 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 && if (version != kComponentStateVersion &&
version != kSelectionZonesRefsV10Version && version != kSelectionZonesRefsV10Version &&
@@ -386,10 +375,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
return out; // unknown -> empty return out; // unknown -> empty
} }
// v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, // v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated // preview velocity, precede the v3 body. A non-{0,1} mode byte treats as mono
// as mono (conservative default) rather than rejected — a corrupt mode never silences the // (conservative default) rather than rejected — a corrupt mode never silences the instance.
// instance.
const std::uint8_t modeByte = r.u8(); const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
@@ -402,8 +390,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.previewVelocity = (previewVel >= 1 && previewVel <= 127) out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
? previewVel ? previewVel
: kPreviewVelocityDefault; : kPreviewVelocityDefault;
// v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the // v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior. // {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) { if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
const std::uint8_t vc = r.u8(); const std::uint8_t vc = r.u8();
const std::uint8_t vm = r.u8(); const std::uint8_t vm = r.u8();
@@ -417,9 +405,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
} }
// v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction // v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or // (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting. // than silencing/blasting.
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) { if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
const double g = bitsToDouble(r.u64()); const double g = bitsToDouble(r.u64());
if (!r.ok) return out; // truncated inside the gain double — out already carries if (!r.ok) return out; // truncated inside the gain double — out already carries
@@ -429,18 +417,18 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
? g ? g
: 1.0; : 1.0;
} }
// v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction // 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 // default (false = implicit) holds, so an already-saved instance's mode is treated as
// un-touched default and the shell may auto-default it from the loaded capture. // the untouched default and the shell may auto-default it from the loaded capture.
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) { if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
const std::uint8_t explicitByte = r.u8(); const std::uint8_t explicitByte = r.u8();
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds) if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
out.channelModeExplicit = (explicitByte == 1); out.channelModeExplicit = (explicitByte == 1);
} }
// v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it — // v10: the sample-refs table. A v9-or-older blob skips it — the EMPTY-table default
// the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve // holds, and the shell lifts the refs once via the bridge-resolve path (then re-saves
// path (then re-saves self-contained). A truncated mid-entry read keeps the entries that // self-contained). A truncated mid-entry read keeps the entries that parsed cleanly and
// parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway). // drops the rest (the selection/zones behind it are unreadable anyway).
if (version >= kSelectionZonesRefsV10Version) { if (version >= kSelectionZonesRefsV10Version) {
const std::uint32_t refCount = r.u32(); const std::uint32_t refCount = r.u32();
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) { for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
@@ -449,11 +437,10 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
e.sampleId = r.str(refIdLen); e.sampleId = r.str(refIdLen);
const std::uint32_t pathLen = r.u32(); const std::uint32_t pathLen = r.u32();
e.ref.relativePath = r.str(pathLen); e.ref.relativePath = r.str(pathLen);
// Range fallbacks (the refs table is the ONLY copy on the play path, so a // 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 // corrupt field must degrade to the field's default, never poison playback. An
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back // out-of-MIDI-range root falls back to the middle-C default distill() uses; a
// to the middle-C default distill() uses; a negative channel count falls back // negative channel count falls back to 0 = unknown (auto-default then skips it).
// to 0 = unknown (the GA auto-default then skips it).
const std::int32_t root = r.i32(); const std::int32_t root = r.i32();
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60; e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
e.ref.loop.hasLoop = (r.u8() != 0); e.ref.loop.hasLoop = (r.u8() != 0);
@@ -468,8 +455,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
} }
if (!r.ok) return out; if (!r.ok) return out;
} }
// v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the // v11: the minted instance guid. A v10-or-older blob skips it — the EMPTY default
// EMPTY default holds and the processor mints a fresh identity on first publish. // holds and the processor mints a fresh identity on first publish.
if (version >= kSelectionZonesRefsIdentityV11Version) { if (version >= kSelectionZonesRefsIdentityV11Version) {
const std::uint32_t guidLen = r.u32(); const std::uint32_t guidLen = r.u32();
out.instanceGuid = r.str(guidLen); out.instanceGuid = r.str(guidLen);
+188 -224
View File
@@ -1,20 +1,15 @@
#pragma once #pragma once
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the // 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 // ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps. // 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 // EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones
// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's // payload v1..v7) must be preserved exactly.
// 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.
#include <cstdint> #include <cstdint>
#include <string> #include <string>
@@ -26,108 +21,86 @@ namespace reasampler::instrument::map {
// --- Performance-map instance state (VST3 setState/getState) ----------------- // --- Performance-map instance state (VST3 setState/getState) -----------------
// //
// The performance map is the instrument's OWN state (D-B), serialized to the VST3 // The performance map is the instrument's OWN state, serialized to the VST3 component-state
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the // IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of // of truncation/wrong-version (bounded reads, never throws across the host).
// truncation/wrong-version by design (bounded reads, never throws across the host).
// //
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the // Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
// ZONES PAYLOAD. // ZONES PAYLOAD.
// //
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones // ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) // carries its OWN version, so the per-zone record can grow without bumping the envelope
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState // version. Zone-record extensions and envelope-field additions stay on independent axes
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the // that can never collide on one version number.
// key composition property: the zone-record extension is versioned inside the map blob, not on // * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length +
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not // id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE
// collide on a single version number. // rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count)
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone: // is v1.
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, // * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride). // equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record
// A payload starting with a small u32 (the zone count) is v1 — there is no marker. // after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone // loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended // (int64). The marker lets the reader detect record shape independent of the envelope.
// to each zone record after rootOverride: // * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, // a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger);
// 8-byte LE loop.end (both two's-complement int64); // 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). // trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads // (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. // LE pitchEnv.attackFrames + decayFrames (int64, FRAMES 44.1k nom); 8-byte LE
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload // peakSemitones (double). A v1/v2 payload (no v3 tail) lifts each zone to the product
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint // defaults (Gate + Preserve, no fades, pitch env disabled) — deliberate for
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): // already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed.
// 1 byte playMode (0 = Gate, 1 = Trigger); // LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; // always written as nominal frames at a baked-in rate; convert to seconds by dividing by
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); // the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); // baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); // absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060).
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); // * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double. // full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + // playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved // trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. // byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds +
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS // decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds +
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds // sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed // intentionally not read. Keymap builders resolve stored seconds to frames at the LIVE
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. // sample rate; no rate is baked into storage or the program.
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). // BACK-COMPAT: a v1 ENVELOPE blob (the original single-selection format: version tag 1 + id
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), // bytes) lifts to a single full-keyboard zone playing that id (no override). A
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full // truncated/unknown/empty blob deserializes to an EMPTY map.
// 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.
// //
// These two functions serialize the ZONES only. Since S10 the instrument's full component // These two functions serialize the ZONES only; the instrument's full component state is
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState // {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
// below, the v3 format the processor actually reads/writes. serializePerformance/
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
inline constexpr std::uint32_t kPerformanceStateVersion = 2; 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). // The zones-payload format version and its detection marker. serializePerformance and
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 // serializeComponentState both emit the CURRENT payload version (v7: marker + version +
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock // records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides // scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope.
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker + // Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts) // tail, wall-clock frame counts) for back-compat, lifting missing fields to defaults. v4 was
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The // never shipped and is not read. The marker is a high sentinel no legitimate zone count
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice, // (bounded by 128 MIDI zones, always tiny) can ever collide with.
// always tiny) can never collide with. // * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the
// * 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 (double) — the per-zone key-tracking
// full v5 play-params tail: // scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack =
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). // 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior
// A v1v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone // repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the // * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. // transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N,
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp // then per point 8-byte LE velocity + 8-byte LE amp (doubles). The two endpoints
// transfer curve appended to each zone record after the v6 keyTrack field: // (velocity 0 and 127) are always included, so N >= 2. A v1-v6 payload (no
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp // velocity-curve field) lifts every zone to VelocityCurve::flat() (Daniel-approved).
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2. // This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft
// A v1v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1 // hits play LOUDER than under the old linear velocity/127. A truncated mid-curve record
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: // leaves the zone's flat default and keeps the zones that parsed.
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A inline constexpr std::uint32_t kZonesPayloadVersion = 7; // + per-zone velocity->amp curve
// 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
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are // (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a // convert 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 // parameter (frames / projectRate = seconds) — the same rate keymap build already receives,
// already receives, so the seconds domain is consistent across both paths. No constant is baked in. // so the seconds domain is consistent across both paths. No constant is baked in.
// The performance map serialized to bytes for IBStream (getState). // The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map); std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
@@ -139,152 +112,145 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes, PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate); 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 // 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 // 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 // project restores an instance's pick AND its zones — and an instance with NO pick and NO
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // zones restores EMPTY (silence + the "pick a capture" empty state), never auto-playing
// state), never auto-playing sample #1. // sample #1.
// //
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono, // Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a // mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system // velocity (MIDI 1..127); three voice-system bytes (1-byte voice count 1..32, 1-byte voice
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono // mode 0 Poly/1 Mono, 1-byte mono trigger 0 Retrigger/1 Legato); 8-byte LE master-gain
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 // LINEAR value (double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB);
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte // 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the // toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the // path + intrinsics + display name per referenced sample; wire shape at
// instance-owned path + intrinsics + display name per referenced sample; wire shape at // kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes —
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE // the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
// length + guid bytes; the minted per-instance identity the usage publisher keys its // record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE // CURRENT zones payload (identical to serializePerformance's body — its own self-describing
// selection-id length + id bytes, then the CURRENT zones payload (identical to // version). The instance guid is the only v11 addition over v10, as the refs table was the
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). // only v10 addition over v9 — the envelope grows a field, the zones payload is untouched (a
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the // PARALLEL track owns zone-record extension under its own versioning — the two version
// only v10 addition over v9 — the envelope grows a field, // numbers are independent axes; do NOT bump the zones-payload version for an envelope
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own // field). An out-of-range voice byte or a non-finite/out-of-range master-gain double (a
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload // corrupt blob) falls back to the field's default rather than silencing the instance.
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range // BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing // lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to // defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = // pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity // loaded capture, and a user who HAD deliberately chosen a mode re-toggles once and the
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the // choice persists explicit from then on — and an EMPTY sample-refs table, which the shell
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD // lifts once via the bridge-resolve path — and an EMPTY instance guid, which the shell
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on — // re-mints on first publish):
// 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):
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. // * 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. // * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). // * v9 blob -> the v10 fields minus sampleRefs (empty tablebridge-resolve lift).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). // * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode.
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). // * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain.
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). // * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults.
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). // * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte.
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). // * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker.
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. // * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode.
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection. // * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection.
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift. // * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift.
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state). // * 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 // WHY THE MARKER PERSISTS. The last-consumed assignment generation stops a re-opened
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user // instance re-applying a stale assign_request the user already got and then manually
// already got and then manually changed away from: on re-open the instance re-reads the pending // changed away from: on re-open the instance re-reads the pending request, and only a
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see // 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 // bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the // assign (generation >= 1) still applies. It is the instrument's own state, never written
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. // to the bank — the extension owns the assign_request key; the instrument only tracks what
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no // it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default. // with no velocity byte lifts to this, audible-but-not-hot.
inline constexpr std::uint8_t kPreviewVelocityDefault = 64; inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
struct ComponentState { struct ComponentState {
std::string selectionId; // the single-capture pick; "" = no pick std::string selectionId; // the single-capture pick; "" = no pick
PerformanceMap map; // the opt-in zones; empty = no zones PerformanceMap map; // the opt-in zones; empty = no zones
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E) ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle). // Whether channelMode was DELIBERATELY set by the user (the editor toggle). While
// While false (implicit), the shell auto-defaults the mode from the loaded capture's // false (implicit), the shell auto-defaults the mode from the loaded capture's channel
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the // count on reload (stereo capture -> Stereo, mono -> Mono); once true, the user's
// user's choice is never fought. Pre-v9 blobs lift to false (implicit). // choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false; bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling // Preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling of
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's // channelMode, NOT per-zone), persisted so the Sample-view preview button retains the
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault. // user's chosen strike velocity across saves.
std::uint8_t previewVelocity = kPreviewVelocityDefault; std::uint8_t previewVelocity = kPreviewVelocityDefault;
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT // Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior
// older blob lifting to these plays byte-identically. // exactly, so an older blob lifting to these plays byte-identically.
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato 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; // Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output // ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per // by process() AFTER the voice sum — never per voice, never a keymap fact. Default
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, // unity reproduces pre-master-gain output byte-identically.
// so an older blob lifting to 1.0 plays exactly as it did.
double masterGainLinear = 1.0; double masterGainLinear = 1.0;
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics // Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
// for every bank sample this instance plays (see the SampleRefs block above). setState // bank sample this instance plays (see the SampleRefs block above). setState decodes
// decodes straight from these; NO bridge/extension read is required for playback. A // straight from these; NO bridge/extension read is required for playback. A pre-v10
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve // blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve path
// path once (then re-saves self-contained). // once (then re-saves self-contained).
SampleRefs sampleRefs; SampleRefs sampleRefs;
// pS-usage (v11): the minted per-instance identity the usage publisher keys its // The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection // ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
// seam). Persisted so the key is stable across sessions (records do not proliferate // the key is stable across sessions. Empty = never published (a fresh or pre-v11
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor // instance); the processor mints one on first publish, and RE-mints when the publish
// mints one on first publish, and RE-mints when the publish plan detects this state // plan detects this state was cloned onto another track (FX copy / track duplication).
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
std::string instanceGuid; std::string instanceGuid;
}; };
inline constexpr std::uint32_t kComponentStateVersion = 11; inline constexpr std::uint32_t kComponentStateVersion = 11;
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed // v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
// after the refs table). Mirrors the v10/v9/… series so the version branches in // v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
// deserializeComponentState stay self-describing.
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11; inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table). // v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection // the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE // 4-byte LE id length + id bytes, 4-byte LE path length + path bytes, 4-byte LE rootNote
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, // (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of // regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length + // length + bytes (display-only; the editor label's extension-absent fallback).
// displayName bytes (display-only; the editor label's extension-absent fallback).
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode // Everything through the master gain, no channel-mode explicit flag. Retained so
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode. // deserializeComponentState can lift a v8 blob to implicit mode.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8; inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the // v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing. // in deserializeComponentState is self-describing.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9; inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + // Selection + zones + channel mode + consumed marker + preview velocity + voice system, no
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can // master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
// lift a v7 blob to unity master gain.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7; inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker + // Selection + zones + channel mode + consumed marker + preview velocity, no voice-system
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a // fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
// v6 blob to the voice defaults {16, Poly, Retrigger}. // {16, Poly, Retrigger}.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6; inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no // Selection + zones + channel mode + consumed marker, no preview velocity. Retained so
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity. // deserializeComponentState can lift a v5 blob to a mid velocity.
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5; inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed // Selection + zones + channel mode, no consumed marker. Retained so
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. // deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4; inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named // Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. // blob to {mono, selection, zones}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3; inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState). // The full instance state serialized to bytes for IBStream (getState).
@@ -300,18 +266,16 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
// --- Instance state (VST3 setState/getState) -------------------------------- // --- Instance state (VST3 setState/getState) --------------------------------
// //
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a // The instrument's OWN state is which bank sample it plays (a performance choice, held by
// performance choice, held by the instrument, never written back to the bank). It is a // the instrument, never written back to the bank) — a single string id. serialize/
// single string id. serialize/deserialize keep the on-the-wire form explicit and // deserialize keep the on-the-wire form explicit and versioned so it can be extended
// versioned so a future Tier can extend it without breaking already-saved instances. // without breaking already-saved instances.
// //
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No // Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
// length prefix is needed the id runs to the end of the stream (the host tells us the // needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob // version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty
// by returning "" (no selection — under the S10 policy reversal an empty selection is // state, not the bank's first sample), never throwing across the host boundary. Retained
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never // for the v1->v3 back-compat lift in deserializeComponentState.
// 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.
inline constexpr std::uint32_t kSelectionStateVersion = 1; inline constexpr std::uint32_t kSelectionStateVersion = 1;
+4 -6
View File
@@ -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" #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 // Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive, DAW convention:
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name. // MIDI 0 == C-1, 60 == C4). Returns nullopt if it is not a note name.
std::optional<int> parseNoteName(const std::string& s) { std::optional<int> parseNoteName(const std::string& s) {
if (s.empty()) return std::nullopt; if (s.empty()) return std::nullopt;
std::size_t i = 0; std::size_t i = 0;
@@ -49,10 +49,8 @@ std::optional<int> parseNoteName(const std::string& s) {
if (base < 0) return std::nullopt; // not a letter -> not a note name if (base < 0) return std::nullopt; // not a letter -> not a note name
++i; ++i;
int semitone = base; 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')) { 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; if (s[i] == '#') ++semitone;
else --semitone; else --semitone;
++i; ++i;
+6 -21
View File
@@ -1,21 +1,9 @@
// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's // note_entry parse + clamp for direct numeric/note-name entry of a zone's low/high/root
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The // MIDI note (a drag on the keyboard strip can't hit a precise note reliably).
// 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.
// //
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't // Accepts a plain decimal integer ("60", "+5") or a note name ("C4", "f#3", "Bb-1", DAW
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a // convention: MIDI 0 == C-1, 60 == C4). Out-of-range CLAMPS to [0,127] rather than
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI // rejecting; unparseable input returns nullopt (shell keeps the old value).
// 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).
#pragma once #pragma once
@@ -24,10 +12,7 @@
namespace reasampler::instrument::map { namespace reasampler::instrument::map {
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal // Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt.
// 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.
std::optional<int> parseNoteEntry(const std::string& text); std::optional<int> parseNoteEntry(const std::string& text);
} // namespace reasampler::instrument::map } // namespace reasampler::instrument::map
+27 -46
View File
@@ -1,6 +1,5 @@
// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec // sample_map — pure implementation (the resolution half; the ComponentState codec lives
// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER / // in component_state_io.cpp). See sample_map.h.
// SWELL / vendor includes; standard library + the pure bank_book / wav_codec / sampler_core.
#include "core/instrument/map/sample_map.h" #include "core/instrument/map/sample_map.h"
@@ -12,9 +11,8 @@ namespace reasampler::instrument::map {
namespace { namespace {
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank // The bank stores loop points as an optional LoopPoints (both-or-neither); the core wants
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a // a SampleLoop with an explicit hasLoop. Absent -> no loop.
// SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) { SampleLoop loopFromSample(const Sample& s) {
SampleLoop out; SampleLoop out;
if (s.loop) { if (s.loop) {
@@ -25,41 +23,33 @@ SampleLoop loopFromSample(const Sample& s) {
return out; return out;
} }
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C // rootNote defaults to middle C (60) when the bank left the intrinsic empty — an
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on // un-rooted sample plays unity at C4 rather than failing to play.
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
SelectedSample distill(const Sample& s) { SelectedSample distill(const Sample& s) {
SelectedSample out; SelectedSample out;
out.relativePath = s.relativePath; out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60; out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s); out.loop = loopFromSample(s);
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry) out.channelCount = s.channelCount; // 0 = unknown (older entry)
return out; return out;
} }
// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the // The ONE override-beats-intrinsic fold shared by resolvePerformance and
// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's // resolvePerformanceFromRefs, so the two resolution paths cannot drift.
// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone.
// Shared so the two resolution paths cannot drift.
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) { ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
ResolvedZone rz; ResolvedZone rz;
rz.relativePath = ref.relativePath; rz.relativePath = ref.relativePath;
rz.lowNote = z.lowNote; rz.lowNote = z.lowNote;
rz.highNote = z.highNote; 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; rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
// S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state — // Key tracking + velocity curve are instrument state — carried straight through.
// carried straight through and applied at play time.
rz.keyTrack = z.keyTrack; rz.keyTrack = z.keyTrack;
rz.velocityCurve = z.velocityCurve; rz.velocityCurve = z.velocityCurve;
// Effective loop / start (S11): the per-zone override wins over the intrinsic; absent // Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0
// -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B). // (start). The bank is never mutated.
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop; rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
rz.startFrame = z.startPoint ? *z.startPoint : 0; rz.startFrame = z.startPoint ? *z.startPoint : 0;
// S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
// resolves them to frames.
rz.play = z.play;
return rz; return rz;
} }
@@ -67,22 +57,20 @@ ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
std::optional<SelectedSample> selectSample(const std::string& banksJson, std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) { const std::string& sampleId) {
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short- // An empty selection is SILENCE, not the first sample — by design.
// circuit before parsing — no stored id resolves to nothing to play by design.
if (sampleId.empty()) return std::nullopt; if (sampleId.empty()) return std::nullopt;
if (banksJson.empty()) return std::nullopt; if (banksJson.empty()) return std::nullopt;
std::optional<BankBook> book = BankBook::deserialize(banksJson); std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return std::nullopt; // malformed -> nothing to play (never throw) if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
// Search every bank (pool first, then named — banks() is ordinal order) for the // Search every bank (ordinal order) for the stored id; a sample lives in exactly
// stored id. A sample lives in exactly one bank, so first hit wins. // one bank, so first hit wins.
for (const Bank& b : book->banks()) { for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(sampleId)) { if (const Sample* s = b.index.query(sampleId)) {
return distill(*s); return distill(*s);
} }
} }
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample: // A stale stored id is SILENCE too — the editor's empty state, not a substitution.
// the editor reflects the missing pick with its empty state rather than masking it.
return std::nullopt; return std::nullopt;
} }
@@ -92,7 +80,7 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono; 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) { const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
if (sampleId.empty()) return nullptr; if (sampleId.empty()) return nullptr;
@@ -133,7 +121,7 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
for (SampleRefEntry& e : refs) { for (SampleRefEntry& e : refs) {
if (e.sampleId == id) { if (e.sampleId == id) {
e.ref = distilled; e.ref = distilled;
e.displayName = found->displayName; // rename sync rides the same refresh e.displayName = found->displayName; // rename sync
updated = true; updated = true;
break; break;
} }
@@ -233,22 +221,18 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate; out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) { if (mode == ChannelMode::Mono) {
// MONO mode: the existing downmix policy (average all source channels), one channel out.
out.monoFrames = downmixToMono(interleaved, sourceChannels); out.monoFrames = downmixToMono(interleaved, sourceChannels);
return out; // framesR stays empty return out; // framesR stays empty
} }
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0 // extractChannel clamps out-of-range, so a mono source yields L == R (dual-mono).
// 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.
out.monoFrames = extractChannel(interleaved, sourceChannels, 0); out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
out.framesR = extractChannel(interleaved, sourceChannels, 1); out.framesR = extractChannel(interleaved, sourceChannels, 1);
return out; return out;
} }
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) { ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R, // seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry // fades) carry through untouched, already frames/fractions.
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) { const auto secToFrames = [sr](double sec) {
@@ -304,8 +288,7 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
if (!book) return out; // malformed -> nothing (never throw) if (!book) return out; // malformed -> nothing (never throw)
for (const PerformanceZone& z : map.zones) { for (const PerformanceZone& z : map.zones) {
// Look the id up across every bank (pool + named) — a sample lives in exactly // A sample lives in exactly one bank, so first hit wins.
// one bank, so first hit wins.
const Sample* found = nullptr; const Sample* found = nullptr;
for (const Bank& b : book->banks()) { for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(z.sampleId)) { if (const Sample* s = b.index.query(z.sampleId)) {
@@ -314,12 +297,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
} }
} }
if (!found) { if (!found) {
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune). out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
out.droppedSampleIds.push_back(z.sampleId);
continue; continue;
} }
// Distill the bank Sample to the same intrinsics shape the refs table carries, then // Distill to the same intrinsics shape the refs table carries, then run the SHARED
// run the SHARED fold — so the bank path and the refs path resolve identically. // fold — so the bank path and refs path resolve identically.
out.zones.push_back(foldZone(z, distill(*found))); out.zones.push_back(foldZone(z, distill(*found)));
} }
return out; return out;
@@ -332,8 +314,7 @@ ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
if (const SelectedSample* r = findRef(refs, z.sampleId)) { if (const SelectedSample* r = findRef(refs, z.sampleId)) {
out.zones.push_back(foldZone(z, *r)); out.zones.push_back(foldZone(z, *r));
} else { } else {
// No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the // No ref for this id: drop + report, same shape as the bank path's stale-id policy.
// zone cleanly + report — the same shape as the bank path's stale-id policy.
out.droppedSampleIds.push_back(z.sampleId); out.droppedSampleIds.push_back(z.sampleId);
} }
} }
+162 -245
View File
@@ -1,22 +1,10 @@
#pragma once #pragma once
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live // sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core // data the sampler core plays, and (de)serializes the instance's zone/selection state.
// plays, and (de)serialize the instance's selected-sample choice for VST3 component // The bank is read over the live-state seam, audio over the file seam; both raw inputs
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the // cross the bridge/file boundary in the shell, everything after (bank parse via the shared
// mirror of capture_paths / wav_codec / bridge_marshal splitting the fiddly, testable // bank_book JSON path, sample pick, mono downmix, keymap build) is pure and unit-tested
// arithmetic out of a host-facing shell. // here. Links bank_book, wav_codec, and sampler_core (all pure).
//
// 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.
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
@@ -29,77 +17,56 @@
namespace reasampler::instrument::map { 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 audio::AudioSample;
using instrument::engine::VelocityCurve; using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint; using instrument::engine::VelocityPoint;
// The bank sample this instance is bound to, distilled from the live "banks" blob: // The bank sample this instance is bound to, distilled from the live "banks" blob: the
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank // project-relative WAV path the file seam resolves+decodes, plus the bank intrinsics the
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet. // core repitches/loops by. A pure value — no host, no PCM yet.
struct SelectedSample { struct SelectedSample {
std::string relativePath; // project-relative; the shell resolves it (M4 convention) std::string relativePath; // project-relative; the shell resolves it
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty int rootNote = 60; // defaults to middle C when the bank left it empty
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty SampleLoop loop; // hasLoop=false when the bank left it empty
int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
// bank entries) — the GA channel-mode auto-default skips it // the GA channel-mode auto-default skips it
}; };
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks" // `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank // an unsaved or pre-bank project); `sampleId` is this instance's stored selection.
// project). `sampleId` is this instance's stored selection.
// //
// Precedence, all pure: // Precedence: empty/malformed banksJson -> nullopt. Empty sampleId -> nullopt (no selection
// * empty / malformed banksJson -> nullopt (nothing to play) // is SILENCE, not the bank's first sample — deliberate: the metric is time-to-first-note via
// * sampleId empty -> nullopt (NO selection -> silence) // an explicit pick, and mystery auto-play of sample #1 was the anti-pattern). sampleId found
// * sampleId names a sample in ANY bank -> that sample (searched pool + named) // in any bank -> that sample. sampleId set but not found (stale) -> nullopt, same as no
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved; // selection — the editor shows its "pick a capture" empty state rather than masking it.
// 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.
std::optional<SelectedSample> selectSample(const std::string& banksJson, std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId); const std::string& sampleId);
// GA auto-default rule (pure, tested): given the capture's requested channel count, the // Auto-default rule: given the capture's channel count, current mode, and whether the user
// instance's current mode, and whether the user has explicitly toggled the mode, return // explicitly toggled it, return the mode to apply. Explicit choice is never overridden;
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0) // channelCount == 0 (unknown) leaves the current mode; >= 2 -> Stereo; == 1 -> Mono.
// 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
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit); 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 // The instrument must never go silent just because the extension's ext-state hasn't parsed
// ext-state has not parsed yet (or the extension is absent). So the instance persists, in // yet (or the extension is absent). So the instance persists, in its OWN component state, a
// its OWN component state, a small table of everything it needs to PLAY each referenced // table of everything needed to PLAY each referenced bank sample: path + decode intrinsics
// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop, // (root, loop, channel count), keyed by bank sample id. The shell decodes straight from
// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the // these refs; the bank blob is a browser source that refreshes the table opportunistically
// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also // when readable, never a runtime lifeline.
// refreshes this table opportunistically when readable (recapture/root edits stay live),
// never a runtime lifeline.
// //
// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an // Consequence: a sample deleted from the bank no longer silences an instance that carries
// instance that carries its ref — the instance keeps playing while the FILE exists (normal // its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
// sampler behavior; prune deleting the file yields the defined no-play). This deliberately // the file yields the defined no-play).
// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution. struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers
struct SampleRefEntry { struct SampleRefEntry {
std::string sampleId; // the bank sample id this ref was copied from (the seam key) 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 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 // Bank display name at copy time — DISPLAY ONLY (editor label fallback when the bank
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref // snapshot is unavailable); never consulted by resolution.
// fallback); never consulted by resolution. Empty for a table written before the field
// existed in-session (it back-fills on the next bank refresh).
std::string displayName; std::string displayName;
}; };
using SampleRefs = std::vector<SampleRefEntry>; using SampleRefs = std::vector<SampleRefEntry>;
@@ -112,43 +79,32 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
std::vector<std::string> referencedSampleIds(const std::string& selectionId, std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map); const PerformanceMap& map);
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same // Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
// distillation selectSample performs), copying the bank display name alongside the decode // name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a // instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op.
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids); const std::vector<std::string>& ids);
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable // Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the // for the ids the instance references?
// instance references? // * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the // * Lift — blob parses and at least one id resolves: copy a ref in (never re-fires once
// project's ext-state may simply not have parsed). // the refs table is non-empty).
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the // * Stale — blob parses and no id resolves: provably stale, nothing to lift, ever — the
// refs table then goes non-empty and the lift never re-fires). // shell latches this and stops retrying (no per-tick churn).
// * 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).
enum class LegacyLiftDecision { Retry, Lift, Stale }; enum class LegacyLiftDecision { Retry, Lift, Stale };
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson, LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids); const std::vector<std::string>& ids);
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks // Keep only the entries whose id is in `ids` (getState hygiene: the persisted table cannot
// exactly what the instance currently plays, so it cannot grow with browsing history). // grow with browsing history).
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids); void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
// One entry in the capture browser's card list: the stable id + display name plus the S2 // One entry in the capture browser's card list: stable id + display name + intrinsics +
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge, // bank, for a card (peak thumbnail + name + root/key badge, filterable by bank). Peaks are
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded // NOT here — computed shell-side from the decoded PCM (reasampler_editor's thumbnail
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache, // cache). rootNote is nullopt when the bank left it empty (badge shows no root, never a
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already // guessed value). Pure projection over the shared parse — the UI never parses JSON itself.
// 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).
struct SampleChoice { struct SampleChoice {
std::string id; std::string id;
std::string displayName; std::string displayName;
@@ -167,35 +123,27 @@ struct BankChoice {
}; };
std::vector<BankChoice> listBanks(const std::string& banksJson); std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames (the shape wav_codec's extractFloatFrames yields: // Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per // by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0, // "take L", not summing: a centered mono source stays unity, a hard-panned source is
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve // attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
// 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.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved, std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount); int channelCount);
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is // Deinterleave one channel (`which`, 0-based). `which` clamps to a valid channel (a request
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the // past the last channel reads the last channel, so a mono source asked for channel 1 yields
// source's 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.
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved, std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which); int channelCount, int which);
// --- Stored (wall-clock SECONDS) per-zone play params ------------------------- // --- Stored (wall-clock SECONDS) per-zone play params -------------------------
// //
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program). // Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the // instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the // SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay) // keymap build. Quantities anchored to the source file's timeline (start point, loop
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds. // points, Trigger %-length + fades) stay in source frames/fractions, carried through
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length // unchanged (TriggerParams reused verbatim).
// 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).
// //
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time. // The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
struct AdsrSeconds { struct AdsrSeconds {
@@ -214,61 +162,51 @@ struct PitchEnvSeconds {
double peakSemitones = 0.0; // signed depth at the peak double peakSemitones = 0.0; // signed depth at the peak
}; };
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in // The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing // in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap // distinct from sampler_core's engine-facing ZonePlayParams (frames).
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
struct ZonePlaySeconds { struct ZonePlaySeconds {
PlayMode playMode = PlayMode::Gate; PlayMode playMode = PlayMode::Gate;
AdsrSeconds adsr; // Gate: AHDSR (seconds) AdsrSeconds adsr; // Gate: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames) 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 PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
}; };
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live // 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, // sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this). // unchanged. `sampleRate` must be > 0 (the caller guards this).
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate); ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole // 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 // keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic).
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0 // `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default), // sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length // bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad // params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
// pair never half-plays. `sampleRate` is the WAV's rate. // picked single capture plays under the same default engine as a zone would. Resolves the
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it // wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
// 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.
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate, Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop, int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {}, std::vector<AudioSample> framesR = {},
const ZonePlaySeconds& play = ZonePlaySeconds{}); 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 // 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 // samples zoned across the keyboard, each with a key range and a root note. A performance
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never // choice, so it lives in the instrument (VST3 component state), never written back to the
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but // bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
// OVERRIDABLE here — the override lives on the zone, never on `Sample`. // shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames
// // + this map into a sampler_core Keymap.
// 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.
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range, // 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 // rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty). // 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
// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain // without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the // initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into
// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins // the effective ResolvedZone.
// 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.
struct PerformanceZone { struct PerformanceZone {
std::string sampleId; // bank sample id this zone plays std::string sampleId; // bank sample id this zone plays
int lowNote = 0; // inclusive int lowNote = 0; // inclusive
@@ -277,146 +215,125 @@ struct PerformanceZone {
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0 std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far // Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
// playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the // (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to
// DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved // exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
// instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double. // key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
// NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by // engines.
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
double keyTrack = 1.0; double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the // Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127. // replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel-
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1 // approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change —
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat // a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an // play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT // in Voice::start.
// 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).
VelocityCurve velocityCurve = VelocityCurve::flat(); VelocityCurve velocityCurve = VelocityCurve::flat();
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch // Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine +
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the // AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build // SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW // for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, // fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail) // to exactly these defaults on read.
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
ZonePlaySeconds play; ZonePlaySeconds play;
}; };
// The instrument's performance map: an ordered list of zones. Order is authoritative for // 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 // overlap resolution first zone in order wins (mirrors the core's first-match
// first-match Keymap::resolve overlaps are neither rejected nor clamped, the earlier // Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
// zone simply takes the contested keys; documented, deterministic).
struct PerformanceMap { struct PerformanceMap {
std::vector<PerformanceZone> zones; std::vector<PerformanceZone> zones;
bool empty() const { return zones.empty(); } 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 // 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 // control edit. Loading a different sample used to change only the selection id, leaving
// selection id, leaving the previous sample's full-range zone in the map — and since zone // the previous sample's full-range zone in the map — and since zone resolution is
// resolution is FIRST-MATCH in order, that stale zone shadowed every later one forever: the // first-match in order, that stale zone shadowed every later one forever: the engine kept
// engine kept playing the old sample while the editor drew the new one's zone (matched by // playing the old sample while the editor drew the new one's zone. This function is called
// sampleId, order-blind). This function is called at every selection-change site so the zone // at every selection-change site so the zone the editor draws is the zone the engine plays.
// the editor draws is the zone the engine plays.
// //
// Rules (pure, order-preserving where it matters): // Rules (order-preserving where it matters):
// * empty `selectedId` or empty map -> untouched, false. // * empty `selectedId` or empty map -> untouched, false.
// * ANY zone with an authored key range (not the full [0,127]) -> the map is Zone-view // * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship,
// authorship; first-match order is load-bearing there — untouched, false. The Sample // first-match order is load-bearing there — untouched, false (the Sample face never
// face never creates a narrow zone, so a narrow zone proves deliberate multi-zone intent. // 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 // * else (every zone full-range) -> keep only the first zone bound to `selectedId`
// first zone bound to `selectedId` (the selection's own params are not reset); drop // (params preserved); drop the rest. A selection with no zone yet empties the map.
// 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).
// Returns true iff the map changed (the caller republishes + reloads on true). // Returns true iff the map changed (the caller republishes + reloads on true).
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId); bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank // One resolved zone ready for the shell to decode + the pure build to stitch: project-
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats // relative WAV path (file seam), effective root note (override beats bank intrinsic beats
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct // middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
// from PerformanceZone (which names an id) — this is the id resolved against the live bank. // an id) — this is the id resolved against the live bank.
struct ResolvedZone { struct ResolvedZone {
std::string relativePath; // project-relative; the shell resolves + decodes it std::string relativePath; // project-relative; the shell resolves + decodes it
int lowNote = 0; int lowNote = 0;
int highNote = 127; int highNote = 127;
int rootNote = 60; // effective: override, else bank intrinsic, else 60 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) double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) 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) 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` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is // preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a // cleanly — not an error, not silence for the whole map — and reported here so the editor
// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence // can flag/prune it.
// for the whole map — and its id is reported here so the editor can flag/prune it).
struct ResolvedPerformance { struct ResolvedPerformance {
std::vector<ResolvedZone> zones; std::vector<ResolvedZone> zones;
std::vector<std::string> droppedSampleIds; std::vector<std::string> droppedSampleIds;
}; };
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared // Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank // is looked up across every bank; a hit yields a ResolvedZone with the effective root note
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride, // and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends // -> empty result.
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
// //
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs // NOT the live load path reloadInstrument resolves via resolvePerformanceFromRefs (the
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE // instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share // (both share foldZone, so the drift test keeps the shared fold honest).
// foldZone, so the drift test is what keeps the shared fold honest.
ResolvedPerformance resolvePerformance(const std::string& banksJson, ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map); const PerformanceMap& map);
// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained // The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table
// playback) — the bank-free mirror of resolvePerformance, sharing the same override- // shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone
// beats-intrinsic fold, so the two paths cannot drift. A zone whose sampleId has no ref // whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path).
// is dropped + reported (same stale-id shape as the bank path). Pure.
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs, ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
const PerformanceMap& map); const PerformanceMap& map);
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the // Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One // `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is // decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order // so first-match overlap resolution matches authored order. A zone whose decoded frames are
// is preserved so first-match overlap resolution matches the map's authored order. A zone // empty is SKIPPED (an unreadable WAV drops the zone, not the map).
// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the
// map). Empty zones in -> empty Keymap (silence).
struct DecodedZonePcm { struct DecodedZonePcm {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode) std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid; every consumer must int sampleRate = 0; // 0 is explicitly invalid
// receive the WAV's real rate before use.
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
}; };
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones, Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded); const std::vector<DecodedZonePcm>& decoded);
// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding // Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's // 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float
// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode. // frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
// * MONO mode -> downmix to one channel (the existing policy: average all source // * MONO mode -> downmix to one channel (average all source channels).
// 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, 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).
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels // Empty/zero-channel input -> empty frames (caller drops the zone or plays silence).
// 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.
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved, DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate); int sourceChannels, ChannelMode mode, int sampleRate);
// The ComponentState envelope + zones-payload binary codec (serializePerformance / // The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
// serializeComponentState / serializeSelection + the deserializers and every version // it grows on every envelope bump and is consumed by the extension's preset-blob path too,
// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows // so both artifacts share the codec while only the VST links the voice engine.
// 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.
} // namespace reasampler::instrument::map } // namespace reasampler::instrument::map
+1 -1
View File
@@ -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" #include "core/instrument/map/trigger_seam.h"
+11 -33
View File
@@ -1,25 +1,10 @@
// trigger_seam.hPURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam. // trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams:
// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. // 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)) // 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).
#pragma once #pragma once
@@ -27,25 +12,18 @@
namespace reasampler::instrument::map { namespace reasampler::instrument::map {
// The source-frame length of the Trigger played span: // postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
// postStart = max(0, frameCount - startFrame) // `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
// playLength = round(lengthFraction * postStart) // or lengthFraction <= 0.
// `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.
std::int64_t triggerPlayLength(double lengthFraction, std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount, std::int64_t frameCount,
std::int64_t startFrame); std::int64_t startFrame);
// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path). // PACK direction (draw path): frames -> fraction of play span. Not clamped here — the
// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is // caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic).
// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic
// stays in envelope_edit, not here.
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength); double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
// Convert a fade fraction to a source-frame count (UNPACK direction, commit path). // UNPACK direction (commit path): fraction -> nearest source frame.
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength); std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
} // namespace reasampler::instrument::map } // namespace reasampler::instrument::map
+7 -13
View File
@@ -1,9 +1,8 @@
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10 // browser_scroll.cpp — see browser_scroll.h. Pure scroll + search geometry; no host types.
// capture_browser. No host types; only the shared Rect + BrowserLayout.
#include "core/instrument/ui/browser_scroll.h" #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 <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -50,11 +49,10 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of
return vr; return vr;
} }
if (offset < 0) offset = 0; if (offset < 0) offset = 0;
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row // First row: floored so a row partially scrolled off the top still draws. Last row:
// partially scrolled off the top still draws (its lower part is visible). // 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; 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; const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
int first = firstRow * columns; int first = firstRow * columns;
int last = lastRow * 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 trackLeft = trackRight - kScrollbarWidth;
const int trackTop = layout.grid.y; const int trackTop = layout.grid.y;
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but // Thumb height proportional to the visible fraction, floored/capped to the track.
// never taller than the track.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content); int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH); thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH); thumbH = (std::min)(thumbH, gridH);
// Thumb top proportional to the offset over the movable track span.
const int trackSpan = gridH - thumbH; // >= 0 const int trackSpan = gridH - thumbH; // >= 0
int thumbTop = trackTop; int thumbTop = trackTop;
if (maxOff > 0 && trackSpan > 0) { 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); const int gridH = (std::max)(0, layout.grid.height);
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset); 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<int>(static_cast<long long>(gridH) * gridH / content); int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH); thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH); thumbH = (std::min)(thumbH, gridH);
@@ -157,8 +153,6 @@ std::vector<int> filterNameIndices(const std::vector<std::string>& names,
return out; 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) { BrowseModal computeBrowseModal(int w, int h) {
constexpr int kBrowseFooterH = 30; constexpr int kBrowseFooterH = 30;
BrowseModal m; BrowseModal m;
+47 -76
View File
@@ -1,23 +1,16 @@
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10 // browser_scroll.h — scroll + type-to-filter geometry layered over capture_browser. Mirror
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of // of capture_browser/editor_geometry; the shell draws the clipped card window, scrollbar,
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box // and search field, and routes wheel/drag/keystrokes into these functions.
// 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.
// //
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell // capture_browser lays out every card top-down and the shell clips at the browser bottom —
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach // a bank longer than the panel has no way to reach the rest. This module adds scroll (a
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry: // vertical pixel offset with max-offset clamp, visible-row window, scrollbar thumb, and
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the // thumb-drag<->offset mapping) and search (a case-insensitive name-substring filter that
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a // composes with capture_browser's bank filter — the shell applies the bank filter first,
// wheel tick or a thumb drag reaches every card; and // then this search narrows within it).
// * 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.
// //
// It holds NO card data and draws nothing — it knows only the browser layout (from // Holds no card data and draws nothing — knows only the browser layout, counts, and the
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It // scroll offset the shell owns as transient UI state.
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
#pragma once #pragma once
@@ -28,96 +21,74 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell // Width of the vertical scrollbar gutter at the grid's right edge. When content fits (no
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so // scroll needed), scrollThumbRect returns empty and the shell may reclaim the gutter.
// 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.
inline constexpr int kScrollbarWidth = 10; inline constexpr int kScrollbarWidth = 10;
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a // Height of the type-to-filter search box the shell draws above the tab strip.
// thin band spanning the browser width). Exposed so the shell reserves the band and tests // capture_browser's tab strip + grid sit below this band.
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
inline constexpr int kSearchBoxHeight = 22; inline constexpr int kSearchBoxHeight = 22;
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s // Total pixel height the card grid needs for `cardCount` cards at `layout`'s column
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height. // count: rows (ceil(cardCount / columns)) times the fixed cell height.
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
int scrollContentHeight(const BrowserLayout& layout, int cardCount); int scrollContentHeight(const BrowserLayout& layout, int cardCount);
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0. // Maximum scroll offset: content height minus 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.
int scrollMaxOffset(const BrowserLayout& layout, int cardCount); int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel // Clamps a proposed scroll offset into [0, scrollMaxOffset].
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
// or above the first. Pure.
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset); 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 // Half-open range of card indices [first, last) at least partially visible at scroll
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every // `offset` (assumed pre-clamped). The shell draws only these cards.
// 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.
struct VisibleRange { struct VisibleRange {
int first = 0; // first card index drawn (inclusive) int first = 0;
int last = 0; // one past the last card index drawn (exclusive) int last = 0;
}; };
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset); 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 // Cell rect of card `index` shifted up by the scroll offset (the shell still adds the
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with // browser sub-area origin).
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset); Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to // Vertical scrollbar thumb rect within the grid's right-edge gutter, sized proportional
// the visible fraction (grid height / content height) and positioned proportional to the // to the visible fraction and positioned proportional to the scroll offset. Empty when
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell // the content fits. A minimum thumb height keeps a tiny thumb grabbable on a long bank.
// 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.
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset); 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 // Maps a thumb-drag to a new (clamped) scroll offset: `startOffset` shifted by the pixel
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new // delta scaled from thumb-track pixels to content pixels. A degenerate track or fitting
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to // content pins to startOffset.
// 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.
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels); 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 // Search-box rect: 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; // area. `w` is the browser sub-area width; the shell adds its origin.
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
Rect searchBoxRect(int w); Rect searchBoxRect(int w);
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query // 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 // matches everything.
// 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.
bool nameMatchesQuery(const std::string& name, const std::string& query); bool nameMatchesQuery(const std::string& name, const std::string& query);
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving // Narrows 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 // order. An empty query returns every index. Kept name-only (indices, not card structs)
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this // so this module stays free of the sample_map/bank_book chain — the shell applies the
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list // bank filter first, then feeds the surviving display names here.
// and applies the bank filter FIRST, then feeds the surviving display names here (search
// narrows within the bank). Pure.
std::vector<int> filterNameIndices(const std::vector<std::string>& names, std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query); 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 // 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 // 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 // covers the full window. Homed here (not editor_geometry) because the search-box
// this single layout so they never drift. Homed here (not editor_geometry) because the // height feeds it.
// search-box height feeds it — browser_scroll already owns the search/scroll geometry.
struct BrowseModal { struct BrowseModal {
Rect title; Rect title;
Rect back; // the "Back" title-band button Rect back;
Rect search; // the type-to-filter box (absolute) Rect search;
Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin Rect content; // browser sub-area (tabs + grid) — layoutBrowser's origin
Rect cancel; // footer Cancel Rect cancel;
Rect confirm; // footer Load (confirm) Rect confirm;
}; };
BrowseModal computeBrowseModal(int w, int h); BrowseModal computeBrowseModal(int w, int h);
+3 -3
View File
@@ -8,9 +8,9 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// The left edge of tab i in a strip of the given x-origin and width divided into `count` // Left edge of tab i in a strip divided into `count` equal segments (mirror of
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same // mode_switch::segmentEdge). Same formula for every boundary so consecutive tabs share
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly. // an exact edge.
int tabEdge(int x, int width, int i, int count) { int tabEdge(int x, int width, int i, int count) {
return x + (i * width) / count; return x + (i * width) / count;
} }
+36 -61
View File
@@ -1,92 +1,67 @@
// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default // capture_browser.h — layout + hit-test for the capture-first editor's default face: a
// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3, // scannable grid of capture cards with a bank-filter tab strip above it. Mirror of
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / // editor_geometry/embed_strip/mode_switch; the shell draws thumbnails/names/badges and
// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is // routes clicks into these functions.
// 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.
// //
// The browser replaces the old text item-list (the named anti-pattern). It lays out N // This module knows only counts and rects — it draws nothing and holds no sample data;
// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab // the shell owns the SampleChoice list, peak envelopes, and filter state.
// 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".
// //
// Scroll is NOT here (S12 layers it over this module). The browser lays out every card // Scroll is layered on top by browser_scroll — this module lays out every card top-down
// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping // and the shell clips at the bottom until a scroll offset is applied.
// 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).
#pragma once #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 { namespace reasampler::instrument::ui {
// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a // Fixed browser metrics, exposed so the shell and tests agree.
// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list. inline constexpr int kBrowserTabHeight = 26;
inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height inline constexpr int kBrowserCardWidth = 132;
inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter) inline constexpr int kBrowserCardHeight = 84;
inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter) inline constexpr int kBrowserCardGutter = 8;
inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card inline constexpr int kBrowserThumbHeight = 44;
inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card
// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to // Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
// the area so a degenerate (tiny/zero) size never yields an inverted rect.
struct BrowserLayout { struct BrowserLayout {
Rect tabStrip; // top: the bank-filter tabs Rect tabStrip;
Rect grid; // below the tabs: where the capture cards tile Rect grid;
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width 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 // Divide a (w x h) browser area into its regions and compute the column count. columns =
// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never // max(1, grid.width/cardWidth) so a browser narrower than one card still lays out a
// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a // single column.
// browser narrower than one card still lays out a single column. A zero/negative size
// yields empty rects + columns==1.
BrowserLayout layoutBrowser(int w, int h); BrowserLayout layoutBrowser(int w, int h);
// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then // Cell rect of capture card `index` (0-based), left-to-right then top-to-bottom across
// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect // `columns`. This is the full cell (card + gutter); cardContentRect insets it.
// 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.
Rect cardCellRect(const BrowserLayout& layout, int index); Rect cardCellRect(const BrowserLayout& layout, int index);
// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides. // 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.
Rect cardContentRect(const BrowserLayout& layout, int index); 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 // 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 // kBrowserThumbHeight (clamped to the card height).
// name + badge go in the remaining strip below. Pure.
Rect cardThumbnailRect(const BrowserLayout& layout, int index); Rect cardThumbnailRect(const BrowserLayout& layout, int index);
// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band. // Name/badge sub-rect below the thumbnail.
// The shell draws the display name + root/key badge here. Pure.
Rect cardLabelRect(const BrowserLayout& layout, int index); 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 // Card a click at (x, y) lands on, given `cardCount` cards, or -1 for a miss. Only the
// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT // card content rect counts as a hit — a click in the inter-card gutter is a miss.
// rect counts as a hit — a click in the inter-card gutter is a miss. Pure.
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y); 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): // Divides tabStrip into `tabCount` equal segments: one tab per bank plus a leading "All"
// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount == // tab the shell prepends. This module only divides the strip + hit-tests; the shell
// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell // supplies labels and tracks the active tab.
// 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).
// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal // Rect of tab `index` when the strip is divided into `tabCount` equal segments. The last
// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with // tab absorbs any width remainder so the tabs tile the whole strip with no gap.
// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an
// empty rect. Pure.
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index); 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); int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+18 -24
View File
@@ -1,17 +1,12 @@
// curve_popup.h — PURE sheet geometry + dismissal test for the r11 velocity-curve popup // curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor.
// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror // Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested // these rects.
// 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.
// //
// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED // A centered sheet over the Sample face (a lighter wash than Browse's, since this is a
// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's // focused sub-editor, not a view change): width/height each clamp to a fraction of the
// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520), // window within min/max bounds. A title row sits over the curve box. The curve box rect
// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP" // here is the border rect — the shell derives the mapping box via its curveBoxFromRect
// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the // formula, so the popup editor and the Zone-panel inline editor share coordinates.
// 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.
#pragma once #pragma once
@@ -19,7 +14,7 @@
namespace reasampler::instrument::ui { 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 kCurvePopupMinW = 360;
inline constexpr int kCurvePopupMaxW = 520; inline constexpr int kCurvePopupMaxW = 520;
inline constexpr int kCurvePopupMinH = 260; 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) inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
struct CurvePopupLayout { struct CurvePopupLayout {
Rect sheet; // the bg/panel sheet, centered in the window Rect sheet;
Rect title; // the caption text rect (left part of the title row) Rect title;
Rect close; // the 18x18 Close (x) button, right-anchored in the title row Rect close; // Close (x) button, right-anchored in the title row
Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect) 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 // Popup geometry for a (w x h) window: sheet width clamp(60% w, min..max) and height
// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate // clamp(55% h, min..max), each additionally capped at the window dimension so a
// window never yields an overhanging sheet — centered; title row + close button at the top; // degenerate window never yields an overhanging sheet — centered.
// the curve box filling the remainder inside kCurvePopupPad margins. Pure.
CurvePopupLayout computeCurvePopup(int w, int h); CurvePopupLayout computeCurvePopup(int w, int h);
// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test. // True when (x, y) lands outside the sheet (on the wash) — the click-outside dismissal
// The shell additionally gates on "no drag in flight" (spec). Pure. // test. The shell additionally gates on "no drag in flight".
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y); bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+16 -51
View File
@@ -8,8 +8,6 @@ namespace reasampler::instrument::ui {
namespace { 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 kTitleBarHeight = 28;
constexpr int kButtonMargin = 10; constexpr int kButtonMargin = 10;
constexpr int kButtonWidth = 120; constexpr int kButtonWidth = 120;
@@ -17,26 +15,18 @@ constexpr int kButtonHeight = 24;
} // namespace } // 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) { EditorLayout layoutEditor(int w, int h) {
// Clamp the surface to non-negative extents so a degenerate view can't produce // Clamp to non-negative extents so a degenerate view can't produce inverted rects.
// inverted rects.
const int cw = std::max(0, w); const int cw = std::max(0, w);
const int ch = std::max(0, h); const int ch = std::max(0, h);
EditorLayout out; EditorLayout out;
// Title bar spans the top, clamped so it never exceeds the client height.
const int titleH = std::min(kTitleBarHeight, ch); const int titleH = std::min(kTitleBarHeight, ch);
out.titleBar = Rect::ltrb(0, 0, cw, titleH); out.titleBar = Rect::ltrb(0, 0, cw, titleH);
// Canvas is everything below the title bar.
out.canvas = Rect::ltrb(0, titleH, cw, ch); 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 // Button inset from the canvas top-left, clamped so it never overhangs a small view.
// fit inside the canvas so it never overhangs on a small view.
const int bx = out.canvas.x + kButtonMargin; const int bx = out.canvas.x + kButtonMargin;
const int by = out.canvas.y + kButtonMargin; const int by = out.canvas.y + kButtonMargin;
const int bRight = std::min(bx + kButtonWidth, out.canvas.right()); 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) { int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
if (rowCount <= 0) return -1; 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 (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
if (y < layout.canvas.y) 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; if (y >= layout.canvas.bottom()) return -1;
const int index = (y - layout.canvas.y) / kSampleRowHeight; const int index = (y - layout.canvas.y) / kSampleRowHeight;
if (index < 0 || index >= rowCount) return -1; 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); const Rect r = sampleRowRect(layout, index);
if (y >= r.bottom()) return -1; if (y >= r.bottom()) return -1;
return index; return index;
@@ -80,9 +66,6 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) {
out.base = layoutEditor(w, h); out.base = layoutEditor(w, h);
const Rect& canvas = out.base.canvas; 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 canvasW = std::max(0, canvas.width);
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
const int splitX = std::max(canvas.x, canvas.right() - splitW); 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.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), 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)); const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
out.addZoneButton = out.addZoneButton =
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(), Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
out.zonePanel.y + addH); out.zonePanel.y + addH);
// Zone rows stack below the button.
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(), out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
out.zonePanel.right(), out.zonePanel.bottom()); out.zonePanel.right(), out.zonePanel.bottom());
return out; return out;
@@ -138,10 +119,8 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int
const Rect row = zoneRowRect(layout, index); const Rect row = zoneRowRect(layout, index);
if (y >= row.bottom()) return ZoneHit{}; if (y >= row.bottom()) return ZoneHit{};
// Seven mini-buttons pinned to the right edge, right-to-left: // Seven mini-buttons pinned to the right edge, each kZoneCtrlWidth wide, in slot
// delete, root+, root-, high+, high-, low+, low- // order 0..6; a click left of the leftmost is the label ("select").
// 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.
const ZoneField fields[7] = { const ZoneField fields[7] = {
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, 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); 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 { namespace {
// Fixed band metrics (formerly the editor shell's anon-ns constants). constexpr int kHeroMinHeight = 150; // elastic hero's floor
constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11)
constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle 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 // Cluster's fixed right-anchored run: Preview button, vel knob cell, curve button, Mono|Stereo.
// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo).
constexpr int kPreviewBtnW = 64; constexpr int kPreviewBtnW = 64;
constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) constexpr int kVelCellW = 48;
constexpr int kCurveBtnSize = 28; // the square curve-preview button constexpr int kCurveBtnSize = 28;
// The S7 mono/stereo toggle segments.
constexpr int kChanSegW = 52; constexpr int kChanSegW = 52;
constexpr int kChanSegH = 18; constexpr int kChanSegH = 18;
} // namespace } // namespace
// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed // Band order: title (fixed) -> hero (elastic, absorbs remaining height, floor
// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- // kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-anchored). A
// anchored). When the window is too short for the floor (below the checkSizeConstraint // window too short for the floor keeps the hero at its floor and clips lower bands.
// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the
// window bottom gracefully.
SampleBands computeSampleBands(int w, int h, int deckH) { SampleBands computeSampleBands(int w, int h, int deckH) {
SampleBands b; SampleBands b;
const int titleH = (std::min)(kTitleHeight, h); const int titleH = (std::min)(kTitleHeight, h);
@@ -214,7 +183,6 @@ SampleBands computeSampleBands(int w, int h, int deckH) {
return b; return b;
} }
// Draw + hit-test both derive from this ONE formula.
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) { ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) {
ClusterRects r; ClusterRects r;
const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2; 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()); 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 // Sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px gap.
// gap, padded kPad horizontally. All call sites use this formula.
Rect zonesStripArea(const Rect& content) { Rect zonesStripArea(const Rect& content) {
const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12 const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12
return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad, return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad,
stripTop + kStripBandHeight); stripTop + kStripBandHeight);
} }
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom // Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom.
// without re-inlining the strip arithmetic here.
Rect noteEntryFieldsArea(const Rect& content) { Rect noteEntryFieldsArea(const Rect& content) {
const int stripBottom = zonesStripArea(content).bottom(); const int stripBottom = zonesStripArea(content).bottom();
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8) const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8)
@@ -292,9 +258,8 @@ Rect zonesControlPanel(const Rect& content) {
content.bottom() - 4); content.bottom() - 4);
} }
// FB2 (R11-F2 parity): the deck lays out from the panel top (top-anchored), with a // Top-anchored; reserves a column at the panel's right for the curve-preview button so
// column at the panel's right reserved for the mini curve-preview button so no deck row // no deck row starts inside it.
// starts inside it.
Rect zonesDeckArea(const Rect& content) { Rect zonesDeckArea(const Rect& content) {
const Rect panel = zonesControlPanel(content); const Rect panel = zonesControlPanel(content);
return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom()); return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom());
+78 -118
View File
@@ -1,14 +1,6 @@
// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE // editor_geometry.h — view geometry + hit-test for the VST3 IPlugView LICE editor. The
// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. // IPlugView shell owns window/bitmap/SWELL plumbing; the rectangle math and hit-testing
// // live here so they can be unit-tested outside the DAW.
// 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.
#pragma once #pragma once
@@ -16,101 +8,80 @@
namespace reasampler::instrument::ui { 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 Rect = ::reasampler::ui::Rect;
using ::reasampler::ui::contains; using ::reasampler::ui::contains;
// The regions the spike editor draws, derived from the current view size. All are // Title band + one button + remaining canvas, clamped so a degenerate (too-small) view
// clamped to the client area so a degenerate (too-small) view never yields a region // never yields a region spilling outside the surface.
// that spills outside the surface.
struct EditorLayout { struct EditorLayout {
Rect titleBar; // top band: the plugin name + a live-state readout Rect titleBar;
Rect button; // a single clickable button (proves hit-test routing) Rect button;
Rect canvas; // the remaining surface below the title bar Rect canvas;
}; };
// Divide a (w x h) client area into the spike editor's regions. Pure: the same // Divide a (w x h) client area into the editor's top-level regions. Pure.
// 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.
EditorLayout layoutEditor(int w, int h); EditorLayout layoutEditor(int w, int h);
// The editor's hit-test targets. kNone means the point landed on inert surface.
enum class HitTarget { enum class HitTarget {
kNone, kNone,
kButton, kButton,
}; };
// Classify a click at (x, y) against a layout. The button wins only when the point is // Classify a click at (x, y) against a layout.
// inside the button rect; everything else (including the title bar and empty canvas)
// is kNone in the spike.
HitTarget hitTest(const EditorLayout& layout, int x, int y); 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 // A vertical stack of fixed-height rows below the title bar; clicking a row selects that
// below the title bar; clicking a row selects that sample. This is the pure geometry: // sample. Pure geometry only — the shell draws names and routes the click.
// 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.
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
inline constexpr int kSampleRowHeight = 22; inline constexpr int kSampleRowHeight = 22;
// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside // Rect for row `index` (0-based), laid out top-down inside the layout's canvas. Rows
// the layout's canvas. Rows beyond what the canvas can show are still computed (the // beyond what the canvas can show are still computed (the shell clips at paint time); a
// shell clips at paint time); a negative index yields an empty rect. Pure. // negative index yields an empty rect.
Rect sampleRowRect(const EditorLayout& layout, int index); 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 // Row index a click at (x, y) lands on given `rowCount` rows, or -1 for a click outside
// outside the list (above the first row, past the last, or on the title bar). Pure. // the list.
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y); 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 // Splits the canvas into a LEFT bank-sample list (the sample-selection rows above, reused
// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing // as the "sample to add / fallback pick") and a RIGHT zone panel listing the performance
// the performance map's zones. An "Add Zone" button sits at the top of the zone panel; // map's zones. An "Add Zone" button sits at the top of the zone panel; each zone row
// each zone row carries small nudge/delete controls so the user can set the range and // carries nudge/delete mini-buttons (LICE has no native numeric entry field).
// 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.
// Fixed metrics for the zone panel, exposed so the shell and tests agree.
inline constexpr int kZoneRowHeight = 24; inline constexpr int kZoneRowHeight = 24;
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas 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 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 // Clamps every rect to the canvas so a degenerate view still yields in-bounds rects.
// canvas so a degenerate view yields in-bounds rects.
struct KeymapEditorLayout { struct KeymapEditorLayout {
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half) EditorLayout base;
Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here) Rect sampleList; // LEFT column
Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows Rect zonePanel; // RIGHT column
Rect addZoneButton; // top of the zone panel 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); KeymapEditorLayout layoutKeymapEditor(int w, int h);
// The rectangle for bank-sample row `index` inside the LEFT sample list column of a // Rect for bank-sample row `index` inside the LEFT column. Negative index -> empty.
// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside
// sampleList. Negative index -> empty. Pure.
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index); 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); int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative // Rect for zone row `index` inside zoneRowArea. Negative index -> empty.
// index -> empty. Pure.
Rect zoneRowRect(const KeymapEditorLayout& layout, int index); Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
// A zone row's interactive fields. The row is a horizontal strip: a label on the left, // A zone row's interactive fields: a label on the left, then seven fixed-width
// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+, // mini-buttons on the right (low-, low+, high-, high+, root-, root+, delete). kZoneNone
// root-, root+, delete). kZoneNone means the click missed a control // means the click missed a control (e.g. the label) — the shell may still treat that as
// (e.g. on the label) — the shell may still treat that as "select this zone". // "select this zone".
enum class ZoneField { enum class ZoneField {
kZoneNone, kZoneNone,
kLowDown, kLowDown,
@@ -122,101 +93,90 @@ enum class ZoneField {
kDelete, kDelete,
}; };
// The result of hit-testing a click against the zone rows: which zone row (or -1) and // Which zone row (or -1) and which field within it a click landed on. A click on
// which field within it. A click on the "Add Zone" button is reported separately by // "Add Zone" is reported separately by addZoneHitTest.
// addZoneHitTest — this covers only the zone rows.
struct ZoneHit { struct ZoneHit {
int zoneIndex = -1; int zoneIndex = -1;
ZoneField field = ZoneField::kZoneNone; ZoneField field = ZoneField::kZoneNone;
}; };
// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a // Classify a click at (x, y) against `zoneCount` zone rows. {-1, kZoneNone} for a miss.
// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width // Within a row, the seven mini-buttons occupy fixed-width slots on the right edge; a
// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete); // click left of those slots is {index, kZoneNone} (the label area — "select").
// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure.
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y); 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); 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 // The capture-first editor's band/cluster/zone-surface layout math. Draw and hit-test
// reasampler_editor shell where it had accreted untestable (the §2 scope gap). Draw and // both derive every rect from these formulas so they can never drift; the shell only
// hit-test both derive every rect from these ONE formulas so they can never drift; the // draws + routes. The Browse-modal layout lives in browser_scroll (its search box
// shell only draws + routes. The Browse-modal layout lives in browser_scroll (its search // height feeds it).
// box height feeds it — dependency-clean placement beside its scroll/search siblings).
// 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 kPad = 8;
inline constexpr int kTitleHeight = 26; inline constexpr int kTitleHeight = 26;
inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons 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 // Sample-face bands (top->bottom): TITLE (name + Browse/Zone nav), a full-width elastic
// FULL-WIDTH ELASTIC HERO (absorbs all height left after the fixed bands, floor // HERO (absorbs all height left after the fixed bands, floored), the root+preview
// kHeroMinHeight), the ROOT + PREVIEW CLUSTER, and the bottom-anchored KNOB DECK // CLUSTER, and the bottom-anchored knob DECK (height `deckH` from knob_deck's wrap). A
// (height `deckH` from the pure knob_deck wrap). When the window is too short for the // window shorter than the hero floor clips the lower bands past the window bottom.
// hero floor (below the checkSizeConstraint minimum — defensive), the hero keeps its
// floor and the lower bands clip past the window bottom gracefully.
struct SampleBands { struct SampleBands {
Rect title; // top: name + Browse/Zone nav buttons Rect title;
Rect navBrowse; // the "Browse" title-band button Rect navBrowse;
Rect navZone; // the "Zone" title-band button Rect navZone;
Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay Rect hero; // waveform + envelope overlay
Rect cluster; // root strip + preview + vel knob + curve button + channel toggle 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); SampleBands computeSampleBands(int w, int h, int deckH);
// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the // Cluster sub-rects: the root strip keeps the left side at remainder width; the right
// right side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve // side is the fixed-width right-anchored run (Preview · vel knob cell · curve button ·
// preview button 28 · Mono|Stereo). `knobSize` is the deck knob square (knob_deck's // Mono|Stereo). `knobSize` is the deck knob square, passed in so this module does not
// kDeckKnobSize — passed in so this module does not depend on knob_deck). // depend on knob_deck.
struct ClusterRects { struct ClusterRects {
Rect rootStrip; // remainder-width fenced root strip Rect rootStrip;
Rect preview; // the preview-trigger button Rect preview;
Rect velCell; // the radial preview-velocity knob cell (knob + label band) Rect velCell; // preview-velocity knob cell (knob + label band)
Rect velKnob; // the knob square at the cell's top Rect velKnob;
Rect velLabel; // the label band beneath it Rect velLabel;
Rect curveBtn; // the mini curve-preview button (opens the popup) Rect curveBtn; // opens the curve-preview popup
}; };
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize); ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize);
// The S7 mono/stereo toggle: a two-segment control right-anchored in `area`, vertically // Mono/stereo toggle: a two-segment control right-anchored in `area`, vertically centered.
// centered. Returns {mono-segment, stereo-segment}, side by side.
struct ChannelToggleRects { struct ChannelToggleRects {
Rect mono; Rect mono;
Rect stereo; Rect stereo;
}; };
ChannelToggleRects channelToggleRects(const Rect& area); 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); Rect zoneContentArea(int w, int h);
// The Zone/Browse "Back" title-band button (right-anchored — the same slot the Sample // Zone/Browse "Back" button — the same slot the Sample face's Zone nav button occupies.
// face's Zone nav button occupies).
Rect zoneBackRect(int w, int h); Rect zoneBackRect(int w, int h);
// The "+ Add Zone" affordance at the top of the Zone content, and the "Delete" button // "+ Add Zone" affordance and the "Delete" button beside it (Delete only draws/hits
// beside it (Delete only draws/hits when a zone is selected). // when a zone is selected).
Rect zoneAddRect(const Rect& content); Rect zoneAddRect(const Rect& content);
Rect zoneDeleteRect(const Rect& addR); Rect zoneDeleteRect(const Rect& addR);
// The Zone-view keyboard strip rect: below the "+ Add Zone" affordance with a 12px gap, // Zone-view keyboard strip rect: below "+ Add Zone" with a 12px gap, padded kPad
// padded kPad horizontally. // horizontally.
Rect zonesStripArea(const Rect& content); Rect zonesStripArea(const Rect& content);
// The S12 numeric-entry field ROW area inside the Zones legend (a band to the right of // Numeric-entry field row area inside the Zones legend, and the rect of field `f`
// the sample label), and the rect of field `f` (0=low, 1=high, 2=root) within it — // (0=low, 1=high, 2=root) within it — three equal segments left-to-right. Out-of-range
// three equal segments left-to-right. An out-of-range index yields an empty rect. // index yields an empty rect.
Rect noteEntryFieldsArea(const Rect& content); Rect noteEntryFieldsArea(const Rect& content);
Rect noteEntryFieldRect(const Rect& fields, int f); Rect noteEntryFieldRect(const Rect& fields, int f);
// The per-zone parameter panel below the strip + the one-line legend, running to the // Per-zone parameter panel below the strip + legend, running to the content bottom; the
// content bottom; the FB2 knob-deck area within it (a column at the right reserved for // knob-deck area within it (a right column reserved for the curve-preview button); and
// the mini curve-preview button); and that button's rect (the cluster's 28px square, // that button's rect (right-anchored at the panel top).
// right-anchored at the panel top).
Rect zonesControlPanel(const Rect& content); Rect zonesControlPanel(const Rect& content);
Rect zonesDeckArea(const Rect& content); Rect zonesDeckArea(const Rect& content);
Rect zonesCurveButton(const Rect& content); Rect zonesCurveButton(const Rect& content);
+5 -8
View File
@@ -8,17 +8,15 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// Clamp a MIDI note to [0, kEmbedKeyCount-1].
int clampNote(int n) { int clampNote(int n) {
if (n < 0) return 0; if (n < 0) return 0;
if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1; if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1;
return n; return n;
} }
// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given // Maps a key boundary (0..128) to an x pixel; keyEdge==128 maps to the band's right. A
// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right. // zone's left uses floor(low) and its right uses floor(high+1), tiling adjacent zones
// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1), // without a seam.
// which tiles adjacent zones without a seam.
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft; if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth; if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth;
@@ -33,9 +31,8 @@ EmbedLayout layoutEmbed(int w, int h) {
EmbedLayout out; EmbedLayout out;
// The level band takes a fixed height at the bottom, but never so much that the keymap // Fixed height at the bottom, but never so much that the keymap falls below its
// above it falls below its minimum (or that the band exceeds the area). On a very short // minimum; on a very short area the band yields to the keymap entirely.
// area the band yields to the keymap entirely.
int bandH = std::min(kEmbedLevelBandHeight, ch); int bandH = std::min(kEmbedLevelBandHeight, ch);
if (ch - bandH < kEmbedKeymapMinHeight) { if (ch - bandH < kEmbedKeymapMinHeight) {
bandH = std::max(0, ch - kEmbedKeymapMinHeight); bandH = std::max(0, ch - kEmbedKeymapMinHeight);
+28 -47
View File
@@ -1,76 +1,57 @@
// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3, // embed_strip.h — layout + hit-test for the embedded TCP/MCP strip. Mirror of
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / // editor_geometry/mode_switch; the embed shell marshals REAPER's embed messages (paint
// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives // bitmap + mouse coords) into these functions.
// 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.
// //
// The strip is a single compact band REAPER draws inline in the track/mixer control panel // A single compact band REAPER draws inline in the track/mixer control panel via the
// (context TCP or MCP) via the Cockos embedded-UI surface. It shows: // Cockos embedded-UI surface: each performance zone as a horizontal segment across the
// * the zone layout — each performance zone as a horizontal segment across the keyboard // keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band
// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and // at the bottom. Interaction is zone selection only — no editing.
// * 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.
#pragma once #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 { 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; inline constexpr int kEmbedKeyCount = 128;
// Fixed metrics for the strip, exposed so the shell and tests agree. // 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 kEmbedLevelBandHeight = 4;
inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller inline constexpr int kEmbedKeymapMinHeight = 6;
// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal // One zone rendered on the strip: its inclusive MIDI key range the minimal projection
// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM // of a PerformanceZone the strip needs (no sample ids or PCM). Expected in [0,127] with
// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are // low <= high; layout clamps defensively regardless.
// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed
// zone never yields an out-of-strip rect.
struct EmbedZone { struct EmbedZone {
int lowNote = 0; int lowNote = 0;
int highNote = 127; int highNote = 127;
}; };
// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to // Clamped to the area so a degenerate (tiny) size never yields a region spilling outside
// the area so a degenerate (tiny) size never yields a region spilling outside the surface. // the surface.
struct EmbedLayout { struct EmbedLayout {
Rect keymap; // top: the zone-segment band (the compact keymap) Rect keymap; // top: zone-segment band
Rect levelBand; // bottom: the thin level/activity indicator Rect levelBand; // bottom: level/activity indicator
}; };
// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout. // Divide a (w x h) embed area into the strip's regions. The level band takes a fixed
// The level band takes a fixed height at the bottom (clamped so it never exceeds the area // height at the bottom (clamped so it never starves the keymap below
// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or // kEmbedKeymapMinHeight); the keymap takes the rest.
// negative size yields empty rects (no inversion).
EmbedLayout layoutEmbed(int w, int h); EmbedLayout layoutEmbed(int w, int h);
// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote] // Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote]
// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect // (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or
// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g. // overlap. Notes clamp to [0,127] and low clamps to <= high.
// 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.
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote); 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 // Zone a click at (x, y) lands on, given zones in draw order, or -1 for a miss. When
// outside the keymap band or on a key not covered by any zone. When zones overlap on a // zones overlap on a key, the first covering zone in order wins — mirroring the sampler
// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match // core's first-match Keymap::resolve, so selection agrees with playback.
// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure.
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
int y); int y);
// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the // Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand
// returned rect is the left sub-rectangle of levelBand whose width is level * band width // whose width is level * band width.
// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure.
Rect levelFillRect(const EmbedLayout& layout, double level); Rect levelFillRect(const EmbedLayout& layout, double level);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+19 -43
View File
@@ -9,34 +9,28 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion).
// 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.
double secondsPerPixel(const Rect& area, double totalSeconds) { double secondsPerPixel(const Rect& area, double totalSeconds) {
const int w = std::max(0, area.width); const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return 0.0; if (w <= 0 || totalSeconds <= 0.0) return 0.0;
return totalSeconds / static_cast<double>(w); return totalSeconds / static_cast<double>(w);
} }
// Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's // Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
// param-domain gatePxPerSecond(area) scale — sample-length-free, matching // dragged handle tracks the cursor 1:1.
// 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.
double gateSecondsPerPixel(const Rect& area) { double gateSecondsPerPixel(const Rect& area) {
const double pps = gatePxPerSecond(area); const double pps = gatePxPerSecond(area);
return pps > 0.0 ? 1.0 / pps : 0.0; 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 // Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
double levelPerPixel(const Rect& area) { double levelPerPixel(const Rect& area) {
const int h = std::max(0, area.height); const int h = std::max(0, area.height);
if (h <= 1) return 0.0; if (h <= 1) return 0.0;
return 1.0 / static_cast<double>(h - 1); return 1.0 / static_cast<double>(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) { bool isDraggable(EnvNode n) {
switch (n) { switch (n) {
case EnvNode::Origin: 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 // Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
// write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a // Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
// 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.
bool nodeInMode(EnvNode n, EnvMode m) { bool nodeInMode(EnvNode n, EnvMode m) {
switch (n) { switch (n) {
case EnvNode::AttackEnd: case EnvNode::AttackEnd:
@@ -64,7 +56,7 @@ bool nodeInMode(EnvNode n, EnvMode m) {
return m == EnvMode::Trigger; return m == EnvMode::Trigger;
case EnvNode::Origin: case EnvNode::Origin:
case EnvNode::ReleaseStart: case EnvNode::ReleaseStart:
return false; // never draggable in any mode (isDraggable filters these anyway) return false;
} }
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) { NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds); const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance // 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 // ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
// coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only // coincidence (FadeOutStart overlaps LengthEnd and wins).
// 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.
NodeHit best; NodeHit best;
int bestDist = kNodeGrabRadius + 1; int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) { for (const EnvVertex& v : poly) {
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue; 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)); 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; bestDist = dist;
best = NodeHit{true, v.node}; best = NodeHit{true, v.node};
} }
@@ -101,16 +90,12 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
const double secPerPx = secondsPerPixel(area, totalSeconds); const double secPerPx = secondsPerPixel(area, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx; const double dSec = static_cast<double>(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<double>(dxPixels) * gateSecondsPerPixel(area); const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area);
switch (node) { switch (node) {
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative // Gate: each cumulative-time node edits its own segment duration. Non-negative durations
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour // ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint. // never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd: case EnvNode::AttackEnd:
out.attackSeconds = out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); 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); out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break; break;
case EnvNode::DecayEnd: { case EnvNode::DecayEnd: {
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower // X sets decay time, Y sets sustain level (drag down = higher y = lower level).
// level, so subtract the level delta).
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(area); const double lvlPerPx = levelPerPixel(area);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx; const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
@@ -132,17 +116,9 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds); std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
break; break;
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED // 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 // span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade
// two fade nodes never cross (each clamps against the other), and length in [0, max]. // nodes from crossing (each clamps against the other).
//
// 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.
case EnvNode::FadeInEnd: { case EnvNode::FadeInEnd: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division if (dxPixels == 0) break; // zero-motion grab: no param change, no division
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds; const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
+35 -72
View File
@@ -1,41 +1,18 @@
// envelope_edit.h — PURE node hit-test + pixel-deltaclamped-param inverse map for the S-VIEW-3 // envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror // envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here, // outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
// 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.
// //
// TWO SURFACES, ONE MODEL. envelope_overlay owns the paramspolyline FORWARD map (draw); this // envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME // (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the zone
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node // every paint), so a node drag and a slider edit are two views on one source of truth.
// drag and a slider edit are two views on one source of truth and can never diverge.
// //
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't: // A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end // between time predecessor/successor) and range-clamped to the same per-param [min,max] the
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0. // slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
// * 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.
// //
// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart, // Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the // Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the // editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
// 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.
#pragma once #pragma once
@@ -47,60 +24,46 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and // Pick radius (px) around a node's drawn point, in both x and y. Mirrors waveform_view's
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a // kMarkerGrabWidth.
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
inline constexpr int kNodeGrabRadius = 6; inline constexpr int kNodeGrabRadius = 6;
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All // Per-param clamp bounds the shell supplies the same maxima its sliders map [0,1] onto.
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the // Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time.
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative // Defaults are placeholders; the shell overrides with its live slider domain.
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
struct EnvClampBounds { struct EnvClampBounds {
double maxAttackSeconds = 4.0; // upper bound of the attack slider double maxAttackSeconds = 4.0;
double maxHoldSeconds = 4.0; double maxHoldSeconds = 4.0;
double maxDecaySeconds = 4.0; double maxDecaySeconds = 4.0;
double maxReleaseSeconds = 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 maxFadeInFraction = 1.0;
double maxFadeOutFraction = 1.0; double maxFadeOutFraction = 1.0;
double maxLengthFraction = 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 // Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles). // inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node;
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a // Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable), // wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters
// and a node from the OTHER mode is never returned (the degenerate baseline's ReleaseEnd vertex // for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade
// is not grabbable in Trigger mode). The NEAREST node within the radius wins (Chebyshev // can be dragged open from zero). Gate nodes never coincide (forward map enforces
// distance); an exact tie goes to the earlier draw-order node (FA2 — deterministic). Gate nodes // kGateNodeSepPx), so every Gate handle is independently grabbable.
// 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.
struct NodeHit { struct NodeHit {
bool hit = false; bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true EnvNode node = EnvNode::Origin; // meaningful only when hit == true
}; };
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y); 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` — // Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay // shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta // `dyPixels` is the pixel delta since grab.
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe: // * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX), // [0, per-param max] and to its monotonic-in-time neighbours.
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time, // * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes.
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment // * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack). // `grabEnv` unchanged.
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1]. // Only the dragged node's param(s) change. Pure.
// 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).
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area, AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds, double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels); int dxPixels, int dyPixels);
+18 -29
View File
@@ -8,15 +8,14 @@
namespace reasampler::instrument::ui { 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) { int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width); const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return area.x; if (w <= 0 || totalSeconds <= 0.0) return area.x;
if (t < 0.0) t = 0.0; if (t < 0.0) t = 0.0;
// Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the // Clamp in double space before the int cast — a huge t would overflow a 32-bit long
// last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge // (Windows) and wrap to the wrong edge.
// t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round.
double px = (t / totalSeconds) * static_cast<double>(w); double px = (t / totalSeconds) * static_cast<double>(w);
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1); if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
return area.x + static_cast<int>(px + 0.5); return area.x + static_cast<int>(px + 0.5);
@@ -33,9 +32,7 @@ int gateTimedWidth(const Rect& area) {
double gatePxPerSecond(const Rect& area) { double gatePxPerSecond(const Rect& area) {
const int timedW = gateTimedWidth(area); const int timedW = gateTimedWidth(area);
if (timedW <= 0) return 0.0; if (timedW <= 0) return 0.0;
// Usable width = timed region minus the four per-segment separation bases and the last // Minus the four per-segment separation bases and the last in-bounds column, floored at 1.
// 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).
const double usable = const double usable =
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx)); std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds); return usable / (4.0 * kGateStageMaxSeconds);
@@ -46,8 +43,8 @@ int levelToY(const Rect& area, double level) {
if (h <= 0) return area.y; if (h <= 0) return area.y;
if (level < 0.0) level = 0.0; if (level < 0.0) level = 0.0;
if (level > 1.0) level = 1.0; if (level > 1.0) level = 1.0;
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The // Level 1 -> top row, level 0 -> bottom row; spans (h-1) px so both endpoints land on a
// range spans (h-1) pixels so both endpoints land ON a drawable row. // drawable row.
const int span = h - 1; const int span = h - 1;
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5); const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
return area.y + static_cast<int>(dy); return area.y + static_cast<int>(dy);
@@ -64,10 +61,8 @@ EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, dou
return v; return v;
} }
// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space — // Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain
// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in // timeToX map; clamps in double space before the int cast for the same overflow reason as above.
// 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).
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) { EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
const int w = std::max(1, area.width); const int w = std::max(1, area.width);
if (px < 0.0) px = 0.0; if (px < 0.0) px = 0.0;
@@ -81,18 +76,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
} }
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) { std::vector<EnvVertex> 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 a = std::max(0.0, env.attackSeconds);
const double h = std::max(0.0, env.holdSeconds); const double h = std::max(0.0, env.holdSeconds);
const double d = std::max(0.0, env.decaySeconds); const double d = std::max(0.0, env.decaySeconds);
const double r = std::max(0.0, env.releaseSeconds); const double r = std::max(0.0, env.releaseSeconds);
const double sus = clamp01(env.sustainLevel); const double sus = clamp01(env.sustainLevel);
// BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved // A/H/D/R map onto the timed region at the param-domain scale, each segment getting a
// sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment // kGateNodeSepPx base so nodes never coincide even at the tier-0 zero-hold/zero-decay
// gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually // defaults. The sustain plateau is the fixed reserve between DecayEnd and ReleaseStart.
// grabbable at any params, incl. 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 int W = std::max(1, area.width);
const double sustainPx = static_cast<double>(W - gateTimedWidth(area)); const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
const double sep = static_cast<double>(kGateNodeSepPx); const double sep = static_cast<double>(kGateNodeSepPx);
@@ -104,10 +97,9 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off) double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
// Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT // Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so
// preserving the minimum gaps, so trailing nodes stay individually separated instead of // trailing nodes stay separated instead of piling on the last column. This re-floor only
// piling on the last column. The re-floor pass only bites when the canvas is too narrow to // bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then.
// hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation).
const double xMax = static_cast<double>(W - 1); const double xMax = static_cast<double>(W - 1);
if (xRelease > xMax) { if (xRelease > xMax) {
xRelease = xMax; xRelease = xMax;
@@ -135,12 +127,11 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area, std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) { 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); const double len = clamp01(env.lengthFraction);
double fadeIn = clamp01(env.fadeInFraction); double fadeIn = clamp01(env.fadeInFraction);
double fadeOut = clamp01(env.fadeOutFraction); double fadeOut = clamp01(env.fadeOutFraction);
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the // Fades cannot overlap; trim fade-out first, matching the engine's TriggerParams clamp.
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn); if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
const double playSeconds = len * totalSeconds; const double playSeconds = len * totalSeconds;
@@ -161,12 +152,10 @@ std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) { double totalSeconds) {
if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) { 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), return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.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) return env.mode == EnvMode::Gate ? gatePolyline(env, area)
: triggerPolyline(env, area, totalSeconds); : triggerPolyline(env, area, totalSeconds);
} }
+54 -176
View File
@@ -1,59 +1,7 @@
// envelope_overlay.h — PURE amp-envelope polyline geometry for the S-VIEW-3 Sample-view // envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of // Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside // param_slider. The shell packs the zone's AdsrSeconds/TriggerParams into AmpEnvelope and draws
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue // the polyline plus a handle at each node (envelope_edit does the hit-test).
// 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.
#pragma once #pragma once
@@ -64,166 +12,96 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the // Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
enum class EnvMode { Gate, Trigger }; enum class EnvMode { Gate, Trigger };
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of // Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the // Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
// forward map (draw) and inverse map (edit) name the same nodes. // Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
//
// 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.
enum class EnvNode { enum class EnvNode {
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor) Origin, // t=0, level 0 — not draggable
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds AttackEnd, // Gate: attack ramp top — sets attackSeconds
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds HoldEnd, // Gate: hold plateau end — sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds, DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
// Y sets sustainLevel) ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) — ReleaseEnd, // Gate: release tail end — sets releaseSeconds
// a DRAWING vertex only, not a draggable handle (release is edited at FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
// ReleaseEnd; this vertex sits a fixed sustain-plateau width right of FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
// DecayEnd — the schematic note-off — Y = sustain level) LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
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
}; };
// The amp-envelope params the overlay draws — the small view struct the shell packs from the // Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include). // TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
// // core/instrument/CLAUDE.md.
// 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.
struct AmpEnvelope { struct AmpEnvelope {
EnvMode mode = EnvMode::Gate; EnvMode mode = EnvMode::Gate;
// Gate (AHDSR), seconds + a dimensionless sustain level. // Gate (AHDSR): seconds, plus a dimensionless sustain level.
double attackSeconds = 0.003; double attackSeconds = 0.003;
double holdSeconds = 0.0; double holdSeconds = 0.0;
double decaySeconds = 0.0; double decaySeconds = 0.0;
double sustainLevel = 1.0; double sustainLevel = 1.0;
double releaseSeconds = 0.060; double releaseSeconds = 0.060;
// Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length. // Trigger: fractions of the played span.
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/ double lengthFraction = 1.0;
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the double fadeInFraction = 0.0;
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy. double fadeOutFraction = 0.0;
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)
}; };
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the // One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin. // inspection.
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
struct EnvVertex { struct EnvVertex {
EnvNode node = EnvNode::Origin; EnvNode node = EnvNode::Origin;
int x = 0; // pixel x inside the overlay rect int x = 0;
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0) int y = 0;
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection) double level = 0.0;
bool operator==(const EnvVertex& o) const { bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level; 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 // Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining // carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
// 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).
inline constexpr double kGateSustainDisplayFraction = 0.15; inline constexpr double kGateSustainDisplayFraction = 0.15;
// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets // Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0 // defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen // radius (6) so a click can never tie between neighbours.
// 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.
inline constexpr int kGateNodeSepPx = 8; inline constexpr int kGateNodeSepPx = 8;
// The Gate schematic's per-stage time domain (seconds): the timed region represents the four // Gate schematic's per-stage time domain (seconds) the timed region represents four stages
// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider // end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its // lands exactly at the canvas edge.
// 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.
inline constexpr double kGateStageMaxSeconds = 2.0; inline constexpr double kGateStageMaxSeconds = 2.0;
// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve, // Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0 // a non-empty area; 0 for a zero/negative-width area.
// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale.
int gateTimedWidth(const Rect& area); int gateTimedWidth(const Rect& area);
// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width, // Pixels per second of the Gate timed region, independent of the sample's actual duration.
// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over // Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a // the cursor 1:1.
// 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.
double gatePxPerSecond(const Rect& area); double gatePxPerSecond(const Rect& area);
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds` // Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive); // duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices
// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads // are in draw order, Origin first.
// left-to-right in draw order, Origin first.
// //
// TIME BASE (FA2). // Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. The canvas splits into a TIMED // waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
// region of gateTimedWidth(area) px — where attack/hold/decay run from t=0 and the release // the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
// ramp runs after the plateau, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment // or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0].
// 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.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds); double totalSeconds);
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds -> // Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins // with envelope_edit's node hit-test so the drawn handle and its grab region agree.
// 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.
int timeToX(const Rect& area, double totalSeconds, double t); 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 // Maps a level [0,1] to a pixel y inside `area` (level 1 at the top, 0 at the bottom row),
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is // clamped. Shared with envelope_edit's node hit-test.
// 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.
int levelToY(const Rect& area, double level); int levelToY(const Rect& area, double level);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+8 -22
View File
@@ -14,10 +14,8 @@ int clampNote(int n) {
return n; return n;
} }
// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given // Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is
// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer // keyEdgeToX(N+1) — tiles adjacent keys/zones without a seam. Mirrors embed_strip::keyEdgeToX.
// 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).
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft; if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth; if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
@@ -37,8 +35,7 @@ StripLayout layoutStrip(int w, int h) {
int keyLeftX(const StripLayout& layout, int note) { int keyLeftX(const StripLayout& layout, int note) {
const Rect& band = layout.keys; const Rect& band = layout.keys;
const int bandWidth = std::max(0, band.width); 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 // note is a key (0..127); callers pass note+1 to get its right edge, 128 -> band right.
// get a key's right edge, and 128 maps to the band right.
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
return keyEdgeToX(band.x, bandWidth, edge); 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; if (!contains(band, x, y)) return -1;
const int bandWidth = std::max(0, band.width); const int bandWidth = std::max(0, band.width);
if (bandWidth <= 0) return -1; if (bandWidth <= 0) return -1;
// Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide // Inverts keyEdgeToX: the key whose half-open [leftX, rightX) contains x.
// the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127).
const int offset = x - band.x; const int offset = x - band.x;
int note = (offset * kStripKeyCount) / bandWidth; int note = (offset * kStripKeyCount) / bandWidth;
return clampNote(note); 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) { Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
int lo = clampNote(lowNote); int lo = clampNote(lowNote);
int hi = clampNote(highNote); 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 leftX = keyLeftX(layout, lo);
const int rightX = keyLeftX(layout, hi + 1); const int rightX = keyLeftX(layout, hi + 1);
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom()); 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; if (!contains(bar, x, y)) return ZoneGrab::kNone;
const int barW = bar.width; const int barW = bar.width;
// A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so // A narrow bar has no body: split at the midpoint, low edge wins the tie.
// a click exactly on the midpoint resizes low (deterministic).
if (barW < 2 * kStripEdgeGrabWidth) { if (barW < 2 * kStripEdgeGrabWidth) {
const int mid = bar.x + barW / 2; const int mid = bar.x + barW / 2;
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge; 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) { bool isNaturalKey(int note) {
// Clamp to the valid MIDI range before indexing.
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note); 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] = { static constexpr bool kNatural[12] = {
true, // 0 C true, // 0 C
false, // 1 C# false, // 1 C#
@@ -129,13 +120,8 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
if (dxPixels == 0) return clampNote(startNote); if (dxPixels == 0) return clampNote(startNote);
const int bandWidth = std::max(0, layout.keys.width); const int bandWidth = std::max(0, layout.keys.width);
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
// Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag // Same linear mapping as keyAtPoint/keyEdgeToX (exact rational), not a truncated-integer
// agree across the full strip, even on non-divisible-by-128 widths. The proportional // bandWidth/128 key width — that drifted at the far end of the strip.
// 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.
const int half = bandWidth / 2; const int half = bandWidth / 2;
int shift; int shift;
if (dxPixels > 0) { if (dxPixels > 0) {
+41 -88
View File
@@ -1,106 +1,70 @@
// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first // keyboard_strip.h — layout + hit-test + drag math for the capture-first editor's
// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. // keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws
// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle + // and marshals mouse events into these functions.
// 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.
// //
// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span // The strip maps the full 128-key MIDI span across a horizontal band (the same idiom
// idiom embed_strip uses). It serves TWO faces of the S10 editor: // embed_strip uses) and serves two faces: the single-capture fast path (a root marker,
// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on // click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a
// the strip, click-a-key (or drag the marker) sets the capture's root note; and // bar with edge-grab resize handles + a body move-handle).
// * 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.
#pragma once #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 { namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named // Named distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips
// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay // stay independent.
// independent — the editor strip may grow octave labels/metrics the embed strip never does.
inline constexpr int kStripKeyCount = 128; inline constexpr int kStripKeyCount = 128;
// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started // Pixel width of a zone bar's edge-grab region. A zone narrower than 2x this has no
// within this many pixels of the bar's left/right edge resizes that edge; a drag started // body move-handle (both edges win their halves).
// 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.
inline constexpr int kStripEdgeGrabWidth = 6; inline constexpr int kStripEdgeGrabWidth = 6;
// The strip's regions, derived from the (w x h) band the shell allots it. The keys band // The keys band takes the whole strip area today; clamped so a degenerate size never
// takes the whole area today (a future octave-label lane can carve a sub-band here without // yields an inverted rect.
// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
struct StripLayout { 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 // Divide a (w x h) strip area into its regions. Pure.
// negative size yields empty rects (no inversion).
StripLayout layoutStrip(int w, int h); 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 // x pixel of the LEFT edge of key `note` (0..127) under the linear 128-key map; key N
// span maps linearly across keys.width; key N occupies the half-open pixel range // occupies [keyLeftX(N), keyLeftX(N+1)). note==128 maps to the band's right edge.
// [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.
int keyLeftX(const StripLayout& layout, int note); int keyLeftX(const StripLayout& layout, int note);
// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note), // Half-open rect of a single key `note`, clamped to [0,127].
// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range)
// note clamps to [0,127]. Pure.
Rect keyRect(const StripLayout& layout, int note); Rect keyRect(const StripLayout& layout, int note);
// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`, // Root-marker rect for the single-capture fast path; equivalent to
// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point // keyRect(layout, rootNote) but named so the intent reads at the call site.
// 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.
Rect rootMarkerRect(const StripLayout& layout, int rootNote); 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 // MIDI note a point (x, y) lands on, or -1 outside the keys band.
// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure.
int keyAtPoint(const StripLayout& layout, int x, int y); int keyAtPoint(const StripLayout& layout, int x, int y);
// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote] // Horizontal sub-rect for a zone spanning [lowNote, highNote] inclusive. Notes clamp to
// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes // [0,127] and low clamps to <= high, so a malformed zone never yields an inverted rect.
// 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.
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote); 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 // Which part of a zone bar a grab landed on: an edge resizes that boundary, the body
// edits: an edge resizes that boundary; the body moves the whole span; none means the grab // moves the whole span, kNone means the grab missed the bar.
// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a
// deselect).
enum class ZoneGrab { enum class ZoneGrab {
kNone, // the point is not on this zone's bar kNone,
kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low kLowEdge,
kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high kHighEdge,
kBody, // on the bar but not an edge -> move the whole span kBody,
}; };
// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the // Classify a grab at (x, y) against one zone's bar. A narrow bar (< 2*kStripEdgeGrabWidth)
// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within // resolves the near half to each edge (no body); the low edge wins a tie at the exact
// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth) // midpoint.
// 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.
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y); 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, // Zone (index into the parallel `lows`/`highs` arrays, draw order) whose bar a grab
// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in // lands on, plus which part, or {-1, kNone} for a miss. First covering zone in draw
// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The // order wins.
// 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).
struct ZoneBarHit { struct ZoneBarHit {
int zoneIndex = -1; int zoneIndex = -1;
ZoneGrab grab = ZoneGrab::kNone; ZoneGrab grab = ZoneGrab::kNone;
@@ -108,24 +72,13 @@ struct ZoneBarHit {
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs, ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
int count, int x, int y); int count, int x, int y);
// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time // Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth),
// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note // clamped to [0,127]. The one arithmetic behind edge-resize, body-move (apply to both
// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to // edges with the same delta to preserve span), and root-marker drag.
// [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.
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); 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 // True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal
// temperament; false when it is an ACCIDENTAL (black) key. Notes out of the [0,127] // temperament; false for an accidental (black) key.
// 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.
bool isNaturalKey(int note); bool isNaturalKey(int note);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+1 -1
View File
@@ -37,7 +37,7 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int innerLeft = box.x + kDeckGroupPadX; const int innerLeft = box.x + kDeckGroupPadX;
const int innerRight = box.right() - 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); out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
if (g.captionToggle.id >= 0) { if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth; const int segW = g.captionToggle.segWidth;
+27 -39
View File
@@ -1,27 +1,18 @@
// knob_deck.h — PURE knob-deck layout + hit-test for the r11 Sample-face recomposition // knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free
// (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary, and — like // like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of
// param_slider — NO engine types: cells and toggles carry opaque shell-owned control ids. // action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
// The mirror of action_bar / param_slider: the fiddly group-box / caption-row / cell-grid // param_slider's — a knob cell here is just a rect the shell composes it into.
// 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.
// //
// THE DECK (CONTEXT.md §S-VIEW r11). A horizontal run of FENCED GROUPS, left -> right, each // The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
// a hairline-bordered bg/panel box with a CAPTION ROW (micro-caps caption left; the group's // caption row (caption left, the group's compact mode toggle right-anchored) over a knob
// compact mode toggle right-anchored IN the caption row — this is where the not-full-width // row of fixed cells (knob centered, label band beneath). A group may also place one
// toggles live) over a KNOB ROW of fixed 48x58 cells (28px knob centered, 12px label band // two-segment toggle in the knob row after its cells. Groups that must keep stable
// beneath). A group may additionally place one 18px-tall two-segment toggle IN the knob row // geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows
// after its cells (the VOICE group's Retrig|Legato — same Mono/Stereo segment grammar, // neighbouring groups.
// 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.
// //
// WRAP (deterministic): groups place left-to-right with kDeckGroupGap between; a group that // Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
// does not fit the remaining width starts a new deck row (whole groups only, never split). // that does not fit the remaining width starts a new row (whole groups only, never
// The first group of a row always places even if wider than the row (degenerate width). // split); the first group of a row always places even if wider than the row.
// deckHeight() exposes the resulting height so the shell can bottom-anchor the deck band and
// give the ELASTIC HERO the rest (r11 band order).
#pragma once #pragma once
@@ -31,7 +22,7 @@
namespace reasampler::instrument::ui { 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 kDeckCellW = 48; // one knob cell
inline constexpr int kDeckCellH = 58; inline constexpr int kDeckCellH = 58;
inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell 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 // 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). // is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell
// `captionWidth` is the px the shell reserves for the caption text (this module does not // reserves for the caption text (this module does not measure text).
// measure text — the house constant-metrics pattern).
struct DeckGroupDesc { struct DeckGroupDesc {
int id = 0; // shell group id (opaque here) int id = 0; // shell group id (opaque here)
int captionWidth = 60; int captionWidth = 60;
@@ -96,21 +86,20 @@ struct DeckLayout {
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups 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 // 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. // knob row (cells + gap + row toggle), plus horizontal padding.
int deckGroupWidth(const DeckGroupDesc& g); int deckGroupWidth(const DeckGroupDesc& g);
// The number of deck rows the groups occupy at `availWidth` under the greedy whole-group // Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap.
// wrap (a group that does not fit the remaining row width starts a new row; the first group // 0 for an empty list.
// of a row always places). 0 for an empty group list. Pure — the wrap is deterministic.
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
// The total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). 0 for an // Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell
// empty list. The shell bottom-anchors a band of exactly this height. Pure. // bottom-anchors a band of exactly this height.
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
// Lay the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's rule. // Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's
// Every rect is absolute. Pure — same inputs, same layout. // rule. Every rect is absolute.
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top, DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth); int availWidth);
@@ -124,10 +113,9 @@ struct DeckHit {
int segment = -1; // 0/1 for a toggle hit; -1 otherwise 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 // The deck element a point lands on: a knob cell (the whole cell, not just the knob
// bare knob circle; the shell anchors the vertical drag wherever the grab lands), a caption- // circle the shell anchors the vertical drag wherever the grab lands), a caption-toggle
// toggle segment, or a row-toggle segment. Blank cells (id -1) and everything else miss. // segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
// Pure — the shell's routing entry point.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+3 -4
View File
@@ -1,5 +1,4 @@
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16 // param_slider.cpp — see param_slider.h. Pure control-surface geometry; no host types.
// editor parameter panel. No host types; only the shared Rect + contains().
#include "core/instrument/ui/param_slider.h" #include "core/instrument/ui/param_slider.h"
@@ -10,7 +9,7 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) using util::clamp01;
std::vector<ControlRow> layoutControls(const Rect& panel, std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls) { const std::vector<ControlDesc>& controls) {
@@ -83,7 +82,7 @@ double valueAtPoint(const Rect& control, int x) {
return static_cast<double>(x - track.x) / static_cast<double>(span); return static_cast<double>(x - track.x) / static_cast<double>(span);
} }
// --- Radial knob (Wave A FA4) --------------------------------------------------------- // --- Radial knob -----------------------------------------------------------------------
namespace { namespace {
+68 -107
View File
@@ -1,180 +1,141 @@
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the // param_slider.h — 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 // editor parameter panel. Engine-free by design (no sampler_core/sample_map). Mirror of
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The // keyboard_strip/waveform_view/mode_switch; the shell draws each row and routes
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and // clicks/drags into these functions, owning the control-id -> engine-param binding and
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell // the value domain mapping.
// 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.
// //
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface // Controls are one of three shapes — a two-segment Toggle, a horizontal Slider, or a
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR // radial Knob with a needle and vertical-drag mapping — laid out as a vertical stack of
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade // fixed-height rows. This module maps a control's normalized value (0..1) to/from its
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope // handle pixel / needle angle; the shell converts each control's engine value (frames,
// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal // seconds, a fraction, a signed semitone depth) to/from that 0..1.
// 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).
#pragma once #pragma once
#include <vector> #include <vector>
#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 { namespace reasampler::instrument::ui {
// Fixed control-panel metrics, exposed so the shell and tests agree. // 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 kControlRowHeight = 22;
inline constexpr int kControlRowGap = 4; // vertical gap below each row inline constexpr int kControlRowGap = 4;
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left inline constexpr int kControlLabelWidth = 92;
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px) inline constexpr int kSliderHandleWidth = 8;
inline constexpr int kToggleSegments = 2; // a toggle is always two segments inline constexpr int kToggleSegments = 2;
// A control is one of three shapes. Toggle = a two-segment selector (the active segment // Toggle = two-segment selector (active segment highlights); Slider = horizontal track
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value; // with a draggable handle over a 0..1 value; Knob = radial dial with a needle, dragged
// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY // vertically (up = increase).
// (up = increase).
enum class ControlKind { Toggle, Slider, Knob }; enum class ControlKind { Toggle, Slider, Knob };
// One control the shell places in the panel, in stack order. `id` is the shell's own control // One control the shell places in the panel, in stack order. `id` is the shell's own
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the // control identifier, returned by the hit-test so the shell routes to the right engine
// shell routes the interaction to the right engine param — this module never interprets it. // param — this module never interprets it.
struct ControlDesc { struct ControlDesc {
int id = 0; int id = 0;
ControlKind kind = ControlKind::Slider; ControlKind kind = ControlKind::Slider;
}; };
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect // Laid-out geometry of one control row: full row rect plus the interactive sub-rect (the
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle // 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. // into segments via toggleSegmentRect).
struct ControlRow { struct ControlRow {
int id = 0; int id = 0;
ControlKind kind = ControlKind::Slider; ControlKind kind = ControlKind::Slider;
Rect row; // the full row (label column + control column) Rect row;
Rect label; // the label column at the left Rect label;
Rect control; // the control column to the right of the label (track / toggle area) Rect control;
}; };
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each // Lays out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down.
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left // Label column takes the left kControlLabelWidth (clamped to the panel), control column
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A // the rest. A row past the panel bottom is still returned (the shell clips/suppresses it)
// row whose top falls past the panel bottom is still returned (the shell clips at paint / // so stack geometry is deterministic regardless of panel height.
// 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.
std::vector<ControlRow> layoutControls(const Rect& panel, std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls); const std::vector<ControlDesc>& controls);
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect, // Rect of segment `seg` within a toggle's `control` rect, splitting it into
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width // kToggleSegments equal segments left-to-right (last absorbs any width remainder).
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
// control rect yields an empty rect. Pure.
Rect toggleSegmentRect(const Rect& control, int seg); 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); 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 // Slider track sub-rect inside `control`: inset so the handle stays fully within the
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle // control at value 0 and 1. The handle center ranges across [track.x, track.right()] as
// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value // the value ranges [0,1].
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
// empty rect. Pure.
Rect sliderTrackRect(const Rect& control); Rect sliderTrackRect(const Rect& control);
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a // Handle rect for a slider at normalized `value` (clamped to [0,1]).
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
Rect sliderHandleRect(const Rect& control, double value); Rect sliderHandleRect(const Rect& control, double value);
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range). // Maps a point x to a normalized slider value [0,1]: at/left of track start -> 0, at/right
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate // of end -> 1, linear between. Inverse of sliderHandleRect's position map.
// 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).
double valueAtPoint(const Rect& control, int x); 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 // Angle convention: degrees clockwise from 12 o'clock (screen coords, y grows downward).
// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6 // The value arc sweeps clockwise from startDeg (value 0) to endDeg (value 1); an endDeg
// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg // at-or-behind startDeg wraps +360.
// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal
// angles mean a full 360° sweep.
// //
// The DEFAULT arc is the conventional 7→5 o'clock layout: min at 7 o'clock (210°) sweeping // Default arc: 7 o'clock (210°) sweeping clockwise 300° to 5 o'clock (150°), leaving a
// clockwise 300° around to max at 5 o'clock (150°), leaving a symmetric 60° dead arc at the // symmetric 60° dead arc at the bottom; the 50% value lands at 12 o'clock. Angles are
// bottom. The 50% (midpoint) value lands at 12 o'clock (0°/360°) — straight up. The angles // parameters, not hardcoded.
// are PARAMETERS, not hardcoded — the shell sets the final sweep when the parallel layout inline constexpr double kKnobArcStartDeg = 210.0;
// spec lands. inline constexpr double kKnobArcEndDeg = 150.0;
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 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; inline constexpr int kKnobDragRangePixels = 128;
// The configurable value arc of a knob. Defaults to the 7->5 o'clock reading above.
struct KnobArc { struct KnobArc {
double startDeg = kKnobArcStartDeg; double startDeg = kKnobArcStartDeg;
double endDeg = kKnobArcEndDeg; double endDeg = kKnobArcEndDeg;
}; };
// A knob's circle within its control cell: center + radius in pixel space (doubles so the // A knob's circle within its control cell: center + radius (doubles so the shell rounds
// shell rounds once, at draw time). radius == 0 marks a degenerate cell. // once, at draw time). radius == 0 marks a degenerate cell.
struct KnobGeometry { struct KnobGeometry {
double centerX = 0.0; double centerX = 0.0;
double centerY = 0.0; double centerY = 0.0;
double radius = 0.0; double radius = 0.0;
}; };
// A pixel-space point (the needle endpoint the shell draws to).
struct KnobPoint { struct KnobPoint {
double x = 0.0; double x = 0.0;
double y = 0.0; double y = 0.0;
}; };
// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A // Knob circle inscribed in `cell`, centered, radius = half the smaller dimension. The
// degenerate cell yields radius 0. CONTRACT: the shell MUST pass `row.control` (the full // shell must pass `row.control` both when drawing and hit-testing — controlAtPoint always
// control column) both when drawing and when hit-testing — `controlAtPoint` always uses // uses `r.control` as the cell, so draw cell and hit cell must agree.
// `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.
KnobGeometry computeKnob(const Rect& cell); KnobGeometry computeKnob(const Rect& cell);
// True if (x, y) falls strictly inside the knob circle (boundary exclusive, matching the // True if (x, y) falls strictly inside the knob circle (boundary exclusive).
// module's half-open Rect convention). A degenerate knob (radius <= 0) hits nothing. Pure.
bool knobHitTest(const KnobGeometry& knob, int x, int y); bool knobHitTest(const KnobGeometry& knob, int x, int y);
// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping // Clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping +360
// +360 when the end is at-or-behind the start (default arc -> 300). Pure. // when the end is at-or-behind the start (default arc -> 300).
double knobSweepDeg(const KnobArc& arc); double knobSweepDeg(const KnobArc& arc);
// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1, // Needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
// linear between, returned normalized to [0, 360). Pure. // linear between, normalized to [0, 360).
double knobValueAngleDeg(const KnobArc& arc, double value); double knobValueAngleDeg(const KnobArc& arc, double value);
// The needle endpoint for normalized `value`: the point on the knob circle at the value's // 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 // angle, from the center.
// (or lerps toward the center for a shorter needle). Pure.
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value); 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), // Maps a vertical drag onto a knob value: `startValue` is the value at drag start,
// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging // `dyPixels` the pointer's y displacement (down = positive). Up increases, down
// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1 // decreases; `dragRangePixels` pixels of travel covers the full 0..1 range.
// 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.
double knobDragValue(double startValue, int dyPixels, double knobDragValue(double startValue, int dyPixels,
int dragRangePixels = kKnobDragRangePixels); int dragRangePixels = kKnobDragRangePixels);
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc // Control a point lands on, given laid-out `rows`. Returns the control id whose
// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's // interactive area contains the point, or -1 for a miss. First matching row wins (rows
// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every // never overlap).
// 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.
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y); int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+2 -4
View File
@@ -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); const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return area.x; if (frameCount <= 0 || w <= 0) return area.x;
const std::int64_t f = clampFrame(frame, frameCount); const std::int64_t f = clampFrame(frame, frameCount);
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line // x = left + round(f * w / frameCount); multiply before divide to keep this exact.
// visually centered on its frame; the divide is exact rational (multiply first).
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2; const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
return area.x + static_cast<int>(num / frameCount); return area.x + static_cast<int>(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.x) return 0;
if (x >= area.right()) return frameCount; if (x >= area.right()) return frameCount;
const std::int64_t dx = static_cast<std::int64_t>(x - area.x); const std::int64_t dx = static_cast<std::int64_t>(x - area.x);
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw // Inverse of frameToX: frame = round(dx * frameCount / w).
// agree at bin granularity.
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2; const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
return clampFrame(num / static_cast<std::int64_t>(w), frameCount); return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
} }
+25 -57
View File
@@ -1,84 +1,52 @@
// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11 // waveform_view.h — waveform/marker geometry + zero-crossing snap. Mirror of keyboard_strip/
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror // editor_geometry: frame<->pixel + marker hit-test + snap arithmetic lives here, unit-tested
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap // outside the DAW; the shell draws and marshals mouse events into it.
// 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.
// //
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal // 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, // waveform rect. Markers are a generic N-named-marker set (not hardcoded specials), so a
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately // different mode (e.g. start + %-length end + fades) can repurpose the same machinery.
// 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.
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom #include "core/instrument/ui/editor_geometry.h" // Rect, contains
#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans #include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
using audio::AudioSample; using audio::AudioSample;
// The width (px) of a marker's grab region either side of its x line: a grab within this many // Pixel width of a marker's grab region either side of its x line. Mirrors keyboard_strip's
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab // edge-grab idiom.
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
// distinguishable.
inline constexpr int kMarkerGrabWidth = 5; inline constexpr int kMarkerGrabWidth = 5;
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x, // x pixel of `frame` under the linear map: frame 0 -> area.x, frame frameCount -> area.right().
// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an // Frame is clamped to [0, frameCount] before mapping. frameCount <= 0 or a zero-width area pins
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a // every frame to area.x.
// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure.
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame); 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 // Inverse of frameToX: the frame a point x maps to, clamped to [0, frameCount]. A point left of
// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount. // 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).
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x); 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 // Which marker (index into the caller's 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 // (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker // its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie.
// 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.
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y); int count, int x, int y);
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time // Resolves a drag to a new frame: `startFrame` shifted by round(dxPixels * frameCount /
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the // areaWidth), clamped to [0, frameCount]. The shell applies between-marker clamps (e.g.
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth), // start <= loopEnd) after this per-marker resolve.
// 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.
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels); int dxPixels);
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the // Nearest zero-crossing frame to `target` in the mono PCM, for loop/start snap. A crossing is a
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames) // frame i (1 <= i < frames) where pcm[i-1] and pcm[i] differ in sign (pcm[i] == 0 snaps to i).
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing // Search fans out symmetrically from the clamped target; an equidistant tie resolves to the
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and // lower frame. No sign change anywhere (or fewer than 2 frames) returns the clamped target
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to // unchanged.
// 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.
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target); std::int64_t target);
+4 -11
View File
@@ -1,6 +1,5 @@
// core/json implementation — see json.h. The bodies are the (previously // core/json implementation — see json.h. Any behavioral change here changes
// quintuplicated) bank_model / view_mode_model lexical layer, verbatim; any // every persisted-blob parser that shares this lexical layer at once.
// behavioral change here changes five persisted-blob parsers at once.
#include "core/json/json.h" #include "core/json/json.h"
@@ -11,9 +10,7 @@
namespace reasampler::json { namespace reasampler::json {
// --------------------------------------------------------------------------- // -- emit helpers -------------------------------------------------------
// emit helpers
// ---------------------------------------------------------------------------
void writeEscaped(std::string& out, const std::string& s) { void writeEscaped(std::string& out, const std::string& s) {
out += '"'; out += '"';
@@ -75,9 +72,7 @@ void writeIntArray(std::string& out, const std::vector<int>& v) {
out += ']'; out += ']';
} }
// --------------------------------------------------------------------------- // -- Reader ---------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------
void Reader::skipWs() { void Reader::skipWs() {
while (!eof()) { while (!eof()) {
@@ -117,7 +112,6 @@ bool Reader::parseString(std::string& out) {
case 'r': out += '\r'; break; case 'r': out += '\r'; break;
case 't': out += '\t'; break; case 't': out += '\t'; break;
case 'u': { case 'u': {
// Decode a \uXXXX escape to its code point.
auto readHex4 = [&](unsigned int& cp) -> bool { auto readHex4 = [&](unsigned int& cp) -> bool {
if (pos_ + 4 > s_.size()) return false; if (pos_ + 4 > s_.size()) return false;
cp = 0; cp = 0;
@@ -149,7 +143,6 @@ bool Reader::parseString(std::string& out) {
return false; // unpaired low surrogate — malformed return false; // unpaired low surrogate — malformed
} }
// Encode codePoint as UTF-8.
if (codePoint <= 0x7F) { if (codePoint <= 0x7F) {
out += static_cast<char>(codePoint); out += static_cast<char>(codePoint);
} else if (codePoint <= 0x7FF) { } else if (codePoint <= 0x7FF) {
+16 -23
View File
@@ -1,22 +1,19 @@
// core/json — the ONE hand-rolled JSON lexical layer (Q-W1; audit T2-02 / §2 // core/json — the ONE hand-rolled JSON lexical layer. Pure: standard library only
// "Parser ×4"). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. // — NO REAPER, NO SWELL, NO VST3.
// //
// This module owns the lexical half of the house JSON dialect: the escape-aware // Owns the lexical half of the house JSON dialect: escape-aware string literals
// string literal (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), the bare // (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), bare scalar tokens, number
// scalar tokens, the number parses (strtod/strtoll with full-token + ERANGE // parsing (strtod/strtoll, full-token + ERANGE rejection), key+':' consumption,
// rejection), key+':' consumption, unknown-value skipping, and the emit side // unknown-value skipping, and the emit side (escaping, %.17g/%d/%lld rendering,
// (escaping, %.17g / %d / %lld number rendering, the scoped object writer). // the scoped object writer). Domain grammars — which keys exist, what shape each
// The DOMAIN grammars — which keys exist, what shape each value takes, what is // value takes — stay in the consumers (bank_model, bank_book, view_mode_model,
// rejected at the model boundary — stay in the consumers (bank_model, bank_book, // owned_manifest, tail_control).
// view_mode_model, owned_manifest, tail_control). One lexical definition means
// the five decoders can no longer drift on tolerance or escaping.
// //
// Byte-compatibility contract (load-bearing): the emit helpers reproduce the // Byte-compatibility contract (load-bearing): the emit helpers reproduce the prior
// prior per-module writers EXACTLY — writeEscaped's escape set, %.17g for // per-module writers EXACTLY — writeEscaped's escape set, %.17g for doubles
// doubles (shortest form that round-trips every IEEE-754 double bit-for-bit), // (shortest form that round-trips every IEEE-754 double bit-for-bit), plain
// plain decimal for ints — so a re-serialized blob is byte-identical to what // decimal for ints — so a re-serialized blob is byte-identical to what the
// the pre-extraction writers produced. This was a structural dedupe, not a // pre-extraction writers produced. Persisted .rpp ext-state must not shift by a byte.
// format change; persisted .rpp ext-state must not shift by a byte.
#pragma once #pragma once
@@ -26,9 +23,7 @@
namespace reasampler::json { namespace reasampler::json {
// --------------------------------------------------------------------------- // -- emit helpers (writer side) ----------------------------------------------
// emit helpers (writer side)
// ---------------------------------------------------------------------------
// Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX // Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX
// for remaining control chars, everything else verbatim (UTF-8 passes through). // for remaining control chars, everything else verbatim (UTF-8 passes through).
@@ -88,9 +83,7 @@ private:
bool first_ = true; 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. // 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 // Only the subset the house writers emit is supported. The reader borrows the
+18 -39
View File
@@ -5,19 +5,13 @@
// bank_book implementation — the registry RULES half: construction, pool // bank_book implementation — the registry RULES half: construction, pool
// privileges, bank lifecycle, active bank, sample movement/removal, slot order, // privileges, bank lifecycle, active bank, sample movement/removal, slot order,
// and the reference queries. The JSON round-trip half (serialize / deserialize — // and the reference queries. The JSON round-trip half lives in bank_book_json.cpp,
// Q-W1's golden-literal-pinned byte format) lives in bank_book_json.cpp, compiled // compiled into the same target. The one symbol both halves share is the private
// into the same bank_book target (the slot_map extraction shape: same header, a // static BankBook::nameKey display-name folding rule (declared in bank_book.h).
// second TU). The one symbol both halves share is the private static
// BankBook::nameKey display-name folding rule (declared in bank_book.h).
namespace reasampler { namespace reasampler {
// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05). // -- construction + bank lookup ----------------------------------------------
// ---------------------------------------------------------------------------
// BankBook — construction + bank lookup
// ---------------------------------------------------------------------------
BankBook::BankBook() { BankBook::BankBook() {
Bank pool; Bank pool;
@@ -59,9 +53,7 @@ const Bank& BankBook::pool() const {
return *bank(kPoolBankId); return *bank(kPoolBankId);
} }
// --------------------------------------------------------------------------- // -- Ordinal normalization ----------------------------------------------------
// Ordinal normalization
// ---------------------------------------------------------------------------
void BankBook::normalizeOrdinals() { void BankBook::normalizeOrdinals() {
// Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a // 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<int>(i); banks_[i].ordinal = static_cast<int>(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 // Folds a display name to its uniqueness key: strip leading/trailing ASCII
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one // whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
// key and cannot coexist. ASCII-only by design — the pure core carries no locale // key. ASCII-only by design — the pure core carries no locale facility; bank names
// facility and must not grow one; bank names are short user labels, not full Unicode // are short user labels, not Unicode case-folding candidates. Private static: the
// case-folding candidates. Private static member (Q-W5): the one folding rule shared // one folding rule shared with bank_book_json.cpp's parse-time coalesce.
// with bank_book_json.cpp's parse-time duplicate-display-name coalesce.
std::string BankBook::nameKey(const std::string& s) { std::string BankBook::nameKey(const std::string& s) {
std::size_t b = 0, e = s.size(); std::size_t b = 0, e = s.size();
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; 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; return false;
} }
// --------------------------------------------------------------------------- // -- Bank lifecycle ------------------------------------------------------------
// Bank lifecycle
// ---------------------------------------------------------------------------
bool BankBook::createBank(const std::string& id, const std::string& displayName) { bool BankBook::createBank(const std::string& id, const std::string& displayName) {
if (id.empty()) return false; // ids key the registry if (id.empty()) return false; // ids key the registry
@@ -208,9 +195,7 @@ bool BankBook::evacuate(const std::string& id) {
return true; return true;
} }
// --------------------------------------------------------------------------- // -- Active bank ----------------------------------------------------------------
// Active bank
// ---------------------------------------------------------------------------
bool BankBook::setActiveBank(const std::string& id) { bool BankBook::setActiveBank(const std::string& id) {
if (bank(id) == nullptr) return false; // unknown id never corrupts state if (bank(id) == nullptr) return false; // unknown id never corrupts state
@@ -227,9 +212,7 @@ const BankModel& BankBook::activeIndex() const {
return bank(activeBankId_)->index; return bank(activeBankId_)->index;
} }
// --------------------------------------------------------------------------- // -- Sample movement (index-only) -----------------------------------------------
// Sample movement (index-only)
// ---------------------------------------------------------------------------
namespace { namespace {
@@ -276,9 +259,7 @@ TransferResult BankBook::copySample(const std::string& sampleId,
return applyDestAdd(to->index, copy, TransferResult::Copied); 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, RemoveResult BankBook::removeSample(const std::string& sampleId,
const std::string& fromBankId, 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 return false; // no bank holds the id
} }
// --------------------------------------------------------------------------- // -- Sample display order — SlotMap driven, index membership untouched -----------
// Sample display order (L7) — SlotMap driven, index membership untouched
// ---------------------------------------------------------------------------
namespace { namespace {
@@ -323,9 +302,9 @@ std::vector<std::string> indexIds(const BankModel& idx) {
return ids; return ids;
} }
// Squares one bank's SlotMap with its index membership. A map with NO overlap with the // Squares one bank's SlotMap with its index membership. A map with NO overlap with
// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from // the index (a bank with no persisted slot data, or freshly constructed) is seeded
// insertion order; an existing map is reconciled (drop stale markers, append unmapped). // dense from insertion order; an existing map is reconciled (drop stale, append unmapped).
void reconcileBankSlots(Bank& b) { void reconcileBankSlots(Bank& b) {
const std::vector<std::string> live = indexIds(b.index); const std::vector<std::string> live = indexIds(b.index);
if (b.slots.empty()) { if (b.slots.empty()) {
+108 -208
View File
@@ -1,41 +1,13 @@
#pragma once #pragma once
// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free // bank_book — pure multi-bank registry: wraps N BankModel instances (bank_model
// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the // itself is untouched — additive, no bankId on Sample). Movement between banks is
// third instance of the same "pure registry + JSON round-trip, unit-tested outside // index-only; files never relocate, banks are logical groupings over one shared pool.
// the DAW" pattern as bank_model and view_mode_model.
// //
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // The pool is bank-zero (fixed id/name, ordinal 0), privileged and enforced HERE:
// vendor/ includes. Standard library only. // always exists, un-deletable, un-renamable, un-evacuable (evacuate's destination
// // only). createBank takes a caller-supplied id — REAPER GUID minting stays in the
// -- What it is -------------------------------------------------------------- // shell so this model stays pure and deterministic; the model still enforces
// // non-empty/unique/not-reserved.
// 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.
#include <optional> #include <optional>
#include <string> #include <string>
@@ -47,26 +19,22 @@
namespace reasampler { namespace reasampler {
// Q-W1 interim: this god module re-namespaces in its own split wave; until then the // Interim: this module re-namespaces later; the model types it wraps live in
// clean model types it wraps live in reasampler::model. // reasampler::model.
using namespace model; using namespace model;
// The pool's fixed identity. The id is reserved: createBank rejects it, and the // The pool's fixed identity: createBank rejects this id; renameBank rejects this name.
// pool is always bank-zero. The name is fixed: renameBank rejects the pool.
inline constexpr const char* kPoolBankId = "pool"; inline constexpr const char* kPoolBankId = "pool";
inline constexpr const char* kPoolBankName = "Pool"; inline constexpr const char* kPoolBankName = "Pool";
// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h. // One bank: id/display/ordinal/BankModel/SlotMap. The pool is the bank whose
// Included above because Bank carries one per bank. // id == kPoolBankId.
// 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.
struct Bank { struct Bank {
std::string id; // stable, persisted; the pool's is kPoolBankId std::string id; // stable, persisted; the pool's is kPoolBankId
std::string displayName; // mutable for named banks; fixed "Pool" for the pool std::string displayName; // mutable for named banks; fixed "Pool" for the pool
int ordinal = 0; // display order; pool is 0, named banks 1..N int ordinal = 0; // display order; pool is 0, named banks 1..N
BankModel index; // this bank's samples 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; } 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 // Outcome of a cross-bank move/copy — reports what happened rather than mutating
// reports what happened rather than silently mutating on a bad request. // silently on a bad request.
// - Moved / Copied: the sample was transferred to the destination as a new entry. // - Moved / Copied: transferred to the destination as a new entry.
// - Collapsed: the destination already held the hash; it collapsed onto the // - Collapsed: destination already held the hash, collapsed onto it (move
// existing entry (a no-op add on the destination side). For a // still removes the source; copy keeps it, as always).
// MOVE the source entry is STILL removed; for a COPY the source // - RejectedUnknownBank / RejectedSampleAbsent / RejectedSameBank: no-op guards.
// 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).
enum class TransferResult { enum class TransferResult {
Moved, Moved,
Copied, Copied,
@@ -95,21 +59,18 @@ enum class TransferResult {
RejectedSameBank, RejectedSameBank,
}; };
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default // Scope of a sample-remove. ThisBank is the only behavior surfaced in the UI;
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam // AllBanks is a tested latent seam, not wired to any affordance.
// live and tested at the model level, promotable later behind this parameter without // - ThisBank: drop the entry from the one named source bank only (no cross-bank
// a rewrite, but never wired to an affordance in B5. // cascade — dedup is per-bank).
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry // - AllBanks: drop the sample's entry from every bank holding it ("purge from
// in another bank survives (no cross-bank cascade — dedup is per-bank). // the library").
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
// ("purge from the library"). Latent; unsurfaced.
enum class RemoveScope { enum class RemoveScope {
ThisBank, ThisBank,
AllBanks, AllBanks,
}; };
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports // Outcome of BankBook::removeSample — same honesty as TransferResult.
// what happened rather than silently mutating on a bad request.
// - Removed: at least one index entry was dropped. // - Removed: at least one index entry was dropped.
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only). // - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed). // - 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 // 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 // indices, an active-bank pointer, and lossless JSON round-trip.
// multi-bank phase — mirror of bank_model / view_mode_model.
class BankBook { class BankBook {
public: public:
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0); BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
@@ -129,34 +89,29 @@ public:
// -- Bank lifecycle ------------------------------------------------------ // -- Bank lifecycle ------------------------------------------------------
// Creates a named bank with the caller-supplied stable id and display name, // Creates a named bank with a caller-supplied id/display name (next ordinal
// assigning the next ordinal. Rejects (returns false, no mutation) an empty id, // assigned automatically). Rejects (false, no mutation) an empty/duplicate id,
// a duplicate id, the reserved pool id, or a display name that duplicates an // the reserved pool id, or a duplicate display name (trimmed + case-insensitive,
// existing bank's name (including the pool's "Pool"). Display-name uniqueness is // ASCII — "Drums"/"drums"/" Drums " collide, including against the pool's "Pool").
// trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide.
bool createBank(const std::string& id, const std::string& displayName); 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 // Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or
// target name already used by a DIFFERENT bank (trimmed + case-insensitive, as // a name already used by another bank. Renaming to its own current name is a
// createBank). Renaming a bank to its own current name is a no-op success. // no-op success.
bool renameBank(const std::string& id, const std::string& displayName); bool renameBank(const std::string& id, const std::string& displayName);
// Deletes a NAMED bank, removing it (and its member index entries) from the // Deletes a named bank and its member entries (files untouched — a shell/prune
// registry. Files are a shell/prune concern and are NOT touched here. Rejects // concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
// (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are // ordinals compact after; if the deleted bank was active, falls back to the pool.
// 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.
bool deleteBank(const std::string& id); bool deleteBank(const std::string& id);
// Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range), // Reorders a named bank to newOrdinal (clamped into range, others shift to stay
// shifting the others to keep ordinals contiguous. The pool is pinned at 0 and // contiguous). The pool is pinned at 0. Rejects an unknown id or the pool.
// cannot be reordered. Rejects (false, no mutation) an unknown id or the pool.
bool reorderBank(const std::string& id, int newOrdinal); bool reorderBank(const std::string& id, int newOrdinal);
// Moves EVERY member of a named bank into the pool (index-only, observing the // Moves every member of a named bank into the pool (index-only, same
// same destination-collapse-by-hash as a move), leaving the bank empty. Rejects // destination-collapse-by-hash as a move). Rejects an unknown id or the pool
// (false, no mutation) an unknown id or the pool (the pool is the destination, // (the pool is only ever a destination). Returns true even if already empty.
// never a source). Returns true on success even if the bank was already empty.
bool evacuate(const std::string& id); bool evacuate(const std::string& id);
// -- Active bank --------------------------------------------------------- // -- Active bank ---------------------------------------------------------
@@ -164,123 +119,83 @@ public:
// The active bank's id (the capture target). Defaults to the pool. // The active bank's id (the capture target). Defaults to the pool.
const std::string& activeBankId() const { return activeBankId_; } const std::string& activeBankId() const { return activeBankId_; }
// Sets the active bank. Rejects (returns false, no change) an id that names no // Sets the active bank. Rejects (false, no change) an id that names no bank.
// bank — an invalid set never corrupts state.
bool setActiveBank(const std::string& id); bool setActiveBank(const std::string& id);
// The active bank's BankModel — the index the capture layer adds to. Always // The active bank's BankModel — always valid (falls back to the pool).
// valid (the active id always names a live bank; it falls back to the pool).
BankModel& activeIndex(); BankModel& activeIndex();
const BankModel& activeIndex() const; const BankModel& activeIndex() const;
// -- Sample movement (index-only; files never relocate) ------------------ // -- Sample movement (index-only; files never relocate) ------------------
// Moves a sample by id from `fromBankId` to `toBankId`: removes it from the // Moves a sample by id between banks (destination collapse-by-hash observed).
// source index and adds it to the destination (observing destination // See TransferResult for the full outcome set.
// collapse-by-hash). See TransferResult for the full outcome set.
TransferResult moveSample(const std::string& sampleId, TransferResult moveSample(const std::string& sampleId,
const std::string& fromBankId, const std::string& fromBankId,
const std::string& toBankId); const std::string& toBankId);
// Copies a sample by id from `fromBankId` to `toBankId`: the source entry is // Copies a sample by id between banks, source retained (destination
// retained, the destination gains it (observing destination collapse-by-hash). // collapse-by-hash observed). Cross-bank dedup is NOT enforced — the same hash
// Same hash may then live in both banks — cross-bank dedup is NOT enforced. // may then live in both banks.
TransferResult copySample(const std::string& sampleId, TransferResult copySample(const std::string& sampleId,
const std::string& fromBankId, const std::string& fromBankId,
const std::string& toBankId); const std::string& toBankId);
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) -- // -- 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). // Drops a sample's index entry — non-destructive to the file (a last-reference
// Index-only and non-destructive to the file: a last-reference remove leaves the // remove leaves it on disk, orphaned until prune reclaims it). See RemoveScope/
// file on disk, orphaned until Phase R prune — remove NEVER deletes bytes. // RemoveResult for scope and outcome. No mutation on any Rejected outcome.
//
// 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).
RemoveResult removeSample(const std::string& sampleId, RemoveResult removeSample(const std::string& sampleId,
const std::string& fromBankId, const std::string& fromBankId,
RemoveScope scope = RemoveScope::ThisBank); 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 // The bank's sample ids in display (slot) order, reconciled against live index
// iterates, sourced from the bank's SlotMap. Reconciles the map against live index // membership first (drops stale markers, appends unmapped samples densely). An
// membership first (drops stale markers, appends unmapped samples densely), so a // unknown bank id yields an empty vector.
// 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.
std::vector<std::string> orderedSampleIds(const std::string& bankId); std::vector<std::string> orderedSampleIds(const std::string& bankId);
// Ensures every bank's SlotMap is consistent with its index membership: seeds a // 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 // dense order, or reconciles a partial map). Idempotent — call after deserialize
// default — dense, no gaps), and reconciles a partially-populated map (drop stale, // or any out-of-band membership change.
// append unmapped). Idempotent. Called after deserialize and after any capture/
// transfer that added samples out-of-band of the L7 reorder path.
void reconcileSlots(); void reconcileSlots();
// Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see // Reorders sample `id` within `bankId` to targetSlot (gap-preserving; index/file/
// SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and // metadata untouched). Returns false (no mutation) on an unknown bank or id.
// 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.
bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot); 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`) // Alt-replace: the dragged `newId` (already a member of bankId) takes the slot of
// takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s // `oldId`, and `oldId` is removed from the index (same semantics as removeSample
// index (index-only, same semantics as removeSample ThisBank — the file stays on // ThisBank). Position is preserved; only the occupant changes.
// disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the
// last-reference case). Position of the slot is preserved; only its occupant changes.
// //
// POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the // Applies the same pool guard as removeSample — per-sample removal from the pool
// remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed. // is allowed (the pool's guards are un-delete/rename/evacuate, never per-sample
// For the pool this is permitted whenever the occupant exists (per-sample removal // remove). Rejects (false, no mutation of either index or slots) an unknown bank,
// is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate, // a newId/oldId the bank doesn't hold, or newId == oldId.
// 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.
bool replaceSample(const std::string& newId, const std::string& oldId, bool replaceSample(const std::string& newId, const std::string& oldId,
const std::string& bankId); const std::string& bankId);
// Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture): // Refreshes a sample in place wherever it lives (re-capture): finds the bank
// finds the bank holding `sampleId` and replaces its entry with `updated` // holding sampleId and replaces its entry with `updated` (order-preserving, no
// (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in // dedup). Updates the FIRST holder in ordinal order if the id lives in multiple
// ordinal order and updates the FIRST holder (a sample id is unique within a // banks via copy. Returns false (no mutation) if no bank holds the id or the
// bank; the same id living in two banks via copy would update the earliest, which // replacement's path is absolute.
// 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.
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated); bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
// Reference-count query backing the confirm-on-last-reference guardrail: does any // Does any bank other than exceptBankId still hold an entry whose contentHash
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`? // == 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."
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key // An empty hash never matches (mirrors findByHash) — reads as
// the whole model already reasons in (findByHash / collapse-by-hash), and two // referenced-nowhere-else, the safe confirm-eliciting default.
// 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).
bool hashReferencedElsewhere(const std::string& hash, bool hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const; const std::string& exceptBankId) const;
// Every project-relative file path referenced by ANY bank in the book, pool // Every project-relative path referenced by any bank (pool included) — the union
// included — the union across the whole book (Phase R, prune). This is the // prune subtracts against. Paths are verbatim (no normalization), first-seen
// safety-critical referenced-set the prune core subtracts: a file referenced by // order across banks in ordinal then insertion order, de-duplicated. An empty
// any bank (INCLUDING via a copy into a second bank) appears here, so prune never // relativePath is skipped.
// 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.
std::vector<std::string> referencedPaths() const; std::vector<std::string> referencedPaths() const;
// -- Query --------------------------------------------------------------- // -- Query ---------------------------------------------------------------
@@ -308,33 +223,25 @@ public:
// -- Persistence --------------------------------------------------------- // -- Persistence ---------------------------------------------------------
// Serializes the whole book to a JSON string (lossless round-trip): the pool // Serializes the whole book to JSON (lossless): pool as bank-zero + named banks
// folded in as bank-zero + named banks + per-bank indices + ordinals + active // + per-bank indices + ordinals + active id. deserialize(serialize(x)) == x.
// id. deserialize(serialize(x)) == x.
std::string serialize() const; std::string serialize() const;
// Parses a book JSON produced by serialize(). std::nullopt on malformed input. // 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 // A bare legacy bank_index JSON (pre-multi-bank shape: a "samples" array, no
// object with a "samples" array and no "banks" key) is promoted into the pool's // "banks" key) is promoted into the pool's index — one-way, lossless — yielding
// index, yielding a book of { pool } with zero named banks — one-way, lossless. // a book of { pool } with zero named banks.
// After migration the book blob is authoritative (the caller persists the book
// shape going forward; the legacy key is retired by the B2 shell).
static std::optional<BankBook> deserialize(const std::string& json); static std::optional<BankBook> deserialize(const std::string& json);
// Resolve a BankBook from the two persisted ext-state values a project may carry: // Resolves a BankBook from the two persisted ext-state values a project may
// the authoritative `banks` blob and the retired-but-possibly-present legacy // carry: the authoritative `banksJson` and the retired legacy `bank_index` blob.
// `bank_index` blob. The persist shell (B2) hands both raw strings straight here so // 1. non-empty banksJson -> deserialize it. If malformed, do NOT fall back to
// the load-source decision stays REAPER-free and unit-tested. Precedence: // legacy — a corrupt banks blob is an error, not an absence; returns an
// 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is // empty book so a stale legacy key can never resurrect superseded state.
// MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks` // 2. else non-empty legacyJson -> deserialize it (pool migration).
// blob is an error, not an absence; return an empty book so a stale legacy key // 3. else -> a fresh empty book (pool only).
// can never resurrect a superseded single-bank state over a broken book. // Never returns nullopt — an unloadable input degrades to the empty 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.
static BankBook loadFromPersisted(const std::string& banksJson, static BankBook loadFromPersisted(const std::string& banksJson,
const std::string& legacyJson); const std::string& legacyJson);
@@ -343,41 +250,34 @@ private:
std::string activeBankId_; // always names a live bank; defaults to pool std::string activeBankId_; // always names a live bank; defaults to pool
// Folds a display name to its uniqueness key: strip leading/trailing ASCII // Folds a display name to its uniqueness key: strip leading/trailing ASCII
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share // whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
// one key and cannot coexist. ASCII-only by design — the pure core carries no // key. ASCII-only by design — the pure core carries no locale facility. Private
// locale facility and must not grow one. A private STATIC member (Q-W5, settled) // static because both halves of the split implementation (rules + JSON) need the
// because BOTH halves of the split implementation need the ONE folding rule: the // one folding rule; a drifted second copy would let a parsed book violate the
// rules TU (bank_book.cpp, displayNameTaken) and the JSON TU (bank_book_json.cpp, // create/rename uniqueness invariant.
// deserialize's duplicate-display-name coalesce) — a drifted second copy would let
// a parsed book violate the create/rename uniqueness invariant.
static std::string nameKey(const std::string& s); static std::string nameKey(const std::string& s);
// True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key // True if a bank other than exceptId already carries name's uniqueness key.
// (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check; // Backs the create/rename uniqueness check; pass exceptId=id to let a bank keep
// pass exceptId=id to let a bank keep (or re-case/-space) its own name. // (or re-case/-space) its own name.
bool displayNameTaken(const std::string& name, const std::string& exceptId) const; bool displayNameTaken(const std::string& name, const std::string& exceptId) const;
// Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a // 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 // contiguous 0..N-1. Called after any structural change (create/delete/reorder).
// structural change (create / delete / reorder).
void normalizeOrdinals(); void normalizeOrdinals();
// Replaces the book's banks with a parsed set, normalizes ordinals, and resolves // 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 // 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<Bank>&& banks, const std::string& activeBank); void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
}; };
// The next bank id to activate when cycling the active bank forward, in ordinal // 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 // order (pool -> named -> ... -> pool, wraps). Free function (not a member) so it's
// after the last returns the first (pool → named → … → pool). This is the pure // unit-testable against a bare id vector without a full book.
// 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.
// * empty list -> "" (nothing to cycle to) // * empty list -> "" (nothing to cycle to)
// * single id (pool-only) -> that id (a one-bank book stays put) // * single id (pool-only) -> that id (a one-bank book stays put)
// * currentBankId not present -> the first id (a sane home to jump to) // * 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<std::string>& orderedBankIds, std::string nextBankId(const std::vector<std::string>& orderedBankIds,
const std::string& currentBankId); const std::string& currentBankId);
+27 -43
View File
@@ -6,33 +6,25 @@
#include "core/json/json.h" #include "core/json/json.h"
// bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header, // bank_book JSON round-trip — a sibling TU to bank_book.cpp, sharing its header
// compiled into the same bank_book target; the slot_map second-TU shape). The // and target. The registry RULES half stays in bank_book.cpp; the one shared
// registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private // symbol is the private static BankBook::nameKey folding rule — the parse-time
// static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time // duplicate-display-name coalesce below must fold names EXACTLY as create/rename
// duplicate-display-name coalesce below must fold names EXACTLY as the create/rename // uniqueness does, or a parsed book could violate the in-model invariant.
// uniqueness check 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 // The book blob nests one bank object per bank, each carrying that bank's
// and view_mode_model. The book blob nests one bank object per bank, each carrying that // BankModel serialized by bank_model's OWN writer, so per-bank sample
// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize), // serialization stays owned by bank_model and is not duplicated here. The book
// so per-bank sample serialization stays owned by bank_model and is not duplicated // writer emits the bank envelope (id / displayName / ordinal) plus a raw "index"
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a // member whose value is the BankModel blob verbatim; the parser splits the book
// raw "index" member whose value is the BankModel blob verbatim; the parser splits // envelope, then hands each nested index blob straight to BankModel::deserialize.
// 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.
namespace reasampler { namespace reasampler {
// =========================================================================== // -- JSON — writer ----------------------------------------------------------
// JSON — writer
// ===========================================================================
namespace { 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); } std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer; 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 // The nested index is bank_model's own JSON, emitted verbatim so the
// per-sample shape stays owned by BankModel::serialize (not duplicated). // per-sample shape stays owned by BankModel::serialize (not duplicated).
b.keyRaw("index", banks_[i].index.serialize()); b.keyRaw("index", banks_[i].index.serialize());
// L7 display positions (gap-preserving). Absent on a pre-L7 blob; the // Display positions (gap-preserving). Absent on a pre-existing blob;
// parser defaults such a bank's slots from insertion order on load. // the parser defaults such a bank's slots from insertion order on load.
b.keyRaw("slots", banks_[i].slots.serialize()); b.keyRaw("slots", banks_[i].slots.serialize());
} }
out += ']'; out += ']';
@@ -67,13 +59,10 @@ std::string BankBook::serialize() const {
return out; return out;
} }
// =========================================================================== // -- JSON — parser (recursive descent; std::nullopt on malformed input, never UB) --
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
// ===========================================================================
namespace { 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}, // parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive // ...]) into (id, slot) pairs (empty array valid; the pair-level defensive
// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root // 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); b.index = std::move(*idx);
haveIndex = true; haveIndex = true;
} else if (key == "slots") { } else if (key == "slots") {
// L7 display positions. Absent on a pre-L7 blob (the else-branch skips // Display positions. Absent on a pre-existing blob; when present it
// nothing because the key never appears); when present it drives the // drives the bank's SlotMap. reconcileSlots() (post-adopt) squares it
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. // with membership.
std::vector<std::pair<std::string, int>> pairs; std::vector<std::pair<std::string, int>> pairs;
if (!parseSlots(r, pairs)) return false; if (!parseSlots(r, pairs)) return false;
b.slots = SlotMap::fromEntries(pairs); b.slots = SlotMap::fromEntries(pairs);
@@ -186,9 +175,7 @@ bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks
} else if (key == "activeBank") { } else if (key == "activeBank") {
if (!r.parseString(activeBank)) return false; if (!r.parseString(activeBank)) return false;
} else if (key == "samples") { } else if (key == "samples") {
// Legacy marker. The legacy index is re-parsed from the whole input below // 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.
sawSamples = true; sawSamples = true;
if (!r.skipValue()) return false; if (!r.skipValue()) return false;
} else { } else {
@@ -253,23 +240,20 @@ std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
json::Reader r(blob); json::Reader r(blob);
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt; 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, // 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 // 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 // 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- // would degrade the user's entire library to empty, so instead we AUTO-
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and // DISAMBIGUATE the later duplicate: scan in parse order, and the first time a
// the first time a folded key repeats, suffix that bank's display name (" 2", // folded key repeats, suffix that bank's display name (" 2", " 3", …) until its
// " 3", …) until its folded key is unique among all names seen so far. The FIRST // folded key is unique among names seen so far — the first bank to carry a key
// bank to carry a key keeps its name verbatim; only subsequent collisions are // keeps its name verbatim. No bank or sample is lost, ids are untouched, and the
// renamed. No bank or sample is lost, and ids are untouched. The pool is included // pool's reserved "Pool" key is seeded first so a named bank folding to "pool"
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
// is disambiguated away from it, never the reverse. // is disambiguated away from it, never the reverse.
// //
// Hosted HERE (a static member, Q-W5) rather than in the free parseBook because // Hosted here (not in the free parseBook) because it folds through the PRIVATE
// it folds through the PRIVATE BankBook::nameKey the same rule the // BankBook::nameKey the create/rename uniqueness check also uses.
// 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.
{ {
std::vector<std::string> seenKeys; std::vector<std::string> seenKeys;
seenKeys.reserve(banks.size()); seenKeys.reserve(banks.size());
+21 -40
View File
@@ -4,21 +4,14 @@
#include "core/json/json.h" #include "core/json/json.h"
// bank_model implementation. // 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
// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer, // significant digits (%.17g), the shortest form that round-trips every
// no per-module Parser copy). The field set is a flat struct of primitives, // IEEE-754 double exactly, so deserialize(serialize(x)) == x holds bit-for-bit.
// 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.
namespace reasampler::model { namespace reasampler::model {
// --------------------------------------------------------------------------- // -- equality -----------------------------------------------------------
// equality
// ---------------------------------------------------------------------------
bool SourceRange::operator==(const SourceRange& o) const { bool SourceRange::operator==(const SourceRange& o) const {
return startSeconds == o.startSeconds && endSeconds == o.endSeconds && return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
@@ -51,20 +44,15 @@ bool Sample::operator==(const Sample& o) const {
provenance == o.provenance && createdTimestamp == o.createdTimestamp; provenance == o.provenance && createdTimestamp == o.createdTimestamp;
} }
// --------------------------------------------------------------------------- // -- path invariant -------------------------------------------------------
// path invariant
// ---------------------------------------------------------------------------
// DECISION: reject absolute paths rather than normalize them. The pure model has // Rejects absolute paths rather than normalizing them: the pure model has no
// no knowledge of the project root, so it cannot correctly relativize an absolute // knowledge of the project root, so any "normalization" would be a guess that
// path — any "normalization" would be a guess that could point at the wrong file. // could point at the wrong file. Covers POSIX ("/x"), Windows drive ("C:\x",
// Rejecting at the boundary is honest and deterministic; the capture backend (M3) // "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") forms. Any leading
// is responsible for handing us an already-relative path. Covers POSIX ("/x"), // <alpha>: is rejected regardless of what follows — drive-relative paths
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") // ("C:foo.wav") resolve against the drive's current directory, not the project
// forms. Any leading <alpha>: is rejected regardless of the character that follows — // root, so they violate relative-paths-only just as much as "C:\foo.wav" does.
// 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.
static bool isAbsolutePath(const std::string& p) { static bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false; if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC
@@ -73,9 +61,7 @@ static bool isAbsolutePath(const std::string& p) {
return false; return false;
} }
// --------------------------------------------------------------------------- // -- BankModel ------------------------------------------------------------
// BankModel
// ---------------------------------------------------------------------------
AddResult BankModel::add(const Sample& sample) { AddResult BankModel::add(const Sample& sample) {
if (sample.id.empty()) return AddResult::RejectedEmptyId; if (sample.id.empty()) return AddResult::RejectedEmptyId;
@@ -139,9 +125,7 @@ std::vector<Sample> BankModel::byTier(Tier tier) const {
return out; return out;
} }
// --------------------------------------------------------------------------- // -- JSON writer ------------------------------------------------------------
// JSON writer
// ---------------------------------------------------------------------------
namespace { namespace {
@@ -182,9 +166,9 @@ void writeSample(std::string& out, const Sample& s) {
w.keyBegin("key"); w.keyBegin("key");
if (s.key) writeEscaped(out, *s.key); else out += "null"; 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` // Emitted as null when absent (same shape as `key`/`provenance`) so JSON that
// and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely — // lacks these keys entirely parses to empty optionals and re-serializes
// parses to empty optionals and re-serializes without invention. // without invention.
w.keyBegin("rootNote"); w.keyBegin("rootNote");
if (s.rootNote) out += numToStr(*s.rootNote); else out += "null"; if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";
@@ -240,12 +224,9 @@ std::string BankModel::serialize() const {
return out; return out;
} }
// --------------------------------------------------------------------------- // -- JSON parser (recursive descent over the shared json::Reader) -----------
// JSON parser (recursive descent over the shared json::Reader). Returns false // Returns false on any malformed input; never reads out of bounds. Only
// on any malformed input; never reads out of bounds. Only supports the subset // supports the subset our writer emits.
// our writer emits. The lexical layer (strings, numbers, skip) lives in
// core/json; only the Sample/index DOMAIN grammar lives here.
// ---------------------------------------------------------------------------
namespace { namespace {
+35 -44
View File
@@ -1,11 +1,7 @@
#pragma once #pragma once
// bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so // bank_model — the HEART of ReaSampler: the per-project sample bank. `Sample`
// it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample // metadata struct + `BankModel` (add/remove/query/tier moves/dedup-by-hash + JSON
// bank: the `Sample` metadata struct and the `BankModel` (add / remove / query / // round-trip to/from std::string).
// 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.
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
@@ -14,8 +10,8 @@
namespace reasampler::model { namespace reasampler::model {
// How the source audio was obtained. Kept in the pure core (no REAPER coupling); // How the source audio was obtained; the capture backends map their own notion
// the capture backends (M3/M8) map their own notion onto these. // onto these.
enum class SourceMode { enum class SourceMode {
MasterMix, // offline render of the master output MasterMix, // offline render of the master output
SelectedTracks, // offline render of selected tracks SelectedTracks, // offline render of selected tracks
@@ -31,9 +27,8 @@ enum class Tier {
Archive, Archive,
}; };
// Sample-accurate source bounds, in both project seconds and PPQ (ticks). Both // Sample-accurate source bounds, in both project seconds and PPQ (ticks) — both
// are stored because capture needs seconds and musical placement needs PPQ; we // stored so capture doesn't re-derive one from the other and risk rounding.
// refuse to re-derive one from the other and risk rounding (precision invariant).
struct SourceRange { struct SourceRange {
double startSeconds = 0.0; double startSeconds = 0.0;
double endSeconds = 0.0; double endSeconds = 0.0;
@@ -44,9 +39,9 @@ struct SourceRange {
}; };
// Present only when a sample was resampled FROM another sample. Carries the // 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 // parent's id and an FX-chain snapshot (a thin drift fingerprint, NOT a restorable
// restorable chunk) captured at resample time; the re-capture-from-source action // chunk) — re-capture-from-source uses it to detect chain drift and replay the
// (M10) uses it to detect chain drift and replay the original capture request. // original capture request.
struct Provenance { struct Provenance {
std::string parentSampleId; std::string parentSampleId;
std::string fxChainSnapshot; std::string fxChainSnapshot;
@@ -63,15 +58,13 @@ struct Levels {
bool operator==(const Levels& o) const; bool operator==(const Levels& o) const;
}; };
// Sample-accurate sustain-loop bounds, as frame indices into the captured file // Sample-accurate sustain-loop bounds, as frame indices into the captured file — a
// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like // bank intrinsic (like sampleRate or length) the MIDI-playback instrument uses to
// sampleRate or length — consumed by the future MIDI-playback instrument to hold // hold notes past the recorded length. One optional struct (not two loose
// notes past the recorded length. Modeled as one optional struct (not two loose // optionals) so "both points or neither" is structural, not a rule to re-check at
// optionals) so "both points or neither" is a structural invariant, not a rule to // every boundary. Frame indices, not seconds — the instrument relates them to time
// re-check at every boundary. Frame indices, not seconds, because the loop is a // via the file's sample rate. Invariant (enforced at deserialize): 0 <= start <=
// per-sample-frame contract; the instrument reads the file's sample rate to relate // end; start == end is a valid zero-length loop marker.
// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end.
// start == end is a valid zero-length loop marker.
struct LoopPoints { struct LoopPoints {
std::int64_t start = 0; std::int64_t start = 0;
std::int64_t end = 0; std::int64_t end = 0;
@@ -102,23 +95,23 @@ struct Sample {
double lengthBeats = 0.0; double lengthBeats = 0.0;
double captureTempo = 0.0; // project tempo (BPM) at capture time double captureTempo = 0.0; // project tempo (BPM) at capture time
// Time signature at capture time (L7 F1 — stamped alongside captureTempo so the // Time signature at capture time, stamped alongside captureTempo so the
// bars.beats.subdivisions read-out is stable under later project meter changes). // 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); // 0/0 means UNSTAMPED (pre-existing sample, or a capture that could not read the
// the metadata formatter renders a blank musical read-out for 0/0 and keeps s.ms. // 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 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 int captureTimeSigDenom = 0; // meter denominator (e.g. 4 in 4/4); 0 = unstamped
std::optional<std::string> key; // musical key, when known std::optional<std::string> key; // musical key, when known
// Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument, // Bank intrinsics for the MIDI-playback instrument, additive like `provenance`.
// additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S // Both default cleanly empty: pre-existing samples deserialize without them and
// samples deserialize without them and re-serialize without inventing values. // re-serialize without inventing values.
// - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument // - 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: // 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. // `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) // Populated only where derivable — never guessed when the source isn't a
// when the source is not a single played note. // single played note.
// - loop: sustain-loop bounds, populated only where explicitly set. // - loop: sustain-loop bounds, populated only where explicitly set.
std::optional<int> rootNote; std::optional<int> rootNote;
std::optional<LoopPoints> loop; std::optional<LoopPoints> loop;
@@ -156,7 +149,7 @@ enum class AddResult {
// An ordered, id-keyed collection of Samples with content-hash dedup, tier // An ordered, id-keyed collection of Samples with content-hash dedup, tier
// moves/filtering, and lossless JSON round-trip. Insertion order is preserved // 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 { class BankModel {
public: public:
// Adds a sample. Enforces the relative-paths-only invariant and dedups by // Adds a sample. Enforces the relative-paths-only invariant and dedups by
@@ -168,16 +161,14 @@ public:
bool remove(const std::string& id); bool remove(const std::string& id);
// Replaces the sample carrying `id` IN PLACE (preserving its position in // Replaces the sample carrying `id` IN PLACE (preserving its position in
// insertion order), with `updated`. Used by M10 re-capture-from-source: a // insertion order) with `updated`. Used by re-capture-from-source: a
// provenanced sample's file is regenerated and its metadata (relativePath, // provenanced sample's file is regenerated and its metadata refreshed while
// contentHash, levels, timestamp, ...) refreshed while its identity (id) and // its identity (id) and slot are kept, so the panel shows the same tile
// slot are kept, so the bank panel shows the same tile updated rather than a // updated rather than a reordered new entry. `updated.id` should equal `id`;
// reordered new entry. `updated.id` should equal `id` (the caller keeps the id // a differing id is written through as given. Does NOT dedup — an in-place
// stable); a differing id is written through as given (the caller's contract). // refresh is not a new insert, so collapse-by-hash (which guards inserts)
// Does NOT dedup — an in-place refresh of one entry is not a new insert, so the // does not apply. Returns false (no mutation) if `id` is absent or
// collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false // `updated.relativePath` is absolute (relative-paths-only still holds here).
// (no mutation) if `id` is absent or `updated.relativePath` is absolute
// (the relative-paths-only invariant still holds for the replacement).
bool updateInPlace(const std::string& id, const Sample& updated); bool updateInPlace(const std::string& id, const Sample& updated);
// Returns the sample with `id`, or nullptr if absent. The pointer is // Returns the sample with `id`, or nullptr if absent. The pointer is
+10 -23
View File
@@ -4,27 +4,21 @@
#include "core/json/json.h" #include "core/json/json.h"
// owned_manifest implementation. // owned_manifest implementation. JSON shape is a single object with one string
// // array:
// 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":["reasampler_bank/a.wav","reasampler_bank/b.wav"]} // {"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 { namespace reasampler::model {
// --------------------------------------------------------------------------- // -- path invariant (mirror of bank_model's isAbsolutePath) ----------------
// path invariant (mirror of bank_model's isAbsolutePath)
// ---------------------------------------------------------------------------
namespace { namespace {
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive, // Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to // 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 // 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). // must match exactly.
bool isAbsolutePath(const std::string& p) { bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false; if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true; if (p[0] == '/' || p[0] == '\\') return true;
@@ -35,9 +29,7 @@ bool isAbsolutePath(const std::string& p) {
} // namespace } // namespace
// --------------------------------------------------------------------------- // -- mutation / query -------------------------------------------------------
// mutation / query
// ---------------------------------------------------------------------------
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) { ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath; if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
@@ -53,9 +45,7 @@ bool OwnedFileManifest::contains(const std::string& relativePath) const {
return false; return false;
} }
// --------------------------------------------------------------------------- // -- JSON writer --------------------------------------------------------
// JSON writer (shared core/json escape — byte-identical to the prior local one)
// ---------------------------------------------------------------------------
std::string OwnedFileManifest::serialize() const { std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":["; std::string out = "{\"owned\":[";
@@ -67,11 +57,8 @@ std::string OwnedFileManifest::serialize() const {
return out; return out;
} }
// --------------------------------------------------------------------------- // JSON parser: string-array-only grammar. Tolerates unknown keys and requires
// JSON parser (string-array-only DOMAIN grammar over the shared core/json // the "owned" value to be an array of strings.
// lexical layer). Tolerates unknown keys (forward-compat) and requires the
// "owned" value to be an array of strings.
// ---------------------------------------------------------------------------
namespace { namespace {
+14 -29
View File
@@ -1,31 +1,16 @@
#pragma once #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 // NOT a mirror of the bank index: removing/moving an index entry does NOT remove
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same // the file's manifest record (the manifest tracks files *created*; prune reconciles
// "small pure type + JSON round-trip" pattern as wav_codec / tab_strip. // manifest-vs-index later). Only the capture add-path adds to it — no remove verb.
// //
// -- What it is -------------------------------------------------------------- // Paths are ALWAYS project-relative (same invariant as Sample.relativePath). add()
// // rejects an absolute path rather than guess a relativization — the pure model has
// The set of files the bank system ITSELF created — every file the capture path // no project root, so "normalizing" could point at the wrong file.
// 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).
#include <optional> #include <optional>
#include <string> #include <string>
@@ -58,12 +43,12 @@ public:
// capture of an identical request does not double-record. // capture of an identical request does not double-record.
ManifestAddResult add(const std::string& relativePath); ManifestAddResult add(const std::string& relativePath);
// True iff the exact path string is recorded. Phase R uses this to attribute a // 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) // present file to the bank system. Exact string match — path normalization (if
// is the caller's concern, consistent across add and query. // any) is the caller's concern, consistent across add and query.
bool contains(const std::string& relativePath) const; 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. // set; here it is the round-trip + query surface.
const std::vector<std::string>& paths() const { return paths_; } const std::vector<std::string>& paths() const { return paths_; }
+15 -28
View File
@@ -4,26 +4,16 @@
#include "core/wire/wire.h" #include "core/wire/wire.h"
// provenance implementation — pure, self-contained (no third-party lib, mirror of // provenance implementation — pure, self-contained encoding.
// bank_model's hand-rolled encoding discipline).
// //
// ENCODING (the fingerprint string): a length-prefixed, field-ordered format so it // Fingerprint grammar: magic "rsprov1" + fixed-order length-prefixed fields
// is unambiguous and forge-proof (a value containing the separator cannot shift // (<len>':'<bytes>), so a value containing the separator can never shift the
// the parse). Grammar: // 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
// "rsprov1" -- magic + version tag // track-GUID count + that many GUIDs, then the folded fxChainIdentity — itself
// then, in fixed order, each field as <len>':'<bytes> // length-prefixed per entry field, so it nests safely as one more field. Any
// // deviation (bad magic, short read, bad number) -> parseFingerprint returns
// Every field — including numbers — is emitted as its decimal / %.17g text then // nullopt.
// 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.
namespace reasampler::model { namespace reasampler::model {
@@ -39,10 +29,9 @@ namespace {
constexpr const char* kMagic = "rsprov1"; constexpr const char* kMagic = "rsprov1";
// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full // The shared core/wire codec carries the field grammar + range-checked fieldInt.
// hardening (incl. the fixed fieldInt range check that closes the old strtol // Only the %.17g double rendering stays local — this writer's convention, shared
// silent-narrowing TODO). Only the %.17g double rendering stays local — it is // with the bank model's JSON doubles.
// this writer's convention, shared with the bank model's JSON doubles.
using wire::putField; using wire::putField;
using Cursor = wire::Cursor; using Cursor = wire::Cursor;
@@ -112,10 +101,9 @@ std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
std::size_t guidCount = 0; std::size_t guidCount = 0;
if (!c.fieldSizeT(guidCount)) return std::nullopt; if (!c.fieldSizeT(guidCount)) return std::nullopt;
// Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least // Each GUID field costs at least 2 wire bytes ("0:"), so a count past size/2 is
// 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the // provably bogus — reject BEFORE the reserve, so a corrupt/crafted persisted
// reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge) // fingerprint can never drive reserve(huge) into std::length_error / bad_alloc.
// into std::length_error / bad_alloc through the shell.
if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt; if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt;
r.trackGuids.reserve(guidCount); r.trackGuids.reserve(guidCount);
for (std::size_t i = 0; i < guidCount; ++i) { for (std::size_t i = 0; i < guidCount; ++i) {
@@ -139,7 +127,6 @@ std::optional<std::string> detectParent(
std::optional<std::string> parent; // the single bank sample all sources point at std::optional<std::string> parent; // the single bank sample all sources point at
for (const std::string& src : sourceItemFiles) { for (const std::string& src : sourceItemFiles) {
// Resolve this source file against the bank by exact normalized path.
const std::string* matchedId = nullptr; const std::string* matchedId = nullptr;
for (const BankFileRef& ref : bankFiles) { for (const BankFileRef& ref : bankFiles) {
if (!ref.absolutePath.empty() && ref.absolutePath == src) { if (!ref.absolutePath.empty() && ref.absolutePath == src) {
+23 -47
View File
@@ -1,35 +1,17 @@
#pragma once #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 // A THIN reproducibility fingerprint (drift-detect + re-run) — NOT a serialized FX
// vendor/ includes. Standard library only. The shell (main.cpp / the action families) // chunk to restore; nothing here stores a restorable chain.
// 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.
#include <optional> #include <optional>
#include <string> #include <string>
@@ -111,7 +93,7 @@ std::string buildFingerprint(const CaptureRecipe& recipe);
// mis-driving a re-capture. // mis-driving a re-capture.
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint); std::optional<CaptureRecipe> 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, // 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 // 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) std::string absolutePath; // normalized (forward-slash, no trailing slash)
}; };
// Decides whether a capture derives from a bank sample. // 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
// RULE (stated for the handoff, honest — no false parentage): a capture derives // be resolved points at the SAME bank sample's file (exact normalized absolute
// from a bank sample iff EVERY source item whose media file could be resolved // path). Files not in the bank, or matching MORE THAN ONE distinct bank sample
// points at the SAME bank sample's file (by exact normalized absolute path). If // (ambiguous), yield no parent. An empty source-file set yields no parent.
// 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.
// //
// sourceItemFiles : normalized absolute paths of the capture's source items' // sourceItemFiles : normalized absolute paths of the capture's source items'
// take media files (the shell gathers + normalizes them). A // take media files, gathered by the shell. A file that could
// file that could not be resolved is simply omitted by the // not be resolved is simply omitted — never an empty string.
// shell — it never becomes an empty string here.
// bankFiles : the active book's samples as BankFileRefs (path -> id). // bankFiles : the active book's samples as BankFileRefs (path -> id).
// //
// Returns the parent sample id, or nullopt when the capture is not a genuine // Returns the parent sample id, or nullopt otherwise. Both sides are normalized
// resample-from-sample. Comparison is exact path identity; the caller normalizes // identically via normalizeSlashes (lowercased on Windows) so a slash/case
// both sides identically via normalizeSlashes (which lowercases on Windows) so a // difference never spuriously matches or misses.
// 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.
std::optional<std::string> detectParent( std::optional<std::string> detectParent(
const std::vector<std::string>& sourceItemFiles, const std::vector<std::string>& sourceItemFiles,
const std::vector<BankFileRef>& bankFiles); const std::vector<BankFileRef>& bankFiles);
+2 -6
View File
@@ -4,12 +4,10 @@
#include "core/json/json.h" #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 // 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 // slot per id. Every mutator restores it; queries assume it.
// shared core/json emit helpers — the emitted fragment is byte-identical to the
// pre-extraction bank_book writer.
namespace reasampler::model { namespace reasampler::model {
@@ -128,8 +126,6 @@ SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pai
std::string SlotMap::serialize() const { std::string SlotMap::serialize() const {
// Array of {id, slot} objects in ascending slot order (entries_ is kept sorted). // 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; std::string out;
out += '['; out += '[';
for (std::size_t i = 0; i < entries_.size(); ++i) { for (std::size_t i = 0; i < entries_.size(); ++i) {
+12 -20
View File
@@ -1,21 +1,13 @@
#pragma once #pragma once
// slot_map — the L7 gap-preserving display-position carrier for ONE bank (F2 settled: // slot_map — the gap-preserving display-position carrier for ONE bank: plain
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a // interchangeable slots, not fixed/addressable ones. A slot is a display position a
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps // sample id occupies; the map is sample id -> slot (>= 0). Gaps are first-class (a
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty // bank may have slot 1 occupied with slot 0 empty). At most one id per slot, at
// first row above an occupied second row). At most one id per slot (a slot is never // most one slot per id.
// double-occupied) and at most one slot per id (an id sits in exactly one place).
// //
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one // Position lives HERE, not on Sample: a copy of one sample into two banks may sit
// sample into two banks may sit at different slots, so position is a per-bank display // at different slots, so position is a per-bank display concern owned by the
// concern owned by the bank's membership. bank_model / Sample stay untouched. // 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.
#include <cstddef> #include <cstddef>
#include <string> #include <string>
@@ -50,7 +42,7 @@ public:
// keeps its position. Returns true if the id was mapped. // keeps its position. Returns true if the id was mapped.
bool remove(const std::string& id); 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 EMPTY -> `id` moves there; its old slot is left empty.
// * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and // * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
// every occupant at slot >= targetSlot (except `id` itself) shifts up by one, // 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); bool reorder(const std::string& id, int targetSlot);
// Rebuilds the map densely from `ids` in the given order (slot i = ids[i]), // 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 // dropping any prior state. The migration path: a bank with no persisted slot
// slot data is seeded from its BankModel insertion order, densely packed (no gaps), // 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. // so it is visually identical on first load. Empty/duplicate ids skipped.
void resetDense(const std::vector<std::string>& ids); void resetDense(const std::vector<std::string>& ids);
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left // Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left
+88 -109
View File
@@ -1,45 +1,32 @@
#pragma once #pragma once
// prune_reconcile — the pure core of Phase R (Reclaim), Wave 1. The safety-critical // prune_reconcile — the safety-critical "which files are orphans" decision, computed
// "which files are orphans" decision, computed with NO filesystem I/O and NO REAPER // with NO filesystem I/O and NO REAPER types. Unit-tested outside the DAW before any
// types. The mirror of view_mode_model's reconcile(liveGuids), one level DOWN: it // I/O exists — this decides which bytes get deleted.
// 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 ------------------------------------------------------
// //
// The one computation:
// orphans = (owned ∩ present) referenced // orphans = (owned ∩ present) referenced
// // * present — files enumerated in the resolved current bank folder (shell).
// * present — files enumerated in the resolved current bank folder (R2 shell).
// * referenced — every project-relative path referenced by ANY bank in the book, // * referenced — every project-relative path referenced by ANY bank in the book,
// pool included (union across the whole book — see BankBook:: // pool included (union across the whole book — see BankBook::
// referencedPaths). A file referenced by any bank — including via a // referencedPaths). A file referenced by any bank — including via a
// COPY into a second bank — is NEVER an orphan (the prune null test). // 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 // * owned — the owned-file manifest: files the bank system itself created. A
// (OwnedFileManifest). A present-but-unowned (hand-dropped) file is // present-but-unowned (hand-dropped) file is NEVER reclaimed.
// NEVER reclaimed — prune reclaims only the system's own leavings.
// //
// 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- // * ∩ present — never proposes deleting a file that is not on disk (an owned-
// but-absent manifest entry yields no orphan, no error). // 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). // * referenced — never a file any bank references (union safety, prune null test).
// //
// -- Path representation: EXACT-STRING match (safety-critical) ----------------- // Path representation: EXACT-STRING match everywhere — Sample.relativePath,
// // OwnedFileManifest::contains, BankModel all use raw std::string equality: no
// Every path in the model is a project-relative string compared VERBATIM: Sample. // separator normalization, no case-folding, no trailing-slash trimming. Feeding a
// relativePath, OwnedFileManifest::contains (p == relativePath), and BankModel all // consistent spelling across the three inputs is the shell's contract (it enumerates
// use raw std::string equality — no separator normalization, no case-folding, no // the folder, unions the book, and reads the manifest against the SAME resolved
// trailing-slash trimming. This core MATCHES that convention exactly: it compares // current folder). Diverging from exact match here (e.g. case-insensitive compare)
// the raw strings the shell supplies. Feeding a consistent spelling across the three // would be the unsafe direction — it could let one spelling of a referenced file be
// inputs is the R2 shell's contract (it enumerates the folder, unions the book, and // treated as an orphan under another.
// 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 <cstdint> #include <cstdint>
#include <string> #include <string>
@@ -48,34 +35,28 @@
namespace reasampler::reclaim { namespace reasampler::reclaim {
// The dry-run prune result (Phase R, Wave 2 — report only, no deletion). The thin // The dry-run prune result — report only, no deletion. The shell fills this from
// prune shell (persist) fills this from pruneOrphans() + a per-file size stat and hands // pruneOrphans() + a per-file size stat; a confirmed delete later acts on the SAME
// it to the report surface; R3 will act on the SAME set behind the confirm guardrail. // set behind the confirm guardrail. Filesystem-free by design (the shell does the
// REAPER-free / filesystem-free by design (the shell does the I/O; this is just the // I/O; this is the tallied outcome), so the aggregation is unit-testable.
// tallied outcome), so the count/size aggregation is unit-testable outside the DAW.
// //
// * count — number of orphan files (== orphans.size(); the AUTHORITATIVE tally, // * count — number of orphan files (authoritative tally, exact even when
// exact even when `orphans` below is a truncated display list). // `orphans` below is a truncated display list).
// * totalBytes — sum of the on-disk sizes of the orphan files, in bytes (reclaimable // * totalBytes — sum of the on-disk sizes of the orphan files, in bytes. A file
// space). A file the stat could not size contributes 0 (never negative). // the stat could not size contributes 0 (never negative).
// * orphans — the orphan file list as project-relative index-spelled paths, in // * orphans — the orphan file list, project-relative, in folder-enumeration
// folder-enumeration order (deterministic). MAY be truncated for a large // order (deterministic). MAY be truncated for a large set (the
// set (the shell's display cap); `count` stays exact regardless, and // shell's display cap); `count` stays exact regardless.
// `truncated` says whether the list was clipped. // * truncated — true iff `orphans` holds fewer than `count` entries.
// * truncated — true iff `orphans` holds fewer than `count` entries (a large set was
// clipped for display); false when the list is complete.
// * abortedUnreadableUsage — true iff the scan found a present-but-unreadable // * abortedUnreadableUsage — true iff the scan found a present-but-unreadable
// rsusage_* instance-usage record (pS-usage fail-safe): the orphan // rsusage_* instance-usage record: the orphan computation was NOT
// computation was NOT performed (count 0, empty list) and the prune // performed (count 0, empty list) and the prune must HALT —
// must HALT — deleting with degraded protection is the data-loss // deleting with degraded protection is the data-loss direction.
// direction. Set by the session's scan shell, never by // Set by the scan shell, never by buildPruneReport (which stays a
// buildPruneReport (which stays a pure tally). // pure tally).
// * offendingUsageKeys — the exact "rsusage_<guid>" ext-state key names that // * offendingUsageKeys — the exact "rsusage_<guid>" key names that triggered the
// triggered the abort (non-empty iff abortedUnreadableUsage). Named // abort (non-empty iff abortedUnreadableUsage), so the operator
// so the action can print them for operator recovery: a corrupt/ // can clear each key via ReaScript:
// 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:
// reaper.SetProjExtState(0, "reasampler", "<key>", "") // reaper.SetProjExtState(0, "reasampler", "<key>", "")
struct PruneReport { struct PruneReport {
std::size_t count = 0; std::size_t count = 0;
@@ -86,20 +67,18 @@ struct PruneReport {
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
}; };
// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills // The outcome of an actual prune DELETION. The shell fills this as it deletes the
// this as it deletes the confirmed orphan set, reporting what it ACTUALLY reclaimed (not // confirmed orphan set, reporting what it ACTUALLY reclaimed (not what it intended)
// what it intended to) so a locked/vanished file shows up as a skip, never a false claim. // so a locked/vanished file shows up as a skip, never a false claim. Filesystem-free
// REAPER-free / filesystem-free by design (the shell does the deletion; this is the // by design, so the count/byte aggregation is unit-testable.
// tallied outcome), so the count/byte aggregation is unit-testable outside the DAW.
// //
// * reclaimedCount — number of files actually removed from disk BY THIS CALL (trash or // * reclaimedCount — files actually removed from disk BY THIS CALL (trash or
// unlink). Already-absent files are NOT counted here. // unlink). Already-absent files are NOT counted.
// * reclaimedBytes — sum of the on-disk sizes of the files actually removed, in bytes. // * reclaimedBytes — sum of the on-disk sizes of the files actually removed.
// * skippedCount — files that could not be or were not reclaimed: stale entries that // * skippedCount — files not reclaimed: stale entries dropped from the
// dropped out of the fresh-orphan intersection, files that vanished // fresh-orphan intersection, files that vanished between plan
// between the plan and the delete call (already absent), and real // and delete, and real delete failures. Never an error/crash.
// delete failures (locked, conversion error). Never an error/crash. // * usedTrash — true iff deletions were routed to the OS trash/recycle bin
// * usedTrash — true iff the deletions were routed to the OS trash/recycle bin
// (recoverable); false iff the platform fell back to hard unlink. // (recoverable); false iff the platform fell back to hard unlink.
struct PruneDeletionResult { struct PruneDeletionResult {
std::size_t reclaimedCount = 0; std::size_t reclaimedCount = 0;
@@ -110,11 +89,11 @@ struct PruneDeletionResult {
// Computes the prune orphan set: (owned ∩ present) referenced. // Computes the prune orphan set: (owned ∩ present) referenced.
// //
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the ORDER // 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 // they appear in `present` (deterministic — mirrors the insertion-order determinism
// determinism the index / manifest keep; the R2 dry-run reports a stable file list). // the index/manifest keep). Duplicate spellings within `present` are de-duplicated
// Duplicate spellings within `present` are de-duplicated in the result (a folder // in the result (a folder enumeration yields distinct names, but the core does not
// enumeration yields distinct names, but the core does not rely on that). // rely on that).
// //
// Pure: no I/O, no REAPER, no hidden state. All three inputs are project-relative // 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). // path strings, compared by exact std::string equality (see header note).
@@ -122,56 +101,56 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced, const std::vector<std::string>& referenced,
const std::vector<std::string>& owned); const std::vector<std::string>& owned);
// Union two referenced-path sets into one (pS-usage): the bank's own referencedPaths() // Unions two referenced-path sets into one: the bank's own referencedPaths() PLUS
// PLUS the paths held by live ReaSampler 9000 instances (sample_usage::usageHeldPaths). // the paths held by live ReaSampler 9000 instances (sample_usage::usageHeldPaths).
// Order-preserving (`primary` first, then the `extra` paths not already present), // Order-preserving (`primary` first, then the `extra` paths not already present),
// exact-string de-dup — the same comparison convention as everything above, so feeding // exact-string de-dup — the same comparison convention as everything above, so
// the result to pruneOrphans keeps the ` referenced` guardrail byte-exact. A path held // feeding the result to pruneOrphans keeps the ` referenced` guardrail byte-exact.
// ONLY by an instance (e.g. its bank entry was deleted while the instance kept its v10 // A path held ONLY by an instance (its bank entry was deleted while the instance
// ref) is protected exactly like a bank-referenced one. // 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 // Pure: no I/O, no REAPER. Kept here (not in the shells) so "instance usage makes a
// file un-prunable" property is provable at the prune layer itself. // file un-prunable" is provable at the prune layer itself.
std::vector<std::string> mergeReferenced(const std::vector<std::string>& primary, std::vector<std::string> mergeReferenced(const std::vector<std::string>& primary,
const std::vector<std::string>& extra); const std::vector<std::string>& extra);
// Tallies a dry-run PruneReport from a computed orphan set and a per-path size lookup. // Tallies a dry-run PruneReport from a computed orphan set and a per-path size
// PURE (no I/O): the shell does the folder stat and passes the sizes in `sizeByPath`; // lookup. Pure (no I/O): the shell does the folder stat and passes sizes in
// this owns the count / byte-sum / display-truncation decision so it is unit-testable. // `sizeByPath`; this owns the count/byte-sum/display-truncation decision.
// //
// * count == orphans.size() (the authoritative tally, exact regardless of the cap). // * count == orphans.size() (authoritative tally, exact regardless of the cap).
// * totalBytes == the sum of sizeByPath[o] over EVERY orphan o (not just the displayed // * totalBytes == sum of sizeByPath[o] over EVERY orphan o (not just displayed); a
// ones); a path missing from sizeByPath contributes 0 (an orphan whose // path missing from sizeByPath contributes 0 (never negative).
// size could not be stat'd — never negative, never dropped from the sum).
// * orphans == the first `displayCap` orphans in input order (the deterministic // * orphans == the first `displayCap` orphans in input order (the deterministic
// folder-enumeration order pruneOrphans preserved); the whole set when // order pruneOrphans preserved); the whole set when count <=
// count <= displayCap. displayCap == 0 means "no display cap" (whole set). // displayCap. displayCap == 0 means "no display cap".
// * truncated == count > orphans.size() (a large set was clipped for display). // * truncated == count > orphans.size().
// //
// Kept separate from pruneOrphans so the safety-critical set algebra stays a pure function // Kept separate from pruneOrphans so the safety-critical set algebra stays a pure
// of three sets, while the presentation tally (which the R2 dry-run and R3 confirm both // function of three sets, while the presentation tally is its own testable step.
// need) is its own small, testable step.
PruneReport buildPruneReport(const std::vector<std::string>& orphans, PruneReport buildPruneReport(const std::vector<std::string>& orphans,
const std::unordered_map<std::string, std::uint64_t>& sizeByPath, const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
std::size_t displayCap); std::size_t displayCap);
// Computes the confirm-time delete plan: the intersection of the set the user was SHOWN // Computes the confirm-time delete plan: the intersection of the set the user was
// and confirmed (`confirmed`) with a FRESH pure-core orphan output (`freshOrphans`) taken // SHOWN and confirmed (`confirmed`) with a FRESH pure-core orphan output
// at delete time. Returns exactly `confirmed ∩ freshOrphans`, in the order of `confirmed` // (`freshOrphans`) taken at delete time. Returns exactly `confirmed ∩ freshOrphans`,
// (deterministic — the same order the confirm listed). // 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: // 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 // * A confirmed path that is NO LONGER a fresh orphan — vanished (gone from
// `present`), or that some bank now references (gone from ` referenced`), or whose // `present`), now referenced (gone from ` referenced`), or ownership changed —
// ownership changed — is DROPPED (a skip, never an error, never a wrongful delete of a // is DROPPED (a skip, never a wrongful delete of a now-referenced file). Because
// now-referenced file). Because `freshOrphans` is itself a pure-core output, the plan // `freshOrphans` is itself a pure-core output, the plan can never contain a
// can never contain a referenced or hand-dropped file: the guard survives recompute. // referenced or hand-dropped file: the guard survives recompute.
// * A path that became an orphan AFTER the confirm (in `freshOrphans` but not `confirmed`) // * A path that became an orphan AFTER the confirm (in `freshOrphans` but not
// is NOT deleted — it was never shown, so it is never swept without its own confirm. // `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 // Pure: no I/O, no REAPER. Duplicate spellings in `confirmed` are de-duplicated in
// result (mirrors pruneOrphans; a confirmed set from a real scan holds distinct names). // the result (mirrors pruneOrphans; a confirmed set from a real scan holds distinct
// names).
std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed, std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed,
const std::vector<std::string>& freshOrphans); const std::vector<std::string>& freshOrphans);
+13 -25
View File
@@ -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" #include "core/ui/action_bar.h"
@@ -8,7 +8,6 @@ namespace reasampler::ui {
namespace { namespace {
// The total button count across all clusters (empty clusters contribute nothing).
int totalButtons(const std::vector<ClusterSpec>& clusters) { int totalButtons(const std::vector<ClusterSpec>& clusters) {
int n = 0; int n = 0;
for (const ClusterSpec& c : clusters) for (const ClusterSpec& c : clusters)
@@ -16,21 +15,16 @@ int totalButtons(const std::vector<ClusterSpec>& clusters) {
return n; 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*/) { 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 innerX = s.x + hpad;
const int innerW = s.width - 2 * 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; 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 // The one placement routine; computeBarSlots and hitTestActionBar both drive it so draw and
// ONE placement routine; both computeBarSlots and hitTestActionBar drive it so draw and // hit-test can't drift apart.
// hit-test can never drift. `visible` is assumed already clamped to [0, total]. Returns the
// slots in ascending flat-index order.
std::vector<ActionBarSlot> tile(const ActionBarRect& bar, std::vector<ActionBarSlot> tile(const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec, int visible) { const ActionBarSpec& spec, int visible) {
@@ -43,21 +37,20 @@ std::vector<ActionBarSlot> tile(const ActionBarRect& bar,
if (btnH <= 0) return slots; if (btnH <= 0) return slots;
int cursorX = bar.x + spec.sidePad; int cursorX = bar.x + spec.sidePad;
int flatIndex = 0; // running flat action index across all clusters int flatIndex = 0;
int placed = 0; // buttons placed so far (stops at `visible`) int placed = 0;
bool firstClusterEmitted = false; bool firstClusterEmitted = false;
for (const ClusterSpec& c : clusters) { 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; if (placed >= visible) break;
// Gap BEFORE this cluster (except the first non-empty one).
if (firstClusterEmitted) cursorX += spec.clusterGap; if (firstClusterEmitted) cursorX += spec.clusterGap;
firstClusterEmitted = true; firstClusterEmitted = true;
for (int i = 0; i < c.count; ++i, ++flatIndex) { for (int i = 0; i < c.count; ++i, ++flatIndex) {
if (placed >= visible) return slots; // overflow cut — stop cleanly if (placed >= visible) return slots;
if (i > 0) cursorX += spec.buttonGap; // gap between buttons in the cluster if (i > 0) cursorX += spec.buttonGap;
ActionBarSlot s; ActionBarSlot s;
s.index = flatIndex; s.index = flatIndex;
@@ -76,9 +69,7 @@ std::vector<ActionBarSlot> tile(const ActionBarRect& bar,
return slots; return slots;
} }
// The rightmost pixel the first `visible` buttons would occupy (bar.x + sidePad based). Used by // Mirrors tile()'s advance math so fit and layout agree.
// 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.
int rightEdgeFor(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters, int rightEdgeFor(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec, int visible) { const ActionBarSpec& spec, int visible) {
if (visible <= 0) return bar.x + spec.sidePad; if (visible <= 0) return bar.x + spec.sidePad;
@@ -93,7 +84,7 @@ int rightEdgeFor(const ActionBarRect& bar, const std::vector<ClusterSpec>& clust
for (int i = 0; i < c.count; ++i) { for (int i = 0; i < c.count; ++i) {
if (placed >= visible) return cursorX; if (placed >= visible) return cursorX;
if (i > 0) cursorX += spec.buttonGap; if (i > 0) cursorX += spec.buttonGap;
cursorX += spec.buttonWidth; // this button's right edge cursorX += spec.buttonWidth;
++placed; ++placed;
if (placed >= visible) return cursorX; if (placed >= visible) return cursorX;
} }
@@ -113,8 +104,6 @@ BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& c
} }
const int usableRight = bar.x + bar.width - spec.sidePad; 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; int visible = 0;
for (int cand = 1; cand <= total; ++cand) { for (int cand = 1; cand <= total; ++cand) {
if (rightEdgeFor(bar, clusters, spec, cand) <= usableRight) if (rightEdgeFor(bar, clusters, spec, cand) <= usableRight)
@@ -138,7 +127,6 @@ std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
int hitTestActionBar(int px, int py, const ActionBarRect& bar, int hitTestActionBar(int px, int py, const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec) { const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec) {
if (bar.height <= 0 || bar.width <= 0) return -1; 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 || if (px < bar.x || px >= bar.x + bar.width ||
py < bar.y || py >= bar.y + bar.height) py < bar.y || py >= bar.y + bar.height)
return -1; 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) if (px >= s.x && px < s.x + s.width && py >= s.y && py < s.y + s.height)
return s.index; return s.index;
} }
return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss return -1;
} }
} // namespace reasampler::ui } // namespace reasampler::ui
+26 -96
View File
@@ -1,73 +1,29 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's // action_bar — layout + hit-test for the bank_panel's task-grouped toolbars: buttons cluster by
// TASK-GROUPED toolbars (Phase L, L2 + L4 + L6). L2's dock-panel layout redesign (DS-3: a // task (Capture/Placement/Maintenance/Tagging/Switching); on a narrow panel, whole trailing
// thorough layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact // buttons drop rather than shrink or clip. The destructive Prune button lives separately in
// bar of clusters, each button carrying a label sub-rect spanning // prune_button, kept out of this cluster on purpose.
// 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.
#include <vector> #include <vector>
namespace reasampler::ui { namespace reasampler::ui {
// The task cluster a button belongs to (the L2 "group by task" mandate). The order here is // Task cluster a button belongs to. Cluster order is caller-supplied via ClusterSpec, not fixed
// NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum // here; a slot just carries which cluster it landed in.
// 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.
enum class ActionCluster { enum class ActionCluster {
Capture, // capture item / track / realtime / batch — top toolbar, primary gesture Capture,
Placement, // insert at cursor / insert-conform — top toolbar, placing a sample Placement,
Maintenance, // re-capture from source / cancel realtime — rarer upkeep actions Maintenance,
Tagging, // tag / untag selected tracks for the active mode — bottom toolbar (L4) Tagging,
Switching, // activate Arrange / Design, toggle mode, show-both — bottom toolbar (L4) Switching,
}; };
// The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the using ActionBarRect = Rect;
// 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
// One visible button's placement within the bar, top-left origin. `index` is the button's // One visible button's placement, top-left origin. `index` is its position in the caller's flat
// position in the caller's flat action list (the caller supplies actions in cluster order, so // action list (cluster order), so index also selects the action to fire on a hit. Only buttons
// index also selects the action to fire on a hit). `cluster` is the task group it was laid out // that fit get a slot — overflow is dropped whole, never clipped.
// 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.
struct ActionBarSlot { struct ActionBarSlot {
int index = 0; int index = 0;
ActionCluster cluster = ActionCluster::Capture; ActionCluster cluster = ActionCluster::Capture;
@@ -75,9 +31,7 @@ struct ActionBarSlot {
int y = 0; int y = 0;
int width = 0; int width = 0;
int height = 0; int height = 0;
// Label rect (absolute, top-left origin), inside `box`. The label spans the full button // Label sub-rect, full button height, horizontally inset so text clears the edge.
// height — a single-row short label only (L6: keybinding sub-row removed from the face;
// binding is surfaced in the hover tooltip instead).
int labelX = 0, labelY = 0, labelW = 0, labelH = 0; int labelX = 0, labelY = 0, labelW = 0, labelH = 0;
bool operator==(const ActionBarSlot& o) const { 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 // One cluster's button count, in the order the caller wants it drawn. count == 0 skips the
// in the left-to-right order it wants them drawn (top toolbar: Capture then Placement; bottom // cluster (no gap emitted). Flat action indices run cluster-by-cluster in this order.
// 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.
struct ClusterSpec { struct ClusterSpec {
ActionCluster cluster = ActionCluster::Capture; ActionCluster cluster = ActionCluster::Capture;
int count = 0; int count = 0;
}; };
// Layout inputs for the bar, in pixels. Defaults are the bank_panel action-bar metrics; the // Layout inputs, in pixels; defaults are the bank_panel action-bar metrics.
// 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).
struct ActionBarSpec { struct ActionBarSpec {
int buttonWidth = 108; int buttonWidth = 108;
int buttonGap = 4; int buttonGap = 4;
@@ -116,35 +58,23 @@ struct ActionBarSpec {
int verticalInset = 3; int verticalInset = 3;
}; };
// How many buttons (from the front, cluster by cluster) fit the bar at `spec.buttonWidth`. // How many buttons (from the front) fit at spec.buttonWidth. Split out so the shell can size an
// Split from slot tiling so the shell can size an overflow affordance / count without // overflow affordance without re-deriving it. A bar too narrow for even one button yields 0.
// 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].
struct BarFit { struct BarFit {
int visibleCount = 0; // buttons that fit (laid out), counted from the front int visibleCount = 0;
int hiddenCount = 0; // total - visibleCount (the overflow, dropped whole) int hiddenCount = 0;
}; };
BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters, BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec); const ActionBarSpec& spec);
// Lays out the VISIBLE buttons (per computeBarFit) left-to-right in cluster order: buttons // Lays out the visible buttons (per computeBarFit) left-to-right in cluster order.
// 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.
std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar, std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec); const ActionBarSpec& spec);
// The flat action index the point (px, py) (SWELL/LICE top-left client coords) lands on, or -1 // Flat action index under (px, py), or -1 for a miss (outside the bar, in a gap, or past the
// for a miss: outside the bar band, in an inter-button / inter-cluster gap, or past the last // last visible button). Gaps are real dead-zones here, not resolved to the nearest button.
// 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).
int hitTestActionBar(int px, int py, const ActionBarRect& bar, int hitTestActionBar(int px, int py, const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec); const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec);
+13 -42
View File
@@ -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" #include "core/ui/bank_grid.h"
@@ -9,8 +9,7 @@ namespace reasampler::ui {
namespace { namespace {
// Builds a sorted, unique ascending index vector for the inclusive range [a, b] // Sorted, unique ascending index vector for the inclusive range [a, b] (order-agnostic in a/b).
// (order-agnostic in a/b). Both ends assumed already in-range by the caller.
std::vector<int> rangeIndices(int a, int b) { std::vector<int> rangeIndices(int a, int b) {
if (a > b) std::swap(a, b); if (a > b) std::swap(a, b);
std::vector<int> out; std::vector<int> out;
@@ -19,8 +18,6 @@ std::vector<int> rangeIndices(int a, int b) {
return out; 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 singleSelection(int index) {
Selection s; Selection s;
s.indices = {index}; s.indices = {index};
@@ -32,11 +29,9 @@ Selection singleSelection(int index) {
} // namespace } // namespace
int columnsForWidth(int panelWidth, const GridSpec& spec) { int columnsForWidth(int panelWidth, const GridSpec& spec) {
// Layout: [gap][cell][gap][cell]...[cell][gap]. n cells occupy // Layout: [gap][cell][gap][cell]...[cell][gap]; n cells occupy gap + n*(cellWidth+gap).
// 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.
const int cell = spec.cellWidth + spec.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; const int usable = panelWidth - spec.gap;
if (usable < spec.cellWidth) return 1; if (usable < spec.cellWidth) return 1;
const int cols = usable / cell; const int cols = usable / cell;
@@ -68,15 +63,12 @@ std::vector<CellRect> computeCellRects(int itemCount,
int contentHeight(int itemCount, int panelWidth, const GridSpec& spec) { int contentHeight(int itemCount, int panelWidth, const GridSpec& spec) {
if (itemCount <= 0) return 0; if (itemCount <= 0) return 0;
const int cols = columnsForWidth(panelWidth, spec); const int cols = columnsForWidth(panelWidth, spec);
// Ceil-divide item count by columns to get the row count (partial last row const int rows = (itemCount + cols - 1) / cols; // ceil-divide
// still occupies a full row of height).
const int rows = (itemCount + cols - 1) / cols;
return spec.gap + rows * (spec.cellHeight + spec.gap); return spec.gap + rows * (spec.cellHeight + spec.gap);
} }
std::string thumbnailKeyString(const ThumbnailKey& key) { std::string thumbnailKeyString(const ThumbnailKey& key) {
// Length-prefix the sampleId so a delimiter byte inside an id cannot forge a // Length-prefix sampleId so a delimiter byte inside it can't forge a collision.
// collision with a different (id, width, generation) triple.
std::string s; std::string s;
s.reserve(key.sampleId.size() + 32); s.reserve(key.sampleId.size() + 32);
s += std::to_string(key.sampleId.size()); 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<CellRect>& rects) { int hitTestCell(int px, int py, const std::vector<CellRect>& rects) {
for (std::size_t i = 0; i < rects.size(); ++i) { for (std::size_t i = 0; i < rects.size(); ++i) {
const CellRect& r = rects[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 && if (px >= r.x && px < r.x + r.width &&
py >= r.y && py < r.y + r.height) py >= r.y && py < r.y + r.height)
return static_cast<int>(i); return static_cast<int>(i);
@@ -110,15 +101,14 @@ Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
int itemCount) { int itemCount) {
if (itemCount <= 0 || index < 0 || index >= itemCount) return current; if (itemCount <= 0 || index < 0 || index >= itemCount) return current;
// Shift takes precedence over ctrl (documented): range-select from the anchor.
if (shift) { if (shift) {
const int anchor = current.anchor >= 0 && current.anchor < itemCount const int anchor = current.anchor >= 0 && current.anchor < itemCount
? current.anchor ? current.anchor
: index; // no valid anchor -> seed at the click : index;
Selection s; Selection s;
s.indices = rangeIndices(anchor, index); s.indices = rangeIndices(anchor, index);
s.focus = index; s.focus = index;
s.anchor = anchor; // anchor unchanged across a shift-range s.anchor = anchor;
return s; return s;
} }
@@ -126,15 +116,14 @@ Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
Selection s = current; Selection s = current;
auto it = std::lower_bound(s.indices.begin(), s.indices.end(), index); auto it = std::lower_bound(s.indices.begin(), s.indices.end(), index);
if (it != s.indices.end() && *it == index) if (it != s.indices.end() && *it == index)
s.indices.erase(it); // toggle OUT s.indices.erase(it);
else else
s.indices.insert(it, index); // toggle IN (keeps sorted order) s.indices.insert(it, index);
s.focus = index; s.focus = index;
s.anchor = index; // ctrl-click reseeds the range origin s.anchor = index;
return s; return s;
} }
// Plain click: sole selection.
return singleSelection(index); return singleSelection(index);
} }
@@ -143,8 +132,7 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
if (itemCount <= 0) return current; if (itemCount <= 0) return current;
if (cols < 1) cols = 1; if (cols < 1) cols = 1;
// A fresh panel (no focus): the first key press focuses cell 0 without moving, // Fresh panel: first key press focuses cell 0 without moving.
// so the user sees the caret appear before it steps.
if (current.focus < 0 || current.focus >= itemCount) { if (current.focus < 0 || current.focus >= itemCount) {
if (shift) { if (shift) {
Selection s; Selection s;
@@ -160,22 +148,15 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
int to = from; int to = from;
switch (key) { switch (key) {
case NavKey::Left: case NavKey::Left:
// Move one; clamp at cell 0 (stay put on the first cell).
if (from > 0) to = from - 1; if (from > 0) to = from - 1;
break; break;
case NavKey::Right: case NavKey::Right:
// Move one; clamp at the last cell (stay put on the last cell).
if (from < itemCount - 1) to = from + 1; if (from < itemCount - 1) to = from + 1;
break; break;
case NavKey::Up: case NavKey::Up:
// Move up a row; if that leaves the grid (top row) stay put.
if (from - cols >= 0) to = from - cols; if (from - cols >= 0) to = from - cols;
break; break;
case NavKey::Down: { 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; const int below = from + cols;
if (below < itemCount) if (below < itemCount)
to = below; to = below;
@@ -189,7 +170,6 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
if (!shift) return singleSelection(to); 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 const int anchor = current.anchor >= 0 && current.anchor < itemCount
? current.anchor ? current.anchor
: from; : from;
@@ -203,23 +183,14 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
float compressAmplitudeForDisplay(float linear) { float compressAmplitudeForDisplay(float linear) {
const float mag = linear < 0.0f ? -linear : linear; const float mag = linear < 0.0f ? -linear : linear;
// The linear magnitude at the floor threshold: 10^(kDisplayFloorDb/20). // std::pow isn't constexpr pre-C++20; derive at runtime, cheap since it's once per bin.
// Any magnitude at or below this maps to display fraction 0.
// Computed once as a constant expression; std::pow is constexpr in C++20 but
// not C++17, so derive it via the floor definition directly at runtime — it is
// only called once per bin, and the branch-free math is cheap.
const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f); const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f);
if (mag <= floorMag) return 0.0f; // below floor (and guards log10(0)) 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); 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); 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); const float clamped = fraction < 0.0f ? 0.0f : (fraction > 1.0f ? 1.0f : fraction);
return linear < 0.0f ? -clamped : clamped; return linear < 0.0f ? -clamped : clamped;
} }
+49 -109
View File
@@ -1,14 +1,7 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// bank_grid — the REAPER-free layout math and cache-key logic behind the docked // bank_grid — layout math, hit-test, selection, and keyboard nav for the docked bank_panel grid,
// bank_panel (M5, Wave A). The panel shell (shell/panel/) owns the SWELL window, // plus its thumbnail cache-key. The panel shell owns SWELL/LICE/PCM; this is the DAW-free half.
// 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.
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
@@ -17,50 +10,34 @@
namespace reasampler::ui { namespace reasampler::ui {
// A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE // One cell's pixel rect, top-left origin. Draw bounds for one sample's thumbnail.
// convention). (x, y) is the top-left corner; width/height are the cell extents. using CellRect = Rect;
// 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
// Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the // cellWidth/cellHeight are the target cell size; layout fits as many whole columns as the panel
// TARGET cell size; the layout fits as many whole columns as the panel width // width allows (>= 1) and wraps rows as needed. gap is the spacing between cells and the margin.
// 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.
struct GridSpec { struct GridSpec {
int cellWidth = 120; int cellWidth = 120;
int cellHeight = 72; int cellHeight = 72;
int gap = 8; int gap = 8;
}; };
// Computes the number of columns that fit in a panel of the given pixel width for // Columns that fit a panel of the given width. Always >= 1 (a too-narrow panel still shows one
// the spec. Always >= 1 (a panel narrower than one cell still shows one column, // clipped column).
// clipped by the window). Pure arithmetic — the panel passes its live client
// width here and to computeCellRects.
int columnsForWidth(int panelWidth, const GridSpec& spec); int columnsForWidth(int panelWidth, const GridSpec& spec);
// Tiles `itemCount` cells left-to-right, top-to-bottom into a panel of the given // Tiles itemCount cells left-to-right, top-to-bottom. Returns exactly itemCount rects in item
// pixel width, honoring the spec's cell size and gap. Returns exactly itemCount // order. A partial last row is left-aligned, not centered or stretched. itemCount == 0 -> empty.
// 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).
std::vector<CellRect> computeCellRects(int itemCount, std::vector<CellRect> computeCellRects(int itemCount,
int panelWidth, int panelWidth,
const GridSpec& spec); const GridSpec& spec);
// The total pixel height the grid occupies for itemCount cells at the given panel // Total pixel height the grid occupies (top margin + rows*cellHeight + inter-row gaps + bottom
// width and spec (top margin + rows*cellHeight + inter-row gaps + bottom margin). // margin); 0 when itemCount == 0.
// 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).
int contentHeight(int itemCount, int panelWidth, const GridSpec& spec); int contentHeight(int itemCount, int panelWidth, const GridSpec& spec);
// Identifies one cached thumbnail. A cached envelope is valid only while the // Identifies one cached thumbnail. Valid only while sample identity, the draw width it was
// sample's identity, the draw width it was computed at, and the bank generation // computed at (the envelope has exactly `width` bins per channel), and bank generation all match;
// it was computed under all match. Width is part of the key because the envelope // generation bump invalidates every cached entry without diffing.
// 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.
struct ThumbnailKey { struct ThumbnailKey {
std::string sampleId; std::string sampleId;
int width = 0; int width = 0;
@@ -72,35 +49,22 @@ struct ThumbnailKey {
} }
}; };
// A stable string form of the key, suitable as a map key. Deterministic: the same // Stable string form of the key for use as a map key. sampleId is length-prefixed so a delimiter
// key always yields the same string, distinct keys always differ (the sampleId is // byte inside an id can't forge a collision.
// length-prefixed so an id containing the delimiter cannot collide with another).
std::string thumbnailKeyString(const ThumbnailKey& key); std::string thumbnailKeyString(const ThumbnailKey& key);
// --- Interaction (M5 Wave B): hit-test, selection, keyboard nav -------------- // --- Interaction: 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.
// Hit-tests a point (SWELL/LICE top-left client coords) against a cell-rect list. // Index of the first rect containing (px, py), or -1 for a miss (gap, margin, below last row).
// Returns the index of the FIRST rect that contains the point, or -1 for a miss // Half-open bounds so adjacent rects never both claim a pixel.
// (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.
int hitTestCell(int px, int py, const std::vector<CellRect>& rects); int hitTestCell(int px, int py, const std::vector<CellRect>& rects);
// The panel's selection state. `indices` is the selected set as a SORTED, unique // Panel selection state. `indices` is sorted unique ascending (deterministic for tests and
// ascending vector (deterministic for tests and for highlight iteration). `focus` // highlight order). `focus` is the caret cell (audition/extend target), -1 when none. `anchor` is
// is the cell the caret sits on — the audition/extend target — or -1 when nothing // the fixed end a shift-range extends from, -1 when none. Empty selection: focus == anchor == -1.
// 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.
// //
// Invariants (upheld by the pure mutators below, asserted in tests): // Invariants upheld by the mutators below: indices sorted/unique; every index (and focus/anchor
// * indices is sorted ascending with no duplicates; // when >= 0) is in [0, itemCount); focus, when >= 0, is a member of indices.
// * every index (and focus/anchor when >= 0) is in [0, itemCount);
// * focus, when >= 0, is a member of indices.
struct Selection { struct Selection {
std::vector<int> indices; std::vector<int> indices;
int focus = -1; int focus = -1;
@@ -113,66 +77,42 @@ struct Selection {
bool empty() const { return indices.empty(); } bool empty() const { return indices.empty(); }
}; };
// Applies a mouse click on cell `index` to `current`, returning the new selection. // Applies a click on cell `index` to `current`. Modifier semantics (file-manager convention):
// Modifier semantics (standard multi-select, matching file-manager conventions): // * plain: select only `index`; focus = anchor = index.
// * plain (no modifier): select ONLY `index`; focus = anchor = index. // * ctrl: toggle `index` in/out; focus = index; anchor reseeds to index either way.
// * ctrl: TOGGLE `index` in/out of the set; focus = index. Anchor moves to // * shift: select the inclusive range [anchor, index]; focus = index, anchor unchanged.
// index on add, and to index on remove too (a ctrl-click reseeds the // No prior anchor behaves like a plain click.
// range origin at the clicked cell). If the toggle empties the set, // ctrl+shift together: shift wins (range select). index out of range or itemCount <= 0: no-op.
// 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.
Selection applyClick(const Selection& current, int index, bool ctrl, bool shift, Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
int itemCount); int itemCount);
// A directional key for keyboard navigation. REAPER-free (the shell maps VK_* to // Directional key for nav; Enter/Space/Esc drive audition and are a shell concern, not modelled
// these) so nav math is testable without SWELL. Enter/Space/Esc are NOT here: they // here.
// drive audition, which is a shell concern (no selection math), so the shell reads
// those key codes directly.
enum class NavKey { Left, Right, Up, Down, Home, End }; enum class NavKey { Left, Right, Up, Down, Home, End };
// Moves the focus by one step for `key` in a grid of `cols` columns holding // Moves focus by one step for `key` in a `cols`-column grid of `itemCount` cells.
// `itemCount` cells, returning the new selection. `cols` >= 1. // * Left/Right move linearly; Up/Down move by `cols`. Movement CLAMPS at the grid edges (no
// * Left/Right move by one cell in linear (row-major) order; Up/Down move by // wrap) — deliberate: wrap on a partial last row is surprising.
// `cols`. Movement CLAMPS at the grid ends (no wrap): Right on the last cell, // * Down from the row above a missing partial-last-row cell clamps to the last cell rather than
// Left on the first, Up on the top row, Down past the last cell all stay put. // overshooting past itemCount.
// (Clamp, not wrap: wrap on a partial last row is surprising and error-prone; // * Without shift: moved-to cell becomes the sole selection (focus = anchor = newIndex).
// clamp is the predictable choice — flagged as the deliberate decision.) // * With shift: focus moves to newIndex, selection becomes the inclusive range from anchor
// * Down from the second-to-last row into a column with no cell in the last row // (seeded at the origin cell on first extend).
// clamps to the last cell rather than overshooting past itemCount. // * Empty selection: first arrow focuses cell 0 without moving.
// * 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.
// itemCount <= 0 returns `current` unchanged. // itemCount <= 0 returns `current` unchanged.
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount, Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
bool shift); bool shift);
// --- Waveform display compression -------------------------------------------- // --- Waveform display compression --------------------------------------------
// // Maps raw linear amplitude to a perceptual display fraction so quiet content stays visible.
// Maps a raw linear amplitude magnitude to a perceptual display fraction so
// quiet and medium content remains visible in the thumbnail. // Below this, amplitude is treated as silence (display fraction 0). Only knob for the curve.
//
// The floor below which amplitude is treated as silence (display fraction 0).
// At -60 dB, 0.001 linear magnitude maps to ~0. Tune this constant in-DAW to
// taste — it is the only knob for the compression curve.
constexpr float kDisplayFloorDb = -60.0f; constexpr float kDisplayFloorDb = -60.0f;
// Maps a signed linear amplitude value in [-1, 1] (a raw envelope extreme such // Maps a signed linear amplitude in [-1, 1] (a raw envelope extreme, e.g. PeakBin::max/min) to a
// as PeakBin::max or PeakBin::min) to a signed display fraction in [-1, 1]. // 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
// The magnitude |linear| is converted to dB, clamped to [kDisplayFloorDb, 0], // stays 0; full-scale (|linear| == 1.0f) returns exactly +-1.0f.
// then normalized so kDisplayFloorDb -> 0 and 0 dB -> 1. The original sign is
// re-applied so positive max values still map positive (draw up) and negative
// min values still map negative (draw down). Exact-zero input returns 0.0f
// (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f.
float compressAmplitudeForDisplay(float linear); float compressAmplitudeForDisplay(float linear);
} // namespace reasampler::ui } // namespace reasampler::ui
+3 -14
View File
@@ -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" #include "core/ui/card_drag.h"
@@ -6,7 +6,6 @@ namespace reasampler::ui {
namespace { 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) { bool insideClient(int px, int py, const PanelClientRect& c) {
return px >= c.x && px < c.x + c.width && return px >= c.x && px < c.x + c.width &&
py >= c.y && py < c.y + c.height; 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, CardGesture decideCardGesture(int px, int py, const PanelClientRect& client,
const DragState& state, const DragModifiers& mods) { const DragState& state, const DragModifiers& mods) {
// No drag / empty payload: nothing to do.
if (!state.dragging || !state.hasArmedSamples) return CardGesture::None; 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; 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) if (mods.region == DropRegion::OtherBankOrTab)
return mods.ctrl ? CardGesture::Copy : CardGesture::Move; return mods.ctrl ? CardGesture::Copy : CardGesture::Move;
// Precedence 3: within the same bank's own grid -> reorder / replace.
if (mods.region == DropRegion::SameBankGrid) { 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; if (mods.alt && mods.slotOccupied) return CardGesture::Replace;
return CardGesture::Reorder; return CardGesture::Reorder;
} }
// Dead space inside the client: a drop here is a no-op.
return CardGesture::None; return CardGesture::None;
} }
@@ -56,7 +48,7 @@ std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
if (maxSlot < 0) return rects; if (maxSlot < 0) return rects;
const int cols = columnsForWidth(panelWidth, spec); 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<std::size_t>(count)); rects.reserve(static_cast<std::size_t>(count));
for (int slot = 0; slot < count; ++slot) { for (int slot = 0; slot < count; ++slot) {
@@ -76,16 +68,13 @@ std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth, std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth,
const GridSpec& spec) { const GridSpec& spec) {
const int cols = columnsForWidth(panelWidth, 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 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); return computeSlotRects(newMax, panelWidth, spec);
} }
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects) { int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects) {
for (const SlotCellRect& r : 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 && if (px >= r.x && px < r.x + r.width &&
py >= r.y && py < r.y + r.height) py >= r.y && py < r.y + r.height)
return r.slot; return r.slot;
+43 -90
View File
@@ -1,32 +1,20 @@
#pragma once #pragma once
// card_drag — the REAPER-free decision logic behind the L7 in-grid reorder drag. Three // card_drag — decision logic behind the in-grid reorder drag. Mirror of drag_out::decideGesture;
// pure concerns live here so they are unit-tested outside the DAW (CLAUDE.md §load-bearing // SWELL wiring, SetCursor, cursor resources, and drop-target draw stay in the shell.
// 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.
// //
// 1. GESTURE PRECEDENCE (F3 settled). A live drag resolves to exactly one gesture, in a // Gesture precedence, evaluated on every mouse-move / at drop, strict order:
// strict precedence the shell evaluates on every mouse-move / at drop:
// (1) pointer LEFT the client rect -> OsDragOut (hand off to the OS) // (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) // (2) else drop over a tab / the OTHER bank -> Move | Copy (Ctrl = Copy)
// (3) else drop within the SAME bank's grid -> Reorder | Replace // (3) else drop within the SAME bank's grid -> Reorder | Replace
// - empty slot -> Reorder (place there) // - empty slot -> Reorder (place there)
// - occupied slot, no modifier -> Reorder (insert-before-and-shift) // - occupied slot, no modifier -> Reorder (insert-before-and-shift)
// - occupied slot, Alt held -> Replace (Alt-replace-over-occupied) // - occupied slot, Alt held -> Replace
// So leave-client wins first, then other-bank, then same-bank-grid = reorder/replace. // Leave-client wins first, then other-bank, then same-bank-grid — so reorder can never steal a
// This keeps the reorder gesture from ever stealing a bank-move or OS-drag. // bank-move or an OS-drag.
// //
// 2. SLOT HIT-TEST. Which grid SLOT a pointer sits over, sparse-aware: the grid tiles // Also owns: sparse-aware slot hit-test (a point -> grid slot, empty or occupied, extending
// slots 0..maxSlot including empty ones, so hit-testing maps a point to a slot index // bank_grid's dense tiling), and the gesture -> cursor-cue mapping (Replace's cue appears only
// (empty or occupied) or -1 for a miss. The pixel<->slot rect math extends bank_grid's // when Alt is actually held over an occupied slot).
// 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.
#include <vector> #include <vector>
@@ -35,79 +23,54 @@
namespace reasampler::ui { namespace reasampler::ui {
// Which drop region the pointer currently sits over WITHIN the client rect. The shell // Which drop region the pointer sits over within the client rect; the shell classifies against
// classifies the live pointer against its own region geometry (tab strip / other bank // its own region geometry and passes the verdict — card_drag knows only precedence over these.
// 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.)
enum class DropRegion { enum class DropRegion {
SameBankGrid, // over the dragged samples' OWN bank grid — a reorder/replace target SameBankGrid, // over the dragged samples' OWN bank grid — reorder/replace target
OtherBankOrTab, // over a tab or the other region's bank — a move/copy target OtherBankOrTab, // over a tab or the other region's bank — move/copy target
DeadSpace, // inside the client but over no drop target (header, footer, gap) 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 { enum class CardGesture {
None, // no drag under way, or an empty payload — do nothing None,
OsDragOut, // pointer left the client rect — hand off to the native OS drag (drag_out) OsDragOut,
Move, // drop over another bank/tab, no Ctrl — move the samples there Move,
Copy, // drop over another bank/tab, Ctrl held — copy the samples there Copy,
Reorder, // drop within the same bank grid — reorder to the target slot Reorder,
Replace, // drop within the same bank grid, Alt over an OCCUPIED slot — replace Replace,
}; };
// The live drag inputs the precedence decision needs beyond position + client rect: // 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).
struct DragModifiers { struct DragModifiers {
DropRegion region = DropRegion::DeadSpace; DropRegion region = DropRegion::DeadSpace;
int targetSlot = -1; int targetSlot = -1; // slot under the pointer in SameBankGrid; -1 otherwise
bool slotOccupied = false; bool slotOccupied = false; // drives Reorder vs Replace
bool ctrl = false; bool ctrl = false; // Copy vs Move over another bank
bool alt = false; 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 // Resolves the gesture for a drag at pointer (px, py) over `client`. See precedence above.
// `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).
CardGesture decideCardGesture(int px, int py, const PanelClientRect& client, CardGesture decideCardGesture(int px, int py, const PanelClientRect& client,
const DragState& state, const DragModifiers& mods); 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 { enum class CursorCue {
Default, // arrow — no drag, or dead space Default,
Reorder, // within-bank reorder Reorder,
Move, // move to another bank/tab Move,
Copy, // copy to another bank/tab Copy,
OsDragOut, // pointer left the client (the OS drag loop owns the cursor once handed off) OsDragOut,
Replace, // Alt-replace over an occupied slot Replace,
}; };
// Maps a resolved gesture to its cursor cue (pure — the shell owns SetCursor only).
CursorCue cursorForGesture(CardGesture g); CursorCue cursorForGesture(CardGesture g);
// --- Sparse-aware slot layout + hit-test -------------------------------------- // --- Sparse-aware slot layout + hit-test --------------------------------------
// The pixel rect of one grid SLOT (empty or occupied). Distinct from bank_grid's CellRect // One grid SLOT's pixel rect (empty or occupied); carries its slot index so the shell can map a
// only in intent — a SlotCellRect carries the slot index it draws, so the shell can map a // rect back to the model slot without a parallel array.
// 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).
struct SlotCellRect { struct SlotCellRect {
int slot = 0; // the model slot this rect represents (0..maxSlot) int slot = 0;
int x = 0; int x = 0;
int y = 0; int y = 0;
int width = 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 // Tiles slots [0, maxSlot] inclusive (empty slots included, so a gap draws and a drop targets it
// grid spec — the sparse-aware sibling of bank_grid::computeCellRects. Every slot in // precisely). maxSlot < 0 -> empty. Same column/row math as bank_grid::computeCellRects.
// [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.
std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth, std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
const GridSpec& spec); const GridSpec& spec);
// Like computeSlotRects but extends one full trailing row of slots beyond maxSlot so a // Like computeSlotRects but extends one full trailing row past maxSlot so a drop pointer beyond
// drop pointer past the last occupied card still resolves to a valid target slot. The // the last occupied card still resolves to a valid (empty) target slot. Drop hit-testing only —
// trailing slots (maxSlot+1 .. maxSlot+cols) are empty — a drop on any of them calls // the draw path uses computeSlotRects, no ghost row in the visual.
// 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).
std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth, std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth,
const GridSpec& spec); const GridSpec& spec);
// Hit-tests a point against slot rects (half-open bounds, matching hitTestCell). Returns // Slot index (rect.slot, NOT the vector index) of the first rect containing the point, or -1 on
// the SLOT index (rect.slot) of the first rect containing the point, or -1 on a miss (gap, // a miss. Half-open bounds, matching hitTestCell.
// margin, below the last row). NOTE the return is the slot index, NOT the vector index —
// callers reason in model slots.
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects); int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects);
} // namespace reasampler::ui } // namespace reasampler::ui
+9 -18
View File
@@ -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" #include "core/ui/card_meta.h"
@@ -8,33 +8,26 @@
namespace reasampler::ui { namespace reasampler::ui {
std::string formatBarsBeats(const MusicalLength& m) { 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 {}; if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {};
const double len = m.lengthSeconds > 0.0 ? m.lengthSeconds : 0.0; 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) // A quarter-note is 60/tempo s; a beat is (4/denom) quarter-notes.
// quarter-notes, so a beat lasts (60/tempo) * (4/denom) seconds. beats = len / that.
const double secondsPerBeat = (60.0 / m.tempoBpm) * (4.0 / m.timeSigDenom); const double secondsPerBeat = (60.0 / m.tempoBpm) * (4.0 / m.timeSigDenom);
double totalBeats = len / secondsPerBeat; double totalBeats = len / secondsPerBeat;
// Snap to an exact beat when we are within a hundredth-of-a-beat epsilon of one, so a // Snap to an exact beat within epsilon so a bar-aligned capture reads "2.1.00" rather than
// bar-aligned capture reads "2.1.00" rather than "1.4.99" from FP error just under the // "1.4.99" from FP error just under the boundary.
// boundary. The epsilon is well below the .01 display quantum, so it never mis-rounds a
// genuinely fractional length.
const double snapped = std::floor(totalBeats + 0.5); const double snapped = std::floor(totalBeats + 0.5);
if (std::fabs(totalBeats - snapped) < 1e-6) totalBeats = snapped; 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 wholeBeats = std::floor(totalBeats);
double frac = totalBeats - wholeBeats; double frac = totalBeats - wholeBeats;
// Bars/beats are 1-based; beat cycles 1..timeSigNum within a bar.
const long wb = static_cast<long>(wholeBeats); const long wb = static_cast<long>(wholeBeats);
const long bar = wb / m.timeSigNum + 1; // 1-based bar const long bar = wb / m.timeSigNum + 1;
const long beat = wb % m.timeSigNum + 1; // 1-based beat within the bar const long beat = wb % m.timeSigNum + 1;
// Subdivision: hundredths of a beat, floored (0..99). A decorative display quantum.
int sub = static_cast<int>(std::floor(frac * 100.0)); int sub = static_cast<int>(std::floor(frac * 100.0));
if (sub < 0) sub = 0; if (sub < 0) sub = 0;
if (sub > 99) sub = 99; if (sub > 99) sub = 99;
@@ -48,12 +41,10 @@ std::string formatSecondsMs(double lengthSeconds) {
double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0; double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0;
long secs = static_cast<long>(std::floor(len)); long secs = static_cast<long>(std::floor(len));
// Round to the nearest millisecond (not floor): FP error means 62.037 s stores as // Round to nearest ms, not floor: FP storage error would otherwise render e.g. "62.036"
// 62.0369999... and a raw floor would render "62.036". +0.5 before truncation rounds // for a value that should read "62.037".
// to the closest ms, which is what a wall-clock read-out should show.
int ms = static_cast<int>((len - static_cast<double>(secs)) * 1000.0 + 0.5); int ms = static_cast<int>((len - static_cast<double>(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; } // rounding can carry into the next second
if (ms >= 1000) { ms -= 1000; ++secs; }
if (ms < 0) ms = 0; if (ms < 0) ms = 0;
char buf[48]; char buf[48];
+11 -35
View File
@@ -1,23 +1,14 @@
#pragma once #pragma once
// card_meta — pure formatting for the L7 decorative card metadata overlay. Each bank // card_meta — formatting for the bank card's decorative metadata overlay: capture length as
// card overlays capture length as bars.beats.subdivisions (bottom-LEFT, musical) and // bars.beats.subdivisions (bottom-left, musical) and seconds.milliseconds (bottom-right,
// seconds.milliseconds (bottom-RIGHT, wall-clock). Both read-outs are DECORATIVE and // wall-clock). Both are non-interactive; bank_panel draws them via the kit.
// 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.
#include <string> #include <string>
namespace reasampler::ui { namespace reasampler::ui {
// The musical length inputs, taken straight off a Sample (L7 F1 capture-time stamp): // Musical length inputs, taken straight off a Sample's capture-time stamp. tempoBpm 0 = unknown;
// lengthSeconds — captured length in wall-clock seconds (>= 0). // timeSigNum/Denom 0 = unstamped.
// 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.
struct MusicalLength { struct MusicalLength {
double lengthSeconds = 0.0; double lengthSeconds = 0.0;
double tempoBpm = 0.0; double tempoBpm = 0.0;
@@ -25,31 +16,16 @@ struct MusicalLength {
int timeSigDenom = 0; 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 // 1-based, zero-padded to two subdivision digits: "1.1.00" is a bar-aligned/zero-length capture,
// (4 / timeSigDenom) quarter-notes; a bar holds timeSigNum beats. From lengthSeconds we // "2.3.50" is 1 bar + 2 beats + half a beat. Unstamped meter or unknown tempo (tempoBpm <= 0)
// get total beats, split into whole bars (÷ timeSigNum) + whole leftover beats + a // returns "" — no musical read-out is derivable, caller keeps the s.ms read-out. Subdivision is
// subdivision remainder scaled to 1..N of the next beat. The output is 1-BASED and // 0..99 (hundredths of a beat), floored — a display quantum, not tick-accurate PPQ.
// 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).
std::string formatBarsBeats(const MusicalLength& m); std::string formatBarsBeats(const MusicalLength& m);
// seconds.milliseconds from a wall-clock length (always derivable, meter-independent). // 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). // "S.mmm", rounded to nearest ms. Negative length clamps to "0.000".
// 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).
std::string formatSecondsMs(double lengthSeconds); std::string formatSecondsMs(double lengthSeconds);
} // namespace reasampler::ui } // namespace reasampler::ui
+4 -12
View File
@@ -1,5 +1,4 @@
// component_geometry — pure implementation. See component_geometry.h. NO REAPER / SWELL / // component_geometry — pure implementation. See component_geometry.h.
// LICE / vendor. Standard library only.
#include "core/ui/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.y = cell.y + padding;
b.width = cell.width - 2 * padding; b.width = cell.width - 2 * padding;
b.height = cell.height - 2 * padding; b.height = cell.height - 2 * padding;
if (b.empty()) return {}; // padding collapsed the cell -> suppress if (b.empty()) return {};
return KitButtonBox{b}; return KitButtonBox{b};
} }
SliderGeometry computeSlider(const KitBox& control, double value, SliderGeometry computeSlider(const KitBox& control, double value,
int handleSize, int trackThickness) { int handleSize, int trackThickness) {
if (control.empty() || handleSize <= 0 || trackThickness <= 0) return {}; 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 (control.width < handleSize || control.height < handleSize) return {};
if (value < 0.0) value = 0.0; if (value < 0.0) value = 0.0;
@@ -34,8 +32,6 @@ SliderGeometry computeSlider(const KitBox& control, double value,
const int half = handleSize / 2; 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; KitBox track;
track.x = control.x + half; track.x = control.x + half;
track.width = control.width - handleSize; // travel span for the handle centre 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.height = trackThickness;
track.y = control.y + (control.height - trackThickness) / 2; 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<int>(value * track.width + 0.5); const int centre = track.x + static_cast<int>(value * track.width + 0.5);
KitBox handle; KitBox handle;
handle.x = centre - half; handle.x = centre - half;
@@ -51,7 +46,6 @@ SliderGeometry computeSlider(const KitBox& control, double value,
handle.width = handleSize; handle.width = handleSize;
handle.height = handleSize; handle.height = handleSize;
// Filled portion: from the track's left up to the handle centre.
KitBox filled; KitBox filled;
filled.x = track.x; filled.x = track.x;
filled.y = track.y; 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) { ListRowBox computeListRow(const KitBox& list, int index, int rowHeight) {
if (list.empty() || rowHeight <= 0 || index < 0) return {}; if (list.empty() || rowHeight <= 0 || index < 0) return {};
const int top = list.y + index * rowHeight; const int top = list.y + index * rowHeight;
// Fully below the list bottom -> clipped away entirely -> no box.
if (top >= list.y + list.height) return {}; if (top >= list.y + list.height) return {};
KitBox b; KitBox b;
b.x = list.x; b.x = list.x;
b.y = top; b.y = top;
b.width = list.width; b.width = list.width;
b.height = rowHeight; // a partially-visible last row keeps full height; caller clips b.height = rowHeight;
return ListRowBox{index, b}; return ListRowBox{index, b};
} }
int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount) { int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount) {
if (list.empty() || rowHeight <= 0 || rowCount <= 0) return -1; if (list.empty() || rowHeight <= 0 || rowCount <= 0) return -1;
// Outside the list band entirely.
if (px < list.x || px >= list.x + list.width || if (px < list.x || px >= list.x + list.width ||
py < list.y || py >= list.y + list.height) py < list.y || py >= list.y + list.height)
return -1; return -1;
const int row = (py - list.y) / rowHeight; 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; return row;
} }
+37 -81
View File
@@ -1,100 +1,64 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// component_geometry — the REAPER-free, LICE-free geometry + hit-test math for the shared // component_geometry — geometry + hit-test math for the shared drawing kit's generic components:
// drawing kit's generic components (Phase L, L1): a button box, a slider's track/handle, // a button box, a slider's track/handle, and a list row. bank_grid / tab_strip / prune_button
// and a list row. These are the kit-level primitives that DON'T already have a pure owner: // stay the source of truth for the surfaces they own; this carries only the reusable component
// 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
// shapes the kit's drawButton / drawSlider / drawListRow draw against. // 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 { namespace reasampler::ui {
// A generic pixel box, top-left origin (SWELL/LICE convention). Shared shape for the kit // A generic pixel box, top-left origin. empty() means "nothing to draw / hit".
// component rects below. A zero-area box (empty()) means "nothing to draw / hit" — the using KitBox = Rect;
// same graceful-suppression convention prune_button uses.
using KitBox = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// True iff (px, py) falls inside `box`, half-open bounds [x, x+width) x [y, y+height) // Half-open bounds [x, x+width) x [y, y+height); an empty box claims no point.
// 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).
bool hitTestBox(int px, int py, const KitBox& box); bool hitTestBox(int px, int py, const KitBox& box);
// --- Button ------------------------------------------------------------------ // --- Button ------------------------------------------------------------------
//
// A button drawn inside a host cell, inset by a uniform padding so it reads as a raised // A button drawn inside a host cell, inset by uniform padding so it reads as raised rather than
// control rather than a full-bleed fill (the kit's drawButton draws the micro-gradient // full-bleed. Distinct from prune_button, which owns its own placement within its strip.
// 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.
struct KitButtonBox { struct KitButtonBox {
KitBox box; KitBox box;
bool operator==(const KitButtonBox& o) const { return box == o.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 // Button box inside `cell`, inset uniformly by `padding`. Returns an empty box (suppressed) when
// empty box (suppressed) when the cell is degenerate or the padding would collapse it to // the cell is degenerate or padding would collapse it to zero-or-negative area. padding < 0 -> 0.
// zero-or-negative area — the caller then draws nothing (graceful, mirrors prune_button).
// padding < 0 is treated as 0.
KitButtonBox computeButtonBox(const KitBox& cell, int padding); KitButtonBox computeButtonBox(const KitBox& cell, int padding);
// --- Slider (horizontal) ----------------------------------------------------- // --- Slider (horizontal) -----------------------------------------------------
//
// A horizontal slider: a track spanning the control width (inset at both ends by the // track spans the control width, inset at both ends by half the handle width so the handle never
// handle's half-width so the handle never clips past the track), and a square handle // clips past it. handle is centered on the track, positioned by the normalized value.
// 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.
struct SliderGeometry { struct SliderGeometry {
KitBox track; // the full track rect (the groove) KitBox track;
KitBox filled; // the filled portion from the track's left up to the handle center KitBox filled; // filled portion from track's left up to the handle center
KitBox handle; // the draggable handle rect KitBox handle;
bool operator==(const SliderGeometry& o) const { bool operator==(const SliderGeometry& o) const {
return track == o.track && filled == o.filled && handle == o.handle; 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 // Lays out a horizontal slider inside `control` for normalized `value` in [0, 1] with a square
// square handle of side `handleSize`. The track is vertically centered at a fixed // handle of side `handleSize`, track vertically centered at `trackThickness`. value clamps to
// `trackThickness`, inset horizontally by handleSize/2 at each end so the handle's travel // [0, 1]. Returns all-empty boxes when the control is too small to host the handle, or when
// stays within `control`. value is clamped to [0, 1]; a value of 0 puts the handle flush // handleSize/trackThickness <= 0.
// 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.
SliderGeometry computeSlider(const KitBox& control, double value, SliderGeometry computeSlider(const KitBox& control, double value,
int handleSize, int trackThickness); int handleSize, int trackThickness);
// The normalized value [0, 1] a click at px maps to, for a slider laid out in `control` // Inverse of computeSlider's handle placement (a track-jump click): normalized value [0, 1] a
// with `handleSize` (the inverse of computeSlider's handle placement — a track jump). // click at px maps to. Clamps to [0, 1] outside the track; 0.0 for a degenerate/too-small
// px left of / at the track start yields 0.0, at/right of the track end yields 1.0, // control. py unused (horizontal slider maps X only) — caller gates with hitTestBox(control) first.
// 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.
double sliderValueAt(int px, const KitBox& control, int handleSize); double sliderValueAt(int px, const KitBox& control, int handleSize);
// --- List row ---------------------------------------------------------------- // --- List row ----------------------------------------------------------------
//
// A single selectable row in a vertical list: full-width, fixed height, stacked from the // One selectable row: full-width, fixed height, stacked from the list's top by index (no scroll
// list's top by index (no scroll — the caller offsets the list origin for scroll). The // caller offsets the list origin for that).
// 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.
struct ListRowBox { struct ListRowBox {
int index = 0; // the row's index in the caller's list (0-based, top-first) int index = 0;
KitBox box; KitBox box;
bool operator==(const ListRowBox& o) const { 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 // Row box for `index` inside `list` at `rowHeight` per row; rows stack from list.y. Empty when
// stack from list.y; row i spans [list.y + i*rowHeight, +rowHeight). Returns an empty box // the list is degenerate, rowHeight <= 0, index < 0, or the row falls entirely below the list's
// when the list is degenerate, rowHeight <= 0, index < 0, or the row would fall entirely // bottom. A partially-visible last row IS returned — caller clips the draw.
// 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.
ListRowBox computeListRow(const KitBox& list, int index, int rowHeight); 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 // Index of the row a point lands on, or -1 for a miss (outside bounds, or in the empty tail past
// `rowHeight`. Returns -1 for a miss: outside the list bounds, in the list band but below // `rowCount` rows). rowCount bounds the hit so blank space past the last row is a clean miss.
// 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.
int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount); int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount);
// --- Waveform column count --------------------------------------------------- // --- Waveform column count ---------------------------------------------------
//
// The number of pixel columns drawWaveform renders inside `box` (its fixed 2px side // Pixel columns drawWaveform renders inside `box` (its fixed 2px side insets), never negative.
// insets), never negative. Callers pass this count directly as the `binCount` argument to // Pass directly as peaks::computeEnvelope's binCount — one bin per column is correct resolution;
// peaks::computeEnvelope — one bin per column is the correct resolution, and // overbinning doesn't improve render quality and wastes memory/CPU.
// 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.
int waveformColumnCount(const KitBox& box); int waveformColumnCount(const KitBox& box);
} // namespace reasampler::ui } // namespace reasampler::ui
+6 -9
View File
@@ -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" #include "core/ui/drag_out.h"
@@ -8,7 +8,6 @@ namespace reasampler::ui {
namespace { 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) { bool insideClient(int px, int py, const PanelClientRect& c) {
return px >= c.x && px < c.x + c.width && return px >= c.x && px < c.x + c.width &&
py >= c.y && py < c.y + c.height; py >= c.y && py < c.y + c.height;
@@ -20,10 +19,8 @@ DragGesture decideGesture(int px, int py, const PanelClientRect& client,
const DragState& state) { const DragState& state) {
if (!state.dragging || !state.hasArmedSamples) return DragGesture::None; if (!state.dragging || !state.hasArmedSamples) return DragGesture::None;
if (insideClient(px, py, client)) return DragGesture::Internal; if (insideClient(px, py, client)) return DragGesture::Internal;
// Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is // Outside the client: a single-capture drag still over REAPER's own UI is an instrument
// still over REAPER's own UI is an instrument drop (heading for a track's FX button); // drop; anything else (multi-capture, or pointer off REAPER entirely) is an OS drag-out.
// anything else (a multi-capture payload, or the pointer off REAPER entirely) is the
// unchanged M11 OS drag-out.
if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop; if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop;
return DragGesture::OsDrag; return DragGesture::OsDrag;
} }
@@ -34,15 +31,15 @@ PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
seen.reserve(resolved.size()); seen.reserve(resolved.size());
for (const ResolvedSample& s : resolved) { for (const ResolvedSample& s : resolved) {
if (s.absolutePath.empty()) { // shell could not resolve it if (s.absolutePath.empty()) {
++out.skippedUnresolved; ++out.skippedUnresolved;
continue; continue;
} }
if (!s.fileExists) { // stale index entry, file gone if (!s.fileExists) {
++out.skippedMissing; ++out.skippedMissing;
continue; continue;
} }
if (!seen.insert(s.absolutePath).second) { // already emitted this path if (!seen.insert(s.absolutePath).second) {
++out.skippedDuplicate; ++out.skippedDuplicate;
continue; continue;
} }
+36 -96
View File
@@ -1,31 +1,17 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// drag_out — the REAPER-free / OS-free decision logic behind the bank_panel's native OS // drag_out — decision logic behind the bank_panel's native OS drag-out. OLE/SWELL initiation and
// drag-out (Milestone 11, the final polish point). Two pure concerns live here so they are // the panel gesture hook stay in the shell (drag_out_win.* + shell/panel/panel_drag.cpp).
// 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).
// //
// 1. GESTURE BOUNDARY (invariant #4 — do not regress the internal drag). The panel // Gesture boundary: the panel's own internal drag (press a selected cell, drop onto a pool/bank
// already runs an INTERNAL drag: press a selected cell, cross a threshold, drop onto // region or tab) lives entirely inside the panel client rect. The moment the pointer LEAVES that
// a pool/banks region or a tab to move/copy the samples between banks. That drag lives // rect while a drag is armed with samples, the gesture becomes OS-bound — dragged out to another
// entirely INSIDE the panel client rect. The OS drag is a DISTINCT gesture with a // window/Explorer/DAW. A single-capture drag that leaves the rect but is still over REAPER's own
// distinct, discoverable boundary: while a drag is armed with samples in the payload, // UI is instead an InstrumentDrop (heading for a track's FX button); do not regress this boundary.
// 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).
// //
// 2. PATH-LIST ASSEMBLY. The OS drop carries absolute file paths (Windows CF_HDROP / // Path-list assembly: turns armed sample ids into the absolute path list the OS drop carries
// macOS file-list pasteboard). Turning the armed sample ids into that path list — // (Windows CF_HDROP / macOS file-list pasteboard) — set algebra only; the shell resolves each id
// resolving each id to its already-on-disk bank file, de-duping, and applying an // to its on-disk bank file. No temp files; copy-only is enforced at the OS layer (drag_out_win).
// 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.
#include <string> #include <string>
#include <vector> #include <vector>
@@ -34,99 +20,53 @@ namespace reasampler::ui {
// --- Gesture boundary --------------------------------------------------------- // --- Gesture boundary ---------------------------------------------------------
// The panel's client rectangle in its own client coordinates (top-left origin, the SWELL/ // The panel's client rect, own client coords, top-left origin. Half-open: [x, x+width) x
// LICE convention). width/height are the extents; a point (px, py) is INSIDE when // [y, y+height).
// x <= px < x + width and y <= py < y + height (half-open, matching the panel's other using PanelClientRect = Rect;
// 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 live drag state the shell tracks, reduced to what the boundary decision needs: // Live drag state reduced to what the boundary decision needs. Pre-threshold "armed but not yet
// whether a drag is currently active (threshold crossed) and whether the armed payload // dragging" is not a drag for this decision.
// 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.
struct DragState { struct DragState {
bool dragging = false; // threshold crossed; a drag is in progress bool dragging = false; // threshold crossed; a drag is in progress
bool hasArmedSamples = false; // the drag payload holds >= 1 sample id bool hasArmedSamples = false; // payload holds >= 1 sample id
bool singleCapture = false; // S17: payload holds EXACTLY one sample (arms InstrumentDrop) bool singleCapture = false; // payload holds EXACTLY one sample (arms InstrumentDrop)
bool overReaperUi = false; // S17: pointer is over REAPER's own UI (shell-supplied) 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. // What the shell should do with the drag given the current pointer position.
enum class DragGesture { enum class DragGesture {
None, // no drag under way, or an empty payload — do nothing None, // no drag under way, or an empty payload
Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag Internal, // dragging inside the panel — bank-to-bank move/copy
InstrumentDrop, // S17: single-capture drag left the panel but is over REAPER's UI — InstrumentDrop, // single-capture drag left the panel but is over REAPER's UI — shell
// the shell hover-tracks the TCP FX button and, on release, adds a // hover-tracks the TCP FX button; on release adds a preloaded instance
// ReaSampler 9000 instance preloaded with the dragged capture. OsDrag, // dragging with samples, pointer left REAPER entirely — hand to the OS
OsDrag, // dragging with samples, pointer left REAPER entirely — hand off to the OS
}; };
// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. // Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. Position-only
// * Not dragging (or no armed samples): None — the shell ignores the move. // + state-only (no hidden state), so re-entry back inside always returns Internal.
// * 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.
DragGesture decideGesture(int px, int py, const PanelClientRect& client, DragGesture decideGesture(int px, int py, const PanelClientRect& client,
const DragState& state); const DragState& state);
// --- Path-list assembly ------------------------------------------------------- // --- Path-list assembly -------------------------------------------------------
// One armed sample reduced to what path assembly needs: the resolved ABSOLUTE file path // One armed sample reduced to what path assembly needs: the shell-resolved absolute path (empty
// the shell computed for it (empty when the shell could not resolve it — e.g. no project // if unresolvable) and whether it exists on disk.
// 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.
struct ResolvedSample { struct ResolvedSample {
std::string absolutePath; // resolved absolute path, or "" when unresolvable std::string absolutePath;
bool fileExists = false; // shell stat() result — drives the skip-missing policy bool fileExists = false;
}; };
// The outcome of assembling the drag's path list: the de-duped, existing-only absolute // Outcome of assembling the drag's path list. An empty `paths` means nothing draggable — do not
// paths to hand to the OS, plus explicit tallies so the shell can decide whether to // start a drag.
// initiate at all (an empty `paths` means nothing draggable — do NOT start a drag).
struct PathList { struct PathList {
std::vector<std::string> paths; // de-duped, existing files, in first-seen order std::vector<std::string> paths; // de-duped, existing files, first-seen order
int skippedMissing = 0; // resolved but file did not exist (skip policy) int skippedMissing = 0; // resolved but file doesn't exist (stale index entry)
int skippedUnresolved = 0; // shell could not resolve a path at all int skippedUnresolved = 0; // shell couldn't resolve a path at all
int skippedDuplicate = 0; // same absolute path seen more than once int skippedDuplicate = 0; // same absolute path seen more than once
}; };
// Assembles the drag path list from the resolved samples (in selection order). // Assembles the drag path list from the resolved samples (selection order). Comparison is
// Policy (all explicit, all tested): // exact-string — the shell normalizes case/slashes upstream if it wants Windows-style dedup.
// * 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).
PathList assemblePathList(const std::vector<ResolvedSample>& resolved); PathList assemblePathList(const std::vector<ResolvedSample>& resolved);
} // namespace reasampler::ui } // namespace reasampler::ui
+3 -5
View File
@@ -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" #include "core/ui/footer_bar.h"
@@ -6,8 +6,7 @@ namespace reasampler::ui {
namespace { namespace {
// True iff a box [x, x+width) fits entirely left of `rightBound` (its right edge does not // True iff a box [x, x+width) fits entirely left of `rightBound`.
// cross the reserved right region). A non-positive width never "fits" (nothing to place).
bool fitsLeftOf(int x, int width, int rightBound) { bool fitsLeftOf(int x, int width, int rightBound) {
return width > 0 && x + width <= 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; const int boxH = footer.height - 2 * spec.verticalInset;
if (boxH <= 0) return out; if (boxH <= 0) return out;
// The right bound the LEFT group must stay clear of (prune + version region). Clamp so a // Clamp so a pathologically large rightReserve never yields a negative bound.
// pathologically large rightReserve never yields a negative bound.
int rightBound = footer.x + footer.width - spec.rightReserve; int rightBound = footer.x + footer.width - spec.rightReserve;
if (rightBound < footer.x) rightBound = footer.x; if (rightBound < footer.x) rightBound = footer.x;
+30 -72
View File
@@ -1,81 +1,46 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// footer_bar — the REAPER-free, LICE-free layout + hit-test math for the bank_panel's L4 // footer_bar — layout + hit-test for the bank_panel footer's LEFT group: the [Arrange|Design]
// footer LEFT group: the narrowed [Arrange|Design] mode toggle, its compact per-mode count // mode toggle, its compact count label, and the Tail button, left-to-right at the footer's left.
// 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) -------------------------------
// //
// Affordance order, left -> right:
// [Arrange|Design] toggle . count label . Tail button . ... . Prune (rightmost, warn) // [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 // The toggle here is only the overall BOX; the shell hands its width to mode_switch
// panel/capture behaves" cluster; Prune stays isolated at the far RIGHT, warn-colored and // (computeSegmentRects / hitTestSegment) for per-segment tiling — mode_switch stays the one
// set apart (it is the only byte-deleting affordance). This module lays out the LEFT group // owner of segment geometry.
// 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.
#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 { namespace reasampler::ui {
// One placed affordance's pixel rectangle within the footer, top-left origin. A zero-area rect // One placed affordance's rect, top-left origin. empty() means "not placed" (footer too narrow
// (empty()) means "not placed" (the footer was too narrow to host it after the ones before it), // after earlier affordances claimed their space) — shell draws/hit-tests nothing for it.
// so the shell draws/hit-tests nothing for it — graceful degradation, mirroring prune_button. using FooterBarRect = Rect;
using FooterBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// The laid-out footer LEFT group: the mode toggle box, the count label box, and the Tail // The laid-out footer LEFT group. Any box may be empty when the footer is too narrow to fit it
// button box, in left-to-right order. Any box may be empty (suppressed) when the footer is // left of the reserved right margin; placement is greedy left-to-right (toggle survives longest,
// too narrow to fit it left of the reserved right margin — placement is greedy left-to-right, // Tail drops first on a very narrow footer).
// 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).
struct FooterBarLayout { struct FooterBarLayout {
FooterBarRect toggle; // the [Arrange|Design] segmented control's overall box FooterBarRect toggle;
FooterBarRect count; // the compact per-mode count label (right of the toggle) FooterBarRect count;
FooterBarRect tail; // the Tail button (right of the count label) FooterBarRect tail;
bool operator==(const FooterBarLayout& o) const { bool operator==(const FooterBarLayout& o) const {
return toggle == o.toggle && count == o.count && tail == o.tail; 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 // Which footer LEFT-group affordance a point landed on. Prune is hit-tested separately via
// affordance). Prune is NOT here — the shell hit-tests it separately via hitTestPruneButton. // hitTestPruneButton.
enum class FooterHit { None, Toggle, Tail }; enum class FooterHit { None, Toggle, Tail };
// Layout inputs for the footer LEFT group, in pixels. Defaults are the bank_panel footer // Layout inputs, in pixels; defaults are the bank_panel footer metrics.
// metrics; the shell passes its own so draw and hit-test share ONE source of truth. // * rightReserve — pixels reserved at the footer's right for the prune button + version
// * toggleWidth — the [Arrange|Design] toggle's overall width. Sized to fit its two // readout; footer_bar never places an affordance whose right edge would cross into it.
// 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.
struct FooterBarSpec { struct FooterBarSpec {
int toggleWidth = 132; int toggleWidth = 132;
int countWidth = 64; int countWidth = 64;
@@ -86,21 +51,14 @@ struct FooterBarSpec {
int rightReserve = 168; // clears prune_button (rightInset 84 + width 72) + margin 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 // Lays out the footer LEFT group inside `footer` per `spec`: toggle, count label, Tail button,
// count label, then the Tail button, each `gap` px apart, starting at footer.left + leftPad, // each `gap` px apart from footer.left + leftPad. Greedy — an affordance places only if it fits
// vertically centred by verticalInset. Greedy: an affordance is placed only if its whole box // left of (footer.right - rightReserve); once one doesn't fit, the rest are suppressed too.
// fits left of (footer.right - rightReserve); otherwise it (and, since placement is ordered, // countWidth <= 0 suppresses the count label without leaving a gap for the Tail button.
// 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).
FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec); FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec);
// The footer LEFT-group affordance the point (px, py) (SWELL/LICE top-left client coords) lands // The affordance (px, py) lands on, or FooterHit::None for a miss (or a hit on the count label,
// on, or FooterHit::None for a miss (outside every placed box, or on the count label — which is // a passive readout, never a control). Half-open bounds match computeFooterBar.
// 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.
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout); FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout);
} // namespace reasampler::ui } // namespace reasampler::ui
+3 -5
View File
@@ -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/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 { namespace reasampler::ui {
bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) { 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 = const char* targetId =
(target == TagTarget::Arrange) ? kArrangeModeId : kDesignModeId; (target == TagTarget::Arrange) ? kArrangeModeId : kDesignModeId;
// Fail-open on an unrecognized active id (neither seed mode): every button live, so a // Fail-open on an unrecognized active id: every button live.
// future added mode never dead-locks the bar and the user can always reach the action.
if (activeModeId != kArrangeModeId && activeModeId != kDesignModeId) return true; if (activeModeId != kArrangeModeId && activeModeId != kDesignModeId) return true;
return activeModeId != targetId; return activeModeId != targetId;
+8 -25
View File
@@ -1,39 +1,22 @@
#pragma once #pragma once
// mode_enable — the REAPER-free opposite-mode enablement predicate behind the bank_panel BOTTOM // mode_enable — enablement predicate behind the bank_panel bottom toolbar's four Item/Track x
// toolbar's four Item/Track × Arrange/Design tag buttons (Phase L, L5, refinement 3). Each tag // Arrange/Design tag buttons. A tag button sends the selection to a TARGET mode; it's live only
// button sends the selection to a TARGET mode; a button is meaningful ONLY when its target is // when its target differs from the currently active mode (you tag INTO the mode you're not in).
// 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.
#include <string> #include <string>
namespace reasampler::ui { namespace reasampler::ui {
// A tag button's TARGET mode — the mode it sends the selection to when fired. Arrange = the // A tag button's TARGET mode. The Item/Track axis is orthogonal to enablement (both buttons for
// untagged default (returning the selection to Arrange), Design = tagged into the Design mode. // a target enable/disable together), so it isn't modelled here — the shell carries it per button.
// 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.
enum class TagTarget { enum class TagTarget {
Arrange, Arrange,
Design, Design,
}; };
// True iff a tag button whose target is `target` should be LIVE (clickable), given the active // True iff a button targeting `target` should be live, given the active mode id `activeModeId`
// mode id `activeModeId` (as returned by ViewModeModel::activeModeId() — the mode ids are the // (ViewModeModel::activeModeId(), i.e. kArrangeModeId / kDesignModeId). An unrecognized active id
// pure `kArrangeModeId` / `kDesignModeId` constants). The rule: a button is live iff its target // leaves every button live (fail-open — never silently disable a reachable action).
// 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.
bool tagButtonEnabled(const std::string& activeModeId, TagTarget target); bool tagButtonEnabled(const std::string& activeModeId, TagTarget target);
} // namespace reasampler::ui } // namespace reasampler::ui
+3 -4
View File
@@ -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" #include "core/ui/overflow_menu.h"
@@ -6,8 +6,7 @@ namespace reasampler::ui {
int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) { int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) {
if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return 0; 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 // Button width plus a right gap and a matching left gap for breathing room.
// (also rightInset) so the frequent buttons have breathing room before the menu button.
return spec.buttonWidth + 2 * spec.rightInset; 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 top = bar.y + spec.verticalInset;
int height = bar.height - 2 * 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; top = bar.y;
height = bar.height; height = bar.height;
} }
+19 -47
View File
@@ -1,44 +1,23 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// overflow_menu — the REAPER-free layout math behind the bank_panel TOP toolbar's "⋯ / More" // overflow_menu — layout for the bank_panel top toolbar's "..." overflow-menu button: the rare
// overflow-menu button (Phase L, L5, refinement 1). The rare capture variants (Batch Items / // capture variants (Batch Items / Batch Razor / Capture RT) live in a popup opened by a small
// Batch Razor / Capture RT) move OFF the always-visible top bar into a popup opened by a small // square button right-anchored in the top toolbar band. Owns the button's placement and the
// square button pinned to the FAR RIGHT of the top toolbar band. This module owns two things, // horizontal reserve action_bar must leave so its buttons never run under it. The popup itself
// both unit-tested outside the DAW: // (TrackPopupMenu) and command dispatch are shell concerns.
// * 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.
namespace reasampler::ui { namespace reasampler::ui {
// The toolbar band the button is drawn into, top-left origin (SWELL/LICE convention). The // The toolbar band the button draws into, top-left origin.
// shell derives this from topToolbarRect(). A distinct type from action_bar::ActionBarRect so using MenuBarRect = Rect;
// 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 More button's pixel rectangle within the band, top-left origin. A zero-area rect // The More button's rect. Zero-area means "no button" — the three variants stay reachable via
// (width <= 0 or height <= 0) means "no button" — the band is degenerate or too narrow to // their bindable commands regardless.
// place the button clear of its left inset; the caller must not draw or hit-test it. The using MenuButtonRect = Rect;
// 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
// Layout inputs for the More button, in pixels. Defaults match the bank_panel top-toolbar // Layout inputs, in pixels; defaults match the bank_panel top-toolbar metrics.
// metrics; the shell passes its own so draw and hit-test share one source of truth. // * minLeftInset — button's left edge must stay at least this far from the band's left edge;
// * buttonWidth — the button's fixed width (a compact square-ish glyph button). // otherwise computeMenuButton suppresses it (empty rect).
// * 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).
struct MenuButtonSpec { struct MenuButtonSpec {
int buttonWidth = 28; int buttonWidth = 28;
int rightInset = 6; int rightInset = 6;
@@ -46,23 +25,16 @@ struct MenuButtonSpec {
int minLeftInset = 40; int minLeftInset = 40;
}; };
// The horizontal reserve (px) the action_bar must leave at the band's right so its buttons // Horizontal reserve (px) action_bar must leave at the band's right: button width + both insets.
// never run under the More button: the button width + both insets (right gap + a matching // 0 for a degenerate band.
// 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).
int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec); int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec);
// Computes the More button's rect within `bar` per `spec`. Right-anchored: the button's right // The More button's rect within `bar`, right-anchored, vertically centred by verticalInset.
// edge is bar.x + bar.width - rightInset, its width is buttonWidth, vertically centred by // Empty when the band is degenerate, buttonWidth <= 0, or the left edge would fall closer to the
// verticalInset. Returns an EMPTY rect when: the band is degenerate (width/height <= 0), the // band's left than minLeftInset. A thin band clamps height to the band's own rather than negative.
// 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).
MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& spec); 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, matching computeMenuButton. Empty button claims nothing.
// 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).
bool hitTestMenuButton(int px, int py, const MenuButtonRect& button); bool hitTestMenuButton(int px, int py, const MenuButtonRect& button);
} // namespace reasampler::ui } // namespace reasampler::ui
+1 -4
View File
@@ -1,9 +1,6 @@
#include "core/ui/prune_button.h" #include "core/ui/prune_button.h"
// prune_button implementation — right-anchored button placement in the footer strip, // prune_button — pure implementation. See prune_button.h.
// 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.
namespace reasampler::ui { namespace reasampler::ui {
+20 -64
View File
@@ -1,82 +1,38 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// prune_button — the REAPER-free layout math behind the bank_panel's Prune button // prune_button — layout for the bank_panel's Prune button in the tail-footer strip. Panel shell
// (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's // owns SWELL/LICE/dispatch; this owns whether a click lands on it.
// 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.
// //
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library // Placement: right-anchored in the footer, inset from the right edge, just left of the version
// only. Builds and unit-tests without REAPER. // 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
// -- Placement contract -------------------------------------------------------- // reachable via its binding either way.
//
// 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.
namespace reasampler::ui { namespace reasampler::ui {
// The footer strip the button is drawn into, top-left origin (SWELL/LICE using FooterRect = Rect;
// convention). (x, y) is the top-left corner; width/height are the strip extents. using ButtonRect = Rect;
// 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
// A button's pixel rectangle within the footer, top-left origin. A zero-area rect // Layout inputs, in pixels; defaults match the bank_panel footer metrics.
// (width <= 0 or height <= 0) means "no button" — the footer is too narrow to place // * rightInset — gap from the footer's right edge to the button's right edge, clearing the
// it, or the footer itself is degenerate; the caller must not draw or hit-test it. // right-aligned version readout. COUPLED to drawFooter's version-readout
using ButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // margin (panel_render.cpp) and to FooterBarSpec::rightReserve, which must
// exceed rightInset + buttonWidth so the left group never runs under this
// Layout inputs for the prune button, in pixels. Defaults match the bank_panel footer // button. Update together if either margin changes.
// metrics; the shell passes its own so draw and hit-test share one source of truth. // * minLeftInset — button's left edge must stay this far from the footer left edge (room for
// * buttonWidth — the button's fixed width. // the footer-left group); otherwise the button is suppressed.
// * 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).
struct PruneButtonSpec { struct PruneButtonSpec {
int buttonWidth = 72; int buttonWidth = 72;
int rightInset = 84; // COUPLED: version readout in drawFooter uses an 8 px right margin int rightInset = 84;
int verticalInset = 4; int verticalInset = 4;
int minLeftInset = 120; int minLeftInset = 120;
}; };
// Computes the prune button's rect within `footer` per `spec`. Right-anchored: the // Right-anchored rect within `footer`, vertically centred. Empty when the footer is degenerate
// button's right edge is footer.x + footer.width - rightInset, its width is buttonWidth, // or the resulting left edge would fall closer to the footer's left than minLeftInset.
// 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.
ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec); 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, matching computePruneButton. Empty button never claims a point.
// 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.
bool hitTestPruneButton(int px, int py, const ButtonRect& button); bool hitTestPruneButton(int px, int py, const ButtonRect& button);
} // namespace reasampler::ui } // namespace reasampler::ui
+6 -26
View File
@@ -1,23 +1,6 @@
#pragma once #pragma once
// rect.h — the ONE concrete pixel rectangle (Q-W1, T2-05 ≡ T4-21). // 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.
// 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.
namespace reasampler::ui { namespace reasampler::ui {
@@ -27,16 +10,13 @@ struct Rect {
int width = 0; int width = 0;
int height = 0; int height = 0;
// Exclusive edges (half-open convention).
int right() const { return x + width; } int right() const { return x + width; }
int bottom() const { return y + height; } int bottom() const { return y + height; }
// A zero-or-negative-area rect means "not placed / suppressed": the caller must // Zero-or-negative area means "not placed / suppressed" caller must not draw or hit-test it.
// not draw or hit-test it (the shared graceful-degradation contract).
bool empty() const { return width <= 0 || height <= 0; } bool empty() const { return width <= 0 || height <= 0; }
// The former LTRB grammar's constructor (editor_geometry and friends): edges in, // LTRB constructor for call sites that think in edges rather than extents.
// extents stored. right/bottom exclusive, matching right()/bottom().
static Rect ltrb(int left, int top, int right, int bottom) { static Rect ltrb(int left, int top, int right, int bottom) {
return Rect{left, top, right - left, bottom - top}; return Rect{left, top, right - left, bottom - top};
} }
@@ -47,8 +27,8 @@ struct Rect {
bool operator!=(const Rect& o) const { return !(*this == o); } bool operator!=(const Rect& o) const { return !(*this == o); }
}; };
// True iff (px, py) falls inside r under the half-open convention. An empty rect // Half-open containment; an empty rect contains nothing, so a suppressed affordance never
// contains nothing, so a suppressed affordance can never claim a click. // claims a click.
inline bool contains(const Rect& r, int px, int py) { 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; return px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height;
} }
+8 -17
View File
@@ -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" #include "core/ui/tab_strip.h"
@@ -8,17 +8,16 @@ namespace reasampler::ui {
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset) { 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; TabStripLayout out;
if (tabCount <= 0 || strip.width <= 0) { if (tabCount <= 0 || strip.width <= 0) {
out.trackX = strip.x; out.trackX = strip.x;
out.trackWidth = strip.width > 0 ? strip.width : 0; 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; const int totalTabsWidth = tabCount * spec.tabWidth;
if (totalTabsWidth <= strip.width) { if (totalTabsWidth <= strip.width) {
// Everything fits: the whole strip is the track; no chevrons, no scroll.
out.overflow = false; out.overflow = false;
out.trackX = strip.x; out.trackX = strip.x;
out.trackWidth = strip.width; out.trackWidth = strip.width;
@@ -26,15 +25,12 @@ TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
return out; return out;
} }
// Overflow: reserve a chevron band at each end; the tabs live between them.
out.overflow = true; out.overflow = true;
out.leftChevron = true; out.leftChevron = true;
out.rightChevron = true; out.rightChevron = true;
out.trackX = strip.x + spec.chevronWidth; out.trackX = strip.x + spec.chevronWidth;
out.trackWidth = strip.width - 2 * spec.chevronWidth; out.trackWidth = strip.width - 2 * spec.chevronWidth;
if (out.trackWidth < 0) out.trackWidth = 0; 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; out.maxScroll = totalTabsWidth - out.trackWidth;
if (out.maxScroll < 0) out.maxScroll = 0; if (out.maxScroll < 0) out.maxScroll = 0;
return out; return out;
@@ -61,11 +57,9 @@ std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
for (int i = 0; i < tabCount; ++i) { for (int i = 0; i < tabCount; ++i) {
const int rawLeft = trackLeft + i * spec.tabWidth - offset; const int rawLeft = trackLeft + i * spec.tabWidth - offset;
const int rawRight = rawLeft + spec.tabWidth; 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 left = rawLeft < trackLeft ? trackLeft : rawLeft;
int right = rawRight > trackRight ? trackRight : rawRight; 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; TabRect r;
r.index = i; r.index = i;
r.x = left; r.x = left;
@@ -79,10 +73,9 @@ std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset) { const TabStripSpec& spec, int scrollOffset) {
TabHit miss; // {None, -1} TabHit miss;
if (tabCount <= 0 || strip.width <= 0 || strip.height <= 0) return 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 || if (px < strip.x || px >= strip.x + strip.width ||
py < strip.y || py >= strip.y + strip.height) py < strip.y || py >= strip.y + strip.height)
return miss; return miss;
@@ -90,8 +83,7 @@ TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
const TabStripLayout layout = const TabStripLayout layout =
computeTabStripLayout(strip, tabCount, spec, scrollOffset); computeTabStripLayout(strip, tabCount, spec, scrollOffset);
// Chevrons take precedence at the strip ends: a click in a reserved chevron band // Chevron bands take precedence at the strip ends over any tab.
// is a scroll, never a tab (the tab track excludes those bands).
if (layout.overflow) { if (layout.overflow) {
if (px < strip.x + spec.chevronWidth) if (px < strip.x + spec.chevronWidth)
return TabHit{TabHitKind::ScrollLeft, -1}; 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}; return TabHit{TabHitKind::ScrollRight, -1};
} }
// Inside the track: find the visible tab whose clipped rect contains px. Reuse // Reuse computeTabRects so the hit matches exactly what was drawn (clipping included).
// computeTabRects so the hit matches exactly what was drawn (clipping included).
const std::vector<TabRect> rects = const std::vector<TabRect> rects =
computeTabRects(strip, tabCount, spec, scrollOffset); computeTabRects(strip, tabCount, spec, scrollOffset);
for (const TabRect& r : rects) { for (const TabRect& r : rects) {
if (px >= r.x && px < r.x + r.width) return TabHit{TabHitKind::Tab, r.index}; 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 } // namespace reasampler::ui
+32 -74
View File
@@ -1,45 +1,25 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// tab_strip — the REAPER-free layout + hit-test math behind the bank_panel's // tab_strip — layout + hit-test for the bank_panel's named-banks tab strip: a LICE-drawn strip
// named-banks tab strip (Phase B, Wave 4 — B4). The named-banks region of the // (not a SWELL tab control) that scrolls via chevrons when tabs overflow the strip width.
// 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 ~812
// 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.
#include <vector> #include <vector>
namespace reasampler::ui { namespace reasampler::ui {
// The strip the tabs are drawn into, top-left origin (SWELL/LICE convention). // The strip the tabs draw into, top-left origin.
// (x, y) is the top-left corner; width/height are the strip extents. The panel using TabStripRect = Rect;
// 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
// Fixed inputs that shape the strip. tabWidth is the pixel width of each tab (fixed // tabWidth is fixed per tab so the strip reads as a uniform segmented control and overflow math
// so the strip reads as a uniform segmented control and overflow math stays simple — // stays simple (labels ellipsize, they don't resize the tab). chevronWidth is reserved at each
// labels ellipsize within the tab, they do not resize it). chevronWidth is the width // end only when tabs overflow.
// 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.
struct TabStripSpec { struct TabStripSpec {
int tabWidth = 96; int tabWidth = 96;
int chevronWidth = 20; int chevronWidth = 20;
}; };
// One tab's pixel rectangle within the strip, top-left origin, ALREADY translated // One tab's rect, already translated by scroll offset and clipped to the visible track. A tab
// by the current scroll offset and clipped to the visible track. `index` is the // scrolled fully out of view is omitted from computeTabRects's result.
// 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.
struct TabRect { struct TabRect {
int index = 0; int index = 0;
int x = 0; int x = 0;
@@ -53,59 +33,41 @@ struct TabRect {
} }
}; };
// The scrollable track's geometry: where the tabs may be drawn (between the // Scrollable track geometry, shared by layout + hit-test so both agree.
// 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.
struct TabStripLayout { struct TabStripLayout {
bool overflow = false; // true iff N tabs at tabWidth exceed the track width bool overflow = false;
int trackX = 0; // left edge of the tab track (past the left chevron) int trackX = 0;
int trackWidth = 0; // width available to tabs (strip minus both chevrons) int trackWidth = 0;
int maxScroll = 0; // largest valid scroll offset (0 when no overflow) int maxScroll = 0;
bool leftChevron = false; // a left-scroll affordance is reserved this frame bool leftChevron = false;
bool rightChevron = false;// a right-scroll affordance is reserved this frame bool rightChevron = false;
}; };
// Computes the strip layout for `tabCount` tabs of `spec.tabWidth` in `strip`, // Layout for `tabCount` tabs of `spec.tabWidth` in `strip`. No overflow: track == strip, no
// given the current `scrollOffset`. Pure geometry: // chevrons, maxScroll 0. Overflow: both chevrons always reserved together (simpler than hiding
// * No overflow (all tabs fit the strip width): overflow=false, no chevrons, the // one at a scroll limit — a chevron click there is a harmless no-op the shell clamps); track is
// track IS the strip, maxScroll=0. // the strip minus both chevrons; maxScroll is how far the tab run exceeds the track.
// * Overflow: both chevrons are reserved (chevronWidth each), the track is the // tabCount <= 0 or non-positive strip width returns a zeroed layout.
// 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).
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset); const TabStripSpec& spec, int scrollOffset);
// Clamps a desired scroll offset into [0, maxScroll] for the given layout. The shell // Clamps a desired scroll offset into [0, maxScroll]; always 0 when tabs fit.
// 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.
int clampTabScroll(int desiredOffset, const TabStripLayout& layout); int clampTabScroll(int desiredOffset, const TabStripLayout& layout);
// Tiles `tabCount` fixed-width tabs left-to-right into the layout's track, shifted // Tiles tabCount fixed-width tabs into the track, shifted by scrollOffset, returning only
// left by `scrollOffset`, and returns the rects that are at least partially visible // partially-or-fully visible rects (clipped to the track so a scrolled tab never draws under a
// (in tab-index order). Each tab i sits at trackX + i*tabWidth - scrollOffset; a tab // chevron). Caller must pass the same scrollOffset used for computeTabStripLayout.
// 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.
std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount, std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset); const TabStripSpec& spec, int scrollOffset);
// What a point in the strip resolves to.
enum class TabHitKind { enum class TabHitKind {
None, // outside the strip, or in dead space between visible tabs None,
Tab, // a tab — `index` is the tab's index in the caller's list Tab,
ScrollLeft, // the left overflow chevron ScrollLeft,
ScrollRight, // the right overflow chevron ScrollRight,
}; };
// The outcome of hit-testing a point against the strip. For Tab, `index` is the tab // index is the tab's index for Tab, -1 for chevrons/None.
// index; for the chevrons and None it is -1.
struct TabHit { struct TabHit {
TabHitKind kind = TabHitKind::None; TabHitKind kind = TabHitKind::None;
int index = -1; int index = -1;
@@ -115,12 +77,8 @@ struct TabHit {
} }
}; };
// Hit-tests a point (SWELL/LICE top-left client coords) against the strip laid out // Hit-tests a point against the strip laid out for `tabCount` tabs at `scrollOffset`. Chevrons
// for `tabCount` tabs at `scrollOffset`. Chevrons take precedence over tabs at the // take precedence at the strip ends. Half-open bounds match computeTabRects.
// 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.
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset); const TabStripSpec& spec, int scrollOffset);
+36 -56
View File
@@ -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" #include "core/ui/theme.h"
@@ -10,59 +10,48 @@ namespace reasampler::ui {
namespace { namespace {
// =========================================================================== // ===========================================================================
// THE ONE DIRECTION CONSTANTS BLOCK (DS-2 revised: B "Neon Console" REAPER-grey // THE ONE DIRECTION CONSTANTS BLOCK. Every role color below is one of these constants;
// neutrals + three-accent pastel system + C pastel spectral). // 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):
// This is the SINGLE POINT OF CHANGE. Every role color below is one of these // text/dim is lifted to the lightest grey that still clears AA 4.5:1 body on the greyest
// constants; roleColor() is a pure switch over them. To re-pick the visual // surface it draws on; each pastel accent is the softest tint that still clears the 3:1
// direction (§4: A Studio Rack / B Neon Console / C full spectral), edit THIS // indicator floor on bg/cell ("punch from the soft side").
// 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).
// =========================================================================== // ===========================================================================
// REAPER-theme mid-grey elevation stack (DS-2 revised — NOT near-black). Matches // REAPER-theme mid-grey elevation stack, matching Daniel's REAPER theme so the dock reads as
// Daniel's REAPER theme so the dock reads as part of REAPER: base = window chrome // part of REAPER: base = window chrome grey, panel/cell one step lighter each. Elevation-ladder
// grey, panel/cell one step lighter each. The elevation-ladder discipline is // discipline: base < panel < cell by a few %, micro-gradient + inner highlight/shadow carry
// unchanged (base < panel < cell by a few %, micro-gradient + inner highlight/ // elevation, not hard borders.
// shadow carry elevation, not hard borders); only the VALUES moved up into grey.
constexpr KitColor kDirBgBase {43, 43, 43, 255}; // #2b2b2b — REAPER chrome grey constexpr KitColor kDirBgBase {43, 43, 43, 255}; // #2b2b2b — REAPER chrome grey
constexpr KitColor kDirBgPanel {51, 51, 51, 255}; // #333333 — one step lighter constexpr KitColor kDirBgPanel {51, 51, 51, 255}; // #333333 — one step lighter
constexpr KitColor kDirBgCell {58, 58, 58, 255}; // #3a3a3a — REAPER track bg constexpr KitColor kDirBgCell {58, 58, 58, 255}; // #3a3a3a — REAPER track bg
constexpr KitColor kDirHairline {74, 74, 74, 255}; // #4a4a4a — subtle step above cell 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 // Text: REAPER body light-grey primary (#dcdcdc, clears ~8:1 on bg/cell) + a dimmer grey
// grey secondary. The greyer surfaces shrank the dim cushion (mid-grey-on-mid-grey // secondary. Mid-grey-on-mid-grey is the classic AA failure: the spec-start #a0a0a0 lands
// is the classic AA failure): the spec-start #a0a0a0 lands ~4.35:1 on bg/cell, UNDER // ~4.35:1 on bg/cell, under the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1), the lightest
// the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1 on bg/cell), the lightest grey // grey that still reads dim while clearing the floor. Locked by test_theme.cpp.
// that still reads dim while clearing AA 4.5 body on the greyest surface it draws
// body text on. Locked by test_theme.cpp.
constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc
constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0) constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0)
// The three-accent pastel system (DS-2 revised — replaces the single electric cyan). // Three-accent pastel system: primary = pastel lime (the live/active/selected signal);
// primary = pastel lime (the live/active/selected signal, the eye-magnet); secondary // secondary = pastel teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never
// = 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
// intensity). accent/hot is a brighter tint OF the primary for hover/live/drag. On the // pastels clear the 3:1 indicator floor comfortably at these values (primary ~7.6, secondary
// greyer bg/cell the pastels clear the 3:1 indicator floor comfortably (primary ~7.6, // ~6.8, tertiary ~5.5), so no per-hue nudge was needed. warn is reserved for byte-deleting
// secondary ~6.8, tertiary ~5.5) at the spec-start values, so no per-hue nudge was // states only.
// needed — the hues stay pastel lime/teal/purple. warn is a reserved red/amber for
// byte-deleting states only.
constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime
constexpr KitColor kDirAccentSecondary{132, 214, 208, 255}; // #84D6D0 — pastel teal constexpr KitColor kDirAccentSecondary{132, 214, 208, 255}; // #84D6D0 — pastel teal
constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple
constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime
constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only
// Direction C pastel spectral ramp (DS-2 revised): a three-stop sweep through the // Spectral ramp: pastel lime (low) -> pastel teal (mid) -> pastel purple (high). Endpoints and
// accents — pastel lime (low) -> pastel teal (mid) -> pastel purple (high) — so the // midpoint ARE the three accent constants (single source), so the keyboard strip reads as an
// signature keyboard strip reads as an extension of the accent system, not a neon // extension of the accent system.
// flourish. Endpoints/midpoint ARE the three accent constants (single source). constexpr KitColor kDirSpectralLo = kDirAccentPrimary;
constexpr KitColor kDirSpectralLo = kDirAccentPrimary; // low notes: pastel lime constexpr KitColor kDirSpectralMid = kDirAccentSecondary;
constexpr KitColor kDirSpectralMid = kDirAccentSecondary; // mid notes: pastel teal constexpr KitColor kDirSpectralHi = kDirAccentTertiary;
constexpr KitColor kDirSpectralHi = kDirAccentTertiary; // high notes: pastel purple
// --- state transform helpers ------------------------------------------------- // --- state transform helpers -------------------------------------------------
@@ -70,8 +59,8 @@ std::uint8_t clamp8(int v) {
return static_cast<std::uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v)); return static_cast<std::uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v));
} }
// Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state // Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state tint changes
// tint changes hue/brightness, not opacity; disabled handles alpha separately). // hue/brightness, not opacity; disabled handles alpha separately).
KitColor mix(const KitColor& a, const KitColor& b, double t) { KitColor mix(const KitColor& a, const KitColor& b, double t) {
return KitColor{ return KitColor{
clamp8(static_cast<int>(std::lround(a.r + (b.r - a.r) * t))), clamp8(static_cast<int>(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) { KitColor scale(const KitColor& c, double factor) {
return KitColor{ return KitColor{
clamp8(static_cast<int>(std::lround(c.r * factor))), clamp8(static_cast<int>(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]. // Desaturate toward the color's own luminance-gray by amount in [0, 1].
KitColor desaturate(const KitColor& c, double amount) { 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<int>( const int gray = clamp8(static_cast<int>(
std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b))); std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b)));
const KitColor g{static_cast<std::uint8_t>(gray), const KitColor g{static_cast<std::uint8_t>(gray),
@@ -132,25 +119,20 @@ KitColor roleColorState(Role role, InteractionState state) {
case InteractionState::Rest: case InteractionState::Rest:
return base; return base;
case InteractionState::Hover: 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); return mix(base, roleColor(Role::AccentHot), 0.10);
case InteractionState::Active: case InteractionState::Active:
// The selected/active layer carries the PRIMARY accent — "this is live" // "This is live" is always the primary hue — secondary/tertiary stay categorical.
// is always the primary hue (DS-2 revised: primary leads state; secondary/
// tertiary are categorical, never intensity).
return roleColor(Role::AccentPrimary); return roleColor(Role::AccentPrimary);
case InteractionState::Pressed: case InteractionState::Pressed:
// The surface "pushes in": darken. return scale(base, 0.82); // the surface "pushes in"
return scale(base, 0.82);
case InteractionState::Dragging: case InteractionState::Dragging:
// A live-drag element reads as active-but-lighter (primary -> hot).
return mix(roleColor(Role::AccentPrimary), roleColor(Role::AccentHot), 0.30); return mix(roleColor(Role::AccentPrimary), roleColor(Role::AccentHot), 0.30);
case InteractionState::Focus: case InteractionState::Focus:
// Focus keeps the surface but is drawn with a text/primary ring by the // Focus keeps the surface; the shell draws a text/primary ring on top, and the
// shell; the fill nudges toward the primary accent so focus reads pre-ring. // fill nudges toward the primary accent so focus reads pre-ring.
return mix(base, roleColor(Role::AccentPrimary), 0.08); return mix(base, roleColor(Role::AccentPrimary), 0.08);
case InteractionState::Disabled: { case InteractionState::Disabled: {
// Desaturate and drop alpha to 40% (§3.3).
KitColor d = desaturate(base, 0.6); KitColor d = desaturate(base, 0.6);
d.a = static_cast<std::uint8_t>(std::lround(base.a * 0.4)); d.a = static_cast<std::uint8_t>(std::lround(base.a * 0.4));
return d; return d;
@@ -162,10 +144,8 @@ KitColor roleColorState(Role role, InteractionState state) {
KitColor spectralColor(double t) { KitColor spectralColor(double t) {
if (t < 0.0) t = 0.0; if (t < 0.0) t = 0.0;
if (t > 1.0) t = 1.0; if (t > 1.0) t = 1.0;
// Three-stop pastel sweep anchored on the accent trio (DS-2 revised Direction C): // Interpolate each half separately so the midpoint IS the secondary accent (a single
// lime (low) -> teal (mid, t=0.5) -> purple (high). A single Lo->Hi lerp would skip // Lo->Hi lerp would skip it and drift the ramp off the accent family).
// 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.
if (t <= 0.5) { if (t <= 0.5) {
return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5); return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5);
} }
+32 -59
View File
@@ -1,35 +1,21 @@
#pragma once #pragma once
// theme — the REAPER-free, LICE-free palette + type-scale core of the shared drawing // theme — the palette + type-scale core of the shared drawing kit: a ROLE-based color model
// kit (Phase L, L1). This is the "one source of drawing" made testable at its root: a // (bg/base, bg/panel, bg/cell, line/hairline, text/primary, text/dim, accent/primary,
// ROLE-based color model (bg/base, bg/panel, bg/cell, line/hairline, text/primary, // accent/secondary, accent/tertiary, accent/hot, warn), an interaction-state model
// text/dim, accent/primary, accent/secondary, accent/tertiary, accent/hot, warn), an // (rest/hover/active/pressed/dragging/focus/disabled), and the WCAG contrast math that lets a
// INTERACTION-STATE model (rest/hover/active/pressed/dragging/focus/disabled), and the // unit test prove every text-on-surface pair clears its floor.
// WCAG contrast math that lets a unit test prove every text-on-surface pair clears its
// floor ("punch to the floor, not past it").
// //
// THE SINGLE POINT OF CHANGE (DS-2 revised): every role color is produced by roleColor() // Every role color is produced by roleColor() from ONE direction constants block (theme.cpp) —
// from ONE direction constants block (kDirection*, below) carrying the settled B (Neon // the single point of change; no shell hardcodes a color, it asks by role. The spectral hue ramp
// Console) neutrals — now REAPER-theme mid-grey, not near-black — plus the three-accent // (spectralColor) lives here too so the keyboard strip derives its per-note hue from the same
// pastel system (primary lime / secondary teal / tertiary purple) and the C pastel // source, anchored on the three accents (primary -> secondary -> tertiary).
// 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.
#include <cstdint> #include <cstdint>
namespace reasampler::ui { namespace reasampler::ui {
// A straight 8-bit-per-channel RGBA color, LICE-free. The draw shell converts this to a // Straight 8-bit-per-channel RGBA, LICE-free; draw_kit converts to LICE_pixel at the boundary.
// LICE_pixel via LICE_RGBA at the boundary (draw_kit); nothing here depends on LICE's // Named "KitColor" (not "Color"/"RGBA") to avoid collision.
// packing. Deliberately NOT named "Color"/"RGBA" (both are common collision surfaces);
// "KitColor" scopes it to the kit.
struct KitColor { struct KitColor {
std::uint8_t r = 0; std::uint8_t r = 0;
std::uint8_t g = 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 // Structural palette roles, direction-independent — the shell always asks by role.
// direction (B/C) sets the concrete hue behind each; the shell always asks by role.
enum class Role { enum class Role {
BgBase, // window canvas BgBase, // window canvas
BgPanel, // a raised region (list, waveform pane) BgPanel, // a raised region (list, waveform pane)
@@ -50,69 +35,57 @@ enum class Role {
LineHairline, // separators (used sparingly — elevation carries most separation) LineHairline, // separators (used sparingly — elevation carries most separation)
TextPrimary, // labels, values TextPrimary, // labels, values
TextDim, // secondary / units 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 AccentSecondary,// categorical role A (pastel teal) — a distinct KIND, never intensity
AccentTertiary,// categorical role B (pastel purple) — 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) AccentHot, // hover / live / drag feedback (a brighter tint OF the primary accent)
Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states
}; };
// The interaction-state model every kit component honors (§3.3). A component draws its // Interaction-state model every kit component honors; stateShift (roleColorState) is the
// role surface transformed by its current state; stateShift() below is that transform. // role-surface transform for the current state.
enum class InteractionState { enum class InteractionState {
Rest, Rest,
Hover, Hover,
Active, // selected / active Active,
Pressed, Pressed,
Dragging, Dragging,
Focus, Focus,
Disabled, Disabled,
}; };
// Text size classes for the WCAG floor. "Large" text (>= ~18.66px, or >= ~14px bold) and // Text size classes for the WCAG floor: "Large" (>= ~18.66px, or >= ~14px bold) and UI-state
// UI-state indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA). The kit's // indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA).
// four cached fonts map onto these: title -> Large, label/value -> Body, micro -> Body.
enum class TextClass { enum class TextClass {
Body, // AA 4.5:1 Body, // AA 4.5:1
Large, // AA-large 3:1 (also the floor for state indicators) 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 concrete color for a role, from the one direction constants block — the single choke
// the single choke point the "single point of change" guarantee rests on: the shell has // point re-picking the direction touches.
// no other way to obtain a palette color, so re-picking the direction is editing the
// kDirection* block this reads and nothing else.
KitColor roleColor(Role role); KitColor roleColor(Role role);
// The color for a role under an interaction state — roleColor(role) transformed by the // roleColor(role) transformed by state (hover lightens toward accent/hot, pressed darkens,
// state (hover lightens toward accent/hot, pressed darkens, disabled desaturates + drops // disabled desaturates + drops alpha, etc). Rest returns roleColor(role) unchanged.
// alpha, etc.). Surfaces use this so every component gets the whole state model for free.
// Rest returns roleColor(role) unchanged.
KitColor roleColorState(Role role, InteractionState state); KitColor roleColorState(Role role, InteractionState state);
// Direction C's spectral hue ramp (DS-2 revised — a PASTEL sweep anchored on the three // Spectral hue ramp for the keyboard strip: maps normalized position t in [0, 1] (low note ->
// accents, not the old neon cool-blue -> hot-magenta): maps a normalized position t in // high note) through accent/primary (low) -> accent/secondary (mid) -> accent/tertiary (high),
// [0, 1] (low note -> high note across the keyboard strip) to a color that runs // so the strip reads as an extension of the accent system rather than a separate flourish.
// accent/primary (pastel lime, low) -> accent/secondary (pastel teal, mid) -> // t is clamped to [0, 1].
// 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].
KitColor spectralColor(double t); KitColor spectralColor(double t);
// --- WCAG contrast (the "punch" rule, made testable) -------------------------- // --- WCAG contrast (the "punch" rule, made testable) --------------------------
//
// The relative luminance of a color per WCAG 2.1 (sRGB linearization + the 0.2126/ // Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha
// 0.7152/0.0722 weighting). Alpha is ignored — contrast is a question about the opaque // is ignored — a translucent overlay's effective color is the caller's to compose first.
// hues; a translucent overlay's effective color is the caller's to compose first.
double relativeLuminance(const KitColor& c); 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); 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 // Contrast floor a text class must clear: 4.5 for Body, 3.0 for Large. test_theme.cpp asserts
// proves the palette asserts contrastRatio(text, surface) >= textFloor(class) for every // contrastRatio(text, surface) >= textFloor(class) for every pair the kit actually draws.
// pair the kit actually draws.
double textFloor(TextClass cls); double textFloor(TextClass cls);
} // namespace reasampler::ui } // namespace reasampler::ui
+1 -1
View File
@@ -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" #include "core/ui/tooltip.h"
+14 -29
View File
@@ -1,22 +1,14 @@
#pragma once #pragma once
// tooltip — the REAPER-free layout math + text helper behind the bank_panel's custom hover-delay // tooltip — layout math + text helper behind the bank_panel's custom hover-delay tooltip. Button
// tooltip (Phase L, L5, refinement 2). Button FACES stay short (the terse shortLabel); hovering a // faces stay short; hovering pops a small tooltip with the full action name, "ReaSampler:"
// button for a short delay pops a small tooltip carrying the FULL action name with the // display prefix stripped. Custom LICE-kit draw, not the native Win32/SWELL tooltip control, for
// "ReaSampler:" display prefix stripped. The tooltip is a custom LICE-kit draw (NOT the native // cross-platform uniformity with the rest of the kit.
// 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.
#include <string> #include <string>
namespace reasampler::ui { namespace reasampler::ui {
// The tooltip's box (top-left origin, SWELL/LICE convention). A zero-area rect means "do not // Zero-area means "do not draw" (degenerate inputs); caller checks empty() first.
// draw" (degenerate inputs); the caller checks empty() before drawing.
struct TooltipBox { struct TooltipBox {
int x = 0; int x = 0;
int y = 0; int y = 0;
@@ -30,10 +22,8 @@ struct TooltipBox {
} }
}; };
// Placement inputs, in pixels. // gap: vertical gap between anchor button and tooltip. padX/padY: text padding inside the box.
// * gap — vertical gap between the anchor button and the tooltip box. // margin: minimum clearance from client edges when clamping.
// * padX/padY — horizontal / vertical text padding inside the box.
// * margin — minimum clearance kept from the client edges when clamping.
struct TooltipSpec { struct TooltipSpec {
int gap = 4; int gap = 4;
int padX = 6; int padX = 6;
@@ -41,20 +31,15 @@ struct TooltipSpec {
int margin = 2; int margin = 2;
}; };
// Strips the action DISPLAY PREFIX from a full action name for the tooltip face. The registered // Strips the action display prefix (e.g. "ReaSampler: ") from a full action name for the
// gaccel name is composed as `prefix + phrase` (prefix from actionDisplayPrefix(), e.g. // tooltip face. If fullName doesn't start with prefix, returned unchanged (defensive). Empty
// "ReaSampler: "); the tooltip shows only the phrase. If `fullName` does not start with // prefix returns fullName unchanged.
// `prefix`, it is returned unchanged (defensive — a name from an unexpected source still shows).
// An empty prefix returns fullName unchanged.
std::string stripActionPrefix(const std::string& fullName, const std::string& prefix); 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 // Places a tooltip of size (textW + 2*padX) x (textH + 2*padY) for the anchor button rect,
// (anchorX, anchorY, anchorW, anchorH), clamped inside the client rect (0,0,clientW,clientH). // clamped inside the client rect. Prefers BELOW the anchor, centered; flips ABOVE if it would
// Preference: BELOW the anchor, horizontally centred on it. If it would clip the bottom edge, // clip the bottom edge, then clamps to stay within `margin` of the client edges. Empty when the
// it flips ABOVE the anchor. It is then clamped horizontally (and vertically as a last resort) // text extent or client is degenerate. textW/textH are measured by the shell before calling.
// 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).
TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH,
int textW, int textH, int clientW, int clientH, int textW, int textH, int clientW, int clientH,
const TooltipSpec& spec); const TooltipSpec& spec);

Some files were not shown because too many files have changed in this diff Show More