Merge comment-reduction: cut source comment volume ~42% tree-wide across 12 tracks
This commit is contained in:
+90
-181
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: Hold→Decay 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;
|
||||||
|
|||||||
@@ -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ₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||||
// outₙ = outₙ*(1−w) + 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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
// Fritsch–Carlson 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 (Fritsch–Carlson eq. 4), which for COLLINEAR knots (dPrev==dNext)
|
|
||||||
// reduces to that common secant — so collinear control points reproduce the straight line to within
|
|
||||||
// floating-point rounding (~1e-15), preserving the Option-B / null-response contract for linear().
|
|
||||||
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 (Fritsch–Carlson) 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);
|
||||||
|
|||||||
@@ -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 (Fritsch–Carlson 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.
|
||||||
// Fritsch–Carlson tangents reduce to the secant slope, so the spline reproduces the straight line to
|
|
||||||
// within floating-point rounding (~1e-15) — that preserves linear()'s null-response contract
|
|
||||||
// (y = velocity/127 to ~1e-15; the 1e-12 test tolerance is deliberately conservative). The two endpoints
|
|
||||||
// (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable.
|
|
||||||
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 (Fritsch–Carlson slope limiting) — a
|
|
||||||
// true curve that provably stays within the two knots' amp range (no overshoot below 0 / above
|
|
||||||
// 1) and reproduces the straight line to within floating-point rounding (~1e-15) for collinear
|
|
||||||
// knots. Single-valued / monotonic in X.
|
|
||||||
// Degenerate cases (shouldn't occur post-construction): an EMPTY curve returns kAmpMax (flat
|
|
||||||
// unity); a ONE-point curve returns that point's amp.
|
|
||||||
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_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
|
||||||
// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the
|
|
||||||
// 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;
|
||||||
int rootNote = 60; // MIDI note recorded at (plays at unity here)
|
SampleLoop loop;
|
||||||
SampleLoop loop; // sustain loop, if any
|
|
||||||
// Initial read position (frame offset) a voice starts playback at — frame 0 by
|
// Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
|
||||||
// default, so an unset start point is exactly the pre-S11 behavior. S11 makes this
|
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
|
||||||
// an instrument-side per-zone override (the "start point" marker); S15 builds on it
|
|
||||||
// (both play modes carry a modifiable start). Clamped into [0, frames) at note-on:
|
|
||||||
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
|
|
||||||
std::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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
// bridge_marshal.h — PURE 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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 v1–v5 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 v1–v6 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 table — bridge-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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
// trigger_seam.h — PURE 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
|
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
||||||
// of Trigger fade lengths:
|
|
||||||
//
|
|
||||||
// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts
|
|
||||||
// that anchor directly to the voice's source-timeline read pointer.
|
|
||||||
//
|
|
||||||
// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played
|
|
||||||
// span, where the played span is:
|
|
||||||
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
|
||||||
// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes;
|
|
||||||
// the engine stores frames so the voice advances correctly at the live rate.
|
|
||||||
//
|
|
||||||
// This module owns the one shared formula so the pack (frames->fractions) and unpack
|
|
||||||
// (fractions->frames) paths are provably consistent and unit-tested independently of the shell.
|
|
||||||
// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope.
|
|
||||||
//
|
|
||||||
// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that
|
|
||||||
// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range
|
|
||||||
// invariant (a drag can never produce a value a slider couldn't reach).
|
|
||||||
|
|
||||||
#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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
// envelope_edit.h — PURE node hit-test + pixel-delta→clamped-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 params→polyline 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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
@@ -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_; }
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+48
-95
@@ -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
|
||||||
// - occupied slot, Alt held -> Replace (Alt-replace-over-occupied)
|
// Leave-client wins first, then other-bank, then same-bank-grid — so reorder can never steal a
|
||||||
// So leave-client wins first, then other-bank, then same-bank-grid = reorder/replace.
|
// bank-move or an OS-drag.
|
||||||
// This keeps the reorder gesture from ever stealing a bank-move or 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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-97
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 ~8–12
|
|
||||||
// tabs). What is NOT DAW-bound — how N fixed-width tabs tile a strip of a given
|
|
||||||
// pixel width, where the overflow chevrons sit, which tab/chevron a click lands in,
|
|
||||||
// and how far the strip may scroll — lives here so it is unit-tested outside the
|
|
||||||
// DAW (CLAUDE.md §load-bearing split). The panel shell (shell/panel/) owns the
|
|
||||||
// SWELL window, LICE drawing, and the live BankBook read; it calls into this seam
|
|
||||||
// for every rect and every hit. Mirror of mode_switch / bank_grid.
|
|
||||||
//
|
|
||||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
|
||||||
// only. Builds and unit-tests without REAPER.
|
|
||||||
|
|
||||||
#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
@@ -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
@@ -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,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
@@ -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
Reference in New Issue
Block a user