diff --git a/CLAUDE.md b/CLAUDE.md index 745d06c..4b1cc1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Repo identity and current state -**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `src/vst/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout. CONTEXT.md is the authoritative spec — settled decisions, invariants, guardrails, and not-yet-built specs; it is large, so locate the relevant phase section by grepping its headings and read only that section with an offset rather than reading it whole. Build detail for landed phases lives in CONTEXT-ARCHIVE.md. Every REAPER API name cited there is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use. A post-S-VIEW DAW-fix pass has landed (all 52 suite tests green): envelope nodes fully editable in both modes (every Gate stage A/H/D/S/R + Trigger zero-fade-out node, param-domain schematic scaling, 8 px min node separation, all nodes clamped in-canvas); gap-free per-column waveform render (`columnMinMax` homed in `peaks`, `waveformColumnCount` in `component_geometry`, shared via `drawWaveform`); `param_slider` `Knob` primitive (7→5 o'clock arc, needle, vertical-drag); zone-bleed fix 3a (`reconcileSingleCaptureZones` in `sample_map`). The voice-system redesign is also landed: `sampler_core` gains user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` retrigger/legato toggle), an isolated `PreviewCard` (dedicated preview voice outside the MIDI pool — never steals from/into it; unity-Preserve zero-latency bypass scoped to it), and two-tier panic (CC 123 = release, CC 120 = immediate hard-stop incl. Trigger one-shots); processor sums the preview card alongside the engine + drain, `retireIdleDrain()` retires fully-idle drain snapshots, and voice-param edits rebuild from the already-decoded PCM (no bank re-read/WAV re-decode) via the drain-slot swap; `ComponentState` envelope bumped v6→v7 (voiceCount/voiceMode/monoTrigger bytes; pre-v7 blobs lift to 16/Poly/Retrigger). **FB1 Sample-view recomposition (r11) has also landed** (suite 55/55 green): all linear sliders replaced by radial **knobs** in a fenced **knob deck** (groups: AMP ENVELOPE / PITCH / PITCH ENV / VOICE / MASTER); mode toggles are compact in the caption row, not full-width; the **hero waveform runs full-width** (elastic band, 840×620 default preserved); the inline velocity-curve box is replaced by a **28×28 curve preview button → centered popup** with right-click node delete; voice-band controls (count / Poly-Mono / Retrig-Legato) are placed in the VOICE deck group; a **post-mixer per-sample-ramped master gain** (−∞…+24 dB, no zipper) is placed in the MASTER deck group, persisted as `masterGainLinear` — `ComponentState` envelope bumped v7→v8 (pre-v8 blobs lift to unity gain). Three new pure `src/vst/` modules landed: `knob_deck` (group-box + caption-row + knob-cell geometry, deterministic wrap, hit-test), `curve_popup` (sheet/close/box geometry + outside-sheet dismissal test), `master_gain` (dB↔linear taper math, −∞…+24 dB). **FB2 Zone-panel parity (r11, 2026-07-28) has also landed** (suite 55/55 green): the Zone param panel now uses the same knob deck + curve-preview-button/popup grammar as the Sample face — one control grammar across both surfaces of the one per-zone storage site; Zone-authoring affordances (+Add Zone / Delete, the piano-key strip, Low/High/Root legend) are preserved; VOICE and MASTER groups remain Sample-only (per-instance). `param_slider`'s linear slider rows are retired on the Zone panel (the FA4 `Knob` primitive is now the only live consumer of that half of `param_slider`). **This completes the r11 editor recomposition (Wave B / Phase S editor redesign).** A **GA post-launch DAW-fix pass** has also landed (suite 55/55 green): `pitch_shift` rewritten from dual-tap OLA (anti-phase cancellation → spectral garbage on repitched notes) to **correlation-aligned SOLA splices** with a ratio-scaled raised-cosine fade (clean pitch shift past +24 st); `Voice::start` applies a **bounded blend** (`out*(1-w) + ref*w`, w decaying from 1.0) at takeover boundaries — mono retrig/fallback, poly at-cap steal, and preview re-trigger — superseding the earlier `(1-amp)` envelope-complement gate that zeroed the compensation on Trigger/zero-attack restarts; the output bus is now **permanently stereo** (`ChannelMode` is decode-only; the dynamic mono↔stereo bus renegotiation is deleted) with channel mode **auto-defaulting from the loaded capture** via new `ComponentState` **v9** (`channelModeExplicit` flag) + a pure `channelModeFor` helper; `SetCapture` moved to drag-arm in `bank_panel` so a first straight-out drag arms correctly; and the Design/Arrange mode-toggle action now calls `bankPanelInvalidate()` so the panel footer reflects the new mode without requiring a button click. **Preview via real MIDI note path (pS, 2026-07-28):** the dedicated `PreviewCard` is RETIRED; preview now injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` (same path host MIDI uses), so it obeys polyphony/mono/voice-stealing/envelopes; the processor no longer sums a separate preview voice; the unity-Varispeed-bypass demotion (GA2 primed shifter speaks on frame 0 anyway) is removed. **Self-contained playback (pS, 2026-07-28):** `ComponentState` bumped v9→**v10** with a `SampleRefs` table — per referenced sample, the instance owns a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent); the bank/bridge is now a browser source (loading a capture copies its reference in); the reopen-heal timer + poll-to-play apparatus are removed; pre-v10 blobs lift to empty refs and re-save self-contained. **Usage-detection (pS-usage, 2026-07-28):** new pure module `sample_usage` + REAPER-facing shell `usage_scan` implement the instance-usage wire (`rsusage_` per-instance ext-state keys); each live ReaSampler 9000 instance publishes the captures it holds (from its `SampleRefs`) at `reloadInstrument` time; the extension reads every usage record at prune-scan time, liveness-folds against the live FX enumeration, and unions the surviving held paths into the prune's referenced set — so a capture held by any live instance can never be an orphan and `BANK_PRUNE_FOLDER` can never delete it; `prune_reconcile` gains `mergeReferenced`; `persist`'s `PruneReport` gains `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) on unreadable usage; `actions` halts and prints offending keys; `reaper_bridge` gains `writeUsageExtState` (prefix-guarded — refuses non-`rsusage_` keys); `ComponentState` bumped v10→**v11** (`instanceGuid` field; pre-v11 blobs mint guid on first publish); new `sample_usage_tests` build target (pure, no REAPER/DAW). +**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout. CONTEXT.md is the authoritative spec — settled decisions, invariants, guardrails, and not-yet-built specs; it is large, so locate the relevant phase section by grepping its headings and read only that section with an offset rather than reading it whole. Build detail for landed phases lives in CONTEXT-ARCHIVE.md. Every REAPER API name cited there is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use. A post-S-VIEW DAW-fix pass has landed (all 52 suite tests green): envelope nodes fully editable in both modes (every Gate stage A/H/D/S/R + Trigger zero-fade-out node, param-domain schematic scaling, 8 px min node separation, all nodes clamped in-canvas); gap-free per-column waveform render (`columnMinMax` homed in `peaks`, `waveformColumnCount` in `component_geometry`, shared via `drawWaveform`); `param_slider` `Knob` primitive (7→5 o'clock arc, needle, vertical-drag); zone-bleed fix 3a (`reconcileSingleCaptureZones` in `sample_map`). The voice-system redesign is also landed: `sampler_core` gains user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` retrigger/legato toggle), an isolated `PreviewCard` (dedicated preview voice outside the MIDI pool — never steals from/into it; unity-Preserve zero-latency bypass scoped to it), and two-tier panic (CC 123 = release, CC 120 = immediate hard-stop incl. Trigger one-shots); processor sums the preview card alongside the engine + drain, `retireIdleDrain()` retires fully-idle drain snapshots, and voice-param edits rebuild from the already-decoded PCM (no bank re-read/WAV re-decode) via the drain-slot swap; `ComponentState` envelope bumped v6→v7 (voiceCount/voiceMode/monoTrigger bytes; pre-v7 blobs lift to 16/Poly/Retrigger). **FB1 Sample-view recomposition (r11) has also landed** (suite 55/55 green): all linear sliders replaced by radial **knobs** in a fenced **knob deck** (groups: AMP ENVELOPE / PITCH / PITCH ENV / VOICE / MASTER); mode toggles are compact in the caption row, not full-width; the **hero waveform runs full-width** (elastic band, 840×620 default preserved); the inline velocity-curve box is replaced by a **28×28 curve preview button → centered popup** with right-click node delete; voice-band controls (count / Poly-Mono / Retrig-Legato) are placed in the VOICE deck group; a **post-mixer per-sample-ramped master gain** (−∞…+24 dB, no zipper) is placed in the MASTER deck group, persisted as `masterGainLinear` — `ComponentState` envelope bumped v7→v8 (pre-v8 blobs lift to unity gain). Three new pure `src/vst/` modules landed: `knob_deck` (group-box + caption-row + knob-cell geometry, deterministic wrap, hit-test), `curve_popup` (sheet/close/box geometry + outside-sheet dismissal test), `master_gain` (dB↔linear taper math, −∞…+24 dB). **FB2 Zone-panel parity (r11, 2026-07-28) has also landed** (suite 55/55 green): the Zone param panel now uses the same knob deck + curve-preview-button/popup grammar as the Sample face — one control grammar across both surfaces of the one per-zone storage site; Zone-authoring affordances (+Add Zone / Delete, the piano-key strip, Low/High/Root legend) are preserved; VOICE and MASTER groups remain Sample-only (per-instance). `param_slider`'s linear slider rows are retired on the Zone panel (the FA4 `Knob` primitive is now the only live consumer of that half of `param_slider`). **This completes the r11 editor recomposition (Wave B / Phase S editor redesign).** A **GA post-launch DAW-fix pass** has also landed (suite 55/55 green): `pitch_shift` rewritten from dual-tap OLA (anti-phase cancellation → spectral garbage on repitched notes) to **correlation-aligned SOLA splices** with a ratio-scaled raised-cosine fade (clean pitch shift past +24 st); `Voice::start` applies a **bounded blend** (`out*(1-w) + ref*w`, w decaying from 1.0) at takeover boundaries — mono retrig/fallback, poly at-cap steal, and preview re-trigger — superseding the earlier `(1-amp)` envelope-complement gate that zeroed the compensation on Trigger/zero-attack restarts; the output bus is now **permanently stereo** (`ChannelMode` is decode-only; the dynamic mono↔stereo bus renegotiation is deleted) with channel mode **auto-defaulting from the loaded capture** via new `ComponentState` **v9** (`channelModeExplicit` flag) + a pure `channelModeFor` helper; `SetCapture` moved to drag-arm in `bank_panel` so a first straight-out drag arms correctly; and the Design/Arrange mode-toggle action now calls `bankPanelInvalidate()` so the panel footer reflects the new mode without requiring a button click. **Preview via real MIDI note path (pS, 2026-07-28):** the dedicated `PreviewCard` is RETIRED; preview now injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` (same path host MIDI uses), so it obeys polyphony/mono/voice-stealing/envelopes; the processor no longer sums a separate preview voice; the unity-Varispeed-bypass demotion (GA2 primed shifter speaks on frame 0 anyway) is removed. **Self-contained playback (pS, 2026-07-28):** `ComponentState` bumped v9→**v10** with a `SampleRefs` table — per referenced sample, the instance owns a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent); the bank/bridge is now a browser source (loading a capture copies its reference in); the reopen-heal timer + poll-to-play apparatus are removed; pre-v10 blobs lift to empty refs and re-save self-contained. **Usage-detection (pS-usage, 2026-07-28):** new pure module `sample_usage` + REAPER-facing shell `usage_scan` implement the instance-usage wire (`rsusage_` per-instance ext-state keys); each live ReaSampler 9000 instance publishes the captures it holds (from its `SampleRefs`) at `reloadInstrument` time; the extension reads every usage record at prune-scan time, liveness-folds against the live FX enumeration, and unions the surviving held paths into the prune's referenced set — so a capture held by any live instance can never be an orphan and `BANK_PRUNE_FOLDER` can never delete it; `prune_reconcile` gains `mergeReferenced`; `persist`'s `PruneReport` gains `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) on unreadable usage; `actions` halts and prints offending keys; `reaper_bridge` gains `writeUsageExtState` (prefix-guarded — refuses non-`rsusage_` keys); `ComponentState` bumped v10→**v11** (`instanceGuid` field; pre-v11 blobs mint guid on first publish); new `sample_usage_tests` build target (pure, no REAPER/DAW). **Phase Q (2026-07-29, landed on `phase-q`)** reorganized all of `src/` into `core/` (pure, subsystem-namespaced: `model`/`view`/`capture`/`audio`/`ui`/`reclaim`/`version`/`json`/`util`/`wire`/`instrument/{engine,map,ui}`), `shell/` (REAPER/host-facing: `capture`/`panel`/`view`/`persist`/`actions`/`instrument`/`bank_ops`), and `app/` (`main.cpp`), splitting several god-modules along the way (`bank_panel` into eight `shell/panel/` TUs, the VST3 processor/editor god-TUs, `capture.cpp`, and `persist.cpp`) under a soft ~600-line-per-TU ceiling; `src/vst/` no longer exists. ## One-time submodule setup @@ -54,6 +54,11 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde ## Architecture: the load-bearing split **Pure core (no REAPER types, unit-testable outside the DAW):** +- `json` (`core/json`) — the ONE hand-rolled JSON lexical layer (Q-W1): string/number/bool/null tokens, the scoped object `Writer`, and the bounds-checked `Reader` cursor, byte-compatible with the five pre-extraction per-module writers it replaced (`bank_model` / `bank_book` / `view_mode_model` / `owned_manifest` / `tail_control`). Domain grammars stay in the consumers; this owns lexing/emitting only. +- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory. +- `file_bytes` (`core/util`) — the ONE whole-file byte loader (Q-W1), linked by both artifacts; blocking I/O, off-audio-thread only. +- `clamp01` (`core/util`, header-only) — the ONE unit-interval clamp (Q-W1), replacing four per-module static copies; NaN passes through unchanged rather than collapsing to a bound. +- `rect` (`core/ui`, header-only) — the ONE concrete pixel rectangle (Q-W1): XYWH storage + `right()`/`bottom()`/`ltrb()`/`contains()`, replacing 12+ byte-identical role structs (`ButtonRect`/`FooterRect`/`CellRect`/`KitBox`/…) and the VST side's separate LTRB `Rect`; every prior role name survives as a `using` alias at its old site (e.g. `editor_geometry::Rect`). - `bank_model` — `Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart. - `peaks` — waveform min/max bin computation from raw PCM; does not depend on REAPER's peak API. - `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, JSON round-trip. @@ -61,10 +66,12 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `bank_grid` — REAPER-free grid layout, selection, keyboard-nav, and thumbnail-cache-key logic for the docked bank panel. - `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip. - `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch. -- `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, index-only move/copy/remove of a sample between banks, and JSON round-trip. +- `slot_map` (`core/model`) — the gap-preserving display-position carrier for ONE bank (sample id → slot, ≥0), extracted from `bank_book` (Q-W1): append/remove/reorder (insert-before-and-shift)/`reconcile` against live membership, `resetDense` migration seed, JSON round-trip. Wrapped (not merged) by `bank_book`. +- `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface. - `owned_manifest` — the set of project-relative files the capture path itself created, persisted under the `"owned_files"` ext-state key, so the prune path can distinguish the bank system's own orphans from hand-dropped files. - `app_version` — REAPER-free version/channel identity: CMake-sourced semver constant, ext-state stamp value, and the full set of channel-derived identity accessors. All channel strings derive from one `REASAMPLER_CHANNEL_IS_BETA` bit; no scattered `#ifdef`s in the shells. -- `wav_trim` — 32-bit-float WAV parse + header-aware truncate plan for the realtime tail's PCM decay-scan trim. +- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner). +- `capture_realtime` (`core/capture`, **renamed from `realtime_record` in Q-W3** — the Q-9 naming rider: pure module takes the stem, the shell takes the suffix, matching `drag_out`/`drag_out_win`) — the M8 realtime-record pure logic: capture scope + FX-tap point → `I_RECMODE`/`I_RECMODE_FLAGS` values, wet/dry → tap point, the recorded-file → `Sample` mapping, and the async record-phase state machine. Depends on `bank_model` for the plain `Sample`/`SourceMode` types. The transport/temp-track/send recipe lives in the shell (`shell/capture/capture_realtime_shell.cpp` + `capture_realtime_finalize.cpp`). - `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** - `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) − referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. Gains `mergeReferenced(bankRefs, liveInstanceHeldPaths)` (pS-usage) — unions live instance holds into the prune referenced-set so the pure orphan computation includes them. - `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button. @@ -85,10 +92,17 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. **REAPER-facing shells:** -- `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. +- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. +- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). +- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + owned-manifest record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places). +- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action. +- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload. +- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26). +- `capture_realtime_finalize` (`shell/capture`) — the file-side half of the realtime-record shell (Q-W3, T4-08): discovers the file REAPER actually recorded, moves it into the bank, runs the Auto-tail PCM decay-scan trim, and populates the finished `Sample`. - `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** -- `bank_panel` — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. -- `persist` — project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, and writing-version stamp. A `projectconfig` hook triggers a deferred session reload on undo/redo. Hosts the prune dry-run and full-set orphan queries; supplies `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core. **pS-usage:** `scanPruneOrphans` unions instance usage via `usage_scan`; `PruneReport` gains `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable. +- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into eight TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU. +- `bank_ops` (`shell/bank_ops`) — the promptless bank-mutation verb seam (Q-W6 lift out of `panel_bank_ops`): `bankOpCreate`/`Rename`/`Delete`/`Evacuate`/`Activate`/`Transfer`/`Remove` + `persistBankOp` (the undo-batched ext-state persist), each taking a `ReaSamplerSession&` and returning whether the model accepted the mutation — no prompts, no message boxes, no panel-state reads. `shell/panel/panel_bank_ops` (menu/prompt UX) and `shell/actions/bank_actions` (bindable-action UX) both consume these as thin skins, so the mutation logic has exactly one home. +- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `prune_fs` hosts the prune dry-run / full-set orphan queries (supplying `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. **pS-usage:** the prune scan unions instance usage via `usage_scan`; `PruneReport` carries `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable. - `usage_scan` — extension-side prune-scan shell (pS-usage): at prune-scan time, enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and folds with `sample_usage::foldUsageRecords` / `usageHeldPaths` to produce the set of held paths — or `abortPrune` when any record is unreadable (fail-safe: an unreadable record may protect anything, so the prune halts). Feeds `prune_reconcile::mergeReferenced`. Read-only: writes no ext-state. - `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. **Never touches master or `B_MUTE`/`I_SOLO`.** - `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys. @@ -97,15 +111,16 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** - `instrument_drop_win` — FX-button drop shell: resolves a screen point to a track + FX-surface hotspot, then adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.** - `draw_kit` — shared LICE draw shell: `fillSurface`, `drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`, cached-font `text()`, full interaction-state model, double-buffer preserved. Consumes `theme` + `component_geometry`. -- `actions` — registers the capture/placement/slot, Design View, multi-bank, and prune action families; routes each via the `command_id`/`gaccel`/`hookcommand` contract. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`BANK_PRUNE_FOLDER`) is **the ONLY file-deletion authority in the system**; it opens no undo point (file deletion is not REAPER-undoable). **pS-usage:** `BANK_PRUNE_FOLDER` halts on `abortedUnreadableUsage` and prints the offending `rsusage_*` key names with clear instructions. +- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). **pS-usage:** `BANK_PRUNE_FOLDER` halts on `abortedUnreadableUsage` and prints the offending `rsusage_*` key names with clear instructions. -**VST3 instrument (`src/vst/`) — pure core:** +**VST3 instrument (`core/instrument/{engine,map,ui}`, plus shared substrate in `core/{audio,ui,wire}`) — pure core:** - `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato toggle), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots); per-zone `ZonePlayParams` (Gate/Trigger, AHDSR, pitch engine Varispeed/Preserve, AD pitch mod envelope), repitch/interpolation with loop-point-aware sustain. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes. - `sample_map` — zone payload: zones keyed by note range. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). JSON round-trip. +- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + zones-payload binary codec (envelope v1…v11, zones-payload v1…v7), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine (`sampler_core`/`pitch_shift`) to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. `zone_params.h` (`core/instrument/engine`) is the sibling header split out of `sampler_core.h` (T4-14/T4-17): the per-zone play-parameter value structs (`ZonePlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`) and the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`) the engine, the codec, and the editor all share. - `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. - `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects. - `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer. -- `editor_geometry` — VST3 editor layout: defines the shared `Rect` type + `contains()` hit-test; provides `EditorLayout` and `layoutEditor(w,h)`. +- `editor_geometry` (`core/instrument/ui`) — VST3 editor layout: aliases the shared `core::ui::Rect` (+ `contains()`) rather than defining its own; owns `EditorLayout`/`layoutEditor(w,h)`, the Tier-0/Tier-1 sample-list and keymap-editor row layout/hit-test, and — hoisted here off the former `reasampler_editor.cpp` god-TU (Q-W2v, T2-06) — the r11 Sample-face band layout (`SampleBands`/`ClusterRects`/`channelToggleRects`) and the Zone-face content/legend/deck layout, so the editor shell only draws + routes. - `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, zone highlight overlay geometry. - `waveform_view` — waveform/marker geometry: maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap. - `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing. @@ -120,10 +135,10 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. - `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge. -**VST3 instrument (`src/vst/`) — shells:** +**VST3 instrument (`shell/instrument/`) — shells:** - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant. -- `reasampler_processor` — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded keymap via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. -- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; default face is the capture browser, then single-capture setup, with opt-in zones panel. Drop-onto-editor ingest is NOT shipped (deferred). +- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded keymap via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. +- `reasampler_editor` (`shell/instrument/`: eight face-axis TUs — `editor_session` session/bridge state, `editor_controls` parameter plumbing, `editor_paint_sample`/`editor_paint_browse_zone` paint, `editor_input_sample`/`editor_input_browse_zone` input, `editor_platform` IPlugView/Win32 window plumbing, plus the pure `editor_geometry` layout hoist as the eighth axis; shared internals in `editor_internal.h`, no TU of its own — Q-W2v, T4-11 split of the former god-TU) — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; default face is the capture browser, then single-capture setup, with opt-in zones panel. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout/hit-test to `embed_strip`. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. @@ -131,7 +146,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - Exactly **one** translation unit defines `REAPERAPI_IMPLEMENT` — that is `main.cpp`. Every other `.cpp` includes `reaper_plugin_functions.h` without the define and gets `extern` declarations for the global API function pointers. - REAPER dlopen()s any `reaper_*.dll|dylib|so` found in `UserPlugins/` and calls the `ReaperPluginEntry` export (produced by `REAPER_PLUGIN_ENTRYPOINT`). `rec->GetFunc` resolves API pointers; `rec->Register` plugs extension callbacks in. -- Action registration pattern (preserve this for all new actions): +- Action registration pattern (preserve this for all new actions). Since Q-W6, `main.cpp`'s own action family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is driven by a data-driven table in `shell/actions/action_registry` (`ActionTableRow`: suffix, phrase, flat function-pointer handler) — registration, `hookcommand` dispatch, and the unload mirror-unregister all iterate that SAME table, so adding an action touches one table row rather than three separate mechanisms. The other action families (`shell/actions/design_view_actions`, `bank_actions`, `prune_action`) register through their own TUs the same way. The per-step contract below is unchanged: 1. `rec->Register("command_id", (void*)"STABLE_FOREVER_STRING")` — mints a persistent command id. **Never change this string after shipping**; user keybindings key off it. Since Phase V (V4), ids and display names are composed via `channelCommandId(suffix)` and `channelActionName(phrase)` from `app_version` — the FOREVER-STABLE contract applies per channel (stable and beta each have their own permanent id family). 2. `rec->Register("gaccel", &accel)` — puts the action in the Actions list. 3. `rec->Register("hookcommand", ...)` — receives every action fired; claim only your own id, return `false` otherwise. diff --git a/CMakeLists.txt b/CMakeLists.txt index 12c69f8..6a21685 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,7 +69,7 @@ endif() # Regenerated at configure time whenever either changes; the exact string is substituted # verbatim and the channel bit fans out through app_version. configure_file( - ${CMAKE_CURRENT_SOURCE_DIR}/src/version_generated.h.in + ${CMAKE_CURRENT_SOURCE_DIR}/src/core/version/version_generated.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/version_generated.h @ONLY) @@ -82,18 +82,40 @@ set(SDK_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/reaper-sdk/sdk) set(WDL_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/WDL/WDL) set(SWELL ${WDL_INC}/swell) +# --------------------------------------------------------------------------- +# 0) core/ — the Q-W1 shared pure substrate. NO REAPER, NO SWELL, NO VST3. +# json: the ONE JSON lexical layer (reader + writer) behind the five +# persisted-blob (de)serializers (bank_model / bank_book / +# view_mode_model / owned_manifest / tail_control). T2-02 / §2. +# wire: the ONE length-prefixed ext-state wire codec (putField + Cursor + +# the guarded decimal accumulate) behind provenance / +# assignment_request / sample_usage / bank_sync. T2-01(b). +# file_bytes: the ONE whole-file byte loader both artifacts link. T2-03. +# Headers are included as "core/json/json.h" etc. (rooted at src/), so the +# include paths survive the Q-W1 part-2 directory relocation unchanged. +# --------------------------------------------------------------------------- +add_library(json STATIC src/core/json/json.cpp) +target_include_directories(json PUBLIC src) + +add_library(wire STATIC src/core/wire/wire.cpp) +target_include_directories(wire PUBLIC src) + +add_library(file_bytes STATIC src/core/util/file_bytes.cpp) +target_include_directories(file_bytes PUBLIC src) + # --------------------------------------------------------------------------- # 1) Pure model library — NO REAPER, NO SWELL. Builds & tests anywhere. # The sampler's heart: Sample metadata + BankIndex (Milestone 1). # --------------------------------------------------------------------------- -add_library(bank_model STATIC src/bank_model.cpp) +add_library(bank_model STATIC src/core/model/bank_model.cpp) target_include_directories(bank_model PUBLIC src) +target_link_libraries(bank_model PRIVATE json) # --------------------------------------------------------------------------- # 2) Pure peaks library — NO REAPER, NO SWELL. Waveform min/max thumbnails from # raw PCM (Milestone 2). A sibling pure lib, kept distinct from bank_model. # --------------------------------------------------------------------------- -add_library(peaks STATIC src/peaks.cpp) +add_library(peaks STATIC src/core/audio/peaks.cpp) target_include_directories(peaks PUBLIC src) # --------------------------------------------------------------------------- @@ -101,7 +123,7 @@ target_include_directories(peaks PUBLIC src) # file-name / project-relative path derivation for the capture shell (M3). # Split out so the fiddly path logic is unit-tested outside the DAW. # --------------------------------------------------------------------------- -add_library(capture_paths STATIC src/capture_paths.cpp) +add_library(capture_paths STATIC src/core/capture/capture_paths.cpp) target_include_directories(capture_paths PUBLIC src) # --------------------------------------------------------------------------- @@ -110,7 +132,7 @@ target_include_directories(capture_paths PUBLIC src) # cache key for the docked bank_panel (M5). Split out so the layout logic is # unit-tested outside the DAW; the panel shell (SWELL/LICE/PCM) is DAW-verified. # --------------------------------------------------------------------------- -add_library(bank_grid STATIC src/bank_grid.cpp) +add_library(bank_grid STATIC src/core/ui/bank_grid.cpp) target_include_directories(bank_grid PUBLIC src) # --------------------------------------------------------------------------- @@ -120,7 +142,7 @@ target_include_directories(bank_grid PUBLIC src) # unit-tested outside the DAW; the bank_panel header strip that draws it and # routes clicks to view::applyMode is DAW-verified. Mirror of bank_grid. # --------------------------------------------------------------------------- -add_library(mode_switch STATIC src/mode_switch.cpp) +add_library(mode_switch STATIC src/core/view/mode_switch.cpp) target_include_directories(mode_switch PUBLIC src) # --------------------------------------------------------------------------- @@ -131,7 +153,7 @@ target_include_directories(mode_switch PUBLIC src) # overflow/scroll math is unit-tested outside the DAW; the bank_panel region # that draws it and routes clicks is DAW-verified. Mirror of mode_switch. # --------------------------------------------------------------------------- -add_library(tab_strip STATIC src/tab_strip.cpp) +add_library(tab_strip STATIC src/core/ui/tab_strip.cpp) target_include_directories(tab_strip PUBLIC src) # --------------------------------------------------------------------------- @@ -140,8 +162,9 @@ target_include_directories(tab_strip PUBLIC src) # visibility derivation + parking/restore planner + JSON round-trip. Mirror of # bank_model; the folder tree is an INPUT supplied by the D2 shell. # --------------------------------------------------------------------------- -add_library(view_mode_model STATIC src/view_mode_model.cpp) +add_library(view_mode_model STATIC src/core/view/view_mode_model.cpp) target_include_directories(view_mode_model PUBLIC src) +target_link_libraries(view_mode_model PRIVATE json) # The pure lane-minting decision (planLaneMinting) names managed lanes via the ONE # durable-key convention in lane_keys (laneNameForMode), so the model depends on that # pure sibling. PUBLIC so every consumer (tests + module) resolves the symbol. @@ -154,7 +177,7 @@ target_link_libraries(view_mode_model PUBLIC lane_keys) # view.cpp; this fiddly folder-depth walk is unit-tested here (mirrors # capture_paths splitting the path math out of the capture shell). # --------------------------------------------------------------------------- -add_library(view_tree STATIC src/view_tree.cpp) +add_library(view_tree STATIC src/core/view/view_tree.cpp) target_include_directories(view_tree PUBLIC src) target_link_libraries(view_tree PUBLIC view_mode_model) @@ -166,7 +189,7 @@ target_link_libraries(view_tree PUBLIC view_mode_model) # live track/item GUID set and applies the tags is DAW-verified. Mirror of # view_tree splitting the folder-depth walk out of view.cpp. # --------------------------------------------------------------------------- -add_library(guid_diff STATIC src/guid_diff.cpp) +add_library(guid_diff STATIC src/core/view/guid_diff.cpp) target_include_directories(guid_diff PUBLIC src) # --------------------------------------------------------------------------- @@ -177,7 +200,7 @@ target_include_directories(guid_diff PUBLIC src) # exemption) and #2 (name-keyed identity survives ordinal renumber). Split out # so the prefix rule is unit-tested; view.cpp reads the names from REAPER. # --------------------------------------------------------------------------- -add_library(lane_keys STATIC src/lane_keys.cpp) +add_library(lane_keys STATIC src/core/view/lane_keys.cpp) target_include_directories(lane_keys PUBLIC src) # --------------------------------------------------------------------------- @@ -186,7 +209,7 @@ target_include_directories(lane_keys PUBLIC src) # load-bearing bit computation (no silent stretch, opt-in conform) is # unit-tested outside the DAW; the InsertMedia call itself is DAW-verified. # --------------------------------------------------------------------------- -add_library(insert_plan STATIC src/insert_plan.cpp) +add_library(insert_plan STATIC src/core/capture/insert_plan.cpp) target_include_directories(insert_plan PUBLIC src) # --------------------------------------------------------------------------- @@ -197,7 +220,7 @@ target_include_directories(insert_plan PUBLIC src) # the DAW; the render-driving + selection reads stay in capture.cpp / main.cpp. # Depends on bank_model for the pure SourceMode enum. # --------------------------------------------------------------------------- -add_library(render_settings STATIC src/render_settings.cpp) +add_library(render_settings STATIC src/core/capture/render_settings.cpp) target_include_directories(render_settings PUBLIC src) target_link_libraries(render_settings PUBLIC bank_model) @@ -210,7 +233,7 @@ target_link_libraries(render_settings PUBLIC bank_model) # the selection read, transient re-selection, and render loop stay in main.cpp. # No dependency on bank_model — it takes plain ranges/values at its boundary. # --------------------------------------------------------------------------- -add_library(batch_capture STATIC src/batch_capture.cpp) +add_library(batch_capture STATIC src/core/capture/batch_capture.cpp) target_include_directories(batch_capture PUBLIC src) # --------------------------------------------------------------------------- @@ -221,9 +244,20 @@ target_include_directories(batch_capture PUBLIC src) # outside the DAW; the bank_panel footer that draws it + routes clicks is # DAW-verified. Depends on render_settings for the pure TailMode enum + caps. # --------------------------------------------------------------------------- -add_library(tail_control STATIC src/tail_control.cpp) +add_library(tail_control STATIC src/core/capture/tail_control.cpp) target_include_directories(tail_control PUBLIC src) target_link_libraries(tail_control PUBLIC render_settings) +target_link_libraries(tail_control PRIVATE json) + +# --------------------------------------------------------------------------- +# 2g''') Pure slot_map library — NO REAPER, NO SWELL. The L7 gap-preserving +# display-position carrier for one bank (extracted from bank_book, Q-W1 +# T4-05): sample id -> slot, gap-preserving append/remove/reorder/reconcile, +# JSON round-trip. Mirror of bank_model; wrapped (not merged) by bank_book. +# --------------------------------------------------------------------------- +add_library(slot_map STATIC src/core/model/slot_map.cpp) +target_include_directories(slot_map PUBLIC src) +target_link_libraries(slot_map PRIVATE json) # --------------------------------------------------------------------------- # 2g') Pure bank_book library — NO REAPER, NO SWELL. The multi-bank phase heart @@ -233,9 +267,13 @@ target_link_libraries(tail_control PUBLIC render_settings) # sample between banks, JSON round-trip + legacy-bank_index→pool migration. # Mirror of bank_model / view_mode_model; wraps BankIndex (bank_model untouched). # --------------------------------------------------------------------------- -add_library(bank_book STATIC src/bank_book.cpp) +add_library(bank_book STATIC + src/core/model/bank_book.cpp + src/core/model/bank_book_json.cpp) target_include_directories(bank_book PUBLIC src) target_link_libraries(bank_book PUBLIC bank_model) +target_link_libraries(bank_book PUBLIC slot_map) +target_link_libraries(bank_book PRIVATE json) # --------------------------------------------------------------------------- # 2g'') Pure owned_manifest library — NO REAPER, NO SWELL. The owned-file manifest @@ -243,11 +281,12 @@ target_link_libraries(bank_book PUBLIC bank_model) # itself created, so Phase R prune can tell the bank system's own orphans from # hand-dropped files. Deliberately DECOUPLED from bank_book — it tracks files # CREATED, not index membership (sample-remove is not manifest-remove). Small -# pure type + JSON round-trip; mirror of wav_trim / tab_strip. B-cap writes + +# pure type + JSON round-trip; mirror of wav_codec / tab_strip. B-cap writes + # persists it; Phase R (R1/R2) consumes it — no prune logic here. # --------------------------------------------------------------------------- -add_library(owned_manifest STATIC src/owned_manifest.cpp) +add_library(owned_manifest STATIC src/core/model/owned_manifest.cpp) target_include_directories(owned_manifest PUBLIC src) +target_link_libraries(owned_manifest PRIVATE json) # --------------------------------------------------------------------------- # 2g''') Pure prune_reconcile library — NO REAPER, NO SWELL, NO filesystem. The @@ -258,7 +297,7 @@ target_include_directories(owned_manifest PUBLIC src) # pure function; R2/R3 wrap the two ends (folder enumeration + deletion) in the # shell. Standalone — depends only on the standard library. # --------------------------------------------------------------------------- -add_library(prune_reconcile STATIC src/prune_reconcile.cpp) +add_library(prune_reconcile STATIC src/core/reclaim/prune_reconcile.cpp) target_include_directories(prune_reconcile PUBLIC src) # --------------------------------------------------------------------------- @@ -269,34 +308,39 @@ target_include_directories(prune_reconcile PUBLIC src) # outside the DAW; the bank_panel footer that draws it and dispatches the # "Prune bank folder" command is DAW-verified. Mirror of mode_switch / tab_strip. # --------------------------------------------------------------------------- -add_library(prune_button STATIC src/prune_button.cpp) +add_library(prune_button STATIC src/core/ui/prune_button.cpp) target_include_directories(prune_button PUBLIC src) # --------------------------------------------------------------------------- -# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record -# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values, -# wet/dry -> tap point, and the recorded-file -> Sample mapping. Split out so -# the fiddly record-mode bit values + Sample population are unit-tested outside -# the DAW; the transport/temp-track/send/file-move recipe stays in capture.cpp. +# 2g) Pure capture_realtime library — NO REAPER, NO SWELL. (Renamed from +# realtime_record in Q-W3 — the Q-9 naming rider: pure module takes the stem, +# the shell takes the suffix, matching drag_out <-> drag_out_win.) The M8 +# realtime-record logic: capture scope + FX-tap point -> I_RECMODE / +# I_RECMODE_FLAGS values, wet/dry -> tap point, the recorded-file -> Sample +# mapping, and the async record-phase state machine. Split out so the fiddly +# record-mode bit values + Sample population + phase transitions are +# unit-tested outside the DAW; the transport/temp-track/send recipe stays in +# capture_realtime_shell.cpp (+ the file-side capture_realtime_finalize.cpp). # Depends on bank_model for the pure Sample / SourceMode types. # --------------------------------------------------------------------------- -add_library(realtime_record STATIC src/realtime_record.cpp) -target_include_directories(realtime_record PUBLIC src) -target_link_libraries(realtime_record PUBLIC bank_model) +add_library(capture_realtime STATIC src/core/capture/capture_realtime.cpp) +target_include_directories(capture_realtime PUBLIC src) +target_link_libraries(capture_realtime PUBLIC bank_model) # --------------------------------------------------------------------------- -# 2h) Pure wav_trim library — NO REAPER, NO SWELL. The realtime tail's (T2) PCM -# decay-scan trim needs to TRUNCATE the recorded 32-bit-float WAV at a frame -# boundary without corrupting the RIFF container. This module holds the fiddly, -# easy-to-get-wrong part unit-tested outside the DAW: parse the WAV geometry -# (fmt/data chunk walk + 32-bit-float verification), extract the tail-region -# floats to scan, and compute the truncate plan (kept byte length + the two -# patched RIFF/data size fields). The file read/write/truncate I/O stays in the -# realtime shell. Depends on peaks for the AudioSample float alias. +# 2h) Pure wav_codec library — NO REAPER, NO SWELL. The ONE owner of the WAV/RIFF +# byte format (Q-W3, audit §4e: T2-08 / T4-10 / T4-23 consolidation): the RIFF +# chunk walker + layout parse (formerly wav_trim), the tail-trim truncate plan + +# size-field patch (formerly duplicated in capture_realtime), the float32 WAV +# build (formerly hand-rolled in ingest), and the WAV-aware content hashes +# (formerly in capture_paths). The dedup-by-hash and null-test invariants rest +# on this one implementation. File I/O stays in the shells. Depends on peaks +# for the AudioSample float alias. (The transitional `wav_trim` alias was +# retired in Q-W6 — every includer points here directly.) # --------------------------------------------------------------------------- -add_library(wav_trim STATIC src/wav_trim.cpp) -target_include_directories(wav_trim PUBLIC src) -target_link_libraries(wav_trim PUBLIC peaks) +add_library(wav_codec STATIC src/core/capture/wav_codec.cpp) +target_include_directories(wav_codec PUBLIC src) +target_link_libraries(wav_codec PUBLIC peaks) # --------------------------------------------------------------------------- # 2i) Pure app_version library — NO REAPER, NO SWELL. The Phase V (V1) version-identity @@ -308,7 +352,7 @@ target_link_libraries(wav_trim PUBLIC peaks) # DAW; the ext-state write/read (persist) and the show-version action (main) are shell. # Depends on the generated header in the build tree (PUBLIC so every consumer sees it). # --------------------------------------------------------------------------- -add_library(app_version STATIC src/app_version.cpp) +add_library(app_version STATIC src/core/version/app_version.cpp) target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) # --------------------------------------------------------------------------- @@ -318,11 +362,12 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge # FX-chain identity fold, and the pure parent-detection decision (resample-from- # sample by resolved file path). Split out so the encoding + decision logic are # unit-tested outside the DAW; the FX-chain query, capture re-run, and action -# registration stay in the shell (main.cpp / actions.cpp). No dependency on +# registration stay in the shell (main.cpp / shell/actions/). No dependency on # bank_model — it takes plain strings/values at its boundary. # --------------------------------------------------------------------------- -add_library(provenance STATIC src/provenance.cpp) +add_library(provenance STATIC src/core/model/provenance.cpp) target_include_directories(provenance PUBLIC src) +target_link_libraries(provenance PRIVATE wire) # --------------------------------------------------------------------------- # 2j') Pure assignment_request library — NO REAPER, NO SWELL, NO VST3. The S8 ingest @@ -334,8 +379,9 @@ target_include_directories(provenance PUBLIC src) # depend on is unit-tested outside the DAW; the reader lands in a separate artifact, # so the round-trip test is the contract guard. No dependency — plain strings + int64. # --------------------------------------------------------------------------- -add_library(assignment_request STATIC src/assignment_request.cpp) +add_library(assignment_request STATIC src/core/wire/assignment_request.cpp) target_include_directories(assignment_request PUBLIC src) +target_link_libraries(assignment_request PRIVATE wire) # --------------------------------------------------------------------------- # 2j'') Pure sample_usage library — NO REAPER, NO SWELL, NO VST3. The pS-usage seam: @@ -347,8 +393,9 @@ target_include_directories(assignment_request PUBLIC src) # matcher. # Linked by BOTH artifacts — the mirror of assignment_request, reversed direction. # --------------------------------------------------------------------------- -add_library(sample_usage STATIC src/sample_usage.cpp) +add_library(sample_usage STATIC src/core/wire/sample_usage.cpp) target_include_directories(sample_usage PUBLIC src) +target_link_libraries(sample_usage PRIVATE wire) # --------------------------------------------------------------------------- # 2l) Pure drag_out library — NO REAPER, NO SWELL, NO OS/OLE. The Milestone 11 @@ -361,7 +408,7 @@ target_include_directories(sample_usage PUBLIC src) # initiation (drag_out_win) and the bank_panel gesture hook are DAW-verified. Mirror # of mode_switch. # --------------------------------------------------------------------------- -add_library(drag_out STATIC src/drag_out.cpp) +add_library(drag_out STATIC src/core/ui/drag_out.cpp) target_include_directories(drag_out PUBLIC src) # --------------------------------------------------------------------------- @@ -371,18 +418,19 @@ target_include_directories(drag_out PUBLIC src) # TrackFX_SetPreset so a freshly-added ReaSampler 9000 plays that capture (the # former "vst_chunk" named-config-parm write fed REAPER raw component bytes its # VST3 wrapper framing cannot apply — the blank-on-drop regression). Reuses the -# instrument's OWN serializer (sample_map::serializeComponentState) — NOT a -# parallel byte writer — so the cross-artifact contract cannot drift; links -# sample_map (which pulls bank_book/wav_trim/sampler_core transitively) and +# instrument's OWN serializer (component_state_io::serializeComponentState) — NOT +# a parallel byte writer — so the cross-artifact contract cannot drift; links +# component_state_io (Q-W2v codec split, T4-13 ≡ T2-07: the extension no longer +# links sampler_core/pitch_shift object code to serialize one preset blob) and # NEITHER SDK. The class-ID string derives from the FROZEN UID macros -# (src/vst/reasampler_uid.h, SDK-free), channel-selected via the generated +# (src/core/wire/reasampler_uid.h, SDK-free), channel-selected via the generated # version header — hence the generated include dir. The round-trip test parses # the container and decodes back through the instrument's own reader. Mirror of # assignment_request. # --------------------------------------------------------------------------- -add_library(instrument_drop STATIC src/instrument_drop.cpp) -target_include_directories(instrument_drop PUBLIC src src/vst ${CMAKE_CURRENT_BINARY_DIR}/generated) -target_link_libraries(instrument_drop PUBLIC sample_map) +add_library(instrument_drop STATIC src/core/wire/instrument_drop.cpp) +target_include_directories(instrument_drop PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) +target_link_libraries(instrument_drop PUBLIC component_state_io) # --------------------------------------------------------------------------- # 2m) Pure theme library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) palette @@ -394,7 +442,7 @@ target_link_libraries(instrument_drop PUBLIC sample_map) # switching the visual direction is a one-file edit. The draw shell (draw_kit) turns # a KitColor into a LICE_pixel at the boundary. Mirror of mode_switch — pure. # --------------------------------------------------------------------------- -add_library(theme STATIC src/theme.cpp) +add_library(theme STATIC src/core/ui/theme.cpp) target_include_directories(theme PUBLIC src) # --------------------------------------------------------------------------- @@ -407,7 +455,7 @@ target_include_directories(theme PUBLIC src) # SliderGeometry/ListRowBox avoid the existing ButtonRect/CellRect collisions. # Mirror of prune_button — pure, CTest-covered. # --------------------------------------------------------------------------- -add_library(component_geometry STATIC src/component_geometry.cpp) +add_library(component_geometry STATIC src/core/ui/component_geometry.cpp) target_include_directories(component_geometry PUBLIC src) # --------------------------------------------------------------------------- @@ -422,7 +470,7 @@ target_include_directories(component_geometry PUBLIC src) # Main_OnCommand dispatch + kbd_getTextFromCmd query are DAW-verified. Mirror of # mode_switch / prune_button. # --------------------------------------------------------------------------- -add_library(action_bar STATIC src/action_bar.cpp) +add_library(action_bar STATIC src/core/ui/action_bar.cpp) target_include_directories(action_bar PUBLIC src) # --------------------------------------------------------------------------- @@ -436,7 +484,7 @@ target_include_directories(action_bar PUBLIC src) # DAW-verified. Reuses prune_button's FooterRect input type. Mirror of action_bar / # mode_switch / prune_button. # --------------------------------------------------------------------------- -add_library(footer_bar STATIC src/footer_bar.cpp) +add_library(footer_bar STATIC src/core/ui/footer_bar.cpp) target_include_directories(footer_bar PUBLIC src) target_link_libraries(footer_bar PUBLIC prune_button) @@ -449,7 +497,7 @@ target_link_libraries(footer_bar PUBLIC prune_button) # L1-kit draw + TrackPopupMenu popup + NamedCommandLookup/Main_OnCommand dispatch are # DAW-verified. Mirror of prune_button / mode_switch. # --------------------------------------------------------------------------- -add_library(overflow_menu STATIC src/overflow_menu.cpp) +add_library(overflow_menu STATIC src/core/ui/overflow_menu.cpp) target_include_directories(overflow_menu PUBLIC src) # --------------------------------------------------------------------------- @@ -461,7 +509,7 @@ target_include_directories(overflow_menu PUBLIC src) # draws disabled buttons in the kit Disabled state. Depends on view_mode_model for the # seed mode-id constants (kArrangeModeId / kDesignModeId — ONE home for the ids). # --------------------------------------------------------------------------- -add_library(mode_enable STATIC src/mode_enable.cpp) +add_library(mode_enable STATIC src/core/ui/mode_enable.cpp) target_include_directories(mode_enable PUBLIC src) target_link_libraries(mode_enable PUBLIC view_mode_model) @@ -473,7 +521,7 @@ target_link_libraries(mode_enable PUBLIC view_mode_model) # prefix strip are unit-tested outside the DAW; the bank_panel hover timer + LICE overlay # draw are DAW-verified. Mirror of prune_button / component_geometry. # --------------------------------------------------------------------------- -add_library(tooltip STATIC src/tooltip.cpp) +add_library(tooltip STATIC src/core/ui/tooltip.cpp) target_include_directories(tooltip PUBLIC src) # --------------------------------------------------------------------------- @@ -484,7 +532,7 @@ target_include_directories(tooltip PUBLIC src) # edge cases) is unit-tested outside the DAW; the bank_panel kit-text overlay draw is # DAW-verified. Mirror of tooltip's prefix-strip helper — pure, CTest-covered. # --------------------------------------------------------------------------- -add_library(card_meta STATIC src/card_meta.cpp) +add_library(card_meta STATIC src/core/ui/card_meta.cpp) target_include_directories(card_meta PUBLIC src) # --------------------------------------------------------------------------- @@ -497,7 +545,7 @@ target_include_directories(card_meta PUBLIC src) # are DAW-verified. Reuses drag_out's PanelClientRect/DragState + bank_grid's CellRect/ # GridSpec. Mirror of drag_out::decideGesture / bank_grid. # --------------------------------------------------------------------------- -add_library(card_drag STATIC src/card_drag.cpp) +add_library(card_drag STATIC src/core/ui/card_drag.cpp) target_include_directories(card_drag PUBLIC src) target_link_libraries(card_drag PUBLIC drag_out bank_grid) @@ -507,10 +555,10 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid) # 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. The mirror of bank_model / peaks / bank_book, -# tested hard outside any host. Lives under src/vst/ (it is instrument code) but +# tested hard outside any host. Lives under core/instrument/engine/ but # links NEITHER SDK — the plain-data boundary is enforced structurally: the test # target below links only sampler_core (+ its peaks dep for the AudioSample alias, -# the one house precedent wav_trim also relies on). The VST3 shell (src/vst/ +# the one house precedent wav_codec also relies on). The VST3 shell (shell/instrument/ # reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified. # --------------------------------------------------------------------------- # pitch_shift (S16) — the pure duration-preserving PitchShifter (Preserve-engine DSP core). @@ -518,8 +566,8 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid) # because that header drags (via wdltypes.h) into any TU that includes it, which # cannot enter the pure sampler_core. Links only peaks (the AudioSample alias). sampler_core # depends on it (Voice owns two PitchShifters). -add_library(pitch_shift STATIC src/vst/pitch_shift.cpp) -target_include_directories(pitch_shift PUBLIC src src/vst) +add_library(pitch_shift STATIC src/core/instrument/engine/pitch_shift.cpp) +target_include_directories(pitch_shift PUBLIC src) target_link_libraries(pitch_shift PUBLIC peaks) # velocity_curve (S-VIEW-9) — the pure velocity->amp transfer curve (eval + editing/clamp/inverse @@ -527,17 +575,31 @@ target_link_libraries(pitch_shift PUBLIC peaks) # explicit pixel box, not a Rect) so the engine can depend on it WITHOUT gaining a transitive # dependency on the editor's layout types. sampler_core depends on it (KeyZone carries a # VelocityCurve; Voice::start eval's it). Mirror of pitch_shift's role, one layer below the engine. -add_library(velocity_curve STATIC src/vst/velocity_curve.cpp) -target_include_directories(velocity_curve PUBLIC src/vst) +add_library(velocity_curve STATIC src/core/instrument/engine/velocity_curve.cpp) +target_include_directories(velocity_curve PUBLIC src) -add_library(sampler_core STATIC src/vst/sampler_core.cpp) -target_include_directories(sampler_core PUBLIC src src/vst) +add_library(sampler_core STATIC src/core/instrument/engine/sampler_core.cpp) +target_include_directories(sampler_core PUBLIC src) target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve) # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- enable_testing() + +# core/ (Q-W1): the shared JSON lexical layer, wire codec, and file loader. +add_executable(json_tests tests/test_json.cpp) +target_link_libraries(json_tests PRIVATE json) +add_test(NAME json_tests COMMAND json_tests) + +add_executable(wire_tests tests/test_wire.cpp) +target_link_libraries(wire_tests PRIVATE wire) +add_test(NAME wire_tests COMMAND wire_tests) + +add_executable(file_bytes_tests tests/test_file_bytes.cpp) +target_link_libraries(file_bytes_tests PRIVATE file_bytes) +add_test(NAME file_bytes_tests COMMAND file_bytes_tests) + add_executable(bank_model_tests tests/test_bank_model.cpp) target_link_libraries(bank_model_tests PRIVATE bank_model) add_test(NAME bank_model_tests COMMAND bank_model_tests) @@ -594,17 +656,21 @@ add_executable(tail_control_tests tests/test_tail_control.cpp) target_link_libraries(tail_control_tests PRIVATE tail_control) add_test(NAME tail_control_tests COMMAND tail_control_tests) -add_executable(realtime_record_tests tests/test_realtime_record.cpp) -target_link_libraries(realtime_record_tests PRIVATE realtime_record) -add_test(NAME realtime_record_tests COMMAND realtime_record_tests) +add_executable(capture_realtime_tests tests/test_capture_realtime.cpp) +target_link_libraries(capture_realtime_tests PRIVATE capture_realtime) +add_test(NAME capture_realtime_tests COMMAND capture_realtime_tests) add_executable(bank_book_tests tests/test_bank_book.cpp) target_link_libraries(bank_book_tests PRIVATE bank_book) add_test(NAME bank_book_tests COMMAND bank_book_tests) -add_executable(wav_trim_tests tests/test_wav_trim.cpp) -target_link_libraries(wav_trim_tests PRIVATE wav_trim) -add_test(NAME wav_trim_tests COMMAND wav_trim_tests) +add_executable(slot_map_tests tests/test_slot_map.cpp) +target_link_libraries(slot_map_tests PRIVATE slot_map json) +add_test(NAME slot_map_tests COMMAND slot_map_tests) + +add_executable(wav_codec_tests tests/test_wav_codec.cpp) +target_link_libraries(wav_codec_tests PRIVATE wav_codec) +add_test(NAME wav_codec_tests COMMAND wav_codec_tests) add_executable(owned_manifest_tests tests/test_owned_manifest.cpp) target_link_libraries(owned_manifest_tests PRIVATE owned_manifest) @@ -628,7 +694,7 @@ add_test(NAME app_version_tests COMMAND app_version_tests) # threaded verbatim or reconstructed from numeric components, at any version, padded or # not — the live suite cannot detect a reconstruct-from-components regression at all. # So: run the SAME version_generated.h.in template through configure_file a second time -# with a SYNTHETIC padded version, and compile the SAME src/app_version.cpp against that +# with a SYNTHETIC padded version, and compile the SAME src/core/version/app_version.cpp against that # header (include-dir substitution — the canary target never sees the live generated/ # dir). The "0.9.01" here is a permanent test fixture, NOT the shipped version (see also # the source-of-truth comment at the top of this file for the live-version invariant); it @@ -645,20 +711,20 @@ function(_configure_padding_canary) # and CMAKE_CURRENT_SOURCE_DIR / CMAKE_CURRENT_BINARY_DIR are inherited read-only. set(REASAMPLER_VERSION "0.9.01") configure_file( - ${CMAKE_CURRENT_SOURCE_DIR}/src/version_generated.h.in + ${CMAKE_CURRENT_SOURCE_DIR}/src/core/version/version_generated.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated_padding_canary/version_generated.h @ONLY) endfunction() _configure_padding_canary() -# NOTE: app_version_padding_tests deliberately recompiles src/app_version.cpp rather +# NOTE: app_version_padding_tests deliberately recompiles src/core/version/app_version.cpp rather # than linking the app_version library target. This is required for the include-dir # substitution to work — the canary needs to see generated_padding_canary/ instead of # the live generated/ dir. If app_version ever gains a link dependency (e.g. a new # pure-module link), the canary target_link_libraries must mirror it here. add_executable(app_version_padding_tests tests/test_app_version_padding.cpp - src/app_version.cpp) + src/core/version/app_version.cpp) target_include_directories(app_version_padding_tests PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated_padding_canary src) add_test(NAME app_version_padding_tests COMMAND app_version_padding_tests) @@ -753,54 +819,65 @@ add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests) # 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE. # editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math # (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge -# read marshalling — GetProjExtState result decode + a small JSON string-field -# reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW; -# the VST3 shell (src/vst/*) that draws/routes/invokes is DAW-verified. +# read marshalling — GetProjExtState result decode + the ONE grow-loop retry +# policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the VST bridge +# read AND the extension's persist/usage_scan ext-state reads (hence linked into +# reaper_reasampler too). Both are unit-tested outside the DAW; +# the VST3 shell (shell/instrument/*) that draws/routes/invokes is DAW-verified. # --------------------------------------------------------------------------- -add_library(editor_geometry STATIC src/vst/editor_geometry.cpp) -target_include_directories(editor_geometry PUBLIC src/vst) +add_library(editor_geometry STATIC src/core/instrument/ui/editor_geometry.cpp) +target_include_directories(editor_geometry PUBLIC src) -add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp) -target_include_directories(bridge_marshal PUBLIC src/vst) +add_library(bridge_marshal STATIC src/core/instrument/map/bridge_marshal.cpp) +target_include_directories(bridge_marshal PUBLIC src) # embed_strip (Phase S6) — PURE layout + hit-test for the embedded TCP/MCP strip: the # 128-key span -> zone-segment rects, point -> zone selection, and the level-band fill. # The mirror of editor_geometry (whose Rect + contains() it reuses); unit-tested outside -# the DAW, while the embed shell (src/vst/reasampler_embed.cpp) marshals REAPER's embed +# the DAW, while the embed shell (src/shell/instrument/reasampler_embed.cpp) marshals REAPER's embed # messages (paint bitmap + mouse coords) into it. Links editor_geometry for the shared Rect. -add_library(embed_strip STATIC src/vst/embed_strip.cpp) -target_include_directories(embed_strip PUBLIC src/vst) +add_library(embed_strip STATIC src/core/instrument/ui/embed_strip.cpp) +target_include_directories(embed_strip PUBLIC src) target_link_libraries(embed_strip PUBLIC editor_geometry) -# sample_map (Phase S4) — PURE mapping logic for the Tier-0 instrument: the live bank -# blob -> selected sample (via the SHARED bank_book JSON parse, NOT a second parser), -# interleaved->mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap -# build, and the selected-sample instance-state (de)serialization. Links the three pure -# modules it composes — bank_book (shared JSON), wav_trim (shared WAV parse), and -# sampler_core (the Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell -# (reasampler_processor.cpp) does the bridge read + file I/O off the audio thread, then -# calls these; the process callback stays allocation-free. -add_library(sample_map STATIC src/vst/sample_map.cpp) -target_include_directories(sample_map PUBLIC src/vst src) -# master_gain: the v8 component-state master-gain field validates against the pure taper's -# linear cap at the (de)serialization boundary (one cap, shared with the knob + the processor). -target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core master_gain) +# sample_map (Phase S4; RESOLUTION half since Q-W2v) — PURE mapping logic for the +# instrument: the live bank blob -> selected sample (via the SHARED bank_book JSON parse, +# NOT a second parser), interleaved->mono downmix (the channel policy), the Tier-0/zoned +# keymap builds, and the refs/performance resolution. Links the three pure modules it +# composes — bank_book (shared JSON), wav_codec (shared WAV parse), and sampler_core (the +# Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell (reasampler_processor) +# does the bridge read + file I/O off the audio thread, then calls these; the process +# callback stays allocation-free. The ComponentState codec is component_state_io below. +add_library(sample_map STATIC src/core/instrument/map/sample_map.cpp) +target_include_directories(sample_map PUBLIC src) +target_link_libraries(sample_map PUBLIC bank_book wav_codec sampler_core) + +# component_state_io (Q-W2v split of sample_map, T4-13 ≡ T2-07) — the ComponentState +# ENVELOPE + zones-payload binary codec (envelope v1..v11, zones payload v1..v7, every +# lift preserved byte-identically). Split so the codec — which grows on every envelope +# bump and is shared with the EXTENSION's preset-blob path (instrument_drop) — links +# WITHOUT the voice engine: its deps are velocity_curve (the per-zone curve field) and +# master_gain (the v8 wire cap) only; sampler_core/pitch_shift object code never enters +# the extension binary. Its own test target linking exactly these is the structural proof. +add_library(component_state_io STATIC src/core/instrument/map/component_state_io.cpp) +target_include_directories(component_state_io PUBLIC src) +target_link_libraries(component_state_io PUBLIC velocity_curve master_gain) # capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the # capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly # grid/tab arithmetic lives here, unit-tested outside the DAW; the editor shell draws each # card's peak thumbnail + name + badge and routes clicks into it. Links editor_geometry for # the shared Rect + contains(). NEITHER SDK. -add_library(capture_browser STATIC src/vst/capture_browser.cpp) -target_include_directories(capture_browser PUBLIC src/vst) +add_library(capture_browser STATIC src/core/instrument/ui/capture_browser.cpp) +target_include_directories(capture_browser PUBLIC src) target_link_libraries(capture_browser PUBLIC editor_geometry) # keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects + # edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's # keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of # embed_strip; links editor_geometry for the shared Rect. NEITHER SDK. -add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp) -target_include_directories(keyboard_strip PUBLIC src/vst) +add_library(keyboard_strip STATIC src/core/instrument/ui/keyboard_strip.cpp) +target_include_directories(keyboard_strip PUBLIC src) target_link_libraries(keyboard_strip PUBLIC editor_geometry) # waveform_view (Phase S11) — PURE frame<->pixel mapping, marker grab regions, drag-delta @@ -808,8 +885,8 @@ target_link_libraries(keyboard_strip PUBLIC editor_geometry) # (draggable start + loop markers over the picked capture's decoded PCM). The mirror of # keyboard_strip; links editor_geometry for the shared Rect and peaks for the AudioSample # alias the snap scans. NEITHER SDK. -add_library(waveform_view STATIC src/vst/waveform_view.cpp) -target_include_directories(waveform_view PUBLIC src/vst src) +add_library(waveform_view STATIC src/core/instrument/ui/waveform_view.cpp) +target_include_directories(waveform_view PUBLIC src) target_link_libraries(waveform_view PUBLIC editor_geometry peaks) # bank_sync (Phase S9/S8 reader) — PURE decision logic for the instrument's off-audio-thread @@ -819,24 +896,25 @@ target_link_libraries(waveform_view PUBLIC editor_geometry peaks) # setSelectedSampleId, component-state marker); this owns only the yes/no maths, unit-tested # outside the DAW. Links assignment_request for the decoded AssignmentRequest it consumes. # NEITHER SDK. -add_library(bank_sync STATIC src/vst/bank_sync.cpp) -target_include_directories(bank_sync PUBLIC src/vst src) +add_library(bank_sync STATIC src/core/instrument/map/bank_sync.cpp) +target_include_directories(bank_sync PUBLIC src) target_link_libraries(bank_sync PUBLIC assignment_request) +target_link_libraries(bank_sync PRIVATE wire) # browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search # geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect + # thumb-drag<->offset mapping, and the name-substring filter that composes with the bank # filter. The mirror of capture_browser; links capture_browser (for BrowserLayout + the card # metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK. -add_library(browser_scroll STATIC src/vst/browser_scroll.cpp) -target_include_directories(browser_scroll PUBLIC src/vst) +add_library(browser_scroll STATIC src/core/instrument/ui/browser_scroll.cpp) +target_include_directories(browser_scroll PUBLIC src) target_link_libraries(browser_scroll PUBLIC capture_browser) # note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of # a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to # [0,127]). No dependency beyond the standard library. NEITHER SDK. -add_library(note_entry STATIC src/vst/note_entry.cpp) -target_include_directories(note_entry PUBLIC src/vst) +add_library(note_entry STATIC src/core/instrument/map/note_entry.cpp) +target_include_directories(note_entry PUBLIC src) # param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface # layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the @@ -844,16 +922,16 @@ target_include_directories(note_entry PUBLIC src/vst) # mirror of keyboard_strip; links editor_geometry for the shared Rect. Deliberately engine-free # (no sampler_core types) — the shell owns the control-id -> param binding + the value DOMAIN # mapping. NEITHER SDK. -add_library(param_slider STATIC src/vst/param_slider.cpp) -target_include_directories(param_slider PUBLIC src/vst) +add_library(param_slider STATIC src/core/instrument/ui/param_slider.cpp) +target_include_directories(param_slider PUBLIC src) target_link_libraries(param_slider PUBLIC editor_geometry) # trigger_seam (Phase S-VIEW-3) — PURE Trigger-mode frames<->fraction converter for the TRIGGER # SEAM documented in envelope_overlay.h: triggerPlayLength / framesToFadeFraction / # fadeFractionToFrames. Owns the one formula so pack (draw) and unpack (commit) are provably # consistent. No shell/LICE/REAPER types — only . NEITHER SDK. -add_library(trigger_seam STATIC src/vst/trigger_seam.cpp) -target_include_directories(trigger_seam PUBLIC src/vst) +add_library(trigger_seam STATIC src/core/instrument/map/trigger_seam.cpp) +target_include_directories(trigger_seam PUBLIC src) # envelope_overlay (Phase S-VIEW-3) — PURE amp-envelope -> polyline geometry for the Sample-view # envelope overlay: AHDSR (Gate) / fade+%-length (Trigger) params + the sample's wall-clock @@ -861,16 +939,16 @@ target_include_directories(trigger_seam PUBLIC src/vst) # The mirror of waveform_view / param_slider; links editor_geometry for the shared Rect. # Deliberately engine-free (no sample_map / sampler_core) — the shell packs the zone's stored # AdsrSeconds / TriggerParams into the small AmpEnvelope view struct. NEITHER SDK. -add_library(envelope_overlay STATIC src/vst/envelope_overlay.cpp) -target_include_directories(envelope_overlay PUBLIC src/vst) +add_library(envelope_overlay STATIC src/core/instrument/ui/envelope_overlay.cpp) +target_include_directories(envelope_overlay PUBLIC src) target_link_libraries(envelope_overlay PUBLIC editor_geometry) # envelope_edit (Phase S-VIEW-3) — PURE node hit-test + pixel-delta -> clamped-param inverse map # for the draggable envelope nodes: monotonic-in-time + range-clamped (against caller-supplied # slider maxima) so a drag can never produce a param a slider couldn't. The mirror of card_drag; # links envelope_overlay for the shared node vocabulary + the timeToX/levelToY maps. NEITHER SDK. -add_library(envelope_edit STATIC src/vst/envelope_edit.cpp) -target_include_directories(envelope_edit PUBLIC src/vst) +add_library(envelope_edit STATIC src/core/instrument/ui/envelope_edit.cpp) +target_include_directories(envelope_edit PUBLIC src) target_link_libraries(envelope_edit PUBLIC envelope_overlay) # knob_deck (Wave B FB1, r11) — PURE knob-deck layout + hit-test for the recomposed Sample face: @@ -878,23 +956,23 @@ target_link_libraries(envelope_edit PUBLIC envelope_overlay) # toggle), deterministic whole-group wrap, point -> control-id hit-test. The mirror of # action_bar / param_slider; links editor_geometry for the shared Rect. Engine-free — cells and # toggles carry opaque shell-owned control ids. NEITHER SDK. -add_library(knob_deck STATIC src/vst/knob_deck.cpp) -target_include_directories(knob_deck PUBLIC src/vst) +add_library(knob_deck STATIC src/core/instrument/ui/knob_deck.cpp) +target_include_directories(knob_deck PUBLIC src) target_link_libraries(knob_deck PUBLIC editor_geometry) # curve_popup (Wave B FB1, r11) — PURE centered-sheet geometry for the velocity-curve popup # editor: size clamps (60%/55% of window, 360..520 x 260..380), title row + close button, the # curve-box border rect, and the outside-sheet dismissal test. The mirror of overflow_menu; # links editor_geometry for the shared Rect. NEITHER SDK. -add_library(curve_popup STATIC src/vst/curve_popup.cpp) -target_include_directories(curve_popup PUBLIC src/vst) +add_library(curve_popup STATIC src/core/instrument/ui/curve_popup.cpp) +target_include_directories(curve_popup PUBLIC src) target_link_libraries(curve_popup PUBLIC editor_geometry) # master_gain (Wave B FB1) — PURE dB<->linear<->knob-taper math for the post-mixer master gain # (-inf..+24 dB; norm 0 = TRUE zero linear). One formula shared by the editor's Gain knob, the # v8 component-state wire cap, and the processor's applied gain. Standard library only. NEITHER SDK. -add_library(master_gain STATIC src/vst/master_gain.cpp) -target_include_directories(master_gain PUBLIC src/vst) +add_library(master_gain STATIC src/core/instrument/engine/master_gain.cpp) +target_include_directories(master_gain PUBLIC src) add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) @@ -908,13 +986,21 @@ add_executable(embed_strip_tests tests/test_embed_strip.cpp) target_link_libraries(embed_strip_tests PRIVATE embed_strip) add_test(NAME embed_strip_tests COMMAND embed_strip_tests) -# sample_map: the S4 mapping heart. Links ONLY sample_map (+ its pure deps) — NEITHER -# the VST3 SDK nor the REAPER SDK — the same structural plain-data-boundary proof the -# sampler_core test enforces. +# sample_map: the S4 mapping heart. Links ONLY sample_map + component_state_io (+ their +# pure deps) — NEITHER the VST3 SDK nor the REAPER SDK — the same structural +# plain-data-boundary proof the sampler_core test enforces. (The historical suite spans +# both halves of the Q-W2v split; the frozen-format assertions live here unmodified.) add_executable(sample_map_tests tests/test_sample_map.cpp) -target_link_libraries(sample_map_tests PRIVATE sample_map) +target_link_libraries(sample_map_tests PRIVATE sample_map component_state_io) add_test(NAME sample_map_tests COMMAND sample_map_tests) +# component_state_io (Q-W2v): the ComponentState codec's OWN target. Links ONLY +# component_state_io (velocity_curve + master_gain transitively) — deliberately NO +# sampler_core/pitch_shift — the structural proof the codec is engine-free (T2-07). +add_executable(component_state_io_tests tests/test_component_state_io.cpp) +target_link_libraries(component_state_io_tests PRIVATE component_state_io) +add_test(NAME component_state_io_tests COMMAND component_state_io_tests) + add_executable(capture_browser_tests tests/test_capture_browser.cpp) target_link_libraries(capture_browser_tests PRIVATE capture_browser) add_test(NAME capture_browser_tests COMMAND capture_browser_tests) @@ -1004,42 +1090,61 @@ set(LICE_SRC ) add_library(reaper_reasampler MODULE - src/main.cpp - src/capture.cpp - src/capture_realtime.cpp - src/realtime_record.cpp - src/persist.cpp - src/bank_panel.cpp - src/draw_kit.cpp - src/mode_switch.cpp - src/tab_strip.cpp - src/insert.cpp - src/insert_plan.cpp + src/app/main.cpp + src/shell/capture/capture.cpp + src/shell/capture/capture_orchestrator.cpp + src/shell/capture/capture_batch.cpp + src/shell/capture/scope_resolve.cpp + src/shell/capture/realtime_lifecycle.cpp + src/shell/capture/capture_realtime_shell.cpp + src/shell/capture/capture_realtime_finalize.cpp + src/core/capture/capture_realtime.cpp + src/shell/persist/session.cpp + src/shell/persist/ext_state_io.cpp + src/shell/persist/prune_fs.cpp + src/shell/bank_ops/bank_ops.cpp + src/shell/panel/panel_audition.cpp + src/shell/panel/panel_bank_ops.cpp + src/shell/panel/panel_drag.cpp + src/shell/panel/panel_input.cpp + src/shell/panel/panel_layout.cpp + src/shell/panel/panel_render.cpp + src/shell/panel/panel_thumbnails.cpp + src/shell/panel/panel_window.cpp + src/shell/panel/draw_kit.cpp + src/core/view/mode_switch.cpp + src/core/ui/tab_strip.cpp + src/shell/capture/insert.cpp + src/core/capture/insert_plan.cpp ${LICE_SRC} - src/view_mode_model.cpp - src/view_tree.cpp - src/view.cpp - src/track_guid.cpp - src/provenance_shell.cpp - src/guid_diff.cpp - src/lane_keys.cpp - src/item_read.cpp - src/actions.cpp + src/core/view/view_mode_model.cpp + src/core/view/view_tree.cpp + src/shell/view/view.cpp + src/shell/capture/track_guid.cpp + src/shell/capture/provenance_shell.cpp + src/core/view/guid_diff.cpp + src/core/view/lane_keys.cpp + src/shell/capture/item_read.cpp + src/shell/actions/action_registry.cpp + src/shell/actions/design_view_actions.cpp + src/shell/actions/bank_actions.cpp + src/shell/actions/prune_action.cpp src/ingest.cpp - src/bank_book.cpp - src/owned_manifest.cpp - src/drag_out_win.cpp - src/instrument_drop_win.cpp - src/action_bar.cpp - src/footer_bar.cpp - src/overflow_menu.cpp - src/mode_enable.cpp - src/tooltip.cpp - src/card_meta.cpp - src/card_drag.cpp - src/usage_scan.cpp + src/core/model/bank_book.cpp + src/core/model/bank_book_json.cpp + src/core/model/owned_manifest.cpp + src/shell/actions/drag_out_win.cpp + src/shell/actions/instrument_drop_win.cpp + src/core/ui/action_bar.cpp + src/core/ui/footer_bar.cpp + src/core/ui/overflow_menu.cpp + src/core/ui/mode_enable.cpp + src/core/ui/tooltip.cpp + src/core/ui/card_meta.cpp + src/core/ui/card_drag.cpp + src/shell/persist/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync bridge_marshal sample_usage) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' @@ -1142,16 +1247,30 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # --- 5b) The VST3 module (loadable .vst3 DLL). ------------------------------- add_library(reasampler_vst MODULE - src/vst/vst_entry.cpp - src/vst/reasampler_processor.cpp - src/vst/reasampler_editor.cpp - src/vst/reasampler_embed.cpp - src/vst/reaper_bridge.cpp + src/shell/instrument/vst_entry.cpp + # The processor family (Q-W2v, T4-12): lifecycle + process() whole (T4-29), with + # component-state I/O and the off-thread reload/publish family in sibling TUs. + src/shell/instrument/reasampler_processor.cpp + src/shell/instrument/processor_state.cpp + src/shell/instrument/processor_reload.cpp + # The editor family (Q-W2v, T4-11): eight face-axis TUs — session/bridge state, + # param plumbing, paint x2 (Sample | Browse+Zone), input x2 (same axis), platform; + # the eighth (editor_layout) hoisted PURE into core/instrument/ui/editor_geometry + # + browser_scroll (T2-06). Shared internals: editor_internal.h (no TU). + src/shell/instrument/editor_session.cpp + src/shell/instrument/editor_controls.cpp + src/shell/instrument/editor_paint_sample.cpp + src/shell/instrument/editor_paint_browse_zone.cpp + src/shell/instrument/editor_input_sample.cpp + src/shell/instrument/editor_input_browse_zone.cpp + src/shell/instrument/editor_platform.cpp + src/shell/instrument/reasampler_embed.cpp + src/shell/instrument/reaper_bridge.cpp # The Phase L (L1) draw kit — the ONE source of drawing the editor + embed shells # now consume (L3). Compiled into the MODULE (not a static lib) for the same reason # bank_panel does: it is the only kit TU touching LICE, and its cached-font engine # (LICE_CachedFont) needs the LICE_SRC TUs below linked into this artifact. - src/draw_kit.cpp + src/shell/panel/draw_kit.cpp # SDK module entry — compiled into the module (not the static lib) so the # InitDll/ExitDll dll exports survive the link (see vst3_sdk note above). ${VST3_SDK}/public.sdk/source/main/dllmain.cpp @@ -1160,10 +1279,10 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") ) # editor_geometry + bridge_marshal: the pure spike helpers. sample_map (S4): the pure # bank->keymap mapping + state (de)ser the processor drives off the audio thread; - # linking it pulls its pure deps (bank_book, wav_trim, sampler_core, bank_model, + # linking it pulls its pure deps (bank_book, wav_codec, sampler_core, bank_model, # peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile / - # projectDirOfRpp) the bridge + processor use. Its PUBLIC include dirs (src, src/vst) - # give the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). + # projectDirOfRpp) the bridge + processor use. Its PUBLIC include dir (src) + # gives the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). # embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals # into; it links editor_geometry transitively (shared Rect). # app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so @@ -1198,13 +1317,15 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # sample_usage (pS-usage): the usage-record wire + publish plan the processor's # reloadInstrument publishes through the bridge (the one sanctioned VST-side write). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sample_map capture_paths embed_strip app_version capture_browser keyboard_strip + sample_map component_state_io capture_paths embed_strip app_version capture_browser keyboard_strip waveform_view bank_sync browser_scroll note_entry param_slider theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit - knob_deck curve_popup master_gain sample_usage) + knob_deck curve_popup master_gain sample_usage file_bytes) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. - target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) + # src: the Q-W1 rooted include convention ("core/..." / "shell/..."). src/vst is GONE + # (Q-W2v): the two former god TUs live split under shell/instrument/. + target_include_directories(reasampler_vst PRIVATE src ${SDK_INC} ${WDL_INC}) # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk # product name, channel-forked (S18): reasampler_9000.vst3 (stable, byte-identical to # pre-S18) / reasampler_9000_beta.vst3 (beta) — driven by REASAMPLER_VST_OUTPUT_NAME set diff --git a/COMPLETED.md b/COMPLETED.md index dbfba43..1b00005 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -2883,3 +2883,376 @@ now covered by the same mechanism. - [x] `reasampler_processor`: `reloadInstrument` decodes from `SampleRefs_` (bank-free); `SampleRefs` table added to `ComponentState` v10; heal timer + poll-to-play removed; preview summing removed; `PreviewCard` member removed. - [x] `sample_map`: `SampleRefs` type + `SampleRefEntry` struct; `refreshRefsFromBank`, `retainRefs`, `resolvePerformanceFromRefs` (decodes directly from refs, no bank blob required); `kComponentStateVersion` bumped to 10; pre-v10 lift to empty refs. - [x] `ComponentState` v10 serialization round-trip: new `sampleRefs` field; pre-v10 blobs migrate on load. + +--- + +## Q-W0 fix-now remediations — closes Q-W0 (2026-07-28) + +> **Merged to `phase-q` 2026-07-28 (commit `546927e`).** Closes the Q-W0 sub-gate — Q-W0 +> (pre-restructure functional + DSP quality audit) is now fully closed. Part of the phase-q +> integration that later reached 60/60 green with Q-W1. + +**Goal:** Land the six Daniel-approved fix-now remediations from the Q-W0 audit triage, plus +seven review riders surfaced during their review, before any structural Q-wave begins. +**Verify:** Each remediation lands with its module's CTest target green; the audible DSP fixes +carry a stated before/after listening check. + +- [x] **T1-01** — linked-lag stereo SOLA + follower self-heal fallback. +- [x] **T1-03** — playable-span prime bound. +- [x] **T1-09** — declick dead-state removal. +- [x] **T2-01a** — provenance wire-cursor hardening backport. +- [x] **T3-01** — rate-derived gain ramp. +- [x] **T3-03** — rate-derived fade ceiling. +- [x] Seven additional review riders landed in the same merge (not individually itemized in + this entry). + +**Notes/decisions:** +- Landing these six fix-nows (plus the review riders) closes Q-W0 entirely — audit, triage, + sign-off, and remediation are all complete — and opens Q-W1. + +--- + +## Q-W1 — safe opener: `core/json` extraction + directory/namespace layout (2026-07-29) + +> **Merged to `phase-q` 2026-07-29. Integrated suite 60/60 green.** First structural wave of +> Phase Q; unblocked by the Q-W0 fix-now remediations closing 2026-07-28. + +**Goal:** The zero-god-module-risk opener. Extract a pure `core/json` module (parser + +serializer) and delete the five hand-rolled JSON decoders — the four `Parser`s in `bank_model` +/ `bank_book` / `view_mode_model` / `owned_manifest` plus `tail_control`'s fifth decoder; impose +the settled `core/`/`shell/`/`app/` directory layout + sub-namespaces on the clean pure libs, +clean shells, and clean VST pure libs — pure relocation, no logic change. +**Verify:** CTest green at every commit (60/60). The five duplicate JSON decoders are gone, +replaced by one `core/json` consumed by all five former consumers; round-trip serialization is +byte-identical to before. Every relocated clean module compiles and its test executable passes +unmoved. No REAPER type crosses into any `core/` file. + +- [x] `core/json` extracted (`json::Reader`/`json::Writer`); `bank_model`, `bank_book`, + `view_mode_model`, `owned_manifest`, and `tail_control` rewired onto it; the five hand-rolled + JSON decoders deleted. +- [x] Wire-`Cursor` family collapsed into `core/wire` — one hardened survivor codec. +- [x] Shared `readFileBytes` pure helper added; linked by both artifacts. +- [x] `slot_map` extracted from `bank_book`, with its own `slot_map_tests`. +- [x] ~50 clean modules relocated into `core/{model,view,capture,audio,ui,reclaim,version,json, + util,wire}/`, `core/instrument/{engine,map,ui}/`, `shell/{capture,panel,view,persist,actions, + instrument}/`; `main.cpp` moved to `app/`. Sub-namespaces applied to every relocated clean + module. +- [x] Rect unification: one concrete `ui::Rect` + `contains()` + per-role aliases — the + XYWH-vs-LTRB fork retired, `footer_bar.h`'s "NAME NOTE" collision workaround gone. +- [x] `clamp01` deduplicated. +- [x] Naming riders: survivor JSON parser minted as `json::Reader`/`json::Writer`; `BankIndex` + renamed to `BankModel` (verified by `bank_model_tests`). +- [x] `reasampler_uid.h` relocated to `core/wire/`. +- [x] Interim `core/namespaces.h` shim added for the six not-yet-split god TUs; each downstream + split wave (Q-W2 onward) retires its own includes of it as that module splits. + +**Notes/decisions:** +- **Riders explicitly skipped/deferred:** T4-22 (`hitIndex` hit-test template) — not trivial, + deferred as an opportunistic follow-on once the rect unification is in use downstream; T4-06 + (`view_mode_model` planner split) — optional, deferred; T4-09 (`view_lanes` split) — + deferred (in scope only if a later wave touches `view.cpp` anyway). +- **Open residual — `bank_book.cpp` still 737 LOC.** The serialize/deserialize seam is + identified but blocked on a `nameKey` linkage design decision, escalated to Daniel and + **pending** as of 2026-07-29. Downstream waves touching `bank_book` should check this + residual before assuming the split is finished. + +--- + +## Q-W2 — split `bank_panel.cpp` (the biggest extension god-module — 3459 LOC at the Q-W0 census) (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w2-panel`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Split the largest extension god-module (8+ responsibilities) along the audit's named +seams — eight TUs (Q-5 SETTLED with the T4-01 reshape, Daniel 2026-07-28): `panel_render` / +`panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` / `panel_window` + +`panel_layout` (toolbar/footer/menu rects + row/cluster builders + region geometry glue) + +`panel_drag` (the card-drag/hover state machine — it already has a pure mirror, `card_drag`) — +without the two new seams, `panel_render` (~700) and `panel_input` (~800) would ship over the +~600 ceiling on day one. Split the fat `bank_panel.h` alongside (Interface Segregation). +Preserve the audition hot path as a direct call-through, never virtual. `panel_bank_ops` becomes +the single home for the bank-CRUD verbs that W4 will dedupe `actions.cpp` against. CONTEXT.md +§Phase Q (bank_panel split seams; hot-path audition guardrail). See +`docs/product/code-organization.md` §2.1, §5. +**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/` and every +TU lands under the ~600-line ceiling (the Q-5 acceptance bar); the panel draws, thumbnails, +auditions, handles input, does bank ops, and manages its window exactly as before (no behavior +change — verify in DAW that the panel is visually and interactively unchanged). Audition/preview +call path stays a direct call-through (no virtual dispatch, no added header→TU indirection on +the preview path). The ~20-function public API is now segmented across the split headers. + +- [x] Split rendering (`draw*`/`paint*`) → `panel_render`; thumbnail compute+cache → + `panel_thumbnails`; toolbar/footer/menu rect + row/cluster builders + region geometry → + `panel_layout` (new seam, T4-01). +- [x] Split the audio audition/preview engine → `panel_audition` — direct call-through, not + virtual; preview idle path unchanged. +- [x] Split input handling (mouse/key/wheel) + new-content detection → `panel_input`; window + lifecycle + OS drag-out/drop-target → `panel_window`; the card-drag/hover state machine → + `panel_drag` (new seam, T4-01). Per-mouse-move work stays plain free-function calls (T4-28). +- [x] Extract bank-CRUD verbs → `panel_bank_ops` (the future single owner; W4 dedupes + `actions.cpp` against it). Split `bank_panel.h` into per-seam headers (I). +- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added; all eight TUs + under the ~600 ceiling. — **PENDING**: in-DAW panel-parity verification not yet performed on + `phase-q` (deferred by design). + +**Notes/decisions:** +- **Recorded ceiling overages (reviewer-endorsed, preserved as a durable record per CONTEXT's + "silent overshoot is not legitimate" rule):** `panel_input.cpp` 636, `panel_render.cpp` 613, + `panel_state.h` 608 — overage is comment volume; non-comment lines are ~322–369 per file; no + honest seam remains; bisection was rejected. +- **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed + prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)` + etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with + `actions.cpp` and are the cheapest first dedupe. +- ~50 TU-private helpers wrapped in anonymous namespaces (review follow-up landed in the same + merge). + +--- + +## Q-W2v — split the VST god-modules (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w2v-vst`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Close the audit's structural scope gap: the VST artifact's god-modules had no owning +wave, and `reasampler_editor.cpp` (3065 LOC) is the largest file in the repo. Split the editor +into eight TUs along the Sample/Browse/Zone face axis (T4-11): `editor_session` / +`editor_controls` / `editor_layout` (pure-candidate hoist into the existing pure homes — +`editor_geometry` is the named owner; this discharges T2-06's stranded-layout-math finding) / +`editor_paint_sample` / `editor_paint_browse_zone` / `editor_input_sample` / +`editor_input_browse_zone` / `editor_platform`. Split `reasampler_processor.cpp` (1164 LOC) into +three TUs (T4-12): `processor_state` / `processor_reload` / lifecycle+`process()` kept whole. +Split `sample_map` into resolution core vs the `component_state_io` binary codec + matching +header split (T4-13 ≡ T2-07 — the codec grows every envelope bump; the extension stops linking +the whole voice engine to serialize one preset blob). `sampler_core.cpp` stays whole (968 LOC) — +a DOCUMENTED hot-path exception to the ~600 ceiling (T4-14/T4-27: envelope `tick()`s run +per-voice-per-sample; same-TU definition is what lets the compiler inline the stack, no LTO in +the build; a by-class split is the exact heuristic-(3) dispatch blowout); its header splits into +`zone_params.h` + `sampler_core.h`. The `core/wire` LE byte-codec template (`putLE`/`readLE`, +T4-20) lands here with its biggest consumer. CONTEXT.md §Phase Q (VST split seams; `sampler_core` +exception). +**Verify:** CTest green at every commit. Editor and processor behave identically in DAW (visual ++ interactive parity across all three faces; `process()` audio unchanged). Every new TU lands +under the ~600-line ceiling except the one documented `sampler_core.cpp` exception. `process()` ++ its per-block helpers stay one TU; the atomic-pointer-swap reload pattern gains no virtual seam +(T4-29); no dispatch-stack blowout anywhere (heuristic 3). + +- [x] Split `reasampler_editor.cpp` → the eight face-axis TUs; hoist `editor_layout`'s pure + geometry into the existing pure homes (`editor_geometry` — discharges T2-06). +- [x] Split `reasampler_processor.cpp` → `processor_state` / `processor_reload` / + lifecycle+`process()` whole; no virtual seam on the atomic-swap pattern (T4-29). +- [x] Split `sample_map` → resolution core + `component_state_io` codec (+ header split); the + extension's preset-blob path stops linking the voice engine (T4-13 ≡ T2-07). +- [x] `sampler_core`: split `zone_params.h` out of the header; TU stays whole — documented + exception (T4-14/T4-27), recorded in the wave brief so nobody "fixes" it later. +- [x] Land the `core/wire` LE byte-codec template (`putLE`/`readLE`, T4-20) with + `component_state_io`; other consumers rewire opportunistically. +- [x] Rider: adopt the pure `ThumbnailKey` on the VST editor side (T2-10). +- [ ] Verify in DAW: editor + processor unchanged; CTest green; ceiling met (one documented + exception); no added dispatch. — **PENDING**: in-DAW editor/processor-parity verification not + yet performed on `phase-q` (deferred by design). + +**Notes/decisions:** +- Golden full-blob v11 fixture pins the `component_state_io` codec bytes. +- The `src/vst/` directory is gone — all VST sources now live under the Q-W1 `core/instrument/` + and new `shell/instrument/` layout. +- **Deferred/known:** `component_state_io.h` still includes `sample_map.h`→`sampler_core.h` + transitively (T2-07's header half — future work); the `engine` namespace is deferred + (`sampler_core` stays flat `reasampler`); capture-side LE rewires are left for the capture + family. + +--- + +## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch) (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w3-main`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Reduce `main.cpp` (1897 LOC at the Q-W0 census) to its actual job — API pointers + +`ReaperPluginEntry` + dispatch — by hoisting four TUs (T4-02 reshape, SETTLED with Q-5, Daniel +2026-07-28 — the planned three left `capture_orchestrator` at ~885, over the ceiling): +`capture_orchestrator` (`RunCapture` / `captureAndIndexOne` / `renderOffline` / single-capture + +realtime/insert action bodies — lands ~450), `capture_batch` (the batch family + +`RunRecaptureFromSource` + the two RAII selection guards — recapture is planner-driven like batch +and shares the guard machinery), `scope_resolve` (`resolveRange`/`resolveRazorRange`/ +`collectSelectedTracks` + provenance assembly inputs), and `realtime_lifecycle` (the +realtime-capture state machine + globals). `FxBypassGuard` moves out but stays a stack RAII +object (precision-critical); the realtime idle tick stays a single pointer test. Q-W0 riders +owned by this wave (all SETTLED 2026-07-28): delete `ICaptureBackend` (T4-26 — one deriver, zero +polymorphic call sites; `OfflineRenderBackend` becomes concrete; the CLAUDE.md/CONTEXT "two +backends behind one interface" correction rides this wave's own commit, not earlier); the shared +`stampCaptureSample` capture-epilogue dedupe (T2-09); the `capture_realtime_finalize` split +riding the Q-9 rename (T4-08); the `makeUniqueTag` per-session monotonic-counter fix (T1-11); and +the WAV/RIFF consolidation (audit §4e) — one pure `wav_codec` owner (walker + layout + build + +patch), absorbing `ingest.cpp`'s pure WAV build helpers (T2-08 / T4-23 / T4-10). CONTEXT.md +§Phase Q (main split seams; FxBypassGuard + realtime-tick guardrails). See +`docs/product/code-organization.md` §2.1, §3. +**Verify:** CTest green at every commit. Capture (offline + realtime + batch + recapture) behaves +identically in DAW; the null test still nulls, bit-identical repeats still match (the precision +invariants `FxBypassGuard` protects are unchanged); capture ≠ placement holds (no hoisted `Run*` +path gains an `InsertMedia` call). The realtime idle fast-path is still a single pointer test. +`main.cpp` is now pointers + entry + dispatch only. The four hoisted TUs + `wav_codec` land under +the ~600 ceiling; the WAV/RIFF layout has one pure owner (the dedup-by-hash and null-test +invariants now rest on one implementation); `ICaptureBackend` is gone with no behavior change and +the CLAUDE.md/CONTEXT description is corrected in the same commit. + +- [x] Hoist capture orchestration → `capture_orchestrator` (`shell/capture/`); keep + `FxBypassGuard` a stack RAII object as it moves (precision-invariant-critical). +- [x] Hoist the batch family + `RunRecaptureFromSource` + the two RAII selection guards → + `capture_batch` (fourth hoist, T4-02) so `capture_orchestrator` lands ~450. +- [x] Hoist scope/source resolution + provenance assembly inputs → `scope_resolve`. +- [x] Hoist the realtime-capture lifecycle state machine + globals → `realtime_lifecycle`; idle + tick stays a single pointer test. +- [x] Leave `main.cpp` = API-pointer ownership + `ReaperPluginEntry` + dispatch; move to `app/`. +- [x] Naming rider (Q-9 — SETTLED, Daniel 2026-07-28: yes): align the `capture_realtime` (shell) + / `realtime_record` (pure) word-order inversion to the house shell↔core convention — the pure + module takes the stem `capture_realtime`, the shell takes the suffix (`drag_out`↔`drag_out_win` + is the model). Split `capture_realtime_finalize` (async lifecycle vs file-side finalize) in the + same surgery (T4-08). No rename on a file this wave isn't already touching (Q-7). +- [x] Delete `ICaptureBackend` (T4-26): `OfflineRenderBackend` becomes concrete; correct the + CLAUDE.md/CONTEXT "two backends behind one interface" description in the same commit. +- [x] Dedupe the capture-stamp epilogue → shared `stampCaptureSample` (T2-09 — the divergent bits + stay in the realtime caller); fix `makeUniqueTag` with a per-session monotonic counter, both + call sites (T1-11 — same-second batch captures currently collide silently). +- [x] WAV/RIFF consolidation rider (audit §4e — SETTLED, Daniel 2026-07-28): one pure `wav_codec` + owner (chunk walker + layout + build + patch), absorbing `ingest.cpp`'s pure WAV/PCM build + (T4-10 — the ingest shell drops to ~500 and the WAV build gains a test target). +- [ ] Verify in DAW: null test nulls, bit-identical repeats match, capture≠placement holds; CTest + green; no realtime-tick branch-shape change. — **PENDING**: in-DAW null-test / + bit-identical-repeats verification not yet performed on `phase-q` (deferred by design). + +**Notes/decisions:** +- `wav_codec_tests` replaces `wav_trim_tests`; `capture_realtime_tests` replaces + `realtime_record_tests`. +- **Known open:** `wav_trim.h`'s transitional forwarding shim still has three live includers + (`sample_map.h`, `editor_session.cpp`, `processor_reload.cpp`) — repoint-and-retire is a named + follow-up; `ingest.cpp` trimmed to 567 LOC but keeps the `namespaces.h` shim (`ingest` + `view` + remain the shim's unowned consumers). + +--- + +## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops` (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w4-actions`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Split the two unrelated command-id families in one TU (1016 LOC at the Q-W0 census — +T4-03: the planned seams still land sub-600, no reshape) into +`design_view_actions` / `bank_actions` / `prune_action`, and **dedupe** `actions.cpp`'s own +`promptText`/`mintBankId` and bank verbs against the `panel_bank_ops` single-owner established in +Q-W2. `prune_action` keeps the `doBankPruneFolder` deletion authority contract intact (routes to +`persist`'s `prune_fs` after W5). CONTEXT.md §Phase Q (actions split seams; bank-verb dedupe). +See `docs/product/code-organization.md` §2.1, §2.4. +**Verify:** CTest green at every commit. Every action fires identically in DAW (Design View +family; multi-bank create/rename/reorder/delete/evacuate/activate/move/copy/remove; prune). The +bank-CRUD verbs have **one** implementation home (no `bank_panel`/`actions` duplication). Each +bank verb still wraps its mutation in one batched undo point; the prune action still writes no +ext state and opens no undo point. Command-id strings are **unchanged** (FOREVER-STABLE +contract — a reorg must not touch a shipped command id). +**Depends on:** Q-W2 (`panel_bank_ops` is the dedupe target). Independent of Q-W3. + +- [x] Split `actions.cpp` (1019 LOC) → `design_view_actions` (toggle/activate/tag/untag/ + showBoth/moveItems), `bank_actions` (bank CRUD family), `prune_action` (`doBankPruneFolder` — + the single file-deletion action), plus a fourth shared `action_registry` TU under + `shell/actions/`. +- [x] Dedupe `actions.cpp`'s `promptText`/`mintBankId` + bank verbs against `panel_bank_ops` + (one owner); no command-id string changed. Bank verbs reshaped to **promptless inner verbs** + (one mutation home, two UX skins — panel and actions each keep their exact prior UX); + `promptText` renamed `promptBankName`; `persistBankOp`/`persistBook` gain null-session guards. +- [x] `prune_action` verified a clean deletion-authority isolate (no `Undo_*`, no ext-state + writes). Command-id suffixes/display phrases verified byte-identical in review. +- [ ] Verify in DAW: all action families fire unchanged; one bank op = one Ctrl-Z; prune still + no-undo/no-ext-state; CTest green. — **PENDING**: in-DAW verification not yet performed on + `phase-q` (deferred by design). + +**Notes/decisions:** +- **Review 🟡 (resolved in Q-W6):** two session pointers / a null-session-as-model-rejection + misreport (unreachable today) — resolved by Q-W6's `bank_ops` lift. + +--- + +## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`) (2026-07-29) + +> **Merged to `phase-q` 2026-07-29 (merge of `pq-w5-persist`). Integrated suite 61/61 green, +> reviewed-approved.** + +**Goal:** Split `persist.cpp` (852 LOC at the Q-W0 census — T4-04: seams unchanged; the +pS-usage growth landed exactly where this wave isolates it; 5 responsibilities) into `session` +(lifecycle+poll, `BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON +serialization bridge + GUID minting + folder relocation), and **`prune_fs`** (prune scanning + +`deleteOrphanFile` via `SHFileOperationW`). The split **concentrates** the byte-deleting +authority into one obvious module — it must never spread it. **Q-W0 rider (T2-04, SETTLED +2026-07-28):** generalize the `GetProjExtState` grow-loop retry policy into `bridge_marshal`'s +pure decode home (or its `core/` successor) and rewire all three hand-rolled copies — +`usage_scan`'s prune-safety-adjacent copy included. CONTEXT.md §Phase Q (persist split seams; +deletion-authority isolation). See `docs/product/code-organization.md` §2.1, §7. +**Verify:** CTest green at every commit. Session save/load/undo-reload, ext-state round-trip, +folder relocation, and prune deletion all behave identically in DAW. **File deletion lives in +exactly one module (`prune_fs`)** — the single-file-deletion-authority invariant is *improved* +(concentrated), never diluted. Relative-paths-only persistence is unchanged. +**Depends on:** Q-W1. Best after Q-W4 (so `prune_action` routes cleanly to `prune_fs`), but +independently landable. + +- [x] Split `persist.cpp` (853 LOC) → `session` (lifecycle/poll + `projectconfig` reload hook), + `ext_state_io` (serialization bridge + GUID minting + folder relocation), under `shell/persist/` + + `persist_internal.h`. +- [x] Isolate prune scanning + `deleteOrphanFile` (`SHFileOperationW`) → **`prune_fs`** — the + deletion authority concentrated in exactly one anonymous-namespace function in `prune_fs.cpp`, + verified tree-wide; the prune fail-safe chain stays byte-intact. +- [x] Dedupe the `GetProjExtState` grow-loop ×3 (T2-04): unified as a header-only template, all + three copies rewired (`usage_scan`'s start cap raised 4KB→64KB, allocation-only, verified + equivalent); the grow-loop gains a defensive NUL. +- [x] Rider: the Q-W1 `bank_book_json` residual lands via a private static `nameKey` + (Daniel-approved option a) — `bank_book.cpp` is now ~462 LOC. +- [ ] Verify in DAW: save/load/undo-reload/relocation/prune unchanged; deletion authority is one + module; relative-paths-only holds; CTest green. — **PENDING**: in-DAW verification not yet + performed on `phase-q` (deferred by design). + +**Notes/decisions:** +- `persist.h` is kept as a compat umbrella for parallel safety across the in-flight waves + (retired in Q-W6); deletion-authority wording is scoped precisely in headers. + +--- + +## Q-W6 — OCP registration-table + residual fat-header (I) splits (2026-07-29) + +> **Merged to `phase-q` 2026-07-29. Integrated suite 61/61 green, reviewed-approved.** + +**Goal:** Close the last SOLID wart: replace the ~350-line hand-written **non-table** action +registration blocks (now isolated in `app/main.cpp` after Q-W3) with a **registration table**, so +adding an action edits one place, not four parallel ones (OCP). Split any remaining fat headers +(`capture.h`/`persist.h`) not already resolved by their TU splits (I). (Q-W0: no reshape — +T4-02 notes the ~385-line registration residue left in `app/main.cpp` after Q-W3 shrinks +further under the table.) CONTEXT.md §Phase Q (OCP +registration-table). See `docs/product/code-organization.md` §2.3, §6 (Q-6). +**Verify:** CTest green at every commit. Every action still registers, appears in the Actions +list, and fires via `hookcommand` exactly as before; command-id + display strings unchanged +(FOREVER-STABLE, per-channel); unload still mirror-unregisters everything. Adding a hypothetical +new action now touches the table only (demonstrated in review, not shipped). Remaining fat +headers are segmented. +**Depends on:** Q-W3 (registration code must be isolated first). Sequenced last; the most +droppable point if the phase needs narrowing (Q-6). + +- [x] Converted the hand-written `Register("command_id"/"gaccel"/"hookcommand")` blocks to a + data-driven `ActionTableRow` registration table (flat function-pointer dispatch, no + `std::function`/virtual); unload mirror-unregisters from the same table; `main.cpp` shrinks + 653→404. Capture rows derive their suffix+phrase from the pure `captureActionTable()` (the + parallel-list risk is gone by construction). FOREVER-STABLE suffixes/phrases/retired-ids + verified byte-identical row-by-row in review. +- [x] Split residual fat headers: `persist.h` umbrella retired (13 callers repointed); + `capture.h`'s realtime seam moved to `capture_realtime_shell.h`; the `wav_trim.h` shim + its + INTERFACE target deleted. +- [x] Phase-end cleanup riders: `bankOp*` verbs + `persistBankOp` lifted to new `shell/bank_ops` + taking `ReaSamplerSession&` (dissolves the Q-W4 🟡 review note); **`core/namespaces.h` + DELETED** (the interim Q-W1 shim's contract fulfilled — ~26 includers rewired); the grow-loop + rehomed to `core/wire/ext_state_read.h`; a stale-comment sweep (`persist.cpp`/`bank_panel.cpp` + refs); CLAUDE.md's persist/bank_book/actions/wav_codec bullets corrected in-wave. +- [ ] Verify: all actions register/fire/unregister unchanged; command-id strings untouched; CTest + green. — **PENDING**: in-DAW verification not yet performed on `phase-q` (deferred by design). + +**Notes/decisions:** +- **Review-noted follow-on (not landed, deferred):** extending the table pattern to the + design_view/bank/ingest families' hand-registration; `channelIdFor`'s shared string-store scan + is correct-by-prefix-disjointness — a suffix-keyed map would make it structural, but isn't + required; `view_mode_model.h` (748 LOC) remains the largest header (T4-06's planner split + stays optional/deferred). diff --git a/CONTEXT-ARCHIVE.md b/CONTEXT-ARCHIVE.md index a2f4207..3e41e15 100644 --- a/CONTEXT-ARCHIVE.md +++ b/CONTEXT-ARCHIVE.md @@ -25,8 +25,10 @@ Pure (no REAPER types, fully unit-tested): format, so this is simpler, testable, and dependency-free. REAPER-facing: -- `capture` — the `ICaptureBackend` interface plus `OfflineRenderBackend` and - `RealtimeRecordBackend`. Input: a `CaptureRequest` (capture scope — item or +- `capture` — two CONCRETE backends, `OfflineRenderBackend` (synchronous) and + `RealtimeRecordBackend` (async begin/tick/abort); no shared interface (the + former `ICaptureBackend` was deleted in Q-W3 — T4-26: one deriver, zero + polymorphic call sites). Input: a `CaptureRequest` (capture scope — item or track, time range, tail, SR/bit-depth/channels, output path). Output: a finished file + a populated `Sample` handed to `bank_model`. Capture is always wet; the FX *scope* (not a wet/dry dial) is the control — the pure `render_settings` module diff --git a/CONTEXT.md b/CONTEXT.md index abaa99f..810316d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2301,7 +2301,10 @@ shell maps that pure result to a cursor. No cue logic in the shell; the shell on > findings report — a functional-correctness/algorithm-quality complement to the SOLID/naming audit > below. **Q-W1 is gated on Q-W0's triage being complete and Daniel signing off on each finding's > disposition** (fix-now vs. document-and-defer). Spec: §"The pre-restructure audit wave (Q-W0)" -> below. +> below. **STATUS (2026-07-28): Q-W0 COMPLETE and SIGNED OFF — all 59 dispositions approved; the +> Q-W1 sub-gate is satisfied once the six approved fix-now remediations land (in flight on +> `pq-w0-fixes`). The audit's plan reshape is folded into this spec: Q-W2 6→8 seams, NEW wave +> Q-W2v (parallel with Q-W2), Q-W3 3→4 hoists + riders, Q-W5 + the ext-state-loop dedupe.** ## What it is @@ -2330,6 +2333,21 @@ reinvented wheel or a numerically-fragile DSP path should be eliminated or consc tree. Bringing the code "into the realm of something I can stand to look at" is not only a matter of shape; it is also a matter of the code being *functionally sound*. +**STATUS (2026-07-28): Q-W0 is COMPLETE — audit run, triage done, sign-off given.** Four parallel +tracks (T1 DSP, T2 architecture, T3 env-coupled constants, T4 sizing + placement), **59 +findings**; report: `docs/product/code-quality-audit.md`, appendices: +`docs/product/audit-notes/q-w0-t{1..4}-*.md`. Daniel approved **every disposition as proposed** on +2026-07-28. Six findings are fix-now *in Q-W0* — **T1-01** (linked-lag stereo splice alignment), +**T1-03** (Preserve prime bound + immediate `freezeTail()`), **T1-09** (`declickR_` dead-state +removal, riding the `sampler_core` edits), **T2-01(a)** (provenance wire-cursor hardening +backport), **T3-01** (gain-ramp seconds), **T3-03** (fade-ceiling seconds) — in flight on branch +`pq-w0-fixes`; **the Q-W1 sub-gate is satisfied once they land** (each with its module's CTest +target green; the audible DSP fixes with a stated before/after listening check). All other +fix-nows are assigned to the wave that already opens their file and are recorded in the wave specs +below. **The Q-11 question is answered:** the correlation-aligned SOLA pitch engine is **sound — +no technique replacement (phase-vocoder / WSOLA) warranted**; every pitch finding is a bounded +in-technique fix or a documented operating limit. + **Audit scope — the named surfaces:** 1. **DSP / audio, close eye on pitch.** Assess *algorithm quality* — correctness, artifacts, @@ -2395,10 +2413,11 @@ the subsystem map off the folders. ReaSampler adopts the *pattern* (directory = adapted to its own most load-bearing invariant — the pure/shell split — as the top level (see below). Vital is GPLv3; the borrowed artifact is the **structural pattern**, not code. -## Settled decisions (Q-1 settled; Q-2..Q-6 recommended — see `docs/product/code-organization.md` §6) +## Settled decisions (Q-1/Q-10/Q-11 settled 2026-07-27; Q-5/Q-6/Q-8/Q-9 + the audit's §4 forks settled 2026-07-28 — REC history kept; see `docs/product/code-organization.md` §6 and `docs/product/code-quality-audit.md` §4) - **Q-1 — namespace letter. SETTLED: `Q` (Quality).** Point-id family `Q1..Qn`, wave prefixes - `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg). `O` (Organization) + `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg; + **`Q-W2v`**, the + VST god-module wave added at the Q-W0 sign-off, 2026-07-28). `O` (Organization) was set aside: the glyph reads ambiguously against zero in point ids, and "Organization" undersells a phase measured against a *quality* bar. - **Q-2 — JSON extraction in scope + first. REC: yes.** The 4× duplicated `Parser` is the largest @@ -2413,17 +2432,43 @@ below). Vital is GPLv3; the borrowed artifact is the **structural pattern**, not `audio`/`ui`/`reclaim`/`version`/`json`. Directory and namespace agree; a symbol's home is unambiguous from either. - **Q-5 — god-module split granularity. REC: to the audit's named seams, no finer.** Well-factored, - not atomized. -- **Q-6 — OCP registration-table. REC: in scope, last (most droppable if narrowing).** + not atomized. **SETTLED (Daniel, 2026-07-28): seams-by-responsibility with the Q-W0 T4 seam + lists adopted** — `bank_panel` 6→8 seams (+ `panel_layout`, `panel_drag`; T4-01), + `capture_orchestrator` further split with `capture_batch` (T4-02) — **and the ~600-line file + ceiling is an acceptance criterion on every split wave**: seams are the method, the ceiling is + the bar; arbitrary bisection to hit the number is rejected. One documented exception: + `sampler_core.cpp` stays whole (T4-14/T4-27). +- **Q-6 — OCP registration-table. REC: in scope, last (most droppable if narrowing).** **SETTLED + (Daniel, 2026-07-28): in scope, last wave, as planned.** - **Q-7 — naming rides the relocation waves, no dedicated naming wave. REC: yes** (forced once Q-3/Q-4 settle — a rename is near-free during relocation, near-pure-churn standalone). - **Q-8 — class/module renames beyond the free namespace fix. REC: fix the two that actively mislead** — `BankIndex`→`BankModel` (the `bank_model.h`/`BankIndex` file↔class word-mismatch) and the unified JSON parser → `json::Reader`/`json::Writer` (or `json::Parser`) — **leave the merely-quirky** (`Book`/`Bank`/`Index`, `Sample`/`AudioSample`, `MinMax`, `KitBox`). Daniel's - to call. + to call. **SETTLED (Daniel, 2026-07-28): both renames** — `BankIndex`→`BankModel` (W1) and the + JSON parser minted as `json::Reader`/`json::Writer` (W1). From the audit, additionally: + **`ICaptureBackend` is deleted in Q-W3** (T4-26 — one deriver, zero polymorphic call sites; the + CLAUDE.md/CONTEXT "two backends behind one interface" correction **rides Q-W3's own commit**, + not earlier). - **Q-9 — align the `capture_realtime` (shell) / `realtime_record` (pure) word-order inversion. - REC: yes, during Q-W3** (a free rider — W3 already hoists the realtime lifecycle). + REC: yes, during Q-W3** (a free rider — W3 already hoists the realtime lifecycle). **SETTLED + (Daniel, 2026-07-28): yes** — the pure module takes the stem `capture_realtime`, the shell + takes the suffix (the `drag_out`↔`drag_out_win` model), during W3. +- **Audit §4a — VST placement. SETTLED (Daniel, 2026-07-28): T4-18** — `src/vst/` integrates into + the single `core/`/`shell/` top split as `core/instrument/{engine,map,ui}` + + `shell/instrument/`. One rule, no special case: a file's directory says whether it may touch a + *host* type (REAPER **or** VST3 SDK); the artifact boundary is a link-graph fact the sources + already straddle. The T4-19 artifact-first subtree was set aside. Directory map below updated. +- **Audit §4e — WAV/RIFF consolidation moment. SETTLED (Daniel, 2026-07-28): a named rider on + Q-W3** — one pure **`wav_codec`** owner (chunk walker + layout + build + patch), absorbing the + T4-10 ingest extraction (T2-08 / T4-23 / T4-10). The dedup-by-hash / null-test maintenance + surface gets exactly one implementation. +- **Audit §4f — Q-W2v scheduling. SETTLED (Daniel, 2026-07-28): parallel with Q-W2** (different + artifact, zero file overlap); the serial "Q-W7" alternative was set aside. +- **Audit §4b/§4c/§4d — the Q-W0 fix-now set. SETTLED (Daniel, 2026-07-28): all approved + fix-now** — T1-01 + T1-03 (with T1-09 riding), T2-01(a), T3-01 + T3-03; in flight on + `pq-w0-fixes`; landing them closes Q-W0 and opens Q-W1. ## The directory + namespace map (Q-3 / Q-4) @@ -2434,24 +2479,42 @@ directories. - `core/model/` (`::model`) — `bank_model`, `bank_book`, `owned_manifest`, `provenance` - `core/view/` (`::view`) — `view_mode_model`, `view_tree`, `lane_keys`, `mode_switch` - `core/capture/` (`::capture`) — `render_settings`, `batch_capture`, `tail_control`, - `capture_paths`, `wav_trim`, `insert_plan` + `capture_paths`, `wav_trim`, `insert_plan`; **post-Q-W3** `wav_codec` (the one pure WAV/RIFF + owner — audit §4e rider) - `core/audio/` (`::audio`) — `peaks` - `core/ui/` (`::ui`) — `theme`, `component_geometry`, `bank_grid`, `tab_strip`, `action_buttons`, `prune_button` - `core/reclaim/` (`::reclaim`) — `prune_reconcile` - `core/version/` (`::version`) — `app_version` -- `core/json/` (`::json`) — **NEW** — extracted parser/serializer (replaces the 4 duplicate - `Parser`s) +- `core/json/` (`::json`) — **NEW** — extracted parser/serializer, minted as + `json::Reader`/`json::Writer` (replaces the 5 duplicate hand-rolled decoders — the four + `Parser`s + `tail_control`'s, T2-02) +- `core/instrument/` — **T4-18 SETTLED (Daniel, 2026-07-28)** — the VST artifact's pure side + joins the one top split (directory = "may it touch a host type", REAPER *or* VST3 SDK): + - `core/instrument/engine/` — `sampler_core`, `pitch_shift`, `velocity_curve`, `master_gain` + - `core/instrument/map/` — `sample_map` (+ `component_state_io` post-Q-W2v), `bank_sync`, + `bridge_marshal`, `note_entry`, `trigger_seam` + - `core/instrument/ui/` — `editor_geometry`, `keyboard_strip`, `waveform_view`, + `capture_browser`, `browser_scroll`, `param_slider`, `knob_deck`, `curve_popup`, + `envelope_overlay`, `envelope_edit`, `embed_strip` **`shell/` (REAPER-facing — subdir by subsystem, namespace as house style prefers):** - `shell/capture/` — `capture`, `capture_realtime`, `provenance_shell`, `track_guid`, `item_read`; - **post-Q-W3** `capture_orchestrator`, `scope_resolve`, `realtime_lifecycle` + **post-Q-W3** `capture_orchestrator`, `capture_batch`, `scope_resolve`, `realtime_lifecycle`, + `capture_realtime_finalize` (with the Q-9 stem/suffix rename) - `shell/panel/` — `draw_kit`; **post-Q-W2** `panel_render`, `panel_thumbnails`, - `panel_audition`, `panel_input`, `panel_bank_ops`, `panel_window` (from `bank_panel`) + `panel_audition`, `panel_input`, `panel_layout`, `panel_drag`, `panel_bank_ops`, + `panel_window` (from `bank_panel` — eight seams, T4-01) - `shell/view/` — `view` - `shell/persist/` — **post-Q-W5** `session`, `ext_state_io`, `prune_fs` (from `persist`) - `shell/actions/` — `drag_out_win`; **post-Q-W4** `design_view_actions`, `bank_actions`, `prune_action` (from `actions`) +- `shell/instrument/` — `reaper_bridge`, `reasampler_embed`, `vst_entry`, `reasampler_vst.h` / + `reasampler_uid.h`; **post-Q-W2v** the editor TUs (`editor_session` / `editor_controls` / + `editor_paint_sample` / `editor_paint_browse_zone` / `editor_input_sample` / + `editor_input_browse_zone` / `editor_platform` — `editor_layout` hoists *pure* → + `core/instrument/ui/`, discharging T2-06) and the processor TUs (`processor_state` / + `processor_reload` / lifecycle+`process()`) **`app/`:** `main.cpp` (post-Q-W3: API-pointer ownership + `ReaperPluginEntry` + dispatch only). @@ -2459,37 +2522,83 @@ directories. unified `Parser` (`::json`) must not collide once flattened into granular namespaces; resolve by subsystem home. -## The god-module split seams (Q-5) +## The god-module split seams (Q-5 — SETTLED 2026-07-28: T4 seam lists adopted; ~600 ceiling is the bar) -Split each to the audit-validated seams, no finer: +Split each to the audit-validated seams, no finer — **and every shipped TU lands under the +~600-line ceiling** (the Q-5 settlement: seams are the method, the ceiling is the bar; arbitrary +bisection to hit the number is rejected; the one documented exception is `sampler_core.cpp`). +LOC figures updated to the Q-W0 T4 census (2026-07-28): -- **`bank_panel.cpp` (2424 LOC → `shell/panel/`, Q-W2):** `panel_render` (draw/paint) / - `panel_thumbnails` (compute+cache) / `panel_audition` (preview engine — **hot path, direct - call-through**) / `panel_input` (mouse/key/wheel + new-content detection) / `panel_bank_ops` - (bank CRUD — the single owner W4 dedupes against) / `panel_window` (lifecycle + OS - drag-out/drop-target). Split the fat `bank_panel.h` per seam (I). -- **`main.cpp` (1762 LOC → hoist to `shell/capture/`, Q-W3):** `capture_orchestrator` - (`RunCapture`/`captureAndIndexOne`/`renderOffline`/batch/recapture/realtime `Run*`) / - `scope_resolve` (range/razor/track resolution + provenance assembly inputs) / - `realtime_lifecycle` (state machine + globals + selection guards). `FxBypassGuard` moves out - but **stays stack RAII** (precision-critical). `main.cpp` → `app/`, reduced to pointers + entry - + dispatch. -- **`actions.cpp` (981 LOC → `shell/actions/`, Q-W4):** `design_view_actions` / +- **`bank_panel.cpp` (3459 LOC → `shell/panel/`, Q-W2 — eight seams, T4-01):** `panel_render` + (draw/paint) / `panel_thumbnails` (compute+cache) / `panel_audition` (preview engine — **hot + path, direct call-through**) / `panel_input` (mouse/key/wheel + new-content detection) / + **`panel_layout`** (toolbar/footer/menu rects + row/cluster builders + region geometry — new + seam) / **`panel_drag`** (the card-drag/hover state machine, pure mirror `card_drag` — new + seam) / `panel_bank_ops` (bank CRUD — the single owner W4 dedupes against) / `panel_window` + (lifecycle + OS drag-out/drop-target). Without the two new seams, `panel_render` (~700) and + `panel_input` (~800) would ship over the ceiling. Split the fat `bank_panel.h` per seam (I). +- **`main.cpp` (1897 LOC → hoist to `shell/capture/`, Q-W3 — four hoists, T4-02):** + `capture_orchestrator` (`RunCapture`/`captureAndIndexOne`/`renderOffline`/single-capture + + realtime/insert action bodies — lands ~450) / **`capture_batch`** (batch family + + `RunRecaptureFromSource` + the two RAII selection guards — new hoist; recapture is + planner-driven like batch and shares the guard machinery) / `scope_resolve` (range/razor/track + resolution + provenance assembly inputs) / `realtime_lifecycle` (state machine + globals). + `FxBypassGuard` moves out but **stays stack RAII** (precision-critical). `main.cpp` → `app/`, + reduced to pointers + entry + dispatch. **Wave riders (SETTLED 2026-07-28):** delete + `ICaptureBackend` + correct the CLAUDE.md/CONTEXT description in the same commit (T4-26); + shared `stampCaptureSample` epilogue dedupe (T2-09); `capture_realtime_finalize` split with + the Q-9 rename (T4-08); `makeUniqueTag` per-session monotonic counter (T1-11); the pure + `wav_codec` consolidation (audit §4e). +- **`actions.cpp` (1016 LOC → `shell/actions/`, Q-W4 — T4-03: seams unchanged, still + sub-600):** `design_view_actions` / `bank_actions` / `prune_action` (`doBankPruneFolder` — the single file-deletion action). Dedupe `promptText`/`mintBankId` + bank verbs against `panel_bank_ops`. -- **`persist.cpp` (766 LOC → `shell/persist/`, Q-W5):** `session` (lifecycle/poll + +- **`persist.cpp` (852 LOC → `shell/persist/`, Q-W5 — T4-04: seams unchanged; the pS-usage + growth landed exactly where this wave isolates it):** `session` (lifecycle/poll + `BeginLoadProjectState` reload hook) / `ext_state_io` (serialization bridge + GUID minting + folder relocation) / **`prune_fs`** (prune scan + `deleteOrphanFile` via `SHFileOperationW` — - the isolated single file-deletion authority). + the isolated single file-deletion authority). **Wave rider (T2-04):** the `GetProjExtState` + grow-loop ×3 dedupes onto `bridge_marshal`'s generalized retry policy — `usage_scan`'s + prune-safety-adjacent copy included. +- **`reasampler_editor.cpp` (3065 LOC — the largest file in the repo → `shell/instrument/`, + Q-W2v — eight TUs, T4-11; split axis = the Sample/Browse/Zone face structure):** + `editor_session` / `editor_controls` / `editor_layout` (**pure-candidate hoist** → + `core/instrument/ui/`, `editor_geometry` the named owner — discharges T2-06's + stranded-layout-math finding) / `editor_paint_sample` / `editor_paint_browse_zone` / + `editor_input_sample` / `editor_input_browse_zone` / `editor_platform`. Rider: adopt the pure + `ThumbnailKey` on the VST side while the editor is open (T2-10). +- **`reasampler_processor.cpp` (1164 LOC → `shell/instrument/`, Q-W2v — three TUs, T4-12):** + `processor_state` / `processor_reload` / lifecycle+`process()` kept whole. **No virtual seam + on the atomic-pointer-swap reload pattern (T4-29).** +- **`sample_map` (970 LOC + 708-line header → Q-W2v, T4-13 ≡ T2-07):** resolution core vs the + **`component_state_io`** binary ComponentState codec (+ matching header split) — the codec + grows every envelope bump (v6→v11 in one quarter); the extension stops linking the whole voice + engine to serialize one preset blob. The `core/wire` LE byte-codec template + (`putLE`/`readLE`, T4-20) lands with it. +- **`sampler_core.cpp` (968 LOC + 762-line header — Q-W2v, T4-14/T4-27): the TU stays WHOLE — + the documented hot-path exception to the ~600 ceiling.** Envelope `tick()`s run + per-voice-per-sample; same-TU definition is what lets the compiler inline the stack (no LTO in + the build); a by-class split is the exact heuristic-(3) dispatch blowout. The header splits + into `zone_params.h` + `sampler_core.h`. Recorded here so nobody "fixes" it later. ## The JSON extraction (Q-2 / Q-W1) -Extract one pure **`core/json`** (`::json`): parser (`parseString`/`parseInt`/`parseKey`/ +Extract one pure **`core/json`** (`::json` — minted as **`json::Reader`/`json::Writer`**, Q-8 +SETTLED 2026-07-28): parser (`parseString`/`parseInt`/`parseKey`/ `skipValue` + escape) + serialize/emit helpers. Rewire `bank_model`, `bank_book`, -`view_mode_model`, `owned_manifest` onto it and **delete their four hand-rolled `Parser`s**. +`view_mode_model`, `owned_manifest`, **and `tail_control` (T2-02 — the fifth hand-rolled decoder +the §2 audit undercounted)** onto it and **delete all five hand-rolled decoders**. Round-trip output must be **byte-identical** to before — this is a structural dedupe, not a format change. Off all hot paths (serialization runs at save/load, never per frame) — safe to abstract -freely. +freely. **W1 siblings (from the Q-W0 sign-off):** the shared `readFileBytes` pure helper +(T2-03); the length-prefixed wire-`Cursor` collapse into one shared wire codec beside +`core/json`, consumed by `provenance` / `assignment_request` / `sample_usage` / +`parseBankGeneration` (T2-01(b) — the structural half; the hardening backport lands in Q-W0); +the **concrete** `ui::Rect` unification + `contains()` + per-role aliases, retiring the +XYWH-vs-LTRB fork — NOT a template (T2-05 ≡ T4-21), with the `clamp01` rider (T4-24) and the +`hitIndex` template only as an opportunistic follow-on (T4-22); the `slot_map` extraction +(T4-05) and optional `view_mode_model` planner split (T4-06) riding files W1 already opens; the +relocation scope grows to the ~20 clean VST pure libs under `core/instrument/` (T4-18). ## The OCP registration-table (Q-6 / Q-W6) @@ -2515,7 +2624,7 @@ focus. The audit (2026-07-27) is grep-verified; the load-bearing findings: one `json::Parser` in Q-W1; the shared pure-UI rect types `FooterRect` / `ButtonRect` (defined in `prune_button.h`, reused by `footer_bar.h` under an explicit hand-collision "NAME NOTE") get one `ui::` owner; `Sample` (`model::`) vs `AudioSample` (`audio::`) de-collide by home. -- **Genuine renames (Q-8/Q-9 — Daniel's call):** `BankIndex`→`BankModel` (the `bank_model.h` +- **Genuine renames (Q-8/Q-9 — SETTLED, Daniel 2026-07-28: all land):** `BankIndex`→`BankModel` (the `bank_model.h` file↔class word-mismatch — the worst legibility wart, rec: rename the class so the model family reads `BankModel`/`BankBook`/`ViewModeModel`); the unified JSON parser named `json::Reader`/ `json::Writer` at W1 mint; align `capture_realtime`(shell)/`realtime_record`(pure) to the house @@ -2550,6 +2659,25 @@ the following are acceptance criteria on every point: **Net:** every recommended split falls on a cold path or preserves call/inline shape on the two hot ones. *A split that would add a hot-path indirection is out of scope — rework it or drop it.* +## Structural heuristics (Daniel, 2026-07-28 — acceptance criteria phase-wide) + +Three heuristics postdate the original framing and bind every wave. They **generalize** the +three-hot-path performance guardrail above — they do not replace it: + +1. **More directories is a must; more files is good; ~600-line file ceiling.** SRP applies to + namespaces, encapsulation, and file organization alike. The ceiling is the *bar*, the audit's + named seams are the *method*: a file landing over ~600 needs a responsibility seam, not an + arbitrary bisection (bisection-to-hit-the-number is rejected). A documented hot-path + exception (`sampler_core.cpp`, T4-14/T4-27) is legitimate; silent overshoot is not. +2. **Templates are the right tool for compile-time dedup — use them where earned.** The LE + byte-codec `putLE`/`readLE` (T4-20) is earned: compile-time dispatch, zero runtime cost, off + the hot paths. The rect family is NOT (T4-21): the types differ in name only, so one + **concrete** `ui::Rect` — a template there would model nothing. +3. **SOLID is great, but saved CPU is better.** No dispatch-stack blowouts *anywhere* — not just + the three named hot paths; prefer static polymorphism where the types are compile-time-known. + The T4-27 warning is the canonical case: a by-class `sampler_core` split would put virtual + envelope `tick()`s on the per-voice-per-sample path — exactly the blowout this forbids. + ## The GATE (load-bearing — Phase Q is last) Phase Q is **gated on the tree being otherwise quiescent.** Daniel's plain readiness target: @@ -2583,16 +2711,27 @@ every commit), a property only an *incremental* reorg uses. Risk-ordered: pre-restructure audit wave (Q-W0)"). Produces a written, triaged findings report; runs **first** and **gates Q-W1** — no structural point begins until the triage closes and Daniel signs off on every disposition. Fix-now findings are remediated here or folded into the wave that opens the - file; the report may add/reshape downstream Q-W1..Q-W6 points before they start. -- **Q-W1** — safe opener: `core/json` extract (delete 4 `Parser`s) + impose the directory/ + file; the report may add/reshape downstream Q-W1..Q-W6 points before they start. **COMPLETE + and signed off 2026-07-28; the sub-gate closes when the six fix-now remediations land + (`pq-w0-fixes`).** +- **Q-W1** — safe opener: `core/json` extract (delete the 5 hand-rolled decoders — T2-02 adds + `tail_control`) + impose the directory/ namespace layout on the 30 clean pure libs + clean shells (pure relocation, no logic change). All later waves assume this layout. **Carries the naming collision fixes + the model-class - renames (Q-8), which are free during this relocation.** -- **Q-W2..Q-W5** — the four god-module splits, one per wave, risk-ordered (`bank_panel` → - `main.cpp` → `actions.cpp` → `persist.cpp`). Q-W4 depends on Q-W2 (`panel_bank_ops` dedupe - target); Q-W5 best after Q-W4 (`prune_action` → `prune_fs` routing); otherwise parallel-safe. + renames (Q-8), which are free during this relocation.** **Scope grown by Q-W0 (2026-07-28):** + + `readFileBytes` (T2-03), the wire-`Cursor` codec (T2-01(b)), the concrete `ui::Rect` + unification (T2-05 ≡ T4-21) + `clamp01` (T4-24), the `slot_map`/planner riders (T4-05/T4-06), + and the ~20 clean VST pure libs under `core/instrument/` (T4-18). +- **Q-W2..Q-W5 (+ Q-W2v)** — the god-module splits, risk-ordered (`bank_panel` → + `main.cpp` → `actions.cpp` → `persist.cpp`), plus **Q-W2v** (NEW, added at the Q-W0 sign-off: + the VST god-modules — editor eight TUs / processor three TUs / `component_state_io`; + `sampler_core` TU whole, the documented exception), which **runs parallel with Q-W2** + (different artifact, zero file overlap — audit §4f SETTLED). Q-W4 depends on Q-W2 + (`panel_bank_ops` dedupe target); Q-W5 best after Q-W4 (`prune_action` → `prune_fs` routing); + otherwise parallel-safe. **Q-W2 carries the `panel_*` names; Q-W3 carries the `capture_realtime`/`realtime_record` - word-order fix (Q-9).** + word-order fix (Q-9) + the settled riders (`ICaptureBackend` deletion + doc correction, + `wav_codec`, `stampCaptureSample` dedupe, T1-11, `capture_realtime_finalize`).** - **Q-W6** — OCP registration-table + residual fat-header (I) splits. Depends on Q-W3 (registration code isolated first). Sequenced last; most droppable if narrowing. - **Naming (Q-7): no dedicated wave** — every rename rides the wave already relocating/splitting @@ -2624,7 +2763,9 @@ every commit), a property only an *incremental* reorg uses. Risk-ordered: realtime-tick — ever (a hard acceptance bar, not advice). - **No design-level (D-letter SOLID) rework.** `main`/`bank_panel` depending on concrete capture backends is a low-priority Dependency-Inversion concern — **out of scope** (a design change, not - a reorg). Phase Q reorganizes; it does not re-architect interfaces. + a reorg). Phase Q reorganizes; it does not re-architect interfaces. (Deleting the *dead* + `ICaptureBackend` abstraction in Q-W3 is the opposite move — removing a false interface with + one deriver and zero polymorphic call sites, T4-26 — and is in scope.) - **No `peaks` data-ownership change.** `peaks` forcing a whole-file `std::vector` copy on the thumbnail path is noted but **not touched** — reworking it risks the hot path. - **No big-bang commit.** Every wave is independently landable and CTest-green; reject a change set @@ -2632,5 +2773,7 @@ every commit), a property only an *incremental* reorg uses. Risk-ordered: - **Do not begin before the GATE.** Re-confirm the tree is quiescent (Phase S + L + D2 merged/closed; M9 abandoned) before any Q point. **And do not begin any structural point (Q-W1+) before the Q-W0 sub-gate:** the audit's triage is complete and Daniel has signed off on every finding's disposition. + (Sign-off given 2026-07-28; the sub-gate now closes when the six fix-now remediations land on + `pq-w0-fixes`.) - **Verify** the CMake `src/` path updates and the SWELL/LICE surfaces still resolve after relocation, as the existing build already requires. diff --git a/PLAN.md b/PLAN.md index 7d52c69..1f23b50 100644 --- a/PLAN.md +++ b/PLAN.md @@ -220,8 +220,21 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > downstream Q-W1..Q-W6 points; fixes that Q-W0 classifies fix-now are remediated in Q-W0 (or folded > into the wave that already touches the file), **not** deferred silently into the structural waves. > +> **Q-W0 SIGN-OFF: COMPLETE (Daniel, 2026-07-28).** The audit ran as four parallel tracks (T1 DSP, +> T2 architecture, T3 env-coupled constants, T4 sizing/placement — **59 findings**; report +> `docs/product/code-quality-audit.md`, appendices `docs/product/audit-notes/`), and **all 59 +> findings' dispositions are approved as proposed.** The Q-W1 sub-gate is satisfied **once the six +> approved fix-now remediations land** (in flight on branch `pq-w0-fixes`, Q-W0-scoped): T1-01, +> T1-03, T1-09, T2-01(a), T3-01, T3-03. The audit's §3 plan reshape is **folded into the waves +> below** (Q-W2 6→8 seams; NEW wave **Q-W2v** parallel with Q-W2; Q-W3 3→4 hoists + riders; Q-W5 +> + the ext-state-loop dedupe), and its §4 decision list is settled — see the settlement block +> below. The Q-11 question is answered by the audit: the SOLA pitch engine is **sound — no +> technique replacement warranted**; every pitch finding is a bounded in-technique fix or a +> documented operating limit. +> > **Settled (Q-1, this-doc):** the phase is **`Q` (Quality)**; point-id family `Q1..Qn`, wave -> prefixes `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg). +> prefixes `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg; **+ +> `Q-W2v`**, the VST god-module wave added at the Q-W0 sign-off, 2026-07-28). > **Settled (Q-10/Q-11, Daniel 2026-07-27):** Q-10 audit-report home = a **committed doc** > (`docs/product/code-quality-audit.md`, not a tracked issue list); Q-11 pitch-remediation depth = > **defer to findings** (default document-and-defer; weigh a bounded OLA fix before a technique @@ -238,6 +251,25 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > `BankModel`; the JSON `Parser`→`json::Reader`/`Writer`), leave the merely-quirky (rec); > Q-9 align the `capture_realtime`/`realtime_record` shell↔core word order during W3 (rec: yes).** > +> **SETTLED (Daniel, 2026-07-28 — with the Q-W0 sign-off; the REC record above kept as history):** +> **Q-5 SETTLED** — split to **seams-by-responsibility with the T4 seam lists adopted** +> (`bank_panel` 6→8 seams adding `panel_layout` + `panel_drag`, T4-01; `capture_orchestrator` +> further split with `capture_batch`, T4-02), and the **~600-line file ceiling is an acceptance +> criterion on every split wave** — seams are the method, the ceiling is the bar; arbitrary +> bisection to hit the number is rejected. **Q-6 SETTLED: in scope, last wave, as planned.** +> **Q-8 SETTLED: both renames** — `BankIndex`→`BankModel` (W1) and the JSON parser minted as +> `json::Reader`/`json::Writer` (W1); additionally from the audit, **`ICaptureBackend` is deleted +> in Q-W3** (T4-26 — one deriver, zero polymorphic call sites; the CLAUDE.md/CONTEXT "two +> backends behind one interface" correction **rides Q-W3 itself**, recorded as a rider — the docs +> are not edited before that wave). **Q-9 SETTLED: yes** — align to stem `capture_realtime`, +> shell suffixed, during W3. **VST placement (audit §4a) SETTLED: T4-18** — `src/vst/` integrates +> into the single `core/`/`shell/` top split as `core/instrument/{engine,map,ui}` + +> `shell/instrument/` (Q-3 directory map updated in CONTEXT.md §Phase Q). **WAV/RIFF +> consolidation (audit §4e) SETTLED:** a named rider on **Q-W3** — one pure **`wav_codec`** owner +> (walker + layout + build + patch), absorbing the T4-10 ingest extraction. **Q-W2v scheduling +> (audit §4f) SETTLED: parallel with Q-W2** (different artifact, zero file overlap; the serial +> "Q-W7" alternative set aside). +> > **HARD CONSTRAINT — performance (see CONTEXT.md §Phase Q, `docs/product/code-organization.md` > §3).** The reorg must cost **zero runtime.** On the three hot paths — `peaks` envelope > compute, audition/preview, the realtime-capture tick — **no added virtual dispatch, no @@ -247,6 +279,21 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > an acceptance criterion on every point: *a split that would add a hot-path indirection is out > of scope — rework it or drop it.* > +> **STRUCTURAL HEURISTICS (Daniel, 2026-07-28 — acceptance criteria on every wave; these +> *generalize* the three-hot-path guardrail above, they do not replace it):** +> (1) **More directories is a must, more files is good, ~600-line file ceiling** — SRP applies to +> namespaces, encapsulation, and file organization alike. The ceiling is the *bar*, the audit's +> named seams are the *method*: a file landing over ~600 needs a responsibility seam, not an +> arbitrary bisection; a documented hot-path exception (`sampler_core.cpp`, T4-14/T4-27) is +> legitimate, silent overshoot is not. +> (2) **Templates are the right tool for compile-time dedup — use them where earned** (the LE +> byte codec `putLE`/`readLE`, T4-20), not for name-only unification (the rect family is one +> **concrete** `ui::Rect`, NOT a template — T4-21's ruling). +> (3) **SOLID is great but saved CPU is better** — no dispatch-stack blowouts *anywhere*, not +> just the three named hot paths; prefer static polymorphism where types are compile-time-known +> (T4-27's warning is the canonical case: a by-class `sampler_core` split would put virtual +> envelope `tick()`s on the per-voice-per-sample path). +> > **NAMING dimension (added 2026-07-27; grep-verified audit in `docs/product/code-organization.md` > §2b).** Beyond giving symbols a directory + namespace *home* (Q-3/Q-4), Phase Q also gives > poorly/inconsistently-named symbols a consistent *name*, against the same Vital bar. The audit @@ -265,9 +312,25 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that > per-module static-lib + per-module test-executable seams already draw the module boundaries; > a file move + namespace change is mechanically verifiable — `ctest --test-dir build` is green > or it isn't. **Green-CTest-at-every-point is an acceptance criterion.** Big-bang is rejected; -> the reorg is risk-ordered waves (W1 safe opener → W2–W5 god-module splits → W6 OCP finish). +> the reorg is risk-ordered waves (W1 safe opener → W2/W2v–W5 god-module splits → W6 OCP finish). +> +> **PHASE STATUS (2026-07-29): all seven waves (Q-W0..Q-W6 incl. Q-W2v) are structurally +> COMPLETE.** Remaining before the phase closes and merges to `dev`: (1) Daniel's in-DAW +> verification batch — the full deferred list across all waves (panel parity, editor/processor +> parity, stereo Preserve listening, null test, bit-identical repeats, capture flows, action +> families, one-op-one-Ctrl-Z, prune fail-safes, save/load/relocation) — now unblocked since the +> tree is stable; (2) the phase-close CLAUDE.md architecture refresh (module map still describes +> some pre-Q homes); (3) the phase-q → dev merge on Daniel's sign-off. See `COMPLETED.md` for +> each wave's full landed narrative. ## Q-W0 — pre-restructure functional + DSP quality audit (runs FIRST; gates Q-W1) +**STATUS (2026-07-29): audit COMPLETE, triage COMPLETE, sign-off COMPLETE, fix-now +remediations LANDED — Q-W0 is fully closed.** The findings report is committed +(`docs/product/code-quality-audit.md`; track appendices in `docs/product/audit-notes/` — T1 +DSP, T2 architecture, T3 env-constants, T4 sizing/placement; 59 findings). Daniel approved +every disposition 2026-07-28. The six approved fix-now remediations plus seven review riders +landed 2026-07-28 (merge `546927e`) — see `COMPLETED.md`. **Q-W1 has since landed on top of +this closure** (see `COMPLETED.md`). **Goal:** Before a single structural point moves, perform a **thorough static/functional audit** of the codebase and produce a **written, triaged findings report**. This is the *functional-correctness and algorithm-quality* complement to the grep-verified SOLID/naming audit that already grounds @@ -322,165 +385,166 @@ before/after listening or null check. **The gate to Q-W1 is: triage complete + D are a decision, not an omission. - [ ] **Sign-off gate.** Daniel reviews the triage and signs off on each disposition. Q-W1 does not begin until this is done; fold any new/reshaped downstream points the audit surfaces into - Q-W1..Q-W6 before starting them. + Q-W1..Q-W6 before starting them. **DONE (Daniel, 2026-07-28): all 59 dispositions approved as + proposed; the §3 plan reshape and §4 decisions are folded into Q-W1..Q-W6 + Q-W2v below.** ## Q-W1 — safe opener: extract `core/json` + impose the directory/namespace layout on clean modules -**Goal:** The zero-god-module-risk opener. Two moves: (1) extract a pure **`core/json`** module -(parser + serializer) and **delete the four hand-rolled `Parser`s** in `bank_model` / -`bank_book` / `view_mode_model` / `owned_manifest` (the single largest DRY+SRP violation, and -entirely off the hot paths); (2) impose the settled `core/`/`shell/`/`app/` directory layout + -sub-namespaces (`reasampler::model`/`view`/`capture`/`audio`/`ui`/`reclaim`/`version`/`json`) on -the **30 clean pure libs + the clean shells that need no splitting** — pure relocation, no logic -change. Proves the wave discipline (relocate + encapsulate, CTest-green) before any god-module -surgery. CONTEXT.md §Phase Q (json extraction; directory + namespace map). -**Verify:** CTest green at every commit. The four duplicate `Parser`s are gone, replaced by one -`core/json` consumed by all four models; round-trip serialization is byte-identical to before -(no format change — a *structural* dedupe, not a behavior change). Every relocated clean module -compiles and its test executable passes unmoved. `Sample` (model) vs `AudioSample` (audio) vs -unified `Parser` (json) do not collide once sub-namespaced. No REAPER type crosses into any -`core/` file; the CMake pure/shell enforcement still holds. -**Depends on:** the GATE (tree quiescent) **and Q-W0 closed** (audit triaged + Daniel signed off; -any fix-now findings the audit assigned to Q-W1 folded in). First structural wave. -- [ ] Extract `core/json` (pure parser + serializer: parseString/parseInt/parseKey/skipValue + - escape, plus emit helpers); unify under `reasampler::json`; guard the `Parser` name against - cross-lib collision. Off all hot paths — safe to abstract freely. -- [ ] Rewire `bank_model`, `bank_book`, `view_mode_model`, `owned_manifest` onto `core/json`; - **delete the four duplicate `Parser`s.** Round-trip output byte-identical (dedupe, not - reformat). -- [ ] Relocate the 30 clean pure libs into `core/{model,view,capture,audio,ui,reclaim,version, - json}/` and the clean shells into `shell/{capture,panel,view,persist,actions}/`; move - `main.cpp` to `app/`. Update `CMakeLists.txt` `src/` paths only (no target-graph change). -- [ ] Apply sub-namespaces matching the directories on every relocated *clean* module (the - god-modules re-namespace their own new TUs as they split, W2–W5). Resolve `Sample`/ - `AudioSample`/`Parser` homes. **This alone resolves the naming *collisions*** (§2b.2): the - shared pure-UI rect types (`FooterRect`/`ButtonRect`/`Selection`/`CellRect`) get one `ui::` - owner — retire the hand-collision "NAME NOTE" in `footer_bar.h`. -- [ ] **Naming riders (Q-8, if settled):** rename the survivor JSON parser to `json::Parser` - (or `json::Reader`/`json::Writer`); if Daniel takes the `BankIndex`→`BankModel` rename, land - it here (mechanical class rename, verified by `bank_model_tests`). No rename on a file this - wave isn't already relocating (Q-7). -- [ ] Confirm CTest green + no hot-path change: `peaks`/audition/realtime-tick untouched by this - wave (pure relocation of clean modules; `peaks` stays a free function). +> **Landed on `phase-q` (2026-07-29). Integrated suite 60/60 green.** `core/json` +> (`json::Reader`/`json::Writer`) extracted; the five hand-rolled JSON decoders (incl. +> `tail_control`'s) deleted; the wire-`Cursor` family collapsed into `core/wire`; the shared +> `readFileBytes` helper added; ~50 clean modules relocated into `core/{model,view,capture, +> audio,ui,reclaim,version,json,util,wire}/`, `core/instrument/{engine,map,ui}/`, +> `shell/{capture,panel,view,persist,actions,instrument}/`, `app/main.cpp`; sub-namespaces +> applied; one concrete `ui::Rect` + aliases (LTRB fork + `footer_bar` NAME NOTE retired); +> `slot_map` extracted from `bank_book`; `clamp01` deduped; `BankIndex`→`BankModel`; +> `reasampler_uid.h` relocated to `core/wire/`. See `COMPLETED.md` for the full narrative. +> +> **Skipped/deferred riders:** T4-22 (`hitIndex` hit-test template) — not trivial, deferred as +> an opportunistic follow-on once the rect unification is in use downstream; T4-06 +> (`view_mode_model` planner split) — optional, deferred; T4-09 (`view_lanes` split) — +> deferred (in scope only if a later wave touches `view.cpp` anyway). +> +> **Open residual — `bank_book.cpp` still 737 LOC.** The serialize/deserialize seam is +> identified but blocked on a `nameKey` linkage design decision, escalated to Daniel and +> **pending** as of 2026-07-29. Downstream waves touching `bank_book` should check this +> residual before assuming the split is finished. +> +> An interim `core/namespaces.h` shim covers the six not-yet-split god TUs; each downstream +> split wave (Q-W2 onward) retires its own includes of it as that module splits. -## Q-W2 — split `bank_panel.cpp` (the biggest god-module, 2424 LOC) -**Goal:** Split the largest god-module (8+ responsibilities) along the audit's named seams: -`panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` / -`panel_window`. Split the fat `bank_panel.h` alongside (Interface Segregation). **Preserve the -audition hot path as a direct call-through, never virtual.** `panel_bank_ops` becomes the single -home for the bank-CRUD verbs that W4 will dedupe `actions.cpp` against. CONTEXT.md §Phase Q -(bank_panel split seams; hot-path audition guardrail). See `docs/product/code-organization.md` -§2.1, §5. -**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/`; the panel -draws, thumbnails, auditions, handles input, does bank ops, and manages its window exactly as -before (no behavior change — verify in DAW that the panel is visually and interactively -unchanged). Audition/preview call path stays a **direct call-through** (no virtual dispatch, no -added header→TU indirection on the preview path). The ~20-function public API is now segmented -across the split headers. -**Depends on:** Q-W1 (directory/namespace layout established). Independently landable. +## Q-W2 — split `bank_panel.cpp` (the biggest extension god-module — 3459 LOC at the Q-W0 census) -- [ ] Split rendering (`draw*`/`paint*`) → `panel_render`; thumbnail compute+cache → - `panel_thumbnails`. -- [ ] Split the audio audition/preview engine → `panel_audition` — **direct call-through, not - virtual; preview idle path unchanged.** -- [ ] Split input handling (mouse/key/wheel) + new-content detection → `panel_input`; window - lifecycle + OS drag-out/drop-target → `panel_window`. -- [ ] Extract bank-CRUD verbs → `panel_bank_ops` (the future single owner; W4 dedupes - `actions.cpp` against it). Split `bank_panel.h` into per-seam headers (I). -- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added. +> **Landed on `phase-q` (2026-07-29, merge of `pq-w2-panel`). Integrated suite 61/61 green, +> reviewed-approved.** `bank_panel.cpp` (3459 LOC) split into eight TUs under `shell/panel/`: +> `panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` / +> `panel_window` / `panel_layout` / `panel_drag`, plus per-seam public headers and internal +> `panel_state.h`; audition stays a direct call-through; the one-bank-op-one-undo invariant is +> preserved; ~50 TU-private helpers wrapped in anonymous namespaces (a review follow-up). See +> `COMPLETED.md` for the full narrative. +> +> **Recorded ceiling overages (reviewer-endorsed, preserved as a durable record per CONTEXT's +> "silent overshoot is not legitimate" rule):** `panel_input.cpp` 636, `panel_render.cpp` 613, +> `panel_state.h` 608 — the overage is comment volume; non-comment lines are ~322–369 per file; +> no honest seam remains; bisection was rejected. +> +> **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed +> prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)` +> etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with +> `actions.cpp` and are the cheapest first dedupe. +> +> **In-DAW verification (panel parity) is PENDING on `phase-q`** — deferred by design, not yet +> performed. + +## Q-W2v — split the VST god-modules (NEW wave — Q-W0 T4 §1.5; runs parallel with Q-W2) + +> **Landed on `phase-q` (2026-07-29, merge of `pq-w2v-vst`). Integrated suite 61/61 green, +> reviewed-approved.** `reasampler_editor.cpp` (3084 LOC, the largest file in the repo) split +> into eight face-axis TUs under `shell/instrument/`, with pure layout hoisted into +> `core/instrument/ui/editor_geometry` (discharges T2-06, newly tested); `reasampler_processor.cpp` +> split into `processor_state` / `processor_reload` / lifecycle+`process()` kept whole (no +> virtual seam, T4-29); `sample_map` split into a resolution core + `component_state_io` codec +> (the extension preset path no longer links the voice engine — link-proven; T4-13 ≡ T2-07); +> `sampler_core.cpp` stays whole with the documented hot-path exception comment (T4-14/T4-27); +> `zone_params.h` split out; `core/wire/bytes.h` (`putLE`/`ByteReader`) lands (T4-20); +> `ThumbnailKey` adopted (T2-10); a golden full-blob v11 fixture pins the codec bytes. The +> `src/vst/` directory is gone. See `COMPLETED.md` for the full narrative. +> +> **Deferred/known:** `component_state_io.h` still includes `sample_map.h`→`sampler_core.h` +> transitively (T2-07's header half — future work); the `engine` namespace is deferred +> (`sampler_core` stays flat `reasampler`); capture-side LE rewires are left for the capture +> family. +> +> **In-DAW verification (editor/processor parity) is PENDING on `phase-q`** — deferred by +> design, not yet performed. ## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch) -**Goal:** Reduce `main.cpp` (1762 LOC) to its actual job — API pointers + `ReaperPluginEntry` + -dispatch (~the owns-pointers ~120 lines) — by hoisting: `capture_orchestrator` (`RunCapture` / -`captureAndIndexOne` / `renderOffline` / batch/recapture/realtime `Run*`), `scope_resolve` -(`resolveRange`/`resolveRazorRange`/`collectSelectedTracks` + provenance assembly inputs), and -`realtime_lifecycle` (the realtime-capture state machine + globals + selection guards). -**`FxBypassGuard` moves out but stays a stack RAII object (precision-critical); the realtime idle -tick stays a single pointer test.** CONTEXT.md §Phase Q (main split seams; FxBypassGuard + -realtime-tick guardrails). See `docs/product/code-organization.md` §2.1, §3. -**Verify:** CTest green at every commit. Capture (offline + realtime + batch + recapture) behaves -identically in DAW; the null test still nulls, bit-identical repeats still match (the precision -invariants `FxBypassGuard` protects are unchanged); capture ≠ placement holds (no hoisted `Run*` -path gains an `InsertMedia` call). The realtime idle fast-path is still a single pointer test. -`main.cpp` is now pointers + entry + dispatch only. -**Depends on:** Q-W1. Independent of Q-W2. -- [ ] Hoist capture orchestration → `capture_orchestrator` (`shell/capture/`); keep - `FxBypassGuard` a **stack RAII** object as it moves (precision-invariant-critical). -- [ ] Hoist scope/source resolution + provenance assembly inputs → `scope_resolve`. -- [ ] Hoist the realtime-capture lifecycle state machine + globals + the two RAII selection - guards → `realtime_lifecycle`; **idle tick stays a single pointer test.** -- [ ] Leave `main.cpp` = API-pointer ownership + `ReaperPluginEntry` + dispatch; move to `app/`. -- [ ] **Naming rider (Q-9, if settled):** align the `capture_realtime` (shell) / `realtime_record` - (pure) word-order inversion to the house shell↔core convention (rec: stem `capture_realtime`, - shell suffixed) — a free rider since W3 already hoists the realtime lifecycle. No rename on a - file this wave isn't already touching (Q-7). -- [ ] Verify in DAW: null test nulls, bit-identical repeats match, capture≠placement holds; - CTest green; no realtime-tick branch-shape change. +> **Landed on `phase-q` (2026-07-29, merge of `pq-w3-main`). Integrated suite 61/61 green, +> reviewed-approved.** `app/main.cpp` reduced 1897 → 653 LOC (pointers + entry + dispatch; the +> remaining bulk is the registration residue Q-W6 dissolves) via four hoists into +> `shell/capture/`: `capture_orchestrator`, `capture_batch`, `scope_resolve`, +> `realtime_lifecycle`; `FxBypassGuard` moved intact as a stack RAII object; the realtime idle +> tick stays a single pointer test; `ICaptureBackend` deleted (T4-26) with the +> CLAUDE.md/CONTEXT-ARCHIVE corrections landed in the same commit; the Q-9 rename done (pure +> `core/capture/capture_realtime`, shell `capture_realtime_shell` + `capture_realtime_finalize` +> split, T4-08); `stampCaptureSample` dedupe (T2-09, divergent time-sig behavior preserved via +> caller arg); `makeUniqueTag` gains a per-session monotonic counter (T1-11 behavior fix — stems +> now `-` / `rt--`; the per-process residual is documented in-code); one pure +> `wav_codec` RIFF owner absorbs `wav_trim` + `ingest`'s WAV build + content hashes, with golden +> hash literals pinned (`wav_codec_tests` replaces `wav_trim_tests`; `capture_realtime_tests` +> replaces `realtime_record_tests`). See `COMPLETED.md` for the full narrative. +> +> **Known open:** `wav_trim.h`'s transitional forwarding shim still has three live includers +> (`sample_map.h`, `editor_session.cpp`, `processor_reload.cpp`) — repoint-and-retire is a named +> follow-up; `ingest.cpp` is trimmed to 567 LOC but keeps the `namespaces.h` shim (`ingest` + +> `view` remain the shim's unowned consumers). +> +> **In-DAW verification (null test, bit-identical repeats) is PENDING on `phase-q`** — deferred +> by design, not yet performed. ## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops` -**Goal:** Split the two unrelated command-id families in one TU (981 LOC) into -`design_view_actions` / `bank_actions` / `prune_action`, and **dedupe** `actions.cpp`'s own -`promptText`/`mintBankId` and bank verbs against the `panel_bank_ops` single-owner established in -Q-W2. `prune_action` keeps the `doBankPruneFolder` deletion authority contract intact (routes to -`persist`'s `prune_fs` after W5). CONTEXT.md §Phase Q (actions split seams; bank-verb dedupe). -See `docs/product/code-organization.md` §2.1, §2.4. -**Verify:** CTest green at every commit. Every action fires identically in DAW (Design View -family; multi-bank create/rename/reorder/delete/evacuate/activate/move/copy/remove; prune). The -bank-CRUD verbs have **one** implementation home (no `bank_panel`/`actions` duplication). Each -bank verb still wraps its mutation in one batched undo point; the prune action still writes no -ext state and opens no undo point. Command-id strings are **unchanged** (FOREVER-STABLE -contract — a reorg must not touch a shipped command id). -**Depends on:** Q-W2 (`panel_bank_ops` is the dedupe target). Independent of Q-W3. -- [ ] Split → `design_view_actions` (toggle/activate/tag/untag/showBoth/moveItems), - `bank_actions` (bank CRUD family), `prune_action` (`doBankPruneFolder` — the single - file-deletion action). -- [ ] Dedupe `actions.cpp`'s `promptText`/`mintBankId` + bank verbs against `panel_bank_ops` - (one owner); do **not** change any command-id string. -- [ ] Verify in DAW: all action families fire unchanged; one bank op = one Ctrl-Z; prune still - no-undo/no-ext-state; CTest green. +> **Landed on `phase-q` (2026-07-29, merge of `pq-w4-actions`). Integrated suite 61/61 green, +> reviewed-approved.** `actions.cpp` (1019 LOC) split into `design_view_actions` / `bank_actions` +> / `prune_action`, plus a fourth shared `action_registry` TU, all under `shell/actions/`; +> `promptText`/`mintBankId` deduped against `panel_bank_ops`; bank verbs reshaped to promptless +> inner verbs (one mutation home, two UX skins — panel and actions each keep their exact prior +> UX); command-id suffixes/display phrases verified byte-identical in review; `prune_action` +> stays a clean deletion-authority isolate (no `Undo_*`, no ext-state writes); +> `persistBankOp`/`persistBook` gain null-session guards; `promptText` renamed `promptBankName`. +> See `COMPLETED.md` for the full narrative. +> +> **Review note (🟡, resolved in Q-W6):** two session pointers / a null-session-as-model-rejection +> misreport (unreachable today) was resolved by Q-W6's `bank_ops` lift. +> +> **In-DAW verification (action families, one-op-one-Ctrl-Z, prune fail-safes) is PENDING on +> `phase-q`** — deferred by design, not yet performed. ## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`) -**Goal:** Split `persist.cpp` (766 LOC, 5 responsibilities) into `session` (lifecycle+poll, -`BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON serialization bridge + -GUID minting + folder relocation), and **`prune_fs`** (prune scanning + `deleteOrphanFile` via -`SHFileOperationW`). The split **concentrates** the byte-deleting authority into one obvious -module — it must never spread it. CONTEXT.md §Phase Q (persist split seams; deletion-authority -isolation). See `docs/product/code-organization.md` §2.1, §7. -**Verify:** CTest green at every commit. Session save/load/undo-reload, ext-state round-trip, -folder relocation, and prune deletion all behave identically in DAW. **File deletion lives in -exactly one module (`prune_fs`)** — the single-file-deletion-authority invariant is *improved* -(concentrated), never diluted. Relative-paths-only persistence is unchanged. -**Depends on:** Q-W1. Best after Q-W4 (so `prune_action` routes cleanly to `prune_fs`), but -independently landable. -- [ ] Split → `session` (lifecycle/poll + `projectconfig` reload hook), `ext_state_io` - (serialization bridge + GUID minting + folder relocation). -- [ ] Isolate prune scanning + `deleteOrphanFile` (`SHFileOperationW`) → **`prune_fs`** — the - one file-deletion module; nothing else may delete bytes. -- [ ] Verify in DAW: save/load/undo-reload/relocation/prune unchanged; deletion authority is one - module; relative-paths-only holds; CTest green. +> **Landed on `phase-q` (2026-07-29, merge of `pq-w5-persist`). Integrated suite 61/61 green, +> reviewed-approved.** `persist.cpp` (853 LOC) split into `session` / `ext_state_io` / `prune_fs` +> under `shell/persist/` + `persist_internal.h`; the file-deletion authority is concentrated — +> `SHFileOperationW`/orphan-remove lives in exactly one anonymous-namespace function in +> `prune_fs.cpp`, verified tree-wide; the prune fail-safe chain stays byte-intact. T2-04's +> `GetProjExtState` grow-loop is unified as a header-only template, with all three hand-rolled +> copies rewired (`usage_scan`'s start cap raised 4KB→64KB, allocation-only, verified +> equivalent). The Q-W1 `bank_book_json` residual lands via a private static `nameKey` +> (Daniel-approved option a) — `bank_book.cpp` is now ~462 LOC. `persist.h` is kept as a compat +> umbrella for parallel safety (retired in Q-W6); deletion-authority wording is scoped precisely +> in headers; the grow-loop gains a defensive NUL. See `COMPLETED.md` for the full narrative. +> +> **In-DAW verification (save/load/undo-reload, ext-state round-trip, folder relocation, prune +> deletion) is PENDING on `phase-q`** — deferred by design, not yet performed. ## Q-W6 — OCP registration-table + residual fat-header (I) splits -**Goal:** Close the last SOLID wart: replace the ~350-line hand-written **non-table** action -registration blocks (now isolated in `app/main.cpp` after Q-W3) with a **registration table**, so -adding an action edits one place, not four parallel ones (OCP). Split any remaining fat headers -(`capture.h`/`persist.h`) not already resolved by their TU splits (I). CONTEXT.md §Phase Q (OCP -registration-table). See `docs/product/code-organization.md` §2.3, §6 (Q-6). -**Verify:** CTest green at every commit. Every action still registers, appears in the Actions -list, and fires via `hookcommand` exactly as before; command-id + display strings unchanged -(FOREVER-STABLE, per-channel); unload still mirror-unregisters everything. Adding a hypothetical -new action now touches the table only (demonstrated in review, not shipped). Remaining fat -headers are segmented. -**Depends on:** Q-W3 (registration code must be isolated first). Sequenced last; the most -droppable point if the phase needs narrowing (Q-6). -- [ ] Convert the hand-written `Register("command_id"/"gaccel"/"hookcommand")` blocks to a - data-driven registration table; unload mirror-unregisters from the same table. -- [ ] Split residual fat headers (`capture.h`/`persist.h` and any other) alongside their TUs (I). -- [ ] Verify: all actions register/fire/unregister unchanged; command-id strings untouched; CTest - green. +> **Landed on `phase-q` (2026-07-29). Integrated suite 61/61 green, reviewed-approved.** Action +> registration/gaccel/hookcommand-dispatch/mirror-unregister all iterate one `ActionTableRow` +> table (flat function-pointer dispatch, no `std::function`/virtual); adding a new action now +> touches one table row only; `main.cpp` shrinks 653→404. FOREVER-STABLE suffixes/phrases/ +> retired-ids verified byte-identical row-by-row in review; capture rows derive their +> suffix+phrase from the pure `captureActionTable()` (the parallel-list risk is gone by +> construction). See `COMPLETED.md` for the full narrative. +> +> **Phase-end cleanup riders (landed in this wave):** `bankOp*` verbs + `persistBankOp` lifted to +> new `shell/bank_ops` taking `ReaSamplerSession&` (dissolves the Q-W4 🟡 review note); +> `persist.h` umbrella retired (13 callers repointed); `capture.h`'s realtime seam moved to +> `capture_realtime_shell.h`; the `wav_trim.h` shim + its INTERFACE target deleted; +> **`core/namespaces.h` DELETED** (the interim Q-W1 shim's contract fulfilled — ~26 includers +> rewired); the grow-loop rehomed to `core/wire/ext_state_read.h`; a stale-comment sweep +> (`persist.cpp`/`bank_panel.cpp` refs); CLAUDE.md's persist/bank_book/actions/wav_codec bullets +> corrected in-wave. +> +> **Review-noted follow-on (not landed, deferred):** extending the table pattern to the +> design_view/bank/ingest families' hand-registration; `channelIdFor`'s shared string-store scan +> is correct-by-prefix-disjointness — a suffix-keyed map would make it structural, but isn't +> required; `view_mode_model.h` (748 LOC) remains the largest header (T4-06's planner split +> stays optional/deferred). +> +> **In-DAW verification (all action families, registration/fire/unregister parity) is PENDING on +> `phase-q`** — deferred by design, not yet performed. ## Phase Q — sequencing ``` @@ -488,27 +552,51 @@ GATE: Phase S + Phase L L3 merged to dev (D2 complete, M9 abandoned) — tree qu ("when Phase S and L3 are finished" — L1/L2/L3/L4–L7 all landed — GATE SATISFIED) │ ▼ -Q-W0 (pre-restructure functional + DSP quality audit — findings report + triage) - │ ── SUB-GATE: triage complete + Daniel signed off on every disposition ── - ▼ (fix-now findings remediated/assigned; downstream Q-W1..W6 reshaped as needed) -Q-W1 (safe opener: core/json extract + directory/namespace layout on clean modules) - ├─► Q-W2 (split bank_panel) ──► Q-W4 (split actions + dedupe bank verbs vs panel_bank_ops) - ├─► Q-W3 (split main.cpp; hoist orchestration) ──► Q-W6 (OCP registration-table + I splits) - └─► Q-W5 (split persist; isolate prune_fs) [best after Q-W4] +Q-W0 (audit + triage + report — COMPLETE; all 59 dispositions signed off 2026-07-28; + │ fix-now remediations LANDED 2026-07-28) + ▼ +Q-W1 (safe opener: core/json ×5 + wire codec + rect unification + relocation incl. ~20 VST + │ pure libs under core/instrument/{engine,map,ui} + riders — LANDED 2026-07-29) + ├─► Q-W2 (split bank_panel — 8 seams — LANDED 2026-07-29) + │ └─► Q-W4 (split actions + dedupe vs panel_bank_ops — LANDED 2026-07-29) + ├─► Q-W2v (VST god-modules — editor 8 TUs / processor 3 TUs / component_state_io; + │ sampler_core TU whole — documented exception — LANDED 2026-07-29) + │ [parallel with Q-W2: zero overlap] + ├─► Q-W3 (split main — 4 hoists incl. capture_batch; + wav_codec, ICaptureBackend deletion, + │ stamp dedupe, T1-11, capture_realtime_finalize — LANDED 2026-07-29) + │ └─► Q-W6 (OCP registration-table — LANDED 2026-07-29) + └─► Q-W5 (split persist; + ext-state-loop dedupe — LANDED 2026-07-29) [best after Q-W4] + +STATUS (2026-07-29): all seven waves (Q-W0..Q-W6 incl. Q-W2v) structurally COMPLETE, 61/61 +integrated suite green. Remaining: Daniel's in-DAW verification batch, the phase-close +CLAUDE.md architecture refresh, and the phase-q → dev merge on sign-off. ``` -Q-W0 is the **entry point** — the functional/DSP audit runs FIRST and gates Q-W1 (no structural -point begins until its triage closes and Daniel signs off). W1 is then the safe, high-leverage -structural opener (all later waves assume the layout it establishes). The four god-module splits -(W2–W5) are risk-ordered and mostly parallel-safe; W4 depends on W2's `panel_bank_ops`, W6 depends -on W3's isolated registration code. Big-bang is rejected — every wave is independently landable and -CTest-green. +Q-W0 ran and closed 2026-07-28 (its six fix-now remediations landed the same day). W1 was the +safe, high-leverage structural opener (all later waves assumed the layout — including the T4-18 +`instrument/` placement — it establishes). The god-module splits (W2, W2v, W3, W5) were +risk-ordered and mostly parallel-safe; **Q-W2v ran parallel with Q-W2** (different artifact, zero +file overlap — audit §4f SETTLED); W4 depended on W2's `panel_bank_ops`, W6 depended on W3's +isolated registration code. Big-bang was rejected — every wave landed independently, +CTest-green throughout. **All seven waves landed on `phase-q` by 2026-07-29 — Phase Q is +structurally complete** (see the phase preamble's PHASE STATUS block for what remains before the +phase closes and merges to `dev`). ## Phase Q — must-verify-before-build - **Q-W0 closed before any structural point** — the functional/DSP audit's findings report exists, every finding is triaged (fix-now vs. document-and-defer, each with rationale), fix-now findings are remediated or assigned to the wave that opens their file, and **Daniel has signed off on every disposition.** Q-W1 does not begin otherwise. (CONTEXT.md §Phase Q Q-W0; naming/DSP smell - categories §2c of `docs/product/code-organization.md`.) + categories §2c of `docs/product/code-organization.md`.) **Status 2026-07-28: triage + sign-off + COMPLETE (all 59 dispositions); the sub-gate closes when the six fix-now remediations land + (`pq-w0-fixes`).** +- **~600-line ceiling on every split wave** — every TU a split wave ships lands under ~600 LOC, + with `sampler_core.cpp` the single documented exception (T4-14/T4-27). Seams are the method, + the ceiling is the bar; arbitrary bisection to hit the number is rejected (Q-5 settlement, + 2026-07-28). +- **No dispatch-stack blowouts anywhere** — heuristic (3) generalizes the hot-path guardrail + beyond the three named paths: prefer static polymorphism where types are compile-time-known; + templates only where earned for compile-time dedup (T4-20 yes; T4-21's rect NO-template + ruling). - **Hot-path call/inline shape** — before landing each split, confirm no virtual dispatch and no header→TU indirection was added on `peaks` envelope compute, audition/preview, or the realtime tick. `computeEnvelope` stays a free function on `const std::vector&`; diff --git a/TODO-1.0.md b/TODO-1.0.md new file mode 100644 index 0000000..55d0f3a --- /dev/null +++ b/TODO-1.0.md @@ -0,0 +1,1673 @@ +# TODO-1.0 + +Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two +extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering +(2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day); +item 14 is a third, single-item batch (2026-07-28, later again); item 15 is +a fourth, single-item batch (2026-07-29); item 16 (2026-07-29) is not a new +ask batch — it records the system change mandated by answer 1 of item 15's +answer round (the zone mapping system is retired); item 17 (2026-07-29) is +likewise not a new ask batch — it records the requirement mandated by +answer 1 of the second fourth-batch answer round (the provenance/usage +tracking consolidated and made 100% robust). +Deliberately specified at the level of product +intent, user-visible behavior, and acceptance criteria — **no implementation +design, no file/module references**. These were authored while Phase Q was +restructuring the tree; the implementing engineer maps each spec onto the +post-Q layout at execution time. Each item preserves Daniel's raw ask verbatim +as the source of truth, then breaks it into behavior, open questions, and an +observable acceptance gate. + +A follow-up answer round from Daniel (2026-07-28) settled most of the open +questions. Each item now carries his follow-up verbatim alongside the original +ask; settled answers are folded into **Behavior** (marked *settled by +follow-up*), and only the genuinely unresolved remainder stays under **Open +questions**. + +A **second follow-up round** (2026-07-28, same day) settled nearly all of the +remainder. Each item carries that round's verbatim as a third provenance block; +answers are folded the same way (marked *settled by second follow-up*). + +A **third follow-up round** (2026-07-28, same day) settled item 3's last two +questions (point-count ceiling; delete gesture — reversing the second round's +alt-click answer back to right-click). **All three first-batch items now have +no open questions; the first batch is fully settled at the product level.** + +A **second batch** from Daniel (2026-07-28) appends items 4–13 — a mix of bug +reports and enhancements. His message numbered them 1, 2, 3, 4, 3, 4, 5, 6, 7, +8 (two items labelled 3 and two labelled 4). His *sequence* is preserved +exactly; each item carries an unambiguous doc number continuing from 3, with +his original label recorded in the heading (e.g. *his 3, second*) so his +message maps onto the doc. Each item is marked **Bug** or **Enhancement**; +item 9 is explicitly both — a suspected regression plus a feature spec. Bugs +are recorded compactly (symptom, expected behavior, acceptance gate) with **no +root-cause analysis** — same no-code-reads constraint as the first batch. + +A **second-batch follow-up round** (2026-07-28, same day) answered items 7, 8, +and 11. Item 7 closes clean. Item 11's answer **supersedes the original ask's +button placement** — a new VELOCITY deck group replaces the per-section +placement, MASTER being reserved for later post-voice-mixer concerns. Item 8's +answer is a **spec change, not a clarification**: the pitch envelope's AD +becomes an **AHD** (a Hold stage is added), and the doc's own prose (items 1 +and 8) is updated to match — Daniel's verbatim asks stay exactly as originally +written. + +A **second-batch second follow-up round** (2026-07-28, same day) closed item +8's last two questions — Hold's 0–100% reference, and the scope of the +1:1-overlay/combined-bound policy. **With that, no open question then in the +doc awaited a Daniel decision.** Everything still marked open in items 1–13 is +verify-or-propose-at-implementation, not a blocker: item 9's loop-point +regression verification and its spec details (crossfade units, storage +confirmation, editing surface — each carries its own resolve-at-review path in +the item), and item 11's does-a-user-facing-pitch-velocity-curve-already-exist +check. Nothing in items 1–13 is blocked on Daniel. + +A **third batch** (2026-07-28, later again) appends item 14 — a single +consolidation enhancement: in Trigger mode the amp envelope's +fade-in-length/fade-out-length pair is replaced by item 8's AHD (with item 1's +curves), so the amp envelope's staged shape follows the playback mode — +Gate → AHDSR, Trigger → AHD. Item 8's sustain-stage scope rule then covers the +amp envelope literally, not just by analogy (a one-line cross-reference is +added there). Item 14 arrived carrying one question awaiting a Daniel +decision — whether item 2's filter AHDSR follows the same mode-driven shape — +answered by the round below. Its other open question (stage-value state across +the Gate/Trigger switch) is propose-at-implementation-review, not +Daniel-blocking. + +A **third-batch follow-up round** (2026-07-28, same day) answered item 14's +filter question: **the filter envelope follows the same mode-driven shape** — +Gate → AHDSR, Trigger → AHD. Item 8's scope rule now governs all three +envelopes uniformly (the general statement lives in item 14; item 2 carries a +cross-reference), and item 14's remaining stage-value question now covers the +filter envelope too. **With that, no open question in items 1–14 awaits a +Daniel decision.** Everything still open across all 14 items is +verify-or-propose-at-implementation: item 9's loop-point regression +verification and its spec details, item 11's +does-a-pitch-velocity-curve-already-exist check, and item 14's +shared-vs-per-mode stage-value question. Nothing in items 1–14 is blocked on +Daniel. + +A **fourth batch** (2026-07-29) appends item 15 — a single enhancement Daniel +himself frames as "a pretty radical feature idea": one-click resampling from +inside the ReaSampler 9000. An offline pass through the instrument's own +processing is captured back into the bank, the source capture is replaced (or +a distinct capture added when other references exist), the instance re-points +at the recapture, and the audio parameters reset to default — the +dial → bake → dial-again iteration loop, run without leaving the sampler. +**Item 15 reopened the Daniel-blocked state: five of its open questions +awaited a Daniel decision** — the multi-zone scope of "the sound," what the +offline pass plays (note, velocity, and the Gate-mode hold/tail policy), the +parameter-reset scope, what counts as "other references," and the +undo/recovery expectation. Its remaining questions are +propose-at-implementation-review; items 1–14 stay fully unblocked. + +A **fourth-batch answer round** (2026-07-29) answered all five. The answers +are folded into item 15's Behavior (marked *settled by answer round*). +Answer 1 is a **system change, not a scoping rule**: the zone mapping system +is retired — ReaSampler 9000 becomes **one capture = one parameter set** — +recorded in full as **item 16**, which supersedes the doc's per-zone-storage +and Sample/Zone-panel-parity language throughout (items 2, 9, 11, and 12 +carry marked superseded-by/cross-reference notes; Daniel's verbatim asks +stand exactly as written). Answer 2 spawns a new sub-feature inside item 15: +a **capture-signal popup** (note length, start/end offsets in ms and in +beats, velocity, and a preview trigger). **Daniel-blocked state after that +round: exactly one question awaited a Daniel decision — item 16's migration +of saved multi-zone instances** (since settled; next paragraph). + +A **second fourth-batch answer round** (2026-07-29) — one message — settled +that migration question and answered the doc's three under-specification +flags. The unnumbered opening line rules the migration: **adopt the first +zone's capture and parameters** — a lossy rule Daniel accepts because he has +**no projects with zones in use**; for a genuinely multi-zone instance the +doc's standing sounds-identical migration bar is **deliberately relaxed** +(recorded in item 16). Answer 1 converts the doc's provenance-consolidation +flag into a requirement — **the provenance/usage tracking is to be +consolidated and made 100% robust** — recorded in full as **item 17**, on +which item 15's replace-vs-add rule now explicitly depends. Answer 2 +("that's good") ratifies item 15's derived reset-scope classification — +promoted from derived to confirmed by Daniel. Answer 3 settles the +capture-window semantics: the start/end offsets anchor to **note-on and +note-off** of the programmed note; note length is a **musical division** +(1/64th through 64/1, with dotted and triplet multipliers), not a free +duration; beats resolve against **the project tempo under the cursor**. +**Current Daniel-blocked state: no open question anywhere in items 1–17 +awaits a Daniel decision.** Everything still open is +verify-or-propose-at-implementation. + +Ordering note: item 1's curve/overlay treatment explicitly anticipates item 2's +filter envelope ("filter to be added"), and item 3 layers on both. They can land +in sequence or together, but 1 and 2 are prerequisites for 3's full surface. +Second-batch interactions: the three bugs (4–6) are independent of the +enhancement chain and can land at any time. Item 8 edits the same overlay +surface as item 1 and is cheapest folded into or immediately after that work; +item 10's per-ring reset presupposes item 1's inner dials; item 11's filter +velocity curve presupposes item 2's filter (though its button now homes in +item 11's own VELOCITY deck, not the Filter deck); item 9's +loop-sustain is a Gate-mode (Staged) feature and composes with item 3's +Gate-unavailable-in-Spline rule. Item 13 (anti-aliasing audit) touches nearly +every surface the other items repaint — sequencing it after the layout/knob +work (1, 8, 10, 11, 12) likely avoids doing the polish twice; that is an +observation, not a decision. Item 14 (third batch) rides directly on item 8's +AHD definition and item 1's curve treatment — cheapest folded into or +immediately after that combined work — and repaints amp-deck/overlay surface +that items 10 and 13 later polish; its filter half (the filter envelope +following the same mode-driven shape) necessarily lands with or after item 2. +Item 15 (fourth batch) presupposes the processing surface it bakes — +"filtering, pitching, amp all set up nice" is the instrument items 1, 2, 3, +and 14 build — so its spec only becomes fully meaningful once those have +landed, and its Gate-mode render policy touches item 9's loop-sustain; that +is a statement of what the feature operates on, not a schedule decision. Its +bank-side half rides the already-settled capture and prune safety model, not +any queued item. Item 16 (zone retirement) is a **prerequisite of meaning** +for item 15 — it is what makes "the sound" singular and dissolves the +multi-zone question — and it touches the surfaces other queued items work +on: items 2, 9, and 11 now store their parameters into the one parameter +set rather than per-zone, and item 12's full-width piano strip is +re-grounded by what the strip means with zones retired. That too is +dependency-of-meaning, not a schedule: the affected items' work is +unchanged in substance; only its storage/parity framing simplifies. +Item 17 (provenance/usage consolidation) is likewise a **prerequisite of +meaning** for item 15's bank-side half — the replace-vs-add rule is +computable only once the consolidated lineage records exist — and is +independent of the editor chain (items 1–14); it reworks tracking machinery +today's prune protections ride, so those guarantees must hold undiminished +through the consolidation. Landing it with or before item 15's bank-side +half is the natural order. + +--- + +## 1 — Envelope editor: per-deck radio switch, segment curve dials, overlay recolor + +**Daniel's ask (verbatim, 2026-07-28).** + +> The VST envelope editor visual is a great start but we need to add some +> features. The Amp AHDSR is currently always displayed. We need to add a radio +> switch to the corner of each env knob deck which makes THAT envelope editable +> via the graphic waveform overlay. This is currently the amp and pitch +> envelopes, with filter to be added (see below). All envelopes (amp AHDSR, +> Pitch AD, Filter AHDSR) should be enhanced to have editable segment curve +> values. This should be an exponential function with the exponent scalar +> parameter for each sloped segment having possible values from 0.1 to 10. The +> knob deck radial knobs for the sloped curvable segments will have an INNER +> dial, with its own inner arc, hover accent (tertiary purple), needle, and +> numerical label which controls the curve for that sloped segment. Also the +> graphical envelope editor draws its segments in secondary blue, which +> contrasts poorly against the primary green waveform display. Change those to +> tertiary purple. + +**Daniel's follow-up (verbatim, 2026-07-28).** + +> yes, one overlay (or none). Everything besides Hold and Sustain, right? "is +> 1.0 the linear neutral" CLAUDE, y=x^1.0 is linear!! of course! yes dragging +> on the segment (add a round knot midsegment) adjusts the curve. + +**Daniel's second follow-up (verbatim, 2026-07-28).** + +> default overlay none +> what about the curves? + +("what about the curves?" is Daniel turning the pre-existing-instances +question back — the answer recorded below is derived from this doc's own +acceptance criterion, not a Daniel quote.) + +**Intent.** Grow the envelope-overlay editor from an amp-only fixture into the +shared graphical surface for every envelope in the instrument, and give every +envelope shapeable (non-linear) segments — while fixing the blue-on-green +contrast failure. + +**Behavior.** + +- **Radio switch per envelope deck.** Each envelope knob-deck group (currently + AMP ENVELOPE and PITCH ENV; the filter envelope joins when item 2 lands) + gains a radio switch in the corner of its deck. Selecting a deck's radio + makes *that* envelope the one displayed and editable in the graphic waveform + overlay — replacing today's behavior where the Amp AHDSR is always the + displayed envelope. The switch is exclusive: **one overlay-active envelope at + a time, or none** — no-envelope-shown is a valid state, not an error. + *(Settled by follow-up.)* +- **Default overlay: none.** The editor opens with **no envelope selected** — + the none-state is the default, replacing today's always-on Amp AHDSR. + *(Settled by second follow-up.)* +- **Pre-existing instances load at exponent 1.0.** Saved instances from before + this change load **every curve at exponent 1.0** — the linear neutral — so + their audible envelope behavior is unchanged. *(Derived, not a Daniel quote: + 1.0 is the settled linear neutral, and the acceptance criterion "a project + saved before this change reopens with unchanged audible envelope behavior" + admits no other default. Daniel prompted the question; this is the only + answer consistent with what he has already settled.)* +- **Segment curve values on all envelopes.** Amp AHDSR, Pitch AHD (AD in the + original ask; item 8's follow-up adds the Hold stage), and Filter AHDSR all + gain an editable curve value per *sloped* segment. The curve is an + exponential function; the per-segment parameter is the exponent scalar, + range **0.1 to 10**. +- **Which segments are sloped.** **Every stage except Hold and Sustain** — for + an AHDSR that is Attack, Decay, and Release; for the Pitch AHD (A→H→D since + item 8's spec change), Attack and Decay — its Hold, like every Hold, is flat + and carries no curve dial. *(Settled by follow-up; the pitch reading updated + for item 8's AD→AHD change.)* +- **Linear neutral.** Exponent **1.0 is the linear neutral** (y = x^1.0 is + linear). *(Settled by follow-up — emphatically.)* +- **Curve editing in the overlay.** Dragging on a segment in the overlay + **adds a round mid-segment knot** whose drag adjusts that segment's curve — + the overlay is a curve-edit surface in its own right, alongside (not instead + of) the inner dial. *(Settled by follow-up.)* +- **Inner dial on curvable-segment knobs.** Every knob-deck radial knob that + controls a sloped, curvable segment gains an **inner dial**: its own inner + arc, its own hover accent (tertiary purple), its own needle, and its own + numerical label. The inner dial controls the curve exponent for that + segment; the outer knob keeps controlling the segment's time/level value as + today. +- **Overlay recolor.** The graphical envelope editor's segments change from + secondary blue to **tertiary purple** (secondary blue contrasts poorly + against the primary green waveform behind it). + +**Open questions.** + +- None remaining — both prior questions (default overlay selection, + pre-existing-instance curve loading) closed by the second follow-up round. + +**Acceptance criteria.** + +- Each envelope deck shows a corner radio switch; activating one puts that + envelope in the overlay, editable there, and the overlay tracks the switch + immediately. At most one envelope is overlay-active; with none active, the + overlay draws no envelope. +- The editor opens with no envelope overlay-active (default is none, not the + Amp AHDSR). +- Every curvable-segment knob shows the inner dial (inner arc, tertiary-purple + hover accent, needle, numeric label); sweeping it through 0.1 → 10 visibly + reshapes the overlay segment and audibly reshapes the envelope on played + notes. Hold and Sustain knobs carry no inner dial. +- Dragging on an overlay segment adds a round mid-segment knot; dragging the + knot adjusts that segment's curve, and the segment's inner dial reflects the + same value. +- Overlay envelope segments render in tertiary purple and are clearly legible + against the primary green waveform. +- A project saved before this change reopens with unchanged audible envelope + behavior: every curve loads at exponent 1.0. + +--- + +## 2 — MM preamp Filter: resonant HP/LP stage in the voice pipeline + +**Daniel's ask (verbatim, 2026-07-28).** + +> MM preamp Filter: We must implement a new processing point in the sampler +> audio pipeline, after pitch env, before amp, for filtering. The processor for +> this will be based on code I wrote for the cortex M4 for resonant high and +> lowpass filtering. The filter will have parameters for mode, cutoff, Q, and +> mod amt, then the AHDSR controls as described above. The knob deck row will +> then be relaid out in signal flow order: pitch -> filter -> amp + +**Daniel's follow-up (verbatim, 2026-07-28).** + +> modes beyond the pass filters will be added later. cutoff range full audio +> spectrum, log scaled, fully open to fully closed, Q should go from 0.1 to 10 +> again, scaled around root 2 at the center. mod amt targets cutoff, -100% - +> +100% to cover full range from either end. per-voice. label it "Filter" + +**Daniel's second follow-up (verbatim, 2026-07-28).** + +> oh, zone. +> yes, filter is off by default +> velocity and keytracking with the rest, not deffered. Same idea as the amp +> velocity transfer curve and pitch key tracking + +**Intent.** Add the instrument's first filter stage — resonant high-pass and +low-pass — as a new fixed point in the per-voice signal path, with its own +AHDSR envelope, and make the knob-deck row read in signal-flow order. + +**Behavior.** + +- **Pipeline position.** A new processing point in the sampler audio pipeline: + **after the pitch envelope, before the amp stage.** +- **DSP source.** The filter processor is based on Daniel's own Cortex-M4 + resonant high/lowpass filter code. **That code is an input Daniel supplies at + implementation time** — it is not in this repo and this spec does not + characterize it beyond "resonant high and lowpass." +- **Parameters.** Mode, cutoff, Q, and mod amt — then the AHDSR controls, + treated exactly as item 1 specifies (curvable sloped segments with inner + dials, overlay editability via the filter deck's radio switch). + *(Ranges settled by follow-up:)* + - **Mode:** high-pass and low-pass now; **modes beyond the pass filters are + explicitly deferred to later.** + - **Cutoff:** the **full audio spectrum, log scaled**, from fully open to + fully closed. + - **Q:** **0.1 to 10** (the curve-exponent range again), scaled so **√2 sits + at the center** of the control. + - **Mod amt:** **bipolar, −100% to +100%**, targeting **cutoff** — covering + the full range from either end. +- **Mode-driven envelope shape.** The filter envelope follows the playback + mode exactly as the amp does: **Gate → AHDSR, Trigger → AHD** — see item 14, + where the policy is stated in full. *(Settled by item 14's follow-up.)* +- **Per-voice.** The filter processes **per voice** — each sounding voice runs + its own filter with its own envelope state. *(Settled by follow-up.)* +- **Per-zone storage.** The filter's parameters are **stored per-zone**, + alongside the other playback parameters — Sample/Zone panel parity applies, + same as the existing per-zone controls (VOICE and MASTER remain the + per-instance exceptions). *(Settled by second follow-up.)* **Superseded by + item 16** (zone retirement): the filter's parameters join the instrument's + **one parameter set** — no per-zone storage, no Sample/Zone parity, no + per-instance exception structure to be an exception to. The follow-up's + substance carries over unchanged: the filter's parameters live with the + other playback parameters. +- **Off by default.** The filter **defaults to off** — pre-existing saved + instances and freshly loaded captures sound unchanged until the user engages + it. *(Settled by second follow-up.)* +- **Velocity and key-tracking modulation — in this item, not deferred.** The + filter gains **velocity** and **key-tracking** modulation now, alongside the + mod-amt/envelope path: the same idea as the amp's velocity transfer curve + and the pitch key-tracking, respectively, applied to the filter. The + follow-up establishes the parallel, not new ranges or control layout — those + follow the cited precedents. *(Settled by second follow-up.)* +- **Label.** The user-facing deck-group label is **"Filter"**. "MM preamp" is + working shorthand for the DSP lineage, not UI text. *(Settled by + follow-up.)* +- **Deck reorder.** The knob deck row is relaid out in signal-flow order: + **pitch → filter → amp**. + +**Open questions.** + +- None remaining — all three prior questions (other mod sources, parameter + storage side, neutral default) closed by the second follow-up round. + +**Acceptance criteria.** + +- With the filter engaged, played notes are audibly filtered at the specified + pipeline point: the filter acts on pitched (post-pitch-envelope) signal, and + the amp envelope still shapes the filtered result (audible ordering: + pitch → filter → amp). +- Mode, cutoff, Q, and mod-amt controls appear in a deck group labeled + **"Filter"**; the filter AHDSR gets the full item-1 treatment (curve inner + dials, corner radio switch, tertiary-purple overlay editing). +- Cutoff sweeps the full audio spectrum on a log scale, from fully open to + fully closed; Q spans 0.1 → 10 with √2 at the control's center; mod amt at + −100% and at +100% each drive cutoff across the full range, from opposite + ends. +- High Q audibly emphasizes the cutoff region (resonance) in both HP and LP + modes. +- Two simultaneously sounding voices at different envelope phases are filtered + independently (per-voice processing is audible, not a shared instance-wide + filter). +- Filter parameters live in the instrument's one parameter set: they edit in + one place and govern the instrument as a whole. (Original per-zone/parity + form — two surfaces in parity, per-zone divergence — superseded by item 16; + neither is expressible after the zone retirement.) +- Velocity and key-tracking modulation of the filter ship with this item and + are audible — velocity following the amp-velocity-transfer-curve pattern, + key-tracking following the pitch-key-tracking pattern. +- The deck row reads pitch → filter → amp left-to-right. +- The filter is off by default: a project saved before this change reopens + sounding identical, and a freshly loaded capture sounds unchanged until the + filter is engaged. + +--- + +## 3 — Alternative Spline EGs: hard/smooth multi-segment monotonic splines + +**Daniel's ask (verbatim, 2026-07-28).** + +> Alternative Spline EGs: Every processor which has an envelope will have the +> ability to change the Staged EG to a Spline EG, based on the monotonic +> splines for the velocity curve. HOWEVER, we will need to enhance the (singly +> implemented, multi referenced) spline algorithm to support multiple segments +> that DON'T minimally smooth the spline, so that hard points are possible. In +> other words, the contour of the EG will be defined by 1 or more monotonic +> spline functions which together form the full time function for that +> processor, such that the first three points could make a curved segment, +> which connects at a sharp angle to the next three points, finishing out the +> contour over the full sample length. Control-clicking a point makes it a +> "hard" or "smooth" point (toggled, smooth by default) which when hard does no +> curve smoothing on either side segment, forming the natural sharp angle +> instead of a continuous derivative. This enhanced spline drawing will be used +> for the velocity-amp transfer curve as well as the pitch, filter, and amp EGs +> (if they are in spline mode). + +**Daniel's follow-up (verbatim, 2026-07-28).** + +> dual state, save but inactive. Default Curve Spline is a smooth y=1-x. Gate +> mode is not available when the Spline is used, spline always covers the full +> sample length. The spline curves don't have to rise AND fall, they also +> aren't globally monotone, the default curve is a smooth downward slope over +> the length. all the soft points between any hard points will be +> smooth/monotone. + +**Daniel's second follow-up (verbatim, 2026-07-28).** + +> normalize the spline eg length to the full width, it should represent the +> time axis of the actual sample visually 1:1 +> left click add, alt-click delete, cntrl-click toggles point hard or soft. +> spline active -> stage knobs disabled. + +**Daniel's third follow-up (verbatim, 2026-07-28).** + +> oh, maybe 64? is that way too much? I don't want to limit from long rhythmic +> phrases, which require a high resolution to be interesting +> +> oh, do right click delete instead for the spline +> 128 then + +**Intent.** Offer a free-drawn alternative to every staged envelope: the user +switches any EG from Staged to Spline mode and draws the contour directly, with +the monotonic-spline machinery already proven by the velocity curve — enhanced +so sharp corners are possible, not everything smoothed. + +**Behavior.** + +- **Mode toggle per EG.** Every processor that has an envelope (pitch, filter, + amp) can switch its Staged EG to a **Spline EG**. +- **Dual state — save but inactive.** Both the Staged and the Spline state are + persisted; switching modes keeps the inactive one **saved but inactive**. No + conversion, no discard — round-tripping Staged↔Spline restores the other + mode's shape untouched. *(Settled by follow-up.)* +- **Gate unavailable in Spline mode.** Gate mode is **not available while a + Spline EG is active**; the spline **always covers the full sample length** — + a pure time function over the sample, i.e. the Trigger/one-shot playback + model. *(Settled by follow-up.)* +- **Time axis: normalized, visually 1:1.** The spline contour is **normalized + to the full sample length**, and the overlay represents **the time axis of + the actual sample visually 1:1** — the contour's full width maps directly + onto the displayed sample. Consequently a different-length capture rescales + the stored contour to its own length. *(Settled by second follow-up.)* +- **Point-editing grammar.** **Left-click adds a point; right-click deletes a + point; control-click toggles a point hard/smooth.** *(Settled by third + follow-up; control-click hard/smooth confirms the original ask.)* Note for + readers of the prior revision: the second follow-up answered alt-click + delete; the third follow-up **supersedes that** with right-click delete — + which matches the velocity-curve popup's already-shipped right-click node + delete, giving one point-editing grammar across both spline consumers. +- **Point-count ceiling: 128.** A spline contour holds at most **128 points** + (floor: the two endpoints implied by full-length coverage). Daniel floated + 64 and raised it to 128 explicitly so the cap does not limit **long rhythmic + phrases, which require high resolution to be interesting** — at roughly two + points per articulation event, 64 points is about two bars of 16ths and 128 + about four. The ceiling is a musical bound, not a performance one (segment + lookup is logarithmic; on-screen the editor's 8 px minimum node separation + is the practical density limit anyway). **An engineer tempted to lower this + number should read that motivation first.** *(Settled by third follow-up.)* +- **Staged controls disabled while Spline is active.** While a Spline EG is + active, that envelope's **staged segment knobs are disabled** — inert, not + merely inaudible — including their item-1 inner curve dials (the dial is + part of the knob). The dormant staged state is edited only by switching back + to Staged mode. *(Settled by second follow-up.)* +- **Not globally monotone.** Spline contours **don't have to rise and fall and + are not globally monotone**; the monotone guarantee is per-segment — **all + soft points between any hard points are smooth/monotone** (no overshoot + between adjacent points). *(Settled by follow-up.)* +- **Default contour.** A new Spline EG defaults to a **smooth y = 1 − x** — a + smooth downward slope over the full sample length. (Read as the Spline-EG + default contour; not a change to the velocity→amp transfer curve's existing + default.) *(Settled by follow-up.)* +- **Spline foundation.** The Spline EG is based on the monotonic splines used + for the velocity curve. The spline algorithm is singly implemented and + multi-referenced; the enhancement below applies to that one implementation + and flows to every consumer. +- **Hard points.** The algorithm is enhanced to support **multiple segments + that don't minimally smooth the spline**, so hard points are possible: the + EG contour is defined by **one or more monotonic spline functions** which + together form the full time function for that processor — e.g. the first + three points form a curved segment that connects **at a sharp angle** to the + next three points, finishing the contour over the **full sample length**. +- **Hard/smooth gesture.** **Control-clicking a point toggles it hard/smooth + (smooth by default).** A hard point does no curve smoothing on either + adjacent segment — the natural sharp angle stands instead of a continuous + derivative. +- **Shared across consumers.** The enhanced spline drawing serves the + **velocity→amp transfer curve** as well as the pitch, filter, and amp EGs + (when those are in spline mode). The velocity curve gains hard-point support + by the same enhancement. + +**Open questions.** + +- None remaining — both prior questions closed by the third follow-up round. + Point-count bounds: ceiling 128, floor two endpoints. Gesture convergence: + resolved by the delete-gesture reversal itself — the Spline EG's right-click + delete now matches the velocity-curve popup's shipped right-click node + delete, so **no migration is needed on either side**; one grammar serves + both consumers. + +**Acceptance criteria.** + +- Each of the pitch, filter, and amp EGs offers a Staged/Spline mode switch; + in Spline mode the overlay (via the item-1 radio switch) shows and edits the + drawn contour, and played notes audibly follow it. +- Left-click on the contour adds a point at that position; right-click on a + point deletes it; control-click toggles it hard/smooth. Points are smooth by + default; a hard point renders a visible sharp angle with no smoothing on + either adjacent segment, and the discontinuous slope is audible where the + modulation target makes it so (e.g. a pitch EG corner). +- A contour accepts points up to the **128-point ceiling**; attempting to add + beyond it is refused without disturbing the existing contour. The two + endpoints cannot be deleted (full-length coverage always holds). +- A contour of several segments joined at hard points plays back over the full + sample length exactly as drawn — including contours that rise and fall + freely (no globally-monotone restriction), with no overshoot between any + adjacent pair of points. +- A freshly created Spline EG shows the smooth y = 1 − x default contour. +- While a Spline EG is active, Gate mode is not selectable; the spline plays + as a pure time function over the full sample length. +- The overlay contour spans the full displayed sample width, 1:1 with the + sample's time axis; loading a different-length capture rescales the contour + to the new sample length (normalized storage), with the drawn shape + preserved proportionally. +- While a Spline EG is active, that envelope's staged segment knobs and their + inner curve dials render disabled and reject edits; switching back to Staged + re-enables them with values exactly as left. +- Staged↔Spline round-trip preserves both states: switch to Spline, draw, + switch back — the staged values are exactly as left; switch forward again — + the spline contour is exactly as drawn. Both survive save/reload. +- The velocity→amp transfer-curve editor supports the same control-click + hard/smooth toggle with identical rendering behavior, and its existing + right-click node delete matches the Spline EG's delete gesture unchanged — + one point-editing grammar across both consumers. + +--- + +## 4 — Bug: end-of-sample click in Trigger mode under Preserve *(his 1)* + +**Daniel's report (verbatim, 2026-07-28).** + +> actual bug: in trigger mode, polyphonic or monophonic, in preserved pitch +> mode only, the end of the sample has an audible click. + +**Symptom.** In Trigger mode — polyphonic or monophonic alike — with the pitch +engine in **Preserve mode only**, the end of the sample produces an audible +click. The scoping is the useful part of the report and is recorded as given: +Trigger × Preserve × end-of-sample; Varispeed is not implicated. No cause +speculation here. + +**Expected behavior.** A Trigger one-shot in Preserve mode ends silently — no +click or discontinuity at the sample end, in either voice mode. + +**Acceptance gate.** A Trigger-mode note played in Preserve, in both Poly and +Mono, ends with no audible click at the sample end (verified by ear and by +inspecting the rendered output for a terminal discontinuity). Varispeed +playback is unchanged. + +--- + +## 5 — Bug: drag-out sometimes lands without audio (extension) *(his 2)* + +**Daniel's report (verbatim, 2026-07-28).** + +> other bug (extension, not VST): Dragging capture out of the pool or bank does +> sometimes doesn't actually contain the audio, and I hae to try dragging +> again. + +**Symptom.** Extension side, not the VST. Dragging a capture out of the pool +or a named bank **sometimes** produces a drop that does not actually contain +the audio; retrying the drag works. Both details are load-bearing: the failure +is intermittent, and a retry succeeds. + +**Expected behavior.** Every completed drag-out delivers the capture's audio +at the drop target — first try, every time. + +**Acceptance gate.** Because the failure is intermittent, the gate is a soak: +across a sustained session of varied drag-outs (pool and bank sources, +including the first drag after other bank activity), every drop yields a +playable file containing the audio, with no retry ever needed. + +--- + +## 6 — Bug: drop onto FX container loads instrument without the capture *(his 3, first)* + +**Daniel's report (verbatim, 2026-07-28).** + +> drop to FX container bug: the reasmpler 9000 loads, but not with the capture. +> drop to FX button works as expected with capture preloaded. + +**Symptom.** Dropping a capture onto an **FX container** loads a ReaSampler +9000 instance, but **without the capture**. Dropping the same capture onto the +**FX button** works as expected — instrument loads with the capture preloaded. + +**Expected behavior.** The FX-button path is the reference; the container path +must match it: instrument added *with* the dragged capture loaded. + +**Acceptance gate.** A capture dropped onto an FX container yields an +instrument instance with that capture loaded and immediately playable, +indistinguishable (apart from where the FX sits) from the FX-button drop. The +FX-button path remains unregressed. + +--- + +## 7 — Enhancement: stereo waveform shows both channels *(his 4, first)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> VST Waveform Visualizer: in stereo mode, both L and R channels should show +> in the waveform visual. left on top. mono mode still shows just one channel +> for unredundancy. + +**Daniel's follow-up (verbatim, 2026-07-28).** + +> 7) One full height; stereo linked processing, one editor + +**Behavior.** + +- In **stereo mode**, the waveform visual shows **both L and R channels, left + on top** (two stacked lanes). +- In **mono mode**, a single channel shows — no redundant duplicate lane. +- The display keys off the active channel mode, per Daniel's phrasing. +- **Overlays draw once, at full height.** Overlays that ride the waveform + (the envelope overlay, markers, and item 9's loop region if it lands) draw + **once at full height across both stacked lanes** — not per lane. + *(Settled by follow-up.)* +- **Stereo processing is linked.** One editor, one set of controls governing + both channels — no per-channel parameter divergence, no per-channel editing + surface. *(Settled by follow-up.)* + +**Open questions.** + +- None remaining — the overlay-layout question closed by the second-batch + follow-up round. + +**Acceptance criteria.** + +- A stereo capture in stereo mode shows two stacked lanes, L above R, each a + true view of its channel's content (an asymmetric-channel capture visibly + differs between lanes). +- Mono mode shows exactly one lane. Switching modes updates the display + accordingly. +- In stereo mode, waveform-riding overlays (envelope, markers, loop region if + present) render once at full stacked height — no duplicated per-lane copies + — and stay legible across both lanes. +- No per-channel controls appear; every edit applies identically to both + channels (linked stereo processing, one editor). + +--- + +## 8 — Enhancement: staged-overlay release anchoring; the Pitch AD becomes AHD *(his 3, second)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> AHDSR visual overlay looks odd, not taking up the whole range. with no +> release, the sustain portion only travels accross a small portion of the +> panel, making the thing look off center. to resolve, the release segment +> should be anchored to the right, and draggable from the top node (connecting +> to sustain segment) instead of the bottom corner, which will now be anchored. +> All staged envelope overlays should follow this policy. + +**Daniel's follow-up (verbatim, 2026-07-28).** + +> 8) For the AD... make it AHD, and the combined A H and D segment lengths +> (time displacement) cannot exceed the full sample length. Hold goes from 0 +> to 100%, and the visual overlay for the envelope is then 1:1 scale with the +> waveform time. D is not release, so it is not right anchored. + +**Daniel's second follow-up (verbatim, 2026-07-28).** + +> 1) 100% of the sample length - attack+decay times +> 2) for all envelopes that DON'T have a sustain stage. The 1:1 mapping only +> makes sense for trigger, not gated envelopes + +**Intent.** A layout-policy change to the staged envelope overlay so it uses +the full panel width: today, with little or no release, the sustain portion +occupies only a small stretch and the whole figure reads off-center. The +follow-up round grew this item beyond layout: it now also carries **the spec +change that turns the pitch envelope from AD into AHD**. + +**Behavior.** + +- The **release segment anchors to the right edge** of the overlay. +- Release is dragged from its **top node** — the node joining sustain to + release — instead of the bottom corner. The bottom corner (the envelope's + end point) becomes **fixed/anchored**, not draggable. +- **The policy applies to the envelopes that have a release** — the amp AHDSR + today and the filter AHDSR when item 2 lands. **It does not apply to the + pitch envelope: D is not a release stage, so it is not right-anchored.** + *(Settled by follow-up — this closes the prior open question about mapping + the policy onto the pitch envelope: the answer is that the policy does not + apply there; the envelope gains a Hold stage instead.)* +- **Spec change: the pitch envelope becomes AHD.** The Pitch AD gains a + **Hold** stage: **Attack → Hold → Decay**. *(Settled by follow-up — a spec + change, not a clarification. The doc's own prose — here and item 1's + sloped-segment reading — is updated to match; Daniel's verbatim asks stay + as written.)* + - **Hold ranges 0 to 100% of the time remaining after Attack and Decay** — + i.e. 100% of (sample length − (attack time + decay time)). At 100%, Hold + fills all the remaining time; at 0% it takes none. *(Settled by second + follow-up.)* + - **The A + H + D ≤ sample-length bound holds by construction**, not by a + separate clamp: Hold is expressed as a fraction of what is left after + Attack and Decay, so the sum cannot overflow. Daniel's stated bound + ("the combined A H and D segment lengths cannot exceed the full sample + length") is a *property* of the Hold definition, not a constraint to + enforce on top of it. + - **The overlay is therefore 1:1 scale with the waveform time axis** — the + drawn envelope maps directly onto the displayed sample's time. +- **Scope rule — split on the sustain stage.** The 1:1-overlay property and + the combined-time bound apply to **all envelopes that do NOT have a sustain + stage**; envelopes **with** a sustain stage (the amp and filter AHDSRs) get + the right-anchored-release policy instead. Daniel's rationale: the 1:1 + mapping only makes sense for trigger, not gated envelopes — that is the + reasoning behind the stage-list rule, not a second competing rule. Today + the pitch AHD is the only sustain-less envelope, but the rule is general: + it governs any future sustain-less envelope too. The two policies + **coexist rather than merge**, split cleanly on whether the envelope has a + sustain stage — the prior revision's open reading, now confirmed. *(Settled + by second follow-up.)* +- **Cross-references.** Item 1 reworks this same overlay surface (radio + switch, mid-segment curve knots, recolor) — this item is cheapest folded + into or immediately after that work; item 1's sloped-segment rule reads + Attack and Decay for the pitch AHD (Hold is flat, no curve dial). Item 3's + Spline overlays are unaffected by construction: a spline always spans the + full sample width already. Item 14 extends the AHD to the amp and filter + envelopes' Trigger mode, so this scope rule selects by the envelope's + current shape under the active playback mode — see item 14. + +**Open questions.** + +- None remaining — both prior questions (Hold's 0–100% reference; the scope + of the 1:1/combined-bound policy) closed by the second-batch second + follow-up round. The coexist-vs-merge reading the prior revision flagged is + confirmed: the two policies coexist, split cleanly on whether the envelope + has a sustain stage. + +**Acceptance criteria.** + +- With release at zero or minimum, the sustain segment extends to (near) the + right edge — the overlay reads full-width, not bunched left. +- Dragging the sustain→release top node adjusts release; the bottom-right + corner is fixed and not draggable. +- The envelopes with a sustain stage (amp, and filter once present) follow + the same anchoring policy; the pitch envelope's Decay is **not** + right-anchored. +- The pitch envelope plays and displays three stages — Attack, Hold, Decay — + with Hold spanning 0–100% of the time remaining after Attack and Decay. By + construction, no combination of A, H, and D settings yields a combined time + displacement exceeding the sample length (at Hold = 100% the three stages + exactly fill it) — no separate clamp fires, because none is needed. +- The overlay of any sustain-less envelope (today: the pitch AHD) is 1:1 with + the waveform's time axis: a stage boundary at N seconds sits over the + waveform at N seconds. + +--- + +## 9 — Bug + Enhancement: loop points — suspected regression, and the Gate-mode loop-sustain spec *(his 4, second)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> I think loop points got lost. ideally if in gate mode we have a loopable +> section with parameterized start end points and parameterized crossfade on +> reset, which will function as the sustain for indefinite playback until note +> off and release. + +**This item is both** a regression report and a feature spec, and is recorded +as both. + +**Regression half (Bug).** Daniel's observation: loop points appear to have +been lost. Whether they were genuinely removed from playback or are merely +unexposed in the current UI is a code question that **cannot be answered here** +under the no-code-reads constraint — it must be verified as the first act of +implementation, not guessed in this document. + +**Feature half (Enhancement — spec as given).** In **Gate mode**: a loopable +section with **parameterized start and end points** and a **parameterized +crossfade on loop reset**. The loop functions as the sustain — indefinite +playback cycling the loop until note-off, then release. + +- **Cross-reference item 3.** Gate mode is unavailable while a Spline EG is + active (settled), so loop-sustain is a **Staged/Gate-mode feature**; Spline + mode remains full-sample-length playback with no loop. + +**Open questions.** + +- The regression verification above (removed vs. unexposed). +- The crossfade parameter's units and range are unspecified — Daniel call, or + a proposed default surfaced at implementation review. +- Storage side: closed by **item 16** (zone retirement) — the loop + parameters belong to the instrument's one parameter set; there is no + per-zone side left to confirm. (Cross-reference: item 15's settled reset + scope classifies loop points as baked-in, so they reset on a resample.) +- Editing surface for loop start/end (waveform markers, knobs, or both) is + unspecified; the waveform display is the natural home for range markers, but + Daniel has not said. + +**Acceptance criteria.** + +- In Gate mode with a loop defined, a held note sustains indefinitely, audibly + cycling the loop section; note-off exits into the release stage. +- With a nonzero crossfade, the loop seam is smooth — no click at the loop + reset; crossfade length audibly follows its parameter. +- Loop start, end, and crossfade are user-parameterized, editable, and + persisted across save/reload. +- Whatever the regression finding, the end state is loop points exposed and + functional per this spec. + +--- + +## 10 — Enhancement: knob and label sizing, ms units, double-click reset *(his 5)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> Radial knobs and text labels are too small. The labels for time constants +> should be in ms not seconds. double clicking any radial knob resets to +> default (applies to the dual-ring radial knobs as individual sections) + +**Behavior.** + +- Radial knobs and their text labels grow — both are currently too small. No + target size was given; this is a visual-judgment change accepted by eye. +- Time-constant labels display in **ms, not seconds**. (A display-unit change; + this spec makes no claim about internal representation.) +- **Double-click on any radial knob resets it to its default value.** +- On item 1's **dual-ring knobs, each ring is its own reset target**: + double-click on the outer ring resets the time/level value; double-click on + the inner curve dial resets the exponent to 1.0 (the settled linear + neutral) — each independently, without touching the other ring. +- **Cross-reference item 1** (introduces the dual-ring knobs this refines). + +**Open questions.** + +- None beyond the sizing being judged by eye (see acceptance). + +**Acceptance criteria.** + +- Knobs and labels are legibly larger; Daniel signs off on the result by eye. +- Every time-constant label reads in ms. +- Double-click resets any radial knob to its default; on dual-ring knobs, + double-clicking the inner dial resets only the exponent (to 1.0) and + double-clicking the outer ring resets only the value. + +--- + +## 11 — Enhancement: preview glyph; VELOCITY deck for velocity-curve buttons; bipolar pitch/filter curves *(his 6)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> Preview button inner text should go and be replaced with an appropriate +> glyph of your choosing (no dependencies just load a bitmap staticly or +> something, whatever plays nice with LICE. Amp velocity curve button moves +> to the master section. pitch velocity curve goes in the pitch section. +> filter velocity curve button goes in the filter section. filter and pitch +> velocity transfer functions default to y=0 and y range is [-1,1] (where as +> amp stays unipolar at [0,1]). + +**Daniel's follow-up (verbatim, 2026-07-28).** + +> 11) actually, that is a real contention point... MASTER is for other things, +> post voice mixer (I will add to this later). The velocity popup buttons +> should all go together in a new control deck group labelled VELOCITY, to the +> left of VOICE + +**Behavior.** + +- **Preview glyph.** The preview button's inner text is replaced with a glyph. + Proposed (product level): a right-pointing **play triangle** — the universal + "audition" read. Daniel's constraint: **no new dependencies** — a statically + embedded bitmap or equivalent that plays nicely with the existing drawing + path is fine. +- **Velocity-curve button placement — the VELOCITY deck.** All three + velocity-curve popup buttons (amp, pitch, filter) live **together in a new + control deck group labelled "VELOCITY", placed to the left of the VOICE + group**. *(Settled by follow-up.)* Note for readers of the prior revision: + this **supersedes the original ask's per-section placement** (amp → MASTER, + pitch → PITCH, filter → Filter). Daniel confirmed the MASTER placement was + a real contention point — **MASTER is reserved for other, post-voice-mixer + concerns** (he will add to it later), so the velocity curves do not belong + there. +- **Storage stays per-zone.** The velocity transfer curves are stored + **per-zone**, with the other playback parameters — Sample/Zone panel parity + applies. *(Derived, not a Daniel quote: the storage doubt existed only + because the proposed MASTER placement implied per-instance storage; with + the buttons in their own VELOCITY group that doubt is gone, and the + project's settled convention — playback parameters are per-zone, VOICE and + MASTER the per-instance exceptions — decides it.)* **Superseded by item + 16** (zone retirement): the curves live in the instrument's **one + parameter set**. The bullet's point survives in simplified form — the + curves store with the other playback parameters; the per-zone framing and + the parity convention it leaned on are gone. +- **Bipolar pitch/filter transfer functions.** Pitch and filter velocity + transfer functions are **bipolar: y range [−1, 1], default y = 0** — flat at + zero, meaning velocity modulation of pitch and filter is **off until the + user draws a curve**. **Amp stays unipolar at [0, 1]**, and its existing + flat default (every velocity → unity) is unchanged. +- **Cross-references.** Item 2 introduces the filter's velocity modulation — + this item specifies that curve's domain and default, and homes its button + in the VELOCITY deck. Item 3's shared spline editor serves these curves, so + it must render and edit a **bipolar y-domain** for pitch and filter + alongside the amp curve's unipolar one. + +**Open questions.** + +- Whether a user-facing pitch velocity transfer curve already exists or is + introduced by this item — unverifiable here under the no-code-reads + constraint; if absent, this item introduces it. (The former second question + — amp-curve storage under the MASTER placement — dissolved with the + placement's supersession; storage is settled above — per-zone at the time + of that round, now the one parameter set per item 16.) + +**Acceptance criteria.** + +- The preview button shows the glyph (no text) and stays legible in all + interaction states; no new build or runtime dependency is introduced. +- The three velocity-curve buttons sit together in a deck group labelled + **VELOCITY**, immediately to the left of the VOICE group; no velocity-curve + button appears in MASTER, PITCH, or Filter. +- The velocity curves persist in the instrument's one parameter set and + round-trip save/reload. (Original per-zone/parity form superseded by + item 16; per-zone divergence is no longer expressible.) +- Opening the pitch or filter curve shows a bipolar editor ([−1, 1]) defaulted + flat at y = 0; played velocities produce no pitch/filter modulation until a + curve is drawn, then audibly follow it. +- The amp curve's domain ([0, 1]) and flat-unity default are unchanged. + +--- + +## 12 — Enhancement: top toolbar cleanup; full-width piano strip; uniform keys; note-name tooltips *(his 7)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> top toolbar text font should be cleaned up to match, and the zone count +> label is not needed in the title. next to the browse and zoom buttons, we +> can move the preview and mono stereo controls, in order to allow the note +> range piano roll control to take up full width. also we need to clean up +> the piano key pattern, idk if it's pixel aliasing but it looks like some +> keys are skinnier than others. NOte value (C4, etc) should display in +> tooltip on hover over the piano notes. + +**Behavior.** + +- **Font cleanup.** One consistent font treatment across the top toolbar text. +- **Zone count label removed** from the title. +- **Preview and mono/stereo controls relocate** next to the browse and zoom + buttons, freeing the **note-range piano strip to take the full width**. +- **Uniform piano keys.** Some keys currently render skinnier than others — + Daniel suspects pixel aliasing, but the requirement stands regardless of + cause: keys of the same class render at uniform width. (No cause analysis + here.) +- **Note-name tooltips.** Hovering a piano key shows its note value (C4 etc., + DAW convention), following the instrument's existing tooltip conventions + where they apply. +- **Cross-references.** Item 11 also changes the preview button (glyph); the + two compose — the glyph button in its new toolbar position. Item 13's + anti-aliasing audit covers the key-pattern rendering if aliasing turns out + to be the cause. **Item 16** (zone retirement) removes the zone count + label's referent entirely — this item's removal of the label is doubly + settled — and re-grounds what the full-width strip displays: with no zone + bars, its surviving jobs are the root affordance (item 15's answer round + makes root explicitly resample-stable) and these note-name tooltips; + whether any key-range display remains follows item 16's key-range + question. + +**Acceptance criteria.** + +- Top toolbar text renders in one consistent font treatment; the title carries + no zone count. +- Preview and mono/stereo sit adjacent to browse/zoom; the piano strip spans + the full editor width. +- Same-class keys are equal pixel width at any window width and DPI scale. +- Hovering any piano key shows its note name in a tooltip. + +--- + +## 13 — Enhancement (audit): antialiased rendering for high-DPI *(his 8)* + +**Daniel's ask (verbatim, 2026-07-28).** + +> We should finally review to make sure we are rendering with some kind of +> antialiasing equivalent for high DPI high res clean rendering. It still +> looks pixely in places (radial arcs, waveform lines, env segment slopes) + +**Intent.** A review pass, not a point fix: audit the editor's drawn surfaces +and confirm they render with antialiasing (or an equivalent) suitable for +high-DPI, high-resolution displays. Daniel named the visibly pixely surfaces: +**radial arcs, waveform lines, envelope segment slopes**. + +**Behavior.** + +- Audit each class of drawn surface; where one renders visibly aliased, bring + it to the smooth standard. The outcome is observable, not procedural: the + named surfaces (and any others the audit turns up) render without visible + stair-stepping. +- **Sequencing observation** (not a decision): this touches nearly every + surface items 1, 3, 7, 8, 10, 11, and 12 repaint — running it after the + layout/knob work likely avoids doing the polish twice. + +**Acceptance criteria.** + +- Radial arcs (including item 1's inner dials), waveform lines (including + item 7's stereo lanes), and envelope segment slopes (staged and spline) + render smooth — no visible jaggies at 100% scale or on a high-DPI display. +- The audit produces a short disposition list: surfaces checked, which needed + work, which were already clean. +- Daniel signs off by eye on the named surfaces. + +--- + +## 14 — Enhancement: Trigger-mode amp envelope — fade-in/fade-out replaced by the AHD (with curves) + +**Daniel's ask (verbatim, 2026-07-28).** + +> the amp env trigger mode fade in length fade out changes to the AHD envelope +> (with curves of course), to reduce code for the same thing, and consolidate +> under the new design. + +**Daniel's follow-up (verbatim, 2026-07-28).** + +> sure, filter follows as well + +**Intent.** Consolidation. In Trigger mode the amp envelope's fade-in-length / +fade-out-length pair is retired and replaced by the same three-stage +Attack → Hold → Decay envelope item 8 specifies for the pitch envelope — with +item 1's per-segment curves. Daniel's stated motive is reducing code for the +same job: one staged-envelope design covering what is currently two separate +mechanisms doing the same thing. The product consequence: **the amp envelope's +staged shape follows the playback mode** — Gate → AHDSR (sustain, +right-anchored release, per item 8); Trigger → AHD (sustain-less). The +follow-up round extends the same rule to item 2's filter envelope. + +**Behavior.** + +- **Trigger mode: fades out, AHD in.** In Trigger mode the amp envelope is an + AHD per item 8's definition: Hold spans 0–100% of the time remaining after + Attack and Decay, so A + H + D ≤ sample length holds by construction and the + overlay is 1:1 with the waveform's time axis. The fade-in-length and + fade-out-length controls go away in Trigger mode; Attack and Decay carry + those roles under the new design. +- **With curves.** The Trigger AHD's sloped segments — Attack and Decay; Hold + is flat, as everywhere — get item 1's full curve treatment: exponent 0.1–10, + inner dials, mid-segment overlay knots, tertiary-purple rendering. +- **Gate mode unchanged.** In Gate mode the amp envelope remains the AHDSR + with item 8's right-anchored release. Item 9's loop-sustain spec (a + Gate-mode feature) is untouched. +- **The filter envelope follows as well.** Item 2's filter envelope takes the + same mode-driven shape: **Gate → AHDSR, Trigger → AHD** — same staged + machinery, same consolidation. It necessarily lands with or after item 2 + (the filter must exist first); item 2 carries a cross-reference. *(Settled + by follow-up.)* +- **Item 8's scope rule now governs all three envelopes uniformly.** Item 8 + settled that the 1:1-overlay property and the by-construction combined-time + bound apply to envelopes **without a sustain stage**, and the + right-anchored-release policy to those **with** one — Daniel's rationale: + "the 1:1 mapping only makes sense for trigger, not gated envelopes." With + this item the amp — and, per the follow-up, the filter — envelope in + Trigger mode *is* sustain-less, so each takes the 1:1 time-mapped overlay + automatically; in Gate mode each keeps the right-anchored release. The + general policy, stated once: **pitch is always AHD (1:1 overlay); amp and + filter are AHDSR in Gate (right-anchored release) and AHD in Trigger (1:1 + overlay)**. The rule selects by the envelope's **current shape under the + active playback mode**, not by which processor the envelope modulates. No + new rule is needed — item 8's rule already decides every case. +- **Pre-existing instances reopen sounding identical.** The doc's standing + migration framing (items 1, 2, 11) applies: a project saved before this + change reopens with unchanged audible behavior — in particular, a Trigger + instance's prior fade-in/fade-out contour is reproduced by the loaded AHD. + The evident mapping (Attack ← fade-in, Decay ← fade-out, Hold ← the full + remainder, curve exponents at whatever value reproduces the prior fade + shape) is a verify-at-implementation detail, not a Daniel call; the gate + below states the requirement. A prior zero fade-out is Decay = 0 — the + abrupt end stays representable, so nothing the old controls could express + is lost. +- **Cross-references.** Item 8 supplies the AHD definition and the scope rule + (this item is that rule's second consumer); item 1 supplies the curves — + sequencing: cheapest folded into or immediately after that combined work. + Item 3 is unaffected in Spline mode (a spline already plays as a + full-sample-length time function — the trigger model); its "save but + inactive" dual-state precedent bears on the second open question below. + Item 4's Trigger × Preserve end-of-sample click sits in the region the + fade-out currently governs — whichever of the two lands first, item 4's + gate must be re-verified under the surviving mechanism. + +**Open questions.** + +- **Stage-value state across the Gate/Trigger switch — shared or per-mode?** + The Gate AHDSR and Trigger AHD share stage names (A, H, D); whether they + share *values* (one envelope whose S and R fall away in Trigger) or keep + per-mode state (item 3's "save but inactive" dual-state precedent) is + unspecified — and with the follow-up the question covers the **filter + envelope too**, not just the amp. Migration leans per-mode for the amp: an + old instance carries both its AHDSR values and its Trigger fade values, and + a shared-value model cannot preserve both modes' prior sound at once. (The + filter is new in item 2, so it carries no migration weight either way; + matching the amp's answer is the natural default.) Resolve at + implementation review with a proposal — not Daniel-blocking, but the + sound-identical gate must hold for whichever mode a saved instance plays + in. (The filter question that previously led this list — does the filter + AHDSR follow the same mode-driven shape? — is settled by the follow-up: + yes; folded into Behavior above.) + +**Acceptance criteria.** + +- In Trigger mode the amp deck shows Attack / Hold / Decay — no fade-in or + fade-out control anywhere in Trigger mode — with curve inner dials on + Attack and Decay and none on Hold; when the amp envelope is overlay-active + (item 1's radio), its overlay is 1:1 with the waveform time axis, and no + combination of A, H, D settings exceeds the sample length (Hold = 100% + exactly fills it — by construction, no clamp). +- In Gate mode the amp envelope is the unchanged AHDSR: sustain stage, + right-anchored release per item 8. +- Switching between Gate and Trigger playback switches the amp envelope + surface (deck and overlay) between AHDSR and AHD accordingly. +- Once item 2's filter lands, its envelope switches identically: AHDSR + (right-anchored release) in Gate, 1:1 AHD in Trigger — the mode switch + swaps the amp and filter envelope surfaces the same way. +- A project saved before this change reopens sounding identical: a Trigger + instance's prior fade-in/fade-out contour — including a zero fade-out's + abrupt end — is audibly reproduced by the loaded AHD. +- The Trigger amp AHD behaves stage-for-stage like item 8's pitch AHD — Hold + semantics, curve treatment, and overlay mapping match. (This is the + observable proxy for the consolidation motive: one staged-envelope design, + two consumers.) + +--- + +## 15 — Enhancement: one-click in-sampler resample — bake the dialed sound into the bank and iterate + +**Daniel's ask (verbatim, 2026-07-29).** + +> I would like the ability to somehow resample from directly inside the +> ReaSampler 9000 VST. Once I have the filtering, pitching, amp all set up +> nice, one click should: +> send a trigger or gate through the sampler offline, capture the audio, +> replace the bank source capture with the recapture (or add a new distinct +> one if there are other references), replace that sampler capture with the +> new/corrected bank capture, and reinitialize the reasampler 9000 audio +> parameters to default, effectively allow reiteration over sounds from +> within the sampler. + +**Daniel's answer round (verbatim, 2026-07-29)** — answering, in order, the +five questions this item carried as awaiting a Daniel decision: + +> 1. I don't actually care, or even really understand, our zones. Retire +> the zone mapping system, simplify. I don't expect to use it, +> reasampler 9000 becomes 1 capture = one parameter set +> 2. capture root (so that parameter is not reset by resampling), for the +> gate and velocity questions, we need a popup menu to program note +> length, start/end offsets in ms AND in beats, and velocity for the +> capture signal. The popup shoulud have a preview trigger button to +> allow previewing the note capture as currently programed. +> 3. only what is baked into the sample recapture (contours, filter, master +> gain, etc) +> 4. any usage of that capture that is tied by the provenance/recaptureing +> system +> 5. Undo/Recovery is a plus + +**Daniel's second answer round (verbatim, 2026-07-29)** — the fourth +batch's second answer round. The unnumbered opening line answers item 16's +migration question (carried there); the numbered answers map, in order, +onto the doc's three under-specification flags: the +provenance-consolidation flag (→ item 17), this item's derived reset-scope +classification, and this item's capture-window semantics: + +> adopt the first zone's capture and parameters, I don't have any projects +> with zones used +> +> 1. the provenance/usage tracking will need to be consolidated and made +> 100% robust +> 2. that's good +> 3. yeah relative to note-on and note-off of the programed note length +> (1/8., 1/4t, 1/16, 4/1 etc (1/64th to 64/1 with dotted and triplet +> multipliers)). the project tempo under the item cursor. + +**Intent.** Close the sound-design loop from inside the instrument: once the +filtering, pitching, and amp are set up nice, one click bakes that processing +into the audio itself — a new bank capture — and hands the instrument back at +neutral, ready to be dialed again. Daniel frames it as a radical feature, and +it is: it turns the sampler from a player of captures into an iterative +resampling instrument, with the bank as the medium each iteration passes +through. + +**Behavior.** + +- **One gesture, whole chain.** A single click performs the full sequence: + offline pass → capture → bank update (replace or add-distinct) → instance + re-point → parameter reset. The steps are one action from the user's side, + not a wizard. +- **Offline pass through the instrument's own processing.** The audio is + produced by sending a trigger or gate through the sampler **offline** — the + instrument's own voice path, with the filtering, pitching, and amp exactly + as dialed, renders the result. The recapture is of that processed output, + not of the raw source. +- **The recapture is a bank capture like any other.** It lands in the bank — + project-relative, indexed, browsable from any surface that browses the + bank — and is governed by the same safety rules as every file the system + itself creates. +- **Replace, or add distinct.** When nothing else references the source + capture, the recapture **replaces** it as the bank entry; when other + references exist, the original entry stays and the recapture is **added as + a new distinct capture**. (What counts as an "other reference" is settled + by the answer round — usage tied by the provenance/recapture system; see + the settled bullet below.) +- **Replacement never destroys audio bytes.** "Replace" means the bank entry + now denotes the recapture; the superseded file itself is not deleted by + this action. *(Derived, not a Daniel quote: the settled rule that the + prune action is the system's only file-deletion authority admits no other + reading — resample writes a new file and retires the old one to + reclaimable-by-prune status; it never overwrites or deletes it.)* Until a + prune reclaims it, the pre-bake audio therefore survives on disk — the + iterate loop's built-in recovery floor, whatever the undo question below + settles. +- **The instance re-points.** The sampler's loaded capture is replaced with + the new/corrected bank capture — the instrument now plays the baked sound. +- **Parameters reinitialize to default.** After the swap, the instrument's + audio parameters return to default — destructive to the dialed settings + **by design**: the processing now lives in the recaptured audio, and the + neutral controls are the starting point for the next iteration. Which + parameters "audio parameters" covers is settled by the answer round — see + the reset-scope bullet below. +- **No timeline item, ever.** Resampling is a capture act: it writes a file + to the bank and updates the index; nothing is placed in the arrange view. + *(Derived, not a Daniel quote: the tool's load-bearing capture/placement + separation forces this — any framing of the feature that auto-inserts the + recapture into the timeline is invalid.)* +- **Single capture, single parameter set — "the sound" is unambiguous.** + Answer 1 dissolves the multi-zone question by **system change, not scoping + rule**: the zone mapping system is retired outright — recorded in full as + **item 16**. With one capture = one parameter set, one click resamples the + instrument's one capture through its one parameter set; no multi-zone rule + is needed because no multi-zone state exists. *(Settled by answer round — + by reference to item 16.)* +- **The capture-signal popup.** A **popup menu programs the capture + signal**: **note length**, **start and end offsets — in ms AND in + beats**, and **velocity**. The popup carries a **preview trigger button** + that auditions the capture note exactly as currently programmed — the + user hears the bake before committing it — and the offline pass renders + that same programmed performance. *(Settled by answer round — a new + sub-feature, specced here with its own acceptance criteria below.)* + - **The note is the capture's root.** The rendered note is the capture's + root note — and, as Daniel states as a consequence, **the root-note + parameter is therefore not reset by resampling** (capturing at root is + what makes the root parameter survivable: it composes with the + reset-scope rule below, and resetting it would detune every subsequent + iteration). *(Settled by answer round.)* + - **Note length is a musical division, not a free duration.** The + programmed note length is chosen from musical divisions spanning + **1/64th through 64/1, with dotted and triplet multipliers** (Daniel's + examples: `1/8.`, `1/4t`, `1/16`, `4/1`). *(Settled by second answer + round.)* + - **Offsets anchor to note-on and note-off.** The start offset is + relative to the programmed note's **note-on**; the end offset is + relative to its **note-off**. *(Settled by second answer round.)* + - **Beats resolve against the project tempo under the cursor.** A + beat-denominated value — the note-length division always, the offsets + when expressed in beats — resolves to time against **"the project + tempo under the item cursor"** (Daniel's phrase; read plainly: the + tempo in effect at the project's cursor position when the preview or + bake runs). *(Settled by second answer round.)* + - **Gate's hold and tail are answered by the programmed window.** The + programmed **note length is the Gate hold bound** — the gate holds for + the note length, then releases; item 9's loop-sustain cycles within the + held span and the render still terminates. The **end offset** is the + natural home of the tail policy: captured time past the note's end is + where the release rings. *(The hold reading is settled by the answer + round; the anchors are settled by the second answer round — with the + end offset anchored to note-off, the tail reading is direct, no longer + a derivation.)* + - **Velocity is explicit.** The programmed velocity is the render + velocity — material because the velocity transfer curves modulate amp + (and, with items 2 and 11, pitch and filter) at that velocity. + *(Settled by answer round.)* +- **Reset scope: only what the bake baked in.** *(Settled by answer round.)* + "Reinitialize the audio parameters to default" covers **only the + parameters whose effect is baked into the recaptured audio** — Daniel + names contours, filter, and master gain, with an explicit "etc". The + per-parameter classification follows — it began as this doc's derivation + and is now **confirmed by Daniel** ("that's good," second answer round); + the record that it originated as a reading-off is preserved, but the + reset/survive lists below are ratified, not derived: + - **Reset** (their effect is in the audio): the envelope contours — staged + and spline alike — the filter parameters, master gain, the pitch + envelope/engine settings, the velocity transfer curves (their effect at + the programmed velocity is in the audio), and the loop points (they + shaped the render, and old loop positions are meaningless against new + audio). + - **Survive** (mapping facts, not present in the audio): the **root + note** (explicit in answer 2), and — post-item-16 — whatever remains of + key mapping (key-tracking; any key-range concept item 16's open + question settles), plus the VOICE group (polyphony behavior leaves no + trace in a single rendered note). + - Edge classifications are verified at implementation review **against + the rule** — not new Daniel calls (open question below). +- **"Other references" = usage tied by the provenance/recapture system.** + *(Settled by answer round.)* Replace-vs-add is decided by whether **any + usage of the source capture is tied to it by the provenance/recapture + system**: tied usage exists → the original entry stays and the recapture + is added distinct; none → replace. Read plainly: the reference universe + is the resample system's own lineage records — **not** the + prune-protection universe. The answer does not appear to cover bank + multi-membership, items placed in the arrange, or a plain hold by another + instance outside any recapture lineage — those do not force add-distinct, + and need not for safety: the superseded file survives until prune, and + prune's protection universe is unchanged and broader than this one. How + the provenance/recapture system represents a "usage tie" is part of the + lineage question below — answer 4 makes that question load-bearing, and + the second answer round's answer 1 converts the dependency into a + requirement in its own right: **item 17** (the provenance/usage tracking + consolidated and made 100% robust), on which this rule now explicitly + depends. +- **Undo/recovery: a plus, not a requirement.** *(Settled by answer round — + at exactly that strength.)* Undo of the bake chain is **desirable but not + required**: welcome if it falls out cheaply, and the feature ships + without it. The guaranteed recovery path remains the floor already stated + above — the superseded file survives on disk until a prune reclaims it. + This is a nice-to-have note, not a spec. + +**Open questions.** + +- The five awaiting-a-Daniel-decision questions the fourth batch opened + (multi-zone scope; what the offline pass plays; reset scope; "other + references"; undo/recovery) are **all settled by the answer round** and + folded into Behavior above. What remains: +- **Capture-window residuals — propose at implementation review.** The + second answer round settles the anchors (note-on / note-off), the tempo + source (the project tempo under the cursor), and note length as a + musical division (1/64th–64/1, dotted/triplet). Two residuals remain: + whether negative offsets are meaningful (unchanged from before); and a + denomination seam — the first answer round expressed the offsets "in ms + AND in beats" while note length is now musical-division-only. The plain + reading is that the ms/beats duality applies to the offsets only; if an + ms display or entry for note length seems wanted at implementation, + propose it at review rather than assuming either way. +- **Reset-scope edge cases — verify at implementation review.** The rule is + settled and the per-parameter classification above is now ratified by + the second answer round ("that's good"). Only a genuinely new parameter — + one arriving with a queued item and absent from the ratified lists — is + classified against the rule and surfaced at review; not a new Daniel + call. +- **Extension presence — propose at implementation review.** The instrument + plays self-contained with the extension absent, but the bank is the + extension's surface, and resampling mutates the bank. The natural answer + is that resample requires the extension present and is cleanly + unavailable — not silently lossy — without it; propose the exact behavior + at review. +- **Provenance of the recapture — homed in item 17.** Captures carry a + reproducibility fingerprint of their capture recipe; a resample's recipe + is the instrument's own settings, not a track's chain. The answer round + made this load-bearing (replace-vs-add is decided by usage ties, so the + settled record-nothing conservative default is no longer available for + the lineage half — the recapture must carry whatever record makes + answer 4's decision computable), and the second answer round's answer 1 + converts it into **item 17**, where the consolidation requirement and + its open questions now live. This item's dependency stands: its + replace-vs-add half is meaningful only once item 17's consolidated + lineage exists. +- **Naming and lineage — propose at implementation review, jointly with + item 17.** When add-distinct fires, the new capture needs a display name + (derived from the original?), and the bank some way to read iteration + lineage across repeated bakes; propose at review as one proposal with + item 17's lineage-record question, on which answer 4 leans. +- (The multi-zone migration question this list previously carried is + settled in item 16 by the second answer round — adopt the first zone's + capture and parameters; a pre-existing multi-zone instance's resample + therefore bakes its first zone's sound.) + +**Acceptance criteria.** + +- On a dialed-in instrument (one capture, one parameter set — item 16), one + click yields all of: a recapture in the bank, the instance holding that + recapture, and the baked-in audio parameters at their defaults — with the + root note and the other surviving mapping parameters untouched (the + settled reset scope). +- The bake is audible and faithful: after the click, playing what the + offline pass played (the programmed capture note: root, at the programmed + length, offsets, and velocity, in the active mode) through the + now-neutral controls sounds as the dialed instrument sounded just before + the click — the processing has moved from the controls into the audio. +- The capture-signal popup exposes: note length as a musical-division + picker spanning 1/64th to 64/1 with dotted and triplet multipliers; + start and end offsets, each readable and editable in both ms and beats, + anchored to note-on and note-off respectively; and velocity. Its preview + trigger auditions the capture note exactly as programmed, and the bake + renders that same programmed performance (preview and bake cannot + diverge). +- Beat-denominated values resolve against the project tempo under the + cursor: the same programmed division yields a correspondingly different + rendered duration when the tempo at the cursor differs. +- A Gate-mode bake terminates on its own: the gate holds for the programmed + note length, then releases — even with item 9's loop-sustain active, the + render ends (no indefinite capture). +- After the bake the root note is unchanged — iteration never detunes: the + next bake plays the same root. +- Sole-reference case: the bank afterwards shows the recapture where the + source capture's entry was; no other bank entry is disturbed. + Other-references case (provenance-tied usage of the original exists — + the tie computed by item 17's consolidated tracking): the original entry + is untouched, a distinct new entry appears, and every other holder of + the original sounds exactly as before. +- The click deletes no file: the superseded audio file still exists on disk + afterwards, and only a later prune — under the settled orphan rules, only + when nothing references it — can reclaim it. +- The arrange timeline is untouched: no item appears anywhere, on any track. +- Iteration composes: dial → click → dial → click bakes the second pass onto + the first's result, repeatable indefinitely. +- Save/reload: an instance holding a recapture reloads and plays it exactly + like any other loaded capture. + +--- + +## 16 — Enhancement (simplification): retire the zone mapping system — one capture = one parameter set + +**Daniel's ask (verbatim, 2026-07-29 — answer 1 of item 15's answer round).** + +> I don't actually care, or even really understand, our zones. Retire the +> zone mapping system, simplify. I don't expect to use it, reasampler 9000 +> becomes 1 capture = one parameter set + +**Daniel's follow-up (verbatim, 2026-07-29 — the second answer round's +unnumbered opening line, answering this item's migration question).** + +> adopt the first zone's capture and parameters, I don't have any projects +> with zones used + +**Intent.** Simplification by deletion, not a feature: the multi-zone +keymap — zones with note ranges, per-zone parameters, and the dedicated +zone-editing surface — comes out of ReaSampler 9000 entirely. Daniel's +motive is stated plainly: he does not use it, does not care to understand +it, and wants the instrument simpler. The instrument becomes what its +common case already is: **one loaded capture played through one set of +parameters.** This arrived as answer 1 of item 15's answer round — it is a +far larger change than the question it answers (item 15's multi-zone +scope), which it dissolves rather than resolves; hence its own item. + +**Behavior.** + +- **One capture = one parameter set.** (Daniel's words.) The instrument + holds one loaded capture and one set of playback parameters governing it. + No zones, no per-zone divergence, no keymap of captures. +- **The zone-mapping surface goes away.** Forced by the decision, not + separately decided: the dedicated zone-editing surface and its authoring + affordances — add/delete zone, the per-zone parameter panel, and the + Low/High/Root zone legend — exist only to author zones and retire with + them. (The root note itself survives as a first-class parameter of the + one set — item 15's answer round makes it explicitly resample-stable; + only its zone-legend housing goes.) +- **The per-zone/per-instance storage distinction collapses.** Forced: + today playback parameters store per-zone, with VOICE and MASTER the + per-instance exceptions, and panel parity keeps the two editing surfaces + in step. With one parameter set there is nothing to be an exception to + and no second surface to keep in parity — every parameter simply belongs + to the instrument. Items 2, 9, and 11 carry superseded-by notes where + they asserted the per-zone convention. +- **Migration: adopt the first zone's capture and parameters.** *(Settled + by the second answer round.)* A saved multi-zone instance lifts to the + one-capture model by adopting its **first zone's** capture and that + zone's parameters; the remaining zones' captures and parameters drop + from the instance. Dropping a zone touches no file and no bank entry — + the instance simply no longer holds those captures; bank and prune + behavior are unchanged, per the bullet below. Daniel's stated rationale + is what makes a lossy rule acceptable here: **he has no projects with + zones in use**, so the migration path carries essentially no real-world + risk — no actual project sounds different under it. Consequently, for a + genuinely multi-zone instance the doc's standing sounds-identical + migration bar is **deliberately relaxed**: such an instance reopens + playing its first zone's sound only, and that is the accepted outcome — + accepted for that reason. Single-zone instances — the actual universe — + lift losslessly and reopen sounding identical, so the bar holds + everywhere it has real referents. (Which zone is "first" — the + instance's existing zone ordering — is a verify-at-implementation + detail with no real-world stakes, given the rationale.) +- **The single-capture experience is unchanged.** *(Derived, not a Daniel + quote: the retirement removes the multi-zone superstructure, not the way + a single capture plays — today's single-capture instrument already + behaves as one capture with one parameter set.)* A loaded capture still + plays across the keyboard repitched from its root, key-tracking still + applies, and every queued enhancement (items 1–15) lands on the one + parameter set unchanged in substance. +- **No effect on the bank or the extension.** The zone system is an + instrument-side mapping concept; captures, banks, the capture/placement + separation, and the prune safety model are untouched. + +**Open questions.** + +- The migration question this item carried as awaiting a Daniel decision + is **settled by the second answer round** — adopt the first zone's + capture and parameters — and folded into Behavior above, with his + rationale (no projects with zones in use) recorded there. **Nothing in + this item awaits a Daniel decision.** What remains is + propose-at-implementation-review: +- **Does any key-range concept survive? — propose at implementation + review.** With zones gone, does the capture respond across the entire + keyboard (repitched from root), or does a user-settable low/high playable + range survive as a plain parameter of the one set? Lean, consistent with + the simplification motive: no range concept — full-keyboard response — + with a low/high pair re-addable later as ordinary parameters if missed. + Not Daniel-blocking because the lean is cheap to reverse. +- **What the piano strip means with no zones — propose at implementation + review, jointly with item 12.** Item 12 keeps the full-width note-range + piano strip and adds note-name tooltips; item 15 needs the root + affordance. With no zone bars to draw, the strip's surviving jobs are the + root display/affordance and the tooltips — plus a range display only if + the key-range question keeps one. Propose the strip's exact contents at + review alongside item 12's work. + +**Acceptance criteria.** + +- The editor exposes no zone-mapping surface anywhere: no zone view or + button, no add/delete-zone affordance, no per-zone parameter panel, no + Low/High zone legend. The root-note control survives as a first-class + parameter. +- Every playback parameter edits in exactly one place and governs the + instrument as a whole; no gesture can express per-zone divergence. +- A single-capture instance saved before this change reopens sounding + identical — same keyboard response, same parameters, same root. +- A multi-zone instance saved before this change reopens holding its first + zone's capture with that zone's parameters — no error, no file touched, + no bank entry disturbed. (The sounds-identical bar deliberately does not + apply to this case: the instance reopens playing the first zone's sound + only — the settled, accepted outcome.) +- The one parameter set round-trips save/reload intact. +- Bank, capture, placement, and prune behavior are unchanged. + +--- + +## 17 — Enhancement (requirement): consolidate provenance/usage tracking — one system, 100% robust + +**Daniel's ask (verbatim, 2026-07-29 — answer 1 of the second fourth-batch +answer round).** + +> the provenance/usage tracking will need to be consolidated and made 100% +> robust + +**Intent.** A requirement, not a feature: the tracking machinery in the +provenance/usage territory becomes one coherent, fully robust system. This +began as a doc flag — item 15's answer 4 made recapture lineage +load-bearing while the territory's mechanisms were separate and ad-hoc — +and Daniel's answer converts the flag into a standing requirement. Today +the territory holds two separately-grown mechanisms plus one new demand: +(1) the **capture-recipe fingerprint** — a thin reproducibility record of +how a capture was made, deliberately not a restorable chain, conservatively +recording nothing when the situation is ambiguous; (2) the **instance-usage +tracking** — each live instrument instance declares the captures it holds, +so the prune can never delete a capture a live instance is using, with a +fail-safe stance that unreadable usage state halts the prune entirely; and +(3) item 15's demand for **recapture lineage** — records tying usages of a +capture to it "by the provenance/recaptureing system," deciding +replace-vs-add at bake time. "Consolidated" plainly means these stop being +separate ad-hoc mechanisms: one tracking system in which recipe provenance, +live usage, and recapture lineage are facets of the same record-keeping. +"100% robust" is a strength statement Daniel chose deliberately: this +territory gates the system's only file-deletion authority (prune) and its +only capture-replacement act (item 15's resample) — the two places where a +tracking error loses a user's audio or sound. + +**Behavior.** + +- **One system, not three mechanisms.** Recipe provenance, live-instance + usage, and recapture lineage are kept by one consolidated tracking + system, and every consumer — the prune's protection decision, the + resample's replace-vs-add decision, and any future lineage reader — is + answered from it. +- **What "100% robust" observably means.** Daniel stated the strength, not + the mechanics; the territory's stakes force three observable + implications, recorded without inventing further specifics: + - **No silent gaps.** Every capture the system itself creates is tracked + from the moment of its creation; a recapture carries its lineage from + birth, never backfilled. There is no window in which a + system-created file exists untracked. + - **Fail-safe on unreadable or ambiguous state.** Tracking state that + cannot be read never yields the destructive answer: the prune deletes + nothing (today's settled stance — preserved and generalized, not + relaxed), and the resample never takes the replace branch on + unreadable lineage (whether the safe branch is add-distinct or a halt + with a clear message is propose-at-review). + - **Consumers cannot disagree.** The prune's protection answer and the + resample's tied-usage answer are different questions with different + universes — item 15 settles that the replace-vs-add universe is + narrower than the prune-protection universe — but both are computed + from the same consolidated records, so they cannot drift apart or + contradict. +- **Existing guarantees are the floor.** Consolidation must not weaken + anything now settled: the prune still deletes only the system's own + orphans and never a referenced or live-held capture; the fail-safe abort + on unreadable usage state survives; the recipe fingerprint's + record-nothing-when-ambiguous conservatism survives for the recipe half. + The lineage half is the one place that conservatism is foreclosed — + item 15's answer 4 makes the lineage record mandatory for a recapture, + since replace-vs-add must be computable. +- **Dependency of meaning for item 15.** Item 15's replace-vs-add rule is + computable only once this item's consolidated lineage exists; item 15's + provenance-of-the-recapture question is homed here, and its + naming-and-lineage question is proposed jointly with this item's + lineage-record question. This is dependency of meaning, not merely + sequencing: without this item, "usage tied by the provenance/recapture + system" denotes nothing. + +**Open questions.** + +- None awaiting a Daniel decision — the requirement and its strength are + his; the shape is review work: +- **The consolidated shape — propose at implementation review.** What "one + system" concretely is (one record family, one authority, how the three + facets relate) is design work proposed at review, not a Daniel call. +- **The lineage record — propose at implementation review, jointly with + item 15's naming-and-lineage question.** What constitutes a "usage tie," + when it is written, whether it is ever severed, and whether iteration + lineage is user-readable from the bank. +- **The recipe-fingerprint half of a recapture — propose at implementation + review.** A resample's recipe is the instrument's own settings, not a + track chain; whether the fingerprint records a resample-shaped recipe — + and what its conservatism means there — is proposed at review. (The + lineage half has no record-nothing option; the recipe half may keep + one.) +- **Never-recorded vs. unreadable — propose at implementation review.** + Pre-existing captures predate lineage records, and the two absences + demand opposite treatment: never-recorded means no tied usage exists + (replace is legitimate, per item 15's settled rule); unreadable means + fail-safe. How the consolidated system distinguishes them — and how + pre-existing banks lift in without weakening any protection they enjoy + today — is proposed at review. + +**Acceptance criteria.** + +- One consolidated tracking system answers both safety-critical consumers: + the prune's protected set and the resample's replace-vs-add decision are + each computed from it, per their own settled rules; no separate ad-hoc + tracking mechanism remains in the territory. +- No silent gaps: a recapture created by item 15's bake is tracked from + the instant it exists — a bake followed immediately by a prune, or by a + second bake, behaves correctly with no window in which the recapture is + untracked or its lineage absent. +- Fail-safe throughout: with tracking state made unreadable, the prune + deletes nothing (and reports what blocked it, per today's behavior) and + the resample never takes the replace branch; no destructive act follows + from ambiguity, anywhere in the territory. +- Every protection settled today holds undiminished after consolidation: a + capture held by a live instance cannot be pruned; files the system did + not create are untouchable; unreadable usage state still halts the + prune. +- A pre-existing bank lifts into the consolidated system with no loss of + protection and no spurious lineage, and never-recorded remains + distinguishable from unreadable. +- Item 15's other-references case is decidable: for any capture, "does + provenance-tied usage exist" has a definite yes/no answer. diff --git a/docs/product/audit-notes/q-w0-t1-dsp.md b/docs/product/audit-notes/q-w0-t1-dsp.md new file mode 100644 index 0000000..86f34d7 --- /dev/null +++ b/docs/product/audit-notes/q-w0-t1-dsp.md @@ -0,0 +1,279 @@ +# Q-W0 Track 1 — DSP / audio algorithm-quality audit (findings) + +Static analysis only; no code changed. Surfaces per the track brief: `src/vst/pitch_shift.{h,cpp}` +(highest priority — GA correlation-aligned SOLA rewrite + GA2 prime + GA3 freeze, never audited), +`src/vst/sampler_core.{h,cpp}`, `src/peaks.*`, `src/wav_trim.*`, the capture/tail paths +(`src/capture.cpp`, `src/capture_realtime.cpp`, `src/realtime_record.h`), `src/vst/master_gain.*`, +`src/vst/velocity_curve.*`. + +Dispositions follow Q-11 (SETTLED): default document-and-defer; a bounded SOLA fix is weighed +before any technique replacement; technique replacement is Daniel's decision at triage. Note on +"assigned wave": **no structural wave Q-W1..Q-W6 opens the `src/vst/` DSP files** (they target +`core/json`, `bank_panel`, `main`, `actions`, `persist`, registration tables) — so any DSP finding +triaged fix-now is remediated in Q-W0 itself or folded in as a new point before Q-W1 begins. + +I cannot listen; every artifact below is stated as mechanism + predicted audible consequence. +Perceptual materiality is Daniel's call. + +--- + +## Overall verdict on the pitch engine (Q-11 framing) + +The correlation-aligned SOLA in `pitch_shift` is **not a reinvented wheel in the pejorative +sense** — single-tap SOLA with normalized cross-correlation splice alignment, parabolic sub-sample +peak refinement, and amplitude-complementary raised-cosine fades *is* an established time-domain +technique family (SOLA/TD-PSOLA lineage), and the implementation is unusually well-defended: +normalized (not raw) correlation, ratio-scaled fade lengths with a drain-headroom derivation, +prime-with-real-content onset, frozen-writer tail, filled-span clamping, and double-before-int64 +clamps at the overflow-prone spots. The RT discipline holds throughout: `process()` does no +allocation, no locks; the splice burst is bounded and fires once per splice cadence, not per frame. +**No technique replacement (phase-vocoder / WSOLA) is warranted on this evidence.** The findings +below are bounded-fix candidates and documented limits within the existing approach, exactly the +Q-11 escalation ladder's first rung. + +--- + +## Findings + +### T1-01 — Stereo Preserve: per-channel independent splice alignment decorrelates L/R +- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` (Preserve branch, ~577–589) + + `src/vst/pitch_shift.cpp` `PitchShifter::splice`. +- **Mechanism:** a stereo Preserve voice owns two `PitchShifter`s, each running its **own** + correlation search on its own channel's PCM. `bestLag + frac` differ per channel at every splice, + so after the first splice the two read taps sit at different ring positions — an inter-channel + time offset of up to ±`maxLag` (= window/4 ≈ **12.5 ms** at the 50 ms window), re-drawn at every + splice (cadence ≈ window/|ratio−1| frames). The splice *schedules* also diverge (delay drift + depends on tap position), so L and R fade at different times. +- **Predicted artifact:** on genuinely stereo captures played through Preserve off-root: stereo + image wander / widening that changes at the splice cadence, and comb-filter coloration on any + mono sum. Correlated stereo content (the common case for a captured bus) is the worst case. + Dual-mono (mono sample in stereo bus) is unaffected — the code correctly mirrors one shifter. +- **Severity:** **High** (Preserve is the product-default pitch engine and the output bus is + permanently stereo with channel mode auto-defaulting from the capture — this hits the flagship + path on stereo material). +- **Disposition proposal:** this is the strongest candidate for a **bounded SOLA fix** (Q-11 rung + 1): link the channels — run the correlation search once (on the L+R mid signal, or on L as + master) and apply the same `bestLag + frac` and splice schedule to both channels. Standard + practice for stereo SOLA. It reshapes the `PitchShifter` seam slightly (splice decision must be + computable once and applied to two rings — e.g. a lag-provider hook or a two-channel shifter), + but no technique change and no new dependency. Recommend **fix-now in Q-W0** pending Daniel's + triage call; if deferred, record it as the known stereo-Preserve limitation. + +### T1-02 — Ratio slew mid-fade can drain the outgoing tap past the writer +- **Location:** `src/vst/pitch_shift.cpp` `splice()` fade-length cap (~301–311) + `process()` tap + advance (~360–366). +- **Mechanism:** `fadeLen_` is capped from the drain headroom **at splice time** using the + then-current `ratio_`. The pitch envelope legitimately slews the ratio per frame + (`setShiftRatio` mid-fade). A ratio that **rises** after the splice (pitch-env attack toward a + positive peak, or attack from a negative dip back to base) drains tap B faster than the cap + assumed; the code comment claims the 2-frame margin covers "any realistic per-frame bias", but + the margin is absolute, not slew-proportional: e.g. a splice at ratio ≈ 1 sets + `fadeLen_ = window/4` with no cap (drainRate ≈ 0), and a pitch-env attack ramping to +24 st + (ratio 4) within those ~12 ms drains tap B ≈ 3·window/4 — far past the `dLow` ≈ window/4 + headroom. Tap B laps the parked/advancing writer and reads ring-length-stale content at up to + ~half fade gain. +- **Predicted artifact:** a periodic click/garble burst at the splice cadence during fast upward + pitch-envelope ramps on Preserve voices. Only reachable with the AD pitch envelope enabled and + steep; base transpositions (constant ratio) are correctly covered by the existing cap. +- **Severity:** Med. +- **Disposition proposal:** document-and-defer (needs pitch-env + Preserve + steep attack to + trigger), with a cheap bounded fix noted for whenever the file is opened: re-tighten `fadeLen_` + when the ratio increases mid-fade (the `freezeTail()` re-anchor block is the exact pattern to + reuse), or clamp tap B's delay to ≥ 2 during a fade. + +### T1-03 — Preserve prime ignores the Trigger play-end bound (short-span rings hold cut content) +- **Location:** `src/vst/sampler_core.cpp` `Voice::start` prime block (~375–400) vs. the GA3 + `feedBound` in `advanceFrame` (~568–572). +- **Mechanism:** the per-frame feed treats `playEnd_` (Trigger) / `frameCount` as source + exhaustion and freezes the writer so "no padding ever enters the ring" (GA3). But `start()` + primes a **full window** from `pcm[q]` bounded only by `frameCount` — not by `playEnd_` — and + pads with zeros past the sample end while declaring the whole window `filled_`. Two + consequences for spans shorter than the 50 ms window: (a) a Trigger zone's ring holds real PCM + **past the user's chosen stop**, which an up-shifted tap can reach and play (transposed) before + `readPos_ ≥ playEnd_` frees the voice; (b) a sample shorter than the window gets zero padding + inside the ring as declared-valid history, so splices can land in silence — a bounded re-entry + of exactly the burst/gap onset artifact GA2/GA3 eliminated, scoped to sub-50 ms material (short + drum one-shots are realistic content). +- **Severity:** Med (bounded to short spans / short samples in Preserve; inaudible for spans ≥ one + window). +- **Disposition proposal:** bounded fix candidate — prime `min(window, span-to-feedBound)` frames + and call `freezeTail()` immediately after prime when the span is shorter than a window (the GA3 + machinery then recycles the real short tail, which is its designed behavior). Small and + contained in `Voice::start`. Recommend fix-now in Q-W0 if Daniel agrees the short-one-shot case + matters; else document-and-defer with this note as the record. + +### T1-04 — No sustain-loop crossfade (hard loop seam) +- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` loop wrap (~493–498, 610–612). +- **Mechanism:** the sustain loop wraps by subtracting the loop length (phase-preserving) and the + interpolation partner wraps `i1 → loop.start`, giving one-sample continuity only. There is no + crossfade region: unless the user's loop points sit at amplitude/slope-matched positions, every + loop pass produces a step discontinuity — a click at the loop rate. `waveform_view`'s + zero-crossing snap on the loop markers mitigates but does not remove it (zero crossings with + mismatched slopes still click). Established samplers crossfade the loop seam (equal-power over a + user- or fixed-length region). +- **Severity:** Med (musically prominent when it hits, fully user-avoidable with careful loop + placement). +- **Disposition proposal:** document-and-defer — a loop-crossfade is a *feature* (needs a + crossfade-length parameter and UI surface), not a bug fix; wrong scope for a reorg phase. Record + as a known limitation beside the zone-loop spec. + +### T1-05 — Linear interpolation + no band-limiting on repitch (both engines) +- **Location:** `src/vst/sampler_core.cpp` Varispeed read (~607–624); `src/vst/pitch_shift.cpp` + `readTap` (~172–185). +- **Mechanism:** all fractional reads are first-order (linear). Linear interpolation's frequency + response rolls off highs and leaks imaging sidebands (the interpolation image spectrum is + attenuated only ~12 dB/oct); Varispeed up-shifts additionally alias (reading faster than 1× with + no pre-filter folds source content above the post-shift Nyquist back into band). This is classic + hardware-sampler behavior — often accepted, sometimes desired — and both engines share it + consistently. +- **Severity:** Low (quality ceiling, not a defect; deterministic and stable). +- **Disposition proposal:** document-and-defer as a recorded trade-off. If a quality bump is ever + wanted, a 4-point cubic Hermite read is a drop-in bounded upgrade at both call sites (no + structural change); band-limited varispeed is a much bigger lift and not recommended. + +### T1-06 — Correlation search: coarse step 4 can mis-lock on very high fundamentals; maxLag bounds alignment to ≥ ~80 Hz +- **Location:** `src/vst/pitch_shift.cpp` `splice()` search loops (~240–257) and `configure()` + geometry (~68–72). +- **Mechanism:** two documented-by-construction limits. (a) The coarse search samples the + correlation every 4 lags and refines ±3 around the coarse best — full integer coverage only + *near* the coarse winner. For content whose correlation oscillates with period < ~8 samples + (fundamentals above ~5.5 kHz at 44.1k), the coarse grid can alias and lock a non-optimal region; + the splice then lands up to half a period misaligned. (b) `maxLag = window/4` (~12.5 ms) cannot + span a full period below ~80 Hz, so deep-bass fundamentals cannot be period-aligned and splices + degrade toward unaligned OLA there. Both are inherent range/cost trades every SOLA makes; the + in-code comment already states (b). +- **Severity:** Low (edge content: pure tones > 5 kHz, fundamentals < 80 Hz). +- **Disposition proposal:** document-and-defer; record both bounds as the engine's stated + operating range. No change recommended — widening either costs splice-burst CPU linearly. + +### T1-07 — `splice()` up-jump clamp comment contradicts the code (margin direction) +- **Location:** `src/vst/pitch_shift.cpp` ~196–210. +- **Mechanism:** the comment derives the "tight cap" as `filled_ - d - maxLag_ - 2`, then says the + code's `- 1` is "one sample of conservative margin" — but `-1` permits a *larger* jump than + `-2`, i.e. the code is *less* restrictive than the comment's own derivation; the sentence has + the direction backwards. Re-deriving: the deepest probe is the parabola's outer lag at + `d + jump + maxLag + 1` (the interpolator's `i1 = i0 + 1` read-ahead moves *younger*, not + deeper), so the code's `-1` is exactly tight and the comment's `-2` double-counts the + interpolator. No out-of-range read either way; the comment is wrong, not the code. +- **Severity:** Low (doc-only; misleads the next maintainer of a safety-critical clamp). +- **Disposition proposal:** fix-now (comment rewrite, zero behavior change) — fold into whichever + Q-W0 remediation touches `pitch_shift`; if none does, a standalone one-line doc fix in Q-W0. + +### T1-08 — Linear-in-amplitude ADSR decay/release segments +- **Location:** `src/vst/sampler_core.cpp` `AdsrEnvelope::tick` (~131–170). +- **Mechanism:** decay and release ramp linearly in amplitude. Constant-slope amplitude is + constant-dB-rate nowhere: a long release spends most of its wall-clock at perceptually loud + levels then collapses abruptly (in dB terms the curve is logarithmic-late). Classic samplers use + exponential (constant-ratio) segments for decay/release. The evaluator itself is correct and + well-tested (release-from-current-level, hold-0 byte-compat re-dispatch are both right). +- **Severity:** Low (character, not correctness; the perceptual judgment is Daniel's). +- **Disposition proposal:** document-and-defer. An exponential-segment option is a contained + evaluator change but alters every existing instrument's envelope feel — a product decision, not + a Q-W0 cleanup. + +### T1-09 — Takeover-declick: `declickR_` is dead state +- **Location:** `src/vst/sampler_core.cpp` (~598–599, 639–646, 515–518). +- **Mechanism:** both channels deliberately share one blend weight (`declickL_` — commented), but + `declickR_` is still seeded and decayed every frame and never read for output. Dead state that + invites a future L/R-weight divergence bug. The blend itself audits **clean**: `out' = + (1−w)·out + w·ref` is a convex combination for w ∈ [0,1], so `|out'| ≤ max(|out|,|ref|)` — the + rev-2 boundedness claim is mathematically sound, the boundary-frame identity holds, and the + ring-out path on voice end is handled (the peer-path symmetry is present). +- **Severity:** Low (hygiene; no audio effect). +- **Disposition proposal:** fix-now-trivial (delete the field or rename the shared weight) — fold + into any Q-W0 edit of `sampler_core`; not worth its own change otherwise. + +### T1-10 — `planWavTruncate` silently drops chunks located after `data` +- **Location:** `src/wav_trim.cpp` `planWavTruncate` (~151), `capture_realtime.cpp` + `trimAutoTailInPlace`. +- **Mechanism:** the plan truncates the file at `dataByteOffset + keptDataBytes`. Any RIFF chunk + REAPER wrote *after* the data chunk (bext/iXML/smpl orderings vary by writer) is discarded; the + RIFF size is patched consistently so the result is a valid WAV, but metadata is lost without a + trace. The PCM and the trim boundary math themselves audit clean (file-rate-authoritative frame + math, scan confined to the tail region, -72 dB threshold single-sourced from + `kAutoTrimThresholdDb`, one-frame-past-last-audible per spec, no-trim fallbacks total). +- **Severity:** Low (metadata only; audio unaffected; trim is a convenience path). +- **Disposition proposal:** document-and-defer — note the behavior in the header's FORMAT + ASSUMPTION block when the file is next touched. Preserving trailing chunks would complicate the + single-truncating-write design for no audio benefit. + +### T1-11 — `makeUniqueTag` has one-second resolution (collision window) +- **Location:** `src/capture.cpp` (~224–227) and `src/capture_realtime.cpp` (~120–123). +- **Mechanism:** the uniqueness tag is `std::time(nullptr)` — 1 s resolution. Two captures of the + same `baseName` within the same wall-clock second derive the same file stem: the offline path + would overwrite the first render's file and mint two Samples with colliding ids. Reachable in + practice via `batch_capture` driving several short renders back-to-back. The realtime path + can't self-collide (transport exclusivity) but shares the pattern. DSP-adjacent rather than + DSP; recorded here because the capture paths are this track's surface — Track 2 may claim it. +- **Severity:** Low-Med (silent data loss on collision; narrow window). +- **Disposition proposal:** fix-now candidate, trivial: append a per-session monotonic counter to + the tag (both call sites). Belongs wherever Track 2/triage routes capture-path hygiene; Q-W3 + (main/orchestration split) is the nearest wave that opens the extension capture flow, else Q-W0. + +--- + +## Surfaces that came back clean + +- **`src/peaks.*` — clean.** The bin partition `[b·frames/binCount, (b+1)·frames/binCount)` is + exact integer math, remainder-distributing, no dropped tail; overflow guarded; short-buffer + clamped; per-channel with no fold (invariant honored). `columnMinMax` mirrors the partition with + 64-bit products and the enclosing-bin fallback. `lastFrameAboveThreshold` scans backward with a + correct strictly-greater test and no wrap hazard. +- **`src/wav_trim.*` — clean** except T1-10 (metadata note). Chunk walk is bounds-checked and + total; even-byte padding honored; extensible-format float discrimination via the SubFormat GUID + leading tag is correct; LE reads via `memcpy` (no aliasing UB); truncate plan never grows. +- **`src/capture.cpp` (offline) — clean** from the algorithm-quality lens except T1-11. Exact + unrounded bounds, dither forced off (bit-identical repeats), float32-only with ground-truth + format blob, surgical trim-end normalize only in Auto, full snapshot/restore RAII. The + precision-invariant plumbing is disciplined. +- **`src/capture_realtime.cpp` + `src/realtime_record.h` — clean** except T1-11 (shared) and the + already-in-code DAW-verify flags (take/frame-0 alignment assumption for the trim; abort()'s + best-effort finalize racing the flush — both explicitly documented in place, correctly scoped). + The record state machine's decisions are pure and ceiling-bounded; the trim is best-effort and + never eats the range body. +- **`src/vst/master_gain.*` — clean.** Taper endpoints single-sourced; norm-0 true-zero detent + with the finite −60 dB floor; unity at ≈ 0.714 as documented; inverse collapses sub-floor values + to the detent (documented); non-finite input clamped. The dB↔linear math is correct. +- **`src/vst/velocity_curve.*` — clean.** The Fritsch–Carlson tangent is the standard + weighted-harmonic-mean form (w₁ = 2h₂ + h₁, w₂ = h₂ + 2h₁), which bounds m ≤ 3·min(d₁,d₂) — + monotonicity and no-overshoot inside [0,1] hold as claimed; sign-change/flat neighbors pin to 0; + zero-span steps and coincident-X knots are handled; deserialize repairs the invariant + defensively. `eval` once per note-on keeps it off the per-frame path. +- **`sampler_core` voice/steal/mono machinery — clean** (beyond the findings above): the steal + policy is deterministic and as documented; the mono held-stack has correct range guards against + uint8 aliasing, order-preserving removal, per-note velocity for retrigger fallback, and CC 123 + as its only reset path; `soundingNote()` correctly excludes ring-out tails from the Preserve cap + and legato predicates; the two-tier panic semantics are right; both render overloads share one + summation discipline with no allocation; envelope/keymap resolution honors the rate-free-seconds + invariant (frames resolved at keymap build against the live rate — the prior frame-domain + incident is not repeated here). The per-frame `std::pow` when the pitch envelope is active is + bounded and acceptable. +- **`pitch_shift` core machinery — clean** (beyond the findings above): the safe-band geometry, + filled-span clamps, normalized correlation, parabolic sub-sample refinement, complementary + raised-cosine fade (correct for phase-aligned content; the −6 dB midpoint on uncorrelated + content is a documented, benign trade), `freezeTail`'s fade re-anchor continuity, and the + down-shift ring-lap margin all audit sound. Down-shift writer-lap is unreachable for any ratio + above ≈ −109 st; up-shift fade drain is covered to +24 st and beyond by the ratio-scaled cap + (T1-02 is the slew case only). + +--- + +## Summary table + +| ID | Surface | Finding | Severity | Disposition proposal | +|-------|----------------------------------|------------------------------------------------------------|----------|---------------------------------------------------| +| T1-01 | pitch_shift + sampler_core | Stereo Preserve: independent L/R splice alignment | High | Bounded SOLA fix (linked lag) — recommend fix-now in Q-W0; Daniel's call | +| T1-02 | pitch_shift | Ratio slew mid-fade can lap the outgoing tap | Med | Document-and-defer; bounded re-cap noted | +| T1-03 | sampler_core (Preserve prime) | Prime ignores Trigger playEnd / pads short samples | Med | Bounded fix candidate in Q-W0; else defer w/ note | +| T1-04 | sampler_core (loop) | No sustain-loop crossfade (hard seam) | Med | Document-and-defer (feature, not reorg scope) | +| T1-05 | sampler_core + pitch_shift | Linear interp, no band-limiting on repitch | Low | Document-and-defer (recorded trade-off) | +| T1-06 | pitch_shift | Coarse-search HF mis-lock; ≥ ~80 Hz alignment bound | Low | Document-and-defer (stated operating range) | +| T1-07 | pitch_shift | maxJump clamp comment contradicts code | Low | Fix-now (comment-only), in Q-W0 | +| T1-08 | sampler_core (ADSR) | Linear-amplitude decay/release segments | Low | Document-and-defer (product decision) | +| T1-09 | sampler_core (declick) | `declickR_` dead state | Low | Fix-now-trivial, fold into any Q-W0 edit | +| T1-10 | wav_trim | Truncate drops post-`data` chunks (metadata) | Low | Document-and-defer (header note) | +| T1-11 | capture.cpp + capture_realtime | 1 s-resolution unique tag → batch collision window | Low-Med | Fix-now candidate (monotonic counter); route at triage | + +Clean surfaces: `peaks`, `master_gain`, `velocity_curve` (fully); `wav_trim`, offline + realtime +capture paths, and the non-flagged machinery of `sampler_core` / `pitch_shift` (clean with the +noted exceptions above). diff --git a/docs/product/audit-notes/q-w0-t2-architecture.md b/docs/product/audit-notes/q-w0-t2-architecture.md new file mode 100644 index 0000000..efea37c --- /dev/null +++ b/docs/product/audit-notes/q-w0-t2-architecture.md @@ -0,0 +1,333 @@ +# Q-W0 Track 2 — architecture-smell audit (functional lens) + +Static analysis of the whole `src/` tree (extension + `src/vst/`), 2026-07-28, branch +`pq-w0-audit`. Complement to the grep-verified SOLID audit (§2) and naming audit (§2b) in +`docs/product/code-organization.md` — this track reports the **functional** smells those did not +target: duplicated *algorithms* (not merely duplicated responsibilities), reinvented wheels, +poor abstractions, and leaky pure/shell boundaries. Findings already catalogued there (the four +god-modules, the 4× JSON `Parser`, fat headers, `promptText`/`mintBankId` duplication, namespace +flatness, naming families) are **not restated**; where a finding below touches the same file it +is because the functional mechanism is new. + +Every claim below was verified by grep/read of the actual tree. Line numbers are as of this +audit's snapshot. Wave assignments reference PLAN.md §Q-W1..Q-W6. + +--- + +## Findings + +### T2-01 — 3× copy-pasted length-prefixed wire `Cursor`, with security-hardening drift +**Location:** `src/provenance.cpp:56–137`, `src/assignment_request.cpp:25–121`, +`src/sample_usage.cpp:25–89` (plus a fourth sibling: `src/vst/bank_sync.cpp:11–28` +`parseBankGeneration` re-rolls the same guarded decimal accumulate). + +**Mechanism.** The `':'` ext-state wire idiom ("one grammar across every ext-state +seam", per sample_usage's own comment) is implemented as three near-identical `putField` + +`Cursor` copies — and they have **drifted on the hardening**. The two newer copies +(`assignment_request`, `sample_usage`) carry a 20-digit length cap and an overflow guard +(`len > (SIZE_MAX - digit) / 10 → fail`) plus the subtraction-first bounds check +(`len > s_.size() - start`). The oldest copy (`provenance.cpp:65–82`) has **neither**: a crafted +long digit run wraps `len` silently, and the additive bounds check `start + len > s_.size()` +can itself wrap, letting a wrapped length pass. Downstream, `parseFingerprint` +(`provenance.cpp:197–199`) calls `r.trackGuids.reserve(guidCount)` on an **unbounded** count +parsed by the equally unguarded `fieldSizeT` — a corrupt/crafted `Sample.provenance` string in +the bank JSON can drive `reserve(huge)` into `std::length_error`/`bad_alloc` thrown through the +shell. (`sample_usage` fixed exactly this with its `count > wire.size()/4 + 1` sanity bound, +`sample_usage.cpp:121`; the fix was never backported.) `std::string::assign` clamping keeps the +wrap short of UB, but the parse-integrity promise ("never UB, never a partial value") is upheld +in two copies and eroded in the third — the textbook cost of a duplicated algorithm. + +**Severity:** High (the drift already produced a concrete robustness gap on a persisted, +user-editable input; the class of bug will recur with every new wire seam). +**Disposition:** **fix-now, split:** (a) backport the hardened `field()` + a count sanity bound +to `provenance.cpp` **in Q-W0** — small, pure, existing `provenance_tests` covers round-trip and +malformed-input paths; (b) the structural collapse (one shared `wire` codec module beside +`core/json`, all three seams + `parseBankGeneration` consuming it) belongs to **Q-W1**, which is +already the serialization-extraction wave. Rationale: the hazard is cheap to close now; the +dedup is a relocation-adjacent move that should ride the wave already creating `core/`. + +### T2-02 — a FIFTH hand-rolled JSON decoder the §2 audit did not count +**Location:** `src/tail_control.cpp:88–130` (`valueAfterKey` + `deserializeTailSetting`). + +**Mechanism.** The catalogued DRY violation is "JSON `Parser` duplicated 4×" (`bank_model`, +`bank_book`, `view_mode_model`, `owned_manifest`). `tail_control` carries a fifth, structurally +different JSON decode: a substring-scan reader (`json.find("\"key\"")` → skip ws → parse token). +It is correct for the flat single-object payload it reads (the file argues this honestly), but +it is a fifth place JSON-reading behavior is defined, with different tolerance semantics (a key +found inside a *string value* would match — impossible today only because the writer is its own +sole producer). If Q-W1 extracts `core/json` from the four `Parser`s and misses this site, the +"one JSON path" goal is silently not achieved. + +**Severity:** Med (no live bug; a completeness gap in the already-planned fix). +**Disposition:** **fix-now, folded into Q-W1** — add `tail_control` to the Q-W1 consumer list +explicitly. Rationale: zero extra cost when `core/json` lands; a stray fifth decoder afterward +would be a defect of the wave. + +### T2-03 — `readFileBytes` hand-rolled five times, both sides of the artifact split +**Location:** `src/capture.cpp:232`, `src/capture_realtime.cpp:329` (as `readAllBytes`), +`src/ingest.cpp:86` (comment admits: "of capture.cpp's readFileBytes"), +`src/vst/reasampler_processor.cpp:72`, and inline in `src/vst/reasampler_editor.cpp:749–756`. + +**Mechanism.** The identical ifstream-binary-ate/tellg/read whole-file loader exists five times +(two spellings, one anonymous inline). Well past extract-on-third-occurrence, and the copies +already disagree cosmetically (name, empty-on-failure comment placement) — the next divergence +will be behavioral (e.g. one copy gaining a size ceiling the others lack). + +**Severity:** Med. +**Disposition:** **fix-now, folded into Q-W1** — a trivial pure `readFileBytes` helper in the +`core/` utility home Q-W1 creates; both CMake targets link it. Rationale: five occurrences of a +ten-line function is pure debt with a zero-risk fix, but creating its home is exactly Q-W1's +job — doing it days earlier in the flat tree would just move the file twice. + +### T2-04 — the growing `GetProjExtState` read loop, three copies, pure half only half-used +**Location:** `src/persist.cpp:140–157` (`getProjExtStateString`), +`src/vst/reaper_bridge.cpp:93–107` (self-described "mirrors persist.cpp's growing strategy"), +`src/usage_scan.cpp:168–181` (self-described "the persist.cpp idiom"). + +**Mechanism.** The grow-buffer-until-it-fits retry loop over `GetProjExtState` is implemented +three times, in three TUs, on both sides of the split. The fiddly part — interpreting the int +return against the filled buffer — is *already extracted pure* as +`bridge_marshal::decodeGetProjExtState`, but only `reaper_bridge` consumes it; `persist` and +`usage_scan` interpret `rv` inline with their own conventions (persist: `rv <= 0` → absent; +bridge: return AND non-empty buffer). The absent-vs-truncated-vs-empty semantics are precisely +the kind of edge that drifts when defined thrice. `usage_scan`'s copy is prune-safety-adjacent +(an unreadable usage record must abort the prune) — its read loop deserves the tested pure +decode, not an inline reimplementation. + +**Severity:** Med. +**Disposition:** **fix-now, assigned to the downstream wave that opens `persist`** +(Q-W4 per the current wave map; whichever wave splits `persist.cpp` is the moment). Generalize +the retry policy (next-capacity/done decision) into `bridge_marshal` (or its `core/` successor) +and make all three loops consume it. Rationale: touching persist's session machinery outside +its own wave risks the highest-traffic shell for a dedup that has no live bug today. + +### T2-05 — 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family +**Location (structs):** `action_bar.h:61`, `bank_grid.h:23`, `card_drag.h:109`, +`component_geometry.h:28,53,106`, `drag_out.h:40`, `footer_bar.h:41`, `mode_switch.h:21,35`, +`overflow_menu.h:23,39`, `prune_button.h:32,46`, `tab_strip.h:24,51`, `tooltip.h:20`, +`vst/editor_geometry.h:19`, `vst/velocity_curve.h:124`. +**Location (predicates):** inline half-open `px >= r.x && px < r.x + r.width && …` re-typed in +`action_bar.cpp`, `bank_grid.cpp`, `card_drag.cpp` (×2), `component_geometry.cpp` (×2), +`drag_out.cpp`, `footer_bar.cpp`, `mode_switch.cpp`, `overflow_menu.cpp`, `prune_button.cpp`, +`tab_strip.cpp` (×2), `bank_panel.cpp:1049`, plus `vst/editor_geometry.cpp:20`. + +**Mechanism.** §2b.2 catalogued the *naming/collision* half of this (the `footer_bar.h` "NAME +NOTE" hand-checking smell). The functional half is uncatalogued: nineteen structurally identical +axis-aligned `{x, y, w, h}` record types, each with its own hand-typed containment predicate. +Every new pure-UI module re-mints both. This is Daniel's heuristic (b) verbatim: N near-identical +concrete implementations that one shared type collapses at compile time — one `ui::Rect` + one +`contains(Rect, x, y)` free function (both already exist in embryo as `vst/editor_geometry`'s +`Rect`/`contains`), with per-module aliases or thin wrappers only where a struct carries extra +fields (e.g. `TabRect::index`). Zero runtime cost; deletes ~15 chances for the next half-open/ +closed-interval inconsistency to slip in. + +**Severity:** Med. +**Disposition:** **fix-now, folded into Q-W2** (the ui/ relocation wave) — collapsing the type +zoo is nearly free precisely when every one of these files is being moved and re-namespaced; +doing it pre-reorg would churn 19 headers twice. Rationale: same-moment-as-relocation is the +stated principle for renames (§2b intro); it holds identically for type unification. + +### T2-06 — pure-computable layout math stranded in the VST editor shell (the §2 scope gap) +**Location:** `src/vst/reasampler_editor.cpp` — `SampleFaceLayout` (~line 922) and its builder, +`ClusterLayout` (~line 964), `zonesStripArea`/`noteEntryFieldsArea`/`noteEntryFieldRect`/ +`zonesControlPanel`/`zonesDeckArea`/`zonesCurveButton` (lines 992–1042), the channel-toggle +segment rects (~line 1065), banner rect math (~line 1195), among ~49 inline geometry +computations across the 3,065-LOC TU. + +**Mechanism.** The codebase's own grammar homes exactly this class of math in pure modules +(`editor_geometry`, `knob_deck`, `curve_popup`, `capture_browser`, …), yet the editor shell has +accreted a second, untested layout layer: whole named layout structs and pure `Rect → Rect` +functions that take only ints and rects, compiled into the one TU that cannot be unit-tested +without a host window. This is the mirrored form of the pure/shell leak (algorithm math living +untestable in a shell). Note also the audit-scope gap this exposes: §2's god-module catalogue +covered the extension tree only — `reasampler_editor.cpp` (3,065 LOC) and +`reasampler_processor.cpp` (1,164 LOC) repeat the bank_panel pattern on the VST side and appear +in no existing finding. + +**Severity:** Med (no correctness bug found in the stranded math; the cost is untestability and +the growth trajectory — the editor gained ~500 LOC/phase through r11). +**Disposition:** **document-and-defer, with a named reshape:** Q-W0 should surface a downstream +point (the wave that opens `src/vst/`, or a new one) hoisting the Sample-face/Zone-panel layout +into the existing pure homes (`editor_geometry` is the natural owner). Rationale: a hoist is a +behavior-preserving mechanical move best done under the reorg's test discipline, not pre-reorg; +but it must be a recorded point or the layer keeps growing. + +### T2-07 — the extension links the entire voice engine to serialize one preset blob +**Location:** `CMakeLists.txt:383–385` (`instrument_drop` → PUBLIC `sample_map`); +`src/instrument_drop.cpp` includes `vst/sample_map.h`, which pulls `sampler_core.h` → +`pitch_shift.h` + `velocity_curve.h`. + +**Mechanism.** `instrument_drop` (extension side) deliberately reuses +`sample_map::serializeComponentState` so the `.vstpreset` payload and the instrument's own +reader cannot drift — the right DRY call, explicitly documented in CMake. But the shared writer +lives *inside* the module that also owns zone resolution, WAV decode plumbing, and (via header +fan-in) the whole voice engine — so `reaper_reasampler` compiles and links `sampler_core`, +`pitch_shift`, and `velocity_curve` object code it never executes. The abstraction is right; its +*granularity* is wrong: the ComponentState codec is not separable from the engine stack today. + +**Severity:** Low (dead weight in the binary and a misleading dependency edge; no runtime cost — +heuristic (c) is about call chains, which this does not add). +**Disposition:** **document-and-defer to Q-W1/Q-W2 module-homing:** when serialization gets its +`core/` home, split a `component_state` codec module (types + serialize/deserialize only) out of +`sample_map`; both artifacts link the codec, only the VST links the engine. Rationale: purely +structural, zero behavior change, and exactly the kind of module-boundary decision the reorg +waves exist to make once, deliberately. + +### T2-08 — WAV/RIFF byte-format knowledge spread across four modules, two chunk walkers +**Location:** `src/wav_trim.cpp` (canonical parse: `parseWavLayout`/`extractFloatFrames`), +`src/capture_paths.cpp:31–115` (a second, independent RIFF chunk walker for content hashing), +`src/ingest.cpp:108–160` (hand-built 32f WAV writer), `src/capture_realtime.cpp:423` (in-place +RIFF/data size patch). + +**Mechanism.** The tree is disciplined about *decoding* ("no third WAV reader" — sample_map, +editor, processor all route through `wav_trim`), but RIFF *container* knowledge is still minted +per site: `capture_paths` walks chunks with its own tag/size/pad-byte logic to hash `fmt `+`data` +while skipping metadata; `wav_trim` walks the same container shape for layout; `ingest` writes +headers by hand; `capture_realtime` patches sizes by offset. Four places know the RIFF framing +rules (even-byte padding, chunk-header arithmetic); 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. + +**Severity:** Low (all four are currently correct against each other by inspection; the smell is +the maintenance surface, not a live divergence). +**Disposition:** **document-and-defer** — consolidate into a `core/wav` home (walker + layout + +writer + patch) when the reorg assigns module homes. Rationale: pre-reorg consolidation churns +the capture hot path (§3 guardrail) for no functional gain; the reorg wave that relocates +`wav_trim` is the natural moment. + +### T2-09 — the two capture backends' Sample-stamping epilogue is copy-paste with silent divergences +**Location:** `src/capture.cpp:490–537` vs `src/capture_realtime.cpp:505–540`. + +**Mechanism.** The finished-capture metadata stamp — `trackGuids`, `channelCount`, `sampleRate`, +`Master_GetTempo`, the `TimeMap_GetTimeSigAtTime` block, the WAV-aware `hashWavContent` content +hash (comment block duplicated verbatim, ~10 lines), `createdTimestamp` — is written twice, once +per backend. The copies have already diverged in quiet ways: offline passes `proj = nullptr` +(active project) to `TimeMap_GetTimeSigAtTime` while realtime pins `st.proj_`; the sampleRate +fallback logic differs in shape; realtime overrides `lengthSeconds` post-hoc. Some divergence is +semantic (realtime's tail-trim length), but the shared stamp is one concept — a future field +(e.g. a new provenance stamp) must currently be added in two places, and the time-sig +active-project vs pinned-project asymmetry is exactly the kind of drift that produces a +wrong-project stamp during a background-project capture. + +**Severity:** Med. +**Disposition:** **fix-now, folded into Q-W3** (the wave already hoisting capture orchestration +out of `main.cpp` / right-sizing `capture.h`). Extract a `stampCaptureSample(Sample&, const +CaptureRequest&, ReaProject*)` shared helper; the divergent bits (length override) stay in the +realtime caller. Rationale: the fix touches both backend TUs, which Q-W3 opens anyway; doing it +there keeps one review of the precision-invariant-adjacent code. + +### T2-10 — the two thumbnail pipelines' cache-invalidation strategies have drifted +**Location:** `src/bank_panel.cpp:422–483` (extension: `computeThumbnail` + pure +`ThumbnailKey{id, width, generation}` via `bank_grid::thumbnailKeyString`) vs +`src/vst/reasampler_editor.cpp:715–789` (VST: `monoPcmFor` keyed by bare `sampleId`, +`thumbnailFor` keyed by ad-hoc `sampleId + "|" + binCount`, invalidated by wholesale +`clear()` at lines 159–160/894). + +**Mechanism.** The dock panel and the VST browser render the same thumbnails through the shared +`peaks::computeEnvelope`, but the caching layer around it was re-designed independently on each +side: the extension bakes the bank generation into a *pure, tested* key type; the editor +hand-concats a string key with no generation and relies on call-site `clear()`s (bank-refresh, +resize). Both are correct **today** — but correctness on the editor side is distributed across +remembering every clear site, and the bin-clamp guard comment ("computeEnvelope pads binCount > +frameCount…") is duplicated verbatim in both TUs (`bank_panel.cpp:462`, +`reasampler_editor.cpp:780`), marking the copied design. A future refresh path that forgets the +clear shows stale waveforms with no test to catch it. + +**Severity:** Low. +**Disposition:** **document-and-defer** — when T2-06's layout hoist opens the editor, adopt the +pure `ThumbnailKey` (or a shared `thumb_cache` helper) on the VST side. Rationale: no live bug; +unifying cache policy is a natural rider on the editor wave, pointless as standalone churn. + +### T2-11 — ComponentState v1→v11 deserialize chain: sound, but the legacy branches triplicate the shared read +**Location:** `src/vst/sample_map.cpp:775–945` (`deserializeComponentState`). + +**Mechanism.** Audited the full lift chain for functional soundness: the bounded `ByteReader` +latches on truncation, every version's tail fields carry per-field corrupt fallbacks +(previewVelocity → mid default, voiceCount → default-not-clamp, gain → unity, refs → keep-parsed +prefix), and the strict-prefix envelope discipline is honest. **No correctness finding.** The +smell is shape: the v3, v4, and v5 branches each re-implement the mode-byte → marker → idLen/id +→ zones read sequence that the v6+ shared path also implements (three near-copies of the same +cursor walk, lines 806–841 vs 851+), and each new envelope version adds another +`version >= kꞏꞏꞏV*Version` stanza to a function already ~170 lines long. + +**Severity:** Low. +**Disposition:** **document-and-defer, explicitly.** The legacy branches are frozen back-compat +contract code with saved-project blobs as their only callers; rewriting them into a table-driven +lift risks the one thing they must never break, for zero user-visible gain. Record the pattern +so the *next* envelope bump (v12) prefers extending the shared path over minting another branch. +(The unbounded-suffix version-constant naming is §2b territory; not restated.) + +--- + +## Surfaces checked and found clean + +Recorded per the wave's no-silent-omission rule; each was read/grepped this audit. + +- **Pure-module include hygiene, both trees.** Every module CLAUDE.md claims pure was scanned + for REAPER/SWELL/WDL/LICE/VST3-SDK includes: all clean, `src/` and `src/vst/` both. The one + grep hit in `pitch_shift.h` is a comment (the S16 WDL-exclusion note), not an include. The one + cross-tree include (`instrument_drop` → `vst/sample_map.h`) is pure-to-pure — see T2-07 for + the granularity concern; it is not a boundary violation. +- **`bank_sync`** — the generation/consume decision rules are pure, explicit, and exhaustively + commented (rules 1–4); `parseBankGeneration` is overflow-guarded (its duplication is rolled + into T2-01's family, not a separate defect). +- **`bridge_marshal`** — one honest job, done pure, with the S1 string-scan JSON reader + documented as retired (verified: no second JSON parser on the VST side; `sample_map` routes + through `BankBook::deserialize`). +- **The realtime record lifecycle** — *not* an implicit state machine: `RecordPhase` is an + explicit enum with pure per-tick transitions in `realtime_record.h`; `main.cpp` holds only the + handle + project pointer. (Its *residence* in main.cpp is catalogued §2.1; nothing functional + to add.) +- **Project-identity transitions** — `classifyProjectTransition` is pure (capture_paths), the + shell passes `sameProjectObject` as a bool to keep it so; the GUID-primary layering is + decision-tabled in one place. +- **`usage_scan`** — every decision delegated to pure `sample_usage`; container recursion is + depth-bounded with a protect-on-truncation fail-safe; the `std::function` parm-getter + indirection is prune-scan-cold (heuristic (c) satisfied — no hot-path chain). +- **Exception boundaries** — the three `catch (...)` sites (`bank_book.cpp:794` stol guard, + `instrument_drop_win.cpp:74` REAPER-callback boundary, `render_settings.cpp:180` stod guard) + are all documented, narrow, and non-swallowing in intent (each converts to an explicit + failure value). No silent error swallowing found. +- **`FxBypassGuard` (main.cpp) vs `view.cpp` park/restore** — both are snapshot-mutate-restore + over track flags and *look* like a dedup candidate; they are deliberately not one. Different + flag sets, different invariants (precision-neutralization vs Design-View parking), different + failure postures. Duplication of shape, not of concept — correctly left separate. +- **Path resolution** — `capture_paths::resolveBankFile` is the single resolver on both sides + of the split (panel, insert, drag_out, editor, processor). No parallel path logic. +- **WAV decode on the play path** — `wav_trim` is genuinely the only decoder (T2-08 concerns + the *container* knowledge spread, not a second decoder). +- **Draw layer** — the VST editor/embed compile the same `draw_kit`/`theme`/`component_geometry` + the extension uses (verified in CMake + includes); no parallel draw vocabulary grew on the + VST side. +- **Interface cost audit (heuristic (c))** — `ICaptureBackend` is the tree's only virtual + interface; two real implementations, dispatched once per capture (cold). No hot-path virtual + or std::function chain found in `sampler_core`/`pitch_shift`/`sample_map` (all static calls). + No interface-with-one-implementation found anywhere. +- **Boolean-parameter proliferation** — swept `src/` headers for multi-bool signatures; the only + hit is `bank_grid::applyClick(…, bool ctrl, bool shift, …)`, which mirrors physical modifier + keys and reads fine at call sites. Not a finding. + +## Cross-checks against the §2/§2b audits (gaps noted, not restated) + +- §2's evidence base is scoped to the extension tree ("45 files / ~19,800 LOC"); the full tree + is now ~39,000 LOC. The VST shells repeat the god-module pattern uncatalogued + (`reasampler_editor.cpp` 3,065 LOC, `reasampler_processor.cpp` 1,164 LOC) — carried here as + T2-06's scope note so the reorg waves size the `src/vst/` work realistically. +- §2.1's "4× JSON Parser" undercounts by one — T2-02 (`tail_control`). +- §2b.2's shared-rect naming hazard has an uncatalogued functional twin — T2-05. + +## Summary table + +| ID | Finding | Severity | Disposition | Where | +|-------|---------------------------------------------------------------|----------|-------------------|--------------| +| T2-01 | Wire `Cursor` ×3 with hardening drift; provenance unguarded | High | fix-now (split) | Q-W0 backport + Q-W1 dedup | +| T2-02 | Fifth JSON decoder in `tail_control` | Med | fix-now | Q-W1 | +| T2-03 | `readFileBytes` ×5 across both artifacts | Med | fix-now | Q-W1 | +| T2-04 | Growing ext-state read loop ×3; pure decode half-adopted | Med | fix-now | persist's wave (Q-W4) | +| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates | Med | fix-now | Q-W2 | +| T2-06 | Layout math stranded in VST editor shell (+§2 scope gap) | Med | document-and-defer (named reshape) | src/vst wave | +| T2-07 | Extension links voice engine to share the preset serializer | Low | document-and-defer | Q-W1/Q-W2 homing | +| T2-08 | RIFF container knowledge in 4 modules / 2 chunk walkers | Low | document-and-defer | core/wav homing | +| T2-09 | Capture backends' Sample-stamp epilogue copy-paste w/ drift | Med | fix-now | Q-W3 | +| T2-10 | Thumbnail cache-invalidation strategies drifted across split | Low | document-and-defer | editor wave | +| T2-11 | ComponentState legacy lift branches triplicate the shared read | Low | document-and-defer | (pattern note for v12) | diff --git a/docs/product/audit-notes/q-w0-t3-env-constants.md b/docs/product/audit-notes/q-w0-t3-env-constants.md new file mode 100644 index 0000000..993aa15 --- /dev/null +++ b/docs/product/audit-notes/q-w0-t3-env-constants.md @@ -0,0 +1,220 @@ +# Q-W0 Track 3 — env-coupled-constant domain-modeling audit + +Static analysis, 2026-07-28, branch `pq-w0-audit`. Scope: any value stored in an +environment-coupled domain — frames, sample rate, DPI, pixels, tick cadence — that should be +stored **rate-free / device-free and resolved at the point of use** (PLAN.md §Q-W0 env-coupled +bullet; `docs/product/code-organization.md` §2c.3; the load-bearing `sample_map` seconds +invariant). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar +applied: a finding requires (a) an env-coupled *stored* domain AND (b) an environment that can +actually change under it. Frame counts computed transiently from seconds at the use site are +correct and are not reported. + +Waves referenced for disposition: Q-W1..Q-W6 open `bank_panel.cpp`, `main.cpp`, `actions.cpp`, +`persist.cpp`, and relocate the clean pure libs — **no downstream wave opens +`reasampler_processor.cpp` / `sampler_core.h` / `reasampler_editor.cpp` for logic change**, so +fix-now findings in those files must be remediated in Q-W0 itself. + +--- + +## Findings + +### T3-01 — master-gain ramp step is a frame-domain constant anchored to 48 kHz + +- **Location:** `src/vst/reasampler_processor.cpp:46-51` (`kGainRampRate = 1.0f / 960.0f`, + `kGainRampSnap`), applied per-sample at `:1069-1085` (stereo) and `:1114-1126` (mono). +- **Stored vs. correct domain:** stored as a **per-sample linear step** — `960` is literally + 20 ms × 48 000 Hz, and the comment says so ("960 samples @ 48 kHz ≈ 20 ms"). The intended + quantity is a **wall-clock ramp time** (~20 ms); the correct model is a seconds/ms constant + with the per-sample step derived from `sampleRate_` at `setupProcessing` — exactly the + pattern the same file already uses two paragraphs away for `kPreserveWindowMs` + (`:618-623`, `:733-735`). +- **What breaks when the environment shifts:** the ramp's wall-clock length halves at 96 kHz + (~10 ms) and quarters at 192 kHz (~5 ms); at 44.1 kHz it stretches to ~21.8 ms. The FB1 + "no zipper" contract degrades silently as the host rate rises. Not persisted, so no on-disk + breakage — but it is a live hardcoded-rate assumption in `src/`, which Daniel's standing + ruling forbids. +- **Severity:** Med. +- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: trivial, isolated, + behavior-identical at 48 kHz; no downstream wave opens this file, so deferring means keeping + a named violation of the no-hardcoded-rate ruling through the whole reorg. Store + `kGainRampSeconds = 0.020`, derive the step from the live rate where `sampleRate_` is set. + +### T3-02 — takeover-declick decay is a per-frame coefficient (documented deliberate) + +- **Location:** `src/vst/sampler_core.h:409-410` (`kDeclickDecay = 0.95`, + `kDeclickFloor = 1e-4`), applied per output frame in `sampler_core.cpp:514-519`, `:643-645`. +- **Stored vs. correct domain:** a per-output-frame exponential coefficient; the implied + wall-clock decay-to-floor is ~4.2 ms at 44.1 kHz and ~1.9 ms at 96 kHz. The strict + domain-model form would be a time constant in seconds resolved to a coefficient at engine + build. +- **What breaks when the environment shifts:** the takeover-blend residue fades ~2× faster at + 96 kHz. Audibly negligible for a declick micro-ramp — and the in-code comment + (`sampler_core.h:397-401`) **already documents this as a deliberate per-frame DSP micro-ramp, + "not a stored wall-clock quantity"**, with the 44.1–96 kHz variance stated and accepted. +- **Severity:** Low. +- **Disposition:** **document-and-defer.** Rationale: the coupling is already an explicit, + written, bounded design decision in the code; converting it buys no audible improvement. + Triage should ratify the in-code note as the record. + +### T3-03 — Trigger-fade UI throw ceiling hardcodes 2 s × 44 100 as `88200.0` frames + +- **Location:** `src/vst/reasampler_editor.cpp:395` + (`constexpr double kFadeMaxFrames = 88200.0`), used by `controlValue`/the commit path to + normalize the Trigger fade-in/out knobs. +- **Stored vs. correct domain:** the fade **storage** domain (int64 SOURCE frames, persisted in + the zones payload) is settled and correct — a source-timeline fact, invariant under project- + rate change (PLAN.md §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second + max fade throw") as a frame count at an assumed 44.1 kHz source. `88200` is a rate-derived + literal in `src/`, brushing the no-hardcoded-rate ruling even though it never touches disk. +- **What breaks when the environment shifts:** the environment here is the **source file's + rate**: a 96 kHz capture's maximum fade throw is ~0.92 s; a 22.05 kHz file gets 4 s. The knob's + full-scale meaning silently varies per loaded sample. +- **Severity:** Low (UI-only, not persisted, comment flags it as a "build-time residual — one + place to retune"). +- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: small and contained — replace + with `kFadeMaxSeconds = 2.0` resolved against the loaded source's rate at the two normalize + sites (the editor already threads `frameCount + rate` through the pack/unpack path, + `envelope_edit.cpp:148`); storage domain unchanged. If triage prefers zero UI-feel change, + the fallback is document-and-defer with the comment amended to name the 44.1 k assumption. + +### T3-04 — drop-hint banner duration stored in sync-timer ticks + +- **Location:** `src/vst/reasampler_editor.cpp:2920-2923` (`dropHintTicks_ = 6`), decayed in + `onSyncTimer` (`:244-245`); field at `reasampler_editor.h:457`. +- **Stored vs. correct domain:** a wall-clock intent ("a few seconds of banner") stored as a + **count of `kSyncTimerIntervalMs` ticks** (6 × 500 ms). Correct model: a duration in ms, + ticks derived — or a `GetTickCount`-style deadline like the bank panel's tooltip already + uses. +- **What breaks when the environment shifts:** retuning the sync cadence (a plausible perf + tweak — the 500 ms value is itself a tuning constant) silently changes the banner duration. + The comment does state the coupling. +- **Severity:** Low. +- **Disposition:** **document-and-defer.** Rationale: cosmetic, self-documenting at the single + site, and the cadence and hint decay live three lines apart; a fix is fine to fold in + opportunistically if the file is ever opened, but does not justify a Q-W0 edit on its own. + +### T3-05 — systemic: no DPI/content-scale support in either UI surface + +- **Location:** systemic. VST3 editor: `reasampler_editor.cpp:150-153` (`ViewRect(0,0,840,620)` + default and the size floor at `:816`), all `editor_geometry` / `knob_deck` / `curve_popup` / + `envelope_overlay` px constants (e.g. the 8 px node min-separation, the 28×28 curve button), + cached font sizes in `draw_kit`. Extension side: the LICE-drawn `bank_panel` dock and its + geometry modules. No implementation of VST3's `IPlugViewContentScaleSupport` anywhere in + `src/vst/` (grep: zero hits for content-scale/DPI), no scale factor threaded through the + pure geometry modules. +- **Stored vs. correct domain:** every layout constant is a **physical device pixel** that + silently assumes ~96 DPI. Correct model: logical units × one scale factor resolved at draw + time (the pure geometry modules take widths/heights as parameters already, so a scale factor + threads through cleanly — the constants are centralized, which is the good news). +- **What breaks when the environment shifts:** on a 150–200 % Windows display the editor and + dock render physically small (or get bitmap-stretched by the host, blurring text); hit + targets like the 8 px min node separation shrink below comfortable pointer accuracy. + Usability, not correctness — nothing mis-plays and nothing persisted is wrong. +- **Severity:** Med (usability on modern displays; Windows-only product makes high-DPI common). +- **Disposition:** **document-and-defer.** Rationale: a proper UI-scaling pass is a feature + wave of its own (scale plumbing through ~15 geometry modules + font cache + both shells), + far outside Q-W0's remediation budget; deferral should be recorded as a named future phase, + and Q-W1's relocation of the geometry modules should keep the constants centralized so the + eventual scale factor lands in one place. + +### T3-06 — legacy v3 zone-payload lift divides by the *current* project rate + +- **Location:** `src/vst/sample_map.cpp:601-605` (v3 lift inside `readZonesPayload`), format + note at `sample_map.h:439-456`, `:510-513`. +- **Stored vs. correct domain:** the v3 blobs (Daniel's beta projects) stored wall-clock times + as frames — **the prior incident itself**. The lift converts frames → seconds by dividing by + the live `projectRate` threaded in at read time. That is exact only if the project rate today + equals the rate in effect when the S15/S16 editor wrote the frames; the write-era rate was + never recorded, so a project whose rate changed since lifts skewed times (old/new ratio, + e.g. ~8.8 % for 44.1→48 k). +- **What breaks when the environment shifts:** already broken by construction for + rate-changed-since-write projects; a one-time lift residue, after which v5+ re-saves in + seconds and the skew is frozen in, silently. +- **Severity:** Low (legacy-only, beta-project blobs, envelope-time magnitudes; unrecoverable + in principle — the missing datum was never written). +- **Disposition:** **document-and-defer.** Rationale: no better conversion exists; this is the + documented residue of the incident that motivated the seconds invariant. Worth one sentence + in the code-quality-audit report so the skew is a recorded known, not a mystery bug later. + +### T3-07 — SOLA correlation-segment cap of 512 frames (deliberate CPU bound; cross-ref T1) + +- **Location:** `src/vst/pitch_shift.cpp:72` + (`corrFrames_ = max(1, min(dLow_ - 1, 512))`; rationale comment at `:64-67`). +- **Stored vs. correct domain:** borderline by design. The quantity being bounded is **work per + splice** (multiply-accumulates), which is genuinely frame-domain — a CPU bound *should* be in + frames. The side effect is that the correlation segment's wall-clock span halves at 96 kHz + (512 frames ≈ 11.6 ms at 44.1 k, ≈ 5.3 ms at 96 k), raising the lowest frequency the + alignment search can lock onto at high rates. All other shifter geometry correctly derives + from `kPreserveWindowMs` resolved at the live rate. +- **What breaks when the environment shifts:** alignment quality for low-frequency content + degrades somewhat at high host rates; no correctness or persistence impact. +- **Severity:** Low. +- **Disposition:** **document-and-defer**, and hand to the T1 DSP audit for the quality call. + Rationale: the frame domain is arguably correct for a compute bound; whether 512 is the right + *number* is an algorithm-quality question (T1's territory), not a domain-modeling one. + +--- + +## Surfaces checked clean + +- **`sample_map` v5+ persistence (the reference implementation):** AHDSR + pitch-env times as + SECONDS doubles; no rate constant anywhere in the read/write paths (`kLegacyV3NominalRate` + deliberately does not exist); legacy v3 lift takes the rate as a parameter. Clean. +- **ComponentState envelope v6–v11 fields:** channel mode, assign generation, preview velocity, + voice count/mode/trigger, `masterGainLinear` (dimensionless linear), explicit flag, + `SampleRefs` (paths + root/loop/channels intrinsics), `instanceGuid` — all rate-free or + file-fact domains. Clean. +- **Trigger `fadeInFrames`/`fadeOutFrames`/`startPoint`/`SampleLoop.start/end` persisted as + int64 SOURCE frames:** deliberate, settled source-timeline facts (PLAN.md §S15; + `bank_model.h:66-72` documents the loop rationale) — frames *of the file* are invariant under + project-rate change; the file's own rate is stored alongside and resolved at decode. Correct + domain, not a finding. +- **`trigger_seam`:** frames↔fraction with `startFrame` threaded both directions; the overlay's + fraction domain is expressly rate-invariant. Clean. +- **`kPreserveWindowMs` (50 ms):** resolved to frames against the live host rate at both call + sites (`reasampler_processor.cpp:618-623`, `:733-735`) — the correct pattern, cited here as + the model T3-01 should copy. +- **`pitch_shift` internal geometry:** ring length, fade, lag band, delay band all derived from + the rate-resolved `window_`; ratio-scaled live fade length. Clean (T3-07 cap noted above). +- **Tail system:** `TailSetting.manualMs` persisted in **ms**; `kMaxTailSeconds`/`kMaxTailMs` + wall-clock; trim threshold in **dB** with the linear ratio derived + (`render_settings.h:59-86`); the realtime decay scan resolves frames against the **file's own + authoritative rate** (`capture_realtime.cpp:384-408`). Clean. +- **`wav_trim`:** frame counts are parsed file facts and transient truncate plans. Clean. +- **`bank_model` persisted metadata:** source bounds in seconds + PPQ (both stored, each for + its consumer); loudness in dB; `sampleRate`/`channelCount` are *recorded facts about the + file*, not assumptions; capture tempo + meter stamped at capture time deliberately so the + bars.beats read-out is stable under later project meter changes (`card_meta`). Clean. +- **Ext-state wires** (`banks` JSON, view-mode model, owned manifest, `assignment_request`, + `rsusage_*`): no frame-domain values; the rate field in the assign wire is a recorded fact. + Clean. +- **Envelope schematic (`envelope_overlay`/`envelope_edit`):** param-domain px↔seconds scale + derived from the live rect (`gatePxPerSecond`), sample-length-free; editor time-slider + ceiling is `kEnvTimeMaxSeconds = 2.0` (seconds). Clean (pixel constants themselves fall under + the systemic T3-05). +- **Timers:** bank_panel tooltip delay uses `GetTickCount()` ms against `kTooltipDelayMs = 500` + (wall-clock — the pattern T3-04 should copy); editor sync timer is a 500 ms `SetTimer` + interval (ms, not ticks); the new-content detector is an event diff per tick with no + wall-clock meaning encoded in tick counts; `retireIdleDrain` is idleness-driven, not + time-driven. Clean. +- **`peaks` / `waveform_view` / `master_gain` / `velocity_curve` / `keyboard_strip`:** bins and + columns derived from rects at use; dB↔linear taper and curve math dimensionless; key rects + from the passed strip rect. Clean. + +## Summary + +| ID | Location | Stored domain | Severity | Disposition | +|----|----------|---------------|----------|-------------| +| T3-01 | `reasampler_processor.cpp:46-51` gain-ramp step | per-sample step (20 ms @ 48 k baked in) | Med | **Fix-now (Q-W0)** — store seconds, derive step from `sampleRate_` | +| T3-02 | `sampler_core.h:409-410` declick decay | per-frame coefficient | Low | Document-and-defer — deliberate, already documented in-code | +| T3-03 | `reasampler_editor.cpp:395` fade throw ceiling | 88200 source frames (2 s @ 44.1 k) | Low | **Fix-now (Q-W0)** — seconds ceiling resolved vs. source rate at use | +| T3-04 | `reasampler_editor.cpp:2923` drop-hint duration | sync-timer ticks | Low | Document-and-defer — cosmetic, coupling stated in-code | +| T3-05 | systemic (both UI surfaces) | physical px, ~96 DPI assumed; no content-scale | Med | Document-and-defer — a UI-scaling phase of its own; keep geometry constants centralized through Q-W1 | +| T3-06 | `sample_map.cpp:601-605` v3 legacy lift | frames ÷ *current* project rate | Low | Document-and-defer — unrecoverable legacy residue; record as known skew | +| T3-07 | `pitch_shift.cpp:72` correlation cap | 512 frames (CPU bound) | Low | Document-and-defer — frame domain arguably correct for a compute bound; hand to T1 for the quality call | + +Two fix-now findings (T3-01, T3-03), both assigned to **Q-W0 itself** — no downstream wave +opens those files for logic change. Five deferrals, each with a recorded rationale. The +persistence surfaces — the highest-stakes case — are clean: every wall-clock quantity written +to disk since the S12 remediation is in seconds or ms, and every frame-domain persisted value +is a source-file fact whose rate travels with it. diff --git a/docs/product/audit-notes/q-w0-t4-sizing.md b/docs/product/audit-notes/q-w0-t4-sizing.md new file mode 100644 index 0000000..9634422 --- /dev/null +++ b/docs/product/audit-notes/q-w0-t4-sizing.md @@ -0,0 +1,399 @@ +# Q-W0 Track 4 — structural sizing + placement audit + +Date: 2026-07-28 · Branch: `pq-w0-audit` · READ-ONLY static analysis (no build, no code edits) + +**Method.** Line counts measured with `wc -l` on the worktree; seams derived from function-definition +skeletons (`grep` for top-level definitions + section markers) plus targeted reads. Acceptance bar = +Daniel's three heuristics: (a) more directories a must, files ≤ ~600 lines, SRP applies to files and +namespaces; (b) templates are good where they dedup at compile time; (c) saved CPU beats abstraction — +no dispatch-stack blowouts, prefer static polymorphism where types are compile-time-known. + +**Measured sizes differ from the wave brief in several places** (the tree moved after the brief was +drafted — GA/pS/pS-usage landed): `reasampler_editor.cpp` 3065 (brief said 3035), +`reasampler_processor.cpp` 1164 (1004), `sampler_core.cpp` 968 (1049), `sample_map.cpp` 970 (807), +`sample_map.h` 708 (600), `sampler_core.h` 762 (820), `actions.cpp` 1016 (996), `persist.cpp` 852 +(812). All numbers below are the measured ones. + +--- + +## 1. Oversize census + seams + +Every `.cpp`/`.h` in `src/` (both sides) over ~600 lines, with the *real* responsibility clusters. +Where a file is genuinely one responsibility, I say so and recommend leaving it. + +### 1.1 The four planned splits — do the plan's seams still land sub-600? + +**T4-01 — `src/bank_panel.cpp` (3459; plan assumed 2424).** +The plan's six seams (`panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / +`panel_bank_ops` / `panel_window`) no longer all land sub-600 at current size. Tally against the +skeleton: + +| Planned TU | Functions (line spans) | Est. LOC | Verdict | +|---|---|---|---| +| `panel_thumbnails` | `computeThumbnail`/`thumbnailFor` (420–487) | ~130 | fine | +| `panel_render` | `drawCardMeta`/`drawThumbnail` (487–547), kit adapters (547–570), `drawFooter` (685–780), `drawToolbar`/`drawMoreButton`/`drawTooltip` (992–1155), `drawRegionGrid`/`drawCardDropTarget`/`drawRegionHeader`/`drawTabStrip`/`paintPanel` (1334–1646) | ~700 | **over — needs the layout cut below** | +| *(unplanned)* **`panel_layout`** | toolbar/footer/menu rect + row/cluster builders (571–684, 785–991), split geometry + region rects + L7 slot-order display bridge (1156–1333) | ~500 | **new TU required** — this is pure-ish geometry glue, distinct from LICE drawing; extracting it puts `panel_render` at ~550 | +| `panel_audition` | `initPreview`/`startAudition`/`stopAudition`/`deinitPreview` (1874–1965) | ~90 | fine (keep the direct call-through guardrail) | +| `panel_input` | input helpers + `regionAt` (1965–2010), click routing `handleBanksChromeClick`…`handleKey`/accelerator (2384–2710), plus new-content detection (1647–1874, ~230) | ~800 | **over — needs the drag cut below** | +| *(unplanned)* **`panel_drag`** | `updateDropTarget`/`dropTargetBankId`/`classifyCardDrag`/`applyDragCursor`/`resolveHover`/`updateHover`/`maybeShowTooltip`/`onMouseMove`/`doReorderDrop`/`doReplaceDrop`/`resetDragState`/`onLBtnUp`/`handleRightClick` (2711–3185) | ~475 | **new TU required** — the card-drag/hover state machine is a cohesive cluster of its own (it already has a pure mirror, `card_drag`); extracting it puts `panel_input` at ~550 | +| `panel_bank_ops` | `promptText`/`mintBankId`/`doCreateBank`…`removeSamples`/`focusedSelectionIds`/`resolveDragPathsForOs` (2006–2231) + popup menus (2231–2384) | ~375 | fine (menus ride with bank_ops or input — either works; they invoke the ops) | +| `panel_window` | `handleDropFiles`/`dlgProc`/`openPanel`/`closePanel` (3185–3336) + public API (3336–3459) | ~275 | fine | + +**Proposal:** eight TUs, not six — add `panel_layout` and `panel_drag`. +**Severity:** high (it is the biggest file in the repo). **Disposition: reshapes wave Q-W2** — +the wave brief must name eight seams, or two of its six TUs ship >600 on day one. + +**T4-02 — `src/main.cpp` (1897; plan assumed 1762).** +The plan's three hoists are the right seams, but `capture_orchestrator` as specced lands **~885 +lines** — over by half again. Tally: `FxBypassGuard` (627–731, ~105), `renderOffline` + +`captureAndIndexOne` + `RunCapture` + `RunCaptureItemAssign` (731–909, ~180), batch family +(`ItemSelectionGuard`/`selectOnlyItem`/`RunBatchCaptureItems`/`collectRazorAreas`/ +`TrackSelectionGuard`/`RunBatchCaptureRazor`, 909–1181, ~270), `RunRecaptureFromSource` (1200–1398, +~200), realtime + insert actions (1398–1512, ~115). `scope_resolve` (361–590) ≈ 230 ✓; +`realtime_lifecycle` (186–360) ≈ 175 ✓; registration/entry residue (1512–1897) ≈ 385 ✓ (shrinks +further under Q-W6's table). +**Proposal:** split the orchestrator seam once more: `capture_orchestrator` (FxBypassGuard + +single-capture path + realtime/insert action bodies, ~450) and **`capture_batch`** (batch family + +`RunRecaptureFromSource` + the two selection guards, ~470). Recapture is planner-driven like batch +and shares the selection-guard machinery — it belongs with batch, not the single-shot path. +**Severity:** high. **Disposition: reshapes wave Q-W3** — add the fourth TU to the brief. + +**T4-03 — `src/actions.cpp` (1016; plan assumed 981).** +Plan's seams still land: `design_view_actions` (59–421, ~360), `bank_actions` (422–1016 minus prune, +~490), `prune_action` (`doBankPruneFolder` 821–916 + registration share, ~130). All sub-600. +**Disposition: no change to Q-W4.** + +**T4-04 — `src/persist.cpp` (852; plan assumed 766).** +Plan's seams still land: `ext_state_io` (helpers + `saveToActiveProject` + `writeAssignmentRequest`, +112–288, ~180), `prune_fs` (`scanPruneOrphans`/`pruneDryRun`/`pruneOrphanSet`/`deleteOrphanFile`/ +`pruneReclaim`, 288–539, ~250 — pS-usage growth landed here, exactly where the plan isolates it), +`session` (load/guid/poll/reload, 539–852, ~315). All sub-600. +**Disposition: no change to Q-W5.** + +### 1.2 Known offenders beyond the planned four — extension side + +**T4-05 — `src/bank_book.cpp` (1109) + `bank_book.h` (457).** +Three genuine seams: **`SlotMap`** (27–143, ~115 — a self-contained ordered-slot container with its +own serialize at 614), **`BankBook`** registry/CRUD/transfer/slot-reconcile (145–545, ~400), and +**JSON serialize + `Parser`** (547–1109, ~560). Q-W1 deletes the Parser + `ObjWriter`/`writeEscaped` +copies; what remains of serialization rewired onto `core/json` is ~150. +**Proposal:** after Q-W1, split `slot_map` into its own TU/header pair (it is a distinct type with +its own tests-worthy invariants); `bank_book.cpp` lands ~550. **Severity:** medium. +**Disposition:** fold into Q-W1 (the JSON rewire already opens this file; the `slot_map` file split +is one `git mv`-shaped extraction on top). + +**T4-06 — `src/view_mode_model.cpp` (1049) + `view_mode_model.h` (748).** +Four seams: **indexes** (`ModeRegistry`/`MembershipIndex`/`LaneOwnershipIndex`, 29–140), **pure +planners** (`autoTagNewContent`/`planItemRetag`/`planLaneMinting`/`makeParkPlan`/`makeRestorePlan`/ +`nextModeId`, 143–326, ~185), **`ViewModeModel`** state + visibility/toggle planning (332–490), and +**JSON serialize + `Parser`** (494–1049, ~555). Q-W1 deletes the Parser (~390); remainder ~660. +**Proposal:** split planners (`view_plan.cpp`) from model+indexes (`view_mode_model.cpp`, ~450 after +JSON extraction). The header's 26 structs split the same way: mode/membership/lane types + model +class vs. the plan-record structs (`TrackPlan`/`TogglePlan`/`AutoTag`/`ItemRetagOp`/`LaneMint*`). +**Severity:** medium. **Disposition:** fold into Q-W1 (JSON rewire opens the file; planner split +rides it). If the wave wants to stay minimal, the planner split can defer — post-extraction ~660 is +marginal, not pathological. + +**T4-07 — `src/bank_model.cpp` (767).** +Two seams only: the model (`Sample` equality + `BankIndex` verbs, 25–145, ~120) and JSON +(`ObjWriter`/`writeSample`/`Parser::parseSample`/`parseIndex`, 148–767, ~620). This file is the +poster child for Q-W1: after the extraction it is ~250 total (model + thin serialize using +`core/json`). **Disposition: already owned by Q-W1; no new seam needed.** + +**T4-08 — `src/capture_realtime.cpp` (867).** +Two seams: the **async record lifecycle** (`RealtimeCaptureState` snapshot/restore, `begin`/`tick`/ +`abort`, 177–324 + 569–867, ~450) and the **file-side finalize** (`readAllBytes`/`writeU32LE`/ +`trimAutoTailInPlace`/`finalizeRecording`, 324–566, ~240). The lifecycle is genuinely one +responsibility (the header itself documents why the restore lives on the state object). The finalize +half — WAV byte-patching, decay-scan trim, move-into-bank — is a distinct concern that talks to +`wav_trim`, not to the transport. +**Proposal:** split `capture_realtime_finalize.cpp` (~240); lifecycle TU lands ~600 with the file +banner. Both stay in `shell/capture/`. **Severity:** low-medium. **Disposition:** ride Q-W3 (the +wave already renames this family per the Q-9 naming rider — same-wave file surgery is free). + +**T4-09 — `src/view.cpp` (677).** +Two halves: **park/restore + flag application** (`snapshotTrack`/`applyFlags`/`parkFxOffline`/ +`restoreFxOffline`/`applyMode`, ~350) and **lane management** (`laneName`/`managedLaneOrdinals`/ +`applyLanePlays`/`applyLaneOps`/`readLaneTracks`/`assignItemToLane`/`applyMintPlan`/ +`mintManagedLanes`/`reconcileManagedLanes`, ~330). The D2 lane machinery arrived after the file's +original charter and is a separable concern. +**Proposal:** split `view_lanes.cpp`. **Severity:** low (677 is barely over). **Disposition:** +document-and-defer unless Q-W1's relocation is already touching it — then take the free split. + +**T4-10 — `src/ingest.cpp` (636).** +Seams: **pure-ish WAV/PCM helpers** (`buildFloat32Wav`/`decodePcmSource` + byte I/O, 86–217, ~130 — +see T4-22: `buildFloat32Wav` is a pure function trapped in a shell TU), **`importFileIntoActiveBank`** +(249–420, ~170), and the three ingest surfaces + registration (420–636). Extracting the pure WAV +build into a testable core module (`wav_write` beside `wav_trim`, or one `wav_codec`) drops the shell +to ~500 and gains a test target. +**Severity:** low-medium. **Disposition:** fix in whichever wave lands the WAV consolidation +(T4-22); the file split itself is a rider. + +### 1.3 Known offenders — VST side (entirely absent from the current plan) + +**T4-11 — `src/vst/reasampler_editor.cpp` (3065) + `reasampler_editor.h` (539).** +The single biggest unplanned file — it grew past `main.cpp` in the r11 recomposition, *after* the +plan was written. It is now the `bank_panel.cpp` of the VST artifact, with the same god-module +profile. Real seams, from the skeleton: + +| Proposed TU | Functions (line spans) | Est. LOC | +|---|---|---| +| `editor_session` | ctor/dtor, `refreshFromBank`/`rebuildVisible`/`onSyncTimer`/`commitAndReload`/`loadSelection`/`upsertPickedOverride`/effective-zone helpers (146–400), PCM + thumbnail caches `monoPcmFor`/`thumbnailFor` (719–800) | ~420 | +| `editor_controls` | control-value map `controlValue`/`applyControl` (399–475), knob-deck plumbing `zoneDeckGroupDescs`/`deckGroupDescs`/`deckControlNorm`/`applyDeckKnob`/`deckValueLabel` (475–649), envelope pack/unpack + `commitPickedMarkers` (649–719), `applyZoneControl` (2576–2587) | ~470 | +| `editor_layout` (pure candidate — see T4-23) | anon-ns geometry: `computeSampleBands`/`clusterRects`/zone-panel areas/`channelToggleRects` (904–1076), `computeBrowseModal` (1756–1785), `zoneContentArea` (1917–1924) | ~250 | +| `editor_paint_sample` | `paint` dispatch (1169), `drawTitleBand`, `paintSample`/`paintEnvelopeOverlay`/`paintVelocityCurve`/`paintKnobDeck`/`paintCurveButton`/`paintCurvePopup`/`paintEmptyState` + `drawKnobFace`/`drawSpectralStrip`/`drawRootMarker` (1076–1756) | ~590 | +| `editor_paint_browse_zone` | `paintBrowse` (1785–1917), `paintZone` (1924–2041) | ~260 | +| `editor_input` | `resolveHover` (2041–2145), `onMouseDown` (2147–2576 — a 430-line per-face dispatch), popup/curve mouse (1637–1735), `onMouseMove`/`onMouseUp`/`onMouseRDown`/`onMouseWheel`/`onSearchChar`/`onFilesDropped` (2587–2929) | ~880 → **split by face**: `editor_input_sample` (~500: sample-face hit branches + drag state + curve popup) and `editor_input_browse_zone` (~380: browser cards/scroll/search + zone strip/note entry) | +| `editor_platform` | IPlugView overrides `isPlatformTypeSupported`/`canResize`/`checkSizeConstraint`/`attachedToParent`/`removedFromParent`/`onSize`/`invalidate` (800–904), `wndProc` + non-Windows stubs (2929–3065) | ~280 | + +Seven-to-eight TUs, all sub-600, each a real cohesive cluster (session/bridge state · param +plumbing · layout · paint × 2 · input × 2 · platform). The face structure (Sample / Browse / Zone) +is the natural input/paint split axis — it mirrors how the code already dispatches. +**Severity: highest of the audit** — this is the largest unowned file in the repo. +**Disposition: reshapes the wave plan — needs a new owning wave** (see §1.5 / summary table). + +**T4-12 — `src/vst/reasampler_processor.cpp` (1164) + `reasampler_processor.h` (502).** +Four seams: **VST3 lifecycle + bus boilerplate** (`queryInterface`…`setupProcessing`, +`setBusArrangements`, 118–209 + 492–506, ~120), **component-state I/O** (`setState`/`getState` + +`legacyLiftShouldRun`, 209–349 + 774–904, ~270), **accessors/param setters** (349–492, ~140), and +**reload + drain + usage-publish + render** (`reloadInstrument`/`publishUsage`/ +`publishBuiltLocked`/`rebuildVoiceEngine`/`retireIdleDrain` 506–774 ~270, `process` 904–1164 ~260). +**Proposal:** three TUs — `processor_state` (state I/O + accessors, ~410), `processor_reload` +(reload/drain/usage, ~290), `reasampler_processor.cpp` (lifecycle + `process()`, ~400). Keep +`process()` and its block-render helpers in one TU (heuristic c — see T4-27). All are member +functions of one class; partial-class-across-TUs is the same pattern the Q-W2 panel split uses. +**Severity:** medium-high. **Disposition:** new VST wave (see §1.5). + +**T4-13 — `src/vst/sample_map.cpp` (970) + `sample_map.h` (708).** +Two clean halves plus a small third: **resolution core** (bank-JSON distill/select/list, SampleRefs +management, PCM downmix/extract/decode, `resolvePlay`, keymap builders, performance-map resolution + +`reconcileSingleCaptureZones`, 14–406, ~390) and **binary component-state codec** (`putU32le`/ +`putU64le`/`ByteReader`, zones payload put/read, `serializePerformance`/`deserializePerformance`, +`serializeComponentState`/`deserializeComponentState` v3→v11 lift ladder, selection codec, +406–970, ~565). +**Proposal:** split `component_state_io.cpp` (the codec, ~565) from `sample_map.cpp` (resolution, +~400). The header splits the same way: wire/state structs (`ComponentState`/`SampleRefEntry`/codec +decls) vs. resolution API (`SelectedSample`/`PerformanceMap`/`ZonePlaySeconds`/resolvers). This is +the same shape as the extension's model-vs-JSON split and directly reduces rebuild fan-out — the +editor and processor both include `sample_map.h` today and recompile on every codec tweak. +**Severity:** medium-high (the codec grows every ComponentState version bump — v6→v11 in one +quarter; it will cross 600 on its own soon). **Disposition:** new VST wave. + +**T4-14 — `src/vst/sampler_core.cpp` (968) + `sampler_core.h` (762).** +Contents: pitch math (15–30), `Keymap` (34–58), `AdsrEnvelope`/`TriggerEnvelope`/`PitchEnvelope` +(62–251, ~190), `Voice` (255–680 — `advanceFrame` alone is ~200), `VoiceEngine` (680–968, ~290). +**This TU is a genuine single responsibility — the realtime voice engine — and it is the hottest +code in the repo.** Every function in it sits on the per-sample render path; the envelope `tick()`s +and `Voice::advanceFrame` benefit from same-TU inlining (no LTO assumption in the build). Splitting +the .cpp along class lines would put per-sample calls across TU boundaries — precisely the +heuristic-(c) violation the phase forbids. +**Proposal:** **leave the TU whole at 968** (documented exception to the 600 bar, justified by the +hot path), but **split the header**, which is where the pain actually is: `zone_params.h` (the enums ++ `AdsrParams`/`TriggerParams`/`PitchEnvParams`/`ZonePlayParams`/`SampleLoop`/`SampleData` — what +`sample_map`, the editor, and the codec actually need, ~250) vs. `sampler_core.h` (Keymap + the +engine classes, ~500). Today every UI TU that reads a param struct recompiles when a `Voice` member +changes. **Severity:** medium. **Disposition:** header split in the new VST wave; TU stays — +recommend recording the exception in the wave brief so nobody "fixes" it later. + +**T4-15 — `src/view_mode_model.h` (748)** — covered under T4-06 (splits with its TU). +**T4-16 — `src/vst/sample_map.h` (708)** — covered under T4-13. +**T4-17 — `src/vst/sampler_core.h` (762)** — covered under T4-14. + +### 1.4 Borderline (no action, for the record) + +`capture.cpp` (549), `reasampler_editor.h` (539 — shrinks when the editor splits move private +helpers into their TUs), `reasampler_processor.h` (502 — same), `bank_book.h` (457), `pitch_shift.cpp` +(371 — single responsibility, hot, leave), `persist.h`/`capture.h` (341/234 — Q-W6 fat-header pass +already owns them). None need action beyond what their TU splits imply. + +### 1.5 The structural conclusion for the wave plan + +The existing plan splits 4 files, all extension-side. The census says **9 files need splitting and 2 +need header-only splits — 5 of them VST-side, which currently have no owning wave.** The VST work is +the same kind and size as Q-W2 (the editor alone ≈ the old bank_panel). Recommendation: add one VST +god-module wave (call it **Q-W2v**, runnable in parallel with Q-W2 — different artifact, zero file +overlap; or sequence after W5 as Q-W7 if Daniel wants serial waves). Q-W1's relocation scope also +grows: the ~20 clean VST pure libs relocate + namespace in W1 alongside the extension's 30. + +--- + +## 2. `src/vst/` placement in the Q-3 directory map + +The settled Q-3 map (`core/{model,view,capture,audio,ui,reclaim,version,json}`, +`shell/{capture,panel,view,persist,actions}`, `app/`) covers only the extension. Two viable shapes +for the VST artifact; **this is Daniel's fork to call at triage.** + +**T4-18 — Leading recommendation: integrate into the same `core/`/`shell/` top split, with +`instrument/` subsystem dirs beneath.** + +``` +core/instrument/engine/ sampler_core, pitch_shift, velocity_curve, master_gain +core/instrument/map/ sample_map (+component_state_io), bank_sync, bridge_marshal, note_entry, trigger_seam +core/instrument/ui/ editor_geometry, keyboard_strip, waveform_view, capture_browser, + browser_scroll, param_slider, knob_deck, curve_popup, + envelope_overlay, envelope_edit, embed_strip +shell/instrument/ reaper_bridge, processor TUs, editor TUs, reasampler_embed, vst_entry, + reasampler_vst.h / reasampler_uid.h +``` + +Rationale: +1. **One rule, no special case.** Q-3's settled reasoning is "top-level by the load-bearing + discipline, because the pure/shell split is the invariant worth making structural." That reasoning + is artifact-agnostic — a file's directory should tell you whether it may touch a *host* type + (REAPER or VST3 SDK), and `shell/instrument/` says exactly that. +2. **The artifact boundary is a link-graph fact, not a source-layout fact — and the sources already + straddle it.** Verified cross-artifact consumers: `sample_map` links `bank_book` + `wav_trim` + + `sampler_core`; the editor includes `draw_kit`/`theme`/`component_geometry`/`capture_paths`/ + `peaks`/`wav_trim`/`app_version`/`ext_keys` (extension-side modules); the extension's pure + `instrument_drop` includes `vst/reasampler_uid.h`. An artifact-first subtree would either + duplicate these or still reach across — the boundary it draws is already false. +3. **Namespace map falls out:** `reasampler::instrument::{engine,map,ui}` beside + `reasampler::model` etc. — the Q-4 rule applied uniformly. +4. **CMake impact: path edits only.** Targets, links, and test executables are unchanged; the + VST3-gate (`EXISTS pluginfactory.cpp`) already guards targets, not directories. + +Cost to name: the VST3-gated targets stay interleaved through the top-level `CMakeLists.txt` rather +than being isolatable behind one `add_subdirectory`. Mitigable by grouping the instrument targets +into one guarded block (or one `include()`d .cmake file) without moving sources. + +**T4-19 — Alternative: parallel artifact-first subtree** — `src/vst/core/{engine,map,ui}` + +`src/vst/shell/`, extension keeps `src/core|shell|app`. Pros: the artifact boundary is visible at +top level; the whole VST tree (sources *and* a dedicated `src/vst/CMakeLists.txt`) can sit behind +one SDK-gated `add_subdirectory`, which is the cleanest possible expression of "this half only +exists on Windows with the submodule slice". Cons: two parallel `core/` trees dilute the "directory += may it touch a host type" invariant into "check which subtree first"; the shared-module reality +(point 2 above) means the subtree is not actually self-contained — its purity is cosmetic; and the +gated-`add_subdirectory` win is achievable under T4-18 with an `include()` anyway. **Recommend +T4-18; T4-19 is defensible if Daniel weighs artifact legibility above discipline uniformity.** +**Disposition: reshapes Q-W1** (the relocation wave executes whichever shape is chosen). + +--- + +## 3. Template-collapse opportunities + +Judged per heuristic (b) — proposed only where duplication is real and the template earns it; two +anti-recommendations included, because a forced template is the worse smell. + +**T4-20 — Little-endian byte codec: real template win.** +Five hand-rolled copies, verified: `putU32le`/`putU64le` + `ByteReader` (`vst/sample_map.cpp` +406–500), `writeU32LE` (`capture_realtime.cpp` 342), `readU32LE` lambda (`capture_paths.cpp` 49), +`putU32` lambda (`ingest.cpp` 134), `appendU32LE` (`instrument_drop.cpp` 18). One header — +`core/wire/bytes.h` (or beside `core/json`): `template void putLE(std::vector&, +T)` / `template bool readLE(ByteReader&, T&)` with the double↔bits helpers — replaces all +five, compile-time dispatched, zero runtime cost, and gives the ComponentState codec (T4-13) a +tested primitive. Entirely off hot paths (serialization/file I/O only). +**Severity:** medium (each new ComponentState version re-duplicates today). **Disposition:** fix in +the wave that lands `component_state_io` (the biggest consumer); consumers rewire opportunistically. + +**T4-21 — Rect family: unify, but with a concrete type, NOT a template.** +Verified 12+ byte-identical `{int x,y,width,height}` structs (`ActionBarRect`/`CellRect`/ +`PanelClientRect`/`FooterBarRect`/`HeaderRect`/`SegmentRect`/`MenuBarRect`/`MenuButtonRect`/ +`FooterRect`/`ButtonRect`/`TabStripRect`/`KitBox`…) plus a *second grammar* on the VST side +(`editor_geometry`'s `Rect` is LTRB with `left/top/right/bottom` + `height()`). The right tool is +one concrete `ui::Rect` + `contains()` with per-role type aliases (`using ButtonRect = ui::Rect;`) +so call sites keep their semantic names — Q-W1's "one `ui::` owner" note already points here; this +finding extends it: (a) retire the XYWH-vs-LTRB fork by picking one grammar (LTRB has the live +`contains`/`height` users; either works — pick once), and (b) the per-type `contains`/`hitTest*` +one-liners collapse for free. A template rect would model nothing — the types differ in name only. +**Watch:** cross-lib name collisions (extension `Rect` vs vst `Rect`) surface only when both headers +meet in one TU — `sample_map` and the editor are exactly such TUs; the Q-4 sub-namespaces are the fix. +**Severity:** medium. **Disposition:** ride Q-W1 (it is the settled `ui::` unification, widened to +include `editor_geometry::Rect`). + +**T4-22 — Linear rect-scan hit-tests: small template, real but modest.** +`hitTestCell` (`bank_grid`), `hitTestSlot` (`card_drag`), and the tab/segment scans are the same +first-rect-containing-point loop over records that carry a rect plus extra fields (`SlotCellRect` +adds `slot`). After T4-21, a single `template int hitIndex(int px, int py, +span)` (requiring `r.rect.contains(px,py)` or a rect accessor) collapses them. Earns its +keep only if T4-21 lands first; alone it would be a forced template. +**Severity:** low. **Disposition:** document-and-defer; opportunistic rider on Q-W1. + +**T4-23 — WAV build/parse consolidation (dedup, not template).** +`buildFloat32Wav` (ingest, 116–179) hand-writes the float32 header that `wav_trim` hand-parses and +`capture_paths`/`capture_realtime` chunk-scan/byte-patch. One pure `wav_codec` (or fold build into +`wav_trim`, renamed) gives one tested owner of the RIFF layout. Concrete functions; nothing to +template. **Severity:** low-medium. **Disposition:** fix-now-sized, but assign to the wave that +opens `ingest.cpp` (T4-10) to avoid a standalone churn commit. + +**T4-24 — `clamp01`: dedup with one inline, anti-template.** +Six verified copies (`envelope_overlay`, `master_gain`, `param_slider`, `reasampler_editor`, +`velocity_curve` + `envelope_edit`'s `clamp`). One `constexpr inline double clamp01(double)` in a +shared core header (or just `std::clamp` at call sites). Not a template candidate — `std::clamp` +already is one. **Severity:** trivial. **Disposition:** rider on Q-W1 relocation. + +**T4-25 — JSON `Parser`/`ObjWriter`/`writeEscaped`/`intToStr` ×4 — already owned by Q-W1; +confirmed still accurate** (verified in `bank_model`/`bank_book`/`view_mode_model`/ +`owned_manifest`; `sample_usage` uses its own `rsusage` k/v wire, *not* a fifth JSON parser — no +scope growth). Concrete class, not a template. **Disposition:** no change. + +--- + +## 4. Indirection audit (heuristic c) + +**T4-26 — `ICaptureBackend` is now a dead abstraction: one implementation, zero polymorphic call +sites. The brief's "two implementations — earning its keep" assumption is FALSE at current state.** +Verified: `capture.h` itself documents (lines 139–148, the "SEAM CHOICE" comment) that +`RealtimeRecordBackend` **deliberately does not implement** `ICaptureBackend` — it has a bespoke +async `begin/tick/abort` seam. `OfflineRenderBackend` is the sole deriver, and the only +construction site (`main.cpp:738`) instantiates the concrete type; nobody anywhere holds an +`ICaptureBackend*`/`&`. The interface costs a vtable and models nothing. +**Proposal:** delete `ICaptureBackend`; `OfflineRenderBackend` becomes a plain concrete class. Note +CLAUDE.md/CONTEXT still describe the module as "`ICaptureBackend` interface; two backends" — the doc +should be corrected in the same commit. **Severity:** low runtime, medium hygiene (it misleads — +this audit's own brief was misled). **Disposition:** fix in Q-W3 (the wave that rehomes the capture +orchestration and touches every call site). + +**T4-27 — Warning to the Q-W2v brief (T4-14): do not split `sampler_core.cpp` along class lines.** +`AdsrEnvelope::tick`/`TriggerEnvelope::amplitudeAt`/`PitchEnvelope::tick` are called per-voice +per-sample from `Voice::advanceFrame`, which is called per-sample from `VoiceEngine::render`. +Same-TU definition is what lets the compiler inline this stack today (no LTO configured). A +by-class TU split converts the hottest inner loop into cross-TU calls — the exact dispatch-stack +blowout heuristic (c) forbids. If a split is ever wanted, the envelopes must move as +header-defined (inline) classes, not to a TU. The engine TU staying whole at 968 is the correct +trade. + +**T4-28 — Warning to the Q-W2 brief (reaffirming the plan's own guardrail).** `panel_audition` and +the preview idle path must stay direct call-throughs after the 8-TU split — the plan already says +this; the two *added* TUs (T4-01: `panel_layout`, `panel_drag`) introduce no new risk (layout is +paint-time, drag is input-time), but the split of `onMouseMove` (which calls hover + drag + tooltip) +should keep per-mouse-move work as plain free-function calls, no interface. + +**T4-29 — Warning to the processor split (T4-12).** Keep `process()` and any per-block helpers it +calls in one TU. The state/reload/accessor TUs are UI-thread or setup-time — safe to move freely. +The atomic-pointer-swap pattern (`publishBuiltLocked`) must not gain a virtual seam. + +**T4-30 — No other gratuitous indirection found (verified, not assumed).** The only extension-side +`virtual` is T4-26. VST-side virtuals are all VST3-SDK-mandated overrides (`SingleComponentEffect`, +`CPluginView`, `IReaperUIEmbedInterface`) — not ours to remove. The layered pure→shell pairs +(`card_drag`→`bank_panel`, `realtime_record`→`capture_realtime`, `drag_out`→`drag_out_win`, +`prune_reconcile`→`persist`) are the load-bearing discipline, not forwarding waste — each layer +adds the decision/side-effect split, and all are direct calls. `bank_panel`'s ~20-function free-API +is a module boundary, not a dispatch chain; Q-W2's header segmentation thins it. + +--- + +## Summary table — every oversize file → proposed seams → owning wave + +| File (LOC) | Proposed TUs/headers | Owning wave | +|---|---|---| +| `bank_panel.cpp` (3459) | 8 TUs: render / **layout (new)** / thumbnails / audition / input / **drag (new)** / bank_ops / window | **Q-W2 (reshaped: 6→8 seams)** | +| `vst/reasampler_editor.cpp` (3065) | 8 TUs: session / controls / layout (pure candidate) / paint_sample / paint_browse_zone / input_sample / input_browse_zone / platform | **NEW wave Q-W2v** | +| `main.cpp` (1897) | orchestrator / **batch+recapture (new)** / scope_resolve / realtime_lifecycle / app-entry residue | **Q-W3 (reshaped: 3→4 hoists)** + Q-W6 | +| `vst/reasampler_processor.cpp` (1164) | processor_state / processor_reload / lifecycle+process (whole) | **NEW wave Q-W2v** | +| `bank_book.cpp` (1109) | slot_map / bank_book / JSON→`core/json` | Q-W1 (+slot_map rider) | +| `view_mode_model.cpp` (1049) + `.h` (748) | indexes+model / planners / JSON→`core/json`; header splits likewise | Q-W1 (+planner rider) | +| `actions.cpp` (1016) | design_view_actions / bank_actions / prune_action (unchanged) | Q-W4 (no change) | +| `vst/sample_map.cpp` (970) + `.h` (708) | sample_map (resolution) / component_state_io (codec); header splits likewise | **NEW wave Q-W2v** | +| `vst/sampler_core.cpp` (968) + `.h` (762) | **TU stays whole (hot-path exception, T4-27)**; header → zone_params.h + sampler_core.h | **NEW wave Q-W2v** (header only) | +| `capture_realtime.cpp` (867) | lifecycle / finalize | Q-W3 (rides the Q-9 rename) | +| `persist.cpp` (852) | session / ext_state_io / prune_fs (unchanged) | Q-W5 (no change) | +| `bank_model.cpp` (767) | model / JSON→`core/json` (unchanged) | Q-W1 (no change) | +| `view.cpp` (677) | apply / lanes | defer, or Q-W1 rider | +| `ingest.cpp` (636) | wav helpers→core / import / surfaces | wave owning T4-23 | + +**Wave-plan deltas requested of triage:** (1) Q-W2 grows to 8 seams; (2) Q-W3 grows to 4 hoists + +the `ICaptureBackend` deletion; (3) a **new VST god-module wave (Q-W2v)** owns the editor / +processor / sample_map splits + the sampler_core header split — parallel-safe with Q-W2 (zero file +overlap); (4) Q-W1's relocation scope includes the VST pure libs under the placement shape chosen +at the T4-18/T4-19 fork; (5) the `core/wire/bytes.h` LE-codec template (T4-20) lands with +`component_state_io`. diff --git a/docs/product/code-quality-audit.md b/docs/product/code-quality-audit.md new file mode 100644 index 0000000..196df62 --- /dev/null +++ b/docs/product/code-quality-audit.md @@ -0,0 +1,378 @@ +# ReaSampler code-quality audit — Q-W0 findings report and triage + +Date: 2026-07-28 · Branch: `pq-w0-audit` · Static analysis only; no code changed by the audit. + +This is the committed Q-W0 findings report (Q-10 SETTLED: a committed doc beside the SOLID/naming +audit — `docs/product/code-organization.md` §2c.3; deliverable contract in PLAN.md §Q-W0). It +synthesizes four parallel audit tracks; the full track notes remain in the tree as appendices and +are the evidence base for every claim here — this report cites finding IDs and does not restate +mechanisms in full: + +- **Track 1 — DSP / audio algorithm quality:** [`audit-notes/q-w0-t1-dsp.md`](audit-notes/q-w0-t1-dsp.md) (T1-01…T1-11) +- **Track 2 — architecture smells (functional lens):** [`audit-notes/q-w0-t2-architecture.md`](audit-notes/q-w0-t2-architecture.md) (T2-01…T2-11) +- **Track 3 — env-coupled-constant domain modeling:** [`audit-notes/q-w0-t3-env-constants.md`](audit-notes/q-w0-t3-env-constants.md) (T3-01…T3-07) +- **Track 4 — structural sizing + placement:** [`audit-notes/q-w0-t4-sizing.md`](audit-notes/q-w0-t4-sizing.md) (T4-01…T4-30) + +Dispositions below are **proposals**. Per the Q-W0 sign-off gate, Q-W1 does not begin until Daniel +has signed off on every disposition; the open calls are collected in §4. + +--- + +## 1. Verdicts up front + +**DSP / pitch engine (the Q-11 question).** The correlation-aligned SOLA in `pitch_shift` is +**sound — no technique replacement (phase-vocoder / WSOLA) is warranted on this evidence** (T1 +overall verdict). Track 1 finds the implementation "unusually well-defended" (normalized +correlation, ratio-scaled fades with drain-headroom derivation, prime-with-real-content onset, +frozen-writer tail, filled-span clamping) with RT discipline intact throughout. Every T1 finding +sits on the Q-11 escalation ladder's first rungs — bounded fixes within the existing technique, or +documented operating limits — exactly the settled default. The one High finding (T1-01, stereo +splice decorrelation) is a bounded fix inside the current design (link the per-channel lag +search), not a technique change. Track 1 states plainly that it cannot listen: every artifact is +mechanism + predicted audible consequence, and perceptual materiality is Daniel's call. + +**Architecture (functional smells).** The load-bearing boundaries hold: no pure module includes a +host type, the VST bridge is genuinely read-only, WAV *decoding* has exactly one owner, the prune +and exception boundaries audit clean (T2 clean list). What Track 2 found instead is the classic +cost of duplicated *algorithms*: the length-prefixed wire `Cursor` copy-pasted 3× **with +security-hardening drift** — the oldest copy (`provenance`) missing the overflow guards its +siblings gained (T2-01, High, with a cheap Q-W0 backport); a fifth hand-rolled JSON decoder the +§2 audit did not count (T2-02); `readFileBytes` ×5 (T2-03); the ext-state grow-loop ×3 with its +pure decode half-adopted (T2-04); a 19-struct rect zoo (T2-05); and drifting copy-paste in the +capture-stamp epilogue (T2-09). All are dedup/relocation-shaped and route onto the reorg waves; +none is a live user-facing bug except the T2-01 robustness gap. + +**Env-coupled constants (the prior-incident category).** The persistence surfaces — the +highest-stakes case — are **clean**: every wall-clock quantity written to disk since the S12 +remediation is in seconds or ms, and every persisted frame-domain value is a source-file fact +whose rate travels with it (T3 clean list). Seven findings, only two fix-now: the master-gain +ramp step hard-codes 20 ms × 48 kHz (T3-01) and the Trigger-fade UI ceiling hard-codes +2 s × 44.1 kHz (T3-03) — both live hardcoded-rate residues in `src/`, both trivial, both in files +no then-planned downstream wave opens. The rest are deliberate-and-documented couplings or +recorded legacy residue, plus one systemic usability note (no DPI/content-scale support, T3-05) +that is a future phase of its own. + +**Sizing + placement.** The wave plan's four planned splits are necessary but no longer +sufficient: the census measures **9 files needing TU splits and 2 needing header-only splits — 5 +of them VST-side, which currently have no owning wave** (T4 §1.5). `reasampler_editor.cpp` +(3,065 LOC) is now the largest unowned file in the repo. Track 4's structural answer is a new +VST god-module wave (**Q-W2v**), reshaped seam lists for Q-W2 (6→8) and Q-W3 (3→4 hoists), one +documented exception (`sampler_core.cpp` stays whole — hot path, T4-14/T4-27), and one dead +abstraction to delete (`ICaptureBackend`, T4-26 — the brief's "two implementations" assumption is +false at current state). The `src/vst/` placement question is a genuine fork for Daniel +(T4-18/T4-19, §4a). + +--- + +## 2. Unified findings register + +Every finding from all four tracks, exactly once, with proposed disposition. Severities are the +tracks' own. "Q-W0" as a destination means remediated in this wave before it closes (post +sign-off). Cross-track overlaps are reconciled in §2.5. + +### 2.1 Track 1 — DSP (appendix: `q-w0-t1-dsp.md`) + +| ID | Finding (one line) | Sev | Proposed disposition | Rationale | +|----|--------------------|-----|----------------------|-----------| +| T1-01 | Stereo Preserve: per-channel independent splice alignment decorrelates L/R (image wander + mono-sum combing on stereo captures) | High | **Fix-now in Q-W0** (bounded SOLA fix: linked lag/schedule across channels) — **Daniel's call, §4b** | Hits the flagship path (Preserve default + permanently stereo bus); standard stereo-SOLA practice; no technique change | +| T1-02 | Ratio slew mid-fade can drain the outgoing tap past the writer (pitch-env attack case) | Med | Document-and-defer; bounded re-cap noted for when the file is next opened | Needs pitch-env + Preserve + steep attack to trigger; constant-ratio case already covered | +| T1-03 | Preserve prime ignores Trigger `playEnd_` bound; zero-pads sub-window samples as declared-valid ring content | Med | **Fix-now in Q-W0** (prime to feedBound + immediate `freezeTail()`) **if Daniel agrees short one-shots matter, else defer with note — §4b** | Small, contained in `Voice::start`; re-uses designed GA3 machinery | +| T1-04 | No sustain-loop crossfade — hard loop seam clicks unless loop points amplitude-matched | Med | Document-and-defer (record beside the zone-loop spec) | A crossfade is a feature (parameter + UI), wrong scope for a reorg phase | +| T1-05 | Linear interpolation + no band-limiting on repitch (both engines) | Low | Document-and-defer (recorded trade-off; cubic Hermite noted as drop-in if ever wanted) | Classic sampler behavior, deterministic and consistent across engines | +| T1-06 | Correlation search: coarse step-4 can mis-lock above ~5 kHz; maxLag bounds alignment to ≥ ~80 Hz | Low | Document-and-defer (record as the engine's stated operating range) | Inherent SOLA range/cost trades; widening costs splice-burst CPU linearly | +| T1-07 | `splice()` up-jump clamp comment contradicts the code (code is exactly tight; comment's margin direction is backwards) | Low | **Fix-now in Q-W0** (comment rewrite, zero behavior change) | Misleads the next maintainer of a safety-critical clamp; one line | +| T1-08 | Linear-in-amplitude ADSR decay/release (constant-dB nowhere; abrupt-late releases) | Low | Document-and-defer | Character-vs-correctness product decision; changing it alters every existing instrument's feel | +| T1-09 | `declickR_` is dead state (blend correctly shares one weight; R is seeded/decayed, never read) | Low | Fix-now-trivial **as a rider on any Q-W0 `sampler_core` edit** (T1-01/T1-03); else defer | Hygiene only, no audio effect; not worth a standalone change | +| T1-10 | `planWavTruncate` silently drops RIFF chunks after `data` (metadata loss on trim) | Low | Document-and-defer (note in the header's FORMAT ASSUMPTION block when next touched) | Metadata-only; preserving trailing chunks complicates the single-truncating-write design for no audio benefit | +| T1-11 | `makeUniqueTag` 1 s resolution → same-second batch captures collide (silent overwrite) | Low-Med | **Fix-now, assigned to Q-W3** (per-session monotonic counter, both call sites); Q-W0 fallback if triage prefers | T1 explicitly routes at triage; Q-W3 is the nearest wave opening the extension capture flow | + +### 2.2 Track 2 — architecture (appendix: `q-w0-t2-architecture.md`) + +| ID | Finding (one line) | Sev | Proposed disposition | Rationale | +|----|--------------------|-----|----------------------|-----------| +| T2-01 | Wire `Cursor` ×3 with hardening drift — `provenance` lacks the overflow/length guards its siblings have; unbounded `reserve` reachable from persisted input | High | **Fix-now, split:** (a) backport hardened `field()` + count sanity bound to `provenance.cpp` **in Q-W0** (§4c); (b) structural collapse to one shared wire codec **in Q-W1** | Hazard is cheap to close now with existing `provenance_tests`; the dedup should ride the wave already creating `core/` | +| T2-02 | Fifth hand-rolled JSON decoder in `tail_control` (the §2 "4× Parser" undercount) | Med | **Fix-now, folded into Q-W1** — add `tail_control` to the `core/json` consumer list explicitly | Zero extra cost when `core/json` lands; a stray fifth decoder afterward would be a defect of the wave | +| T2-03 | `readFileBytes` hand-rolled ×5 across both artifacts | Med | **Fix-now, folded into Q-W1** — one pure helper in the `core/` utility home; both targets link it | Five copies of a ten-line function; creating its home is exactly Q-W1's job | +| T2-04 | `GetProjExtState` grow-loop ×3; pure `bridge_marshal` decode only half-adopted (`usage_scan`'s copy is prune-safety-adjacent) | Med | **Fix-now, assigned to Q-W5** (the wave that splits `persist.cpp` — T2's own rule; its "Q-W4" label predates the plan's persist=W5 numbering) | Touching persist's session machinery outside its own wave risks the highest-traffic shell for a dedup with no live bug | +| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family | Med | **Fix-now, folded into Q-W1** — reconciled with T4-21 into one disposition, see §2.5(1) | Same-moment-as-relocation principle; PLAN Q-W1 already owns the `ui::` rect unification | +| T2-06 | Pure-computable layout math stranded in the VST editor shell (~49 inline geometry computations; §2's VST scope gap) | Med | Document-and-defer **with named reshape — satisfied by Q-W2v's `editor_layout` pure-candidate TU** (see §2.5(4), §3) | Behavior-preserving hoist best done under the reorg's test discipline; must be a recorded point or the layer keeps growing | +| T2-07 | Extension links the entire voice engine to serialize one preset blob (codec not separable from engine) | Low | Document-and-defer → **executed by Q-W2v's `component_state_io` split** (same split as T4-13, see §2.5(3)) | Right abstraction, wrong granularity; a module-homing decision the reorg waves exist to make once | +| T2-08 | WAV/RIFF container knowledge in 4 modules / 2 chunk walkers (dedup-by-hash + null-test invariants sit on their agreement) | Low | Document-and-defer → **consolidation moment is a triage question, §4e** (T2 prefers the `core/wav` homing moment; T4-23 prefers the wave that opens `ingest.cpp`) | All four currently correct against each other; pre-reorg consolidation churns the capture hot path for no functional gain | +| T2-09 | Capture backends' Sample-stamping epilogue copy-paste with silent divergences (active-project vs pinned-project time-sig read) | Med | **Fix-now, folded into Q-W3** — extract shared `stampCaptureSample` helper; divergent bits stay in the realtime caller | Q-W3 opens both backend TUs anyway; keeps one review of precision-invariant-adjacent code | +| T2-10 | Thumbnail cache-invalidation drifted across the split (extension: pure generation-baked key; editor: ad-hoc string key + call-site `clear()`s) | Low | Document-and-defer — adopt the pure `ThumbnailKey` on the VST side **as a rider on Q-W2v's editor work** | No live bug; pointless as standalone churn, natural rider on the editor wave | +| T2-11 | ComponentState v1→v11 deserialize chain sound, but v3/v4/v5 legacy branches triplicate the shared read | Low | Document-and-defer, explicitly — record that the next envelope bump (v12) extends the shared path rather than minting another branch | Legacy branches are frozen back-compat contract; rewriting them risks the one thing they must never break | + +### 2.3 Track 3 — env-coupled constants (appendix: `q-w0-t3-env-constants.md`) + +| ID | Finding (one line) | Sev | Proposed disposition | Rationale | +|----|--------------------|-----|----------------------|-----------| +| T3-01 | Master-gain ramp step is a per-sample constant baking in 20 ms × 48 kHz (`kGainRampRate = 1/960`); FB1 no-zipper contract degrades silently at higher rates | Med | **Fix-now in Q-W0** — store `kGainRampSeconds`, derive step from `sampleRate_` (the file's own `kPreserveWindowMs` pattern) — **§4d** | Trivial, isolated, behavior-identical at 48 kHz; a live violation of the no-hardcoded-rate ruling in a file no then-planned wave opens | +| T3-02 | Takeover-declick decay is a per-frame coefficient (~2× faster at 96 kHz) — documented deliberate in-code | Low | Document-and-defer — triage ratifies the in-code note as the record | Already an explicit, written, bounded design decision; converting buys no audible improvement | +| T3-03 | Trigger-fade UI throw ceiling hardcodes 2 s × 44 100 as `88200.0` frames (knob full-scale varies per source rate) | Low | **Fix-now in Q-W0** — `kFadeMaxSeconds = 2.0` resolved against the loaded source's rate; T3's stated fallback (defer, amend comment) if zero UI-feel change is preferred — **§4d** | Small and contained; storage domain unchanged; the editor already threads `frameCount + rate` through pack/unpack | +| T3-04 | Drop-hint banner duration stored in sync-timer ticks (6 × 500 ms) | Low | Document-and-defer; fold in opportunistically if the file is opened | Cosmetic, self-documenting, cadence and decay live three lines apart | +| T3-05 | Systemic: no DPI/content-scale support in either UI surface (all layout constants are physical px at ~96 DPI) | Med | Document-and-defer — record as a named future phase; Q-W1's geometry relocation keeps constants centralized so the eventual scale factor lands in one place | A proper UI-scaling pass is a feature wave of its own, far outside Q-W0's remediation budget | +| T3-06 | Legacy v3 zone-payload lift divides by the *current* project rate (skewed times if the rate changed since write; write-era rate never recorded) | Low | Document-and-defer — this report is the record; the skew is a known, not a future mystery bug | Unrecoverable in principle; the documented residue of the incident that motivated the seconds invariant | +| T3-07 | SOLA correlation-segment cap of 512 frames — frame-domain by design (CPU bound); wall-clock span halves at 96 kHz | Low | Document-and-defer — **resolved against Track 1's verdict, see §2.5(2)** | T3 judged the frame domain arguably correct for a compute bound and handed the quality call to T1 | + +### 2.4 Track 4 — sizing + placement (appendix: `q-w0-t4-sizing.md`) + +| ID | Finding (one line) | Sev | Proposed disposition | Rationale | +|----|--------------------|-----|----------------------|-----------| +| T4-01 | `bank_panel.cpp` (3459): the plan's six seams no longer land sub-600 — `panel_render` ~700, `panel_input` ~800 | High | **Fix-now → reshapes Q-W2:** eight TUs, adding `panel_layout` and `panel_drag` | Without the two new seams, two of six TUs ship >600 on day one | +| T4-02 | `main.cpp` (1897): `capture_orchestrator` as specced lands ~885 | High | **Fix-now → reshapes Q-W3:** fourth hoist `capture_batch` (batch family + recapture + selection guards) | Recapture is planner-driven like batch and shares the guard machinery — it belongs with batch | +| T4-03 | `actions.cpp` (1016): plan's seams still land sub-600 | — | No change to Q-W4 (confirmation) | Measured against the current tree | +| T4-04 | `persist.cpp` (852): plan's seams still land sub-600; pS-usage growth landed exactly where the plan isolates it | — | No change to Q-W5 (confirmation) | Measured against the current tree | +| T4-05 | `bank_book.cpp` (1109): `SlotMap` is a self-contained type; post-JSON-extraction remainder splits cleanly | Med | Fix-now, fold into Q-W1 (`slot_map` extraction rides the JSON rewire already opening this file) | One `git mv`-shaped extraction on top of owned work | +| T4-06 | `view_mode_model.cpp` (1049) + `.h` (748): planners separable from model+indexes after JSON extraction | Med | Fix-now, fold into Q-W1 (planner split rides the JSON rewire); T4 allows deferring the planner split if the wave wants to stay minimal (~660 post-extraction is marginal) | JSON rewire opens the file; header splits the same way | +| T4-07 | `bank_model.cpp` (767): model vs JSON — the Q-W1 poster child, ~250 after extraction | — | Already owned by Q-W1; no new seam (confirmation) | — | +| T4-08 | `capture_realtime.cpp` (867): async lifecycle vs file-side finalize are distinct concerns | Low-Med | Fix-now, ride Q-W3 (`capture_realtime_finalize.cpp` split rides the Q-9 naming rider) | Same-wave file surgery is free | +| T4-09 | `view.cpp` (677): park/restore vs D2 lane machinery are separable halves | Low | Document-and-defer unless Q-W1's relocation touches it — then take the free `view_lanes` split | 677 is barely over the bar | +| T4-10 | `ingest.cpp` (636): pure WAV/PCM build helpers trapped in a shell TU | Low-Med | Fix in whichever wave lands the WAV consolidation — **moment is the §4e triage question** (circular with T4-23; no current wave opens `ingest.cpp`) | Extracting the pure WAV build gains a test target and drops the shell to ~500 | +| T4-11 | `vst/reasampler_editor.cpp` (3065): the largest unowned file — the `bank_panel` of the VST artifact | Highest | **Fix-now → new wave Q-W2v:** eight TUs (session / controls / layout / paint ×2 / input ×2 / platform), split axis = the face structure | Grew past `main.cpp` after the plan was written; same god-module profile | +| T4-12 | `vst/reasampler_processor.cpp` (1164): four seams | Med-High | **Fix-now → Q-W2v:** three TUs (`processor_state` / `processor_reload` / lifecycle+`process()` whole) | Partial-class-across-TUs is the same pattern as the Q-W2 panel split | +| T4-13 | `vst/sample_map.cpp` (970) + `.h` (708): resolution core vs binary ComponentState codec | Med-High | **Fix-now → Q-W2v:** split `component_state_io.cpp` + matching header split — one disposition with T2-07, see §2.5(3) | The codec grows every envelope bump (v6→v11 in one quarter); reduces editor/processor rebuild fan-out | +| T4-14 | `vst/sampler_core.cpp` (968) + `.h` (762): TU is a genuine single responsibility on the hottest path | Med | **Fix-now → Q-W2v, header only:** split `zone_params.h` from `sampler_core.h`; **TU stays whole — documented exception to the 600 bar** (record in the wave brief so nobody "fixes" it later) | Same-TU inlining on the per-sample path; no LTO in the build (see T4-27) | +| T4-15 | `view_mode_model.h` (748) | — | Covered under T4-06 (splits with its TU) | — | +| T4-16 | `vst/sample_map.h` (708) | — | Covered under T4-13 | — | +| T4-17 | `vst/sampler_core.h` (762) | — | Covered under T4-14 | — | +| T4-18 | VST placement, leading recommendation: integrate into the one `core/`/`shell/` split with `instrument/` subsystem dirs | — | **Daniel's fork — §4a** (T4 recommends T4-18) | One rule, no special case; the artifact boundary is a link-graph fact the sources already straddle | +| T4-19 | VST placement, alternative: parallel artifact-first subtree (`src/vst/core|shell` behind one SDK-gated `add_subdirectory`) | — | **Daniel's fork — §4a** | Defensible if artifact legibility outweighs discipline uniformity; T4 notes its self-containment is cosmetic | +| T4-20 | Little-endian byte codec hand-rolled ×5 — real template win (`putLE`/`readLE`) | Med | Fix-now, lands with `component_state_io` in Q-W2v (its biggest consumer); other consumers rewire opportunistically — relationship to T2-03/T2-04 noted in §2.5(5) | Compile-time dispatch, zero runtime cost, entirely off hot paths; gives the codec a tested primitive | +| T4-21 | Rect family: unify with one **concrete** `ui::Rect` + `contains()` + per-role aliases — NOT a template; retire the XYWH-vs-LTRB fork | Med | **Fix-now, ride Q-W1** — reconciled with T2-05 into one disposition, see §2.5(1) | The types differ in name only; a template would model nothing; cross-lib `Rect` collision is fixed by the Q-4 sub-namespaces | +| T4-22 | Linear rect-scan hit-tests: one small `hitIndex` template collapses them — only after T4-21 | Low | Document-and-defer; opportunistic rider on Q-W1 once the rect unification lands | Alone it would be a forced template | +| T4-23 | WAV build/parse consolidation into one pure `wav_codec` owner (dedup, not template) | Low-Med | Fix-now-sized, but the owning moment is the **§4e triage question** (T4 assigns it to the wave opening `ingest.cpp`; T2-08 prefers the `core/wav` homing moment; no current wave opens ingest) | Avoid a standalone churn commit; one tested owner of the RIFF layout | +| T4-24 | `clamp01` ×6 — one `constexpr inline` (or `std::clamp` at sites); anti-template | Trivial | Fix-now, rider on Q-W1 relocation | — | +| T4-25 | JSON `Parser` ×4 confirmed still accurate; `sample_usage` is **not** a fifth JSON parser (no scope growth from this track) | — | No change to Q-W1 (confirmation; T2-02's fifth decoder is a different file and does grow the consumer list) | — | +| T4-26 | `ICaptureBackend` is a dead abstraction: one deriver, zero polymorphic call sites; the brief's "two implementations" assumption is FALSE (realtime deliberately has a bespoke seam) | Low (runtime) / Med (hygiene) | **Fix-now, in Q-W3:** delete the interface, `OfflineRenderBackend` becomes concrete; **correct CLAUDE.md/CONTEXT ("ICaptureBackend interface; two backends") in the same commit** | It misleads — this audit's own brief was misled; Q-W3 touches every call site | +| T4-27 | Warning: do **not** split `sampler_core.cpp` along class lines — envelope `tick()`s are per-voice-per-sample; a by-class split is the exact heuristic-(c) dispatch blowout | — | Record as a guardrail in the Q-W2v brief (pairs with T4-14's whole-TU exception) | Same-TU definition is what lets the compiler inline the stack today | +| T4-28 | Warning to Q-W2: audition/preview stays a direct call-through across the 8-TU split; per-mouse-move work stays plain free-function calls | — | Record as a guardrail in the (reshaped) Q-W2 brief — reaffirms the plan's own guardrail; the two added TUs introduce no new risk | — | +| T4-29 | Warning to the processor split: `process()` + per-block helpers stay one TU; the atomic-pointer-swap pattern must not gain a virtual seam | — | Record as a guardrail in the Q-W2v brief | — | +| T4-30 | No other gratuitous indirection found (verified: only extension-side `virtual` is T4-26; VST virtuals are SDK-mandated; layered pure→shell pairs are the discipline, all direct calls) | — | Clean verification — see §5 | — | + +### 2.5 Cross-track dedupes and reconciliations + +1. **T2-05 ≡ T4-21 (+ T4-22 rider) — the rect zoo. Reconciled into ONE disposition: fix-now, + ride Q-W1.** Both tracks found the same duplication (19 XYWH structs + inline predicates; + T4-21 adds the VST side's second LTRB grammar) and both prescribe the same mechanism — one + concrete `ui::Rect` + `contains()` with per-role aliases, explicitly **not** a template + (T4-21's ruling). The only divergence was the wave label: T2-05 said "Q-W2 (the ui/ + relocation wave)", but in the plan the relocation wave — and the settled `ui::` rect + unification (PLAN §Q-W1, "shared pure-UI rect types … get one `ui::` owner") — is **Q-W1**. + T2-05's own rationale ("same-moment-as-relocation") therefore points at Q-W1; reconciled + there. T4-22's hit-test template stays a deferred opportunistic rider behind it. +2. **T3-07 → T1 — the 512-frame correlation cap. Resolved: document-and-defer.** T3 handed the + "is 512 the right number" quality call to Track 1. Track 1's verdict on the correlation + search's bounds (T1-06) is document-and-defer — record the alignment limits as the engine's + stated operating range; "widening either costs splice-burst CPU linearly." Note for accuracy: + T1-06 adjudicates the coarse-step and `maxLag` bounds specifically and does not name the + 512-frame `corrFrames_` cap; the resolution here rests on T1's general verdict (bounded + limits recorded, no change recommended) applied to the same search-cost family. If triage + wants the 512 value separately adjudicated, that is a residual question — flagged rather than + silently absorbed. +3. **T2-07 ≡ T4-13 — the ComponentState codec split. One disposition: Q-W2v's + `component_state_io`.** T2-07's complaint (extension links the whole voice engine to share + the preset serializer) is *solved by* T4-13's proposed split; T2-07's link-weight rationale + rides the T4-13 seam. T4-14's `zone_params.h` header split completes the extension-side + decoupling. T2 had provisionally pointed at "Q-W1/Q-W2 module-homing"; with Q-W2v now + proposed as the wave that opens `sample_map`, that is the owning wave. +4. **T2-06 ≡ T4-11's `editor_layout` — the stranded editor layout math.** T2-06's + document-and-defer explicitly demanded a *named* downstream reshape; Q-W2v's `editor_layout` + TU (T4-11, marked "pure candidate") is that reshape. One disposition: assigned to Q-W2v, with + the hoist targeting the existing pure homes (`editor_geometry` is T2's named natural owner). + T2-06's scope note (the §2 god-module catalogue missed the VST side) is also the evidence + base for Q-W2v existing at all. +5. **T4-20 / T2-03 / T2-04 — related but distinct dedup family, three separate dispositions.** + All three converge on shared `core/` utility homes but touch disjoint code: T4-20 (LE byte + codec ×5) lands with `component_state_io` in Q-W2v; T2-03 (`readFileBytes` ×5) lands in + Q-W1's utility home; T2-04 (ext-state grow-loop ×3, generalizing `bridge_marshal`) lands in + Q-W5 with the persist split. Marked here so triage sees them as one family and no wave + assumes another already covered its slice. +6. **T2-08 / T4-23 / T4-10 — WAV/RIFF consolidation. Genuine track disagreement on the moment; + surfaced as §4e** rather than silently picked. T2-08 defers to "the reorg wave that + relocates `wav_trim`" (Q-W1's relocation); T4-23 assigns to "the wave that opens + `ingest.cpp`" — and T4-10 assigns the ingest split to "whichever wave lands the WAV + consolidation," which is circular: **no current wave opens `ingest.cpp`.** +7. **T1-11 — capture-tag collision.** T1 offered it to Track 2 ("Track 2 may claim it"); + Track 2 did not. It remains a single T1 finding, routed per T1's own suggestion to Q-W3. +8. **Cross-track interaction on the DSP/VST fix-nows.** T1 and T3 both justified + fix-now-in-Q-W0 partly by "no downstream wave opens these files" — written before T4 + proposed Q-W2v, which *does* open `reasampler_processor.cpp` / `reasampler_editor.cpp` / + `sampler_core.h`. The recommendation stands unchanged: Q-W2v is a behavior-preserving + mechanical-split wave, and folding behavior-changing DSP/domain fixes into it would break the + wave discipline (CTest-green mechanical moves, no logic change). Fix-now items stay in Q-W0; + noted so the rationale reads correctly against the reshaped plan. + +--- + +## 3. Plan reshape — what Q-W0 proposes for Q-W1..Q-W6 + +The concrete deltas to the PLAN §Phase Q wave graph. Every wave is named; "no change" +confirmations included deliberately. + +**Q-W0 (this wave, post sign-off) — remediations before close:** T2-01(a) provenance cursor +hardening backport; T3-01 gain-ramp seconds; T3-03 fade-ceiling seconds (or its stated fallback); +T1-07 comment fix; pending §4b — T1-01 linked-lag stereo fix and T1-03 prime bound, with T1-09 +riding any `sampler_core` edit. Each behavior-touching remediation lands with its module's CTest +target green per the Q-W0 verify contract. + +**Q-W1 (safe opener — scope grows):** +- Add `tail_control` to the `core/json` consumer list (T2-02) — the wave's "one JSON path" goal + is not met without it. (T4-25 confirms the original 4× scope is otherwise accurate.) +- Add the shared `readFileBytes` pure helper to the `core/` utility home (T2-03). +- Rect unification: one concrete `ui::Rect` + `contains()` + per-role aliases, including + retiring the XYWH-vs-LTRB fork and folding in `editor_geometry`'s `Rect` (T2-05 ≡ T4-21); + `clamp01` dedup rider (T4-24); `hitIndex` template only as an opportunistic follow-on (T4-22). +- Structural collapse of the wire `Cursor` family into one shared `wire` codec module beside + `core/json`, consumed by `provenance` / `assignment_request` / `sample_usage` / + `parseBankGeneration` (T2-01(b)). +- `slot_map` extraction rider on the `bank_book` JSON rewire (T4-05); `view_mode_model` planner + split rider (T4-06, optional per T4); `bank_model` unchanged-as-planned (T4-07); `view_lanes` + split only if relocation touches `view.cpp` anyway (T4-09). +- Relocation scope grows to include the ~20 clean VST pure libs, under whichever placement shape + §4a settles (T4 §1.5, T4-18/T4-19). + +**Q-W2 (bank_panel split — 6→8 seams):** the wave brief must name **eight** TUs — the planned +six plus `panel_layout` and `panel_drag` (T4-01) — or two of its TUs ship >600 on day one. +Guardrails reaffirmed: audition direct call-through; per-mouse-move work stays plain free-function +calls (T4-28). + +**Q-W2v (NEW — VST god-module wave; the T2-06/T4 §1.5 scope gap made structural):** +- `reasampler_editor.cpp` → eight TUs: session / controls / **layout (pure-candidate hoist into + the existing pure homes — this discharges T2-06)** / paint_sample / paint_browse_zone / + input_sample / input_browse_zone / platform (T4-11). +- `reasampler_processor.cpp` → three TUs: `processor_state` / `processor_reload` / + lifecycle+`process()` kept whole (T4-12), with the T4-29 guardrail (no virtual seam on the + atomic-swap pattern). +- `sample_map` → `component_state_io` codec split + matching header split (T4-13 ≡ T2-07). +- `sampler_core`: **TU stays whole at 968 — documented hot-path exception** recorded in the wave + brief (T4-14, T4-27); header splits into `zone_params.h` + `sampler_core.h`. +- The `core/wire/bytes.h` LE-codec template lands here with its biggest consumer (T4-20). +- Rider: adopt the pure `ThumbnailKey` on the VST side while the editor is open (T2-10). +- **Scheduling:** parallel-safe with Q-W2 (different artifact, zero file overlap) — T4 also + offers serial-after-W5 as "Q-W7" if Daniel prefers serial waves (§4f). + +**Q-W3 (main.cpp split — 3→4 hoists):** add **`capture_batch`** (batch family + +`RunRecaptureFromSource` + the two selection guards) as a fourth TU so `capture_orchestrator` +lands ~450 (T4-02). Also owned here: **delete `ICaptureBackend`** and correct the +CLAUDE.md/CONTEXT description in the same commit (T4-26); the shared `stampCaptureSample` +epilogue dedup (T2-09); the `capture_realtime_finalize` split riding the Q-9 naming rider +(T4-08); the `makeUniqueTag` monotonic-counter fix (T1-11). + +**Q-W4 (actions split): no change** — the planned seams still land sub-600 (T4-03). + +**Q-W5 (persist split): seams unchanged** (T4-04); **add** the ext-state grow-loop dedup — +generalize the retry policy into `bridge_marshal` (or its `core/` successor) and rewire all three +loops, `usage_scan`'s prune-safety-adjacent copy included (T2-04). + +**Q-W6 (registration table): no change**; T4-02 notes the `main.cpp` registration residue (~385) +shrinks further under its table. + +**Unassigned pending §4e:** the WAV/RIFF consolidation family (T2-08 / T4-23 / T4-10) — no +current wave opens `ingest.cpp`; the owning moment is Daniel's call. + +Updated dependency sketch: + +``` +Q-W0 (this report + remediations) ── SUB-GATE: Daniel signs off every disposition (§4) ── + ▼ +Q-W1 (core/json + wire codec + relocation incl. VST pure libs + rect unification + riders) + ├─► Q-W2 (bank_panel split, 8 seams) ──► Q-W4 (actions; unchanged) + ├─► Q-W2v (NEW: VST god-modules — editor/processor/sample_map splits, sampler_core header, + │ LE codec) [parallel-safe with Q-W2; serial "Q-W7" alternative — §4f] + ├─► Q-W3 (main split, 4 hoists; ICaptureBackend deletion + doc fix; stamp dedup; T1-11) + │ └──► Q-W6 (registration table; unchanged) + └─► Q-W5 (persist; seams unchanged; + ext-state-loop dedup) [best after Q-W4] +``` + +--- + +## 4. Daniel's decision list (sign-off gate) + +Each item: leading recommendation first, alternative second. Settled matters (Q-10, Q-11 framing, +the clean bills) are deliberately absent. + +- **(a) VST placement fork — T4-18 vs T4-19.** *Recommend T4-18:* integrate `src/vst/` into the + one `core/`/`shell/` top split with `instrument/{engine,map,ui}` subsystem dirs — one rule + ("directory = may it touch a host type"), the artifact boundary is a link-graph fact the + sources already straddle, CMake impact is path-edits only. *Alternative T4-19:* artifact-first + subtree (`src/vst/core|shell` behind one SDK-gated `add_subdirectory`) — cleanest expression + of the Windows-only gate, at the cost of two parallel `core/` trees and a subtree whose + self-containment is cosmetic. Q-W1 executes whichever shape is chosen. +- **(b) DSP bounded fixes in Q-W0 — T1-01 and T1-03.** *Recommend fix-now for T1-01* (linked + L/R lag + splice schedule): High severity on the flagship stereo-Preserve path, standard + stereo-SOLA practice, no technique change. *Alternative:* defer and record as the known + stereo-Preserve limitation. *For T1-03* (prime bound + immediate `freezeTail()` on + sub-window spans): T1's own framing — fix-now **if the short-one-shot case matters** to you + (short drum one-shots are realistic content); else document-and-defer with the T1 note as the + record. T1-09 (`declickR_` dead state) rides whichever `sampler_core` edit happens. +- **(c) T2-01(a) provenance wire-cursor hardening backport in Q-W0.** *Recommend yes:* small, + pure, closes a concrete robustness gap on persisted user-editable input, covered by existing + `provenance_tests`. *Alternative:* wait for the Q-W1 wire-codec collapse to fix it + structurally — leaves the gap open through the gate for no saving. +- **(d) Env-constant fix-nows in Q-W0 — T3-01 and T3-03.** *Recommend fix-now for both:* each + is trivial, isolated, and a live hardcoded-rate residue Daniel's standing ruling forbids; + behavior-identical at the baked-in rates. *Alternative for T3-03 only* (T3's stated + fallback): document-and-defer with the comment amended to name the 44.1 k assumption, if zero + UI-feel change is preferred. (Folding either into Q-W2v instead is *not* recommended — it + would put behavior changes inside a mechanical-split wave; §2.5(8).) + + **Recorded deviation (Q-W0 remediation, code review):** T3-03 as implemented resolves + `fadeMaxFrames()` against `liveSampleRate()` (the host/project rate), not the per-file rate + this section's text literally suggests ("the loaded source's rate"). Reviewer verified this + is the more correct choice: no resample path exists anywhere in `src/`, the engine advances + one source frame per host frame, and this matches the time base `paintEnvelopeOverlay` + already uses for the same fades (`totalSeconds = frames / liveSampleRate()`). No further + action — recorded here so the audit text and the shipped behavior don't read as diverged. +- **(e) WAV/RIFF consolidation moment — the one true track disagreement (T2-08 vs T4-23/T4-10).** + T2 prefers the `core/wav` homing moment (the relocation wave); T4 prefers "the wave that opens + `ingest.cpp`" — which does not exist, and T4-10 points back circularly. *Recommend:* record + the consolidation (one pure `wav_codec` owner: walker + layout + build + patch, absorbing + T4-10's ingest extraction) as a named rider on **Q-W3** — the wave already opening the capture + family (`capture_realtime` finalize, T4-08) — with Q-W1-relocation as the alternative moment + if Daniel prefers T2's framing. Either way it must land somewhere named, or the + dedup-by-hash/null-test maintenance surface stays quadruplicated. +- **(f) Q-W2v scheduling.** *Recommend parallel with Q-W2* (different artifact, zero file + overlap — T4 §1.5). *Alternative:* sequence it serially after W5 as "Q-W7" if you want serial + waves throughout. + +--- + +## 5. Clean bills — surfaces audited and found clean + +Consolidated coverage evidence; details in the appendices' clean-surface sections. + +**DSP (T1):** `peaks` (bin partition exact, overflow-guarded, per-channel no-fold), `master_gain` +(taper math correct end to end), `velocity_curve` (Fritsch–Carlson monotonicity/no-overshoot +claims hold) — fully clean. Clean with only the noted findings: `wav_trim` (T1-10 metadata note), +offline capture path (T1-11; precision-invariant plumbing "disciplined"), realtime capture path +(T1-11 + already-in-code DAW-verify flags), `sampler_core`'s voice/steal/mono/panic machinery +(rate-free-seconds invariant honored; takeover blend mathematically sound), `pitch_shift`'s core +machinery (safe-band geometry, clamps, correlation, fades, `freezeTail` continuity all audit +sound; down-shift writer-lap unreachable above ≈ −109 st). + +**Architecture (T2):** pure-module include hygiene across both trees (zero host-type includes in +any claimed-pure module); `bank_sync`; `bridge_marshal`; the realtime record lifecycle (explicit +enum state machine, not implicit); project-identity transitions; `usage_scan` (decisions +delegated pure, depth-bounded recursion, protect-on-truncation); the three `catch (...)` sites +(documented, narrow, non-swallowing); `FxBypassGuard` vs `view.cpp` park/restore (duplication of +shape, not concept — correctly separate); path resolution (`resolveBankFile` is the single +resolver both sides); WAV decode (one decoder); the draw layer (no parallel vocabulary on the VST +side); interface cost (no hot-path virtual/`std::function` chains); boolean parameters (no +smell). Also T2-11's headline: the ComponentState v1→v11 lift chain is **functionally sound** — +the finding is shape, not correctness. + +**Env-coupled constants (T3):** `sample_map` v5+ persistence (the reference implementation); +ComponentState v6–v11 fields; Trigger fade/loop frames as source-file facts with the rate stored +alongside; `trigger_seam`; `kPreserveWindowMs` (the model pattern); `pitch_shift` internal +geometry; the tail system; `wav_trim`; `bank_model` persisted metadata; all ext-state wires; the +envelope schematic's param-domain scaling; every timer surveyed; `peaks` / `waveform_view` / +`master_gain` / `velocity_curve` / `keyboard_strip`. The persistence layer — the category's +highest-stakes surface — is clean end to end. + +**Sizing/indirection (T4):** borderline files needing no action: `capture.cpp` (549), +`reasampler_editor.h`/`reasampler_processor.h` (shrink with their TU splits), `bank_book.h`, +`pitch_shift.cpp` (371 — single responsibility, hot, leave), `persist.h`/`capture.h` (owned by +Q-W6). T4-30: no gratuitous indirection anywhere beyond the dead `ICaptureBackend` — VST-side +virtuals are SDK-mandated, and the layered pure→shell pairs are the load-bearing discipline, all +direct calls. Plan-confirmations: Q-W4 and Q-W5 seams land as planned (T4-03/T4-04); Q-W1's JSON +scope confirmed accurate modulo T2-02 (T4-25). diff --git a/src/actions.cpp b/src/actions.cpp deleted file mode 100644 index 185afea..0000000 --- a/src/actions.cpp +++ /dev/null @@ -1,1016 +0,0 @@ -// actions.cpp — the Design View action family (Phase D4). See actions.h. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers -// (CLAUDE.md §contract). The action ids are minted from FOREVER-STABLE strings (the -// same CEREBELLUM_REASAMPLER_ family prefix main.cpp uses); user keybindings key off -// them, so they must never change after ship. -// -// Each action: -// 1. mutates the session's ViewModeModel (membership tag/untag/show-both, or the -// active mode via toggle/activate) — the pure D1 state, -// 2. reapplies the active mode through the D2 view shell (applyMode) so the change -// takes visible effect immediately (tagging a track into Design while in Arrange -// parks it right away; a mode change re-partitions and re-parks in one step). -// -// Selection-driven mutations iterate the CURRENT REAPER track selection -// (CountSelectedTracks/GetSelectedTrack — both ignore the master, which is correct: -// the master is never tagged) and resolve each track to its canonical GUID key via -// the shared guidString helper, so the keys match exactly what the D2 shell / view -// tree key on (the cross-module key contract). - -#include "actions.h" - -#include -#include -#include - -#include "app_version.h" // channelCommandId / channelActionName — one channel-identity point - -#include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1) -#include "bank_panel.h" // selection seam + full-height toggles (B3/B4) -#include "item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) -#include "lane_keys.h" // isOnManualLane — the single managed/manual predicate -#include "persist.h" // ReaSamplerSession (owns book() + view() model) -#include "track_guid.h" // shared MediaTrack* -> canonical GUID key -#include "view.h" // applyMode + mintManagedLanes (D2 shell) -#include "view_mode_model.h" - -#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) - -#define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_CountSelectedTracks -#define REAPERAPI_WANT_GetSelectedTrack -#define REAPERAPI_WANT_CountSelectedMediaItems -#define REAPERAPI_WANT_GetSelectedMediaItem -#define REAPERAPI_WANT_GetMediaItemTrack -#define REAPERAPI_WANT_GetMediaTrackInfo_Value -#define REAPERAPI_WANT_EnumProjects -#define REAPERAPI_WANT_Main_SaveProject -#define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_GetUserInputs -#define REAPERAPI_WANT_ShowMessageBox -#define REAPERAPI_WANT_genGuid -#define REAPERAPI_WANT_guidToString -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 -#include "reaper_plugin_functions.h" - -namespace reasampler { - -namespace { - -// FOREVER-STABLE action-id SUFFIXES (Phase V, V4). The channel family prefix is prepended -// at register time via channelCommandId (app_version), so stable rebuilds the exact shipped -// id ("CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE") and beta yields the isolated forever-family -// id ("CEREBELLUM_REASAMPLER_BETA_VIEW_TOGGLE_MODE"). Each composed id is minted into a -// persistent command id user keybindings key off — NEVER change a shipped suffix after ship. -constexpr const char* kIdToggleMode = "VIEW_TOGGLE_MODE"; -constexpr const char* kIdActivateArrange = "VIEW_ACTIVATE_ARRANGE"; -constexpr const char* kIdActivateDesign = "VIEW_ACTIVATE_DESIGN"; -constexpr const char* kIdTagDesign = "VIEW_TAG_DESIGN"; -constexpr const char* kIdTagArrange = "VIEW_TAG_ARRANGE"; -constexpr const char* kIdUntag = "VIEW_UNTAG"; -constexpr const char* kIdShowBoth = "VIEW_SHOW_BOTH"; -// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same -// FOREVER-STABLE contract (suffix composed with the channel prefix) — NEVER change these. -constexpr const char* kIdMoveItemsDesign = "VIEW_MOVE_ITEMS_DESIGN"; -constexpr const char* kIdMoveItemsArrange = "VIEW_MOVE_ITEMS_ARRANGE"; -constexpr const char* kIdUntagItems = "VIEW_UNTAG_ITEMS"; - -// The live session the actions mutate. Set once by designViewRegisterActions and -// read by the hookcommand handler. Not owned here (main.cpp owns g_session). -ReaSamplerSession* g_session = nullptr; - -// Minted command ids (0 until registration succeeds). Compared in the handler. -int g_cmdToggleMode = 0; -int g_cmdActivateArrange = 0; -int g_cmdActivateDesign = 0; -int g_cmdTagDesign = 0; -int g_cmdTagArrange = 0; -int g_cmdUntag = 0; -int g_cmdShowBoth = 0; -int g_cmdMoveItemsDesign = 0; -int g_cmdMoveItemsArrange = 0; -int g_cmdUntagItems = 0; - -// gaccel storage must outlive registration — REAPER holds each pointer until we -// mirror-unregister it. One per action. -gaccel_register_t g_accelToggleMode{}; -gaccel_register_t g_accelActivateArrange{}; -gaccel_register_t g_accelActivateDesign{}; -gaccel_register_t g_accelTagDesign{}; -gaccel_register_t g_accelTagArrange{}; -gaccel_register_t g_accelUntag{}; -gaccel_register_t g_accelShowBoth{}; -gaccel_register_t g_accelMoveItemsDesign{}; -gaccel_register_t g_accelMoveItemsArrange{}; -gaccel_register_t g_accelUntagItems{}; - -// Durable store of composed, channel-qualified strings (ids + labels). A std::deque never -// invalidates references on push_back, so a c_str() handed to REAPER (a command_id at -// register, a gaccel desc for its lifetime) stays valid until process exit. Memoized by -// suffix so register and the mirror-unregister get the SAME id pointer for a given action. -std::deque g_strStore; - -// Returns the channel-qualified command id for `suffix`, interning it once. Called by BOTH -// registerAction and the unregister path, so a '-command_id' presents the identical string. -const char* channelIdFor(const char* suffix) { - const std::string composed = channelCommandId(suffix); - for (const std::string& s : g_strStore) - if (s == composed) return s.c_str(); - g_strStore.push_back(composed); - return g_strStore.back().c_str(); -} - -// Mints a command id from a channel-qualified SUFFIX and registers its gaccel (Actions-list -// entry with a channel-qualified label PHRASE). Returns the command id (0 on failure). Both -// the composed id and label are interned durably (g_strStore) — REAPER holds the desc -// pointer, and the id must survive to the mirror-unregister. The gaccel storage itself is -// caller-owned (the file-scope g_accel* above). -int registerAction(reaper_plugin_info_t* rec, const char* suffix, - gaccel_register_t& accel, const char* phrase) { - const char* id = channelIdFor(suffix); - const int cmd = rec->Register("command_id", (void*)id); - if (cmd) { - g_strStore.push_back(channelActionName(phrase)); - accel.accel.cmd = cmd; - accel.desc = g_strStore.back().c_str(); - rec->Register("gaccel", (void*)&accel); - } - return cmd; -} - -// Collects the canonical GUID keys of the current track selection. Empty if nothing -// is selected. CountSelectedTracks/GetSelectedTrack ignore the master (SDK), which is -// exactly right — the master is never a tagged leaf. -std::vector selectedTrackGuids() { - std::vector guids; - const int n = CountSelectedTracks(nullptr); // nullptr = active project - guids.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaTrack* tr = GetSelectedTrack(nullptr, i); - if (!tr) continue; - std::string g = guidString(tr); - if (!g.empty()) guids.push_back(std::move(g)); - } - return guids; -} - -// Reapplies the model's CURRENT active mode to the active project so a membership -// mutation takes visible effect immediately (park/unpark/re-derive parents). Called -// after every tag/untag/show-both. `proj = nullptr` -> REAPER's active project. -void reapplyActiveMode() { - applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); -} - -// Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to -// decide whether an item's lane name is meaningful for the manual-lane read. -constexpr int kFreeModeFixedLanes = 2; - -// Collects the current media-item selection as the pure decision's input: each selected -// item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned). -// The manual-lane read follows the shared pure predicate exactly as the shell's readers -// do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal -// track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped. -// Items whose GUID cannot be read are dropped (an empty GUID must never be retagged). -std::vector selectedRetagItems() { - std::vector items; - const int n = CountSelectedMediaItems(nullptr); // nullptr = active project - items.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (!it) continue; - std::string g = itemGuid(it); - if (g.empty()) continue; - - MediaTrack* tr = GetMediaItemTrack(it); - const bool fixedLane = - tr && static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; - // Only read the lane name on a fixed-lane track; the pure predicate handles the - // normal-track case (returns false) so we pass an empty name and skip the read. - const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{}; - items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)}); - } - return items; -} - -// Persists both the bank and the Design-View model to the active project's ext -// state. Called after every state-changing Design View action so the view model -// is not lost across save/close/reopen. Marking the project dirty is correct — -// a Design View mutation is a project-level change the user should be prompted -// to save. -// -// When the membership index is non-empty AND the project is unsaved, we prompt -// the user to Save-As before persisting — mirroring the flow capture uses. -// Gate: if membership is empty (no tracks tagged), skip the prompt entirely; -// saveToActiveProject will no-op for an unsaved project, which is correct. -// -// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save-As dialog and -// blocks until the user dismisses it. The blocking behaviour and dialog -// appearance can only be confirmed in a running REAPER (same caveat as capture). -void persistViewState() { - if (!g_session->view().membership().empty()) { - // At least one track is tagged — worth persisting. Check whether the - // project is saved and, if not, prompt Save-As so saveToActiveProject - // can write ext state. Mirrors capture's readRppPath idiom exactly. - ReaProject* proj = EnumProjects(-1, nullptr, 0); - if (proj) { - auto readRppPath = [&]() -> std::string { - std::vector buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(buf.size())); - return std::string(buf.data()); - }; - - if (readRppPath().empty()) { - // Project is unsaved — prompt Save-As. - Main_SaveProject(proj, true); - // Re-read: still empty means the user cancelled. - if (readRppPath().empty()) { - ShowConsoleMsg( - "ReaSampler: Design View state will not persist until " - "the project is saved.\n"); - // The in-session tag state is left as-is — the mode change - // already applied and remains valid for this session. - return; - } - } - } - } - g_session->saveToActiveProject(); -} - -// -- Action bodies --------------------------------------------------------- - -// Toggle: cycle to the next mode in ordinal order (Arrange <-> Design with two -// seeds; scales to cycle-through-all for >2 modes with no change here). applyMode -// itself sets the model's active mode, so we only compute the target and apply. -void doToggleMode() { - const std::string target = - nextModeId(g_session->view().modes(), g_session->view().activeModeId()); - if (target.empty()) return; // no modes to cycle to (degenerate) - applyMode(g_session->view(), target, nullptr); - persistViewState(); - bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately -} - -// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if -// the id is unregistered, so an absent mode fails safe. -void doActivateMode(const std::string& modeId) { - applyMode(g_session->view(), modeId, nullptr); - persistViewState(); - bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately -} - -// Tag the selection's leaves into `modeId`, then reapply so the change is immediate. -// tag() replaces any prior single-mode membership (a leaf lives in one mode; the -// cross-mode case is show-both), matching the D1 contract. -void doTag(const std::string& modeId) { - for (const std::string& g : selectedTrackGuids()) - g_session->view().membership().tag(g, modeId); - reapplyActiveMode(); - persistViewState(); -} - -// Untag the selection entirely (return each to the Arrange default). This is the -// shared body behind both "Untag selected" and "Tag -> Arrange" (Arrange = the -// absence of a tag), so the two actions are the same act by definition. -void doUntag() { - for (const std::string& g : selectedTrackGuids()) - g_session->view().membership().untag(g); - reapplyActiveMode(); - persistViewState(); -} - -// Toggle the per-track show-both pin for the selection. Read the CURRENT pin of each -// track and flip it independently (a mixed selection converges toward "all on" then -// "all off" only if uniform; per-track flip is the honest semantics of a toggle on a -// multi-selection). show-both leaves are never parked (D1), so reapply reflects the -// change immediately. -void doShowBoth() { - MembershipIndex& m = g_session->view().membership(); - for (const std::string& g : selectedTrackGuids()) - m.setShowBoth(g, !m.isShowBoth(g)); - reapplyActiveMode(); - persistViewState(); -} - -// -- Item-level mode moves (D2 Wave 3-B) ----------------------------------- -// -// Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default), -// then re-drive the minting + apply path so each moved item lands on its target mode's -// managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag -// decides which selected items to retag (manual-lane items are EXEMPT — never retagged, -// never re-laned), upholding the managed-lanes-only invariant even under this explicit -// user action. The whole structural act is wrapped in ONE Undo block with a descriptive -// label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it). -// -// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which -// must NOT sit inside the Undo block, so we close the block first, then persist — the same -// separation the track actions rely on (they persist outside applyMode's own block). -void doMoveItems(const std::string& targetMode) { - const std::vector selected = selectedRetagItems(); - const std::vector ops = planItemRetag(selected, targetMode); - if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty - - MembershipIndex& membership = g_session->view().membership(); - - Undo_BeginBlock2(nullptr); - // Apply the pure decision's membership writes: tag into targetMode, or untag. - for (const ItemRetagOp& op : ops) { - if (op.untag) membership.untag(op.guid); - else membership.tag(op.guid, op.modeId); - } - // Re-drive the SAME minting/apply path auto-tag uses: mint/split lanes for any track - // whose items now span modes and assign each moved item to its mode's managed lane, - // then reassert the active mode's lane visibility. Manual lanes stay untouched - // (mintManagedLanes reports their items exempt and never mints over them). - mintManagedLanes(g_session->view(), nullptr); - reapplyActiveMode(); - - const std::string label = - targetMode.empty() - ? std::string("ReaSampler: untag selected items") - : std::string("ReaSampler: move selected items -> ") + targetMode; - Undo_EndBlock2(nullptr, label.c_str(), -1); - - persistViewState(); -} - -} // namespace - -void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { - g_session = session; - - // command_id -> gaccel for each. The single hookcommand that routes these lives - // in main.cpp (one hook per extension); designViewHandleCommand services them. - g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode, - "toggle Design View mode"); - g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange, - "activate mode Arrange"); - g_cmdActivateDesign = registerAction(rec, kIdActivateDesign, g_accelActivateDesign, - "activate mode Design"); - g_cmdTagDesign = registerAction(rec, kIdTagDesign, g_accelTagDesign, - "tag selected tracks -> Design"); - g_cmdTagArrange = registerAction(rec, kIdTagArrange, g_accelTagArrange, - "tag selected tracks -> Arrange"); - g_cmdUntag = registerAction(rec, kIdUntag, g_accelUntag, - "untag selected tracks"); - g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth, - "show both for selected tracks"); - - // Item-level mode moves (D2 W3-B): the item analog of the track tag family. - g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign, - "move selected items -> Design"); - g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange, - "move selected items -> Arrange"); - g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems, - "untag selected items"); -} - -bool designViewHandleCommand(int command) { - if (command == 0 || !g_session) return false; - - if (command == g_cmdToggleMode) { doToggleMode(); return true; } - if (command == g_cmdActivateArrange) { doActivateMode(kArrangeModeId); return true; } - if (command == g_cmdActivateDesign) { doActivateMode(kDesignModeId); return true; } - if (command == g_cmdTagDesign) { doTag(kDesignModeId); return true; } - // Tag -> Arrange and Untag are the same act (Arrange = the absence of a tag). - if (command == g_cmdTagArrange) { doUntag(); return true; } - if (command == g_cmdUntag) { doUntag(); return true; } - if (command == g_cmdShowBoth) { doShowBoth(); return true; } - - // Item-level moves. Move -> Arrange and Untag items collapse to the same act (an - // empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above. - if (command == g_cmdMoveItemsDesign) { doMoveItems(kDesignModeId); return true; } - if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; } - if (command == g_cmdUntagItems) { doMoveItems(std::string{}); return true; } - - return false; // not ours — caller's hookcommand keeps looking -} - -void designViewUnregisterActions(reaper_plugin_info_t* rec) { - // Mirror-unregister with '-'-prefixed strings, per the contract's unload rule. - // gaccel first, then the command_id string (reverse of registration order — the item - // moves registered last, so they tear down first). - // Each '-command_id' re-presents the SAME interned, channel-qualified id (channelIdFor - // returns the memoized pointer registered above), so the unregister matches exactly. - rec->Register("-gaccel", (void*)&g_accelUntagItems); - rec->Register("-command_id", (void*)channelIdFor(kIdUntagItems)); - rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange); - rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsArrange)); - rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign); - rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsDesign)); - rec->Register("-gaccel", (void*)&g_accelShowBoth); - rec->Register("-command_id", (void*)channelIdFor(kIdShowBoth)); - rec->Register("-gaccel", (void*)&g_accelUntag); - rec->Register("-command_id", (void*)channelIdFor(kIdUntag)); - rec->Register("-gaccel", (void*)&g_accelTagArrange); - rec->Register("-command_id", (void*)channelIdFor(kIdTagArrange)); - rec->Register("-gaccel", (void*)&g_accelTagDesign); - rec->Register("-command_id", (void*)channelIdFor(kIdTagDesign)); - rec->Register("-gaccel", (void*)&g_accelActivateDesign); - rec->Register("-command_id", (void*)channelIdFor(kIdActivateDesign)); - rec->Register("-gaccel", (void*)&g_accelActivateArrange); - rec->Register("-command_id", (void*)channelIdFor(kIdActivateArrange)); - rec->Register("-gaccel", (void*)&g_accelToggleMode); - rec->Register("-command_id", (void*)channelIdFor(kIdToggleMode)); - - g_session = nullptr; -} - -// =========================================================================== -// Multi-bank action family (Phase B3) -// =========================================================================== -// -// Each action drives the B1 model on g_session->book() and persists via -// g_session->saveToActiveProject() so the change travels with the .rpp — exactly as -// the capture path persists a new Sample (main.cpp RunCapture). The book's rules -// (pool privileges, collapse-by-hash, active-fallback-to-pool) all live in bank_book; -// these handlers only call the model and react to the boolean / TransferResult. -// -// REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index -// return a reference INTO the book's internal vector, which a create/delete can -// reallocate. No handler here caches a BankIndex& (or a Bank*) across a structural -// mutation — each resolves ids to strings up front and re-resolves after any -// create/delete. Move/copy pass ids (not references) straight to moveSample/copySample. - -namespace { - -// FOREVER-STABLE multi-bank action-id SUFFIXES (Phase V, V4). The channel family prefix is -// prepended at register via channelCommandId (as with the Design View family above) — -// stable rebuilds the shipped id, beta the isolated one. NEVER change a shipped suffix. -// Each suffix + the stable prefix must byte-match the pre-V4 shipped literal exactly -// (e.g. "BANK_REMOVE_SELECTED" -> "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED"). -constexpr const char* kIdBankCreate = "BANK_CREATE"; -constexpr const char* kIdBankRename = "BANK_RENAME"; -constexpr const char* kIdBankDelete = "BANK_DELETE"; -constexpr const char* kIdBankEvacuate = "BANK_EVACUATE"; -constexpr const char* kIdBankActivateNext = "BANK_ACTIVATE_NEXT"; -constexpr const char* kIdBankActivatePool = "BANK_ACTIVATE_POOL"; -constexpr const char* kIdBankMoveSel = "BANK_MOVE_SELECTED"; -constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED"; -constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED"; -constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT"; -constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT"; -// Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so -// in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion), -// and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id. -constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER"; - -int g_cmdBankCreate = 0; -int g_cmdBankRename = 0; -int g_cmdBankDelete = 0; -int g_cmdBankEvacuate = 0; -int g_cmdBankActivateNext = 0; -int g_cmdBankActivatePool = 0; -int g_cmdBankMoveSel = 0; -int g_cmdBankCopySel = 0; -int g_cmdBankRemoveSel = 0; -int g_cmdBankPoolFull = 0; -int g_cmdBankBanksFull = 0; -int g_cmdBankPruneFolder = 0; - -gaccel_register_t g_accelBankCreate{}; -gaccel_register_t g_accelBankRename{}; -gaccel_register_t g_accelBankDelete{}; -gaccel_register_t g_accelBankEvacuate{}; -gaccel_register_t g_accelBankActivateNext{}; -gaccel_register_t g_accelBankActivatePool{}; -gaccel_register_t g_accelBankMoveSel{}; -gaccel_register_t g_accelBankCopySel{}; -gaccel_register_t g_accelBankRemoveSel{}; -gaccel_register_t g_accelBankPoolFull{}; -gaccel_register_t g_accelBankBanksFull{}; -gaccel_register_t g_accelBankPruneFolder{}; - -// Persists the book after a bank mutation. Mirrors the CAPTURE path (main.cpp -// RunCapture), NOT the Design-View path: a bank change is held in-session and written -// to the active project's ext state so it travels with the .rpp. Deliberately no -// Save-As prompt — saveToActiveProject no-ops on an unsaved project (the change stays -// valid for the session and persists on the user's next save), exactly as capture -// persists. This is an intentional divergence from persistViewState (above), which -// DOES prompt Save-As on an unsaved project; do not "align" the two — a bank mutation -// follows capture's quiet-persist idiom, a Design-View mutation follows the prompt idiom. -// Returns whether a persist actually happened (false on an unsaved/no-active project), -// so persistBankOp can skip its undo block when nothing was written. -bool persistBook() { return g_session->saveToActiveProject(); } - -// Persists a completed bank index verb (create/rename/reorder/delete/evacuate/ -// move/copy) as a SINGLE batched REAPER undo point (R-B) — one bank op = one Ctrl-Z. -// -// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project -// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures -// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents -// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199). -// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family -// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them -// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs -// INSIDE the block so the post-mutation ext-state is the block's "after" image. -// -// NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a -// rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here, -// so no dangling/empty undo point is ever opened for a rejected op. -// -// Prompts the user for a single line of text via REAPER's stock input dialog. -// GetUserInputs(title, num_inputs=1, captions_csv, retvals_csv, sz) -> false on -// cancel (SDK ~3808). `initial` pre-fills the field. Returns false (leaving `out` -// untouched) on cancel or an empty entry. Self-contained bindable-action name entry; -// B4's panel affordances supersede this with in-panel editing. -// -// COMMA GUARD: GetUserInputs splits the returned values on a separator that defaults -// to ',', so a bank name containing a comma would be truncated at the comma. We -// override the return separator to \x1f (ASCII unit separator, un-typeable in the -// dialog) via the documented `separator=X` extra caption field (SDK ~3806), so any -// printable name — commas included — round-trips whole. The captions_csv itself stays -// comma-joined: the single field caption, then the `separator=` directive as a -// trailing pseudo-caption (the directive redefines only the RETURN separator). -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out) { - std::vector buf(512, '\0'); - // Pre-fill: GetUserInputs seeds the field from the retvals buffer's initial value. - std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); - const std::string captions = std::string(caption) + ",separator=\x1f"; - if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), static_cast(buf.size()))) - return false; // user cancelled - std::string s(buf.data()); - if (s.empty()) return false; // an empty name is not a valid bank name - out = std::move(s); - return true; -} - -// Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model design: -// ids are caller-supplied and stable; the model stays pure and mints none). Distinct -// from a track GUID by origin only — both are canonical guidToString output. -std::string mintBankId() { - GUID g{}; - genGuid(&g); - char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract) - guidToString(&g, buf); - return std::string(buf); -} - -// Resolves a user-typed bank reference (a display name) to a bank id, scanning the -// book's banks in ordinal order. Exact match on displayName; "Pool" resolves the pool. -// Returns "" when no bank carries that name. Kept in the action layer (not the model) -// — it is UI name-resolution, not a model rule. First-match is unambiguous BY -// CONSTRUCTION: the model enforces unique display names (trimmed + case-insensitive), -// so at most one bank can carry a given name — no duplicate can shadow another here. -std::string bankIdByDisplayName(const std::string& name) { - for (const Bank& b : g_session->book().banks()) - if (b.displayName == name) return b.id; - return {}; -} - -// -- Action bodies --------------------------------------------------------- - -// Create a named bank: prompt for a display name, mint a stable GUID id, create it in -// the model, persist. The new bank is NOT auto-activated (create and activate are -// distinct acts — mirrors capture/placement separation). The model rejects a display -// name that duplicates an existing bank's (trimmed + case-insensitive, incl. "Pool"); -// the create then fails and the user is told the name is taken. -void doBankCreate() { - std::string name; - if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; - const std::string id = mintBankId(); - if (!g_session->book().createBank(id, name)) { - ShowConsoleMsg( - ("ReaSampler: could not create bank \"" + name + - "\" (a bank with that name already exists).\n") - .c_str()); - return; - } - persistBankOp("ReaSampler: create bank"); -} - -// Rename a bank: prompt for which bank (by current display name) and the new name. -// The pool is un-renamable (the model rejects it). Two prompts keep the bindable form -// self-contained; B4's panel renames in place on a tab. -void doBankRename() { - std::string which; - if (!promptText("ReaSampler: rename bank", "Bank to rename (current name):", "", - which)) - return; - const std::string id = bankIdByDisplayName(which); - if (id.empty()) { - ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); - return; - } - std::string newName; - if (!promptText("ReaSampler: rename bank", "New name:", which, newName)) return; - if (!g_session->book().renameBank(id, newName)) { - // renameBank rejects the pool (un-renamable) or a name already used by another - // bank (unique display names, trimmed + case-insensitive). - ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable, " - "or another bank already uses that name).\n"); - return; - } - persistBankOp("ReaSampler: rename bank"); -} - -// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail: -// prompt for the bank; if it holds members, a YESNO ShowMessageBox names evacuate as -// the alternative before dropping them (a plain delete orphans those members' files -// until prune — CONTEXT.md §delete). An empty bank deletes with no prompt. The richer -// panel confirm (naming evacuate inline, with a one-click evacuate) arrives in B4. -void doBankDelete() { - std::string which; - if (!promptText("ReaSampler: delete bank", "Bank to delete:", "", which)) return; - const std::string id = bankIdByDisplayName(which); - if (id.empty()) { - ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); - return; - } - // Pool early-out: the pool is un-deletable (the model rejects it). Catch it here, - // BEFORE the non-empty confirm, so typing "Pool" never shows a misleading - // "delete anyway?" prompt for an operation the model will refuse regardless. - if (id == kPoolBankId) { - ShowConsoleMsg("ReaSampler: the pool cannot be deleted.\n"); - return; - } - // Read member count BEFORE deleting (the Bank* is invalidated by deleteBank; we do - // not cache it — resolve size to an int up front). - const Bank* b = g_session->book().bank(id); - if (!b) return; // race-safe: id resolved above but re-check - const std::size_t members = b->index.size(); - if (members > 0) { - const std::string msg = - "\"" + which + "\" holds " + std::to_string(members) + - (members == 1 ? " sample" : " samples") + - ".\n\nDeleting drops them from every bank (their files are NOT deleted, " - "but no bank will reference them until prune).\n\nTo keep the samples, " - "cancel and Evacuate the bank to the pool first.\n\nDelete anyway?"; - const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 4); - if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544) - } - if (!g_session->book().deleteBank(id)) { - ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); - return; - } - // S9: bump only when the deleted bank held samples — dropping them changes what a live - // instance referencing one could play. Deleting an EMPTY bank is purely organizational. - persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); -} - -// Evacuate a named bank: move every member back to the pool (index-only, collapse by -// hash), leaving the bank empty. The pool is un-evacuable (the model rejects it). The -// intended "keep the samples" companion to delete. -void doBankEvacuate() { - std::string which; - if (!promptText("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", - which)) - return; - const std::string id = bankIdByDisplayName(which); - if (id.empty()) { - ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); - return; - } - if (!g_session->book().evacuate(id)) { - ShowConsoleMsg("ReaSampler: cannot evacuate that bank (the pool is the " - "destination, not a source).\n"); - return; - } - // S9: evacuate moves members between banks (bank membership changes) -> bump. - persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); -} - -// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), -// via the pure nextBankId helper. Activating a bank changes the CAPTURE TARGET (the -// next capture lands in the newly-active bank — B2's book().activeIndex() seam) and -// never touches the timeline. Persist so the active id travels with the .rpp. -void doBankActivateNext() { - std::vector ids; - ids.reserve(g_session->book().size()); - for (const Bank& b : g_session->book().banks()) ids.push_back(b.id); - const std::string target = nextBankId(ids, g_session->book().activeBankId()); - if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded) - if (!g_session->book().setActiveBank(target)) return; - persistBankOp("ReaSampler: activate bank"); -} - -// Activate the pool directly (the common "back to the default target" jump). Bindable -// direct-by-id form; a general activate-bank-by-name/menu is a B4 affordance. -void doBankActivatePool() { - if (!g_session->book().setActiveBank(kPoolBankId)) return; - persistBankOp("ReaSampler: activate bank"); -} - -// Move or copy the panel's selected samples into a named destination bank (prompted -// by display name). The SOURCE is the bank the selection lives in — the focused -// region's displayed bank (bankPanelSelectedSourceBankId), which under B4's vertical -// split is NOT necessarily the active/capture-target bank (active ≠ shown). Both are -// index-only (files never relocate); move removes the source entry, copy retains it; -// both observe destination collapse-by-hash (bank_book). B4's "move to bank" menu -// drives moveSample/copySample directly with a menu-chosen destination — this bindable -// form is the same operation with a text-prompt destination. -void doBankTransferSelected(bool copy) { - const std::vector selected = bankPanelSelectedSampleIds(); - if (selected.empty()) { - ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to " - "move/copy.\n"); - return; - } - const char* verb = copy ? "copy" : "move"; - const std::string title = std::string("ReaSampler: ") + verb + " selected samples"; - std::string destName; - if (!promptText(title.c_str(), "Destination bank:", "", destName)) return; - const std::string destId = bankIdByDisplayName(destName); - if (destId.empty()) { - ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str()); - return; - } - // Source = the bank the selection lives in (the focused region's displayed bank). - // Pass ids by value — no BankIndex& is cached across the loop's mutations. - const std::string srcId = bankPanelSelectedSourceBankId(); - if (srcId == destId) { - ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n"); - return; - } - - // Tally per-sample transfer outcomes so the no-op guardrail below can decide whether - // the index actually mutated (R-B). The console summary m11 stripped is gone; the - // counts remain because the verb-aware undo guardrail is driven by them. - int ok = 0, collapsed = 0; - for (const std::string& sampleId : selected) { - const TransferResult r = - copy ? g_session->book().copySample(sampleId, srcId, destId) - : g_session->book().moveSample(sampleId, srcId, destId); - switch (r) { - case TransferResult::Moved: - case TransferResult::Copied: ++ok; break; - case TransferResult::Collapsed: ++collapsed; break; - // RejectedSampleAbsent and unknown-bank / same-bank (pre-checked above) are - // no-ops for the guardrail; nothing mutated for those ids. - case TransferResult::RejectedSampleAbsent: - case TransferResult::RejectedUnknownBank: - case TransferResult::RejectedSameBank: break; - } - } - // No-op guardrail — VERB-AWARE (a collapse means different things per verb): - // * MOVE collapse: the source entry WAS removed (bank_book moveSample removes - // unconditionally before the dest add collapses on hash), so the index DID - // mutate — it counts toward opening an undo point. - // * COPY collapse: the source is left intact AND the dest already held the hash, - // so NOTHING changed — a true index no-op. It must NOT open an undo point. - // Hence: copy counts only real gains (ok); move counts gains OR collapses. - const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); - if (mutated) { - const std::string label = - std::string("ReaSampler: ") + verb + " sample(s)"; - // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an - // instance may reference) -> bump so assigned instances refresh hands-free. - persistBankOp(label.c_str(), /*bumpGeneration=*/true); - } -} - -// Remove the panel's selected samples from the SOURCE bank (the focused region's -// displayed bank — bankPanelSelectedSourceBankId, same source as move/copy). Index-only -// and non-destructive to the file: a last-reference remove leaves the file on disk, -// orphaned until Phase R prune (remove NEVER deletes bytes — the manifest is untouched). -// -// SCOPE (fork R-A): this-bank only — the sole surfaced verb. The RemoveScope::AllBanks -// seam stays latent in the model; nothing here reaches for it. -// -// SILENT REMOVE: removes proceed without a confirm dialog. Recoverability is provided -// by the batched REAPER undo (R-B) — one Ctrl-Z restores the index entry. Files are -// never deleted by remove (orphaned-until-prune is unchanged). hashReferencedElsewhere -// is a tested model API retained for Phase R prune; it has no shell caller here. -void doBankRemoveSelected() { - const std::vector selected = bankPanelSelectedSampleIds(); - if (selected.empty()) { - ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to remove.\n"); - return; - } - const std::string srcId = bankPanelSelectedSourceBankId(); - BankBook& book = g_session->book(); - if (book.bank(srcId) == nullptr) { - ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n"); - return; - } - - // Perform the removes (this-bank scope). Pass ids by value — no BankIndex& is cached - // across the loop's mutations. Count real drops so the no-op guardrail can skip the - // undo point when nothing was removed (every id was already absent). - int removed = 0; - for (const std::string& sampleId : selected) { - if (book.removeSample(sampleId, srcId, RemoveScope::ThisBank) == - RemoveResult::Removed) - ++removed; - // RejectedSampleAbsent / RejectedUnknownBank are no-ops for the guardrail. - // (Unknown bank cannot occur — srcId was resolved to a live bank above.) - } - - // No-op guardrail (R-B): open an undo point only if the index actually mutated. - // S9: a remove drops a sample from a bank (an instance referencing it must refresh — it - // will resolve to silence, per the stale-id policy) -> bump. - if (removed > 0) persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); -} - -// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE -// file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only), -// then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest -// (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then -// on explicit Yes delete EXACTLY that set (session->pruneReclaim, which recomputes the -// pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback). -// Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing. -// -// The full (untruncated) orphan set is captured here for the delete; the dry-run's -// truncated list is only the confirm's readout. No ext-state is written and no undo point -// is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project -// state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files. -void doBankPruneFolder() { - const PruneReport report = g_session->pruneDryRun(); - - // pS-usage FAIL-SAFE: a present instance-usage record could not be read — the - // protected set is unknowable, so the prune HALTS outright (deletes nothing) rather - // than proceed with degraded protection. Distinct from "no orphans": the user must - // know the prune refused to run and why. - if (report.abortedUnreadableUsage) { - std::string msg = - "ReaSampler prune: ABORTED -- one or more instance usage records could not " - "be read or decoded. Nothing was deleted.\n" - "If the owning instance is still loaded it will republish its record on the " - "next poll tick, clearing the abort. If the instance no longer exists (the " - "key is an orphaned corrupt record), clear it manually via ReaScript:\n" - " reaper.SetProjExtState(0, \"reasampler\", \"\", \"\")\n" - "Offending key(s):\n"; - for (const std::string& key : report.offendingUsageKeys) { - msg += " " + key + "\n"; - } - ShowConsoleMsg(msg.c_str()); - return; - } - - if (report.count == 0) { - ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n"); - return; - } - - // The EXACT set the delete will target — full, untruncated, so what the confirm - // summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the - // confirm so the confirm and the delete reason about the same enumeration. - const std::vector orphanSet = g_session->pruneOrphanSet(); - - // Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped - // list (the same clip the R2 readout used), with a "N more not shown" tail when clipped. - std::string msg = - "ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) + - " orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n" - "These files are no longer referenced by any bank and were created by ReaSampler.\n" - "They will be moved to the Recycle Bin on Windows (recoverable), or deleted on " - "other platforms.\n\n"; - for (const std::string& rel : report.orphans) msg += " " + rel + "\n"; - if (report.truncated) { - msg += " ... (" + std::to_string(report.count - report.orphans.size()) + - " more not shown)\n"; - } - msg += "\nReclaim these files now?"; - - const int r = ShowMessageBox(msg.c_str(), "ReaSampler: prune bank folder", 4); - if (r != 6) { // 6 == YES; anything else cancels -> delete NOTHING (SDK ~6544) - ShowConsoleMsg("ReaSampler prune: cancelled -- nothing deleted.\n"); - return; - } - - // Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped). - const PruneDeletionResult del = g_session->pruneReclaim(orphanSet); - - std::string done = "ReaSampler prune: reclaimed " + - std::to_string(del.reclaimedCount) + " file(s), " + - std::to_string(del.reclaimedBytes) + " bytes" + - (del.usedTrash ? " (to Recycle Bin)" : " (deleted)") + "."; - if (del.skippedCount > 0) { - done += " " + std::to_string(del.skippedCount) + - " file(s) skipped (locked, or changed since the report)."; - } - done += "\n"; - ShowConsoleMsg(done.c_str()); -} - -} // namespace - -// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — -// one bank op = one Ctrl-Z. Declared in actions.h so bank_panel.cpp can call it -// without duplicating the undo logic. -// -// WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project -// ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures -// iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents -// MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199). -// We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family -// does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them -// would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs -// INSIDE the block so the post-mutation ext-state is the block's "after" image. -// -// NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a -// rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here, -// so no dangling/empty undo point is ever opened for a rejected op. -// -// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project persistBook() no-ops -// (nothing is written to ext state). We must still CLOSE the block we opened, but with -// an EMPTY label and a zero flag so REAPER DISCARDS the point instead of recording a -// no-effect undo entry — mirroring view.cpp's empty-plan close. The in-session model -// change stands and persists on the user's next save; it just earns no undo point until -// there is a project to persist into (undo of an unsaved bank op has nothing to roll -// back to anyway). The Begin/End must still be balanced, hence the close-either-way. -void persistBankOp(const char* label, bool bumpGeneration) { - Undo_BeginBlock2(nullptr); - // S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the - // fresh generation rides the same ext-state write the persist makes (persistBook() -> - // saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs - // (the caller decides); a pure-organizational verb passes false and leaves the counter be, - // so a rename/activate does not needlessly refresh live instances. - if (bumpGeneration) g_session->bumpBankGeneration(); - const bool persisted = persistBook(); - if (persisted) - Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); - else - Undo_EndBlock2(nullptr, "", 0); // no ext-state write -> discard the empty point -} - -void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { - g_session = session; // shared with the Design View family; same live session - - g_cmdBankCreate = registerAction(rec, kIdBankCreate, g_accelBankCreate, - "create bank"); - g_cmdBankRename = registerAction(rec, kIdBankRename, g_accelBankRename, - "rename bank"); - g_cmdBankDelete = registerAction(rec, kIdBankDelete, g_accelBankDelete, - "delete bank"); - g_cmdBankEvacuate = registerAction(rec, kIdBankEvacuate, g_accelBankEvacuate, - "evacuate bank to pool"); - g_cmdBankActivateNext = registerAction(rec, kIdBankActivateNext, g_accelBankActivateNext, - "activate next bank (cycle)"); - g_cmdBankActivatePool = registerAction(rec, kIdBankActivatePool, g_accelBankActivatePool, - "activate pool"); - g_cmdBankMoveSel = registerAction(rec, kIdBankMoveSel, g_accelBankMoveSel, - "move selected samples to bank"); - g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel, - "copy selected samples to bank"); - g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel, - "remove selected samples"); - g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull, - "toggle pool full-height"); - g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, - "toggle banks full-height"); - // Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends - // the confirm-and-delete step behind this SAME forever-stable id). - g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder, - "prune bank folder"); -} - -bool bankHandleCommand(int command) { - if (command == 0 || !g_session) return false; - - if (command == g_cmdBankCreate) { doBankCreate(); return true; } - if (command == g_cmdBankRename) { doBankRename(); return true; } - if (command == g_cmdBankDelete) { doBankDelete(); return true; } - if (command == g_cmdBankEvacuate) { doBankEvacuate(); return true; } - if (command == g_cmdBankActivateNext) { doBankActivateNext(); return true; } - if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; } - if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; } - if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; } - if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; } - if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; } - if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; } - if (command == g_cmdBankPruneFolder) { doBankPruneFolder(); return true; } - - return false; // not ours — caller's hookcommand keeps looking -} - -int bankPruneCommandId() { return g_cmdBankPruneFolder; } - -void bankUnregisterActions(reaper_plugin_info_t* rec) { - // Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each - // '-command_id' re-presents the same interned channel-qualified id (channelIdFor). - rec->Register("-gaccel", (void*)&g_accelBankPruneFolder); - rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder)); - rec->Register("-gaccel", (void*)&g_accelBankBanksFull); - rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull)); - rec->Register("-gaccel", (void*)&g_accelBankPoolFull); - rec->Register("-command_id", (void*)channelIdFor(kIdBankPoolFull)); - rec->Register("-gaccel", (void*)&g_accelBankRemoveSel); - rec->Register("-command_id", (void*)channelIdFor(kIdBankRemoveSel)); - rec->Register("-gaccel", (void*)&g_accelBankCopySel); - rec->Register("-command_id", (void*)channelIdFor(kIdBankCopySel)); - rec->Register("-gaccel", (void*)&g_accelBankMoveSel); - rec->Register("-command_id", (void*)channelIdFor(kIdBankMoveSel)); - rec->Register("-gaccel", (void*)&g_accelBankActivatePool); - rec->Register("-command_id", (void*)channelIdFor(kIdBankActivatePool)); - rec->Register("-gaccel", (void*)&g_accelBankActivateNext); - rec->Register("-command_id", (void*)channelIdFor(kIdBankActivateNext)); - rec->Register("-gaccel", (void*)&g_accelBankEvacuate); - rec->Register("-command_id", (void*)channelIdFor(kIdBankEvacuate)); - rec->Register("-gaccel", (void*)&g_accelBankDelete); - rec->Register("-command_id", (void*)channelIdFor(kIdBankDelete)); - rec->Register("-gaccel", (void*)&g_accelBankRename); - rec->Register("-command_id", (void*)channelIdFor(kIdBankRename)); - rec->Register("-gaccel", (void*)&g_accelBankCreate); - rec->Register("-command_id", (void*)channelIdFor(kIdBankCreate)); - - // g_session is shared with the Design View family; designViewUnregisterActions - // also nulls it. Nulling twice is harmless. Leave it to whichever runs last. - g_session = nullptr; -} - -} // namespace reasampler diff --git a/src/actions.h b/src/actions.h deleted file mode 100644 index e9d9bfe..0000000 --- a/src/actions.h +++ /dev/null @@ -1,92 +0,0 @@ -#pragma once -// actions — the Design View action family (Phase D4). Registers the bindable -// actions that drive the mode workflow and wires them end-to-end: toggle/activate -// a mode, tag/untag/show-both the current track selection. Each action mutates the -// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies -// the active mode through the view shell (D2) so the change takes effect immediately. -// -// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This -// header is SDK-free; main.cpp calls register/handle/unregister and nothing else. -// -// Split out of main.cpp (rather than inlined there) to match CONTEXT.md's planned -// `actions` module and keep main.cpp's entrypoint focused on API-pointer ownership -// and lifecycle. The reapply-on-open glue stays in main.cpp (it owns the timer that -// drives persist.poll()); this module only registers and services the actions. - -// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct -// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real -// definition. Declared before the namespace so it is the global type, not a -// namespace-local shadow. -struct reaper_plugin_info_t; - -namespace reasampler { - -class ReaSamplerSession; - -// Registers the Design View action family against `rec` (command_id + gaccel + -// hookcommand-routing is owned by the caller's single hookcommand). `session` is the -// live session the actions mutate; it must outlive the registration. Idempotent is -// NOT promised — call exactly once at load, mirror-unregister once at unload. -void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); - -// Services one fired command. Returns true iff `command` is one of this module's -// action ids (and it was handled); false otherwise so the caller's hookcommand keeps -// looking (per the contract: claim only our own ids). Safe to call for any command. -bool designViewHandleCommand(int command); - -// Mirror-unregisters everything designViewRegisterActions registered, with the -// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr. -void designViewUnregisterActions(reaper_plugin_info_t* rec); - -// --- Multi-bank action family (Phase B3) ----------------------------------- -// The bindable action set that drives the multi-bank workflow: create / rename / -// delete / evacuate a bank, activate a bank (direct pool/design-free + cycle), move / -// copy the panel's selected samples into a bank, and the two vertical-split -// full-height toggles. Every mutating action drives the B1 model on -// g_session.book() and persists via g_session.saveToActiveProject() so the change -// travels with the .rpp; the toggles flip the B4-rendered layout bit on the panel. -// -// Same registration/routing/unload contract as the Design View family above and the -// same shared g_session. Kept a distinct trio (not folded into the Design View one) -// because the two families are orthogonal pillars — but they share the single -// hookcommand main.cpp owns; each family's Handle claims only its own ids. - -// Registers the multi-bank family against `rec`. `session` is the live session (must -// outlive registration). Call exactly once at load. Shares g_session with the Design -// View family — pass the SAME session pointer. -void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); - -// Services one fired command for the multi-bank family. True iff it was one of this -// family's ids (and handled); false otherwise so the caller's hookcommand keeps -// looking. Safe for any command. -bool bankHandleCommand(int command); - -// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on -// rec==nullptr (before g_session is torn down). -void bankUnregisterActions(reaper_plugin_info_t* rec); - -// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 before -// registration. The bank_panel prune button fires the action THROUGH this id via -// Main_OnCommand (fork R-E: the button dispatches the command, it does not call the session -// directly) so the panel affordance and the bindable action share one guarded code path. -int bankPruneCommandId(); - -// Persists a completed bank-index verb as a single REAPER undo point (R-B). -// Wraps persistBook() (= SetProjExtState) in a Begin/End block with UNDO_STATE_MISCCFG -// so the bank op is one Ctrl-Z. On an unsaved / no-active project persistBook() no-ops -// and the block is closed with an empty label + zero flag (REAPER discards it). Callers -// must invoke this ONLY after a successful/effective mutation — rejected ops (duplicate -// name, un-deletable pool, etc.) must return before reaching here so no empty undo -// point is ever opened for a no-op. Defined in actions.cpp alongside persistBook(). -// -// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a live -// instance would PLAY — move / copy / remove / evacuate / delete-with-members (a sample left, -// arrived, or dropped out of a bank an instance may reference). Leave it false (the default) -// for a PURELY ORGANIZATIONAL verb — create / rename / activate / reorder — which changes no -// existing (bankId, sampleId) -> content mapping, so no instance need refresh. The bump (when -// requested) happens INSIDE the block, BEFORE persistBook(), so the stamped counter rides the -// same ext-state write and undo captures the pre/post generation with the rest of the blob. -void persistBankOp(const char* label, bool bumpGeneration = false); - -} // namespace reasampler diff --git a/src/app/main.cpp b/src/app/main.cpp new file mode 100644 index 0000000..a721162 --- /dev/null +++ b/src/app/main.cpp @@ -0,0 +1,404 @@ +// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers. +// +// This file is the entire contract between REAPER and the extension: +// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and +// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry +// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro). +// * REAPER calls it, handing over `rec` — a small dispatch struct. +// - rec->GetFunc(name) resolves any REAPER API function to a pointer +// - rec->Register(what,ptr) plugs OUR callbacks into REAPER +// * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and +// fills in every global function pointer (ShowConsoleMsg, InsertMedia...). +// +// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates +// storage for those global pointers. Every other .cpp includes +// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. +// +// Since Q-W3 this TU is ONLY pointers + entry + dispatch; since Q-W6 its own +// action family registers through the DATA-DRIVEN TABLE below (kMainActionRows + +// action_registry's registerActionTable/actionTableHandleCommand/ +// unregisterActionTable) — adding a bindable action here means adding ONE row and +// its handler function, nothing else (OCP). The design_view / bank / ingest +// families keep their own register/handle/unregister triples, called from entry. + +#define REAPERAPI_IMPLEMENT +#include "reaper_plugin.h" +#include "reaper_plugin_functions.h" + +#include +#include +#include + +#include "core/capture/render_settings.h" // captureActionTable +#include "core/version/app_version.h" // appVersion +#include "ingest.h" +#include "shell/actions/action_registry.h" // the Q-W6 registration table +#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home) +#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home) +#include "shell/capture/capture_batch.h" // batch + recapture action bodies +#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies +#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver +#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded +#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) +#include "shell/persist/session.h" // ReaSamplerSession +#include "shell/view/view.h" // reconcileManagedLanes / applyMode + +namespace capture = reasampler::capture; + +// Globals other files reference via `extern`. +REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle +reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct + +// Retired command-id SUFFIXES. Kept ONLY to mirror-unregister them on unload so a +// user's stale keybindings are cleaned up. Never re-register these. Composed through +// the channel prefix at unload (channelIdFor) so a beta unload clears beta-qualified +// retired ids and a stable unload clears stable's — each channel cleans up only its +// own family. +// * The M7 four-mode ids (tracks/items/razor WET). +// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the +// master realtime action are REMOVED (capture is now item + track only; realtime +// taps the selected track). Their shipped ids are retired so old keybindings clear. +// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants +// are REMOVED; tail is now a panel-setting toggle, not a paired action. +static const char* const kRetiredCaptureCmdSuffixes[] = { + "CAPTURE_TRACKS_WET", + "CAPTURE_ITEMS_WET", + "CAPTURE_RAZOR_WET", + "CAPTURE_MASTER", + "CAPTURE_MASTER_REALTIME", + "CAPTURE_ITEM_TAIL", + "CAPTURE_TRACK_TAIL", +}; + +// The persistence session (M4): owns the in-memory BankModel and bridges it to +// project ext state. A timer tick drives g_session.poll() to detect project +// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to +// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize +// the book back into the active project's ext state (the `banks` key) so it travels +// with the .rpp. Replaces the M3 session-only g_bank. +static reasampler::ReaSamplerSession g_session; + +// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so +// OnToggleAction's checked-state poll is a single int compare (no per-poll lookup). +static int g_cmdToggleBankPanel = 0; + +// --- Action handlers (the table's function pointers) -------------------------- +// +// Each is a thin stateless routing shim: (session, per-row arg) -> the action body +// hoisted in Q-W3/Q-W4 (shell/capture/, shell/panel/). The bodies own all behavior; +// these exist only so the table rows can be plain data with flat function pointers. + +// Capture scope family: `arg` is the captureActionTable() row index — the table rows +// below are built by iterating that pure taxonomy, so the routing stays 1:1 by +// construction (never a hand-kept parallel list). +static void RunCaptureScopeRow(int arg) { + capture::RunCapture(g_session, + capture::captureActionTable()[static_cast(arg)]); +} +static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); } +static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); } +// Insert: `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (CONTEXT.md +// §insert: conform is opt-in, never silent); 0 inserts at native length. +static void RunInsertSelected(int arg) { + capture::RunInsertSelected(g_session, arg != 0); +} +static void RunBatchCaptureItems(int) { capture::RunBatchCaptureItems(g_session); } +static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session); } +static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); } +static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); } +static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); } +static void RunShowVersion(int) { + // On-demand version readout — the ONLY version output on any path (Phase V: no + // unconditional startup print; routine console chatter pops the console window). + ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); +} + +// --- The registration table (Q-W6) -------------------------------------------- +// +// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix (channel prefix +// composed at register — stable rebuilds the exact shipped id, e.g. +// "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"; beta its isolated forever-family), the +// Actions-list phrase (after the "ReaSampler[ beta]: " lead), the handler, and its +// per-row arg. Registration, hookcommand dispatch, and the unload mirror-unregister +// all iterate this data — adding an action = adding a row + a handler above. +// +// The capture scope rows (CAPTURE_ITEM / CAPTURE_TRACK) come first, sourced from the +// pure captureActionTable() taxonomy (render_settings) — suffix/phrase live in that +// one testable list, and `arg` carries the row index back to RunCapture. The +// remaining rows are this TU's singles, in the pre-table registration order. +static std::vector buildMainActionTable() { + using reasampler::ActionTableRow; + std::vector rows; + + const auto& cap = capture::captureActionTable(); + for (std::size_t i = 0; i < cap.size(); ++i) + rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase, + &RunCaptureScopeRow, static_cast(i)}); + + // M5: show/hide the docked bank panel (display-only; never captures/inserts). + rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel}); + // S8: Item-scope capture + assignment-request write (capture family because it + // leans on the capture render machinery; the other ingest surfaces live in the + // ingest family and the panel drop callback). + rows.push_back({"CAPTURE_ITEM_ASSIGN", + "capture selected item into bank + assign to active instance", + &RunCaptureItemAssign}); + // M6: place the panel's selected sample at the edit cursor. Two variants that + // differ ONLY in InsertOptions — native length vs the explicit conform opt-in. + rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor", + &RunInsertSelected, 0}); + rows.push_back({"INSERT_SELECTED_CONFORM", + "insert selected sample at edit cursor (conform to tempo)", + &RunInsertSelected, 1}); + // M11: one action fires N captures (per selected item / per razor area); the + // original selection is restored on every exit path. Bank-only, never places. + rows.push_back({"CAPTURE_BATCH_ITEMS", + "batch capture selected items (one per item)", + &RunBatchCaptureItems}); + rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)", + &RunBatchCaptureRazor}); + // M8: realtime sibling of the offline CAPTURE_TRACK scope — records the selected + // track's own output into a hidden temp track, dialog-free — plus its + // cancel-in-flight companion (stop + restore, non-destructive). + rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)", + &RunCaptureRealtime}); + rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture", + &RunCancelRealtime}); + // M10: regenerate the selected PROVENANCED sample from its recorded source's + // current state, in place. Bank-only, never places on the timeline. + rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source", + &RunRecaptureFromSource}); + // Phase V: on-demand version readout for bug reports. + rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion}); + + return rows; +} + +// The timer callback REAPER runs periodically (registered via "timer"). It only +// forwards to the session poll — cheap per tick (reads the active project id and +// its .rpp path, acts only on a change). +static void OnTimer() +{ + // Advance any in-flight realtime capture FIRST, so a project switch is caught and + // the capture torn down/restored before session.poll() reacts to that switch. + // LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER + // TEST — the cross-TU drive call is made only when a capture is in flight. + if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); + + g_session.poll(); + + // D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view + // model but deliberately does NOT apply visibility — that would couple persist + // to the view shell). Instead poll() raises a one-shot load signal; here — the + // integration layer that already drives both persist and the view shell — we + // drain it and reapply the SAVED active mode's visibility/processing so opening a + // project saved in Design mode parks the Arrange tracks automatically, no manual + // toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks + // skip it. proj = nullptr -> REAPER's active project (the one poll just loaded). + // + // The SAME signal re-arms the bank panel's new-content detector: a load must + // re-baseline the detector against the just-loaded project's content so its + // pre-existing tracks are never mis-detected as "new" and mass-tagged into the + // active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's + // re-arm and the model restore ride the one authoritative load event. + if (g_session.consumeLoadSignal()) { + reasampler::bankPanelNotifyProjectLoaded(); + // Reconcile the restored lane-ownership index against the live project's lanes + // FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth), + // so a saved lane-split project's managed/manual classification is correct + // before the active mode's lane visibility is reapplied. Never re-mints, never + // mass-tags — it only records managed ownership recovered from lane names. + reasampler::reconcileManagedLanes(g_session.view(), nullptr); + reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); + } + + // Reflect a live bank change (capture / project load) in the docked grid. + // Cheap when the bank is unchanged (a fingerprint compare); repaints only on + // 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 but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's +// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view +// would stay stale until close+reopen. REAPER's projectconfig extension fires +// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore +// (isUndo == true for both). We hook it to drive a session reload. +// +// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as +// firing BEFORE any state restore. Reading GetProjExtState synchronously here would +// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request +// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which +// point REAPER has finished restoring the block and GetProjExtState returns +// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling. +// +// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false); +// we ignore that here so a normal open flows solely through the timer's identity-transition +// Load path (no double load). Only undo/redo (isUndo=true) requests the reload. +static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) +{ + if (isUndo) + g_session.requestReload(); +} + +// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores +// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"), +// which REAPER persists in its own RPP block — NOT via this extension's own +// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo +// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER +// keeps dispatching (we claim none). SaveExtensionConfig writes nothing. +static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/, + bool /*isUndo*/, project_config_extension_t* /*reg*/) +{ + return false; // we own no project lines — ext state carries our data +} + +static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/, + project_config_extension_t* /*reg*/) +{ + // Nothing to write: our data rides in ext state, not project lines. +} + +// Storage must outlive registration — REAPER holds this pointer until we unregister it. +static project_config_extension_t g_projectConfig{ + &OnProcessExtensionLine, + &OnSaveExtensionConfig, + &OnBeginLoadProjectState, + nullptr, // userData +}; + +// REAPER calls this for EVERY action fired anywhere; claim only our own id, +// return false otherwise so REAPER keeps looking. This TU's own family dispatches +// through the registration table; the Q-W4 families claim their own ids after it. +static bool OnHookCommand(int command, int /*flag*/) +{ + if (command == 0) return false; + if (reasampler::actionTableHandleCommand(command)) return true; + // Design View action family (D4). Claims only its own ids; returns false for the + // rest so this hook keeps looking (per the contract). + if (reasampler::designViewHandleCommand(command)) return true; + // Multi-bank action family (B3). Same contract: claims only its own ids. + if (reasampler::bankHandleCommand(command)) return true; + // S8 ingest action family (Media-Explorer import). Same contract. + if (reasampler::ingestHandleCommand(command)) return true; + return false; +} + +// REAPER polls this to render each of OUR actions' checked state in menus/toolbars. +// Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract). +static int OnToggleAction(int command) +{ + if (command != 0 && command == g_cmdToggleBankPanel) + return reasampler::bankPanelIsOpen() ? 1 : 0; + return -1; // not ours / non-toggling +} + +extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( + REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec) +{ + if (!rec) + { + // rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every + // callback with the same strings prefixed '-' (per the contract). + if (g_rec) + { + // Abort any in-flight realtime capture FIRST, while the API pointers are + // still live — finalize-or-abort + restore so we never leave a temp track, + // an armed track, or an altered transport/cursor in the user's project on + // unload. Commit whatever was captured (best effort) before tearing down. + capture::AbortRealtimeCaptureForUnload(g_session); + + g_rec->Register("-timer", (void*)&OnTimer); + g_rec->Register("-projectconfig", (void*)&g_projectConfig); + g_rec->Register("-toggleaction", (void*)&OnToggleAction); + g_rec->Register("-hookcommand", (void*)&OnHookCommand); + // Tear down the Design View action family (D4) — mirror-unregisters each + // gaccel + command_id with '-'-prefixed strings. After the hook is gone. + reasampler::designViewUnregisterActions(g_rec); + // Tear down the multi-bank action family (B3) — same mirror-unregister. + reasampler::bankUnregisterActions(g_rec); + // Tear down the S8 ingest action family — same mirror-unregister. + reasampler::ingestUnregisterActions(g_rec); + // Tear down this TU's own family from the registration table (reverse + // table order; each '-command_id' re-presents the SAME interned, + // channel-qualified pointer used at register). + reasampler::unregisterActionTable(g_rec); + // Retire the REMOVED command ids (command_id only — we never held a gaccel + // for them this session). Clears stale user keybindings on unload. Composed + // per channel so a beta clears beta-qualified retired ids, stable its own. + for (const char* suffix : kRetiredCaptureCmdSuffixes) + g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix)); + } + // Destroy the docked window and release cached thumbnails before we drop + // the API pointers (DockWindowRemove/DestroyWindow need them live). + reasampler::bankPanelShutdown(); + g_rec = nullptr; + return 0; + } + + // ABI guard: the struct layout we compiled against must match this REAPER. + if (rec->caller_version != REAPER_PLUGIN_VERSION) + return 0; + + // Resolve every REAPER API function pointer. Returns the number that FAILED + // to load; 0 == success. Non-zero usually means REAPER is older than our SDK. + if (REAPERAPI_LoadAPI(rec->GetFunc) != 0) + return 0; + + g_hInst = hInstance; + g_rec = rec; + + // Point the bank panel at the live session BEFORE registering its action, so + // a toggle firing immediately has a session to read (M5). Does not open the + // window — only stores the session pointer. + reasampler::bankPanelInit(&g_session); + + // Register this TU's whole action family from the table: command_id -> gaccel + // per row, all channel-qualified, all FOREVER-STABLE per channel. + { + const std::vector rows = buildMainActionTable(); + reasampler::registerActionTable(rec, rows.data(), rows.size()); + } + + // The panel toggle renders a checked state — resolve its minted id once and + // register the toggleaction hook that reports it. + g_cmdToggleBankPanel = reasampler::actionTableCommandId("TOGGLE_BANK_PANEL"); + if (g_cmdToggleBankPanel) + rec->Register("toggleaction", (void*)&OnToggleAction); + + // Register the Design View action family (D4): toggle/activate mode, tag/untag/ + // show-both selected tracks. Each mints its own command_id + gaccel; the single + // hookcommand below routes them via designViewHandleCommand. Registered before + // the hook so every id is minted first. + reasampler::designViewRegisterActions(rec, &g_session); + + // Register the multi-bank action family (B3): create/rename/delete/evacuate bank, + // activate (cycle + pool), move/copy selected samples to a bank, and the two + // full-height layout toggles. Shares g_session with the Design View family; routed + // by the same hookcommand via bankHandleCommand. Registered before the hook. + reasampler::bankRegisterActions(rec, &g_session); + + // Register the S8 ingest action family: the Media-Explorer import-into-bank+assign + // action. Shares g_session with the other families; routed by the same hookcommand via + // ingestHandleCommand. (The arrange capture+assign action is a table row above; the + // drop path is a bank_panel callback, not a bindable action.) + reasampler::ingestRegisterActions(rec, &g_session); + + // One hookcommand routes every ReaSampler action (table + the three families). + // Registered once, after all command ids are minted. + rec->Register("hookcommand", (void*)&OnHookCommand); + + // Drive project-load / Save-As detection (M4 persist). The timer polls the + // active project each tick; on a project load it reloads the bank from ext + // state, on a Save-As it relocates the bank folder under the new .rpp. + rec->Register("timer", (void*)&OnTimer); + + // Register the projectconfig hook so an UNDO/REDO state restore reloads the + // session's book + view from the restored ext state (R-B). The timer's identity + // poll cannot see an undo (same project identity), so this hook owns undo/redo; it + // requests a deferred reload that the next timer tick drains (see the hook comment). + rec->Register("projectconfig", (void*)&g_projectConfig); + + return 1; // success — REAPER keeps us loaded +} diff --git a/src/assignment_request.cpp b/src/assignment_request.cpp deleted file mode 100644 index 077aeee..0000000 --- a/src/assignment_request.cpp +++ /dev/null @@ -1,147 +0,0 @@ -// assignment_request.cpp — see assignment_request.h. Pure: standard library only. - -#include "assignment_request.h" - -#include -#include - -namespace reasampler { - -namespace { - -constexpr const char* kMagic = "rsassign1"; - -// Append one length-prefixed field: ':' . Mirror of -// provenance's putField so the two seams share one wire idiom. -void putField(std::string& out, const std::string& field) { - out += std::to_string(field.size()); - out += ':'; - out += field; -} - -// Cursor over the encoded string. All reads are bounds-checked; a short read fails -// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the -// three field kinds this record needs. -class Cursor { -public: - explicit Cursor(const std::string& s) : s_(s) {} - - bool ok() const { return ok_; } - bool atEnd() const { return pos_ >= s_.size(); } - - // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or - // non-numeric length, a length that overflows SIZE_MAX, or a length that runs past - // the end. The digit count is capped at 20 (the decimal width of SIZE_MAX on a - // 64-bit host) so a crafted 200-digit length cannot accumulate past SIZE_MAX via - // repeated multiply. "never UB" promise from the header is upheld here. - bool field(std::string& out) { - if (!ok_) return false; - const std::size_t colon = s_.find(':', pos_); - if (colon == std::string::npos) return fail(); - if (colon == pos_) return fail(); // empty length token - // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. - if (colon - pos_ > 20u) return fail(); - std::size_t len = 0; - for (std::size_t i = pos_; i < colon; ++i) { - const char c = s_[i]; - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. - if (len > (std::numeric_limits::max() - digit) / 10u) - return fail(); - len = len * 10u + digit; - } - const std::size_t start = colon + 1; - // Guard: start may equal s_.size() (empty remainder), in which case only len==0 - // is valid; start > s_.size() cannot happen (colon < s_.size() by find()). - // Use subtraction-first form to avoid start+len wrapping on a huge len. - if (start > s_.size() || len > s_.size() - start) return fail(); - out.assign(s_, start, len); - pos_ = start + len; - return true; - } - - // Reads a length-prefixed field and parses it as a signed 64-bit decimal (an - // optional leading '-'). Fails on empty, non-digit, trailing bytes, or a value - // that would overflow INT64_MAX / underflow INT64_MIN. The digit count is capped - // at 19 (the decimal width of INT64_MAX, plus 1 for the optional sign = 20 - // characters maximum) so a crafted 21-digit field cannot accumulate UB. "never UB" - // promise from the header is upheld: all arithmetic is done on positive digits - // and capped before applying the sign. - bool fieldInt64(std::int64_t& out) { - std::string f; - if (!field(f)) return false; - if (f.empty()) return fail(); - std::size_t i = 0; - bool neg = false; - if (f[0] == '-') { - neg = true; - i = 1; - if (f.size() == 1) return fail(); // bare "-" - } - // Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit - // positive value would overflow INT64_MAX; a 20-digit negative might be valid - // (INT64_MIN = -9223372036854775808) but we conservatively reject it too: the - // generation field is a unix timestamp, never near INT64 limits in practice. - if (f.size() - i > 19u) return fail(); - std::int64_t v = 0; - for (; i < f.size(); ++i) { - const char c = f[i]; - if (c < '0' || c > '9') return fail(); - const std::int64_t digit = static_cast(c - '0'); - // Overflow guard: v * 10 + digit must not exceed INT64_MAX. - if (v > (std::numeric_limits::max() - digit) / 10) - return fail(); - v = v * 10 + digit; - } - out = neg ? -v : v; - return true; - } - - // Consumes an exact literal at the cursor (the magic tag). Fails if absent. - bool literal(const char* lit) { - if (!ok_) return false; - std::size_t i = 0; - for (; lit[i] != '\0'; ++i) { - if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); - } - pos_ += i; - return true; - } - -private: - bool fail() { - ok_ = false; - return false; - } - - const std::string& s_; - std::size_t pos_ = 0; - bool ok_ = true; -}; - -} // namespace - -std::string encodeAssignmentRequest(const AssignmentRequest& req) { - std::string out = kMagic; - putField(out, req.bankId); - putField(out, req.sampleId); - putField(out, std::to_string(req.generation)); - return out; -} - -std::optional decodeAssignmentRequest(const std::string& wire) { - Cursor cur(wire); - if (!cur.literal(kMagic)) return std::nullopt; - - AssignmentRequest req; - if (!cur.field(req.bankId)) return std::nullopt; - if (!cur.field(req.sampleId)) return std::nullopt; - if (!cur.fieldInt64(req.generation)) return std::nullopt; - - // Reject trailing garbage: a well-formed value ends exactly at the last field. - if (!cur.ok() || !cur.atEnd()) return std::nullopt; - return req; -} - -} // namespace reasampler diff --git a/src/bank_book.cpp b/src/bank_book.cpp deleted file mode 100644 index 786e265..0000000 --- a/src/bank_book.cpp +++ /dev/null @@ -1,1109 +0,0 @@ -#include "bank_book.h" - -#include -#include -#include - -// bank_book implementation. -// -// JSON is hand-rolled and self-contained, matching the house style of bank_model -// and view_mode_model (brief: keep the pure core dependency-free — no third-party -// JSON lib). The book blob nests one bank object per bank, each carrying that -// bank's BankIndex serialized by bank_model's OWN writer (BankIndex::serialize), -// so per-bank sample serialization stays owned by bank_model and is not duplicated -// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a -// raw "index" member whose value is the BankIndex blob verbatim; the parser splits -// the book envelope, then hands each nested index blob straight to -// BankIndex::deserialize. Ints use %d; strings are escaped by writeEscaped. - -namespace reasampler { - -// =========================================================================== -// SlotMap — the L7 gap-preserving display-position carrier (pure). See bank_book.h. -// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one -// slot per id. Every mutator restores it; queries assume it. -// =========================================================================== - -void SlotMap::sortBySlot() { - std::stable_sort(entries_.begin(), entries_.end(), - [](const Entry& a, const Entry& b) { return a.slot < b.slot; }); -} - -int SlotMap::slotOf(const std::string& id) const { - for (const auto& e : entries_) - if (e.id == id) return e.slot; - return -1; -} - -std::string SlotMap::idAt(int slot) const { - for (const auto& e : entries_) - if (e.slot == slot) return e.id; - return {}; -} - -int SlotMap::maxSlot() const { - int m = -1; - for (const auto& e : entries_) - if (e.slot > m) m = e.slot; - return m; -} - -std::vector SlotMap::orderedIds() const { - // entries_ is sorted ascending by slot, so a straight walk is display order. - std::vector out; - out.reserve(entries_.size()); - for (const auto& e : entries_) out.push_back(e.id); - return out; -} - -void SlotMap::append(const std::string& id) { - if (id.empty()) return; - remove(id); // an existing id is re-appended, not left in place - entries_.push_back(Entry{id, maxSlot() + 1}); // next free slot after the last occupied - sortBySlot(); -} - -bool SlotMap::remove(const std::string& id) { - for (auto it = entries_.begin(); it != entries_.end(); ++it) { - if (it->id == id) { - entries_.erase(it); // leaves the slot empty — no re-pack - return true; - } - } - return false; -} - -bool SlotMap::reorder(const std::string& id, int targetSlot) { - if (slotOf(id) < 0) return false; // not mapped -> no mutation - if (targetSlot < 0) targetSlot = 0; - if (slotOf(id) == targetSlot) return false; // already there — true no-op - - // Detach the moving id first so the occupancy test below sees the post-move world. - remove(id); - - const bool occupied = !idAt(targetSlot).empty(); - if (occupied) { - // Insert-before-and-shift: every occupant at slot >= targetSlot shifts up by one, - // preserving relative order and interior gaps above the target. The moving id then - // takes targetSlot cleanly. - for (auto& e : entries_) - if (e.slot >= targetSlot) ++e.slot; - } - entries_.push_back(Entry{id, targetSlot}); - sortBySlot(); - return true; -} - -void SlotMap::resetDense(const std::vector& ids) { - entries_.clear(); - int slot = 0; - for (const auto& id : ids) { - if (id.empty()) continue; - if (slotOf(id) >= 0) continue; // skip a duplicate id (one slot per id) - entries_.push_back(Entry{id, slot++}); - } - // Already ascending by construction; no sort needed. -} - -void SlotMap::reconcile(const std::vector& liveIds) { - // Drop markers whose sample left the index. - entries_.erase( - std::remove_if(entries_.begin(), entries_.end(), - [&](const Entry& e) { - return std::find(liveIds.begin(), liveIds.end(), e.id) == - liveIds.end(); - }), - entries_.end()); - // Append live ids that have no mapping yet (out-of-band index growth), in liveIds - // order, each to the next free slot after the current frontier. - for (const auto& id : liveIds) - if (slotOf(id) < 0) append(id); - sortBySlot(); -} - -bool SlotMap::operator==(const SlotMap& o) const { - return entries_ == o.entries_; -} - -SlotMap SlotMap::fromEntries(const std::vector>& pairs) { - SlotMap m; - for (const auto& [id, slot] : pairs) { - if (id.empty() || slot < 0) continue; // drop malformed pair - if (m.slotOf(id) >= 0) continue; // duplicate id: first wins - if (!m.idAt(slot).empty()) continue; // slot taken: never double-occupy - m.entries_.push_back(Entry{id, slot}); - } - m.sortBySlot(); - return m; -} - -// SlotMap::serialize is defined in the JSON writer section below (it reuses the -// file-local ObjWriter / intToStr helpers). - -// --------------------------------------------------------------------------- -// BankBook — construction + bank lookup -// --------------------------------------------------------------------------- - -BankBook::BankBook() { - Bank pool; - pool.id = kPoolBankId; - pool.displayName = kPoolBankName; - pool.ordinal = 0; - banks_.push_back(std::move(pool)); - activeBankId_ = kPoolBankId; -} - -Bank* BankBook::bank(const std::string& id) { - for (auto& b : banks_) - if (b.id == id) return &b; - return nullptr; -} - -const Bank* BankBook::bank(const std::string& id) const { - for (const auto& b : banks_) - if (b.id == id) return &b; - return nullptr; -} - -BankIndex* BankBook::index(const std::string& id) { - Bank* b = bank(id); - return b ? &b->index : nullptr; -} - -const BankIndex* BankBook::index(const std::string& id) const { - const Bank* b = bank(id); - return b ? &b->index : nullptr; -} - -Bank& BankBook::pool() { - // The pool is seeded on construction and is un-deletable, so it always exists. - return *bank(kPoolBankId); -} - -const Bank& BankBook::pool() const { - return *bank(kPoolBankId); -} - -// --------------------------------------------------------------------------- -// Ordinal normalization -// --------------------------------------------------------------------------- - -void BankBook::normalizeOrdinals() { - // Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a - // contiguous 0..N-1. Stability preserves the caller's relative order among banks - // that share (or, after a reorder shuffle, tie on) an ordinal. - std::stable_sort(banks_.begin(), banks_.end(), [](const Bank& a, const Bank& b) { - if (a.isPool() != b.isPool()) return a.isPool(); // pool always first - return a.ordinal < b.ordinal; - }); - for (std::size_t i = 0; i < banks_.size(); ++i) - banks_[i].ordinal = static_cast(i); -} - -// --------------------------------------------------------------------------- -// Display-name uniqueness (trimmed + case-insensitive, ASCII) -// --------------------------------------------------------------------------- - -namespace { - -// Folds a display name to its uniqueness key: strip leading/trailing ASCII -// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one -// key and cannot coexist. ASCII-only by design — the pure core carries no locale -// facility and must not grow one; bank names are short user labels, not full Unicode -// case-folding candidates. -std::string nameKey(const std::string& s) { - std::size_t b = 0, e = s.size(); - auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; - while (b < e && isWs(s[b])) ++b; - while (e > b && isWs(s[e - 1])) --e; - std::string out; - out.reserve(e - b); - for (std::size_t i = b; i < e; ++i) { - char c = s[i]; - if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); - out += c; - } - return out; -} - -} // namespace - -// True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The -// exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name. -bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const { - const std::string key = nameKey(name); - for (const auto& b : banks_) - if (b.id != exceptId && nameKey(b.displayName) == key) return true; - return false; -} - -// --------------------------------------------------------------------------- -// Bank lifecycle -// --------------------------------------------------------------------------- - -bool BankBook::createBank(const std::string& id, const std::string& displayName) { - if (id.empty()) return false; // ids key the registry - if (id == kPoolBankId) return false; // reserved pool id - if (bank(id) != nullptr) return false; // duplicate id - // Display names are unique (trimmed + case-insensitive); the pool's "Pool" is a - // reserved name and is caught here like any other collision. - if (displayNameTaken(displayName, /*exceptId=*/id)) return false; - - Bank b; - b.id = id; - b.displayName = displayName; - b.ordinal = static_cast(banks_.size()); // append; normalize compacts it - banks_.push_back(std::move(b)); - normalizeOrdinals(); - return true; -} - -bool BankBook::renameBank(const std::string& id, const std::string& displayName) { - if (id == kPoolBankId) return false; // pool is un-renamable - Bank* b = bank(id); - if (b == nullptr) return false; - // Reject a name already used by a DIFFERENT bank. Renaming a bank to its own - // current name (or a case/space variant of it) is a no-op success, not a - // rejection — exceptId=id excludes the bank itself from the collision scan. - if (displayNameTaken(displayName, /*exceptId=*/id)) return false; - b->displayName = displayName; - return true; -} - -bool BankBook::deleteBank(const std::string& id) { - if (id == kPoolBankId) return false; // pool is un-deletable - auto it = std::find_if(banks_.begin(), banks_.end(), - [&](const Bank& b) { return b.id == id; }); - if (it == banks_.end()) return false; - - banks_.erase(it); - // If the active bank was the one deleted, fall back to the pool (invariant: the - // active id always names a live bank). - if (activeBankId_ == id) activeBankId_ = kPoolBankId; - normalizeOrdinals(); - return true; -} - -bool BankBook::reorderBank(const std::string& id, int newOrdinal) { - if (id == kPoolBankId) return false; // pool is pinned at ordinal 0 - if (bank(id) == nullptr) return false; - - // Work on the named banks as an ordered list (banks_ is already ordinal-sorted - // with the pool first, so named banks are banks_[1..]). Pull the target out and - // re-insert it at the requested position, clamped into the named-bank range - // [1..N], then rewrite ordinals contiguously. This is O(N) and obviously correct. - std::vector named; - named.reserve(banks_.size()); - for (auto& b : banks_) - if (!b.isPool()) named.push_back(std::move(b)); - - auto it = std::find_if(named.begin(), named.end(), - [&](const Bank& b) { return b.id == id; }); - Bank moved = std::move(*it); - named.erase(it); - - // Named ordinals are 1..N; convert to a 0-based insertion index into `named`. - const int hi = static_cast(named.size()); // insert-at range is [0..size] - int insertAt = std::max(0, std::min(newOrdinal - 1, hi)); - named.insert(named.begin() + insertAt, std::move(moved)); - - // Rebuild banks_: pool first, then the reordered named banks. Assign ordinals - // directly by position here — NOT via normalizeOrdinals(), whose stable_sort keys - // on the (now stale) ordinals and would undo the reinsertion order. - std::vector rebuilt; - rebuilt.reserve(named.size() + 1); - rebuilt.push_back(std::move(pool())); - for (auto& b : named) rebuilt.push_back(std::move(b)); - banks_ = std::move(rebuilt); - for (std::size_t i = 0; i < banks_.size(); ++i) - banks_[i].ordinal = static_cast(i); - return true; -} - -bool BankBook::evacuate(const std::string& id) { - if (id == kPoolBankId) return false; // pool is un-evacuable (it is the target) - Bank* src = bank(id); - if (src == nullptr) return false; - - // Move every member into the pool, index-only, observing destination collapse. - // Snapshot the members first, then clear the source — BankIndex has no bulk move, - // and adding into the pool must not alias the vector we are draining. - BankIndex& poolIndex = pool().index; - const std::vector members = src->index.all(); // copy - for (const auto& s : members) - poolIndex.add(s); // Added or Collapsed; either way the pool now holds the hash - src->index = BankIndex{}; // leave the evacuated bank empty - return true; -} - -// --------------------------------------------------------------------------- -// Active bank -// --------------------------------------------------------------------------- - -bool BankBook::setActiveBank(const std::string& id) { - if (bank(id) == nullptr) return false; // unknown id never corrupts state - activeBankId_ = id; - return true; -} - -BankIndex& BankBook::activeIndex() { - // activeBankId_ always names a live bank; it falls back to the pool on delete. - return bank(activeBankId_)->index; -} - -const BankIndex& BankBook::activeIndex() const { - return bank(activeBankId_)->index; -} - -// --------------------------------------------------------------------------- -// Sample movement (index-only) -// --------------------------------------------------------------------------- - -namespace { - -// Adds `s` to `dest` and maps the BankIndex outcome onto the transfer outcome for -// the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes -// (absolute path / empty id) cannot occur here: the sample already passed add() on -// the source side, so its path and id are already valid. -TransferResult applyDestAdd(BankIndex& dest, const Sample& s, TransferResult gained) { - return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained; -} - -} // namespace - -TransferResult BankBook::moveSample(const std::string& sampleId, - const std::string& fromBankId, - const std::string& toBankId) { - Bank* from = bank(fromBankId); - Bank* to = bank(toBankId); - if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; - if (fromBankId == toBankId) return TransferResult::RejectedSameBank; - - const Sample* s = from->index.query(sampleId); - if (s == nullptr) return TransferResult::RejectedSampleAbsent; - - // Copy the sample out before removing it: query returns a pointer into the - // source vector that remove() invalidates. - const Sample moved = *s; - from->index.remove(sampleId); // source loses the entry unconditionally on a move - return applyDestAdd(to->index, moved, TransferResult::Moved); -} - -TransferResult BankBook::copySample(const std::string& sampleId, - const std::string& fromBankId, - const std::string& toBankId) { - Bank* from = bank(fromBankId); - Bank* to = bank(toBankId); - if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; - if (fromBankId == toBankId) return TransferResult::RejectedSameBank; - - const Sample* s = from->index.query(sampleId); - if (s == nullptr) return TransferResult::RejectedSampleAbsent; - - const Sample copy = *s; // source entry is left intact - return applyDestAdd(to->index, copy, TransferResult::Copied); -} - -// --------------------------------------------------------------------------- -// Sample removal (index-only) + the last-reference query -// --------------------------------------------------------------------------- - -RemoveResult BankBook::removeSample(const std::string& sampleId, - const std::string& fromBankId, - RemoveScope scope) { - if (scope == RemoveScope::AllBanks) { - // Latent seam: purge the id from every bank that holds it. fromBankId is - // ignored (the id is dropped book-wide). Removed iff at least one drop landed. - bool any = false; - for (auto& b : banks_) - if (b.index.remove(sampleId)) { - b.slots.remove(sampleId); // keep SlotMap in sync: leave an empty gap - any = true; - } - return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent; - } - - // ThisBank (default, the only surfaced verb): drop from the one named source bank. - Bank* from = bank(fromBankId); - if (from == nullptr) return RemoveResult::RejectedUnknownBank; - if (!from->index.remove(sampleId)) return RemoveResult::RejectedSampleAbsent; - from->slots.remove(sampleId); // keep SlotMap in sync: the removed sample's slot becomes a gap - return RemoveResult::Removed; -} - -bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) { - for (auto& b : banks_) - if (b.index.query(sampleId) != nullptr) - return b.index.updateInPlace(sampleId, updated); - return false; // no bank holds the id -} - -// --------------------------------------------------------------------------- -// Sample display order (L7) — SlotMap driven, index membership untouched -// --------------------------------------------------------------------------- - -namespace { - -// The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed. -std::vector indexIds(const BankIndex& idx) { - std::vector ids; - for (const auto& s : idx.all()) ids.push_back(s.id); - return ids; -} - -// Squares one bank's SlotMap with its index membership. A map with NO overlap with the -// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from -// insertion order; an existing map is reconciled (drop stale markers, append unmapped). -void reconcileBankSlots(Bank& b) { - const std::vector live = indexIds(b.index); - if (b.slots.empty()) { - b.slots.resetDense(live); // migration / first-population default: dense, no gaps - return; - } - b.slots.reconcile(live); // partial map: keep positions, drop stale, append new -} - -} // namespace - -void BankBook::reconcileSlots() { - for (auto& b : banks_) reconcileBankSlots(b); -} - -std::vector BankBook::orderedSampleIds(const std::string& bankId) { - Bank* b = bank(bankId); - if (b == nullptr) return {}; - reconcileBankSlots(*b); // ensure the map covers all live members - return b->slots.orderedIds(); -} - -bool BankBook::reorderSample(const std::string& id, const std::string& bankId, - int targetSlot) { - Bank* b = bank(bankId); - if (b == nullptr) return false; - if (b->index.query(id) == nullptr) return false; // bank does not hold the sample - reconcileBankSlots(*b); // complete the target space first - return b->slots.reorder(id, targetSlot); // gap-preserving; index untouched -} - -bool BankBook::replaceSample(const std::string& newId, const std::string& oldId, - const std::string& bankId) { - if (newId == oldId) return false; - Bank* b = bank(bankId); - if (b == nullptr) return false; - // Both the dragged sample and the occupant must live in this bank. - if (b->index.query(newId) == nullptr) return false; - if (b->index.query(oldId) == nullptr) return false; - - reconcileBankSlots(*b); // complete the map so oldId's slot is known - - // Capture the target slot BEFORE any mutation so the position survives the removal. - const int targetSlot = b->slots.slotOf(oldId); - if (targetSlot < 0) return false; // occupant not positioned (shouldn't happen post-reconcile) - - // POOL GUARD (settled): the occupant's index-removal must pass the SAME guard the - // remove verb applies. Commit the removal FIRST so a rejection is a true no-op (no - // slot mutation happened yet). removeSample(ThisBank) permits per-sample removal from - // any bank incl. the pool (per-sample remove is not a pool privilege), so it succeeds - // whenever the occupant exists — which we verified — but routing through it means a - // future pool-floor guard added to remove governs replace identically, one rule. - const RemoveResult r = removeSample(oldId, bankId, RemoveScope::ThisBank); - if (r != RemoveResult::Removed) return false; // guard rejected -> nothing changed - - // Occupant gone from the index; now update the slot markers. Drop oldId's now-dangling - // marker to free the target slot, then move newId onto it. reorder onto an EMPTY slot - // places newId there exactly and empties newId's own (source) slot — the slot position - // is preserved and only its occupant changed, exactly the replace contract. - b->slots.remove(oldId); - b->slots.reorder(newId, targetSlot); - return true; -} - -bool BankBook::hashReferencedElsewhere(const std::string& hash, - const std::string& exceptBankId) const { - if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash) - for (const auto& b : banks_) { - if (b.id == exceptBankId) continue; // the removed-from bank is excluded - if (b.index.findByHash(hash) != nullptr) return true; - } - return false; -} - -std::vector BankBook::referencedPaths() const { - // Union across the whole book (pool first, then named banks in ordinal order — - // banks_ is kept ordinal-sorted). De-duplicate by exact string so a file a copy - // put in two banks appears once. Skip empty paths (they reference no file). - std::vector paths; - std::unordered_set seen; - for (const auto& b : banks_) { - for (const auto& s : b.index.all()) { - if (s.relativePath.empty()) continue; - if (seen.insert(s.relativePath).second) - paths.push_back(s.relativePath); - } - } - return paths; -} - -// =========================================================================== -// JSON — writer -// =========================================================================== - -namespace { - -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -std::string intToStr(int v) { - char buf[16]; - std::snprintf(buf, sizeof(buf), "%d", v); - return buf; -} - -class ObjWriter { -public: - explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } - ~ObjWriter() { out_ += '}'; } - - void keyRaw(const char* key, const std::string& rawValue) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - out_ += rawValue; - } - void keyStr(const char* key, const std::string& value) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - writeEscaped(out_, value); - } - void keyBegin(const char* key) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - } - -private: - void sep() { if (first_) first_ = false; else out_ += ','; } - std::string& out_; - bool first_ = true; -}; - -} // namespace - -std::string SlotMap::serialize() const { - // Array of {id, slot} objects in ascending slot order (entries_ is kept sorted). - std::string out; - out += '['; - for (std::size_t i = 0; i < entries_.size(); ++i) { - if (i) out += ','; - ObjWriter e(out); - e.keyStr("id", entries_[i].id); - e.keyRaw("slot", intToStr(entries_[i].slot)); - } - out += ']'; - return out; -} - -std::string BankBook::serialize() const { - std::string out; - { - ObjWriter root(out); - root.keyRaw("version", intToStr(1)); - root.keyStr("activeBank", activeBankId_); - - // banks: array of { id, displayName, ordinal, index: }. - // The pool rides in as bank-zero, persisted identically to any named bank. - root.keyBegin("banks"); - out += '['; - for (std::size_t i = 0; i < banks_.size(); ++i) { - if (i) out += ','; - ObjWriter b(out); - b.keyStr("id", banks_[i].id); - b.keyStr("displayName", banks_[i].displayName); - b.keyRaw("ordinal", intToStr(banks_[i].ordinal)); - // The nested index is bank_model's own JSON, emitted verbatim so the - // per-sample shape stays owned by BankIndex::serialize (not duplicated). - b.keyRaw("index", banks_[i].index.serialize()); - // L7 display positions (gap-preserving). Absent on a pre-L7 blob; the - // parser defaults such a bank's slots from insertion order on load. - b.keyRaw("slots", banks_[i].slots.serialize()); - } - out += ']'; - } // root closes here (see bank_model note on NRVO + deferred close) - return out; -} - -// =========================================================================== -// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB) -// =========================================================================== - -namespace { - -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - - // Parses a book blob into a bank set + active id. On success fills the out-params - // and returns true. Distinguishes the legacy shape (a bare bank_index object: has - // "samples", no "banks") from the book shape (has "banks"): a legacy blob yields a - // single pool bank carrying the migrated index and an empty active id (⇒ pool). The - // member deserialize() adopts the result (ordinal normalize + active resolve). - bool parseBook(std::vector& banks, std::string& activeBank); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - - bool parseString(std::string& out); - bool parseInt(int& out); - bool parseKey(std::string& key); - bool skipValue(); - // Captures the raw source text of one JSON value (object / array / string / - // scalar) verbatim, so a nested BankIndex blob can be handed to its own parser. - bool captureValue(std::string& raw); - - bool parseBank(Bank& out); - // Parses the "slots" array ([{id, slot}, ...]) into (id, slot) pairs. An empty - // array is valid (an empty bank). Malformed structure fails the whole parse; the - // pair-level defensive repair (dupes/conflicts) lives in SlotMap::fromEntries. - bool parseSlots(std::vector>& out); -}; - -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - unsigned int hi = 0; - if (!readHex4(hi)) return false; - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; // unpaired low surrogate - } - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated -} - -bool Parser::parseInt(int& out) { - skipWs(); - std::size_t start = pos_; - if (!eof() && (s_[pos_] == '-' || s_[pos_] == '+')) ++pos_; - std::size_t digitsStart = pos_; - while (!eof() && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_; - if (pos_ == digitsStart) return false; // no digits - long v = 0; - try { - v = std::stol(s_.substr(start, pos_ - start)); - } catch (...) { - return false; // out of long range → malformed - } - if (v < INT_MIN || v > INT_MAX) return false; - out = static_cast(v); - return true; -} - -bool Parser::parseKey(std::string& key) { - if (!parseString(key)) return false; - if (!consume(':')) return false; - return true; -} - -bool Parser::skipValue() { - std::string raw; - return captureValue(raw); -} - -// Records the raw source span of one JSON value starting at the current position -// (after whitespace) so it can be re-parsed by a nested parser. Handles nested -// objects/arrays with string-aware brace matching (braces inside strings ignored). -bool Parser::captureValue(std::string& raw) { - skipWs(); - if (eof()) return false; - std::size_t start = pos_; - char c = s_[pos_]; - if (c == '"') { - std::string tmp; - if (!parseString(tmp)) return false; - raw.assign(s_, start, pos_ - start); - return true; - } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - ++pos_; - int depth = 1; - while (!eof() && depth > 0) { - char d = s_[pos_]; - if (d == '"') { - std::string tmp; - if (!parseString(tmp)) return false; // advances past the string - continue; - } - if (d == open) ++depth; - else if (d == close) --depth; - ++pos_; - } - if (depth != 0) return false; - raw.assign(s_, start, pos_ - start); - return true; - } - // bare scalar (number / true / false / null) - while (!eof()) { - char d = s_[pos_]; - if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' || - d == '\n' || d == '\r') - break; - ++pos_; - } - if (pos_ == start) return false; - raw.assign(s_, start, pos_ - start); - return true; -} - -bool Parser::parseBank(Bank& b) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return false; // a bank object must at least carry an id - - bool haveId = false; - bool haveIndex = false; - do { - std::string key; - if (!parseKey(key)) return false; - - if (key == "id") { - if (!parseString(b.id)) return false; - haveId = true; - } else if (key == "displayName") { - if (!parseString(b.displayName)) return false; - } else if (key == "ordinal") { - if (!parseInt(b.ordinal)) return false; - } else if (key == "index") { - std::string raw; - if (!captureValue(raw)) return false; - auto idx = BankIndex::deserialize(raw); - if (!idx) return false; // a malformed nested index fails the whole parse - b.index = std::move(*idx); - haveIndex = true; - } else if (key == "slots") { - // L7 display positions. Absent on a pre-L7 blob (the else-branch skips - // nothing because the key never appears); when present it drives the - // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. - std::vector> pairs; - if (!parseSlots(pairs)) return false; - b.slots = SlotMap::fromEntries(pairs); - } else { - if (!skipValue()) return false; // forward-compat unknown keys - } - } while (consume(',')); - - if (!consume('}')) return false; - if (!haveId || b.id.empty()) return false; // id keys the registry - if (!haveIndex) return false; // every bank persists its index - return true; -} - -bool Parser::parseSlots(std::vector>& out) { - out.clear(); - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; // empty slot array — a bank with no positions yet - do { - if (!consume('{')) return false; - std::string id; - int slot = 0; - bool haveId = false, haveSlot = false; - do { - std::string k; - if (!parseKey(k)) return false; - if (k == "id") { if (!parseString(id)) return false; haveId = true; } - else if (k == "slot") { if (!parseInt(slot)) return false; haveSlot = true; } - else { if (!skipValue()) return false; } // forward-compat - } while (consume(',')); - if (!consume('}')) return false; - if (!haveId || !haveSlot) return false; // a slot entry needs both - out.emplace_back(std::move(id), slot); - } while (consume(',')); - return consume(']'); -} - -bool Parser::parseBook(std::vector& banks, std::string& activeBank) { - banks.clear(); - activeBank.clear(); - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return false; // an empty object is neither shape → malformed - - // Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a - // "samples" key with no "banks" ⇒ legacy shape (promote into the pool). - std::vector parsedBanks; - bool sawBanks = false; - bool sawSamples = false; - - do { - std::string key; - if (!parseKey(key)) return false; - - if (key == "banks") { - sawBanks = true; - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { - do { - Bank b; - if (!parseBank(b)) return false; - parsedBanks.push_back(std::move(b)); - } while (consume(',')); - if (!consume(']')) return false; - } - } else if (key == "activeBank") { - if (!parseString(activeBank)) return false; - } else if (key == "samples") { - // Legacy marker. The legacy index is re-parsed from the whole input below - // (BankIndex::deserialize owns that shape); here we only skip the value to - // keep the scan well-formed and note that we saw it. - sawSamples = true; - if (!skipValue()) return false; - } else { - if (!skipValue()) return false; // version, or unknown - } - } while (consume(',')); - - if (!consume('}')) return false; - skipWs(); - if (!eof()) return false; // trailing garbage - - // --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- - if (!sawBanks) { - if (!sawSamples) return false; // neither shape's marker → malformed - auto legacy = BankIndex::deserialize(s_); - if (!legacy) return false; - Bank pool; - pool.id = kPoolBankId; - pool.displayName = kPoolBankName; - pool.ordinal = 0; - pool.index = std::move(*legacy); - banks.push_back(std::move(pool)); // { pool } with zero named banks - activeBank.clear(); // ⇒ pool (default) after adoption - return true; - } - - // --- Book shape: the parsed banks ARE the book (pool folded in). --- - // The pool must be present as bank-zero (serialize always emits it). Reject a - // book blob that omits it rather than silently re-seeding — a book without its - // pool is malformed, not a legacy blob. - bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(), - [](const Bank& b) { return b.isPool(); }); - if (!hasPool) return false; - - // Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup). - for (std::size_t i = 0; i < parsedBanks.size(); ++i) - for (std::size_t j = i + 1; j < parsedBanks.size(); ++j) - if (parsedBanks[i].id == parsedBanks[j].id) return false; - - // Force the pool's fixed display name — it is not user-mutable, so we do not - // trust a persisted override for it (keeps kPoolBankName authoritative). - for (auto& b : parsedBanks) - if (b.isPool()) b.displayName = kPoolBankName; - - // --- Coalesce duplicate folded display names (B4 re-review fold-in). -------- - // The in-model create/rename path enforces unique display names under nameKey, - // but a hand-edited .rpp blob can smuggle in two banks whose names fold to the - // same key ("Drums" and " drums "). Rejecting the whole book over one collision - // would degrade the user's entire library to empty, so instead we AUTO- - // DISAMBIGUATE the later duplicate deterministically: scan in parse order, and - // the first time a folded key repeats, suffix that bank's display name (" 2", - // " 3", …) until its folded key is unique among all names seen so far. The FIRST - // bank to carry a key keeps its name verbatim; only subsequent collisions are - // renamed. No bank or sample is lost, and ids are untouched. The pool is included - // in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool" - // is disambiguated away from it, never the reverse. - { - std::vector seenKeys; - seenKeys.reserve(parsedBanks.size()); - for (auto& b : parsedBanks) { - if (b.isPool()) { // pool's name is fixed; reserve its key - seenKeys.push_back(nameKey(b.displayName)); - continue; - } - const auto taken = [&](const std::string& k) { - return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end(); - }; - std::string key = nameKey(b.displayName); - if (taken(key)) { - // Suffix with an ascending integer until the folded key is free. Guard - // against a pathological blob whose base name already ends in a number - // by folding the candidate each attempt (nameKey normalizes it). - const std::string base = b.displayName; - for (int n = 2;; ++n) { - const std::string candidate = base + " " + std::to_string(n); - const std::string candKey = nameKey(candidate); - if (!taken(candKey)) { - b.displayName = candidate; - key = candKey; - break; - } - } - } - seenKeys.push_back(key); - } - } - - banks = std::move(parsedBanks); - return true; -} - -} // namespace - -void BankBook::adoptBanks(std::vector&& banks, const std::string& activeBank) { - banks_ = std::move(banks); - normalizeOrdinals(); - // Resolve the active bank defensively: fall back to the pool if the persisted id - // names no bank, so a corrupt active id never leaves a dangling capture target. - activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId); -} - -std::optional BankBook::deserialize(const std::string& json) { - std::vector banks; - std::string activeBank; - Parser p(json); - if (!p.parseBook(banks, activeBank)) return std::nullopt; - - BankBook book; - book.adoptBanks(std::move(banks), activeBank); - return book; -} - -// --------------------------------------------------------------------------- -// Active-bank cycle ordering (pure, free function — mirror of nextModeId) -// --------------------------------------------------------------------------- - -std::string nextBankId(const std::vector& orderedBankIds, - const std::string& currentBankId) { - if (orderedBankIds.empty()) return {}; // nothing to cycle to - for (std::size_t i = 0; i < orderedBankIds.size(); ++i) { - if (orderedBankIds[i] == currentBankId) - return orderedBankIds[(i + 1) % orderedBankIds.size()]; // wrap past the last - } - // Active id not in the list (stale/unknown) — jump to the first id as a sane - // home rather than returning "" (matches nextModeId's fallback). - return orderedBankIds.front(); -} - -BankBook BankBook::loadFromPersisted(const std::string& banksJson, - const std::string& legacyJson) { - // Precedence 1: the authoritative `banks` blob. A present-but-malformed blob is - // an error, not an absence — degrade to an empty book rather than falling through - // to a stale legacy key (which would resurrect superseded single-bank state). - if (!banksJson.empty()) { - auto book = deserialize(banksJson); - return book ? std::move(*book) : BankBook{}; - } - // Precedence 2: no `banks` yet, but a legacy `bank_index` — one-way pool migration - // (deserialize's parse-time legacy path promotes it into the pool). A malformed - // legacy blob likewise degrades to empty. - if (!legacyJson.empty()) { - auto book = deserialize(legacyJson); - return book ? std::move(*book) : BankBook{}; - } - // Precedence 3: a brand-new / never-captured project — a fresh empty book. - return BankBook{}; -} - -} // namespace reasampler diff --git a/src/bank_model.cpp b/src/bank_model.cpp deleted file mode 100644 index a5286ec..0000000 --- a/src/bank_model.cpp +++ /dev/null @@ -1,767 +0,0 @@ -#include "bank_model.h" - -#include -#include -#include -#include -#include - -// bank_model implementation. -// -// JSON is hand-rolled and self-contained (brief: keep the pure core -// dependency-free — no third-party JSON lib, no WDL coupling). The field set is -// a flat struct of primitives, strings, one enum, a small string array, and a -// few optionals, so a compact writer + recursive-descent parser 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 { - -// --------------------------------------------------------------------------- -// equality -// --------------------------------------------------------------------------- - -bool SourceRange::operator==(const SourceRange& o) const { - return startSeconds == o.startSeconds && endSeconds == o.endSeconds && - startPpq == o.startPpq && endPpq == o.endPpq; -} - -bool Provenance::operator==(const Provenance& o) const { - return parentSampleId == o.parentSampleId && fxChainSnapshot == o.fxChainSnapshot; -} - -bool Levels::operator==(const Levels& o) const { - return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs; -} - -bool LoopPoints::operator==(const LoopPoints& o) const { - return start == o.start && end == o.end; -} - -bool Sample::operator==(const Sample& o) const { - return id == o.id && displayName == o.displayName && relativePath == o.relativePath && - sourceMode == o.sourceMode && sourceRange == o.sourceRange && - trackGuids == o.trackGuids && wetDry == o.wetDry && - channelCount == o.channelCount && sampleRate == o.sampleRate && - lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats && - captureTempo == o.captureTempo && - captureTimeSigNum == o.captureTimeSigNum && - captureTimeSigDenom == o.captureTimeSigDenom && key == o.key && - rootNote == o.rootNote && loop == o.loop && levels == o.levels && - clipped == o.clipped && tier == o.tier && contentHash == o.contentHash && - provenance == o.provenance && createdTimestamp == o.createdTimestamp; -} - -// --------------------------------------------------------------------------- -// path invariant -// --------------------------------------------------------------------------- - -// DECISION: reject absolute paths rather than normalize them. The pure model has -// no knowledge of the project root, so it cannot correctly relativize an absolute -// path — any "normalization" would be a guess that could point at the wrong file. -// Rejecting at the boundary is honest and deterministic; the capture backend (M3) -// is responsible for handing us an already-relative path. Covers POSIX ("/x"), -// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") -// forms. Any leading : is rejected regardless of the character that follows — -// drive-relative paths ("C:foo.wav") resolve against the drive's current directory, -// not the project root, so they violate the relative-paths-only invariant just as -// much as "C:\foo.wav" does. -static bool isAbsolutePath(const std::string& p) { - if (p.empty()) return false; - if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC - if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') - return true; // Windows drive (C:\, C:/, C:foo, C:) - return false; -} - -// --------------------------------------------------------------------------- -// BankIndex -// --------------------------------------------------------------------------- - -AddResult BankIndex::add(const Sample& sample) { - if (sample.id.empty()) return AddResult::RejectedEmptyId; - if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath; - - if (findByHash(sample.contentHash) != nullptr) - return AddResult::Collapsed; - - samples_.push_back(sample); - return AddResult::Added; -} - -bool BankIndex::remove(const std::string& id) { - for (auto it = samples_.begin(); it != samples_.end(); ++it) { - if (it->id == id) { - samples_.erase(it); - return true; - } - } - return false; -} - -bool BankIndex::updateInPlace(const std::string& id, const Sample& updated) { - if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds - for (auto& s : samples_) { - if (s.id == id) { - s = updated; // replace in place — position (insertion order) preserved - return true; - } - } - return false; -} - -const Sample* BankIndex::query(const std::string& id) const { - for (const auto& s : samples_) - if (s.id == id) return &s; - return nullptr; -} - -const Sample* BankIndex::findByHash(const std::string& contentHash) const { - if (contentHash.empty()) return nullptr; // empty hashes never dedup - for (const auto& s : samples_) - if (s.contentHash == contentHash) return &s; - return nullptr; -} - -bool BankIndex::moveTier(const std::string& id, Tier tier) { - for (auto& s : samples_) { - if (s.id == id) { - s.tier = tier; - return true; - } - } - return false; -} - -std::vector BankIndex::byTier(Tier tier) const { - std::vector out; - for (const auto& s : samples_) - if (s.tier == tier) out.push_back(s); - return out; -} - -// --------------------------------------------------------------------------- -// JSON writer -// --------------------------------------------------------------------------- - -namespace { - -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -std::string numToStr(double v) { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%.17g", v); - return buf; -} - -std::string numToStr(std::int64_t v) { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%lld", static_cast(v)); - return buf; -} - -std::string numToStr(int v) { return numToStr(static_cast(v)); } - -class ObjWriter { -public: - explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } - ~ObjWriter() { out_ += '}'; } - - void keyRaw(const char* key, const std::string& rawValue) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - out_ += rawValue; - } - void keyStr(const char* key, const std::string& value) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - writeEscaped(out_, value); - } - // Begin a nested value; caller writes the value immediately after. - void keyBegin(const char* key) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - } - -private: - void sep() { - if (first_) first_ = false; else out_ += ','; - } - std::string& out_; - bool first_ = true; -}; - -void writeStringArray(std::string& out, const std::vector& v) { - out += '['; - for (std::size_t i = 0; i < v.size(); ++i) { - if (i) out += ','; - writeEscaped(out, v[i]); - } - out += ']'; -} - -void writeSample(std::string& out, const Sample& s) { - ObjWriter w(out); - w.keyStr("id", s.id); - w.keyStr("displayName", s.displayName); - w.keyStr("relativePath", s.relativePath); - w.keyRaw("sourceMode", numToStr(static_cast(s.sourceMode))); - - w.keyBegin("sourceRange"); - { - ObjWriter r(out); - r.keyRaw("startSeconds", numToStr(s.sourceRange.startSeconds)); - r.keyRaw("endSeconds", numToStr(s.sourceRange.endSeconds)); - r.keyRaw("startPpq", numToStr(s.sourceRange.startPpq)); - r.keyRaw("endPpq", numToStr(s.sourceRange.endPpq)); - } - - w.keyBegin("trackGuids"); - writeStringArray(out, s.trackGuids); - - w.keyRaw("wetDry", numToStr(s.wetDry)); - w.keyRaw("channelCount", numToStr(s.channelCount)); - w.keyRaw("sampleRate", numToStr(s.sampleRate)); - w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds)); - w.keyRaw("lengthBeats", numToStr(s.lengthBeats)); - w.keyRaw("captureTempo", numToStr(s.captureTempo)); - w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum)); - w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom)); - - // Optionals are emitted as null when absent so present/absent round-trips. - w.keyBegin("key"); - if (s.key) writeEscaped(out, *s.key); else out += "null"; - - // Phase S seam fields (D-B). Emitted as null when absent (same shape as `key` - // and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely — - // parses to empty optionals and re-serializes without invention. - w.keyBegin("rootNote"); - if (s.rootNote) out += numToStr(*s.rootNote); else out += "null"; - - w.keyBegin("loop"); - if (s.loop) { - ObjWriter lp(out); - lp.keyRaw("start", numToStr(s.loop->start)); - lp.keyRaw("end", numToStr(s.loop->end)); - } else { - out += "null"; - } - - w.keyBegin("levels"); - { - ObjWriter l(out); - l.keyRaw("peakDb", numToStr(s.levels.peakDb)); - l.keyRaw("rmsDb", numToStr(s.levels.rmsDb)); - l.keyRaw("lufs", numToStr(s.levels.lufs)); - } - - w.keyRaw("clipped", s.clipped ? "true" : "false"); - w.keyRaw("tier", numToStr(static_cast(s.tier))); - w.keyStr("contentHash", s.contentHash); - - w.keyBegin("provenance"); - if (s.provenance) { - ObjWriter p(out); - p.keyStr("parentSampleId", s.provenance->parentSampleId); - p.keyStr("fxChainSnapshot", s.provenance->fxChainSnapshot); - } else { - out += "null"; - } - - w.keyRaw("createdTimestamp", numToStr(s.createdTimestamp)); -} - -} // namespace - -std::string BankIndex::serialize() const { - std::string out; - { - ObjWriter root(out); - root.keyRaw("version", numToStr(1)); - root.keyBegin("samples"); - out += '['; - for (std::size_t i = 0; i < samples_.size(); ++i) { - if (i) out += ','; - writeSample(out, samples_[i]); - } - out += ']'; - } // root closes the object here — not deferred to function return (NRVO would - // otherwise let the caller observe `out` before the closing brace is appended) - return out; -} - -// --------------------------------------------------------------------------- -// JSON parser (recursive descent). Returns false on any malformed input; never -// reads out of bounds. Only supports the subset our writer emits. -// --------------------------------------------------------------------------- - -namespace { - -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - - bool parseIndex(BankIndex& out); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - char peek() const { return s_[pos_]; } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - - bool parseString(std::string& out); - bool parseRawScalar(std::string& out); // number / true / false / null token - bool parseDouble(double& out); - bool parseInt64(std::int64_t& out); - bool parseInt(int& out); - bool parseBool(bool& out); - bool expectNullOr(bool& wasNull); // peeks for `null`; consumes if present - - bool parseSample(Sample& out); - bool parseKey(std::string& key); // an object member key + ':' - bool skipValue(); // for forward-compat unknown keys -}; - -// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX -// for control chars). Positioned at the opening quote after whitespace. -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - // Decode a \uXXXX escape to its code point. - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - - unsigned int hi = 0; - if (!readHex4(hi)) return false; - - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - // High surrogate — must be followed by \uDC00–\uDFFF. - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; // unpaired low surrogate — malformed - } - - // Encode codePoint as UTF-8. - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated string -} - -// Reads a bare token (number, true, false, null) up to the next structural char. -bool Parser::parseRawScalar(std::string& out) { - skipWs(); - std::size_t start = pos_; - while (!eof()) { - char c = s_[pos_]; - if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || - c == '\n' || c == '\r') - break; - ++pos_; - } - if (pos_ == start) return false; - out.assign(s_, start, pos_ - start); - return true; -} - -bool Parser::parseDouble(double& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - const char* b = tok.c_str(); - char* end = nullptr; - errno = 0; - double v = std::strtod(b, &end); - if (end != b + tok.size()) return false; - if (errno == ERANGE) return false; // overflow / underflow → malformed - out = v; - return true; -} - -bool Parser::parseInt64(std::int64_t& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - const char* b = tok.c_str(); - char* end = nullptr; - errno = 0; - long long v = std::strtoll(b, &end, 10); - if (end != b + tok.size()) return false; - if (errno == ERANGE) return false; // overflow → malformed - out = static_cast(v); - return true; -} - -bool Parser::parseInt(int& out) { - std::int64_t v = 0; - if (!parseInt64(v)) return false; - out = static_cast(v); - return true; -} - -bool Parser::parseBool(bool& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - if (tok == "true") { out = true; return true; } - if (tok == "false") { out = false; return true; } - return false; -} - -// If the next value is the `null` token, consumes it and sets wasNull=true. -// Otherwise leaves the position untouched and sets wasNull=false. Returns false -// only on eof. -bool Parser::expectNullOr(bool& wasNull) { - skipWs(); - if (eof()) return false; - if (s_.compare(pos_, 4, "null") == 0) { - pos_ += 4; - wasNull = true; - } else { - wasNull = false; - } - return true; -} - -bool Parser::parseKey(std::string& key) { - if (!parseString(key)) return false; - if (!consume(':')) return false; - return true; -} - -// Skips one JSON value (object / array / string / scalar) for forward-compat -// with keys we don't recognize. Assumes position is at the start of the value. -bool Parser::skipValue() { - skipWs(); - if (eof()) return false; - char c = s_[pos_]; - if (c == '"') { - std::string tmp; - return parseString(tmp); - } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - ++pos_; - int depth = 1; - while (!eof() && depth > 0) { - char d = s_[pos_]; - if (d == '"') { - std::string tmp; - if (!parseString(tmp)) return false; - continue; - } - if (d == open) ++depth; - else if (d == close) --depth; - ++pos_; - } - return depth == 0; - } - std::string tmp; - return parseRawScalar(tmp); -} - -bool Parser::parseSample(Sample& s) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // empty object (shouldn't happen, but valid) - - do { - std::string key; - if (!parseKey(key)) return false; - - if (key == "id") { - if (!parseString(s.id)) return false; - } else if (key == "displayName") { - if (!parseString(s.displayName)) return false; - } else if (key == "relativePath") { - if (!parseString(s.relativePath)) return false; - } else if (key == "sourceMode") { - int v = 0; - if (!parseInt(v)) return false; - // Valid range: MasterMix(0) .. Realtime(5). - if (v < static_cast(SourceMode::MasterMix) || - v > static_cast(SourceMode::Realtime)) - return false; - s.sourceMode = static_cast(v); - } else if (key == "sourceRange") { - if (!consume('{')) return false; - do { - std::string rk; - if (!parseKey(rk)) return false; - double dv = 0.0; - if (!parseDouble(dv)) return false; - if (rk == "startSeconds") s.sourceRange.startSeconds = dv; - else if (rk == "endSeconds") s.sourceRange.endSeconds = dv; - else if (rk == "startPpq") s.sourceRange.startPpq = dv; - else if (rk == "endPpq") s.sourceRange.endPpq = dv; - } while (consume(',')); - if (!consume('}')) return false; - } else if (key == "trackGuids") { - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { - do { - std::string g; - if (!parseString(g)) return false; - s.trackGuids.push_back(g); - } while (consume(',')); - if (!consume(']')) return false; - } - } else if (key == "wetDry") { - if (!parseDouble(s.wetDry)) return false; - } else if (key == "channelCount") { - if (!parseInt(s.channelCount)) return false; - } else if (key == "sampleRate") { - if (!parseInt(s.sampleRate)) return false; - } else if (key == "lengthSeconds") { - if (!parseDouble(s.lengthSeconds)) return false; - } else if (key == "lengthBeats") { - if (!parseDouble(s.lengthBeats)) return false; - } else if (key == "captureTempo") { - if (!parseDouble(s.captureTempo)) return false; - } else if (key == "captureTimeSigNum") { - if (!parseInt(s.captureTimeSigNum)) return false; - } else if (key == "captureTimeSigDenom") { - if (!parseInt(s.captureTimeSigDenom)) return false; - } else if (key == "key") { - bool wasNull = false; - if (!expectNullOr(wasNull)) return false; - if (wasNull) { - s.key.reset(); - } else { - std::string k; - if (!parseString(k)) return false; - s.key = k; - } - } else if (key == "rootNote") { - bool wasNull = false; - if (!expectNullOr(wasNull)) return false; - if (wasNull) { - s.rootNote.reset(); - } else { - int v = 0; - if (!parseInt(v)) return false; - // Valid MIDI note range: 0..127 inclusive (boundaries valid). - if (v < 0 || v > 127) return false; - s.rootNote = v; - } - } else if (key == "loop") { - bool wasNull = false; - if (!expectNullOr(wasNull)) return false; - if (wasNull) { - s.loop.reset(); - } else { - if (!consume('{')) return false; - LoopPoints lp; - do { - std::string lk; - if (!parseKey(lk)) return false; - std::int64_t lv = 0; - if (!parseInt64(lv)) return false; - if (lk == "start") lp.start = lv; - else if (lk == "end") lp.end = lv; - } while (consume(',')); - if (!consume('}')) return false; - // Invariant: 0 <= start <= end. start == end is a valid zero-length - // marker; a negative index or start > end is malformed, not silently - // clamped (mirrors the enum-range rejection above). - if (lp.start < 0 || lp.end < lp.start) return false; - s.loop = lp; - } - } else if (key == "levels") { - if (!consume('{')) return false; - do { - std::string lk; - if (!parseKey(lk)) return false; - double dv = 0.0; - if (!parseDouble(dv)) return false; - if (lk == "peakDb") s.levels.peakDb = dv; - else if (lk == "rmsDb") s.levels.rmsDb = dv; - else if (lk == "lufs") s.levels.lufs = dv; - } while (consume(',')); - if (!consume('}')) return false; - } else if (key == "clipped") { - if (!parseBool(s.clipped)) return false; - } else if (key == "tier") { - int v = 0; - if (!parseInt(v)) return false; - // Valid range: Scratch(0) .. Archive(1). - if (v < static_cast(Tier::Scratch) || - v > static_cast(Tier::Archive)) - return false; - s.tier = static_cast(v); - } else if (key == "contentHash") { - if (!parseString(s.contentHash)) return false; - } else if (key == "provenance") { - bool wasNull = false; - if (!expectNullOr(wasNull)) return false; - if (wasNull) { - s.provenance.reset(); - } else { - if (!consume('{')) return false; - Provenance p; - do { - std::string pk; - if (!parseKey(pk)) return false; - std::string pv; - if (!parseString(pv)) return false; - if (pk == "parentSampleId") p.parentSampleId = pv; - else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv; - } while (consume(',')); - if (!consume('}')) return false; - s.provenance = p; - } - } else if (key == "createdTimestamp") { - if (!parseInt64(s.createdTimestamp)) return false; - } else { - if (!skipValue()) return false; // forward-compat: ignore unknown - } - } while (consume(',')); - - return consume('}'); -} - -bool Parser::parseIndex(BankIndex& out) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // empty object — vacuously an empty index - - std::vector parsed; - do { - std::string key; - if (!parseKey(key)) return false; - - if (key == "samples") { - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { - do { - Sample s; - if (!parseSample(s)) return false; - parsed.push_back(std::move(s)); - } while (consume(',')); - if (!consume(']')) return false; - } - } else { - if (!skipValue()) return false; // version, or unknown keys - } - } while (consume(',')); - - if (!consume('}')) return false; - - // Trailing garbage after the root object is malformed. - skipWs(); - if (!eof()) return false; - - // Rebuild via add() so the same invariants (relative-path, dedup) that guard - // live inserts also guard deserialized data. Rejected/collapsed entries are - // dropped silently — a well-formed serialized index never triggers them. - for (auto& s : parsed) out.add(s); - return true; -} - -} // namespace - -std::optional BankIndex::deserialize(const std::string& json) { - BankIndex idx; - Parser p(json); - if (!p.parseIndex(idx)) return std::nullopt; - return idx; -} - -} // namespace reasampler diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp deleted file mode 100644 index 5a0b618..0000000 --- a/src/bank_panel.cpp +++ /dev/null @@ -1,3459 +0,0 @@ -// bank_panel.cpp — REAPER-facing docked grid (M5 Wave A/B + Phase B4). See -// bank_panel.h. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). -// -// What this file owns (all REAPER/SWELL/LICE-bound, hence DAW-verified, not unit -// tested): -// * a SWELL dialog (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via -// DockWindowRemove; toggled open/closed. -// * WM_PAINT: a VERTICAL SPLIT (Phase B4) — the pool grid region on top, a -// LICE-drawn named-banks tab-page region below (one tab per named bank, an -// overflow/scroll strip), and two full-height toggles that collapse the split. -// Each region reuses the M5 grid render loop (waveform thumbnails / empty state). -// * per-sample PCM read via PCM_source fed to peaks::computeEnvelope, one bin per -// drawn pixel column; drawWaveform's gap-free render comes from peaks::columnMinMax. -// * an in-memory thumbnail cache keyed by (sample id, draw width, bank generation). -// * id-keyed bank management (create / rename / delete / evacuate / activate) and -// sample move/copy — driven from a tab context menu and a drag — against the B1 -// BankBook model on g_session.book(), persisted via g_session.saveToActiveProject(). -// -// READ-ONLY of the TIMELINE (load-bearing principle): this panel never inserts into -// the arrange. It DOES mutate the bank BOOK (create/rename/move/etc.) — that is the -// whole point of B4 — but only the index/model + ext-state, never the arrange, never -// a sample file on disk (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank). -// -// THE PURE SEAMS: grid tiling / hit-test / selection math live in bank_grid; the -// mode-switch geometry in mode_switch; the named-banks TAB-STRIP layout, overflow/ -// scroll, and hit-test in tab_strip. All three are unit-tested outside the DAW; only -// draw + input routing + the model calls live here. -// -// REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural -// mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, -// so a BankIndex& / Bank* must NEVER be cached across one. Every handler below -// resolves fresh AFTER any mutation and passes bank IDS (not references) into the -// model ops. - -#include "bank_panel.h" - -#include -#include // std::abs (drag threshold) -#include -#include -#include -#include -#include -#include - -#include "action_bar.h" // pure TASK-GROUPED action-bar layout + hit-test (L2) -#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) -#include "drag_out.h" // pure gesture-boundary decision + path-list assembly (M11) -#include "drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) -#include "app_version.h" // channelCommandId — compose the named-command lookup string (M11) -#include "bank_book.h" -#include "bank_grid.h" -#include "bank_model.h" -#include "card_drag.h" // L7 pure gesture precedence + sparse slot layout/hit-test -#include "card_meta.h" // L7 decorative overlay formatters: bars.beats + s.ms (pure) -#include "capture_paths.h" -#include "component_geometry.h" // KitBox — the kit text()'s draw box (L1) -#include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1) -#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4) -#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) -#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest -#include "instrument_drop.h" // pure buildInstrumentDropPreset — the .vstpreset payload (S17) -#include "instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17) -#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) -#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) -#include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5) -#include "mode_switch.h" -#include "overflow_menu.h" // top-toolbar More-button geometry + reserve (pure, L5) -#include "peaks.h" -#include "persist.h" -#include "prune_button.h" // footer prune-button layout + hit-test (pure, R3) -#include "tooltip.h" // tooltip placement + prefix-strip (pure, L5) -#include "render_settings.h" // captureActionTable — the table-driven button rows (M11) -#include "tab_strip.h" -#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) -#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) -#include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires -#include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2) - -// SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP); -// on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32). -#ifdef _WIN32 -#include -#include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) -#include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest -#else -#include -#endif -#include "wdltypes.h" -#include "swell/swell.h" -#include "lice/lice.h" - -#include "resource.h" - -#include "reaper_plugin.h" - -#define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_DockWindowAddEx -#define REAPERAPI_WANT_DockWindowActivate -#define REAPERAPI_WANT_DockWindowRemove -#define REAPERAPI_WANT_EnumProjects -#define REAPERAPI_WANT_MarkProjectDirty // mark dirty when the tail toggle changes (saves with the project) -#define REAPERAPI_WANT_GetMainHwnd -#define REAPERAPI_WANT_PCM_Source_CreateFromFile -#define REAPERAPI_WANT_PCM_Source_Destroy -// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane -// state to classify an item's lane as managed vs manual. -#define REAPERAPI_WANT_CountTracks -#define REAPERAPI_WANT_GetTrack -#define REAPERAPI_WANT_GetMediaTrackInfo_Value -#define REAPERAPI_WANT_CountTrackMediaItems -#define REAPERAPI_WANT_GetTrackMediaItem -// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h): -// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the -// STOCK symbols (not SWS-only) — see the audition section below. -#define REAPERAPI_WANT_PlayPreview -#define REAPERAPI_WANT_StopPreview -#define REAPERAPI_WANT_GetUserInputs -#define REAPERAPI_WANT_ShowMessageBox -#define REAPERAPI_WANT_Main_OnCommand // fire the prune action by command id (R3 button) -#define REAPERAPI_WANT_genGuid -#define REAPERAPI_WANT_guidToString -// Action-trigger buttons (M11): resolve each button's command id at runtime from the -// composed named-command string, fire it through the existing action contract, and read -// its current key binding for the reminder label. All main-section (SectionFromUniqueID(0)). -#define REAPERAPI_WANT_NamedCommandLookup -#define REAPERAPI_WANT_Main_OnCommand -#define REAPERAPI_WANT_kbd_getTextFromCmd -#define REAPERAPI_WANT_SectionFromUniqueID -#include "reaper_plugin_functions.h" - -// main.cpp owns the module instance handle and REAPER's dispatch struct. -extern REAPER_PLUGIN_HINSTANCE g_hInst; -extern reaper_plugin_info_t* g_rec; - -namespace reasampler { - -namespace { - -namespace fs = std::filesystem; - -// --- Layout constants --------------------------------------------------------- -// -// L2: every panel COLOR now comes from the pure `theme` module by ROLE (drawn through the L1 -// kit — fillSurface / drawButton / kit text). The former flat LICE_RGBA / RGB palette blocks -// are retired; only the pixel LAYOUT metrics (band heights, grid/tab specs, insets) live here. - -const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; - -constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) - -// --- Footer (Phase L, L4) ----------------------------------------------------- -// The footer now carries a task-cluster of small persistent controls: the narrowed -// [Arrange|Design] mode toggle, a compact per-mode count, the Tail BUTTON (L4 §4 — -// a real kit button, no longer a click-zone), and the set-apart Prune button at the -// right. Taller than the L2 footer to host the toggle segments + button chrome cleanly. -// Layout is the pure footer_bar (left group) + prune_button (right); this is the band height. -constexpr int kFooterHeight = 30; - -// --- Toolbars (Phase L, L4) --------------------------------------------------- -// TWO task-grouped toolbars, both drawn through the pure action_bar module: -// * kTopToolbarHeight — the TOP toolbar (capture + placement clusters) at the very top of -// the client, where the eye lands (L4 §1). Replaces the L2 mode-switch header there. -// * kBottomToolbarHeight — the BOTTOM toolbar (Design-View tag/switch verbs) directly above -// the footer (L4 §2). This is the L2 action-bar band, repurposed. -// The kBarSpec metrics the bars consume live near the draw below; only heights live here. -constexpr int kTopToolbarHeight = 28; // single-row label face (L6: keybinding sub-row removed) -constexpr int kBottomToolbarHeight = 28; // same shape — both bars consistent - -// --- Tooltip (Phase L, L5) ---------------------------------------------------- -// The custom hover-delay tooltip's timing + approximate text metrics. The delay matches the -// platform convention (~0.5 s) so the tooltip is deliberate, not twitchy; it is driven off the -// OnTimer poll (bankPanelRefresh) + WM_MOUSEMOVE, so no dedicated timer is added. The kit font -// is AA and proportional, so the width is estimated from a per-char average (the tooltip box is -// generous — a slight over/under-estimate only pads the box, never clips the text). -constexpr unsigned int kTooltipDelayMs = 500; -constexpr int kTooltipCharPx = 7; // approx px per char at Font::Label (generous) -constexpr int kTooltipTextH = 14; // approx line height at Font::Label - -// --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) ------- -// -// The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) | -// split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer -// (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool -// region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER -// band: a title, the active-bank readout, and a full-height toggle button. The named-banks -// region's header ALSO hosts the LICE tab strip and a "+" create button. -constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band -constexpr int kTabStripHeight = 26; // the named-banks tab strip band -constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions -constexpr int kFullHtBtnWidth = 22; // the square full-height toggle button -constexpr int kCreateBtnWidth = 22; // the "+" create-bank button - -// Tab strip metrics (the pure tab_strip owns the math; these are its inputs). -const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20}; - -// --- Panel state -------------------------------------------------------------- - -struct CachedThumbnail { - Envelope envelope; - int width = 0; -}; - -// Which of the two split regions currently owns the selection / receives keyboard -// input. The move/copy source is the focused region's displayed bank. -enum class Region { Pool, Banks }; - -// What a drag is dropping onto, resolved live under the pointer during a drag. -// BanksRegion fires when the pointer is anywhere in the named-banks grid that is NOT -// on a specific tab (tab takes precedence — more specific wins). The resolved bank is -// always shownBankId. -enum class DropKind { None, PoolRegion, Tab, BanksRegion }; - -// --- Hover model (Phase L, L2) ------------------------------------------------ -// -// The hovered interactive element, resolved live in WM_MOUSEMOVE so the kit draws its -// hover state on that element only (the "hover on every interactive element" + "sub-frame -// feedback = the perception of speed" L2 constraint). SWELL exposes no WM_MOUSELEAVE (grep -// of vendor/WDL/WDL/swell — none), so hover is cleared by a move that resolves to None -// rather than a leave message; the panel is Windows-only (D5) but this stays portable-safe. -// `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A. -enum class HoverKind { - None, - TopBarButton, // a button in the TOP toolbar (index = flat action index into topBarRows) - BottomBarButton, // a button in the BOTTOM toolbar (index = flat action index into bottomBarRows) - MoreButton, // the TOP toolbar's far-right "⋯" overflow-menu button (L5) - PruneButton, - FullHtPool, // pool region full-height toggle - FullHtBanks, // banks region full-height toggle - CreateBank, // the "+" create-bank button - Tab, // a named-bank tab (index = tab ordinal) - TailButton, // the footer Tail button (L4 §4 — a real button, was a click-zone) - ModeSegment, // a footer mode-toggle segment (index = segment ordinal) -}; - -struct Hover { - HoverKind kind = HoverKind::None; - int index = -1; - - bool operator==(const Hover& o) const { return kind == o.kind && index == o.index; } - bool operator!=(const Hover& o) const { return !(*this == o); } -}; - -// The kit interaction state for an interactive element: Hover when this (kind,index) is the -// live hovered element, else Rest. Active/Pressed are decided per-element by the caller (e.g. -// an active tab draws Active regardless of hover); this is the base rest/hover resolver. -InteractionState hoverState(const Hover& hovered, HoverKind kind, int index) { - return (hovered.kind == kind && hovered.index == index) ? InteractionState::Hover - : InteractionState::Rest; -} - -struct PanelState { - ReaSamplerSession* session = nullptr; - - HWND hwnd = nullptr; - bool open = false; - - std::string bankFingerprint; - std::uint64_t generation = 0; - - std::unordered_map cache; - - // --- Selection (per focused region) --------------------------------------- - // One live selection, scoped to `focusedRegion`. Switching regions moves the - // selection with the focus (a click in the other region reseeds it there). - Selection selection; - int selItemCount = 0; - Region focusedRegion = Region::Pool; - - // --- Hover (Phase L, L2) -------------------------------------------------- - // The live hovered interactive element (WM_MOUSEMOVE resolves it; the kit draws its - // hover state). Repaint fires only when this changes (sub-frame, no per-move jank). - Hover hovered; - - // --- Tooltip (Phase L, L5) ------------------------------------------------ - // A custom LICE-kit hover-delay tooltip (NOT the native Win32/SWELL tooltip control): when a - // TOOLTIP-capable element (a toolbar button) stays hovered past kTooltipDelayMs, the panel - // draws a small overlay carrying the full, prefix-stripped action name. hoverSinceTick is the - // GetTickCount() at which the CURRENT hovered element was first entered (reset on every hover - // change); tooltipShown latches once the delay elapses so the OnTimer poll repaints exactly - // once when the tooltip appears. The last-seen pointer pos anchors nothing (the anchor is the - // hovered button's rect), but is kept so the OnTimer path can re-resolve without a live event. - unsigned int hoverSinceTick = 0; - bool tooltipShown = false; - - // --- Vertical-split state ------------------------------------------------- - BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; - - // The named bank whose grid the banks region shows (the SHOWN tab) — DISTINCT - // from the active/capture-target bank (book().activeBankId()). Empty when there - // are no named banks. Reconciled each fingerprint pass so it always names a live - // named bank (or is empty). - std::string shownBankId; - - // Tab-strip horizontal scroll offset (px), clamped to the strip's max each frame. - int tabScroll = 0; - - // --- Drag (sample move between regions/onto a tab) ------------------------ - // A drag begins only after the pointer moves past a threshold from a press that - // landed on a SELECTED grid cell — this is how it is disambiguated from the M5 - // multi-select drag (which begins immediately on any grid press). See onLBtnDown/ - // onMouseMove. dragging is true once the threshold is crossed. - bool dragArmed = false; // pressed on a selected cell; watching for threshold - bool dragging = false; // threshold crossed; a move-drag is in progress - int dragStartX = 0, dragStartY = 0; - Region dragSourceRegion = Region::Pool; - std::string dragSourceBankId; // the bank the dragged samples come from - std::vector dragSampleIds;// snapshot of the selection at drag start - std::string dragPrimaryId; // the single card grabbed (the focus) — the L7 - // reorder/replace subject (see onLBtnUp dispatch) - DropKind dropKind = DropKind::None; // live drop target under the pointer - std::string dropBankId; // destination bank id when dropKind==Tab - - // --- L7 in-grid reorder/replace drag -------------------------------------- - // The live card gesture resolved by the pure card_drag::decideCardGesture each mouse- - // move (drives the cursor cue AND the drop dispatch), plus the same-bank target slot the - // pointer sits over (>= 0 only for a Reorder/Replace over the source bank's own grid; -1 - // otherwise). A Reorder highlights dragTargetSlot's cell; Replace + a live cursor cue - // signal the Alt-over-occupied case. Reset with the rest of the drag state on drop/cancel. - CardGesture cardGesture = CardGesture::None; - int dragTargetSlot = -1; - - // --- S17 drop-and-load (InstrumentDrop) ----------------------------------- - // While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an - // InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX - // hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the - // dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last - // resolved FX-hotspot track (null when the pointer is not over an FX button) — read on - // release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched. - MediaTrack* instrumentDropTrack = nullptr; - - // --- Tail-mode toggle ----------------------------------------------------- - // The authoritative tail setting now lives in ReaSamplerSession (session->tail()), - // NOT in panel state, so it travels inside the .rpp (persist serializes it on save, - // restores it on project load). The panel reads it for drawing and mutates it via - // the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the - // project dirty so the choice saves. bankPanelTailSetting is the read seam for the - // capture actions. Held here only through the session pointer above. - - // --- Audition preview ----------------------------------------------------- - preview_register_t preview{}; - PCM_source* previewSrc = nullptr; - bool previewActive = false; - bool previewInited = false; // guards double init / deinit - - // --- New-content detection (D2 Wave 2) ------------------------------------ - // - // Each timer tick diffs the live track+item GUID set against the previous tick to - // auto-tag content created SINCE the last tick into the then-active mode. The - // baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its - // first observe()) so pre-existing content is never mass-tagged (it stays Arrange). - // - // Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a - // pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact - // tick persist restores a project's membership + active mode (the same tick it - // reapplies the active mode); that sets reloadPending so the NEXT detect tick this - // same tick re-baselines against the fully-loaded set and reports nothing new. This - // replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than - // persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto - // a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then - // diffed against the previous project's stale baseline and were mass-tagged into the - // active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the - // two identity checks agree by construction. - // - // Lives for the extension's lifetime alongside the session, independent of panel - // open/close — detection must run whether or not the dock is visible (content is - // created in the arrange, not the panel). - GuidBaseline contentBaseline; - bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick -}; - -PanelState g_panel; - -void stopAudition(); - -// --- Current-project directory (mirrors persist.cpp's derivation) ------------- -std::string currentProjectDir() { - std::vector buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(buf.size())); - std::string rpp(buf.data()); - if (rpp.empty()) return {}; - return normalizeSlashes(fs::path(rpp).parent_path().string()); -} - -// --- Book / bank accessors ---------------------------------------------------- - -BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; } - -// The BankIndex a region currently displays. Pool region -> the pool; banks region -> -// the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved -// FRESH every call (never cached across a mutation). -const BankIndex* indexForRegion(Region r) { - BankBook* b = book(); - if (!b) return nullptr; - if (r == Region::Pool) return &b->pool().index; - if (g_panel.shownBankId.empty()) return nullptr; - return b->index(g_panel.shownBankId); -} - -// The bank id a region displays (pool id, or the shown tab's id; "" when none). -std::string bankIdForRegion(Region r) { - if (r == Region::Pool) return std::string(kPoolBankId); - return g_panel.shownBankId; -} - -// The named banks in ordinal order (pool excluded) — the tabs. Resolved fresh. -std::vector namedBanks() { - std::vector out; - BankBook* b = book(); - if (!b) return out; - for (const Bank& bk : b->banks()) - if (!bk.isPool()) out.push_back(&bk); - return out; -} - -// --- Thumbnail computation (M5; `width` is a BIN count since FA3 oversampling) -- - -Envelope computeThumbnail(const std::string& absPath, int width) { - if (width <= 0 || absPath.empty()) return {}; - - PCM_source* src = PCM_Source_CreateFromFile(absPath.c_str()); - if (!src) return {}; - - const int nch = src->GetNumChannels(); - const double srate = src->GetSampleRate(); - const double lengthSec = src->GetLength(); - if (nch <= 0 || srate < 1.0 || lengthSec <= 0.0) { - PCM_Source_Destroy(src); - return {}; - } - - std::int64_t totalFrames = static_cast(lengthSec * srate); - if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; } - int frames = totalFrames > kMaxThumbnailFrames - ? kMaxThumbnailFrames - : static_cast(totalFrames); - - std::vector buf(static_cast(frames) * nch, 0.0); - PCM_source_transfer_t block{}; - block.time_s = 0.0; - block.samplerate = srate; - block.nch = nch; - block.length = frames; - block.samples = buf.data(); - block.samples_out = 0; - src->GetSamples(&block); - - PCM_Source_Destroy(src); - - const int got = block.samples_out; - if (got <= 0) return {}; - - const std::size_t sampleCount = static_cast(got) * nch; - std::vector pcm(sampleCount); - for (std::size_t i = 0; i < sampleCount; ++i) - pcm[i] = static_cast(buf[i]); - - // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with - // trailing empty {0,0} bins, which would render a very short sample as a comb of - // spikes over flat gaps. - const int binCount = width < got ? width : got; - return computeEnvelope(pcm, static_cast(nch), - static_cast(got), - static_cast(binCount)); -} - -const Envelope& thumbnailFor(const Sample& sample, int width, - const std::string& projectDir) { - ThumbnailKey key{sample.id, width, g_panel.generation}; - const std::string ks = thumbnailKeyString(key); - - auto it = g_panel.cache.find(ks); - if (it != g_panel.cache.end()) return it->second.envelope; - - const std::string abs = resolveBankFile(projectDir, sample.relativePath); - CachedThumbnail thumb; - thumb.width = width; - thumb.envelope = computeThumbnail(abs, width); - auto ins = g_panel.cache.emplace(ks, std::move(thumb)); - return ins.first->second.envelope; -} - -// --- Drawing: thumbnails (via the kit's shared drawWaveform since FA3) --------- - -// Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT -// (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT -// (wall-clock). Decorative + non-interactive (no hit-test, no hover). Drawn in the kit's -// Micro / ValueMono classes in text/dim, subordinate to the waveform. A blank musical -// read-out (unstamped meter / unknown tempo) simply omits the bottom-left string. -void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) { - MusicalLength ml; - ml.lengthSeconds = s.lengthSeconds; - ml.tempoBpm = s.captureTempo; - ml.timeSigNum = s.captureTimeSigNum; - ml.timeSigDenom = s.captureTimeSigDenom; - const std::string bars = formatBarsBeats(ml); // "" when unstamped/no-tempo - const std::string secs = formatSecondsMs(s.lengthSeconds); - - // A short strip along the card's bottom edge. Left/right halves; text/dim so the - // waveform stays the centerpiece. Micro on the left (musical), ValueMono on the right - // (tabular numbers that must not jitter). - const int stripH = 12; - const int pad = 3; - const int y = rect.y + rect.height - stripH; - if (!bars.empty()) { - const KitBox left{rect.x + pad, y, rect.width / 2 - pad, stripH}; - text(bmp, left, bars.c_str(), Font::Micro, Role::TextDim, Align::Left); - } - const KitBox right{rect.x + rect.width / 2, y, rect.width / 2 - pad, stripH}; - text(bmp, right, secs.c_str(), Font::ValueMono, Role::TextDim, Align::Right); -} - -void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, - bool selected, bool focused, bool hovered, const Sample* sample) { - // Cell surface through the kit (L7 selection restyle): a selected card draws the NORMAL - // cell surface (Rest, or Hover when hovered) — NOT the inverted accent-fill. Selection is - // marked purely by an accent/tertiary (pastel purple) border below; hover stays a fill- - // state change orthogonal to that border, so a hovered selected card still reads selected. - const KitBox cell{rect.x, rect.y, rect.width, rect.height}; - const InteractionState state = hovered ? InteractionState::Hover : InteractionState::Rest; - fillSurface(bmp, cell, Role::BgCell, state); - - // Border (L7): accent/TERTIARY purple when selected (the sole selection signal), else - // hairline. Focus is a distinct text/primary inner ring so a focused-AND-selected card - // reads BOTH — the purple outer border + the inner focus ring — kept visually separate. - const KitColor border = selected ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0); - if (focused) { - const LICE_pixel ring = toLice(roleColor(Role::TextPrimary)); - LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0); - } - - // Waveform plot through the kit's shared primitive (FA3): the SAME per-pixel-column - // min/max envelope draw the VST editor hero + browser cards use — one algorithm, one - // look, everywhere. The oversampled env (see drawRegionGrid's binWidth) collapses per - // column via peaks::columnMinMax inside the kit; an empty env draws just the midline. - drawWaveform(bmp, cell, env); - - // L7 decorative metadata overlay, drawn last so it sits over the waveform. - if (sample) drawCardMeta(bmp, rect, *sample); -} - -// --- Kit draw adapters (Phase L) ---------------------------------------------- -// -// All panel text draws through the kit's cached AA font (draw_kit::text), NOT raw GDI DrawText -// (retired at L1). L2 re-roles every color through the pure `theme` module and draws surfaces -// via the kit (fillSurface / drawButton). These thin adapters bridge the panel's RECT-based -// geometry helpers to the kit's KitBox and give the panel a KitColor->LICE_pixel boundary for -// the few raw borders it still draws over kit surfaces. The kit owns the font lifecycle -// (kitFontsInit/Shutdown, wired at panel open/close below). - -KitBox toKitBox(const RECT& r) { - return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top}; -} - -// KitColor -> LICE_pixel: all sites use the kit's toLice() from draw_kit.h — the single -// conversion boundary the kit enforces. No local alias needed. - -// L2 role/font-aware text: draws through the kit in a palette ROLE color and a chosen kit -// Font (the action bar uses Micro for the keybinding sub-label, Label for the name, Title for -// region headings). Takes a KitBox directly (the pure geometry the L2 modules return). -void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt, - Font font, Role role, Align align) { - text(bmp, box, txt, font, role, align); -} - -// --- Mode toggle (D5; relocated to the footer at L4) -------------------------- - -int modeCount() { - if (!g_panel.session) return 0; - return static_cast(g_panel.session->view().modes().size()); -} - -// --- Top toolbar band (L4; L5 overflow-menu reserve) -------------------------- -// -// The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate -// (height 0) when the client is too short to host it above the split body. The WHOLE band -// (topToolbarRect) is what the far-right More button anchors into; the action_bar's frequent -// buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run -// under the menu button (L5 refinement 1). - -// The spec for the far-right More ("⋯") overflow-menu button. One source of truth for its -// geometry + the reserve the action_bar leaves for it. -const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6, - /*verticalInset=*/3, /*minLeftInset=*/40}; - -ActionBarRect topToolbarRect(int w) { - ActionBarRect s; - s.x = 0; - s.y = 0; - s.width = w; - s.height = kTopToolbarHeight; - return s; -} - -// The band the More button occupies (the whole top toolbar band as a MenuBarRect). -MenuBarRect topMenuBarRect(int w) { - const ActionBarRect bar = topToolbarRect(w); - return MenuBarRect{bar.x, bar.y, bar.width, bar.height}; -} - -// The More button's rect (right-anchored in the top band). Empty when the band is too narrow -// to place it clear of its left inset — the three variants stay reachable via their bindable -// commands (graceful suppression). -MenuButtonRect topMenuButtonRect(int w) { - return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec); -} - -// The rect the TOP toolbar's action_bar tiles into: the whole band MINUS the reserve for the -// far-right More button, so the frequent buttons never overlap it. When the More button is -// suppressed (band too narrow) the reserve is still subtracted (the reserve is 0 only for a -// degenerate band), which keeps draw and hit-test consistent whether or not the button shows. -ActionBarRect topToolbarActionRect(int w) { - ActionBarRect bar = topToolbarRect(w); - const int reserve = menuButtonReserve(topMenuBarRect(w), kMenuBtnSpec); - bar.width -= reserve; - if (bar.width < 0) bar.width = 0; - return bar; -} - -// --- Footer (L4) -------------------------------------------------------------- - -RECT panelFooter(int w, int h) { - RECT rc{}; - rc.left = 0; - rc.right = w; - rc.top = h - kFooterHeight; - rc.bottom = h; - // Keep the footer below the top toolbar; if the client is too short, collapse it. - if (rc.top < kTopToolbarHeight) rc.top = rc.bottom; - return rc; -} - -// The session's live tail setting (default None / 2 s when no session). Single read -// point so draw, wheel-adjust, and the capture read seam all agree on the source. -TailSetting currentTail() { - return g_panel.session ? g_panel.session->tail() : TailSetting{}; -} - -// The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client -// size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate. -FooterBarLayout footerBarLayoutFor(int w, int h) { - const RECT f = panelFooter(w, h); - if (f.top >= f.bottom) return FooterBarLayout{}; - const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; - return computeFooterBar(footer, FooterBarSpec{}); -} - -// The per-mode membership count that travels with the toggle (L4 §3): the number of leaves -// tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no -// session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.) -// A display-only tally over the model's public membership map — no model semantics duplicated. -int activeModeMemberCount() { - if (!g_panel.session) return 0; - const ViewModeModel& view = g_panel.session->view(); - const std::string& active = view.activeModeId(); - if (active.empty()) return 0; - int n = 0; - for (const auto& [guid, m] : view.membership().all()) - if (m.modeIds.count(active) != 0) ++n; - return n; -} - -// The prune button's rect within the footer, derived from the client size. SINGLE source -// of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate -// or too narrow to place the button clear of the footer-left group / version readout — the -// action stays reachable via its bindable command, so a suppressed button is graceful. Kept -// set apart at the RIGHT (footer_bar reserves the matching space at its right so the two -// groups never overlap). See prune_button.h §Placement contract. -ButtonRect pruneButtonRectFor(int w, int h) { - const RECT f = panelFooter(w, h); - if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button - const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; - return computePruneButton(footer, PruneButtonSpec{}); -} - -// Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design] -// toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and -// the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button -// set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it. -void drawFooter(LICE_IBitmap* bmp, int w, int h) { - const RECT f = panelFooter(w, h); - if (f.top >= f.bottom) return; - - // Footer band + hairline top divider (the base persistent-controls strip). - fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel, - InteractionState::Rest); - LICE_Line(bmp, f.left, f.top, f.right, f.top, - toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); - - const FooterBarLayout fb = footerBarLayoutFor(w, h); - - // [Arrange|Design] toggle — drawn as N mode_switch segments inside footer_bar's toggle box - // (the segment geometry stays owned by the pure mode_switch; footer_bar owns the box). The - // active mode's segment carries the accent; others hover-or-rest bg/cell. - if (!fb.toggle.empty() && g_panel.session) { - const ViewModeModel& view = g_panel.session->view(); - const std::vector& modes = view.modes().all(); - const int n = static_cast(modes.size()); - const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; - const std::vector segs = computeSegmentRects(th, n); - const std::string& activeId = view.activeModeId(); - for (int i = 0; i < static_cast(segs.size()); ++i) { - const SegmentRect& s = segs[static_cast(i)]; - const Mode& mode = modes[static_cast(i)]; - const bool active = mode.id == activeId; - const InteractionState state = - active ? InteractionState::Active - : hoverState(g_panel.hovered, HoverKind::ModeSegment, i); - fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state); - LICE_DrawRect(bmp, s.x, s.y, s.width, s.height, - toLice(roleColor(Role::LineHairline)), 1.0f, 0); - const Role tr = active ? Role::BgBase : Role::TextPrimary; - kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(), - Font::Label, tr, Align::Center); - } - } - - // Per-mode member count, a compact dim readout beside the toggle (L4 §3 — "the count - // travels with the toggle"). Passive text, not a control. - if (!fb.count.empty()) { - const int members = activeModeMemberCount(); - const std::string countLabel = - std::to_string(members) + (members == 1 ? " track" : " tracks"); - kitText(bmp, KitBox{fb.count.x, fb.count.y, fb.count.width, fb.count.height}, - countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center); - } - - // Tail BUTTON (L4 §4) — a real kit button with rest/hover states; its click cycles the - // tail mode exactly as the old click-zone did. Label is the pure tailToggleLabel. - if (!fb.tail.empty()) { - const InteractionState state = hoverState(g_panel.hovered, HoverKind::TailButton, -1); - const std::string label = tailToggleLabel(currentTail()); - const KitButtonBox box{KitBox{fb.tail.x, fb.tail.y, fb.tail.width, fb.tail.height}}; - drawButton(bmp, box, label.c_str(), state, /*warn=*/false); - } - - // Version/channel readout (Phase V, V3/V4), right-aligned, unobtrusive. appVersion() - // renders the configured version string on stable and that string plus "-beta" on beta, - // so a beta panel self-identifies. It sits inside the space footer_bar reserves at the - // right (rightReserve) and clears the prune button (prune_button::rightInset). Dim, - // passive identification (V3). - kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top}, - reasampler::appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right); - - // Prune button — set apart at the far RIGHT (the ONLY warn-colored, byte-deleting control), - // honoring hover. No-op when suppressed (footer too narrow). Order reads left (benign, - // frequent) -> right (destructive, rare) per the L4 footer contract. - const ButtonRect pb = pruneButtonRectFor(w, h); - if (!pb.empty()) { - const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1); - const KitButtonBox box{KitBox{pb.x, pb.y, pb.width, pb.height}}; - drawButton(bmp, box, "Prune", state, /*warn=*/true); - } -} - -// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the -// scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The -// Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar). -bool pointInFooter(int x, int y) { - if (!g_panel.hwnd) return false; - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top); - return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; -} - -// Commits the current tail setting to ext state and marks the active project dirty -// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only -// path that calls SetProjExtState for the tail key — calling it here closes the gap -// where toggle/scroll would dirty the project but the new value was never written. -// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h). -// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way. -// NON-DESTRUCTIVE: touches nothing in the bank/arrange. -void markTailDirty() { - if (g_panel.session) g_panel.session->saveToActiveProject(); - ReaProject* proj = EnumProjects(-1, nullptr, 0); - if (proj) MarkProjectDirty(proj); -} - -// === Task-grouped toolbars (Phase L, L2 + L4) ================================= -// -// L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin) -// across TWO toolbars, BOTH drawn through the pure action_bar module: -// * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the -// two acts the tool exists for (L4 §1); -// * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the -// footer, in the space capture/placement vacated (L4 §2). -// Each button is drawn with its action name (Font::Label) and live key binding on a Micro -// sub-row (the L2 contract). action_bar owns the cluster tiling, the label/binding sub-rects, -// the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK binding query -// + the NamedCommandLookup/Main_OnCommand dispatch live here. -// -// Each button resolves its command id at RUNTIME from the composed named-command string -// (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable -// and beta and adds NO second registration. A cmd of 0 (action not registered on this channel) -// draws Disabled and no-ops on click. L4 is layout-only: the SAME existing actions fire via the -// SAME contract — no re-wiring, no command-id changes, and capture never auto-inserts. - -// One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix -// at fire time — never a hardcoded numeric id), its terse on-button FACE label, its full action -// NAME for the hover tooltip (already prefix-stripped — the "ReaSampler:" display prefix is -// dropped at build), and the task cluster it belongs to. The order of a toolbar's row list IS -// the flat action index the pure action_bar slots carry, so each list is built cluster-by-cluster -// in its toolbar's cluster order. -// -// L5: the FACE stays short (shortLabel, sized to never overflow the button width); the FULL name -// (fullName) is the hover tooltip content. fullName is sourced from the SAME phrase the action -// was registered with (render_settings' descriptionPhrase for the capture scopes; the literal -// registered phrase otherwise) so the tooltip matches the Actions-list entry exactly — the -// "ReaSampler:" prefix is not stored here (the face/tooltip never show it, per L5 refinement 2). -struct ActionBarRow { - std::string suffix; - std::string shortLabel; - std::string fullName; - ActionCluster cluster = ActionCluster::Capture; - bool enabled = true; // L5: opposite-mode gate for the bottom-bar tag buttons; always true - // for the top bar (its actions are unconditional triggers). -}; - -// The TOP toolbar inventory (L6 refinement): the FREQUENT acts only — Capture (item / track) -// then Re-capture (Maintenance, set between the two capture verbs and the placement verbs) then -// Placement (insert / insert-conform). The FOUR RARE variants (Batch Items / Batch Razor / -// Capture RT / Cancel RT) are ALL in the far-right "⋯" overflow menu (overflowMenuRows) — -// same registered actions, same command-id contract, just a different home. Capture scopes come -// from captureActionTable() (render_settings, pure); the rest are the registered M11/M10/M8 -// commands. Built once per draw/click. Each row carries its full (prefix-stripped) action name -// for the hover tooltip. -std::vector topBarRows() { - std::vector rows; - // Capture cluster — the primary gesture, leftmost. Face is a terse "Capture Item/Track"; - // the tooltip carries the full descriptionPhrase the action was registered with. - for (const CaptureActionDef& def : captureActionTable()) { - std::string label = def.commandSuffix; - if (label == "CAPTURE_ITEM") label = "Capture Item"; - else if (label == "CAPTURE_TRACK") label = "Capture Track"; - rows.push_back({def.commandSuffix, label, def.descriptionPhrase, - ActionCluster::Capture, true}); - } - // Maintenance cluster — Re-capture from source (M10), placed BETWEEN the capture group and - // the placement group so its position reads "refine the last capture before placing it". - // Cancel RT lives in the overflow menu (both realtime verbs share that home — L6). - rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture", - "re-capture from source", ActionCluster::Maintenance, true}); - // Placement cluster — the second act (still a distinct on-demand act; no auto-insert). - rows.push_back({"INSERT_SELECTED", "Insert", - "insert selected sample at edit cursor", ActionCluster::Placement, true}); - rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", - "insert selected sample at edit cursor (conform to tempo)", - ActionCluster::Placement, true}); - return rows; -} - -// The TOP-toolbar OVERFLOW menu inventory (L6): four items pulled off the visible bar into the -// far-right "⋯" menu button's popup — the three rare batch/realtime capture variants plus -// Cancel RT (both realtime verbs share the menu home). Each fires the SAME existing registered -// command id via the SAME NamedCommandLookup/Main_OnCommand contract — no action changes. The -// fullName is the popup entry text (the terse shortLabel is unused for menu items; the popup has -// room for the full name). Batch entries first, then the two realtime verbs. -std::vector overflowMenuRows() { - return { - {"CAPTURE_BATCH_ITEMS", "Batch Items", - "batch capture selected items (one per item)", ActionCluster::Capture, true}, - {"CAPTURE_BATCH_RAZOR", "Batch Razor", - "batch capture razor areas (one per area)", ActionCluster::Capture, true}, - {"CAPTURE_TRACK_REALTIME", "Capture RT", - "capture selected track (realtime)", ActionCluster::Capture, true}, - {"CANCEL_REALTIME_CAPTURE", "Cancel RT", - "cancel realtime capture", ActionCluster::Maintenance, true}, - }; -} - -// The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for -// "which mode is active"). Empty when no session (every button then falls to fail-open live). -std::string activeModeIdOrEmpty() { - if (!g_panel.session) return {}; - return g_panel.session->view().activeModeId(); -} - -// The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons -// then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from -// actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; -// VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not -// paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag). -// The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the -// footer [Arrange|Design] toggle owns mode switching. -// -// OPPOSITE-MODE ENABLEMENT (L5): a tag button is LIVE only for the OPPOSITE of the active mode -// (you tag into the mode you are not in). The pure mode_enable::tagButtonEnabled decides it from -// the active mode id; Show Both is unconditional (not a tag target). enabled=false rows draw -// Disabled and no-op on click. The Item/Track axis is display-only here — both the Item and the -// Track button for a target share the target's enablement. -std::vector bottomBarRows() { - const std::string active = activeModeIdOrEmpty(); - const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange); - const bool designLive = tagButtonEnabled(active, TagTarget::Design); - - std::vector rows; - // Tagging cluster — the four Item/Track x Arrange/Design tag buttons. - rows.push_back({"VIEW_MOVE_ITEMS_ARRANGE", "Item: Arrange", - "move selected items -> Arrange", ActionCluster::Tagging, arrangeLive}); - rows.push_back({"VIEW_MOVE_ITEMS_DESIGN", "Item: Design", - "move selected items -> Design", ActionCluster::Tagging, designLive}); - rows.push_back({"VIEW_TAG_ARRANGE", "Track: Arrange", - "tag selected tracks -> Arrange", ActionCluster::Tagging, arrangeLive}); - rows.push_back({"VIEW_TAG_DESIGN", "Track: Design", - "tag selected tracks -> Design", ActionCluster::Tagging, designLive}); - // Switching cluster — Show Both, set apart (the only survivor of the old switching group). - rows.push_back({"VIEW_SHOW_BOTH", "Show Both", - "show both for selected tracks", ActionCluster::Switching, true}); - return rows; -} - -// The cluster button-count specs for a given row set, in the row list's cluster order (so the -// pure action_bar's flat index lines up with the row list). Handles all five cluster kinds; -// empty clusters contribute a 0-count spec (action_bar skips them, emitting no gap). The spec -// order follows each toolbar's fixed layout order (top: Capture, Maintenance, Placement — -// Re-capture sits between the two capture verbs and the placement verbs; bottom: Tagging, -// Switching). The bottom bar's Maintenance count is 0, so the order change is transparent there. -std::vector actionBarClusters(const std::vector& rows) { - int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0; - for (const ActionBarRow& r : rows) { - switch (r.cluster) { - case ActionCluster::Capture: ++nCap; break; - case ActionCluster::Placement: ++nPlace; break; - case ActionCluster::Maintenance: ++nMaint; break; - case ActionCluster::Tagging: ++nTag; break; - case ActionCluster::Switching: ++nSwitch; break; - } - } - return { - {ActionCluster::Capture, nCap}, - {ActionCluster::Maintenance, nMaint}, - {ActionCluster::Placement, nPlace}, - {ActionCluster::Tagging, nTag}, - {ActionCluster::Switching, nSwitch}, - }; -} - -// The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared -// by both toolbars' draw and hit-test (identical button shape top and bottom). L5 refinement 5: -// clusterGap widened 16 -> 24 (a 6:1 inter/intra ratio) so semantic groups read AS groups. L6: -// bindingHeight / minSplitHeight removed — buttons are single-row label-only faces now. -const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24, - /*sidePad=*/8, /*verticalInset=*/3}; - -// The BOTTOM toolbar band: a fixed-height band directly above the footer (below the split -// body). Degenerate (height 0) when the client is too short to host it above the footer. -ActionBarRect bottomToolbarRect(int w, int h) { - ActionBarRect s; - const RECT footer = panelFooter(w, h); - const int footerTop = (footer.top < footer.bottom) ? footer.top : h; - s.x = 0; - s.width = w; - s.height = kBottomToolbarHeight; - s.y = footerTop - kBottomToolbarHeight; - // Keep the bar below the top toolbar; if the client is too short, collapse it. - if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; } - return s; -} - -// Resolves a row's composed named command to its runtime command id (0 if not registered). -// The named-command lookup string is "_" + the channel-qualified id (REAPER's convention). -int resolveBarCommandId(const ActionBarRow& row) { - if (!NamedCommandLookup) return 0; - const std::string named = "_" + channelCommandId(row.suffix); - return NamedCommandLookup(named.c_str()); -} - -// The current key binding string for a command in the MAIN section, or "" (unbound / not -// registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)). -std::string barBindingText(int cmd) { - if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) { - const char* t = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0)); - if (t) return std::string(t); - } - return {}; -} - -// Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button -// as a kit drawButton (rest/hover/disabled) with the action short label on the single-row face. -// Overflow drops WHOLE trailing buttons (the pure layout returns only the buttons that fit), so -// nothing is drawn clipped. `hoverKind` selects which HoverKind this bar's buttons use -// (TopBarButton / BottomBarButton) so the two toolbars' hover states never cross. `topDivider` -// draws a hairline at the band's top edge (the bottom toolbar's elevation over the split body); -// the top toolbar draws it at its bottom edge instead. Key binding help is in the hover tooltip -// (L6), not on the button face — the face shows only shortLabel. -void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar, - const std::vector& rows, HoverKind hoverKind, bool topDivider) { - if (bar.height <= 0 || bar.width <= 0) return; - - const KitBox band{bar.x, bar.y, bar.width, bar.height}; - fillSurface(bmp, band, Role::BgPanel, InteractionState::Rest); - const int dividerY = topDivider ? bar.y : bar.y + bar.height - 1; - LICE_Line(bmp, bar.x, dividerY, bar.x + bar.width, dividerY, - toLice(roleColor(Role::LineHairline)), 0.5f, 0, false); - - const std::vector clusters = actionBarClusters(rows); - const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); - - for (const ActionBarSlot& s : slots) { - if (s.index < 0 || s.index >= static_cast(rows.size())) continue; - const ActionBarRow& row = rows[static_cast(s.index)]; - const int cmd = resolveBarCommandId(row); - - // State: Disabled when the action is not registered on this channel OR the row is gated - // off (L5 opposite-mode enablement — the tag buttons for the ACTIVE mode); else Hover - // when hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.) - InteractionState state = InteractionState::Rest; - if (cmd == 0 || !row.enabled) state = InteractionState::Disabled; - else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index) - state = InteractionState::Hover; - - // The button surface (drawButton draws the micro-gradient + rounded border + honors - // the state). The label is drawn separately so the text role tracks the state correctly; - // pass no label to drawButton. - const KitButtonBox box{KitBox{s.x, s.y, s.width, s.height}}; - drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); - - const Role textRole = - (state == InteractionState::Disabled) ? Role::TextDim : Role::TextPrimary; - const KitBox labelBox{s.labelX, s.labelY, s.labelW, s.labelH}; - kitText(bmp, labelBox, row.shortLabel.c_str(), Font::Label, textRole, Align::Center); - } -} - -// The flat action index under (x, y) in `bar` for the given row set, or -1 (miss). Pure hit-test. -int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows) { - if (bar.height <= 0) return -1; - return hitTestActionBar(x, y, bar, actionBarClusters(rows), kBarSpec); -} - -// Routes a click in a toolbar to the hit button's action, fired through the command-id contract -// (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click -// was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the -// caller stops before grid handling. `rows` is the toolbar's inventory. -bool handleToolbarClick(int x, int y, const ActionBarRect& bar, - const std::vector& rows) { - if (bar.height <= 0) return false; - const int hit = toolbarHit(x, y, bar, rows); - if (hit < 0) { - // Inside the band but in a gap / overflow dead-zone: claim it so it never falls through - // to the grid. Outside the band: not ours. - return y >= bar.y && y < bar.y + bar.height && - x >= bar.x && x < bar.x + bar.width; - } - const ActionBarRow& row = rows[static_cast(hit)]; - // A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the - // action and never falls through to the grid (a dead button reads as inert, not absent). - if (!row.enabled) return true; - const int cmd = resolveBarCommandId(row); - if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); - return true; -} - -// --- Top-toolbar overflow ("⋯" More) menu (L5 refinement 1) ------------------- -// -// The three rare capture variants live only in this popup. The button is drawn kit-style (rest/ -// hover) at the far right of the top band; a click opens a REAPER/host TrackPopupMenu listing the -// variants, each firing its existing registered command id via NamedCommandLookup/Main_OnCommand -// (the SAME contract the visible buttons use — no action changes). A transient OS menu is fine -// for panel-external chrome (brief §1); only the button geometry (overflow_menu) is pure. - -// Draws the far-right More button (rest/hover). No-op when suppressed (band too narrow). -void drawMoreButton(LICE_IBitmap* bmp, int w) { - const MenuButtonRect mb = topMenuButtonRect(w); - if (mb.empty()) return; - const InteractionState state = hoverState(g_panel.hovered, HoverKind::MoreButton, -1); - const KitButtonBox box{KitBox{mb.x, mb.y, mb.width, mb.height}}; - drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); - // The glyph: three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text - // path). Drawn as text so it picks up the kit font + AA. Reads as the conventional "More". - kitText(bmp, KitBox{mb.x, mb.y, mb.width, mb.height}, "...", - Font::Label, Role::TextPrimary, Align::Center); -} - -// Opens the More popup: defined later (after the menuAppend/menuSeparator helpers), forward- -// declared here so drawMoreButton's neighbours read together. The click site (handleClick) sits -// after the definition, so no forward-declaration is strictly required — this documents intent. -void showMoreMenu(); - -// --- Tooltip (L5 refinement 2) ------------------------------------------------ -// -// A custom hover-delay tooltip: the full, prefix-stripped action name of the hovered toolbar -// button. Resolves the hovered element to its (anchor rect, text); returns false when the current -// hover has no tooltip (grid / chrome / the More button — the More button's own popup is its -// affordance). The tooltip DRAW is below; timing (kTooltipDelayMs) is applied by the caller. - -// The full (prefix-stripped) tooltip text for the currently hovered toolbar button, plus its -// anchor rect. Returns false when the hover is not a tooltip-bearing toolbar button. -bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah) { - const Hover& hv = g_panel.hovered; - std::vector rows; - ActionBarRect bar{}; - if (hv.kind == HoverKind::TopBarButton) { - rows = topBarRows(); - bar = topToolbarActionRect(w); - } else if (hv.kind == HoverKind::BottomBarButton) { - rows = bottomBarRows(); - bar = bottomToolbarRect(w, h); - } else { - return false; - } - if (hv.index < 0 || hv.index >= static_cast(rows.size())) return false; - - // The hovered button's slot rect (the anchor). computeBarSlots is the same layout the draw + - // hit-test use, so the anchor matches the drawn button exactly. - const std::vector clusters = actionBarClusters(rows); - const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); - const ActionBarSlot* slot = nullptr; - for (const ActionBarSlot& s : slots) - if (s.index == hv.index) { slot = &s; break; } - if (!slot) return false; - - // The full name is stored already prefix-free, but strip defensively in case a source ever - // carries the "ReaSampler:" display prefix (the tooltip must never show it — L5 refinement 2). - // L6: the keybinding sub-row was removed from the button face, so the tooltip now carries - // both the name AND the binding (when bound) — e.g. "capture selected item — F5". When the - // action is unbound the tooltip shows only the name (no "(unbound)" noise in the tooltip). - const std::string phrase = stripActionPrefix(rows[static_cast(hv.index)].fullName, - actionDisplayPrefix()); - const int cmd = resolveBarCommandId(rows[static_cast(hv.index)]); - const std::string binding = barBindingText(cmd); - textOut = binding.empty() ? phrase : phrase + " \xe2\x80\x94 " + binding; // " — " (em dash, UTF-8) - ax = slot->x; ay = slot->y; aw = slot->width; ah = slot->height; - return true; -} - -// Draws the hover-delay tooltip over the given anchor button, if a tooltip is due (the current -// hover is a toolbar button AND it has been hovered past kTooltipDelayMs). Drawn LAST in the -// paint so it overlays the toolbars. The box is placed by the pure tooltip module (below the -// anchor, flipping above near the bottom edge, clamped to the client). -void drawTooltip(LICE_IBitmap* bmp, int w, int h) { - if (!g_panel.tooltipShown) return; - std::string txt; - int ax = 0, ay = 0, aw = 0, ah = 0; - if (!currentTooltip(w, h, txt, ax, ay, aw, ah) || txt.empty()) return; - - const int textW = static_cast(txt.size()) * kTooltipCharPx; - const TooltipBox tb = - computeTooltip(ax, ay, aw, ah, textW, kTooltipTextH, w, h, TooltipSpec{}); - if (tb.empty()) return; - - // The tooltip surface: a raised bg/cell chip with a hairline border, then the AA text. - const KitBox box{tb.x, tb.y, tb.width, tb.height}; - fillSurface(bmp, box, Role::BgCell, InteractionState::Hover); - LICE_DrawRect(bmp, tb.x, tb.y, tb.width, tb.height, - toLice(roleColor(Role::LineHairline)), 1.0f, 0); - kitText(bmp, box, txt.c_str(), Font::Label, Role::TextPrimary, Align::Center); -} - -// --- Split geometry ----------------------------------------------------------- -// -// Every rect below is derived from the client size + fullHeight state, and BOTH paint -// and hit-testing call these so they never drift. All are top-left origin. - -// The body band between the TOP toolbar and the BOTTOM toolbar (L4). Its top edge is below the -// top toolbar; its bottom edge is the bottom toolbar's top. When the bottom bar collapses on a -// short client, bottomToolbarRect returns its y at the footer top, so the body still ends there. -RECT splitBody(int w, int h) { - RECT rc{}; - rc.left = 0; - rc.right = w; - rc.top = kTopToolbarHeight; - const ActionBarRect bar = bottomToolbarRect(w, h); - rc.bottom = bar.y; - if (rc.bottom < rc.top) rc.bottom = rc.top; - return rc; -} - -// True when both regions are shown (the split is live). Otherwise one region fills -// the body. -bool poolShown() { return g_panel.fullHeight != BankPanelFullHeight::BanksOnly; } -bool banksShown() { return g_panel.fullHeight != BankPanelFullHeight::PoolOnly; } - -// The pool region's rect (whole-region: header band + grid). Empty when hidden. -RECT poolRegionRect(int w, int h) { - const RECT body = splitBody(w, h); - if (!poolShown()) return RECT{0, 0, 0, 0}; - if (!banksShown()) return body; // pool full-height: the whole body - // Split: pool gets the top half (minus the divider). - RECT rc = body; - rc.bottom = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; - if (rc.bottom < rc.top) rc.bottom = rc.top; - return rc; -} - -// The named-banks region's rect (whole-region: header band + tab strip + grid). -RECT banksRegionRect(int w, int h) { - const RECT body = splitBody(w, h); - if (!banksShown()) return RECT{0, 0, 0, 0}; - if (!poolShown()) return body; // banks full-height: the whole body - RECT rc = body; - rc.top = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2 + - kSplitDividerHeight; - if (rc.top > rc.bottom) rc.top = rc.bottom; - return rc; -} - -// A region's header band (the top kRegionHeaderHeight of the region). -RECT regionHeaderRect(const RECT& region) { - RECT rc = region; - rc.bottom = region.top + kRegionHeaderHeight; - if (rc.bottom > region.bottom) rc.bottom = region.bottom; - return rc; -} - -// The named-banks region's tab strip (below its header band). -TabStripRect banksTabStripRect(const RECT& region) { - const RECT hdr = regionHeaderRect(region); - TabStripRect s; - s.x = region.left; - s.y = hdr.bottom; - s.width = region.right - region.left; - s.height = kTabStripHeight; - if (s.y + s.height > region.bottom) s.height = region.bottom - s.y; - if (s.height < 0) s.height = 0; - return s; -} - -// A region's grid viewport (below the header band, and below the tab strip for the -// banks region). This is where cells tile. -RECT regionGridRect(const RECT& region, bool isBanks) { - RECT rc = region; - rc.top = region.top + kRegionHeaderHeight; - if (isBanks) rc.top += kTabStripHeight; - if (rc.top > rc.bottom) rc.top = rc.bottom; - return rc; -} - -// The full-height toggle button rect inside a region header (right-aligned). -RECT fullHtBtnRect(const RECT& region) { - const RECT hdr = regionHeaderRect(region); - RECT rc = hdr; - rc.right = hdr.right - 4; - rc.left = rc.right - kFullHtBtnWidth; - rc.top = hdr.top + 2; - rc.bottom = hdr.bottom - 2; - return rc; -} - -// The "+" create-bank button rect inside the named-banks region header (left of the -// full-height button). -RECT createBtnRect(const RECT& region) { - RECT ft = fullHtBtnRect(region); - RECT rc = ft; - rc.right = ft.left - 4; - rc.left = rc.right - kCreateBtnWidth; - return rc; -} - -// --- L7 slot-order display bridge --------------------------------------------- -// -// L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted -// SlotMap order (sparse, gap-preserving), NOT BankIndex insertion order. This one helper -// is the single place that resolves a region's display, composed purely from bank_book's -// slot order (orderedSampleIds) + card_drag's sparse slot rects (computeSlotRects) — the -// shell adds no layout math of its own. -// -// TWO INDEX SPACES the whole panel must keep straight: -// * SLOT — a display position 0..maxSlot; gaps are empty slots that draw as empty -// cells and are valid drop targets. This is what pixels/hit-tests speak. -// * SELECTION — the DENSE occupied-ordinal [0, occupied) space the pure Selection / -// applyClick / navigate reason in. Selection index i <-> orderedIds[i]. -// Keyboard navigation therefore traverses ONLY occupied cells and SKIPS -// gaps (spec: skip-vs-land-on-gap is unspecified -> skip, documented here). -// RegionDisplay carries both plus the translation between them, resolved FRESH each call -// (never cached across a mutation, per the reference-invalidation guardrail). -struct RegionDisplay { - std::vector orderedIds; // occupied ids in slot order (selection space) - std::vector slotRects; // one rect per slot 0..maxSlot, viewport coords - const Bank* bank = nullptr; - - // The id occupying `slot`, or "" for an empty slot / out of range. - std::string idAtSlot(int slot) const { - return bank ? bank->slots.idAt(slot) : std::string{}; - } - // The slot a selection ordinal `sel` maps to, or -1. orderedIds[sel] -> its slot. - int slotForSelection(int sel) const { - if (sel < 0 || sel >= static_cast(orderedIds.size()) || !bank) return -1; - return bank->slots.slotOf(orderedIds[static_cast(sel)]); - } - // The selection ordinal for `slot` (index of its occupant in orderedIds), or -1 when - // the slot is empty. Inverse of slotForSelection. - int selectionForSlot(int slot) const { - const std::string id = idAtSlot(slot); - if (id.empty()) return -1; - for (std::size_t i = 0; i < orderedIds.size(); ++i) - if (orderedIds[i] == id) return static_cast(i); - return -1; - } - int occupiedCount() const { return static_cast(orderedIds.size()); } -}; - -// Resolves a region's display for the currently-shown bank. Empty (no bank / no width) -// yields an empty display. orderedSampleIds reconciles the bank's SlotMap against live -// membership, so a freshly-migrated or out-of-band-mutated bank always yields a complete -// order (trailing empties are trimmed by the model — maxSlot walks only live occupants). -RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) { - RegionDisplay d; - BankBook* b = book(); - if (!b) return d; - const std::string bankId = bankIdForRegion(reg); - if (bankId.empty()) return d; - d.bank = b->bank(bankId); - if (!d.bank) return d; - - d.orderedIds = b->orderedSampleIds(bankId); // occupied ids, slot order (reconciles) - if (d.orderedIds.empty()) return d; - - const RECT grid = regionGridRect(region, isBanks); - const int w = grid.right - grid.left; - if (w <= 0) return d; - d.slotRects = computeSlotRects(d.bank->slots.maxSlot(), w, kGrid); - for (SlotCellRect& r : d.slotRects) { r.x += grid.left; r.y += grid.top; } - return d; -} - -// The FOCUSED region's display (the slot-order bridge for the region holding the live -// selection). Mirrors columnsForRegion's client read. -RegionDisplay focusedDisplay() { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const bool isBanks = g_panel.focusedRegion == Region::Banks; - const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - return regionDisplay(region, isBanks, g_panel.focusedRegion); -} - -// --- Drawing: a grid region --------------------------------------------------- - -// Draws one region's grid of thumbnails (or an empty-state line) clipped to its -// viewport. `selectionOwner` is true when this region holds the live selection, so -// its cells show selection/focus chrome; the other region draws plain. -void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, - const BankIndex* index, const std::string& emptyMsg, - bool selectionOwner, const std::string& projectDir, Region reg) { - const RECT grid = regionGridRect(region, isBanks); - if (grid.bottom <= grid.top) return; - - if (!index || index->empty()) { - kitText(bmp, toKitBox(grid), emptyMsg.c_str(), Font::Label, Role::TextDim, Align::Center); - return; - } - - // L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense - // BankIndex insertion order. Selection/focus are keyed by the occupied-ordinal (selection - // space); a slot maps back to its ordinal via selectionForSlot. - const RegionDisplay disp = regionDisplay(region, isBanks, reg); - // FA3 gap-free: request one bin per drawn pixel column; drawWaveform's - // peaks::columnMinMax exact partition makes every column gap-free — overbinning - // produces byte-identical pixels at higher memory/CPU cost. computeThumbnail clamps - // the request to the frame count. - const int binWidth = kWaveformOversample * - waveformColumnCount(KitBox{0, 0, kGrid.cellWidth, kGrid.cellHeight}); - for (const SlotCellRect& r : disp.slotRects) { - if (r.y >= grid.bottom) continue; // below the viewport: skip (no scroll) - const CellRect rect{r.x, r.y, r.width, r.height}; - const std::string id = disp.idAtSlot(r.slot); - if (id.empty()) { - // Interior gap slot: a subtle empty-slot treatment through the kit — a hairline - // outline on bg/cell, clearly NOT a card (decorative, per the L7 spec). No - // selection/focus/waveform, and not a hover or hit target (the grid never tracks - // cell hover; a click on an empty slot clears selection like any grid miss). - fillSurface(bmp, KitBox{rect.x, rect.y, rect.width, rect.height}, - Role::BgCell, InteractionState::Rest); - LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, - toLice(roleColor(Role::LineHairline)), 1.0f, 0); - continue; - } - const Sample* s = index->query(id); - if (!s) continue; // reconciled order should never name a stale id; defensive - const int sel = disp.selectionForSlot(r.slot); - const bool selected = selectionOwner && sel >= 0 && g_panel.selection.contains(sel); - const bool focused = selectionOwner && sel >= 0 && g_panel.selection.focus == sel; - const Envelope& env = thumbnailFor(*s, binWidth, projectDir); - // Grid-cell hover is intentionally not tracked: the cell already carries selection + - // focus chrome (the centerpiece's "bones"); a third transient hover state on every - // cell would add repaint churn + visual noise. Hover lights the chrome/buttons/tabs. - drawThumbnail(bmp, rect, env, selected, focused, /*hovered=*/false, s); - } -} - -// L7: draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but -// ONLY when a same-bank in-grid drag (Reorder or Replace) is live over THIS region (the drag -// source region). An accent/HOT outline (distinct from the accent/tertiary purple selection -// border, per the spec's "must not be confusable" constraint); Replace draws a doubled outline -// so an Alt-over-occupied replace reads as a stronger "swap" cue than a plain reorder. No-op -// for a move/copy/OS drag or when the pointer is off any slot (dragTargetSlot < 0). -void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Region reg) { - if (!g_panel.dragging) return; - if (g_panel.cardGesture != CardGesture::Reorder && - g_panel.cardGesture != CardGesture::Replace) - return; - if (g_panel.dragSourceRegion != reg) return; // highlight only the source bank's grid - if (g_panel.dragTargetSlot < 0) return; - - const RECT grid = regionGridRect(region, isBanks); - const RegionDisplay disp = regionDisplay(region, isBanks, reg); - // Use the drop rects (includes the trailing row past maxSlot) so a beyond-extent - // target slot gets a visible highlight cue, not silence. - const int gridW = grid.right - grid.left; - const int maxSlot = disp.bank ? disp.bank->slots.maxSlot() : -1; - std::vector dropRects = computeSlotRectsForDrop(maxSlot, gridW, kGrid); - for (SlotCellRect& r : dropRects) { r.x += grid.left; r.y += grid.top; } - for (const SlotCellRect& r : dropRects) { - if (r.slot != g_panel.dragTargetSlot) continue; - if (r.y >= grid.bottom) return; // below the viewport (no scroll) - const LICE_pixel hot = toLice(roleColor(Role::AccentHot)); - LICE_DrawRect(bmp, r.x, r.y, r.width, r.height, hot, 1.0f, 0); - if (g_panel.cardGesture == CardGesture::Replace) - LICE_DrawRect(bmp, r.x + 1, r.y + 1, r.width - 2, r.height - 2, hot, 1.0f, 0); - return; - } -} - -// Draws a region header: title, the active-bank readout, and the full-height button. -void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, - const std::string& activeName, bool poolBtnIsPool) { - const RECT hdr = regionHeaderRect(region); - // Region header band (kit bg/panel — a raised region title bar). A hairline underline. - fillSurface(bmp, KitBox{hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top}, - Role::BgPanel, InteractionState::Rest); - LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1, - toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); - - // Title, left (Font::Title — a region heading). The two regions are distinct KINDS of - // container, so the title carries a CATEGORICAL accent (DS-2 revised: secondary/tertiary - // mark kinds, never intensity) — Pool = secondary teal, Banks = tertiary purple. This is - // a category mark, NOT the "what's live" signal (that stays the primary-lime "Active:" - // readout beside it), keeping primary reserved for the live/active layer. - RECT titleRc = hdr; - titleRc.left += 8; - titleRc.right = titleRc.left + 120; - const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary; - kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left); - - // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the PRIMARY - // accent role in BOTH region headers so the active/capture-target bank is legible even when - // it is not the shown tab and even when it is the pool. Primary = "what's live" (DS-2). - const std::string readout = "Active: " + activeName; - RECT actRc = hdr; - actRc.left = titleRc.right + 6; - actRc.right = createBtnRect(region).left - 6; - if (actRc.right > actRc.left) - kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::AccentPrimary, Align::Left); - - // Full-height toggle button: an arrow glyph. In split it means "maximize this region"; - // when this region is already full it means "restore the split". Kit drawButton + hover. - const RECT btn = fullHtBtnRect(region); - const bool thisFull = - poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly) - : (g_panel.fullHeight == BankPanelFullHeight::BanksOnly); - const HoverKind hk = poolBtnIsPool ? HoverKind::FullHtPool : HoverKind::FullHtBanks; - const InteractionState state = - thisFull ? InteractionState::Active : hoverState(g_panel.hovered, hk, -1); - const KitButtonBox box{KitBox{btn.left, btn.top, btn.right - btn.left, - btn.bottom - btn.top}}; - drawButton(bmp, box, thisFull ? "v" : "^", state, /*warn=*/false); -} - -// Draws the named-banks tab strip: one tab per named bank (ordinal order), the SHOWN -// tab highlighted, the ACTIVE bank's tab lit with the accent border, overflow -// chevrons when present, plus the "+" create button in the header. During a drag, -// the tab under the pointer gets the drop-target highlight. -void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { - const TabStripRect strip = banksTabStripRect(region); - if (strip.height <= 0) return; - // Tab strip band (kit bg/base — recessed relative to the region header above it). - fillSurface(bmp, KitBox{strip.x, strip.y, strip.width, strip.height}, - Role::BgBase, InteractionState::Rest); - - const std::vector tabs = namedBanks(); - const int n = static_cast(tabs.size()); - if (n == 0) { - kitText(bmp, KitBox{strip.x + 8, strip.y, strip.width - 8, strip.height}, - "No named banks -- click + to create one.", - Font::Label, Role::TextDim, Align::Left); - return; - } - - const TabStripLayout layout = - computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); - - // Chevrons (drawn first so tabs sit above their inner edges). - if (layout.overflow) { - const KitBox lc{strip.x, strip.y, kTabSpec.chevronWidth, strip.height}; - const KitBox rc{strip.x + strip.width - kTabSpec.chevronWidth, strip.y, - kTabSpec.chevronWidth, strip.height}; - fillSurface(bmp, lc, Role::BgCell, InteractionState::Rest); - fillSurface(bmp, rc, Role::BgCell, InteractionState::Rest); - kitText(bmp, lc, "<", Font::Label, Role::TextPrimary, Align::Center); - kitText(bmp, rc, ">", Font::Label, Role::TextPrimary, Align::Center); - } - - const std::string activeId = book() ? book()->activeBankId() : std::string(); - const std::vector rects = - computeTabRects(strip, n, kTabSpec, g_panel.tabScroll); - for (const TabRect& tr : rects) { - const Bank* bk = tabs[static_cast(tr.index)]; - const bool shown = bk->id == g_panel.shownBankId; - const bool active = bk->id == activeId; - const bool dropHere = g_panel.dragging && - g_panel.dropKind == DropKind::Tab && - g_panel.dropBankId == bk->id; - const bool hovered = g_panel.hovered.kind == HoverKind::Tab && - g_panel.hovered.index == tr.index; - - // Surface state: the ACTIVE bank (capture target) carries the accent (Active); a drag - // drop-target reads Dragging; the SHOWN (browsed) tab reads Pressed (recessed-lit); - // else hover-or-rest bg/cell. - const KitBox tb{tr.x, tr.y, tr.width, tr.height}; - InteractionState state = InteractionState::Rest; - if (active) state = InteractionState::Active; - else if (dropHere) state = InteractionState::Dragging; - else if (shown) state = InteractionState::Pressed; - else if (hovered) state = InteractionState::Hover; - fillSurface(bmp, tb, Role::BgCell, state); - - // The active bank's tab gets a bright accent border (unmistakable), distinct from the - // shown tab's fill — active != shown, made visible (kit accent role). - const KitColor border = active ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, toLice(border), 1.0f, 0); - if (active) - LICE_DrawRect(bmp, tr.x + 1, tr.y + 1, tr.width - 2, tr.height - 2, - toLice(border), 1.0f, 0); - - // Label: bg/base on the accent-active fill for contrast, else text/primary. - const Role trole = active ? Role::BgBase : Role::TextPrimary; - kitText(bmp, KitBox{tr.x + 4, tr.y, tr.width - 8, tr.height}, - bk->displayName.c_str(), Font::Label, trole, Align::Center); - } -} - -// The active bank's display name (for the readout). "Pool" when the pool is active. -std::string activeBankName() { - BankBook* b = book(); - if (!b) return std::string(kPoolBankName); - const Bank* bk = b->bank(b->activeBankId()); - return bk ? bk->displayName : std::string(kPoolBankName); -} - -// --- Full paint --------------------------------------------------------------- - -void paintPanel(HWND hwnd, HDC hdc) { - RECT cr{}; - GetClientRect(hwnd, &cr); - const int w = cr.right - cr.left; - const int h = cr.bottom - cr.top; - if (w <= 0 || h <= 0) return; - - LICE_SysBitmap bmp(w, h); - LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); - - const std::string projectDir = currentProjectDir(); - const std::string activeName = activeBankName(); - - // Pool region (top). - if (poolShown()) { - const RECT region = poolRegionRect(w, h); - drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true); - drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool), - "No samples in the pool yet. Capture one to see it here.", - g_panel.focusedRegion == Region::Pool, projectDir, Region::Pool); - // Drop-target highlight for the pool region during a MOVE/COPY drag (a whole-grid - // outline signalling "drop here to move/copy into this bank"). Suppressed for a - // same-bank reorder (that shows a per-SLOT highlight below, not the whole grid). - if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion && - (g_panel.cardGesture == CardGesture::Move || - g_panel.cardGesture == CardGesture::Copy)) { - const RECT grid = regionGridRect(region, false); - LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, - grid.right - grid.left - 2, grid.bottom - grid.top - 2, - toLice(roleColor(Role::AccentHot)), 1.0f, 0); - } - // L7 per-slot reorder/replace target highlight (source = pool). An accent/hot outline - // on the target slot's cell — distinct from the accent/tertiary purple selection - // border, so it is never confusable with a selected card. - drawCardDropTarget(&bmp, region, /*isBanks=*/false, Region::Pool); - } - - // Split divider. - if (poolShown() && banksShown()) { - const RECT body = splitBody(w, h); - const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; - LICE_FillRect(&bmp, 0, dy, w, kSplitDividerHeight, - toLice(roleColor(Role::BgBase)), 1.0f, 0); - } - - // Named-banks region (bottom). - if (banksShown()) { - const RECT region = banksRegionRect(w, h); - drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false); - // "+" create button (drawn as part of the banks header) — kit drawButton + hover. - const RECT cbtn = createBtnRect(region); - const InteractionState createState = - hoverState(g_panel.hovered, HoverKind::CreateBank, -1); - drawButton(&bmp, KitButtonBox{KitBox{cbtn.left, cbtn.top, cbtn.right - cbtn.left, - cbtn.bottom - cbtn.top}}, - "+", createState, /*warn=*/false); - - drawTabStrip(&bmp, region); - drawRegionGrid(&bmp, region, /*isBanks=*/true, indexForRegion(Region::Banks), - g_panel.shownBankId.empty() - ? "Select or create a named bank." - : "This bank is empty. Move samples here from the pool.", - g_panel.focusedRegion == Region::Banks, projectDir, Region::Banks); - // Drop-target highlight for the banks region during a drag. BanksRegion fires - // when the pointer is in the grid but not on a specific tab; Tab draws its own - // highlight on the individual tab (drawTabStrip above handles that case). - if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion && - (g_panel.cardGesture == CardGesture::Move || - g_panel.cardGesture == CardGesture::Copy)) { - const RECT grid = regionGridRect(region, true); - LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, - grid.right - grid.left - 2, grid.bottom - grid.top - 2, - toLice(roleColor(Role::AccentHot)), 1.0f, 0); - } - // L7 per-slot reorder/replace target highlight (source = banks region). - drawCardDropTarget(&bmp, region, /*isBanks=*/true, Region::Banks); - } - - // L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles - // into the band MINUS the far-right More-button reserve; the More button is drawn over the - // band's reserved right strip; the BOTTOM toolbar (four opposite-mode tag buttons + Show - // Both); then the footer (mode toggle + count + Tail button + Prune). Drawn last so they sit - // over the split body's edges. drawToolbar fills only its passed (action) rect, so fill the - // WHOLE top band first — otherwise the reserved right strip behind the More button is bare. - fillSurface(&bmp, KitBox{0, 0, w, kTopToolbarHeight}, Role::BgPanel, InteractionState::Rest); - drawToolbar(&bmp, topToolbarActionRect(w), topBarRows(), HoverKind::TopBarButton, - /*topDivider=*/false); - drawMoreButton(&bmp, w); - drawToolbar(&bmp, bottomToolbarRect(w, h), bottomBarRows(), HoverKind::BottomBarButton, - /*topDivider=*/true); - drawFooter(&bmp, w, h); - - // The custom hover-delay tooltip overlays everything (L5 refinement 2). - drawTooltip(&bmp, w, h); - - BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); -} - -// --- Bank-change detection ---------------------------------------------------- - -// A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag -// + per-sample id/path. Catches every mutation the panel must redraw for: capture, -// project load, and B4's own create/rename/delete/move/activate. -std::string bookFingerprint() { - BankBook* b = book(); - if (!b) return {}; - std::string fp = std::to_string(b->size()); - fp += '\x1e'; fp += b->activeBankId(); - for (const Bank& bk : b->banks()) { - fp += '\x1d'; - fp += bk.id; - fp += '\x1c'; - fp += bk.displayName; - for (const Sample& s : bk.index.all()) { - fp += '\x1f'; - fp += s.id; - fp += '\x1f'; - fp += s.relativePath; - } - } - return fp; -} - -// Reconciles shownBankId against the live named banks: keep it if it still names a -// named bank; otherwise fall to the first named bank (or empty when none). Keeps the -// banks region always showing a valid tab. Never touches the ACTIVE bank. -void reconcileShownBank() { - BankBook* b = book(); - if (!b) { g_panel.shownBankId.clear(); return; } - if (!g_panel.shownBankId.empty()) { - const Bank* bk = b->bank(g_panel.shownBankId); - if (bk && !bk->isPool()) return; // still valid - } - const std::vector named = namedBanks(); - g_panel.shownBankId = named.empty() ? std::string() : named.front()->id; -} - -bool refreshFingerprint() { - if (!book()) return false; - std::string fp = bookFingerprint(); - if (fp == g_panel.bankFingerprint) return false; - g_panel.bankFingerprint = std::move(fp); - ++g_panel.generation; - g_panel.cache.clear(); - // The selection indexes into the OLD order; a change can invalidate those, so - // clear it and stop any audition. - if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { - g_panel.selection = Selection{}; - stopAudition(); - } - reconcileShownBank(); - const BankIndex* idx = indexForRegion(g_panel.focusedRegion); - g_panel.selItemCount = idx ? static_cast(idx->size()) : 0; - return true; -} - -// --- New-content detection (D2 Wave 2) ---------------------------------------- -// -// REAPER exposes no "item/track added" callback, so we diff live project state on the -// existing timer. Each tick: enumerate every track GUID and every item GUID, diff -// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new -// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane -// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All -// enumeration is READ-ONLY on the project; the only mutation is to the in-memory -// membership index (persisted by persist on the next save, same as an action-driven tag). - -// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified -// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so -// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace. -constexpr int kFreeModeFixedLanes = 2; - -bool isFixedLaneTrack(MediaTrack* tr) { - return static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; -} - -// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h): -// itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies. - -// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set, -// baseline input) and, for each item, records whether it sits on a manual lane so a -// newly-detected item can be exempted from auto-tag without a second project walk. -// `trackItemGuids` additionally maps each track GUID to the item GUIDs it carries, so a -// newly-detected item's PRE-EXISTING siblings can be resolved (the adoption / strand -// guard) without a second project walk. -// -// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack, -// laneName) from lane_keys — the same predicate the apply path consults — so the exemption -// rule is defined in exactly one place and is unit-tested there. -void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, - std::map& itemOnManualLane, - std::map>& trackItemGuids) { - const int trackCount = CountTracks(proj); - for (int t = 0; t < trackCount; ++t) { - MediaTrack* tr = GetTrack(proj, t); - if (!tr) continue; - std::string tg = guidString(tr); - if (!tg.empty()) allGuids.insert(tg); - - // Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a - // track-level attribute and is the same for every item on the track. - const bool fixedLane = isFixedLaneTrack(tr); - - std::vector& itemsOnTrack = trackItemGuids[tg]; - const int itemCount = CountTrackMediaItems(tr); - for (int i = 0; i < itemCount; ++i) { - MediaItem* it = GetTrackMediaItem(tr, i); - if (!it) continue; - std::string ig = itemGuid(it); - if (ig.empty()) continue; - allGuids.insert(ig); - // Classify via the single shared predicate. For a fixed-lane track we read - // the item's lane name; for a normal track we pass "" (isOnManualLane returns - // false immediately for non-fixed-lane tracks regardless of name). - const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; - itemOnManualLane[ig] = isOnManualLane(fixedLane, ln); - itemsOnTrack.push_back(ig); - } - } -} - -// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones -// into the active mode. Runs every timer tick regardless of panel open/close (content -// is created in the arrange). READ-ONLY on the project; mutates only the in-memory -// membership index. -// -// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a -// background metadata update (like setting a label), not a destructive project edit. -// persist.cpp writes it on the next project save alongside the bank and view state, the -// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood -// the REAPER undo history with a new entry for every timer tick that sees new content. -// Returns true iff this tick tagged at least one new GUID into a mode — the signal the -// caller uses to decide whether to run the lane-minting pass (a track can only newly -// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint. -bool detectNewContent() { - if (!g_panel.session) return false; - - ReaProject* proj = EnumProjects(-1, nullptr, 0); - - // A project (re)load re-arms the first-poll guard so we never diff across two - // projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded() - // on the tick persist restores the project's membership + active mode, which sets - // reloadPending. Draining it here re-baselines against the fully-loaded set (that - // same tick's reapply-active-mode enumerated those tracks, so they are present), - // and the observe() below returns nothing new — pre-existing untagged tracks stay - // Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so - // no separate first-tick handling is needed here. Using persist's GUID-primary load - // signal (not a local pointer compare) is what fixes the reload-mis-tag: the two - // identity checks can no longer diverge on a recycled ReaProject* address. - if (g_panel.reloadPending) { - g_panel.contentBaseline.reset(); - g_panel.reloadPending = false; - } - - std::set live; - std::map itemOnManualLane; - std::map> trackItemGuids; - enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids); - - const std::vector added = g_panel.contentBaseline.observe(live); - if (added.empty()) return false; // first poll after open, or nothing new this tick - - ViewModeModel& model = g_panel.session->view(); - - // Which of `added` are items (the manual-lane map keys every item; track GUIDs never - // appear there). Used below to exclude sibling new items from a track's PRE-EXISTING - // mode set — a drop plus its own new siblings must not count each other as prior. - const std::set newItemGuids = [&] { - std::set s; - for (const std::string& g : added) - if (itemOnManualLane.count(g)) s.insert(g); - return s; - }(); - - // Item guid -> its track guid (reverse of trackItemGuids), so a new item's siblings - // are found in one lookup. - std::map trackOfItem; - for (const auto& [trackGuid, items] : trackItemGuids) - for (const std::string& ig : items) trackOfItem[ig] = trackGuid; - - // The distinct modes the PRE-EXISTING (not-new-this-tick) MANAGED-ELIGIBLE items on - // `trackGuid` resolve to. Untagged siblings resolve to Arrange (leafBelongsToMode's - // default); new siblings are excluded; manual-lane siblings are EXEMPT — exactly as - // planLaneMinting ignores them when computing a track's own-item mode span, so the - // adoption guard's view of the track matches the split decision's. Drives the adoption - // / strand guard in autoTagNewContent. - const auto preExistingTrackModes = - [&](const std::string& trackGuid) -> std::set { - std::set modes; - auto it = trackItemGuids.find(trackGuid); - if (it == trackItemGuids.end()) return modes; - for (const std::string& sib : it->second) { - if (newItemGuids.count(sib)) continue; // a sibling added THIS tick — not prior - auto ml = itemOnManualLane.find(sib); - if (ml != itemOnManualLane.end() && ml->second) continue; // manual lane — exempt - const std::set m = model.membership().modesOf(sib); - if (m.empty()) modes.insert(kArrangeModeId); // untagged ⇒ Arrange default - else modes.insert(m.begin(), m.end()); - } - return modes; - }; - - // Split the new GUIDs into tracks vs items so the pure decision can apply the - // manual-lane exemption to items only. A GUID present in the item-lane map is an - // item; otherwise it is a track (track GUIDs never appear in that map). - std::vector newTracks; - std::vector newItems; - for (const std::string& g : added) { - auto it = itemOnManualLane.find(g); - if (it == itemOnManualLane.end()) { - newTracks.push_back(g); // a track GUID - } else { - NewItem ni{g, it->second, {}}; - auto tk = trackOfItem.find(g); - if (tk != trackOfItem.end()) ni.trackModes = preExistingTrackModes(tk->second); - newItems.push_back(std::move(ni)); // an item; carries exemption + track modes - } - } - - const std::vector tags = - autoTagNewContent(newTracks, newItems, model.activeModeId()); - for (const AutoTag& tag : tags) - model.membership().tag(tag.guid, tag.modeId); - return !tags.empty(); -} - -// --- Audition preview --------------------------------------------------------- -// -// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW -// playback only. It NEVER inserts into the arrange, creates items/tracks, or -// mutates the project or bank. PlayPreview streams a caller-owned PCM_source -// through REAPER's preview bus and touches nothing in the project. -// -// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by -// signature/struct, not semantics — DAW-verify): -// 1. REAPER's audio thread reads the preview_register_t by POINTER while the -// preview is active (the struct's own comment mandates a cs/mutex we init), -// so the register must outlive playback — we hold it in g_panel (static), -// never on the stack. -// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it -// returns, making it safe to PCM_Source_Destroy the source immediately after. -// This is the conventional contract (SWS' preview helpers rely on it) but is -// NOT documented in the header — flagged. If a rare race surfaced, the fix is -// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence). -// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not -// set mono (&1024). volume 1.0, loop false, curpos 0. - -void initPreview() { - if (g_panel.previewInited) return; -#ifdef _WIN32 - InitializeCriticalSection(&g_panel.preview.cs); -#else - pthread_mutex_init(&g_panel.preview.mutex, nullptr); -#endif - g_panel.previewInited = true; -} - -void stopAudition() { - if (g_panel.previewActive) { - StopPreview(&g_panel.preview); - g_panel.previewActive = false; - } - if (g_panel.previewSrc) { - PCM_Source_Destroy(g_panel.previewSrc); - g_panel.previewSrc = nullptr; - } - g_panel.preview.src = nullptr; -} - -void deinitPreview() { - if (!g_panel.previewInited) return; -#ifdef _WIN32 - DeleteCriticalSection(&g_panel.preview.cs); -#else - pthread_mutex_destroy(&g_panel.preview.mutex); -#endif - g_panel.previewInited = false; -} - -// Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank. -// L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw -// BankIndex position. -void startAudition(int idx) { - stopAudition(); - - const BankIndex* index = indexForRegion(g_panel.focusedRegion); - if (!index) return; - const RegionDisplay disp = focusedDisplay(); - if (idx < 0 || idx >= disp.occupiedCount()) return; - const Sample* s = index->query(disp.orderedIds[static_cast(idx)]); - if (!s) return; - - const std::string projectDir = currentProjectDir(); - const std::string abs = resolveBankFile(projectDir, s->relativePath); - if (abs.empty()) return; - - PCM_source* src = PCM_Source_CreateFromFile(abs.c_str()); - if (!src) return; - - g_panel.preview.src = src; - g_panel.preview.m_out_chan = 0; - g_panel.preview.curpos = 0.0; - g_panel.preview.loop = false; - g_panel.preview.volume = 1.0; - g_panel.preview.peakvol[0] = 0.0; - g_panel.preview.peakvol[1] = 0.0; - g_panel.preview.preview_track = nullptr; - - if (PlayPreview(&g_panel.preview) != 0) { - g_panel.previewSrc = src; - g_panel.previewActive = true; - } else { - PCM_Source_Destroy(src); - g_panel.preview.src = nullptr; - } -} - -// --- Input helpers ------------------------------------------------------------ - -bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } -bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } -bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; } // Alt = replace modifier (L7) - -void invalidatePanel() { - if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); -} - -// The item count the SELECTION reasons over — the focused region's occupied-cell count. -// L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not -// selectable). Occupied count == index size by construction: every index member maps to -// exactly one occupied slot (gaps are empty slots, which the index never backs), so the -// raw index size IS the dense selection-space extent. -int focusedItemCount() { - const BankIndex* idx = indexForRegion(g_panel.focusedRegion); - return idx ? static_cast(idx->size()) : 0; -} - -// Which region (if any) contains client point (x, y); returns false via `out` set to -// Pool by default when the point is in neither region body. -bool regionAt(int x, int y, Region& out) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - if (poolShown()) { - const RECT r = poolRegionRect(w, h); - if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { - out = Region::Pool; return true; - } - } - if (banksShown()) { - const RECT r = banksRegionRect(w, h); - if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { - out = Region::Banks; return true; - } - } - return false; -} - -// --- Bank management ops (id-keyed; drive the B1 model + persist) -------------- -// -// Each op mutates g_session.book() then persists via persistBankOp(). After a -// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankIndex& is invalid — we -// resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an -// unsaved project the empty-close discard in persistBankOp ensures no stale state -// survives (matches the capture/B3 quiet-persist idiom). - -// REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3). -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out) { - std::vector buf(512, '\0'); - std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); - const std::string captions = std::string(caption) + ",separator=\x1f"; - if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), - static_cast(buf.size()))) - return false; - std::string s(buf.data()); - if (s.empty()) return false; - out = std::move(s); - return true; -} - -// Mints a genuine REAPER GUID string as a stable bank id (same as B3 mintBankId). -std::string mintBankId() { - GUID g{}; - genGuid(&g); - char buf[64] = {0}; - guidToString(&g, buf); - return std::string(buf); -} - -void doCreateBank() { - if (!book()) return; - std::string name; - if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; - const std::string id = mintBankId(); - if (!book()->createBank(id, name)) { - ShowMessageBox("A bank with that name already exists.", - "ReaSampler: create bank", 0); - return; - } - g_panel.shownBankId = id; // show the freshly-created bank - g_panel.focusedRegion = Region::Banks; - persistBankOp("ReaSampler: create bank"); - invalidatePanel(); -} - -void doRenameBank(const std::string& bankId) { - if (!book()) return; - const Bank* bk = book()->bank(bankId); - if (!bk || bk->isPool()) return; - const std::string current = bk->displayName; // copy before any mutation - std::string newName; - if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return; - if (!book()->renameBank(bankId, newName)) { - ShowMessageBox("Another bank already uses that name.", - "ReaSampler: rename bank", 0); - return; - } - persistBankOp("ReaSampler: rename bank"); - invalidatePanel(); -} - -// Delete with the RICHER confirm-on-non-empty affordance (B4): the confirm names the -// member count AND offers evacuate as the one-click alternative (Yes=delete anyway, -// No=evacuate-then-keep, Cancel=abort) — richer than B3's basic YESNO. -void doDeleteBank(const std::string& bankId) { - if (!book()) return; - const Bank* bk = book()->bank(bankId); - if (!bk || bk->isPool()) return; - const std::size_t members = bk->index.size(); // read BEFORE any mutation - const std::string name = bk->displayName; - - if (members > 0) { - const std::string msg = - "\"" + name + "\" holds " + std::to_string(members) + - (members == 1 ? " sample" : " samples") + - ".\n\nYes -- delete the bank AND drop its samples (files are kept on disk " - "but no bank references them until prune).\nNo -- Evacuate them to the " - "pool first, then delete the empty bank (keeps the samples).\nCancel -- " - "do nothing."; - // 3 == MB_YESNOCANCEL. 6=Yes, 7=No, 2=Cancel (SDK). - const int r = ShowMessageBox(msg.c_str(), - "ReaSampler: delete non-empty bank", 3); - if (r == 2) return; // Cancel - if (r == 7) { // No -> evacuate, then delete empty - if (!book()->evacuate(bankId)) return; - // book() may have reallocated; re-resolve nothing (we pass the id again). - } - // r == 6 (Yes) falls through to a plain delete (drops members). - } - if (!book()->deleteBank(bankId)) return; - // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then- - // delete path moved/dropped members) — both change what a live instance could play. An - // empty-bank delete is purely organizational, no bump. - persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); - // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, - // nudge focus to the pool so the selection has a valid home. - if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; - invalidatePanel(); -} - -void doEvacuateBank(const std::string& bankId) { - if (!book()) return; - const Bank* bk = book()->bank(bankId); - if (!bk || bk->isPool()) return; - if (!book()->evacuate(bankId)) return; - persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); // S9: membership changed - invalidatePanel(); -} - -void doActivateBank(const std::string& bankId) { - if (!book()) return; - if (!book()->setActiveBank(bankId)) return; // rejects an unknown id - persistBankOp("ReaSampler: activate bank"); - invalidatePanel(); -} - -// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass -// ids straight to the model op (no BankIndex& cached across the loop's mutations). -// -// NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected): -// * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally -// before the dest add collapses on hash), so the index DID mutate — counts. -// * COPY collapse: the source is left intact AND the dest already held the hash, -// so NOTHING changed — a true index no-op. Must NOT open an undo point. -// Hence: copy counts only real gains (Copied); move counts gains OR collapses. -void transferSamples(const std::vector& sampleIds, - const std::string& srcBankId, const std::string& destBankId, - bool copy) { - if (!book()) return; - if (sampleIds.empty() || srcBankId == destBankId) return; - if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return; - int ok = 0, collapsed = 0; - for (const std::string& sid : sampleIds) { - const TransferResult r = - copy ? book()->copySample(sid, srcBankId, destBankId) - : book()->moveSample(sid, srcBankId, destBankId); - switch (r) { - case TransferResult::Moved: - case TransferResult::Copied: ++ok; break; - case TransferResult::Collapsed: ++collapsed; break; - case TransferResult::RejectedUnknownBank: - case TransferResult::RejectedSampleAbsent: - case TransferResult::RejectedSameBank: break; - } - } - const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); - if (!mutated) return; // nothing changed — no persist, no undo point - - const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)"; - persistBankOp(label, /*bumpGeneration=*/true); // S9: bank membership changed - // The selection indexed into the source; after a move those indices are stale, so - // clear it (the fingerprint pass will also clear, but do it now for immediacy). - g_panel.selection = Selection{}; - invalidatePanel(); -} - -// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to -// the file: a last-reference remove leaves the file on disk, orphaned until Phase R -// prune — remove NEVER deletes bytes (the manifest is untouched). Removes are silent -// (no confirm dialog); recoverability is provided by the batched REAPER undo (R-B) — -// one Ctrl-Z restores the index entry. Ids passed by value — no BankIndex& cached -// across the loop's mutations. -void removeSamples(const std::vector& sampleIds, - const std::string& srcBankId) { - if (!book() || sampleIds.empty()) return; - if (!book()->bank(srcBankId)) return; - - int removed = 0; - for (const std::string& sid : sampleIds) - if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) == - RemoveResult::Removed) - ++removed; - if (removed == 0) return; // nothing changed — no persist, no undo point - - persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); // S9: sample dropped - // The selection indexed into the source; after a remove those indices are stale, so - // clear it (the fingerprint pass will also clear, but do it now for immediacy). - g_panel.selection = Selection{}; - invalidatePanel(); -} - -// The selection's sample ids resolved against the FOCUSED region's bank (source of a -// move/copy). Returns ids in bank order; empty when nothing selected. -std::vector focusedSelectionIds() { - // L7: selection ordinals index the DISPLAY (slot) order, not BankIndex insertion order. - // orderedIds[i] is the id at selection ordinal i. - std::vector ids; - const RegionDisplay disp = focusedDisplay(); - const int count = disp.occupiedCount(); - for (int i : g_panel.selection.indices) - if (i >= 0 && i < count) ids.push_back(disp.orderedIds[static_cast(i)]); - return ids; -} - -// Resolves the ARMED drag payload (g_panel.dragSampleIds, from g_panel.dragSourceBankId) to -// the absolute, existing-file path list for a native OS drag-out (M11). Reuses the SAME M4 -// path machinery the panel uses for audition/insert (resolveBankFile over the current -// project dir) — no temp copies; the drag points straight at the on-disk bank files. Each -// id is looked up in its SOURCE bank's index (the payload's origin, not the focused region, -// which can differ once the pointer roams), resolved, stat'd, then handed to the pure -// drag_out::assemblePathList for dedupe + skip-missing/unresolved policy. Read-only: no -// mutation of sample / index / selection (invariant #2). -std::vector resolveDragPathsForOs() { - std::vector resolved; - BankBook* b = book(); - if (!b) return {}; - const BankIndex* idx = b->index(g_panel.dragSourceBankId); - if (!idx) return {}; - - const std::string projectDir = currentProjectDir(); - resolved.reserve(g_panel.dragSampleIds.size()); - for (const std::string& sid : g_panel.dragSampleIds) { - const Sample* s = idx->query(sid); - if (!s) continue; // stale id — the pure layer would skip it anyway; nothing to resolve - ResolvedSample rs; - rs.absolutePath = resolveBankFile(projectDir, s->relativePath); - rs.fileExists = !rs.absolutePath.empty() && fs::exists(fs::path(rs.absolutePath)); - resolved.push_back(std::move(rs)); - } - return assemblePathList(resolved).paths; -} - -// --- Popup menus -------------------------------------------------------------- -// -// SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu -// -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of -// (label -> small int command), track it at screen coords, and switch on the return. -// Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD -// hands the chosen id straight back, so no hookcommand routing is involved. - -// Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both -// accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative -// position appending. Win32 and SWELL both treat pos < 0 as an append. -void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) { - UINT flags = MF_BYPOSITION | MF_STRING; - if (grayed) flags |= MF_GRAYED; - InsertMenu(menu, -1, flags, id, text); -} -void menuSeparator(HMENU menu) { - InsertMenu(menu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr); -} - -// Menu command ids (local to a popup). -enum : unsigned int { - kMenuNone = 0, - kMenuActivate = 100, - kMenuRename, - kMenuDelete, - kMenuEvacuate, - kMenuCreate, - kMenuRemove, // remove selected sample(s) from the source bank (B5) - kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index - kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index -}; - -// Shows the right-click context menu for a named-bank TAB: activate / rename / delete -// / evacuate that bank, plus a create entry. Drives the id-keyed ops. -void showTabMenu(int screenX, int screenY, const std::string& bankId) { - if (!book()) return; - const Bank* bk = book()->bank(bankId); - if (!bk || bk->isPool()) return; - const bool isActive = book()->activeBankId() == bankId; - const bool nonEmpty = !bk->index.empty(); - - HMENU menu = CreatePopupMenu(); - menuAppend(menu, kMenuActivate, - isActive ? "Active (capture target)" : "Activate (make capture target)", - /*grayed=*/isActive); - menuSeparator(menu); - menuAppend(menu, kMenuRename, "Rename..."); - menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty); - menuAppend(menu, kMenuDelete, "Delete..."); - menuSeparator(menu); - menuAppend(menu, kMenuCreate, "New bank..."); - - const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, - g_panel.hwnd, nullptr); - DestroyMenu(menu); - - switch (cmd) { - case kMenuActivate: doActivateBank(bankId); break; - case kMenuRename: doRenameBank(bankId); break; - case kMenuEvacuate: doEvacuateBank(bankId); break; - case kMenuDelete: doDeleteBank(bankId); break; - case kMenuCreate: doCreateBank(); break; - default: break; - } -} - -// Opens the top-toolbar overflow ("⋯" More) popup at the button's screen position and fires the -// chosen rare-capture variant's command (L5 refinement 1). Menu ids are LOCAL to the popup -// (1-based ordinal into overflowMenuRows); TPM_RETURNCMD hands the chosen id back, then we -// resolve + fire the corresponding registered command id via the SAME contract the visible -// buttons use. Defined here (after menuAppend/menuSeparator); forward-declared above. -void showMoreMenu() { - if (!g_panel.hwnd) return; - const std::vector rows = overflowMenuRows(); - if (rows.empty()) return; - - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const MenuButtonRect mb = topMenuButtonRect(cr.right - cr.left); - if (mb.empty()) return; - - HMENU menu = CreatePopupMenu(); - for (std::size_t i = 0; i < rows.size(); ++i) { - const int cmd = resolveBarCommandId(rows[i]); - // Grey a variant not registered on this channel (defensive — all three are registered). - menuAppend(menu, static_cast(i + 1), rows[i].fullName.c_str(), - /*grayed=*/cmd == 0); - } - - // Anchor the popup at the button's bottom-left, in screen coords. - POINT pt{mb.x, mb.y + mb.height}; - ClientToScreen(g_panel.hwnd, &pt); - const int chosen = TrackPopupMenu(menu, TPM_RETURNCMD, pt.x, pt.y, 0, g_panel.hwnd, nullptr); - DestroyMenu(menu); - - if (chosen >= 1 && chosen <= static_cast(rows.size())) { - const int cmd = resolveBarCommandId(rows[static_cast(chosen - 1)]); - if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); - } -} - -// Shows the move/copy menu for the current selection (the SOURCE is the focused -// region's bank). Lists every OTHER bank (pool + named) as a move destination, then a -// copy submenu-free flat list (copy entries follow the move block). Move is the -// default (listed first); copy is the deliberate secondary act. -void showSelectionMenu(int screenX, int screenY) { - const std::vector sel = focusedSelectionIds(); - if (sel.empty()) return; - const std::string srcId = bankIdForRegion(g_panel.focusedRegion); - - // Destinations: pool + named banks, excluding the source. Ordinal order. - struct Dest { std::string id; std::string name; }; - std::vector dests; - if (srcId != std::string(kPoolBankId)) - dests.push_back({std::string(kPoolBankId), std::string(kPoolBankName)}); - for (const Bank* bk : namedBanks()) - if (bk->id != srcId) dests.push_back({bk->id, bk->displayName}); - - const std::string label = std::to_string(sel.size()) + - (sel.size() == 1 ? " sample" : " samples"); - - HMENU menu = CreatePopupMenu(); - // Move/copy blocks appear only when there is another bank to transfer to; Remove is - // always offered (it needs no destination — it drops the entry from the source). - if (!dests.empty()) { - menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); - for (std::size_t i = 0; i < dests.size(); ++i) - menuAppend(menu, kMenuMoveBase + static_cast(i), - (" " + dests[i].name).c_str()); - menuSeparator(menu); - menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true); - for (std::size_t i = 0; i < dests.size(); ++i) - menuAppend(menu, kMenuCopyBase + static_cast(i), - (" " + dests[i].name).c_str()); - menuSeparator(menu); - } - menuAppend(menu, kMenuRemove, ("Remove " + label + "...").c_str()); - - const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, - g_panel.hwnd, nullptr); - DestroyMenu(menu); - if (cmd == static_cast(kMenuRemove)) { - removeSamples(sel, srcId); - } else if (cmd >= static_cast(kMenuMoveBase) && - cmd < static_cast(kMenuMoveBase + dests.size())) { - transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false); - } else if (cmd >= static_cast(kMenuCopyBase) && - cmd < static_cast(kMenuCopyBase + dests.size())) { - transferSamples(sel, srcId, dests[cmd - kMenuCopyBase].id, /*copy=*/true); - } -} - -// --- Click routing ------------------------------------------------------------ - -// Handles a header/tab-strip/button click for the banks region. Returns true if the -// click was consumed (a region-chrome hit), false to fall through to grid selection. -bool handleBanksChromeClick(int x, int y, const RECT& region) { - // Full-height toggle button. - const RECT ftb = fullHtBtnRect(region); - if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { - bankPanelToggledBanksFullHeight(); - return true; - } - // "+" create button. - const RECT cb = createBtnRect(region); - if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) { - doCreateBank(); - return true; - } - // Tab strip: chevrons scroll, a tab click SHOWS that bank (browse — NOT activate). - const TabStripRect strip = banksTabStripRect(region); - const std::vector tabs = namedBanks(); - const int n = static_cast(tabs.size()); - const TabHit hit = hitTestTabStrip(x, y, strip, n, kTabSpec, g_panel.tabScroll); - if (hit.kind == TabHitKind::ScrollLeft || hit.kind == TabHitKind::ScrollRight) { - const TabStripLayout layout = - computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); - const int step = kTabSpec.tabWidth; - const int desired = g_panel.tabScroll + - (hit.kind == TabHitKind::ScrollLeft ? -step : step); - g_panel.tabScroll = clampTabScroll(desired, layout); - invalidatePanel(); - return true; - } - if (hit.kind == TabHitKind::Tab) { - const Bank* bk = tabs[static_cast(hit.index)]; - if (bk->id != g_panel.shownBankId) { - g_panel.shownBankId = bk->id; // browse: show this bank's grid - g_panel.selection = Selection{}; // grid changed — reset selection - stopAudition(); - } - g_panel.focusedRegion = Region::Banks; - invalidatePanel(); - return true; - } - return false; -} - -// Handles the pool region's full-height toggle. Returns true if consumed. -bool handlePoolChromeClick(int x, int y, const RECT& region) { - const RECT ftb = fullHtBtnRect(region); - if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { - bankPanelToggledPoolFullHeight(); - return true; - } - return false; -} - -// The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by -// mode_switch inside footer_bar's toggle box, so both draw and hit-test use the same box. -int footerToggleSegmentHit(int x, int y, int w, int h) { - if (!g_panel.session) return -1; - const FooterBarLayout fb = footerBarLayoutFor(w, h); - if (fb.toggle.empty()) return -1; - const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; - return hitTestSegment(x, y, th, modeCount()); -} - -// Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) / -// bottom toolbar / region chrome / grid selection, and arm a potential drag when the click -// lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom. -void handleClick(int x, int y) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - - // TOP toolbar: the far-right More button first (its rect sits in the band's reserved right - // strip, outside the action rect), then the frequent capture/placement buttons. A button - // fires its registered action via the command-id contract; the band is claimed whole (a - // gap/overflow miss is a harmless no-op, never a fall-through). Capture never auto-inserts. - { - const MenuButtonRect mb = topMenuButtonRect(w); - if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; } - } - if (handleToolbarClick(x, y, topToolbarActionRect(w), topBarRows())) return; - // Claim the WHOLE top band (including the reserved right strip between the last button and - // the More button) so a click there is inert chrome, never a fall-through to the grid. - if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return; - - // Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design] - // toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a - // click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar / - // grid so a footer click never selects a cell. - { - const int seg = footerToggleSegmentHit(x, y, w, h); - if (seg >= 0) { - const std::vector& modes = g_panel.session->view().modes().all(); - if (seg < static_cast(modes.size())) { - applyMode(g_panel.session->view(), - modes[static_cast(seg)].id, nullptr); - invalidatePanel(); - } - return; - } - - const FooterBarLayout fb = footerBarLayoutFor(w, h); - if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) { - // Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates - // the SESSION's tail setting (capture reads it; persist saves it with the project) - // and marks the project dirty — touches NOTHING in the bank/arrange. - TailSetting& tail = g_panel.session->tail(); - tail.mode = cycleTailMode(tail.mode); - markTailDirty(); - invalidatePanel(); - return; - } - - // Prune button (R3): fires the "Prune bank folder" action THROUGH its registered - // command id (fork R-E: dispatch the command, not the session directly) so the panel - // affordance and the bindable action share the one guarded dry-run/confirm/delete path - // in doBankPruneFolder. A 0 id (pre-registration) no-ops. - const ButtonRect pb = pruneButtonRectFor(w, h); - if (hitTestPruneButton(x, y, pb)) { - const int cmd = bankPruneCommandId(); - if (cmd != 0) Main_OnCommand(cmd, 0); - return; - } - } - - // BOTTOM toolbar (Design-View verbs): a button fires its registered action via the - // command-id contract. Claimed whole like the top toolbar. - if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return; - - // Region chrome (headers, tab strip, buttons). - if (poolShown()) { - const RECT pr = poolRegionRect(w, h); - if (y >= pr.top && y < regionGridRect(pr, false).top) { - if (handlePoolChromeClick(x, y, pr)) return; - } - } - if (banksShown()) { - const RECT br = banksRegionRect(w, h); - if (y >= br.top && y < regionGridRect(br, true).top) { - if (handleBanksChromeClick(x, y, br)) return; - } - } - - // Grid selection. Resolve which region's grid the point is in. - Region reg = Region::Pool; - if (!regionAt(x, y, reg)) return; - const bool isBanks = reg == Region::Banks; - const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - // L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An - // empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a - // click on a gap clears selection, exactly like a click in the margin) — empty slots - // are decorative, not selectable. - const RegionDisplay disp = regionDisplay(region, isBanks, reg); - const int hitSlot = hitTestSlot(x, y, disp.slotRects); - const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot); - const int count = disp.occupiedCount(); - - // Switching focus region reseeds the selection there. - if (g_panel.focusedRegion != reg) { - g_panel.focusedRegion = reg; - g_panel.selection = Selection{}; - stopAudition(); - } - - if (hit < 0) { - if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { - g_panel.selection = Selection{}; - stopAudition(); - } - invalidatePanel(); - return; - } - - // Drag-arm disambiguation for plain (no ctrl, no shift) presses on a grid cell: - // - // • Already-selected cell: defer the selection change to LBUTTONUP so a plain - // press on a multi-selection doesn't collapse it before we know whether a drag - // will happen. Arm the drag with the current (multi-)selection as the payload - // candidate; only the caret moves immediately. - // - // • Unselected cell: apply the plain-click selection immediately (collapses to - // the single pressed cell) THEN arm a drag from it — so the user can press-and- - // drag in one gesture without a prior selecting click. The selection is set - // before arming so that focusedSelectionIds() resolves the right payload when - // the threshold is crossed in onMouseMove. - // - // ctrl / shift presses are selection-only gestures — no drag arm in either case. - const bool onSelected = g_panel.selection.contains(hit); - if (!ctrlDown() && !shiftDown()) { - if (!onSelected) { - // Commit the single-cell selection now so the drag payload is correct. - g_panel.selection = applyClick(g_panel.selection, hit, false, false, count); - g_panel.selItemCount = count; - } else { - // Move the caret to the pressed cell; defer collapsing multi-selection. - g_panel.selection.focus = hit; - } - g_panel.dragArmed = true; - g_panel.dragStartX = x; - g_panel.dragStartY = y; - g_panel.dragSourceRegion = reg; - // Capture the mouse NOW so WM_MOUSEMOVE is delivered even when the pointer leaves the - // panel client rect before the drag threshold is crossed. Without capture, outside moves - // are not delivered, so a fast straight-out drag never transitions dragArmed → dragging - // and the OS drag-out never fires on the first pass. The capture is released on button-up - // (no drag: onLBtnUp dragArmed branch; drag: OsDrag path or onLBtnUp dragging branch) - // and on WM_CAPTURECHANGED (stolen or external release — already calls resetDragState). - SetCapture(g_panel.hwnd); - invalidatePanel(); - return; - } - - g_panel.selection = applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count); - g_panel.selItemCount = count; - invalidatePanel(); -} - -// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`. -// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is -// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens, -// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns -// false so the caller can let REAPER/the docker handle the wheel normally). On a real -// change it mutates the SESSION's tail setting, marks the project dirty (so it saves), -// and repaints the live length. Returns true iff the wheel was consumed. -bool handleWheel(int x, int y, int delta) { - if (!g_panel.session) return false; - if (!pointInFooter(x, y)) return false; - - TailSetting& tail = g_panel.session->tail(); - if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only - - // One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad - // that sends fractional deltas still steps predictably. Sign carries direction. - const int notches = delta / 120; - if (notches == 0) return false; // sub-notch movement — nothing to apply yet - - const double before = tail.manualMs; - tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs); - if (tail.manualMs == before) return true; // already at a bound — consumed, no change - - markTailDirty(); - invalidatePanel(); // label shows the new length live - return true; -} - -// The column count for a region's current grid width (nav needs the layout's wrap). -int columnsForRegion(Region reg) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const RECT region = reg == Region::Banks ? banksRegionRect(w, h) - : poolRegionRect(w, h); - const RECT grid = regionGridRect(region, reg == Region::Banks); - return columnsForWidth(grid.right - grid.left, kGrid); -} - -bool isOurWindow(HWND hwnd) { - for (HWND w = hwnd; w; w = GetParent(w)) - if (w == g_panel.hwnd) return true; - return false; -} - -bool handleKey(int vk) { - const int count = focusedItemCount(); - if (count <= 0) return false; - - switch (vk) { - case VK_LEFT: - case VK_RIGHT: - case VK_UP: - case VK_DOWN: { - const NavKey nk = vk == VK_LEFT ? NavKey::Left - : vk == VK_RIGHT ? NavKey::Right - : vk == VK_UP ? NavKey::Up - : NavKey::Down; - g_panel.selection = navigate(g_panel.selection, nk, - columnsForRegion(g_panel.focusedRegion), - count, shiftDown()); - g_panel.selItemCount = count; - invalidatePanel(); - return true; - } - case VK_RETURN: - case VK_SPACE: - if (g_panel.selection.focus >= 0) - startAudition(g_panel.selection.focus); - return true; - case VK_ESCAPE: - stopAudition(); - return true; - case VK_DELETE: { - // Remove the focused-region selection (B5). Silent; a no-op when nothing - // is selected. - const std::vector sel = focusedSelectionIds(); - if (sel.empty()) return false; // nothing selected — let the key fall through - removeSamples(sel, bankIdForRegion(g_panel.focusedRegion)); - return true; - } - default: - return false; - } -} - -int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) { - if (!msg || msg->message != WM_KEYDOWN) return 0; - if (!g_panel.open || !g_panel.hwnd) return 0; - if (!isOurWindow(GetFocus())) return 0; - return handleKey(static_cast(msg->wParam)) ? 1 : 0; -} - -accelerator_register_t g_accel{translateAccel, true, nullptr}; -bool g_accelRegistered = false; - -void registerAccel() { - if (g_accelRegistered || !g_rec) return; - g_rec->Register("accelerator", &g_accel); - g_accelRegistered = true; -} - -void unregisterAccel() { - if (!g_accelRegistered || !g_rec) return; - g_rec->Register("-accelerator", &g_accel); - g_accelRegistered = false; -} - -// --- Drag (move between regions/onto a tab) ----------------------------------- - -constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag - -// Resolves the drop target under client (x, y) during a drag, updating dropKind / -// dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank; -// anywhere else -> none. -void updateDropTarget(int x, int y) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - - if (banksShown()) { - const RECT br = banksRegionRect(w, h); - const TabStripRect strip = banksTabStripRect(br); - const std::vector tabs = namedBanks(); - const TabHit hit = hitTestTabStrip(x, y, strip, - static_cast(tabs.size()), kTabSpec, - g_panel.tabScroll); - if (hit.kind == TabHitKind::Tab) { - g_panel.dropKind = DropKind::Tab; - g_panel.dropBankId = tabs[static_cast(hit.index)]->id; - return; - } - // Tab takes precedence over the region; if the point is in the banks region but - // not on a specific tab, treat the whole grid as a drop zone for the shown bank. - // No valid target when there are no named banks or no shown bank. - if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) { - if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) { - g_panel.dropKind = DropKind::BanksRegion; - g_panel.dropBankId = g_panel.shownBankId; - return; - } - } - } - if (poolShown()) { - const RECT pr = poolRegionRect(w, h); - const RECT grid = regionGridRect(pr, false); - if (x >= grid.left && x < grid.right && y >= grid.top && y < grid.bottom) { - g_panel.dropKind = DropKind::PoolRegion; - return; - } - } -} - -// The destination bank id under the current drop target (pool id for PoolRegion; the tab/ -// shown-bank id for Tab/BanksRegion; "" for no target). Derived from updateDropTarget's -// dropKind/dropBankId — the single source of "what bank is under the pointer". -std::string dropTargetBankId() { - switch (g_panel.dropKind) { - case DropKind::PoolRegion: return std::string(kPoolBankId); - case DropKind::Tab: - case DropKind::BanksRegion: return g_panel.dropBankId; - case DropKind::None: return {}; - } - return {}; -} - -// L7: classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop) -// the target slot, updating g_panel.cardGesture / dragTargetSlot. Call AFTER updateDropTarget -// so dropKind/dropBankId are current. The pure card_drag::decideCardGesture owns the -// precedence (leave-client -> OS; other-bank -> move/copy; same-bank grid -> reorder/replace); -// the shell only supplies the region verdict, the same-bank target slot + occupancy, and the -// live modifier state. The OS-drag-out boundary is handled by the existing decideGesture path -// in onMouseMove BEFORE this runs, so here the pointer is always inside the client. -void classifyCardDrag(int x, int y) { - g_panel.cardGesture = CardGesture::None; - g_panel.dragTargetSlot = -1; - - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const PanelClientRect client{cr.left, cr.top, w, h}; - - const std::string destBank = dropTargetBankId(); - DragModifiers mods; - mods.ctrl = ctrlDown(); - mods.alt = altDown(); - - if (!destBank.empty() && destBank == g_panel.dragSourceBankId) { - // Same-bank grid: a reorder/replace target. Resolve the slot the pointer sits over - // in the SOURCE bank's own region display + whether it is occupied. - // Uses computeSlotRectsForDrop (one trailing row past maxSlot) so a drop beyond - // the last occupied card resolves to a valid trailing slot, not a -1 miss. - mods.region = DropRegion::SameBankGrid; - const bool isBanks = g_panel.dragSourceRegion == Region::Banks; - const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); - const RECT grid = regionGridRect(region, isBanks); - const int gridW = grid.right - grid.left; - const std::vector dropRects = - computeSlotRectsForDrop(disp.bank ? disp.bank->slots.maxSlot() : -1, - gridW, kGrid); - // Translate the drop rects to client space (matching regionDisplay's translation). - std::vector dropRectsClient = dropRects; - for (SlotCellRect& r : dropRectsClient) { r.x += grid.left; r.y += grid.top; } - const int slot = hitTestSlot(x, y, dropRectsClient); - mods.targetSlot = slot; - mods.slotOccupied = slot >= 0 && !disp.idAtSlot(slot).empty(); - g_panel.dragTargetSlot = slot; - } else if (!destBank.empty()) { - mods.region = DropRegion::OtherBankOrTab; // move/copy to a different bank/tab - } else { - mods.region = DropRegion::DeadSpace; // header/footer/gap — a no-op drop - } - - const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; - g_panel.cardGesture = decideCardGesture(x, y, client, st, mods); -} - -// Maps the pure L7 cursor cue to a SWELL stock cursor and sets it. The cue DECISION is pure -// (card_drag::cursorForGesture); the shell owns only this SetCursor call + the resource choice. -// Stock SWELL cursors (vendor/WDL/WDL/swell/swell-types.h:1320-1329, mirroring the Win32 OCR_* -// set): Reorder -> IDC_SIZEALL (four-way move, the file-manager reorder idiom); Move -> -// IDC_HAND (grab-and-place to another bank/tab); Copy -> IDC_UPARROW (no stock copy cursor -// exists cross-platform — this is the closest distinct stock cue; a bespoke copy cursor would -// need a resource file, deliberately NOT added); Replace -> IDC_SIZEWE (a distinct "swap -// occupant" cue, shown ONLY when the pure result is Replace, i.e. Alt over an occupied slot); -// OsDragOut -> the OS drag loop owns the cursor once handed off, so leave it (arrow here is -// never seen — the handoff happens before this runs); Default/None -> IDC_ARROW. -void applyDragCursor(CardGesture g) { - const char* idc = IDC_ARROW; - switch (cursorForGesture(g)) { - case CursorCue::Reorder: idc = IDC_SIZEALL; break; - case CursorCue::Move: idc = IDC_HAND; break; - case CursorCue::Copy: idc = IDC_UPARROW; break; - case CursorCue::Replace: idc = IDC_SIZEWE; break; - case CursorCue::OsDragOut: return; // OS drag owns the cursor; do not fight it - case CursorCue::Default: idc = IDC_ARROW; break; - } - SetCursor(LoadCursor(nullptr, idc)); -} - -// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring -// handleClick's precedence exactly (so the element that lights on hover is the one a click -// would hit). Returns HoverKind::None for the grid / dead space / a point outside the client -// (the grid cells carry their own selection/focus chrome, not a kit hover surface). Pure -// resolution over the same pure geometry the click path uses. -Hover resolveHover(int x, int y) { - if (!g_panel.hwnd) return Hover{}; - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - - // TOP toolbar: the far-right More button, then the frequent buttons (matching the click - // order — first zone top-to-bottom). - { - const MenuButtonRect mb = topMenuButtonRect(w); - if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1}; - const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows()); - if (hit >= 0) return Hover{HoverKind::TopBarButton, hit}; - } - // Footer: mode-toggle segments, Tail button, then Prune (matching the click order). - { - const int seg = footerToggleSegmentHit(x, y, w, h); - if (seg >= 0) return Hover{HoverKind::ModeSegment, seg}; - const FooterBarLayout fb = footerBarLayoutFor(w, h); - if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1}; - const ButtonRect pb = pruneButtonRectFor(w, h); - if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1}; - } - // BOTTOM toolbar buttons. - { - const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows()); - if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit}; - } - // Region chrome: full-height toggles, create button, tabs. - if (poolShown()) { - const RECT pr = poolRegionRect(w, h); - const RECT ftb = fullHtBtnRect(pr); - if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) - return Hover{HoverKind::FullHtPool, -1}; - } - if (banksShown()) { - const RECT br = banksRegionRect(w, h); - const RECT ftb = fullHtBtnRect(br); - if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) - return Hover{HoverKind::FullHtBanks, -1}; - const RECT cb = createBtnRect(br); - if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) - return Hover{HoverKind::CreateBank, -1}; - const TabStripRect strip = banksTabStripRect(br); - const std::vector tabs = namedBanks(); - const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), - kTabSpec, g_panel.tabScroll); - if (hit.kind == TabHitKind::Tab) return Hover{HoverKind::Tab, hit.index}; - } - return Hover{}; -} - -// Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no -// per-move jank — the "speed is the selling point" repaint discipline). L5: a hover CHANGE also -// resets the tooltip timer (hoverSinceTick) and hides any shown tooltip, so the tooltip only -// appears after the pointer rests kTooltipDelayMs on ONE element (the delay is applied by the -// poll tick in maybeShowTooltip). A move within the SAME element leaves the timer running. -void updateHover(int x, int y) { - const Hover next = resolveHover(x, y); - if (next != g_panel.hovered) { - g_panel.hovered = next; - g_panel.hoverSinceTick = GetTickCount(); - if (g_panel.tooltipShown) { g_panel.tooltipShown = false; } - invalidatePanel(); - } -} - -// Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past -// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the -// OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer; -// WM_MOUSEMOVE's updateHover resets the timer, so a moving pointer never trips it. No-op when the -// current hover has no tooltip (grid / chrome / the More button). -void maybeShowTooltip() { - if (g_panel.tooltipShown) return; - const HoverKind k = g_panel.hovered.kind; - if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return; - const unsigned int now = GetTickCount(); - if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) { - g_panel.tooltipShown = true; - invalidatePanel(); - } -} - -void onMouseMove(int x, int y) { - // Hover feedback (L2): resolve + repaint-on-change, but NOT during a drag (the drag owns - // the visual feedback then — a drop-target highlight, not a hover). Cleared to None when - // the pointer is over the grid / dead space. - if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); - - if (g_panel.dragArmed && !g_panel.dragging) { - if (std::abs(x - g_panel.dragStartX) > kDragThreshold || - std::abs(y - g_panel.dragStartY) > kDragThreshold) { - // Threshold crossed — begin the drag. Snapshot the payload NOW. - g_panel.dragging = true; - g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); - g_panel.dragSampleIds = focusedSelectionIds(); - // The single card actually grabbed = the focus ordinal's id. This is the L7 - // in-grid reorder/replace subject (see onLBtnUp) — "drag a card" is a single-card - // gesture, distinct from the multi-select move/copy payload in dragSampleIds. - { - const RegionDisplay disp = focusedDisplay(); - const int f = g_panel.selection.focus; - g_panel.dragPrimaryId = - (f >= 0 && f < disp.occupiedCount()) - ? disp.orderedIds[static_cast(f)] : std::string{}; - } - g_panel.hovered = Hover{}; // clear hover — the drag owns the visual feedback now - g_panel.tooltipShown = false; // a drag never shows a tooltip - // SetCapture was already called at drag-arm time (handleClick); no re-capture needed. - } - } - if (g_panel.dragging) { - // M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the - // pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant - // #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture - // splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an - // InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a - // pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays - // Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint. - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top}; - const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom); - - DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; - st.singleCapture = (g_panel.dragSampleIds.size() == 1); - - // Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can - // only arise there) and only for a single-capture payload — the SDK hit-test is skipped - // on the common internal-drag path so it costs nothing there. The screen conversion is - // Windows-only (D5); resolveFxDropTarget owns the REAPER hit query. - FxDropTarget fx; - if (!inside && st.singleCapture) { - POINT sp{x, y}; - ClientToScreen(g_panel.hwnd, &sp); - fx = resolveFxDropTarget(sp.x, sp.y); - st.overReaperUi = fx.overReaperUi; - } - - const DragGesture gesture = decideGesture(x, y, client, st); - - if (gesture == DragGesture::InstrumentDrop) { - // Track the FX hotspot for the release; the highlight is REAPER's own FX-button - // hover feedback under the pointer (the drop is driven on button-up). We keep the - // internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does - // NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the - // panel does not also paint a bank-drop cue while the drag is out over a track. - g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr; - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - invalidatePanel(); - return; - } - - // Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target. - g_panel.instrumentDropTrack = nullptr; - - if (gesture == DragGesture::OsDrag) { - // Resolve the payload to existing on-disk paths BEFORE tearing down internal - // drag state (the resolver reads dragSourceBankId / dragSampleIds). - const std::vector paths = resolveDragPathsForOs(); - - // Reset internal drag state and release capture NOW: DoDragDrop runs its own - // modal loop and takes over mouse capture, so the internal drag must be fully - // wound down first (no stale dragging/dropKind, no lingering SetCapture). A - // cancelled/empty OS drag therefore leaves the panel in a clean, no-op state - // (invariant #2 — nothing mutated). - if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - g_panel.dragArmed = false; - g_panel.dragging = false; - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - g_panel.cardGesture = CardGesture::None; - g_panel.dragTargetSlot = -1; - g_panel.dragPrimaryId.clear(); - invalidatePanel(); - - // Empty path list -> nothing draggable (all stale/missing); do not start a drag. - if (!paths.empty()) - initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows - return; - } - // Inside the client: classify the in-grid gesture (L7 reorder/replace vs the existing - // move/copy) and reflect it as a cursor cue. updateDropTarget first so dropKind/ - // dropBankId are current for classifyCardDrag's same-vs-other-bank decision. - updateDropTarget(x, y); - classifyCardDrag(x, y); - applyDragCursor(g_panel.cardGesture); - invalidatePanel(); - } -} - -// L7 in-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap- -// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the pure -// BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z (persistBankOp opens the -// batched undo point + saves). A no-op reorder (already at the target, model returns false) -// opens no undo point. Selection reasons over slot order, so it is cleared after — the -// fingerprint pass rebuilds it against the new order. -void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) { - if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return; - if (!book()->reorderSample(id, bankId, targetSlot)) return; // rejected/no-op: no undo point - persistBankOp("ReaSampler: reorder sample"); - g_panel.selection = Selection{}; - invalidatePanel(); -} - -// L7 Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is -// removed from the bank's index (index-only, file untouched — pool guard enforced in the pure -// BankBook::replaceSample). Rejected (pool guard / absent) = a true NO-OP: no fallback insert, -// no undo point (per spec). One drop = one Ctrl-Z on success. -void doReplaceDrop(const std::string& newId, const std::string& oldId, - const std::string& bankId) { - if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return; - if (!book()->replaceSample(newId, oldId, bankId)) return; // pool-guard reject: NO-OP - persistBankOp("ReaSampler: replace sample"); - g_panel.selection = Selection{}; - invalidatePanel(); -} - -// Clears all drag-state fields to their resting values. Called from every exit path -// (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields -// stays consistent across all four sites. -void resetDragState() { - g_panel.dragArmed = false; - g_panel.dragging = false; - g_panel.dropKind = DropKind::None; - g_panel.dropBankId.clear(); - g_panel.cardGesture = CardGesture::None; - g_panel.dragTargetSlot = -1; - g_panel.dragPrimaryId.clear(); - g_panel.instrumentDropTrack = nullptr; -} - -// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides: -// * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each. -// * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy). -// * None -> a drop over dead space / the source-bank gap = no-op. -// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove. -void onLBtnUp(int x, int y) { - if (g_panel.dragging) { - // S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a - // ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move, - // NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank - // drop (the pointer is out over a track, not over a bank region). Single-capture only (the - // gesture never armed for a multi payload), so dragSampleIds.front() is the capture. - if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) { - const std::string sampleId = g_panel.dragSampleIds.front(); - performInstrumentDrop(g_panel.instrumentDropTrack, - buildInstrumentDropPreset(sampleId)); - // Read-only over the bank + arrange: the ONLY mutations are the new FX instance + - // its state (both undoable in performInstrumentDrop). No book change, no ext-state, - // no dirty-mark here. - } else { - updateDropTarget(x, y); - classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) - const CardGesture g = g_panel.cardGesture; - - if (g == CardGesture::Reorder) { - doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId, - g_panel.dragTargetSlot); - } else if (g == CardGesture::Replace) { - // Replace targets the OCCUPANT of the target slot with the single grabbed card. - const bool isBanks = g_panel.dragSourceRegion == Region::Banks; - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); - const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot); - // Replace only makes sense for a single grabbed card over a DIFFERENT occupant. - if (!occupant.empty() && occupant != g_panel.dragPrimaryId) - doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId); - } else if (g == CardGesture::Move || g == CardGesture::Copy) { - const std::string destId = dropTargetBankId(); - if (!destId.empty() && destId != g_panel.dragSourceBankId && - !g_panel.dragSampleIds.empty()) { - transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, - /*copy=*/g == CardGesture::Copy); - } - } - } - // CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None). - SetCursor(LoadCursor(nullptr, IDC_ARROW)); // restore the arrow on drop - if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - } else if (g_panel.dragArmed) { - // Press-release on a selected cell with no drag: treat as a plain click that - // collapses the multi-selection to the pressed cell (standard behavior). - // Release capture acquired at arm time (handleClick) — drag never started. - if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - const BankIndex* idx = indexForRegion(g_panel.focusedRegion); - const int count = idx ? static_cast(idx->size()) : 0; - const int focus = g_panel.selection.focus; - if (focus >= 0) - g_panel.selection = applyClick(g_panel.selection, focus, false, false, count); - } - resetDragState(); - invalidatePanel(); -} - -// A right-click: on a named tab -> the tab management menu; on a grid cell of the -// focused region with a selection -> the move/copy menu. -void handleRightClick(int x, int y) { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - - // Tab management menu. - if (banksShown()) { - const RECT br = banksRegionRect(w, h); - const TabStripRect strip = banksTabStripRect(br); - const std::vector tabs = namedBanks(); - const TabHit hit = hitTestTabStrip(x, y, strip, - static_cast(tabs.size()), kTabSpec, - g_panel.tabScroll); - if (hit.kind == TabHitKind::Tab) { - POINT pt{x, y}; - ClientToScreen(g_panel.hwnd, &pt); - showTabMenu(pt.x, pt.y, tabs[static_cast(hit.index)]->id); - return; - } - } - - // Grid selection menu (move/copy). Only when the right-click lands in the focused - // region's grid and there is a selection. - Region reg = Region::Pool; - if (regionAt(x, y, reg) && reg == g_panel.focusedRegion && - !g_panel.selection.empty()) { - POINT pt{x, y}; - ClientToScreen(g_panel.hwnd, &pt); - showSelectionMenu(pt.x, pt.y); - } -} - -// --- Dialog proc + docking ---------------------------------------------------- - -// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands -// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all into the active -// bank (bank-fill only — no assignment to any live instance). Always DragFinish's the HDROP -// (frees the shell-allocated drop buffer) on every path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) -// returns the file count; then each path is queried by index. Both Win32 and SWELL expose -// DragQueryFile/DragFinish with this contract. -void handleDropFiles(HDROP hDrop) { - std::vector paths; - const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0); - paths.reserve(count); - for (UINT i = 0; i < count; ++i) { - // Query the required length first (excludes the NUL), then read into a sized buffer. - const UINT len = DragQueryFile(hDrop, i, nullptr, 0); - if (len == 0) continue; - std::vector buf(static_cast(len) + 1, '\0'); - DragQueryFile(hDrop, i, buf.data(), static_cast(buf.size())); - std::string p(buf.data()); - if (!p.empty()) paths.push_back(std::move(p)); - } - DragFinish(hDrop); - if (!paths.empty()) ingestDroppedFiles(paths); -} - -WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { - switch (msg) { - case WM_DROPFILES: - // S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import - // into the active bank (bank-fill only). wParam is the HDROP. - handleDropFiles(reinterpret_cast(wParam)); - return 0; - case WM_PAINT: { - PAINTSTRUCT ps; - HDC hdc = BeginPaint(hwnd, &ps); - paintPanel(hwnd, hdc); - EndPaint(hwnd, &ps); - return 0; - } - case WM_LBUTTONDOWN: { - SetFocus(hwnd); - handleClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - } - case WM_MOUSEMOVE: - onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - case WM_LBUTTONUP: - onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - case WM_RBUTTONDOWN: - SetFocus(hwnd); - handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - case WM_CAPTURECHANGED: - // Capture lost (pointer left window pre-threshold and released outside, or another - // window stole capture mid-drag) — cancel the whole drag as a NO-OP so no stale - // state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is - // mutated on a cancel; the cursor is restored to the arrow. - if (g_panel.dragArmed || g_panel.dragging) { - resetDragState(); - SetCursor(LoadCursor(nullptr, IDC_ARROW)); - invalidatePanel(); - } - return 0; - case WM_MOUSEWHEEL: { - // Fine-adjust the Manual tail length when the wheel is over the footer. - // UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in - // lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so - // convert to client space before hit-testing the footer. The signed wheel - // delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120, - // matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer - // handler acts, so scrolling elsewhere in the dock still behaves normally. - POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; - ScreenToClient(hwnd, &pt); - const int delta = static_cast(HIWORD(wParam)); - return handleWheel(pt.x, pt.y, delta) ? 1 : 0; - } - case WM_DESTROY: - if (GetCapture() == hwnd) ReleaseCapture(); - stopAudition(); - g_panel.selection = Selection{}; - resetDragState(); - g_panel.hovered = Hover{}; - g_panel.tooltipShown = false; - g_panel.hwnd = nullptr; - g_panel.open = false; - return 0; - default: - break; - } - return 0; -} - -void openPanel() { - if (g_panel.open && g_panel.hwnd) { - DockWindowActivate(g_panel.hwnd); - return; - } - initPreview(); - - // Create the kit's cached AA fonts before the first paint (Phase L, L1). Idempotent, so - // a reopen after closePanel (which leaves the fonts alive) is a cheap no-op; the fonts - // are torn down once at bankPanelShutdown. All panel text draws through these. - kitFontsInit(); - - g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), - GetMainHwnd(), dlgProc, 0); - if (!g_panel.hwnd) return; - - // Channel-qualified dock identity (Phase V, V4). The title and the persisted-position - // identstr both come from app_version, so a beta panel is distinguishable ("ReaSampler - // Bank beta") and does not fight over stable's saved dock slot (the identstr is a - // REAPER-global collision surface — it keys the persisted dock position). - DockWindowAddEx(g_panel.hwnd, dockTitle().c_str(), dockIdent().c_str(), true); - DockWindowActivate(g_panel.hwnd); - g_panel.open = true; - - // S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles). - // DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it, - // so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3 - // instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration - // surface is out of scope for this dispatch. WM_DROPFILES handling itself uses - // DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means - // would still ingest — only the accept opt-in is gated. -#ifdef _WIN32 - DragAcceptFiles(g_panel.hwnd, TRUE); -#endif - - registerAccel(); - - reconcileShownBank(); - refreshFingerprint(); -} - -void closePanel() { - if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - stopAudition(); - g_panel.selection = Selection{}; - resetDragState(); - unregisterAccel(); - if (g_panel.hwnd) { - DockWindowRemove(g_panel.hwnd); - DestroyWindow(g_panel.hwnd); - g_panel.hwnd = nullptr; - } - g_panel.open = false; -} - -} // namespace - -// --- Public API --------------------------------------------------------------- - -void bankPanelInit(ReaSamplerSession* session) { - g_panel.session = session; -} - -// Returns true only when the panel window is actually visible to the user right now. -// IsWindowVisible() returns false when the docker is hidden via Alt+D even though the -// HWND and g_panel.open are still live — the live query is the source of truth for -// toggle decisions and the Actions-list checkmark (OnToggleAction in main.cpp). -static bool panelEffectivelyVisible() { - return g_panel.hwnd && IsWindowVisible(g_panel.hwnd); -} - -void bankPanelToggle() { - // Decide from live visibility, not the cached g_panel.open flag. - // Alt+D hides the docker without destroying the window, leaving g_panel.open - // stale (true) while the panel is gone. Using IsWindowVisible avoids the - // double-fire needed to re-show the panel after a docker hide. - if (panelEffectivelyVisible()) - closePanel(); - else - openPanel(); -} - -bool bankPanelIsOpen() { - // Derive from live window state so the Actions-list checkmark stays honest - // even after Alt+D hides the docker without notifying the extension. - return panelEffectivelyVisible(); -} - -std::vector bankPanelSelectedSampleIds() { - return focusedSelectionIds(); -} - -std::string bankPanelSelectedSourceBankId() { - // The focused region's displayed bank is the move/copy source. Default to the - // pool (a safe source) when nothing is selected / the panel never opened. - if (g_panel.selection.empty()) return std::string(kPoolBankId); - const std::string id = bankIdForRegion(g_panel.focusedRegion); - return id.empty() ? std::string(kPoolBankId) : id; -} - -void bankPanelNotifyProjectLoaded() { - // Persist restored a project's membership + active mode this tick (main.cpp calls - // this from the same consumeLoadSignal() branch that reapplies the active mode). - // Arm the new-content detector to re-baseline on its next tick so the just-loaded - // project's pre-existing content is treated as the baseline (nothing new) rather - // than diffed against the previous project and mass-tagged into the active mode. - // A flag (not an inline reset) because detectNewContent owns the baseline and runs - // later in the SAME OnTimer tick — it drains this and re-baselines against the live - // set in one place, keeping the reset and the observe() adjacent and ordered. - g_panel.reloadPending = true; -} - -void bankPanelRefresh() { - // New-content auto-tag detection runs EVERY tick regardless of panel open/close: - // tracks/items are created in the arrange view, not the panel, so detection must - // not be gated on the dock being visible. READ-ONLY on the project; only mutates - // the in-memory membership index (persist saves it like any action-driven tag). - const bool tagged = detectNewContent(); - - // Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a - // track can only newly become multi-mode when auto-tag placed content on it. Unlike - // the invisible membership tag above, minting is a visible structural mutation - // (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo - // block and only mints for tracks that hold >1 mode's content — a single-mode track - // is left to D1 whole-track parking. Managed lanes only; manual lanes untouched. - if (tagged && g_panel.session) { - ReaProject* proj = EnumProjects(-1, nullptr, 0); - mintManagedLanes(g_panel.session->view(), proj); - } - - if (!g_panel.open || !g_panel.hwnd) return; - - // L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a - // toolbar button has rested under the pointer past the delay, latch + repaint the tooltip. - maybeShowTooltip(); - - if (refreshFingerprint()) - InvalidateRect(g_panel.hwnd, nullptr, FALSE); -} - -TailSetting bankPanelTailSetting() { - // The authoritative setting lives in the session (session->tail()) so it travels - // inside the .rpp: it loads per project and saves with the project. This stays the - // read seam for the capture actions. manualMs is clamped here so a caller always - // receives a within-cap length regardless of what was stored/scrolled. - TailSetting s = currentTail(); - s.manualMs = clampManualMs(s.manualMs); - return s; -} - -BankPanelFullHeight bankPanelFullHeight() { - return g_panel.fullHeight; -} - -static void setFullHeight(BankPanelFullHeight target) { - g_panel.fullHeight = - (g_panel.fullHeight == target) ? BankPanelFullHeight::Split : target; - if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); -} - -void bankPanelToggledPoolFullHeight() { - setFullHeight(BankPanelFullHeight::PoolOnly); -} - -void bankPanelToggledBanksFullHeight() { - setFullHeight(BankPanelFullHeight::BanksOnly); -} - -void bankPanelInvalidate() { - if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); -} - -void bankPanelShutdown() { - closePanel(); - deinitPreview(); - kitFontsShutdown(); // free the kit's cached AA fonts + their owned HFONTs (L1) - g_panel.cache.clear(); - g_panel.session = nullptr; -} - -} // namespace reasampler diff --git a/src/bank_panel.h b/src/bank_panel.h deleted file mode 100644 index 8a3b5c8..0000000 --- a/src/bank_panel.h +++ /dev/null @@ -1,130 +0,0 @@ -#pragma once -// bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns -// a SWELL dialog docked via DockWindowAddEx, and paints the current project's -// bank as a grid of LICE-drawn waveform thumbnails. The panel itself NEVER inserts -// into the arrange or mutates the project/bank (CONTEXT.md §load-bearing -// principle). Audition / multi-select / keyboard nav are Wave B. -// -// The header is REAPER-free as practical: main.cpp drives the panel through these -// free functions, passing the live session so the panel reads the current bank. -// All SWELL / LICE / PCM_source use is confined to bank_panel.cpp. The pure -// layout math and cache keys live in bank_grid (unit-tested outside the DAW). - -#include -#include - -#include "tail_control.h" // TailSetting — the panel's tail-mode toggle state - -namespace reasampler { - -class ReaSamplerSession; - -// Wires the panel into main.cpp's lifecycle. Called once after the API pointers -// are loaded, BEFORE the toggle action is registered. `session` must outlive the -// panel (it is the extension-lifetime g_session). Stores the session pointer the -// panel reads on every repaint; does not create the window yet. -void bankPanelInit(ReaSamplerSession* session); - -// Toggles the docked window: creates+docks it if hidden, hides+undocks it if -// shown. Bound to the "toggle bank panel" action. Safe to call before the first -// timer tick. -void bankPanelToggle(); - -// Whether the panel window is currently open/visible. Feeds the action's -// checked-state (toggleaction) so REAPER shows a tick next to the menu entry. -bool bankPanelIsOpen(); - -// The stable ids of the currently-selected samples, in bank (insertion) order. -// Empty when nothing is selected or the panel has never opened. This is the clean -// seam the `insert` action reads to know WHAT to place — it returns ids (not grid -// indices) so the caller resolves against the live bank and is unaffected by the -// panel's internal index bookkeeping. READ of panel state only; no mutation. -// -// Note: the panel's selection is cleared on a bank change (capture / project -// load), so a returned id always names a sample present in the current bank at -// the moment of the call; the caller still tolerates an absent id gracefully. -// -// Phase B4 (vertical split): the selection lives in whichever REGION the user last -// interacted with (the pool grid on top or a named-bank grid below), which is NOT -// necessarily the active/capture-target bank. The returned ids therefore name -// samples in the FOCUSED region's displayed bank — the bank the user visibly -// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those -// ids belong to (the move/copy source). -std::vector bankPanelSelectedSampleIds(); - -// The bank id the current selection belongs to — the displayed bank of the region -// the user last interacted with (pool region -> the pool id; named-banks region -> -// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current -// selection, and it is distinct from the active/capture-target bank (active ≠ shown). -// Returns the pool id when nothing is selected or the panel has never opened (a safe -// default source). READ of panel state only; no mutation. -std::string bankPanelSelectedSourceBankId(); - -// Requests a repaint if the bank changed since the last paint (generation bump). -// Cheap when nothing changed. Driven by the timer so a capture / project load is -// reflected without the panel diffing the bank itself. -void bankPanelRefresh(); - -// Notifies the panel that persist just (re)loaded a project's view model (membership + -// active mode). main.cpp calls this on the exact tick it drains persist's load signal -// and reapplies the active mode. It re-arms the new-content detector so the just-loaded -// project's PRE-EXISTING content is taken as the baseline (reported as nothing new), -// never diffed against the previously-open project and mass-tagged into the active mode. -// This coordinates the detector's project-identity signal with persist's authoritative -// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address, -// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/ -// arm of panel state only; no project or bank mutation. -void bankPanelNotifyProjectLoaded(); - -// The panel's current tail-mode setting (mode + Manual length), read by the plain -// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture -// applies whatever the panel toggle is set to. Default None (exact bounds) — a -// capture with no explicit choice stays byte-identical to today. Extension-session -// setting: persists across project loads and panel open/close within a REAPER session; -// resets to None only when the extension unloads (fresh REAPER session). Project -// persistence across REAPER restarts is a noted follow-on. -// Safe to call before the panel has ever opened (returns the default). READ of panel -// state only; the toggle is mutated by a click inside the panel, never here. -TailSetting bankPanelTailSetting(); - -// The vertical-split full-height layout state (Phase B). The bank window splits -// vertically — pool on top, named-banks region below — and two toggles collapse the -// split: pool full-height (hide the named-banks region) and banks full-height (hide -// the pool). The two are mutually exclusive with the default (both regions shown), -// so one enum captures the whole state. -// -// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives -// here beside the tail setting — the other session-level view-layout bit the panel -// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not -// project state, so it must not travel with the .rpp. In-memory for the extension's -// lifetime; resets to Split on unload. -enum class BankPanelFullHeight { - Split, // default: pool region on top, named-banks region below - PoolOnly, // pool full-height — named-banks region hidden - BanksOnly, // banks full-height — pool region hidden -}; - -// The current full-height layout state (default Split). READ by B4's panel to decide -// which region(s) to draw. Safe before the panel has ever opened. -BankPanelFullHeight bankPanelFullHeight(); - -// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from -// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool -// full-height" action. Requests a repaint so an open panel reflects the change. -void bankPanelToggledPoolFullHeight(); - -// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle. -// Bound to the "banks full-height" action. Requests a repaint. -void bankPanelToggledBanksFullHeight(); - -// Requests an immediate repaint of the panel if it is open. A no-op when the panel -// is closed (safe to call unconditionally). Called by the actions layer after a -// mode change so the footer [Arrange|Design] toggle reflects the new mode without -// requiring a hide/reshow. -void bankPanelInvalidate(); - -// Tears the panel down on extension unload: destroys the window and releases any -// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. -void bankPanelShutdown(); - -} // namespace reasampler diff --git a/src/capture.h b/src/capture.h deleted file mode 100644 index 03b61ca..0000000 --- a/src/capture.h +++ /dev/null @@ -1,234 +0,0 @@ -#pragma once -// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split). -// -// This header declares the capture *seam* the later milestones fill: -// * CaptureRequest — everything a capture needs, source-mode-agnostic. -// * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements -// (headless, immediate, returns a finished Sample). -// * OfflineRenderBackend — the deterministic default; drives the offline scopes. -// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven -// across timer ticks; deliberately NOT an ICaptureBackend -// (see the SEAM CHOICE note at its declaration). -// -// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER -// headers — the .cpp is the REAPER-facing translation unit. Keeping this header -// REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam -// without dragging the SDK into every include site. - -#include -#include -#include - -#include "bank_model.h" -#include "render_settings.h" // TailMode (pure) — the three-state tail contract - -// MediaTrack is forward-declared (like track_guid.h) so this header stays -// REAPER-free while RealtimeRecordBackend::begin can take the resolved source -// MediaTrack* to tap. The pointers are opaque here — never dereferenced in a -// pure/header context; only the REAPER-facing capture_realtime.cpp touches them. -class MediaTrack; - -namespace reasampler { - -// Audio bit-depth for the rendered wav. 32-bit float is the M3 default — -// rationale lives in capture.cpp next to the sink-config bytes. -enum class WavBitDepth { - Int16, - Int24, - Float32, -}; - -// One capture, independent of source mode. Populated by the caller (the action -// handler in M3; the action family in M7) and consumed by a backend. -// -// M3 fills only the fields the master-mix/time-selection path needs; the rest -// are declared now so M7/M8 do not reshape the struct (they are the seam). -struct CaptureRequest { - SourceMode sourceMode = SourceMode::MasterMix; - - // Sample-accurate render bounds in project seconds. For the M3 spike these - // come straight from the time selection (GetSet_LoopTimeRange) — NO rounding. - double startSeconds = 0.0; - double endSeconds = 0.0; - - // 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet). - // The field is kept as the seam for future true-dry work (M10 null test): - // true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires - // FX-bypass-around-render or the M8 realtime pre-FX path, and will be - // designed alongside the M10 null test. Also recorded on the Sample. - double wetDry = 1.0; - - // Track GUID(s) the capture came from, when the source mode is track-scoped - // (SelectedTracks). Empty for master/items/razor. The action layer (M7) - // resolves the selection to canonical GUID strings and passes them here; the - // backend copies them onto the Sample (it does NOT itself read the selection — - // it stays source-agnostic, driven entirely by the request). - std::vector trackGuids; - - // Render tail (docs/product/capture-tail.md §The three tail states). Default - // None: exact bounds, no added silence — the precision invariant, and the only - // mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for - // TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the - // 8 s cap + -72 dB trim internally, None ignores it. - TailMode tailMode = TailMode::None; - double tailMs = 0.0; - - // Output format. 0 sampleRate => follow project rate (deterministic: the - // project rate is fixed for a given project). - int sampleRate = 0; - int channelCount = 2; - WavBitDepth bitDepth = WavBitDepth::Float32; - - // Human base name for the file stem; sanitized by capture_paths. The unique - // tag (disambiguator) is supplied separately by the backend caller so the - // pure naming logic stays testable. - std::string baseName = "capture"; - std::string uniqueTag; // e.g. a timestamp/counter; may be empty -}; - -// Outcome of a capture attempt. `Ok` carries the populated Sample; every failure -// is an explicit code (never a thrown exception across the REAPER boundary) so -// the action handler can log a precise reason. -enum class CaptureStatus { - Ok, - NoProject, // no active project to render / resolve a bank folder - EmptyRange, // start >= end: nothing to render - UnsupportedMode, // backend does not implement this source mode (M3 scope) - UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only) - RenderFailed, // the render action ran but produced no output file - TransportBusy, // realtime backend: transport already playing/recording — refused -}; - -struct CaptureResult { - CaptureStatus status = CaptureStatus::RenderFailed; - Sample sample; // valid only when status == Ok - std::string message; // human-readable detail for the console log -}; - -// The capture seam. One method: run a request, return a populated Sample (or a -// failure code). Backends are non-destructive — they must restore any global -// state they touch before returning (OfflineRenderBackend snapshots/restores the -// RENDER_* project settings). -class ICaptureBackend { -public: - virtual ~ICaptureBackend() = default; - virtual CaptureResult capture(const CaptureRequest& request) = 0; -}; - -// Deterministic offline-render backend. Drives the full offline source family — -// master mix / time selection, selected tracks, selected items, razor area — all -// wet-only (render_settings.h) with optional tail. The source selection + range -// are resolved by the caller (the action layer) and handed in via the -// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection -// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend). -class OfflineRenderBackend : public ICaptureBackend { -public: - CaptureResult capture(const CaptureRequest& request) override; -}; - -// --- Realtime-record backend: the ASYNC seam --------------------------------- -// -// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport -// on REAPER's audio thread and returns immediately — it does NOT block until the -// range completes, which takes (end - start) wall-clock seconds. Blocking the main -// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN -// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called -// from the same OnTimer that runs session.poll()) advances the in-flight record and -// reports when it is done. -// -// SEAM CHOICE (surfaced): RealtimeRecordBackend deliberately does NOT implement the -// synchronous ICaptureBackend — that interface returns a finished Sample from one -// call, which no longer fits a record that spans ticks. The two backends have -// genuinely different lifecycles (offline is headless + immediate; realtime is -// transport-driven + async), so forcing a shared async interface would make offline -// fake a lifecycle it does not have (its tick() would always be Done on the first -// call — dead code / an LSP smell). Offline stays synchronous and unchanged; the -// realtime backend owns this small bespoke async seam, driven by exactly one caller -// (main.cpp's OnTimer). This is the split-sync/async fork, chosen over a unified -// async interface for that reason. - -// One tick's verdict from the in-flight record. -enum class RealtimeTickStatus { - InProgress, // still recording — call tick() again next timer tick - Done, // finished (range end reached, or the user stopped) — `result` is set - Failed, // an error tore the capture down — `result.message` explains -}; - -struct RealtimeTickResult { - RealtimeTickStatus status = RealtimeTickStatus::InProgress; - CaptureResult result; // meaningful only when status == Done or Failed -}; - -// The opaque in-flight capture state. Owns the snapshot of everything to restore -// (temp track + its receive sends from the source tracks, other tracks' I_RECARM, -// transport, edit cursor, time selection) and the record's own project handle. -// Defined in -// capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject* -// leaks here) by holding it behind a forward-declared type + unique_ptr. -// -// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope -// RAII guard) because the record spans ticks — no single stack frame outlives it. -// Every terminal path (normal completion, user stop, error, project switch, unload) -// funnels through the same single restore, safe to call once from whichever fires. -class RealtimeCaptureState; - -// Out-of-line deleter so callers (main.cpp) can own a unique_ptr to the opaque -// RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the delete is -// compiled in capture_realtime.cpp where the type is complete, keeping this header -// REAPER-free (load-bearing split). -struct RealtimeCaptureStateDeleter { - void operator()(RealtimeCaptureState* p) const noexcept; -}; -using RealtimeCaptureHandle = - std::unique_ptr; - -// Realtime-record backend — captures by RECORDING in realtime (transport-driven) -// into a hidden temp track, then moves the recorded file into the bank as a Sample. -// For sources offline render cannot do (hardware, performed FX) and as the true -// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline -// render has none). Dialog-free: never invokes the offline-render progress window. -// -// Non-bit-identical by nature (it is realtime); offline stays the deterministic -// default. Non-destructive across EVERY terminal path — the review gate — which is -// harder here than offline because the record spans ticks: the snapshot + restore -// live on RealtimeCaptureState, not a function-scope RAII destructor. -// -// SCOPE (this increment): TRACK scope only — records the selected track's OWN -// output (item + that track's own FX + its own fader/pan, PRE-parent), matching -// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's -// output is naturally PRE-parent (the parent has not summed it yet), so the tap is -// chain-independent by construction. Item realtime is deferred (UnsupportedMode). -class RealtimeRecordBackend { -public: - // Starts a realtime record: validates the request (track scope, non-empty range, - // at least one source track, active + saved project, transport idle), snapshots - // all state to restore, creates the hidden temp track, routes a send FROM each - // source track INTO the temp track, arms, and CSurf_OnRecord — then returns - // IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to - // tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free, - // carrying only the provenance GUIDs). On success the returned unique_ptr owns the - // in-flight state; drive it with tick(). On a validation/setup failure returns - // nullptr and fills `outFailure` with the CaptureStatus + message (nothing was - // left mutated — begin() restores on its own failure paths). - RealtimeCaptureHandle begin(const CaptureRequest& request, - const std::vector& sourceTracks, - CaptureResult& outFailure); - - // Advances the in-flight record one tick. Reads the transport (bound to the - // record's OWN project handle so a project switch cannot confuse it), and on a - // terminal verdict stops the transport, finalizes the recorded file into the - // bank Sample (Done) or reports the failure (Failed), then restores ALL - // snapshotted state. Returns InProgress while the record is still running. - // After Done/Failed the state is spent — the caller drops the unique_ptr. - RealtimeTickResult tick(RealtimeCaptureState& state); - - // Force-terminate an in-flight record NOW without waiting for the range end: - // stops the transport, finalizes whatever was captured (best effort) or abandons - // it, and restores ALL snapshotted state. For the shutdown / project-switch - // paths (extension unload, a new project became active) where the record must - // not leak a temp track / armed track / altered transport into the user's - // project. Idempotent — safe even if a prior tick already tore the state down. - RealtimeTickResult abort(RealtimeCaptureState& state); -}; - -} // namespace reasampler diff --git a/src/peaks.cpp b/src/core/audio/peaks.cpp similarity index 98% rename from src/peaks.cpp rename to src/core/audio/peaks.cpp index b2a695a..1d79625 100644 --- a/src/peaks.cpp +++ b/src/core/audio/peaks.cpp @@ -1,4 +1,4 @@ -#include "peaks.h" +#include "core/audio/peaks.h" #include #include @@ -14,7 +14,7 @@ // extra frames) with no rounding drift and no dropped tail — the last bin's end is // always exactly frameCount. -namespace reasampler { +namespace reasampler::audio { Envelope computeEnvelope(const std::vector& interleaved, std::size_t channelCount, @@ -123,4 +123,4 @@ std::size_t lastFrameAboveThreshold(const std::vector& interleaved, return kNoFrameAboveThreshold; } -} // namespace reasampler +} // namespace reasampler::audio diff --git a/src/peaks.h b/src/core/audio/peaks.h similarity index 99% rename from src/peaks.h rename to src/core/audio/peaks.h index dc1365a..6f3d5b6 100644 --- a/src/peaks.h +++ b/src/core/audio/peaks.h @@ -11,7 +11,7 @@ #include #include -namespace reasampler { +namespace reasampler::audio { // Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer // format (its render/PCM_source callbacks hand back interleaved 32-bit float), so @@ -119,4 +119,4 @@ std::size_t lastFrameAboveThreshold(const std::vector& interleaved, std::size_t frameCount, AudioSample linearThreshold); -} // namespace reasampler +} // namespace reasampler::audio diff --git a/src/batch_capture.cpp b/src/core/capture/batch_capture.cpp similarity index 95% rename from src/batch_capture.cpp rename to src/core/capture/batch_capture.cpp index 3e46851..3f5ff1d 100644 --- a/src/batch_capture.cpp +++ b/src/core/capture/batch_capture.cpp @@ -1,11 +1,11 @@ // batch_capture.cpp — pure logic for M11 batch capture. See header. // NO REAPER types; unit-tested by tests/test_batch_capture.cpp. -#include "batch_capture.h" +#include "core/capture/batch_capture.h" #include -namespace reasampler { +namespace reasampler::capture { std::vector planCaptureUnits(const std::vector& ranges) { std::vector units; @@ -73,4 +73,4 @@ std::string BatchOutcome::summaryLine(const std::string& noun) const { return line; } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/batch_capture.h b/src/core/capture/batch_capture.h similarity index 98% rename from src/batch_capture.h rename to src/core/capture/batch_capture.h index 213eb9e..e1353a7 100644 --- a/src/batch_capture.h +++ b/src/core/capture/batch_capture.h @@ -29,7 +29,7 @@ #include #include -namespace reasampler { +namespace reasampler::capture { // One capture in a batch: an exact source range plus its 1-based ordinal within the // KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's @@ -97,4 +97,4 @@ private: std::vector results_; }; -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/capture_paths.cpp b/src/core/capture/capture_paths.cpp similarity index 61% rename from src/capture_paths.cpp rename to src/core/capture/capture_paths.cpp index 33bfbce..575af3e 100644 --- a/src/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -1,120 +1,14 @@ -#include "capture_paths.h" +#include "core/capture/capture_paths.h" #include #include -#include -#include -#include // std::memcmp #include -#include -namespace reasampler { +namespace reasampler::capture { -std::string hashBytes(const std::uint8_t* data, std::size_t len) { - // FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity. - // Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/). - constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL; - constexpr std::uint64_t kPrime = 1099511628211ULL; - std::uint64_t h = kOffsetBasis; - for (std::size_t i = 0; i < len; ++i) { - h ^= static_cast(data[i]); - h *= kPrime; - } - // Format as 16-digit lowercase hex (zero-padded) for a fixed-length string. - char buf[17]; - std::snprintf(buf, sizeof(buf), "%016llx", - static_cast(h)); - return std::string(buf); -} - -std::string hashWavContent(const std::vector& bytes) { - // Walk the RIFF/WAVE container 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. - // - // The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates - // FNV state instead of recording geometry — no second parser, same logic. - - // FNV-1a 64-bit constants (same as hashBytes). - constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL; - constexpr std::uint64_t kPrime = 1099511628211ULL; - - // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes. - auto tagEq = [&](std::size_t off, const char* tag) -> bool { - return off + 4 <= bytes.size() && - std::memcmp(bytes.data() + off, tag, 4) == 0; - }; - auto readU32LE = [&](std::size_t off) -> std::uint32_t { - return static_cast(bytes[off]) | - (static_cast(bytes[off + 1]) << 8) | - (static_cast(bytes[off + 2]) << 16) | - (static_cast(bytes[off + 3]) << 24); - }; - - bool isWav = bytes.size() >= 12 && - tagEq(0, "RIFF") && - tagEq(8, "WAVE"); - - if (isWav) { - // Accumulate FNV-1a starting with the domain-separation tag byte 'W'. - std::uint64_t h = kOffsetBasis; - auto feedByte = [&](std::uint8_t b) { - h ^= static_cast(b); - h *= kPrime; - }; - - bool haveFmt = false; - bool haveData = false; - - // Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a - // whole-file hash of different bytes that happen to be the same length. - feedByte(static_cast('W')); - - std::size_t pos = 12; - while (pos + 8 <= bytes.size()) { - const std::size_t bodyOffset = pos + 8; - const std::uint32_t bodySize = readU32LE(pos + 4); - - if (tagEq(pos, "fmt ")) { - // Feed the entire fmt body (all fields, including format tag, channels, - // sample rate, bits-per-sample — everything that defines the audio format). - if (bodyOffset + bodySize <= bytes.size()) { - for (std::uint32_t i = 0; i < bodySize; ++i) - feedByte(bytes[bodyOffset + i]); - haveFmt = true; - } - } else if (tagEq(pos, "data")) { - // Feed the entire PCM payload. - if (bodyOffset + bodySize <= bytes.size()) { - for (std::uint32_t i = 0; i < bodySize; ++i) - feedByte(bytes[bodyOffset + i]); - haveData = true; - } - } - // All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped. - - // Advance past this chunk's body, honoring RIFF even-byte padding. - std::size_t advance = bodySize; - if (advance & 1u) ++advance; // RIFF pad byte - if (advance > bytes.size() - bodyOffset) break; // overrun guard - pos = bodyOffset + advance; - } - - if (haveFmt && haveData) { - char buf[17]; - std::snprintf(buf, sizeof(buf), "%016llx", - static_cast(h)); - return std::string(buf); - } - // Falls through to whole-file fallback if chunks were missing/malformed. - } - - // Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old - // per-call hashBytes). No prefix tag: identical to hashBytes(data, size). - return hashBytes(bytes.data(), bytes.size()); -} +// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec +// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the +// layout parse so hashing and decoding cannot desynchronize. std::string normalizeSlashes(const std::string& path) { std::string out = path; @@ -216,7 +110,7 @@ std::string resolveBankFile(const std::string& projectDir, std::string projectDirOfRpp(const std::string& rppPath) { // An unsaved project reports an empty .rpp path; keep it empty so downstream - // resolution refuses (no default-location fallback). Mirrors persist.cpp's prior + // 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(); @@ -284,4 +178,4 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject, return ProjectTransition::NoOp; } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/capture_paths.h b/src/core/capture/capture_paths.h similarity index 83% rename from src/capture_paths.h rename to src/core/capture/capture_paths.h index 4ab8026..bd188e3 100644 --- a/src/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -17,7 +17,7 @@ #include #include -namespace reasampler { +namespace reasampler::capture { // The project-relative bank subfolder. All captured wavs live here so the bank // travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank). @@ -25,7 +25,7 @@ inline constexpr const char* kBankSubfolder = "reasampler_bank"; // A resolved pair of paths for one capture: where REAPER must be told to write // (absolute, because RENDER_FILE wants a directory REAPER can create/open) and -// what we store in the BankIndex (project-relative, because the index is +// what we store in the BankModel (project-relative, because the index is // relative-paths-only — CLAUDE.md precision invariant). struct BankPaths { std::string absoluteDir; // /reasampler_bank (forward slash) @@ -34,37 +34,9 @@ struct BankPaths { std::string fileStem; // (RENDER_PATTERN — REAPER appends the extension) }; -// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data` -// and returns it as a 16-character lowercase hex string. Designed to fill -// Sample::contentHash so the confirm-on-last-reference guardrail -// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this -// file" from "another bank holds the same file." An empty buffer returns the bare -// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty -// files would share, but real WAV files are never empty). -std::string hashBytes(const std::uint8_t* data, std::size_t len); - -// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float -// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all -// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED). -// -// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a -// `bext` chunk containing the origination date/time) even when the format config blob -// requests no BWF metadata. Two renders of identical audio therefore differ in those -// bytes, making whole-file hashes diverge and preventing dedup collapse. -// -// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before -// the fmt/data bytes are fed in, so a content hash can never equal a whole-file -// hashBytes result for a different file of the same size. -// -// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a -// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) — -// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an -// unrecognized or malformed file still gets a non-empty hash rather than silently -// skipping dedup. -// -// Called by both capture commit paths (offline and realtime) in place of the raw -// hashBytes call. -std::string hashWavContent(const std::vector& bytes); +// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent) +// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte +// format — so this module holds path arithmetic only, with no RIFF chunk knowledge. // Normalizes a path to forward slashes and strips any trailing slash. Empty in // -> empty out. Pure string transform (does not consult the filesystem). @@ -87,7 +59,7 @@ std::string sanitizeStem(const std::string& baseName); // timestamp or counter) so repeated captures do not collide. // Also sanitized. May be empty. // Produces "[_].wav". The relativePath is always project-relative and -// forward-slashed so it satisfies BankIndex::add's relative-only invariant. +// forward-slashed so it satisfies BankModel::add's relative-only invariant. BankPaths deriveBankPaths(const std::string& projectDir, const std::string& baseName, const std::string& uniqueTag); @@ -212,4 +184,4 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& currentGuid, const std::string& currentPath); -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/realtime_record.cpp b/src/core/capture/capture_realtime.cpp similarity index 94% rename from src/realtime_record.cpp rename to src/core/capture/capture_realtime.cpp index 9b287d1..2ff05e4 100644 --- a/src/realtime_record.cpp +++ b/src/core/capture/capture_realtime.cpp @@ -1,9 +1,10 @@ -// realtime_record.cpp — pure logic for the realtime-record backend (M8). See header. -// NO REAPER types; unit-tested by tests/test_realtime_record.cpp. +// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See +// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp. +// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.) -#include "realtime_record.h" +#include "core/capture/capture_realtime.h" -namespace reasampler { +namespace reasampler::capture { RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) { RecordModePlan p; @@ -124,4 +125,4 @@ bool isTerminalPhase(RecordPhase phase) { return phase == RecordPhase::Done || phase == RecordPhase::Failed; } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/realtime_record.h b/src/core/capture/capture_realtime.h similarity index 90% rename from src/realtime_record.h rename to src/core/capture/capture_realtime.h index 82fec4c..d497e8b 100644 --- a/src/realtime_record.h +++ b/src/core/capture/capture_realtime.h @@ -1,9 +1,12 @@ #pragma once -// realtime_record — the REAPER-free logic behind the realtime-record backend (M8). +// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8). +// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module +// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp / +// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.) // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The realtime backend (capture.cpp) -// drives the transport, the temp track, the send routing, and the file move — +// vendor/ includes. Standard library only. The realtime shell drives the +// transport, the temp track, the send routing, and the file move — // all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong // pieces are split out here and unit-tested outside the DAW: // @@ -24,9 +27,13 @@ #include #include -#include "bank_model.h" // Sample, SourceMode (pure) +#include "core/model/bank_model.h" // Sample, SourceMode (pure) -namespace reasampler { +namespace reasampler::capture { + +using model::Sample; +using model::Tier; +using model::SourceMode; // --- I_RECMODE values (verbatim from SDK header ~2197) ----------------------- // @@ -107,6 +114,16 @@ struct RecordedCapture { std::vector trackGuids; int channelCount = 0; + + // TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer + // populates these five fields before calling sampleFromRecordedCapture — the + // finalize path (capture_realtime_finalize.cpp) leaves them at their defaults + // and instead calls the shared stampCaptureSample(result.sample, ...) right + // after, which writes Sample::sampleRate/captureTempo/captureTimeSigNum/ + // captureTimeSigDenom/createdTimestamp directly, overwriting whatever + // sampleFromRecordedCapture set from these. Kept (not deleted) because the pure + // unit tests still construct/assert them directly; removing the fields is a + // struct-shape decision out of scope here. int sampleRate = 0; // 0 when the project rate was unknown (as offline) double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo) // Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime). @@ -231,4 +248,4 @@ bool isStopRequested(RecordPhase phase); // Only Done and Failed are terminal; Recording and Finalizing are live. bool isTerminalPhase(RecordPhase phase); -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/insert_plan.cpp b/src/core/capture/insert_plan.cpp similarity index 93% rename from src/insert_plan.cpp rename to src/core/capture/insert_plan.cpp index 9c3983e..b58d56c 100644 --- a/src/insert_plan.cpp +++ b/src/core/capture/insert_plan.cpp @@ -1,8 +1,8 @@ // insert_plan.cpp — see insert_plan.h. Pure InsertMedia mode-bit arithmetic. -#include "insert_plan.h" +#include "core/capture/insert_plan.h" -namespace reasampler { +namespace reasampler::capture { namespace { @@ -46,4 +46,4 @@ int computeInsertMode(const InsertOptions& opts) { return mode; } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/insert_plan.h b/src/core/capture/insert_plan.h similarity index 98% rename from src/insert_plan.h rename to src/core/capture/insert_plan.h index 7a6e2cb..2bb45e9 100644 --- a/src/insert_plan.h +++ b/src/core/capture/insert_plan.h @@ -22,7 +22,7 @@ #include -namespace reasampler { +namespace reasampler::capture { // Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3). // We expose only the two placement targets M6 needs; "add as takes" (3) is a @@ -72,4 +72,4 @@ int computeInsertMode(const InsertOptions& opts); // any computed mode (the "no silent time-stretch" invariant, made checkable). inline constexpr int kStretchToTimeSelBit = 4; -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/render_settings.cpp b/src/core/capture/render_settings.cpp similarity index 98% rename from src/render_settings.cpp rename to src/core/capture/render_settings.cpp index 041cfa9..a83c5ca 100644 --- a/src/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -1,13 +1,13 @@ // render_settings.cpp — pure logic for the three-scope capture action family. See header. // NO REAPER types; unit-tested by tests/test_render_settings.cpp. -#include "render_settings.h" +#include "core/capture/render_settings.h" #include #include #include -namespace reasampler { +namespace reasampler::capture { double autoTrimEndRatio() { // Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is @@ -221,4 +221,4 @@ const std::vector& captureActionTable() { return table; } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/render_settings.h b/src/core/capture/render_settings.h similarity index 98% rename from src/render_settings.h rename to src/core/capture/render_settings.h index 8d337eb..4c88323 100644 --- a/src/render_settings.h +++ b/src/core/capture/render_settings.h @@ -25,9 +25,11 @@ #include #include -#include "bank_model.h" // SourceMode (pure enum) +#include "core/model/bank_model.h" // SourceMode (pure enum) -namespace reasampler { +namespace reasampler::capture { + +using model::SourceMode; // --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) -- // @@ -260,4 +262,4 @@ struct CaptureActionDef { // capture applies is read from the docked-panel setting, not baked into the row. const std::vector& captureActionTable(); -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/tail_control.cpp b/src/core/capture/tail_control.cpp similarity index 55% rename from src/tail_control.cpp rename to src/core/capture/tail_control.cpp index 1d4063a..01b9ad8 100644 --- a/src/tail_control.cpp +++ b/src/core/capture/tail_control.cpp @@ -1,14 +1,13 @@ // tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor. -#include "tail_control.h" +#include "core/capture/tail_control.h" #include -#include #include -#include -#include -namespace reasampler { +#include "core/json/json.h" + +namespace reasampler::capture { TailMode cycleTailMode(TailMode current) { switch (current) { @@ -51,13 +50,13 @@ std::string tailToggleLabel(const TailSetting& setting) { // JSON round-trip // --------------------------------------------------------------------------- // -// The setting is a flat object of one enum + one double, so a compact hand-rolled -// writer + a tolerant minimal reader is the simplest thing that works (mirroring -// bank_model's dependency-free JSON choice). manualMs is emitted with 17 significant -// digits (%.17g) — the shortest form that round-trips every IEEE-754 double exactly — -// so deserialize(serialize(x)) == x holds bit-for-bit. deserialize is deliberately -// forgiving: any parse failure returns nullopt so the caller falls back to a default, -// exactly as an absent ext-state key does. +// 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 { @@ -81,52 +80,52 @@ std::optional modeFromInt(int v) { } } -// Find the value token following `"key":` in `json`. Returns a pointer just past the -// colon (skipping whitespace) or nullptr if the key is absent. Minimal: the writer -// emits exactly one flat object with unique keys, so a substring search is sufficient -// and there is no nesting to confuse it. -const char* valueAfterKey(const std::string& json, const char* key) { - const std::string needle = std::string("\"") + key + "\""; - const std::size_t pos = json.find(needle); - if (pos == std::string::npos) return nullptr; - const char* p = json.c_str() + pos + needle.size(); - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; - if (*p != ':') return nullptr; - ++p; - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; - return p; -} - } // namespace std::string serializeTailSetting(const TailSetting& setting) { - char buf[128]; - std::snprintf(buf, sizeof(buf), "{\"mode\":%d,\"manualMs\":%.17g}", - modeToInt(setting.mode), setting.manualMs); - return std::string(buf); + // Byte-identical to the former snprintf writer: {"mode":%d,"manualMs":%.17g}. + std::string out; + { + json::Writer w(out); + w.keyRaw("mode", json::numToStr(modeToInt(setting.mode))); + w.keyRaw("manualMs", json::numToStr(setting.manualMs)); + } // Writer closes the object here (see bank_model's NRVO note) + return out; } -std::optional deserializeTailSetting(const std::string& json) { - const char* modeTok = valueAfterKey(json, "mode"); - const char* msTok = valueAfterKey(json, "manualMs"); - if (!modeTok || !msTok) return std::nullopt; // absent key -> malformed -> default +std::optional deserializeTailSetting(const std::string& blob) { + json::Reader r(blob); + if (!r.consume('{')) return std::nullopt; - char* end = nullptr; - errno = 0; - const long modeVal = std::strtol(modeTok, &end, 10); - if (end == modeTok || errno != 0) return std::nullopt; - const std::optional mode = modeFromInt(static_cast(modeVal)); + int modeInt = 0; + double ms = 0.0; + bool haveMode = false, haveMs = false; + r.skipWs(); + if (!r.consume('}')) { + do { + std::string key; + if (!r.parseKey(key)) return std::nullopt; + if (key == "mode") { + if (!r.parseInt(modeInt)) return std::nullopt; + haveMode = true; + } else if (key == "manualMs") { + if (!r.parseDouble(ms)) return std::nullopt; + haveMs = true; + } else { + if (!r.skipValue()) return std::nullopt; // forward-compat + } + } while (r.consume(',')); + if (!r.consume('}')) return std::nullopt; + } + if (!haveMode || !haveMs) return std::nullopt; // absent key -> malformed -> default + + const std::optional mode = modeFromInt(modeInt); if (!mode) return std::nullopt; - end = nullptr; - errno = 0; - const double ms = std::strtod(msTok, &end); - if (end == msTok || errno != 0) return std::nullopt; - TailSetting out; out.mode = *mode; out.manualMs = ms; return out; } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/tail_control.h b/src/core/capture/tail_control.h similarity index 91% rename from src/tail_control.h rename to src/core/capture/tail_control.h index 1e7ab93..fa4ae4b 100644 --- a/src/tail_control.h +++ b/src/core/capture/tail_control.h @@ -1,6 +1,6 @@ #pragma once // tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode -// toggle. The panel shell (bank_panel.cpp) owns the SWELL window, LICE drawing, and +// toggle. The panel shell (shell/panel/) owns the SWELL window, LICE drawing, and // click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length // clamp, and the toggle's label text — lives here so it is unit-tested outside the // DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch. @@ -12,9 +12,9 @@ #include #include -#include "render_settings.h" // TailMode (pure enum) — the three-state tail contract +#include "core/capture/render_settings.h" // TailMode (pure enum) — the three-state tail contract -namespace reasampler { +namespace reasampler::capture { // The Manual-mode starting length. 2 s is a musically useful default tail (a bar of // reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a @@ -27,7 +27,7 @@ inline constexpr double kDefaultManualTailMs = 2000.0; inline constexpr double kManualStepMs = 250.0; // The panel's current tail setting: the mode plus the length used ONLY when the -// mode is Manual. Held as in-memory panel/session state (bank_panel.cpp), default +// mode is Manual. Held as in-memory panel/session state (shell/panel), default // None so a capture with no explicit choice stays exact-bounds / byte-identical to // today. `manualMs` is a stored default a future fine-adjust UI can tune; it is // clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest. @@ -67,4 +67,4 @@ std::string tailToggleLabel(const TailSetting& setting); std::string serializeTailSetting(const TailSetting& setting); std::optional deserializeTailSetting(const std::string& json); -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/core/capture/wav_codec.cpp b/src/core/capture/wav_codec.cpp new file mode 100644 index 0000000..99d76fb --- /dev/null +++ b/src/core/capture/wav_codec.cpp @@ -0,0 +1,333 @@ +// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor. +// +// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the +// content hash both walk with it, so their view of the container cannot drift. + +#include "core/capture/wav_codec.h" + +#include // std::snprintf (hash hex render) +#include // std::memcpy, std::memcmp + +namespace reasampler::capture { + +namespace { + +// Little-endian readers. Bounds are checked by the caller before each read; these +// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB. +std::uint16_t readU16LE(const std::vector& b, std::size_t off) { + return static_cast(b[off] | (b[off + 1] << 8)); +} +std::uint32_t readU32LE(const std::vector& b, std::size_t off) { + return static_cast(b[off]) | + (static_cast(b[off + 1]) << 8) | + (static_cast(b[off + 2]) << 16) | + (static_cast(b[off + 3]) << 24); +} + +bool tagEquals(const std::vector& b, std::size_t off, const char* tag) { + return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; +} + +// WAVE format tags we accept as 32-bit float (see wav_codec.h FORMAT ASSUMPTION). +constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003; +constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; + +// FNV-1a 64-bit constants (http://www.isthe.com/chongo/tech/comp/fnv/). +constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL; +constexpr std::uint64_t kFnvPrime = 1099511628211ULL; + +std::string fnvHex(std::uint64_t h) { + // 16-digit lowercase hex (zero-padded) for a fixed-length string. + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016llx", static_cast(h)); + return std::string(buf); +} + +// --- The ONE RIFF chunk traversal -------------------------------------------- +// +// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at +// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`. +// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk +// whose declared size lies past the end is still REPORTED (callers decide how to +// treat it) but its body must not be read. +struct WavChunkView { + std::size_t headerOffset = 0; + std::size_t bodyOffset = 0; + std::uint32_t bodySize = 0; + bool bodyInBounds = false; +}; + +// Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call +// fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte +// padding. Returns false when no further chunk header fits. If the padded advance +// would overrun the buffer, the chunk is still reported (return true) and `pos` is +// parked past the end so the NEXT call returns false — exactly the process-then- +// break shape the pre-consolidation walkers shared. +bool nextWavChunk(const std::vector& bytes, std::size_t& pos, + WavChunkView& out) { + if (pos + 8 > bytes.size()) return false; + + out.headerOffset = pos; + out.bodyOffset = pos + 8; + out.bodySize = readU32LE(bytes, pos + 4); + out.bodyInBounds = (out.bodyOffset + out.bodySize <= bytes.size()); + + std::size_t advance = out.bodySize; + if (advance & 1u) ++advance; // RIFF pad byte + if (advance > bytes.size() - out.bodyOffset) { + pos = bytes.size(); // overrun -> this is the last reported chunk + } else { + pos = out.bodyOffset + advance; + } + return true; +} + +bool isRiffWave(const std::vector& bytes) { + // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes. + return bytes.size() >= 12 && tagEquals(bytes, 0, "RIFF") && + tagEquals(bytes, 8, "WAVE"); +} + +} // namespace + +WavLayout parseWavLayout(const std::vector& bytes) { + WavLayout out; + + if (!isRiffWave(bytes)) return out; + + bool haveFmt = false; + std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0; + std::uint32_t sampleRate = 0; + std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible + + // Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A + // malformed/truncated file is "invalid", never an OOB read. + std::size_t pos = 12; + WavChunkView c; + while (nextWavChunk(bytes, pos, c)) { + if (tagEquals(bytes, c.headerOffset, "fmt ")) { + // fmt body: at least 16 bytes (PCM/float common fields). + if (c.bodyOffset + 16 > bytes.size() || c.bodySize < 16) return out; + fmtTag = readU16LE(bytes, c.bodyOffset + 0); + channels = readU16LE(bytes, c.bodyOffset + 2); + sampleRate = readU32LE(bytes, c.bodyOffset + 4); + bitsPerSample = readU16LE(bytes, c.bodyOffset + 14); + // For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading + // 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM + // integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to + // reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in + // the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected). + if (fmtTag == kWaveFormatExtensible) { + if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) { + extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24); + } + } + haveFmt = true; + } else if (tagEquals(bytes, c.headerOffset, "data")) { + // The data chunk: PCM starts at bodyOffset, declared length bodySize. + // Reject if it runs past the buffer (truncated / lying header). + if (!c.bodyInBounds) return out; + if (!haveFmt) return out; // data before fmt — not a WAV we parse + + // Plain IEEE-float tag (0x0003): accept as-is. + // Extensible tag (0xFFFE): accept only when the SubFormat tag read from + // the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag + // 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT + // float and must be rejected to prevent mis-decoding as float. + const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || + (fmtTag == kWaveFormatExtensible && + extensibleSubFormatTag == kWaveFormatIeeeFloat); + if (!floatTag || bitsPerSample != 32 || channels == 0) return out; + + out.valid = true; + out.channelCount = channels; + out.sampleRate = sampleRate; + out.dataByteOffset = c.bodyOffset; + out.dataByteLength = c.bodySize; + out.riffSizeFieldOffset = 4; + out.dataSizeFieldOffset = c.headerOffset + 4; // the `data` size field (LE uint32) + return out; + } + } + + return out; // no data chunk found -> invalid +} + +std::vector extractFloatFrames(const std::vector& bytes, + const WavLayout& layout, + std::size_t startFrame, + std::size_t frameCount) { + std::vector out; + if (!layout.valid) return out; + + const std::size_t bytesPerFrame = + static_cast(layout.channelCount) * 4u; + const std::size_t totalFrames = layout.frameCount(); + if (startFrame >= totalFrames) return out; + + // Clamp the requested span to the frames that actually exist. + const std::size_t avail = totalFrames - startFrame; + const std::size_t frames = (frameCount < avail) ? frameCount : avail; + if (frames == 0) return out; + + const std::size_t firstByte = + layout.dataByteOffset + startFrame * bytesPerFrame; + out.resize(frames * layout.channelCount); + // memcpy each float (LE on target hosts — see header's byte-order note). + for (std::size_t i = 0; i < out.size(); ++i) { + float f = 0.0f; + std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u); + out[i] = f; + } + return out; +} + +WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) { + WavTruncatePlan plan; + if (!layout.valid) return plan; + + const std::size_t totalFrames = layout.frameCount(); + if (keptFrames > totalFrames) return plan; // never grow + + const std::size_t bytesPerFrame = + static_cast(layout.channelCount) * 4u; + const std::size_t keptDataBytes = keptFrames * bytesPerFrame; + + plan.valid = true; + plan.newFileByteLength = layout.dataByteOffset + keptDataBytes; + plan.dataSizeFieldOffset = layout.dataSizeFieldOffset; + plan.newDataSize = static_cast(keptDataBytes); + plan.riffSizeFieldOffset = layout.riffSizeFieldOffset; + // RIFF size counts everything after the 8-byte "RIFF"+size prefix. + plan.newRiffSize = static_cast(plan.newFileByteLength - 8); + return plan; +} + +void patchU32LE(std::vector& bytes, std::size_t off, std::uint32_t v) { + bytes[off + 0] = static_cast(v & 0xFF); + bytes[off + 1] = static_cast((v >> 8) & 0xFF); + bytes[off + 2] = static_cast((v >> 16) & 0xFF); + bytes[off + 3] = static_cast((v >> 24) & 0xFF); +} + +std::vector buildFloat32Wav(int nch, std::uint32_t rate, + std::size_t frameCount, + const std::vector& interleaved) { + const std::size_t sampleCount = frameCount * static_cast(nch); + const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32 + + // The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload. + // Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount. + const std::uint32_t riffSize = + static_cast(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data + + std::vector out; + out.reserve(44u + dataBytesCount); + + auto putU16 = [&](std::uint16_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + }; + auto putU32 = [&](std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); + }; + auto putTag = [&](const char* t) { + for (int i = 0; i < 4; ++i) + out.push_back(static_cast(t[i])); + }; + auto putF32 = [&](float f) { + std::uint8_t tmp[4]; + std::memcpy(tmp, &f, 4); + for (int i = 0; i < 4; ++i) out.push_back(tmp[i]); + }; + + // RIFF header + putTag("RIFF"); + putU32(riffSize); + putTag("WAVE"); + + // fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003) + putTag("fmt "); + putU32(16u); // chunk body size + putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT + putU16(static_cast(nch)); + putU32(rate); + putU32(rate * static_cast(nch) * 4u); // avgBytesPerSec + putU16(static_cast(nch * 4)); // blockAlign + putU16(32u); // bitsPerSample + + // data chunk + putTag("data"); + putU32(static_cast(dataBytesCount)); + for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i) + putF32(static_cast(interleaved[i])); + + return out; +} + +std::string hashBytes(const std::uint8_t* data, std::size_t len) { + // FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity. + std::uint64_t h = kFnvOffsetBasis; + for (std::size_t i = 0; i < len; ++i) { + h ^= static_cast(data[i]); + h *= kFnvPrime; + } + return fnvHex(h); +} + +std::string hashWavContent(const std::vector& bytes) { + // Walk the RIFF/WAVE container (the shared traversal) and feed only the `fmt ` + // body and `data` body through FNV-1a, prefixed with the domain-separation tag + // byte 'W' (0x57). Any render-varying metadata chunks (bext, iXML, LIST, SMED, + // etc.) are skipped. If the file does not parse as RIFF/WAVE with both fmt and + // data chunks, fall back to whole-file hashBytes (no prefix) so an unrecognized + // file still gets a hash. + if (isRiffWave(bytes)) { + std::uint64_t h = kFnvOffsetBasis; + auto feedByte = [&](std::uint8_t b) { + h ^= static_cast(b); + h *= kFnvPrime; + }; + + bool haveFmt = false; + bool haveData = false; + + // Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a + // whole-file hash of different bytes that happen to be the same length. + feedByte(static_cast('W')); + + std::size_t pos = 12; + WavChunkView c; + while (nextWavChunk(bytes, pos, c)) { + if (tagEquals(bytes, c.headerOffset, "fmt ")) { + // Feed the entire fmt body (all fields, including format tag, channels, + // sample rate, bits-per-sample — everything that defines the audio format). + if (c.bodyInBounds) { + for (std::uint32_t i = 0; i < c.bodySize; ++i) + feedByte(bytes[c.bodyOffset + i]); + haveFmt = true; + } + } else if (tagEquals(bytes, c.headerOffset, "data")) { + // Feed the entire PCM payload. + if (c.bodyInBounds) { + for (std::uint32_t i = 0; i < c.bodySize; ++i) + feedByte(bytes[c.bodyOffset + i]); + haveData = true; + } + } + // All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped. + } + + if (haveFmt && haveData) return fnvHex(h); + // Falls through to whole-file fallback if chunks were missing/malformed. + } + + // Fallback: not a parseable RIFF/WAVE — hash the whole file (identical to + // hashBytes(data, size); no prefix tag). + return hashBytes(bytes.data(), bytes.size()); +} + +} // namespace reasampler::capture diff --git a/src/wav_trim.h b/src/core/capture/wav_codec.h similarity index 51% rename from src/wav_trim.h rename to src/core/capture/wav_codec.h index 7acf5ee..e85aa75 100644 --- a/src/wav_trim.h +++ b/src/core/capture/wav_codec.h @@ -1,10 +1,20 @@ #pragma once -// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim. +// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e: +// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32 +// build + size-field patch + the WAV-aware content hash, in one tested module. // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // vendor/ includes. Standard library only. Builds and unit-tests without REAPER. // -// WHY THIS EXISTS (docs/product/capture-tail.md §The realtime path). The realtime +// 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 @@ -12,12 +22,12 @@ // the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking, // format verification, and the size-field patch offsets — is exactly the fiddly, // easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER -// shell (capture_realtime.cpp) does only the file I/O: read the bytes, call the -// pure parse, run the decay scan, call the pure plan, write the truncated bytes. +// 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). This parser +// 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 @@ -27,11 +37,16 @@ #include #include +#include #include -#include "peaks.h" // AudioSample (float) +#include "core/audio/peaks.h" // AudioSample (float) -namespace reasampler { +namespace reasampler::capture { + +using audio::AudioSample; + +// --- Layout parse ------------------------------------------------------------ // The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the // bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field @@ -77,6 +92,8 @@ std::vector extractFloatFrames(const std::vector& byt std::size_t startFrame, std::size_t frameCount); +// --- Truncate plan + size-field patch --------------------------------------- + // The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte // length and the two size-field values to patch. `valid` is false if the layout is // invalid or keptFrames exceeds the file's frames (never GROW a file — the caller @@ -94,8 +111,62 @@ struct WavTruncatePlan { // Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV. // keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure + -// total. The shell applies it: patch the two size fields in the byte buffer, then -// truncate the file to newFileByteLength. +// total. The shell applies it: patch the two size fields in the byte buffer +// (patchU32LE), then truncate the file to newFileByteLength. WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); -} // namespace reasampler +// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size +// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size() +// (the plan's offsets came from a valid parse of the same buffer). +void patchU32LE(std::vector& bytes, std::size_t off, std::uint32_t v); + +// --- Float32 WAV build ------------------------------------------------------- + +// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved +// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, +// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels, +// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is +// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT +// ASSUMPTION above); the reduction is intentional. The output round-trips through +// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical +// source through REAPER's PCM_source, then writes the bank copy with this. +std::vector buildFloat32Wav(int nch, std::uint32_t rate, + std::size_t frameCount, + const std::vector& interleaved); + +// --- Content identity (dedup hashes) ----------------------------------------- + +// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data` +// and returns it as a 16-character lowercase hex string. Designed to fill +// Sample::contentHash so the confirm-on-last-reference guardrail +// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this +// file" from "another bank holds the same file." An empty buffer returns the bare +// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty +// files would share, but real WAV files are never empty). +std::string hashBytes(const std::uint8_t* data, std::size_t len); + +// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float +// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all +// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED). +// +// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a +// `bext` chunk containing the origination date/time) even when the format config blob +// requests no BWF metadata. Two renders of identical audio therefore differ in those +// bytes, making whole-file hashes diverge and preventing dedup collapse. +// +// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before +// the fmt/data bytes are fed in, so a content hash can never equal a whole-file +// hashBytes result for a different file of the same size. +// +// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a +// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) — +// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an +// unrecognized or malformed file still gets a non-empty hash rather than silently +// skipping dedup. +// +// Called by both capture commit paths (offline and realtime) and the ingest import +// in place of the raw hashBytes call. Walks the container with the SAME chunk +// traversal parseWavLayout uses, so hashing and decoding can never desynchronize. +std::string hashWavContent(const std::vector& bytes); + +} // namespace reasampler::capture diff --git a/src/vst/master_gain.cpp b/src/core/instrument/engine/master_gain.cpp similarity index 85% rename from src/vst/master_gain.cpp rename to src/core/instrument/engine/master_gain.cpp index 00413e4..02957dc 100644 --- a/src/vst/master_gain.cpp +++ b/src/core/instrument/engine/master_gain.cpp @@ -1,17 +1,17 @@ // master_gain.cpp — see master_gain.h. Pure math; no LICE/VST3/REAPER includes. -#include "master_gain.h" +#include "core/instrument/engine/master_gain.h" + +#include "core/util/clamp01.h" #include #include #include #include -namespace reasampler::vst { +namespace reasampler::instrument::engine { -namespace { -double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } -} // namespace +using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); } @@ -48,4 +48,4 @@ void formatMasterGainLabel(double norm, char* buf, std::size_t len) { std::snprintf(buf, len, "%+.1fdB", db); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::engine diff --git a/src/vst/master_gain.h b/src/core/instrument/engine/master_gain.h similarity index 97% rename from src/vst/master_gain.h rename to src/core/instrument/engine/master_gain.h index 9ba20aa..42e2b3b 100644 --- a/src/vst/master_gain.h +++ b/src/core/instrument/engine/master_gain.h @@ -19,7 +19,7 @@ #include -namespace reasampler::vst { +namespace reasampler::instrument::engine { // The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the // finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1. @@ -55,4 +55,4 @@ double masterGainNormFromLinear(double linear); // including the terminator. Pure. void formatMasterGainLabel(double norm, char* buf, std::size_t len); -} // namespace reasampler::vst +} // namespace reasampler::instrument::engine diff --git a/src/vst/pitch_shift.cpp b/src/core/instrument/engine/pitch_shift.cpp similarity index 82% rename from src/vst/pitch_shift.cpp rename to src/core/instrument/engine/pitch_shift.cpp index 2fd34fc..6bcdae3 100644 --- a/src/vst/pitch_shift.cpp +++ b/src/core/instrument/engine/pitch_shift.cpp @@ -20,13 +20,13 @@ // 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 "pitch_shift.h" +#include "core/instrument/engine/pitch_shift.h" #include #include #include -namespace reasampler { +namespace reasampler::instrument::engine { namespace { @@ -48,6 +48,7 @@ void PitchShifter::configure(std::int64_t windowFrames) { filled_ = 0; ratio_ = 1.0; tailFrozen_ = false; + lastSplice_ = SpliceEvent{}; return; } // 2x-window ring: one window of splice-jump span plus search + fade headroom on each side. @@ -97,6 +98,7 @@ void PitchShifter::reset() { filled_ = 0; ratio_ = 1.0; tailFrozen_ = false; + lastSplice_ = SpliceEvent{}; } void PitchShifter::freezeTail() { @@ -146,6 +148,7 @@ void PitchShifter::prime(const AudioSample* src, std::int64_t count) { fadeLen_ = 0; filled_ = count; tailFrozen_ = false; // a fresh note-on always starts with a live writer + lastSplice_ = SpliceEvent{}; // ratio_ deliberately untouched: the voice sets it per frame around the prime. } @@ -163,6 +166,7 @@ void PitchShifter::warm() { fadeLen_ = 0; filled_ = window_; tailFrozen_ = false; + lastSplice_ = SpliceEvent{}; } void PitchShifter::setShiftRatio(double ratio) { @@ -197,8 +201,9 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { // search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's // read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search, // +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead), - // so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample of - // conservative margin, never out-of-range. In steady state (filled_ == ringLen_) this is + // so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER + // than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so + // this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is // > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to // what real history exists (still many source periods with a full-window prime). The floor of // 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant. @@ -311,11 +316,42 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { } fading_ = true; fadePos_ = 0; + // Record the decision for a linked follower channel (T1-01): the follower applies this + // verbatim so both channels share one lag and one splice schedule. + lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_}; } -AudioSample PitchShifter::process(AudioSample in) { +void PitchShifter::applySplice(const SpliceEvent& ev) { + // Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no + // correlation search of our own. The master's jump was clamped against ITS filled_/delay, + // which match ours by the lockstep contract (identical configure/prime/ratio history); + // the fade length likewise derives only from shared geometry + ratio. + posB_ = posA_; + double p = posA_ - static_cast(ev.jump) + static_cast(ev.lag) + ev.frac; + const double len = static_cast(ringLen_); + while (p < 0.0) p += len; + while (p >= len) p -= len; + posA_ = p; + fadeLen_ = std::max(1, ev.fadeLen); + fading_ = true; + fadePos_ = 0; + lastSplice_ = ev; // observable mirror (tests assert follower == master per frame) +} + +AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); } + +AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) { + return processImpl(in, &master); +} + +AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) { if (window_ <= 1) return in; // pass-through (unconfigured / degenerate) + // Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer; + // 5 plain fields, negligible on the RT path). + const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{}; + lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices + // 1. Write the incoming sample at the write head (source rate). One more slot of the // ring now holds valid history (capped at the ring length once it has wrapped). // TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write @@ -335,6 +371,33 @@ AudioSample PitchShifter::process(AudioSample in) { const double gNew = 0.5 * (1.0 - std::cos(kPi * t)); out = gNew * out + (1.0 - gNew) * readTap(posB_); if (++fadePos_ >= fadeLen_) fading_ = false; + } else if (linked != nullptr) { + // 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the + // master channel did this frame. Lockstep state means our own trigger would have + // fired on the same frame; applying the master's decision keeps the two rings + // sample-aligned (one shared lag, one shared schedule). + if (linkedEv.fired) { + applySplice(linkedEv); + } else { + // Self-healing fallback (review rider): the master not firing normally means this + // channel's own trigger wouldn't fire either (lockstep). But if the processor ever + // renders a mono block mid-note, this follower channel is skipped for that block + // while the master keeps advancing — its writePos_/filled_ falls behind and, with + // only the `if (linkedEv.fired)` path above, could never resync. So check this + // follower's OWN tap distance against the safe band and splice via its own search + // when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() — + // no allocation, no new RT cost. In the normal (non-mono-block) case this branch + // never triggers: the master's trigger fires first and this whole `if` is false. + double d = static_cast(writePos_) - posA_; + const double len = static_cast(ringLen_); + while (d < 0.0) d += len; + while (d >= len) d -= len; + if (d <= static_cast(dLow_)) { + splice(+window_, d); + } else if (d >= static_cast(dHigh_)) { + splice(-window_, d); + } + } } else { // 3. Splice scheduling: relocate when the active tap's delay leaves the safe band. // Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down- @@ -368,4 +431,4 @@ AudioSample PitchShifter::process(AudioSample in) { return static_cast(out); } -} // namespace reasampler +} // namespace reasampler::instrument::engine diff --git a/src/vst/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h similarity index 75% rename from src/vst/pitch_shift.h rename to src/core/instrument/engine/pitch_shift.h index 5fdbf1d..f9fa407 100644 --- a/src/vst/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -25,7 +25,7 @@ // #include ` unconditionally, which CANNOT enter the pure sampler_core module // (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither // SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native -// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same +// 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. // @@ -53,7 +53,7 @@ // // 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_trim do the same). +// 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 @@ -66,13 +66,31 @@ #include #include -#include "peaks.h" // AudioSample (float) +#include "core/audio/peaks.h" // AudioSample (float) -namespace reasampler { +namespace reasampler::instrument::engine { + +using audio::AudioSample; + +// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG +// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation +// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller +// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the +// follower applies exactly this decision instead of running its own search. Both channels +// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel +// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice: +// stereo image wander at the splice cadence plus comb coloration on any mono sum. +struct SpliceEvent { + bool fired = false; // a splice was scheduled on this frame + std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed) + std::int64_t lag = 0; // correlation best integer lag + double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5] + std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen +}; // A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel; -// a stereo voice owns two — the algorithm is per-sample and channel-count agnostic, matching -// the S7 "one read head, per-channel value" idiom of the core. +// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice +// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned. // // The default-constructed shifter is INERT: with no configure() it passes input through // unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is @@ -91,10 +109,13 @@ public: // the tap on src[0] (delay == count, mid safe band at count == window()). The caller then // feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO // structural latency at every ratio, and splices always have `count` frames of real - // history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]; pass - // the full window (pad the tail with silence yourself if the source is shorter — trailing - // silence IS the true stream there). RT-safe: bounded copy into the pre-sized ring, no - // allocation. No-op when unconfigured. The current shift ratio is left untouched. + // history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]. + // When the PLAYABLE source is shorter than one window, prime only the real span and call + // freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real + // short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring + // are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material. + // RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured. + // The current shift ratio is left untouched. void prime(const AudioSample* src, std::int64_t count); // prime()-with-silence: zero the ring, park the tap one window behind the writer, and @@ -118,6 +139,20 @@ public: // active tap leaves its safe delay band, a correlation-aligned splice is scheduled. AudioSample process(AudioSample in); + // FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process() + // except the splice decision is NOT computed here — when `master.fired` is true this + // frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is + // considered. The caller must process the master channel FIRST each frame and pass its + // lastSplice() here, with both shifters configured/primed/ratio'd identically — their + // ring state then advances in lockstep, so the follower's own trigger would have fired + // on the same frame anyway; skipping its search only removes the second correlation + // burst (strictly cheaper, never costlier). RT-safe: same guarantees as process(). + AudioSample processLinked(AudioSample in, const SpliceEvent& master); + + // The splice decision made by the most recent process()/processLinked() call (fired == + // false when that frame spliced nothing). Feed to a follower channel's processLinked(). + const SpliceEvent& lastSplice() const { return lastSplice_; } + // TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame // remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore // their input and write nothing, but read, splice, and crossfade exactly as before over @@ -150,8 +185,15 @@ private: double readTap(double pos) const; // fractional ring read, linear interp // Relocate the active tap by ~`nominalJump` frames of added delay (clamped to the filled // span for up-jumps) and start the crossfade. `delay` is the tap's current delay behind - // the writer (the caller just computed it for the trigger test). + // the writer (the caller just computed it for the trigger test). Records the decision in + // lastSplice_ for a linked follower channel. void splice(std::int64_t nominalJump, double delay); + // Apply a master channel's already-computed splice decision verbatim (no search) — + // the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_. + void applySplice(const SpliceEvent& ev); + // Shared body of process()/processLinked(); `linked` null = master mode (own trigger + + // search), non-null = follower mode (splice iff linked->fired, with linked's decision). + AudioSample processImpl(AudioSample in, const SpliceEvent* linked); std::vector ring_; // delay line, length `ringLen_` == 2 * window_ std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through @@ -176,6 +218,8 @@ private: // its up-jump to this so no splice lands in unwritten // silence — the GA2 onset-gap fix. double ratio_ = 1.0; // current shift ratio (>0) + SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01): + // cleared at the top of every frame, set on a splice bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap // recycles the ring's frozen real tail, splices still // aligned. With the writer parked, a tap drains toward it @@ -183,4 +227,4 @@ private: // live fade by that rate. }; -} // namespace reasampler +} // namespace reasampler::instrument::engine diff --git a/src/vst/sampler_core.cpp b/src/core/instrument/engine/sampler_core.cpp similarity index 91% rename from src/vst/sampler_core.cpp rename to src/core/instrument/engine/sampler_core.cpp index 2b8e24f..0982462 100644 --- a/src/vst/sampler_core.cpp +++ b/src/core/instrument/engine/sampler_core.cpp @@ -1,8 +1,18 @@ // sampler_core — pure sampler engine implementation. See sampler_core.h for the // contract and the design rationale (keymap resolution, pitch ratio, ADSR shape, // voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes. +// +// DOCUMENTED HOT-PATH EXCEPTION to the Phase Q ~600-line file ceiling (Q-W2v, +// T4-14/T4-27 — Daniel-settled 2026-07-28): this TU deliberately STAYS WHOLE. +// AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called +// per-voice-per-sample from Voice::advanceFrame, which is called per-sample from +// VoiceEngine::render — same-TU definition is what lets the compiler inline that +// stack (the build configures NO LTO). A by-class TU split would put the hottest +// inner loop across TU boundaries — the exact heuristic-(3) dispatch blowout the +// phase forbids. Do NOT "fix" this file's length; the header is split instead +// (zone_params.h carries the shared value structs). -#include "sampler_core.h" +#include "core/instrument/engine/sampler_core.h" #include @@ -270,7 +280,7 @@ bool Voice::sustainLoopUsable() const { } void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, - double keyTrack, const vst::VelocityCurve& velocityCurve, + double keyTrack, const VelocityCurve& velocityCurve, bool declickTakeover) { // Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT // REFERENCE — the last rendered output — and mark the compensation PENDING iff this @@ -297,8 +307,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote // Any in-flight ramp is superseded: pending re-derives from the reference, which already // includes the running declick's contribution via lastOut (it tracks post-declick output). declickActive_ = false; - declickL_ = 0.0; - declickR_ = 0.0; + declickWeight_ = 0.0; active_ = true; releasing_ = false; @@ -378,25 +387,49 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote const SampleLoop& loop = sample.loop; const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0; const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured(); + // Q-W0 T1-03: the prime may only carry PLAYABLE source. The per-frame feed stops at + // feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and + // freezes the writer there (GA3) — but the prime used to pull a FULL window bounded + // only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an + // up-shifted tap could play it, transposed, before the voice freed), and a + // shorter-than-window sample got zero padding declared as valid history (splices + // landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window + // material). So bound the prime by the same playable span and, when that span is + // shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3 + // machinery then recycles the real short tail, its designed behavior. The sustain- + // loop path is unbounded by construction (the wrap keeps q inside the loop forever). + const std::int64_t primeBound = + (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) + ? playEnd_ : frameCount; + const std::int64_t primeCount = + loopWrap ? w : std::min(w, primeBound - start); // Both channels walk identical SOURCE positions (the walk depends only on loop geometry, // not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1. std::int64_t p = start; for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) { const std::vector& pcmCh = ch == 0 ? sample.frames : sample.framesR; std::int64_t q = start; - for (std::int64_t i = 0; i < w; ++i) { + for (std::int64_t i = 0; i < primeCount; ++i) { if (loopWrap) { while (q >= loop.end) q -= loopLen; } + // q < frameCount holds by construction on the non-loop path (primeCount is + // bounded); the guard stays as a belt for the loop-wrap walk. primeBuf_[static_cast(i)] = (q < frameCount) ? pcmCh[static_cast(q)] : 0.0f; ++q; } - (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), w); + (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount); if (ch == 0) p = q; // capture the end position once from channel 0's walk } - // Per-frame feed continues at `p`, exactly one window ahead of readPos_. + // 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). feedPos_ = p; + if (!loopWrap && primeCount < w) { + // Sub-window playable span: the source is ALREADY exhausted at prime time. + shiftL_.freezeTail(); + if (stereoSample) shiftR_.freezeTail(); + } } ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine. } @@ -457,8 +490,7 @@ void Voice::seedDeclick(double newOutL, double newOutR) { // gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction. (void)newOutL; (void)newOutR; // consumed only for the floor guard below declickPending_ = false; - declickL_ = 1.0; - declickR_ = 1.0; + declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state) // The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp // on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here. // Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend. @@ -512,11 +544,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { if (declickActive_) { // Bounded blend at silence: outCurrent == 0, so the blend is w*(ref − 0) == w*ref. // The weight decays by kDeclickDecay each frame, floor-checked on the weight itself. - const double l = declickL_ * declickRefL_; - const double r = declickL_ * declickRefR_; // same weight for both channels - declickL_ *= kDeclickDecay; - declickR_ *= kDeclickDecay; - if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) { + const double l = declickWeight_ * declickRefL_; + const double r = declickWeight_ * declickRefR_; // same weight for both channels + declickWeight_ *= kDeclickDecay; + if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) { declickActive_ = false; active_ = false; } @@ -578,15 +609,21 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { outL = shiftedL * gain; if (stereo) { if (haveR && shiftR_.configured()) { - // Genuine stereo: an independent shifter transposes channel 1. Each shifter is - // process()'d EXACTLY ONCE per output frame (never twice — that would advance its - // heads twice and corrupt the OLA state). Gated on haveR so a MONO sample never - // touches shiftR_ — start() only primes it for genuinely stereo samples, and a - // stale un-primed ring must not leak a previous note into this one. + // Genuine stereo (Q-W0 T1-01, linked lag): channel 1's shifter FOLLOWS channel + // 0's splice decisions via processLinked — one correlation search, one lag, one + // splice schedule for both channels (standard stereo SOLA). An independent + // per-channel search re-drew an inter-channel offset of up to +/-maxLag at + // every splice: stereo image wander at the splice cadence + mono-sum combing. + // Each shifter is still processed EXACTLY ONCE per output frame (never twice — + // that would advance its heads twice and corrupt the state). Gated on haveR so + // a MONO sample never touches shiftR_ — start() only primes it for genuinely + // stereo samples, and a stale un-primed ring must not leak a previous note. if (exhausted) shiftR_.freezeTail(); const AudioSample feedR = feedOk ? pcmR[static_cast(feedPos_)] : 0.0f; shiftR_.setShiftRatio(shift); - outRlocal = static_cast(shiftR_.process(feedR)) * gain; + outRlocal = + static_cast(shiftR_.processLinked(feedR, shiftL_.lastSplice())) * + gain; } else { // Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted // value from the mono feed; mirror it to R. Do NOT call shiftL_.process again @@ -636,13 +673,12 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { // Inactive (the common case) costs one branch; the blend itself costs one extra subtract. if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL); if (declickActive_) { - const double addL = declickL_ * (declickRefL_ - outL); - const double addR = declickL_ * (declickRefR_ - (stereo ? outRlocal : outL)); + const double addL = declickWeight_ * (declickRefL_ - outL); + const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL)); outL += addL; if (stereo) outRlocal += addR; - declickL_ *= kDeclickDecay; - declickR_ *= kDeclickDecay; // kept in sync (mirrors L — both channels share one weight) - if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) { + declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together + if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) { declickActive_ = false; } } diff --git a/src/vst/sampler_core.h b/src/core/instrument/engine/sampler_core.h similarity index 76% rename from src/vst/sampler_core.h rename to src/core/instrument/engine/sampler_core.h index 90dc92a..e241e1a 100644 --- a/src/vst/sampler_core.h +++ b/src/core/instrument/engine/sampler_core.h @@ -13,7 +13,7 @@ // 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_trim does the same). The +// 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. @@ -22,182 +22,24 @@ #include #include -#include "peaks.h" // AudioSample (float) -#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) -#include "velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) +#include "core/audio/peaks.h" // AudioSample (float) +#include "core/instrument/engine/zone_params.h" // per-zone play params + mode enums (Q-W2v header split) +#include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) +#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) namespace reasampler { -// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 -// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders -// per-channel. A PERFORMANCE choice the instrument owns (component state), never written -// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain -// value so the shell (bus negotiation, state) and the engine share one spelling; the core -// itself never branches on it — the mode only picks which render overload the shell drives. -enum class ChannelMode { Mono, Stereo }; +// Q-W1 interim: the engine deps live in their sub-namespace homes now; sampler_core +// re-namespaces in its own split wave (Q-W2v). +using audio::AudioSample; +using instrument::engine::PitchShifter; +using instrument::engine::VelocityCurve; +using instrument::engine::VelocityPoint; -// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's -// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE -// priority over a held-note stack (classic mono synth: a new note takes the voice over; the -// release of the top note falls back to the most-recent still-held note). A PERFORMANCE -// choice the instrument owns (component state), never a bank fact. Default Poly preserves -// current behavior. -enum class VoiceMode { Poly, Mono }; - -// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). -// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps -// the envelope running when a note is taken over while another is held — pitch moves without -// a re-attack (and the fallback on top-note release glides back the same way). Legato applies -// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts -// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample -// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger. -enum class MonoTrigger { Retrigger, Legato }; - -// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. -// One spelling shared by the engine, the component-state (de)serializer, and the editor's -// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool. -inline constexpr int kMinVoiceCount = 1; -inline constexpr int kMaxVoiceCount = 32; -inline constexpr int kDefaultVoiceCount = 16; - -// --------------------------------------------------------------------------- -// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because -// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching -// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower -// with the rest of the engine machinery; only the value structs need to precede SampleData. -// --------------------------------------------------------------------------- - -// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack -// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below. -struct AdsrParams { - std::int64_t attackFrames = 0; - std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR - std::int64_t decayFrames = 0; - double sustainLevel = 1.0; // 0..1 - std::int64_t releaseFrames = 0; -}; - -// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's -// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, -// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone -// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. -enum class PlayMode { Gate, Trigger }; - -// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span -// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), -// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over -// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play -// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger. -struct TriggerParams { - double lengthFraction = 1.0; // (0,1] of the post-start span to play - std::int64_t fadeInFrames = 0; // 0->1 ramp at the head - std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd -}; - -// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default -// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) -// so a third curve can join without a signature change. -enum class FadeCurve { EqualPower, Linear }; - -// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted. -inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower; - -// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration -// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances -// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). -enum class PitchEngine { Varispeed, Preserve }; - -// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" -// directive). ONE constant to flip if Varispeed should be the default instead. This is the -// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's -// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core -// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16 -// engine" holds for the core's own regression tests (an octave up still halves duration in the -// bare engine); the Preserve product default is layered on above at (de)serialization. -inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; - -// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds -// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = -// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first -// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix). -// One knob, resolved at voice allocation. -inline constexpr double kPreserveWindowMs = 50.0; - -// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always -// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to -// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack -// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-). -struct PitchEnvParams { - bool enabled = false; - std::int64_t attackFrames = 0; - std::int64_t decayFrames = 0; - double peakSemitones = 0.0; // signed depth at the peak -}; - -// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData -// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 -// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope -// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the -// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one -// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine. -struct ZonePlayParams { - PlayMode playMode = PlayMode::Gate; - AdsrParams adsr; // Gate: the AHDSR envelope - TriggerParams trigger; // Trigger: %-length + fades - PitchEngine pitchEngine = PitchEngine::Varispeed; - PitchEnvParams pitchEnv; // AD pitch modulation, off by default -}; - -// --------------------------------------------------------------------------- -// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that -// govern playback. The shell decodes the on-disk WAV and fills this; the core -// never touches a file. -// --------------------------------------------------------------------------- - -// A loop over [start, end) frames, half-open. A zero-length loop (start == end) -// is the "no sustain loop" marker — a held note past the sample end goes silent -// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false. -struct SampleLoop { - bool hasLoop = false; - std::int64_t start = 0; // first looped frame (inclusive) - std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end -}; - -// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is -// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). -// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise -// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both -// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical -// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was -// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. -struct SampleData { - std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) - std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample - int sampleRate = 0; // frames per second (for reference; ratio is - // note-relative, so rate cancels for repitch). - // 0 is explicitly invalid — every consumer must - // receive a real rate before use. - int rootNote = 60; // MIDI note recorded at (plays at unity here) - SampleLoop loop; // sustain loop, if any - // Initial read position (frame offset) a voice starts playback at — frame 0 by - // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this - // an instrument-side per-zone override (the "start point" marker); S15 builds on it - // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: - // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. - std::int64_t startFrame = 0; - - // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch - // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is - // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData. - ZonePlayParams play; - - // 2 iff a matching-length second channel exists; else 1. A framesR of a different - // length than frames is treated as absent (mono) — a malformed pair never half-plays. - int channelCount() const { - return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; - } - -}; +// The per-zone play-parameter VALUE STRUCTS + per-instance mode enums (ChannelMode / +// VoiceMode / MonoTrigger, AdsrParams / TriggerParams / PitchEnvParams / ZonePlayParams, +// SampleLoop / SampleData, and their constants) live in zone_params.h (Q-W2v header +// split, T4-14/T4-17) so param-reading TUs stop recompiling on engine-class edits. // --------------------------------------------------------------------------- // Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves @@ -232,7 +74,7 @@ struct KeyZone { // 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. - vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); + VelocityCurve velocityCurve = VelocityCurve::flat(); std::size_t sampleIndex = 0; // index into Keymap::samples }; @@ -439,7 +281,7 @@ public: // kDeclickDecay). A fresh start never declicks. void start(int note, int velocity, const SampleData& sample, int rootNote, double keyTrack = 1.0, - const vst::VelocityCurve& velocityCurve = vst::VelocityCurve::flat(), + const VelocityCurve& velocityCurve = VelocityCurve::flat(), bool declickTakeover = false); // MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the @@ -582,7 +424,9 @@ private: // recent rendered output (post-gain, incl. any running declick). A takeover/steal start() // records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_; // the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND: - // outₙ = outₙ*(1−w) + ref*w where w = declickL_/R_ starts at 1.0 and decays by + // outₙ = outₙ*(1−w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared + // by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy) + // starts at 1.0 and decays by // kDeclickDecay each frame. This is algebraically `outₙ + w*(ref − outₙ)`, so the // boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by // max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising. @@ -595,8 +439,7 @@ private: bool declickActive_ = false; double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target) double declickRefR_ = 0.0; - double declickL_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame - double declickR_ = 0.0; + double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame double lastOutL_ = 0.0; double lastOutR_ = 0.0; diff --git a/src/vst/velocity_curve.cpp b/src/core/instrument/engine/velocity_curve.cpp similarity index 97% rename from src/vst/velocity_curve.cpp rename to src/core/instrument/engine/velocity_curve.cpp index 248184d..50af883 100644 --- a/src/vst/velocity_curve.cpp +++ b/src/core/instrument/engine/velocity_curve.cpp @@ -1,23 +1,18 @@ // velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types. -#include "velocity_curve.h" +#include "core/instrument/engine/velocity_curve.h" #include // std::max, std::min, std::abs, std::stable_sort #include // std::fabs #include // std::move -namespace reasampler::vst { +namespace reasampler::instrument::engine { namespace { -double clamp(double v, double lo, double hi) { - if (v < lo) return lo; - if (v > hi) return hi; - return v; -} -double clampVelocity(double v) { return clamp(v, kVelMin, kVelMax); } -double clampAmp(double a) { return clamp(a, kAmpMin, kAmpMax); } +double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); } +double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); } // Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y // spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward). @@ -203,7 +198,7 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl // Interior point: clamp X strictly within its immediate neighbours so it can't cross them. const double lo = points_[index - 1].velocity; const double hi = points_[index + 1].velocity; - newVel = clamp(clampVelocity(velocity), lo, hi); + newVel = std::clamp(clampVelocity(velocity), lo, hi); } points_[index] = VelocityPoint{newVel, newAmp}; return points_[index]; @@ -274,4 +269,4 @@ bool VelocityCurve::equals(const VelocityCurve& other, double eps) const { return true; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::engine diff --git a/src/vst/velocity_curve.h b/src/core/instrument/engine/velocity_curve.h similarity index 99% rename from src/vst/velocity_curve.h rename to src/core/instrument/engine/velocity_curve.h index 6c761e9..a2de2dc 100644 --- a/src/vst/velocity_curve.h +++ b/src/core/instrument/engine/velocity_curve.h @@ -38,7 +38,7 @@ // 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::vst { +namespace reasampler::instrument::engine { // The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into. inline constexpr double kVelMin = 0.0; @@ -168,4 +168,4 @@ private: std::vector points_; }; -} // namespace reasampler::vst +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/zone_params.h b/src/core/instrument/engine/zone_params.h new file mode 100644 index 0000000..387375e --- /dev/null +++ b/src/core/instrument/engine/zone_params.h @@ -0,0 +1,192 @@ +#pragma once +// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the +// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor +// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec +// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes. +// PURE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes — standard library + peaks only. +// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the +// engine (Keymap / Voice / VoiceEngine) stay in sampler_core.h. + +#include +#include + +#include "core/audio/peaks.h" // AudioSample (float) + +namespace reasampler { + +// Q-W1 interim: the flat `reasampler` namespace is the engine family's home until its own +// re-namespace lands; the deps live in their sub-namespace homes. +using audio::AudioSample; + +// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 +// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders +// per-channel. A PERFORMANCE choice the instrument owns (component state), never written +// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain +// value so the shell (bus negotiation, state) and the engine share one spelling; the core +// itself never branches on it — the mode only picks which render overload the shell drives. +enum class ChannelMode { Mono, Stereo }; + +// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's +// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE +// priority over a held-note stack (classic mono synth: a new note takes the voice over; the +// release of the top note falls back to the most-recent still-held note). A PERFORMANCE +// choice the instrument owns (component state), never a bank fact. Default Poly preserves +// current behavior. +enum class VoiceMode { Poly, Mono }; + +// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). +// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps +// the envelope running when a note is taken over while another is held — pitch moves without +// a re-attack (and the fallback on top-note release glides back the same way). Legato applies +// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts +// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample +// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger. +enum class MonoTrigger { Retrigger, Legato }; + +// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. +// One spelling shared by the engine, the component-state (de)serializer, and the editor's +// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool. +inline constexpr int kMinVoiceCount = 1; +inline constexpr int kMaxVoiceCount = 32; +inline constexpr int kDefaultVoiceCount = 16; + +// --------------------------------------------------------------------------- +// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because +// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching +// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower +// with the rest of the engine machinery; only the value structs need to precede SampleData. +// --------------------------------------------------------------------------- + +// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack +// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below. +struct AdsrParams { + std::int64_t attackFrames = 0; + std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR + std::int64_t decayFrames = 0; + double sustainLevel = 1.0; // 0..1 + std::int64_t releaseFrames = 0; +}; + +// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's +// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, +// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone +// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. +enum class PlayMode { Gate, Trigger }; + +// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span +// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), +// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over +// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play +// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger. +struct TriggerParams { + double lengthFraction = 1.0; // (0,1] of the post-start span to play + std::int64_t fadeInFrames = 0; // 0->1 ramp at the head + std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd +}; + +// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default +// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) +// so a third curve can join without a signature change. +enum class FadeCurve { EqualPower, Linear }; + +// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted. +inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower; + +// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration +// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances +// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). +enum class PitchEngine { Varispeed, Preserve }; + +// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" +// directive). ONE constant to flip if Varispeed should be the default instead. This is the +// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's +// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core +// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16 +// engine" holds for the core's own regression tests (an octave up still halves duration in the +// bare engine); the Preserve product default is layered on above at (de)serialization. +inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; + +// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds +// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = +// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first +// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix). +// One knob, resolved at voice allocation. +inline constexpr double kPreserveWindowMs = 50.0; + +// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always +// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to +// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack +// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-). +struct PitchEnvParams { + bool enabled = false; + std::int64_t attackFrames = 0; + std::int64_t decayFrames = 0; + double peakSemitones = 0.0; // signed depth at the peak +}; + +// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData +// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 +// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope +// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the +// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one +// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine. +struct ZonePlayParams { + PlayMode playMode = PlayMode::Gate; + AdsrParams adsr; // Gate: the AHDSR envelope + TriggerParams trigger; // Trigger: %-length + fades + PitchEngine pitchEngine = PitchEngine::Varispeed; + PitchEnvParams pitchEnv; // AD pitch modulation, off by default +}; + +// --------------------------------------------------------------------------- +// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that +// govern playback. The shell decodes the on-disk WAV and fills this; the core +// never touches a file. +// --------------------------------------------------------------------------- + +// A loop over [start, end) frames, half-open. A zero-length loop (start == end) +// is the "no sustain loop" marker — a held note past the sample end goes silent +// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false. +struct SampleLoop { + bool hasLoop = false; + std::int64_t start = 0; // first looped frame (inclusive) + std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end +}; + +// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is +// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). +// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise +// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both +// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical +// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was +// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. +struct SampleData { + std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) + std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample + int sampleRate = 0; // frames per second (for reference; ratio is + // note-relative, so rate cancels for repitch). + // 0 is explicitly invalid — every consumer must + // receive a real rate before use. + int rootNote = 60; // MIDI note recorded at (plays at unity here) + SampleLoop loop; // sustain loop, if any + // Initial read position (frame offset) a voice starts playback at — frame 0 by + // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this + // an instrument-side per-zone override (the "start point" marker); S15 builds on it + // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: + // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. + std::int64_t startFrame = 0; + + // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch + // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is + // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData. + ZonePlayParams play; + + // 2 iff a matching-length second channel exists; else 1. A framesR of a different + // length than frames is treated as absent (mono) — a malformed pair never half-plays. + int channelCount() const { + return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; + } + +}; + +} // namespace reasampler diff --git a/src/vst/bank_sync.cpp b/src/core/instrument/map/bank_sync.cpp similarity index 71% rename from src/vst/bank_sync.cpp rename to src/core/instrument/map/bank_sync.cpp index 8ca894b..10532e9 100644 --- a/src/vst/bank_sync.cpp +++ b/src/core/instrument/map/bank_sync.cpp @@ -1,29 +1,22 @@ // bank_sync.cpp — see bank_sync.h. Pure; standard library only. -#include "bank_sync.h" +#include "core/instrument/map/bank_sync.h" #include -#include #include -namespace reasampler::vst { +#include "core/wire/wire.h" + +namespace reasampler::instrument::map { std::int64_t parseBankGeneration(const std::string& raw) { - if (raw.empty()) return kBankGenerationAbsent; - - // Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises. - // A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max - // all reject to the absent default (0). Manual accumulation with an overflow guard so a + // Whole-string, non-negative decimal parse WITHOUT exceptions or locale + // surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading + // '+' / '-', any non-digit, an empty string, or overflow past int64 max all + // reject to the absent default (0); the guarded accumulate means a // pathologically long digit run can never wrap into a bogus small value. std::int64_t value = 0; - constexpr std::int64_t kMax = std::numeric_limits::max(); - for (const char c : raw) { - if (c < '0' || c > '9') return kBankGenerationAbsent; // any non-digit -> reject whole - const int digit = c - '0'; - // Guard value*10 + digit against overflow before performing it. - if (value > (kMax - digit) / 10) return kBankGenerationAbsent; // would overflow -> reject - value = value * 10 + digit; - } + if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent; return value; } @@ -67,4 +60,4 @@ AssignConsumeDecision consumeDecision(const std::optional& re return d; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::map diff --git a/src/vst/bank_sync.h b/src/core/instrument/map/bank_sync.h similarity index 96% rename from src/vst/bank_sync.h rename to src/core/instrument/map/bank_sync.h index cb1002d..856bfe5 100644 --- a/src/vst/bank_sync.h +++ b/src/core/instrument/map/bank_sync.h @@ -21,9 +21,11 @@ #include #include -#include "assignment_request.h" // AssignmentRequest (the decoded request this consumes) +#include "core/wire/assignment_request.h" // AssignmentRequest (the decoded request this consumes) -namespace reasampler::vst { +namespace reasampler::instrument::map { + +using wire::AssignmentRequest; // The S9 bank-generation "generation 0 = never stamped" default. A project saved before // S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value @@ -102,4 +104,4 @@ AssignConsumeDecision consumeDecision(const std::optional& re std::int64_t lastConsumed, bool resolves, bool isFocusedTarget); -} // namespace reasampler::vst +} // namespace reasampler::instrument::map diff --git a/src/vst/bridge_marshal.cpp b/src/core/instrument/map/bridge_marshal.cpp similarity index 80% rename from src/vst/bridge_marshal.cpp rename to src/core/instrument/map/bridge_marshal.cpp index 23d908c..2163b55 100644 --- a/src/vst/bridge_marshal.cpp +++ b/src/core/instrument/map/bridge_marshal.cpp @@ -1,8 +1,8 @@ // bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types. -#include "bridge_marshal.h" +#include "core/instrument/map/bridge_marshal.h" -namespace reasampler::vst { +namespace reasampler::instrument::map { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer) { @@ -13,4 +13,4 @@ std::optional decodeGetProjExtState(int apiReturn, return buffer; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::map diff --git a/src/vst/bridge_marshal.h b/src/core/instrument/map/bridge_marshal.h similarity index 79% rename from src/vst/bridge_marshal.h rename to src/core/instrument/map/bridge_marshal.h index 2c75068..53a003c 100644 --- a/src/vst/bridge_marshal.h +++ b/src/core/instrument/map/bridge_marshal.h @@ -4,7 +4,7 @@ // 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_trim splitting the arithmetic out +// 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 @@ -21,8 +21,10 @@ #include #include +#include +#include -namespace reasampler::vst { +namespace reasampler::instrument::map { // Interpret a GetProjExtState result: the int return value (bytes the API reports for // the key) and the buffer it filled. Returns the value only when the API reported a @@ -34,4 +36,8 @@ namespace reasampler::vst { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer); -} // namespace reasampler::vst +// The GetProjExtState GROW-LOOP retry policy (T2-04) lived here through Q-W5; it +// was rehomed to core/wire/ext_state_read.h in Q-W6 (its consumers are 2:1 +// extension-side, so it belongs on the neutral wire seam, not the instrument map). + +} // namespace reasampler::instrument::map diff --git a/src/vst/sample_map.cpp b/src/core/instrument/map/component_state_io.cpp similarity index 50% rename from src/vst/sample_map.cpp rename to src/core/instrument/map/component_state_io.cpp index 959582d..bcec2b6 100644 --- a/src/vst/sample_map.cpp +++ b/src/core/instrument/map/component_state_io.cpp @@ -1,495 +1,33 @@ -// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL / -// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. +// component_state_io — the ComponentState envelope + zones-payload binary codec. See +// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7) +// and the why-a-separate-module note (Q-W2v, T4-13 ≡ T2-07). PURE: standard library + +// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve +// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer. -#include "sample_map.h" +#include "core/instrument/map/component_state_io.h" -#include // std::min -#include // assert +#include // std::min (bounded curve-point reserve) +#include // assert (v3-lift projectRate guard) #include // std::isfinite (v8 master-gain validation) -#include // std::memcpy +#include // std::memcpy (serializeSelection) #include // std::move -#include "master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap +#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap +#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20) -namespace reasampler { +namespace reasampler::instrument::map { + +using engine::masterGainMaxLinear; +using reasampler::wire::ByteReader; +using reasampler::wire::bitsToDouble; +using reasampler::wire::doubleToBits; +using reasampler::wire::putLE; namespace { -// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank -// stores loop points as an optional LoopPoints (both-or-neither); the core wants a -// SampleLoop with an explicit hasLoop. Absent -> no loop. -SampleLoop loopFromSample(const Sample& s) { - SampleLoop out; - if (s.loop) { - out.hasLoop = true; - out.start = s.loop->start; - out.end = s.loop->end; - } - return out; -} - -// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C -// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on -// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4). -SelectedSample distill(const Sample& s) { - SelectedSample out; - out.relativePath = s.relativePath; - out.rootNote = s.rootNote ? *s.rootNote : 60; - out.loop = loopFromSample(s); - out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry) - return out; -} - -// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the -// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's -// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone. -// Shared so the two resolution paths cannot drift. -ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) { - ResolvedZone rz; - rz.relativePath = ref.relativePath; - rz.lowNote = z.lowNote; - rz.highNote = z.highNote; - // Effective root: override beats intrinsic (distill already defaulted an empty - // intrinsic to middle C). - rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote; - // S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state — - // carried straight through and applied at play time. - rz.keyTrack = z.keyTrack; - rz.velocityCurve = z.velocityCurve; - // Effective loop / start (S11): the per-zone override wins over the intrinsic; absent - // -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B). - rz.loop = z.loopOverride ? *z.loopOverride : ref.loop; - rz.startFrame = z.startPoint ? *z.startPoint : 0; - // S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap - // resolves them to frames. - rz.play = z.play; - return rz; -} - -} // namespace - -std::optional selectSample(const std::string& banksJson, - const std::string& sampleId) { - // POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short- - // circuit before parsing — no stored id resolves to nothing to play by design. - if (sampleId.empty()) return std::nullopt; - if (banksJson.empty()) return std::nullopt; - std::optional book = BankBook::deserialize(banksJson); - if (!book) return std::nullopt; // malformed -> nothing to play (never throw) - - // Search every bank (pool first, then named — banks() is ordinal order) for the - // stored id. A sample lives in exactly one bank, so first hit wins. - for (const Bank& b : book->banks()) { - if (const Sample* s = b.index.query(sampleId)) { - return distill(*s); - } - } - // A stale stored id (no longer resolves) is SILENCE, not a substituted first sample: - // the editor reflects the missing pick with its empty state rather than masking it. - return std::nullopt; -} - -ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit) { - if (isExplicit) return current; // user's explicit choice is never fought - if (channelCount <= 0) return current; // unknown (0) or pathological -> no change - return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono; -} - -// --- Instance-owned sample references (pS self-contained playback) ------------- - -const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) { - if (sampleId.empty()) return nullptr; - for (const SampleRefEntry& e : refs) { - if (e.sampleId == sampleId) return &e.ref; - } - return nullptr; -} - -std::vector referencedSampleIds(const std::string& selectionId, - const PerformanceMap& map) { - std::vector ids; - const auto addUnique = [&ids](const std::string& id) { - if (id.empty()) return; - for (const std::string& have : ids) { - if (have == id) return; - } - ids.push_back(id); - }; - addUnique(selectionId); - for (const PerformanceZone& z : map.zones) addUnique(z.sampleId); - return ids; -} - -void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, - const std::vector& ids) { - if (ids.empty() || banksJson.empty()) return; - std::optional book = BankBook::deserialize(banksJson); - if (!book) return; // malformed blob -> no-op (the instance keeps its own copies) - for (const std::string& id : ids) { - const Sample* found = nullptr; - for (const Bank& b : book->banks()) { - if (const Sample* s = b.index.query(id)) { found = s; break; } - } - if (!found) continue; // bank miss: NEVER strips a ref — the instance owns its copy - const SelectedSample distilled = distill(*found); - bool updated = false; - for (SampleRefEntry& e : refs) { - if (e.sampleId == id) { - e.ref = distilled; - e.displayName = found->displayName; // rename sync rides the same refresh - updated = true; - break; - } - } - if (!updated) refs.push_back(SampleRefEntry{id, distilled, found->displayName}); - } -} - -LegacyLiftDecision legacyLiftDecision(const std::optional& banksJson, - const std::vector& ids) { - if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry; - const std::optional book = BankBook::deserialize(*banksJson); - if (!book) return LegacyLiftDecision::Retry; // present but unparseable: not readable YET - for (const std::string& id : ids) { - for (const Bank& b : book->banks()) { - if (b.index.query(id)) return LegacyLiftDecision::Lift; - } - } - // The blob parses and knows none of the referenced ids (or there are none): provably - // stale — a lift can never make progress against this bank. - return LegacyLiftDecision::Stale; -} - -void retainRefs(SampleRefs& refs, const std::vector& ids) { - refs.erase(std::remove_if(refs.begin(), refs.end(), - [&ids](const SampleRefEntry& e) { - for (const std::string& id : ids) { - if (id == e.sampleId) return false; - } - return true; - }), - refs.end()); -} - -std::vector listSamples(const std::string& banksJson) { - std::vector out; - if (banksJson.empty()) return out; - std::optional book = BankBook::deserialize(banksJson); - if (!book) return out; - for (const Bank& b : book->banks()) { - for (const Sample& s : b.index.all()) { - out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id}); - } - } - return out; -} - -std::vector listBanks(const std::string& banksJson) { - std::vector out; - if (banksJson.empty()) return out; - std::optional book = BankBook::deserialize(banksJson); - if (!book) return out; - for (const Bank& b : book->banks()) { - out.push_back(BankChoice{b.id, b.displayName}); - } - return out; -} - -std::vector downmixToMono(const std::vector& interleaved, - int channelCount) { - std::vector out; - if (channelCount <= 0 || interleaved.empty()) return out; - const std::size_t stride = static_cast(channelCount); - const std::size_t frames = interleaved.size() / stride; - out.resize(frames); - const double inv = 1.0 / static_cast(channelCount); - for (std::size_t f = 0; f < frames; ++f) { - double acc = 0.0; - const std::size_t base = f * stride; - for (std::size_t c = 0; c < stride; ++c) { - acc += static_cast(interleaved[base + c]); - } - out[f] = static_cast(acc * inv); - } - return out; -} - -std::vector extractChannel(const std::vector& interleaved, - int channelCount, int which) { - std::vector out; - if (channelCount <= 0 || interleaved.empty()) return out; - const std::size_t stride = static_cast(channelCount); - // Clamp the requested channel into the source's range: a channel past the last one reads - // the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono). - std::size_t ch = which < 0 ? 0 : static_cast(which); - if (ch >= stride) ch = stride - 1; - const std::size_t frames = interleaved.size() / stride; - out.resize(frames); - for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch]; - return out; -} - -DecodedZonePcm decodeChannels(const std::vector& interleaved, - int sourceChannels, ChannelMode mode, int sampleRate) { - assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)"); - DecodedZonePcm out; - if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate - out.sampleRate = sampleRate; - if (mode == ChannelMode::Mono) { - // MONO mode: the existing downmix policy (average all source channels), one channel out. - out.monoFrames = downmixToMono(interleaved, sourceChannels); - return out; // framesR stays empty - } - // STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0 - // duplicated when the source is mono (dual-mono, centered). extractChannel clamps the - // out-of-range channel request to the last channel, so a mono source yields L == R. - out.monoFrames = extractChannel(interleaved, sourceChannels, 0); - out.framesR = extractChannel(interleaved, sourceChannels, 1); - return out; -} - -ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) { - // seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R, - // pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry - // through untouched — they are already source frames / fractions. Non-time fields pass as-is. - assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); - const double sr = sampleRate > 0 ? static_cast(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first - const auto secToFrames = [sr](double sec) { - double f = sec * sr; - if (f < 0.0) f = 0.0; - return static_cast(f + 0.5); - }; - ZonePlayParams out; - out.playMode = stored.playMode; - out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds); - out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds); - out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds); - out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time - out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds); - out.trigger = stored.trigger; // source-frame / fraction, unchanged - out.pitchEngine = stored.pitchEngine; - out.pitchEnv.enabled = stored.pitchEnv.enabled; - out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds); - out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds); - out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time - return out; -} - -Keymap buildTier0Keymap(std::vector frames, int sampleRate, - int rootNote, const SampleLoop& loop, - std::vector framesR, const ZonePlaySeconds& play) { - assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)"); - SampleData data; - data.frames = std::move(frames); - // A second channel only counts when it length-matches channel 0 (else the sample stays - // mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays). - if (!framesR.empty() && framesR.size() == data.frames.size()) { - data.framesR = std::move(framesR); - } - if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first - data.sampleRate = sampleRate; - data.rootNote = rootNote; - data.loop = loop; - // Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate. - data.play = resolvePlay(play, data.sampleRate); - - return Keymap::singleSampleChromatic(std::move(data)); -} - -// --- Performance map --------------------------------------------------------- - -ResolvedPerformance resolvePerformance(const std::string& banksJson, - const PerformanceMap& map) { - ResolvedPerformance out; - if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0) - if (banksJson.empty()) return out; // no bank -> nothing resolves - std::optional book = BankBook::deserialize(banksJson); - if (!book) return out; // malformed -> nothing (never throw) - - for (const PerformanceZone& z : map.zones) { - // Look the id up across every bank (pool + named) — a sample lives in exactly - // one bank, so first hit wins. - const Sample* found = nullptr; - for (const Bank& b : book->banks()) { - if (const Sample* s = b.index.query(z.sampleId)) { - found = s; - break; - } - } - if (!found) { - // STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune). - out.droppedSampleIds.push_back(z.sampleId); - continue; - } - // Distill the bank Sample to the same intrinsics shape the refs table carries, then - // run the SHARED fold — so the bank path and the refs path resolve identically. - out.zones.push_back(foldZone(z, distill(*found))); - } - return out; -} - -ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs, - const PerformanceMap& map) { - ResolvedPerformance out; - for (const PerformanceZone& z : map.zones) { - if (const SelectedSample* r = findRef(refs, z.sampleId)) { - out.zones.push_back(foldZone(z, *r)); - } else { - // No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the - // zone cleanly + report — the same shape as the bank path's stale-id policy. - out.droppedSampleIds.push_back(z.sampleId); - } - } - return out; -} - -bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) { - if (selectedId.empty() || map.zones.empty()) return false; - for (const PerformanceZone& z : map.zones) { - // An authored key range marks Zone-view intent — first-match order is load-bearing - // there, so the map is left exactly as authored. - if (z.lowNote != 0 || z.highNote != 127) return false; - } - // Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first - // zone bound to the selection (preserving its params); drop the stale shadowers. - // Decide BEFORE mutating so the no-change path leaves the map bit-identical. - std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection - for (std::size_t i = 0; i < map.zones.size(); ++i) { - if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; } - } - const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u; - if (keptCount == map.zones.size()) return false; // one zone, already the selection's - if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]); - map.zones.resize(keptCount); - return true; -} - -Keymap buildZonedKeymap(const std::vector& zones, - const std::vector& decoded) { - Keymap km; - const std::size_t n = std::min(zones.size(), decoded.size()); - for (std::size_t i = 0; i < n; ++i) { - // An unreadable/empty WAV drops just this zone (not the whole map). - if (decoded[i].monoFrames.empty()) continue; - SampleData data; - data.frames = decoded[i].monoFrames; - // Carry the second channel only when it length-matches channel 0 (channelCount() - // enforces the same rule; a mismatched pair falls back to mono rather than half-play). - if (!decoded[i].framesR.empty() && - decoded[i].framesR.size() == data.frames.size()) { - data.framesR = decoded[i].framesR; - } - assert(decoded[i].sampleRate > 0 && - "buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)"); - if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first - data.sampleRate = decoded[i].sampleRate; - data.rootNote = zones[i].rootNote; - data.loop = zones[i].loop; - data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) - // Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's - // actual rate; source-timeline params (trigger %-length + fades, start) carry through. - data.play = resolvePlay(zones[i].play, data.sampleRate); - const std::size_t sampleIndex = km.samples.size(); - km.samples.push_back(std::move(data)); - KeyZone zone; - zone.lowNote = zones[i].lowNote; - zone.highNote = zones[i].highNote; - zone.rootNote = zones[i].rootNote; - zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time - zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start - zone.sampleIndex = sampleIndex; - km.zones.push_back(zone); - } - return km; // empty zones in -> empty Keymap (silence) -} - -// --- Performance-map instance state (setState/getState) ----------------------- - -namespace { - -void putU32le(std::vector& out, std::uint32_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - out.push_back(static_cast((v >> 16) & 0xFF)); - out.push_back(static_cast((v >> 24) & 0xFF)); -} - -// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as -// two's-complement u64, mirroring the u32 signed-int idiom above). -void putU64le(std::vector& out, std::uint64_t v) { - for (int b = 0; b < 8; ++b) out.push_back(static_cast((v >> (b * 8)) & 0xFF)); -} - +// Signed 64-bit values ride the wire as their two's-complement unsigned image. std::uint64_t asU64(std::int64_t v) { return static_cast(v); } -// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++). -// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields. -std::uint64_t doubleToBits(double d) { - std::uint64_t bits; - std::memcpy(&bits, &d, sizeof(bits)); - return bits; -} -double bitsToDouble(std::uint64_t bits) { - double d; - std::memcpy(&d, &bits, sizeof(d)); - return d; -} - -// A bounded little-endian reader over a byte blob. Every read is length-checked; once a -// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated -// blob degrades to a partial/empty parse rather than reading out of bounds. -struct ByteReader { - const std::vector& bytes; - std::size_t pos = 0; - bool ok = true; - - explicit ByteReader(const std::vector& b) : bytes(b) {} - - std::uint32_t u32() { - if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; } - const std::uint32_t v = static_cast(bytes[pos]) | - (static_cast(bytes[pos + 1]) << 8) | - (static_cast(bytes[pos + 2]) << 16) | - (static_cast(bytes[pos + 3]) << 24); - pos += 4; - return v; - } - std::uint8_t u8() { - if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; } - return bytes[pos++]; - } - std::string str(std::uint32_t len) { - if (!ok || pos + len > bytes.size()) { ok = false; return {}; } - std::string s(reinterpret_cast(bytes.data() + pos), len); - pos += len; - return s; - } - // Signed ints go on the wire as u32 two's-complement (fixed 32-bit width). - int i32() { return static_cast(static_cast(u32())); } - - std::uint64_t u64() { - if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; } - std::uint64_t v = 0; - for (int b = 0; b < 8; ++b) - v |= static_cast(bytes[pos + static_cast(b)]) << (b * 8); - pos += 8; - return v; - } - // Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width). - std::int64_t i64() { return static_cast(u64()); } - - // Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields - // 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob - // as "no marker" and falls through to the (also-guarded) v1 count read. - std::uint32_t peekU32() const { - if (!ok || pos + 4 > bytes.size()) return 0; - return static_cast(bytes[pos]) | - (static_cast(bytes[pos + 1]) << 8) | - (static_cast(bytes[pos + 2]) << 16) | - (static_cast(bytes[pos + 3]) << 24); - } -}; - // Append the zones payload — the shared body of the performance blob and the component blob, // so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion // == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail @@ -498,57 +36,57 @@ struct ByteReader { // (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& out, const PerformanceMap& map) { - putU32le(out, kZonesFormatMarker); - putU32le(out, kZonesPayloadVersion); - putU32le(out, static_cast(map.zones.size())); + putLE(out, kZonesFormatMarker); + putLE(out, kZonesPayloadVersion); + putLE(out, static_cast(map.zones.size())); for (const PerformanceZone& z : map.zones) { - putU32le(out, static_cast(z.sampleId.size())); + putLE(out, static_cast(z.sampleId.size())); out.insert(out.end(), z.sampleId.begin(), z.sampleId.end()); - putU32le(out, static_cast(static_cast(z.lowNote))); - putU32le(out, static_cast(static_cast(z.highNote))); + putLE(out, static_cast(static_cast(z.lowNote))); + putLE(out, static_cast(static_cast(z.highNote))); out.push_back(z.rootOverride ? 1 : 0); if (z.rootOverride) { - putU32le(out, + putLE(out, static_cast(static_cast(*z.rootOverride))); } // S11 extension: loop override (hasLoop flag + start/end), then start point. out.push_back(z.loopOverride ? 1 : 0); if (z.loopOverride) { out.push_back(z.loopOverride->hasLoop ? 1 : 0); - putU64le(out, asU64(z.loopOverride->start)); - putU64le(out, asU64(z.loopOverride->end)); + putLE(out, asU64(z.loopOverride->start)); + putLE(out, asU64(z.loopOverride->end)); } out.push_back(z.startPoint ? 1 : 0); - if (z.startPoint) putU64le(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). // Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames / // fraction. Order matches the header's v5 record spec. const ZonePlaySeconds& pp = z.play; out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); - putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds - putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction - putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames - putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames + putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds + putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction + putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames + putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); out.push_back(pp.pitchEnv.enabled ? 1 : 0); - putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds - putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds - putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth + putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds + putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds + putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level). - putU64le(out, doubleToBits(pp.adsr.attackSeconds)); - putU64le(out, doubleToBits(pp.adsr.decaySeconds)); - putU64le(out, doubleToBits(pp.adsr.sustainLevel)); - putU64le(out, doubleToBits(pp.adsr.releaseSeconds)); + putLE(out, doubleToBits(pp.adsr.attackSeconds)); + putLE(out, doubleToBits(pp.adsr.decaySeconds)); + putLE(out, doubleToBits(pp.adsr.sustainLevel)); + putLE(out, doubleToBits(pp.adsr.releaseSeconds)); // PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET). - putU64le(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 // control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included). - const std::vector& pts = z.velocityCurve.points(); - putU32le(out, static_cast(pts.size())); - for (const reasampler::vst::VelocityPoint& p : pts) { - putU64le(out, doubleToBits(p.velocity)); - putU64le(out, doubleToBits(p.amp)); + const std::vector& pts = z.velocityCurve.points(); + putLE(out, static_cast(pts.size())); + for (const VelocityPoint& p : pts) { + putLE(out, doubleToBits(p.velocity)); + putLE(out, doubleToBits(p.amp)); } } } @@ -642,7 +180,7 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { // false mid-curve) leaves the flat default and the mid-zone break below drops the rest. if (curveTail) { const std::uint32_t ptCount = r.u32(); - std::vector pts; + std::vector pts; // Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge // count can't trigger a giant allocation before the bounded reads fail — the loop still // stops on r.ok, this only caps the speculative reserve. @@ -651,9 +189,9 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) { const double vel = bitsToDouble(r.u64()); const double amp = bitsToDouble(r.u64()); - pts.push_back(reasampler::vst::VelocityPoint{vel, amp}); + pts.push_back(VelocityPoint{vel, amp}); } - if (r.ok) z.velocityCurve = reasampler::vst::VelocityCurve::fromPoints(std::move(pts)); + if (r.ok) z.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts)); } // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. @@ -666,7 +204,7 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { std::vector serializePerformance(const PerformanceMap& map) { std::vector out; - putU32le(out, kPerformanceStateVersion); + putLE(out, kPerformanceStateVersion); putZonesPayload(out, map); return out; } @@ -704,13 +242,13 @@ PerformanceMap deserializePerformance(const std::vector& bytes, std::vector serializeComponentState(const ComponentState& state) { std::vector out; - putU32le(out, kComponentStateVersion); + putLE(out, kComponentStateVersion); // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0); // v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE // two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that // stops at the mode byte is a strict prefix (see the v4 lift below). - putU64le(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 // the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift). out.push_back(state.previewVelocity); @@ -729,10 +267,10 @@ std::vector serializeComponentState(const ComponentState& state) { // negative falls back to unity; above the +24 dB cap clamps to the cap. { double g = state.masterGainLinear; - const double maxLin = vst::masterGainMaxLinear(); + const double maxLin = masterGainMaxLinear(); if (!std::isfinite(g) || g < 0.0) g = 1.0; if (g > maxLin) g = maxLin; - putU64le(out, doubleToBits(g)); + putLE(out, doubleToBits(g)); } // v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag, // 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the @@ -744,29 +282,29 @@ std::vector serializeComponentState(const ComponentState& state) { // the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per // entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always // written), channelCount, displayName (length-prefixed; display-only). - putU32le(out, static_cast(state.sampleRefs.size())); + putLE(out, static_cast(state.sampleRefs.size())); for (const SampleRefEntry& e : state.sampleRefs) { - putU32le(out, static_cast(e.sampleId.size())); + putLE(out, static_cast(e.sampleId.size())); out.insert(out.end(), e.sampleId.begin(), e.sampleId.end()); - putU32le(out, static_cast(e.ref.relativePath.size())); + putLE(out, static_cast(e.ref.relativePath.size())); out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end()); - putU32le(out, static_cast(static_cast(e.ref.rootNote))); + putLE(out, static_cast(static_cast(e.ref.rootNote))); out.push_back(e.ref.loop.hasLoop ? 1 : 0); - putU64le(out, asU64(e.ref.loop.start)); - putU64le(out, asU64(e.ref.loop.end)); - putU32le(out, + putLE(out, asU64(e.ref.loop.start)); + putLE(out, asU64(e.ref.loop.end)); + putLE(out, static_cast(static_cast(e.ref.channelCount))); - putU32le(out, static_cast(e.displayName.size())); + putLE(out, static_cast(e.displayName.size())); out.insert(out.end(), e.displayName.begin(), e.displayName.end()); } // v11 envelope addition (pS-usage instance identity): the minted per-instance guid, // length-prefixed, following the refs table so a v10 blob is a strict prefix up to // here (see the v10 lift). Empty = never published — legal, round-trips as empty. - putU32le(out, static_cast(state.instanceGuid.size())); + putLE(out, static_cast(state.instanceGuid.size())); out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end()); // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — // unlike the v1 selection blob where the id ran to end-of-stream). - putU32le(out, static_cast(state.selectionId.size())); + putLE(out, static_cast(state.selectionId.size())); out.insert(out.end(), state.selectionId.begin(), state.selectionId.end()); putZonesPayload(out, state.map); return out; @@ -887,7 +425,7 @@ ComponentState deserializeComponentState(const std::vector& bytes, if (!r.ok) return out; // truncated inside the gain double — out already carries // mode/marker/velocity/voice fields from above; unity holds out.masterGainLinear = - (std::isfinite(g) && g >= 0.0 && g <= vst::masterGainMaxLinear() * (1.0 + 1e-9)) + (std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9)) ? g : 1.0; } @@ -967,4 +505,4 @@ std::string deserializeSelection(const std::vector& bytes) { bytes.size() - 4); } -} // namespace reasampler +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h new file mode 100644 index 0000000..a66b1f4 --- /dev/null +++ b/src/core/instrument/map/component_state_io.h @@ -0,0 +1,326 @@ +#pragma once +// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the +// ReaSampler 9000 instrument (Q-W2v split out of sample_map, T4-13 ≡ T2-07). PURE: NO +// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps. +// +// WHY A SEPARATE MODULE. The codec grows on EVERY ComponentState envelope bump (v6→v11 +// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's +// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop +// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset +// so the payload and the instrument's reader can never drift. Housing it inside +// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift) +// to serialize one preset blob; split out, both artifacts link the codec and only the +// VST links the engine. The codec's own links are velocity_curve + master_gain (wire +// value validation) — never the engine. +// +// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full +// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly. + +#include +#include +#include + +#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core) + +namespace reasampler::instrument::map { + +// --- Performance-map instance state (VST3 setState/getState) ----------------- +// +// The performance map is the instrument's OWN state (D-B), serialized to the VST3 +// component-state IBStream — NOT written to the "reasampler" bank ext-state (the +// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of +// truncation/wrong-version by design (bounded reads, never throws across the host). +// +// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the +// ZONES PAYLOAD. +// +// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones +// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) +// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState +// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the +// key composition property: the zone-record extension is versioned inside the map blob, not on +// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not +// collide on a single version number. +// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone: +// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, +// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride). +// A payload starting with a small u32 (the zone count) is v1 — there is no marker. +// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone +// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended +// to each zone record after rootOverride: +// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, +// 8-byte LE loop.end (both two's-complement int64); +// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). +// The reader detects the marker to know the record shape — a v1 payload (no marker) reads +// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. +// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload +// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint +// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; +// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); +// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); +// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double. +// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + +// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved +// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. +// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS +// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds +// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed +// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. +// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). +// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), +// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full +// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine; 1 byte pitchEnv.enabled; +// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double); +// 8-byte LE pitchEnv.peakSemitones (double); +// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double); +// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double). +// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4 +// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader +// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds +// to frames at the LIVE sample rate; no rate is baked into storage or the program. +// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is +// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved +// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes +// to an EMPTY map. +// +// These two functions serialize the ZONES only. Since S10 the instrument's full component +// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState +// below, the v3 format the processor actually reads/writes. serializePerformance/ +// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift. + +inline constexpr std::uint32_t kPerformanceStateVersion = 2; + +// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9). +// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 — +// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock +// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides +// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker + +// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts) +// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The +// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice, +// always tiny) can never collide with. +// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the +// full v5 play-params tail: +// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). +// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone +// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the +// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. +// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp +// transfer curve appended to each zone record after the v6 keyTrack field: +// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp +// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2. +// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1 +// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: +// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A +// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed. +inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve +inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; + +// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are +// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a +// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build +// already receives, so the seconds domain is consistent across both paths. No constant is baked in. + +// The performance map serialized to bytes for IBStream (getState). +std::vector serializePerformance(const PerformanceMap& map); + +// The performance map parsed back from IBStream bytes (setState). A v2 blob parses +// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map. +// `projectRate` is the live host/project sample rate (must be > 0) used to convert the +// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. +PerformanceMap deserializePerformance(const std::vector& bytes, + double projectRate); + +// --- Combined component state (VST3 setState/getState, v3 — S10) ------------- +// +// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that +// BOTH persist: the default face is one picked capture (the selection id), and zones are a +// demoted opt-in overlay (the performance map). The component state carries both so a saved +// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an +// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty +// state), never auto-playing sample #1. +// +// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono, +// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a +// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system +// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono +// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 +// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte +// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the +// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the +// instance-owned path + intrinsics + display name per referenced sample; wire shape at +// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE +// length + guid bytes; the minted per-instance identity the usage publisher keys its +// "rsusage_" ext-state record under, see sample_usage.h), then a 4-byte LE +// selection-id length + id bytes, then the CURRENT zones payload (identical to +// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). +// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the +// only v10 addition over v9 — the envelope grows a field, +// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own +// versioning; the two version numbers are independent axes — do NOT bump the zones-payload +// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range +// master-gain double (a corrupt blob) falls back to the field's default rather than silencing +// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to +// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = +// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity +// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the +// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD +// deliberately chosen a mode re-toggles once and the choice persists explicit from then on — +// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path — +// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish): +// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. +// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage. +// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). +// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). +// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). +// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). +// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). +// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). +// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. +// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection. +// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift. +// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state). +// +// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is +// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user +// already got and then manually changed away from: on re-open the instance re-reads the pending +// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see +// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign +// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the +// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. +// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no +// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default. +inline constexpr std::uint8_t kPreviewVelocityDefault = 64; + +struct ComponentState { + std::string selectionId; // the single-capture pick; "" = no pick + PerformanceMap map; // the opt-in zones; empty = no zones + ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E) + // GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle). + // While false (implicit), the shell auto-defaults the mode from the loaded capture's + // channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the + // user's choice is never fought. Pre-v9 blobs lift to false (implicit). + bool channelModeExplicit = false; + std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed + // S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling + // of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's + // chosen strike velocity across saves. Defaults to kPreviewVelocityDefault. + std::uint8_t previewVelocity = kPreviewVelocityDefault; + // Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT + // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an + // older blob lifting to these plays byte-identically. + int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount + VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) + MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato + // FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; + // up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output + // trim applied by process() AFTER the voice sum (engine + drain + preview) — never per + // voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, + // so an older blob lifting to 1.0 plays exactly as it did. + double masterGainLinear = 1.0; + // pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics + // for every bank sample this instance plays (see the SampleRefs block above). setState + // decodes straight from these; NO bridge/extension read is required for playback. A + // pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve + // path once (then re-saves self-contained). + SampleRefs sampleRefs; + // pS-usage (v11): the minted per-instance identity the usage publisher keys its + // "rsusage_" ext-state record under (see sample_usage.h — the prune-protection + // seam). Persisted so the key is stable across sessions (records do not proliferate + // per reopen). Empty = never published (a fresh or pre-v11 instance); the processor + // mints one on first publish, and RE-mints when the publish plan detects this state + // was cloned onto another track (FX copy / track duplication — planUsagePublish). + std::string instanceGuid; +}; + +inline constexpr std::uint32_t kComponentStateVersion = 11; + +// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed +// after the refs table). Mirrors the v10/v9/… series so the version branches in +// deserializeComponentState stay self-describing. +inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11; + +// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table). +// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection +// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE +// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, +// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of +// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length + +// displayName bytes (display-only; the editor label's extension-absent fallback). +inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; + +// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode +// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8; + +// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the +// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9; + +// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + +// preview velocity + voice system, no master gain). Retained so deserializeComponentState can +// lift a v7 blob to unity master gain. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7; + +// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker + +// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a +// v6 blob to the voice defaults {16, Poly, Retrigger}. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6; + +// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no +// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity. +inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5; + +// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed +// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. +inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4; + +// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named +// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. +inline constexpr std::uint32_t kSelectionZonesV3Version = 3; + +// The full instance state serialized to bytes for IBStream (getState). +std::vector serializeComponentState(const ComponentState& state); + +// The full instance state parsed back from IBStream bytes (setState). Tolerant of +// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table +// above so already-saved instances restore cleanly. +// `projectRate` is the live host/project sample rate (must be > 0) used to convert the +// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. +ComponentState deserializeComponentState(const std::vector& bytes, + double projectRate); + +// --- Instance state (VST3 setState/getState) -------------------------------- +// +// The instrument's OWN state is which bank sample it plays (D-B: the selection is a +// performance choice, held by the instrument, never written back to the bank). It is a +// single string id. serialize/deserialize keep the on-the-wire form explicit and +// versioned so a future Tier can extend it without breaking already-saved instances. +// +// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No +// length prefix is needed — the id runs to the end of the stream (the host tells us the +// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob +// by returning "" (no selection — under the S10 policy reversal an empty selection is +// SILENCE + the "pick a capture" empty state, not the bank's first sample), never +// throwing across the host boundary. Retained for the v1→v3 back-compat lift in +// deserializeComponentState; the processor's live state is the v3 ComponentState above. + +inline constexpr std::uint32_t kSelectionStateVersion = 1; + +// The selected-sample id serialized to bytes for IBStream (getState). +std::vector serializeSelection(const std::string& sampleId); + +// The selected-sample id parsed back from IBStream bytes (setState). Unknown version, +// too-short, or empty -> "" (graceful no-selection). +std::string deserializeSelection(const std::vector& bytes); + + +} // namespace reasampler::instrument::map diff --git a/src/vst/note_entry.cpp b/src/core/instrument/map/note_entry.cpp similarity index 96% rename from src/vst/note_entry.cpp rename to src/core/instrument/map/note_entry.cpp index 99cb354..3a3e2a1 100644 --- a/src/vst/note_entry.cpp +++ b/src/core/instrument/map/note_entry.cpp @@ -1,11 +1,11 @@ // note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry. -#include "note_entry.h" +#include "core/instrument/map/note_entry.h" #include #include -namespace reasampler::vst { +namespace reasampler::instrument::map { namespace { char asciiUpper(char c) { @@ -110,4 +110,4 @@ std::optional parseNoteEntry(const std::string& text) { return parseNoteName(s); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::map diff --git a/src/vst/note_entry.h b/src/core/instrument/map/note_entry.h similarity index 95% rename from src/vst/note_entry.h rename to src/core/instrument/map/note_entry.h index de3ae0a..b1e909d 100644 --- a/src/vst/note_entry.h +++ b/src/core/instrument/map/note_entry.h @@ -22,7 +22,7 @@ #include #include -namespace reasampler::vst { +namespace reasampler::instrument::map { // Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal // integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is @@ -30,4 +30,4 @@ namespace reasampler::vst { // into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types. std::optional parseNoteEntry(const std::string& text); -} // namespace reasampler::vst +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp new file mode 100644 index 0000000..4f92b13 --- /dev/null +++ b/src/core/instrument/map/sample_map.cpp @@ -0,0 +1,404 @@ +// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec +// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER / +// SWELL / vendor includes; standard library + the pure bank_book / wav_codec / sampler_core. + +#include "core/instrument/map/sample_map.h" + +#include // std::min +#include // assert +#include // std::move + +namespace reasampler::instrument::map { + +namespace { + +// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank +// stores loop points as an optional LoopPoints (both-or-neither); the core wants a +// SampleLoop with an explicit hasLoop. Absent -> no loop. +SampleLoop loopFromSample(const Sample& s) { + SampleLoop out; + if (s.loop) { + out.hasLoop = true; + out.start = s.loop->start; + out.end = s.loop->end; + } + return out; +} + +// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C +// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on +// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4). +SelectedSample distill(const Sample& s) { + SelectedSample out; + out.relativePath = s.relativePath; + out.rootNote = s.rootNote ? *s.rootNote : 60; + out.loop = loopFromSample(s); + out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry) + return out; +} + +// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the +// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's +// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone. +// Shared so the two resolution paths cannot drift. +ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) { + ResolvedZone rz; + rz.relativePath = ref.relativePath; + rz.lowNote = z.lowNote; + rz.highNote = z.highNote; + // Effective root: override beats intrinsic (distill already defaulted an empty + // intrinsic to middle C). + rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote; + // S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state — + // carried straight through and applied at play time. + rz.keyTrack = z.keyTrack; + rz.velocityCurve = z.velocityCurve; + // Effective loop / start (S11): the per-zone override wins over the intrinsic; absent + // -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B). + rz.loop = z.loopOverride ? *z.loopOverride : ref.loop; + rz.startFrame = z.startPoint ? *z.startPoint : 0; + // S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap + // resolves them to frames. + rz.play = z.play; + return rz; +} + +} // namespace + +std::optional selectSample(const std::string& banksJson, + const std::string& sampleId) { + // POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short- + // circuit before parsing — no stored id resolves to nothing to play by design. + if (sampleId.empty()) return std::nullopt; + if (banksJson.empty()) return std::nullopt; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return std::nullopt; // malformed -> nothing to play (never throw) + + // Search every bank (pool first, then named — banks() is ordinal order) for the + // stored id. A sample lives in exactly one bank, so first hit wins. + for (const Bank& b : book->banks()) { + if (const Sample* s = b.index.query(sampleId)) { + return distill(*s); + } + } + // A stale stored id (no longer resolves) is SILENCE, not a substituted first sample: + // the editor reflects the missing pick with its empty state rather than masking it. + return std::nullopt; +} + +ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit) { + if (isExplicit) return current; // user's explicit choice is never fought + if (channelCount <= 0) return current; // unknown (0) or pathological -> no change + return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono; +} + +// --- Instance-owned sample references (pS self-contained playback) ------------- + +const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) { + if (sampleId.empty()) return nullptr; + for (const SampleRefEntry& e : refs) { + if (e.sampleId == sampleId) return &e.ref; + } + return nullptr; +} + +std::vector referencedSampleIds(const std::string& selectionId, + const PerformanceMap& map) { + std::vector ids; + const auto addUnique = [&ids](const std::string& id) { + if (id.empty()) return; + for (const std::string& have : ids) { + if (have == id) return; + } + ids.push_back(id); + }; + addUnique(selectionId); + for (const PerformanceZone& z : map.zones) addUnique(z.sampleId); + return ids; +} + +void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, + const std::vector& ids) { + if (ids.empty() || banksJson.empty()) return; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return; // malformed blob -> no-op (the instance keeps its own copies) + for (const std::string& id : ids) { + const Sample* found = nullptr; + for (const Bank& b : book->banks()) { + if (const Sample* s = b.index.query(id)) { found = s; break; } + } + if (!found) continue; // bank miss: NEVER strips a ref — the instance owns its copy + const SelectedSample distilled = distill(*found); + bool updated = false; + for (SampleRefEntry& e : refs) { + if (e.sampleId == id) { + e.ref = distilled; + e.displayName = found->displayName; // rename sync rides the same refresh + updated = true; + break; + } + } + if (!updated) refs.push_back(SampleRefEntry{id, distilled, found->displayName}); + } +} + +LegacyLiftDecision legacyLiftDecision(const std::optional& banksJson, + const std::vector& ids) { + if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry; + const std::optional book = BankBook::deserialize(*banksJson); + if (!book) return LegacyLiftDecision::Retry; // present but unparseable: not readable YET + for (const std::string& id : ids) { + for (const Bank& b : book->banks()) { + if (b.index.query(id)) return LegacyLiftDecision::Lift; + } + } + // The blob parses and knows none of the referenced ids (or there are none): provably + // stale — a lift can never make progress against this bank. + return LegacyLiftDecision::Stale; +} + +void retainRefs(SampleRefs& refs, const std::vector& ids) { + refs.erase(std::remove_if(refs.begin(), refs.end(), + [&ids](const SampleRefEntry& e) { + for (const std::string& id : ids) { + if (id == e.sampleId) return false; + } + return true; + }), + refs.end()); +} + +std::vector listSamples(const std::string& banksJson) { + std::vector out; + if (banksJson.empty()) return out; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; + for (const Bank& b : book->banks()) { + for (const Sample& s : b.index.all()) { + out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id}); + } + } + return out; +} + +std::vector listBanks(const std::string& banksJson) { + std::vector out; + if (banksJson.empty()) return out; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; + for (const Bank& b : book->banks()) { + out.push_back(BankChoice{b.id, b.displayName}); + } + return out; +} + +std::vector downmixToMono(const std::vector& interleaved, + int channelCount) { + std::vector out; + if (channelCount <= 0 || interleaved.empty()) return out; + const std::size_t stride = static_cast(channelCount); + const std::size_t frames = interleaved.size() / stride; + out.resize(frames); + const double inv = 1.0 / static_cast(channelCount); + for (std::size_t f = 0; f < frames; ++f) { + double acc = 0.0; + const std::size_t base = f * stride; + for (std::size_t c = 0; c < stride; ++c) { + acc += static_cast(interleaved[base + c]); + } + out[f] = static_cast(acc * inv); + } + return out; +} + +std::vector extractChannel(const std::vector& interleaved, + int channelCount, int which) { + std::vector out; + if (channelCount <= 0 || interleaved.empty()) return out; + const std::size_t stride = static_cast(channelCount); + // Clamp the requested channel into the source's range: a channel past the last one reads + // the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono). + std::size_t ch = which < 0 ? 0 : static_cast(which); + if (ch >= stride) ch = stride - 1; + const std::size_t frames = interleaved.size() / stride; + out.resize(frames); + for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch]; + return out; +} + +DecodedZonePcm decodeChannels(const std::vector& interleaved, + int sourceChannels, ChannelMode mode, int sampleRate) { + assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)"); + DecodedZonePcm out; + if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate + out.sampleRate = sampleRate; + if (mode == ChannelMode::Mono) { + // MONO mode: the existing downmix policy (average all source channels), one channel out. + out.monoFrames = downmixToMono(interleaved, sourceChannels); + return out; // framesR stays empty + } + // STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0 + // duplicated when the source is mono (dual-mono, centered). extractChannel clamps the + // out-of-range channel request to the last channel, so a mono source yields L == R. + out.monoFrames = extractChannel(interleaved, sourceChannels, 0); + out.framesR = extractChannel(interleaved, sourceChannels, 1); + return out; +} + +ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) { + // seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R, + // pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry + // through untouched — they are already source frames / fractions. Non-time fields pass as-is. + assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); + const double sr = sampleRate > 0 ? static_cast(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first + const auto secToFrames = [sr](double sec) { + double f = sec * sr; + if (f < 0.0) f = 0.0; + return static_cast(f + 0.5); + }; + ZonePlayParams out; + out.playMode = stored.playMode; + out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds); + out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds); + out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds); + out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time + out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds); + out.trigger = stored.trigger; // source-frame / fraction, unchanged + out.pitchEngine = stored.pitchEngine; + out.pitchEnv.enabled = stored.pitchEnv.enabled; + out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds); + out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds); + out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time + return out; +} + +Keymap buildTier0Keymap(std::vector frames, int sampleRate, + int rootNote, const SampleLoop& loop, + std::vector framesR, const ZonePlaySeconds& play) { + assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)"); + SampleData data; + data.frames = std::move(frames); + // A second channel only counts when it length-matches channel 0 (else the sample stays + // mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays). + if (!framesR.empty() && framesR.size() == data.frames.size()) { + data.framesR = std::move(framesR); + } + if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first + data.sampleRate = sampleRate; + data.rootNote = rootNote; + data.loop = loop; + // Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate. + data.play = resolvePlay(play, data.sampleRate); + + return Keymap::singleSampleChromatic(std::move(data)); +} + +// --- Performance map --------------------------------------------------------- + +ResolvedPerformance resolvePerformance(const std::string& banksJson, + const PerformanceMap& map) { + ResolvedPerformance out; + if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0) + if (banksJson.empty()) return out; // no bank -> nothing resolves + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; // malformed -> nothing (never throw) + + for (const PerformanceZone& z : map.zones) { + // Look the id up across every bank (pool + named) — a sample lives in exactly + // one bank, so first hit wins. + const Sample* found = nullptr; + for (const Bank& b : book->banks()) { + if (const Sample* s = b.index.query(z.sampleId)) { + found = s; + break; + } + } + if (!found) { + // STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune). + out.droppedSampleIds.push_back(z.sampleId); + continue; + } + // Distill the bank Sample to the same intrinsics shape the refs table carries, then + // run the SHARED fold — so the bank path and the refs path resolve identically. + out.zones.push_back(foldZone(z, distill(*found))); + } + return out; +} + +ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs, + const PerformanceMap& map) { + ResolvedPerformance out; + for (const PerformanceZone& z : map.zones) { + if (const SelectedSample* r = findRef(refs, z.sampleId)) { + out.zones.push_back(foldZone(z, *r)); + } else { + // No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the + // zone cleanly + report — the same shape as the bank path's stale-id policy. + out.droppedSampleIds.push_back(z.sampleId); + } + } + return out; +} + +bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) { + if (selectedId.empty() || map.zones.empty()) return false; + for (const PerformanceZone& z : map.zones) { + // An authored key range marks Zone-view intent — first-match order is load-bearing + // there, so the map is left exactly as authored. + if (z.lowNote != 0 || z.highNote != 127) return false; + } + // Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first + // zone bound to the selection (preserving its params); drop the stale shadowers. + // Decide BEFORE mutating so the no-change path leaves the map bit-identical. + std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection + for (std::size_t i = 0; i < map.zones.size(); ++i) { + if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; } + } + const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u; + if (keptCount == map.zones.size()) return false; // one zone, already the selection's + if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]); + map.zones.resize(keptCount); + return true; +} + +Keymap buildZonedKeymap(const std::vector& zones, + const std::vector& decoded) { + Keymap km; + const std::size_t n = std::min(zones.size(), decoded.size()); + for (std::size_t i = 0; i < n; ++i) { + // An unreadable/empty WAV drops just this zone (not the whole map). + if (decoded[i].monoFrames.empty()) continue; + SampleData data; + data.frames = decoded[i].monoFrames; + // Carry the second channel only when it length-matches channel 0 (channelCount() + // enforces the same rule; a mismatched pair falls back to mono rather than half-play). + if (!decoded[i].framesR.empty() && + decoded[i].framesR.size() == data.frames.size()) { + data.framesR = decoded[i].framesR; + } + assert(decoded[i].sampleRate > 0 && + "buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)"); + if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first + data.sampleRate = decoded[i].sampleRate; + data.rootNote = zones[i].rootNote; + data.loop = zones[i].loop; + data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) + // Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's + // actual rate; source-timeline params (trigger %-length + fades, start) carry through. + data.play = resolvePlay(zones[i].play, data.sampleRate); + const std::size_t sampleIndex = km.samples.size(); + km.samples.push_back(std::move(data)); + KeyZone zone; + zone.lowNote = zones[i].lowNote; + zone.highNote = zones[i].highNote; + zone.rootNote = zones[i].rootNote; + zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time + zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start + zone.sampleIndex = sampleIndex; + km.zones.push_back(zone); + } + return km; // empty zones in -> empty Keymap (silence) +} + + +} // namespace reasampler::instrument::map diff --git a/src/vst/sample_map.h b/src/core/instrument/map/sample_map.h similarity index 52% rename from src/vst/sample_map.h rename to src/core/instrument/map/sample_map.h index d8703b9..3f6d5a8 100644 --- a/src/vst/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -3,7 +3,7 @@ // "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core // plays, and (de)serialize the instance's selected-sample choice for VST3 component // state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the -// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable +// mirror of capture_paths / wav_codec / bridge_marshal splitting the fiddly, testable // arithmetic out of a host-facing shell. // // WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam @@ -14,7 +14,7 @@ // 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_trim (the shared +// 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. @@ -23,11 +23,17 @@ #include #include -#include "bank_book.h" // BankBook::deserialize (shared bank JSON parse) -#include "sampler_core.h" // Keymap, SampleData, SampleLoop -#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) +#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse) +#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop +#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse) -namespace reasampler { +namespace reasampler::instrument::map { + +// Cross-subsystem deps by their real namespace homes (Q-W2v: sample_map now lives in +// instrument::map; the engine family stays in flat `reasampler` until its own wave). +using audio::AudioSample; +using instrument::engine::VelocityCurve; +using instrument::engine::VelocityPoint; // The bank sample this instance is bound to, distilled from the live "banks" blob: // the project-relative WAV path the file seam must resolve+decode, plus the S2 bank @@ -161,7 +167,7 @@ struct BankChoice { }; std::vector listBanks(const std::string& banksJson); -// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields: +// Downmix interleaved float frames (the shape wav_codec's extractFloatFrames yields: // [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per // frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0, // documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve @@ -287,7 +293,7 @@ struct PerformanceZone { // already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT // preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start. // Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7). - vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); + VelocityCurve velocityCurve = VelocityCurve::flat(); // S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch // engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the @@ -341,7 +347,7 @@ struct ResolvedZone { int highNote = 127; int rootNote = 60; // effective: override, else bank intrinsic, else 60 double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET) - vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone + VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build) @@ -407,302 +413,10 @@ Keymap buildZonedKeymap(const std::vector& zones, DecodedZonePcm decodeChannels(const std::vector& interleaved, int sourceChannels, ChannelMode mode, int sampleRate); -// --- Performance-map instance state (VST3 setState/getState) ----------------- -// -// The performance map is the instrument's OWN state (D-B), serialized to the VST3 -// component-state IBStream — NOT written to the "reasampler" bank ext-state (the -// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of -// truncation/wrong-version by design (bounded reads, never throws across the host). -// -// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the -// ZONES PAYLOAD. -// -// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones -// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) -// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState -// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the -// key composition property: the zone-record extension is versioned inside the map blob, not on -// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not -// collide on a single version number. -// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone: -// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, -// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride). -// A payload starting with a small u32 (the zone count) is v1 — there is no marker. -// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone -// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended -// to each zone record after rootOverride: -// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, -// 8-byte LE loop.end (both two's-complement int64); -// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). -// The reader detects the marker to know the record shape — a v1 payload (no marker) reads -// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. -// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload -// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint -// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): -// 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; -// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); -// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); -// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); -// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); -// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double. -// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + -// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved -// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. -// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS -// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds -// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed -// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. -// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). -// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), -// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full -// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): -// 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double); -// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); -// 1 byte pitchEngine; 1 byte pitchEnv.enabled; -// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double); -// 8-byte LE pitchEnv.peakSemitones (double); -// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double); -// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double). -// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4 -// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader -// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds -// to frames at the LIVE sample rate; no rate is baked into storage or the program. -// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is -// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved -// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes -// to an EMPTY map. -// -// These two functions serialize the ZONES only. Since S10 the instrument's full component -// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState -// below, the v3 format the processor actually reads/writes. serializePerformance/ -// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift. +// The ComponentState envelope + zones-payload binary codec (serializePerformance / +// serializeComponentState / serializeSelection + the deserializers and every version +// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows +// on every envelope bump and is consumed by the EXTENSION's preset-blob path too — the +// split lets both artifacts share the codec while only the VST links the voice engine. -inline constexpr std::uint32_t kPerformanceStateVersion = 2; - -// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9). -// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 — -// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock -// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides -// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker + -// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts) -// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The -// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice, -// always tiny) can never collide with. -// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the -// full v5 play-params tail: -// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). -// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone -// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the -// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. -// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp -// transfer curve appended to each zone record after the v6 keyTrack field: -// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp -// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2. -// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1 -// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: -// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A -// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed. -inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve -inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; - -// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are -// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a -// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build -// already receives, so the seconds domain is consistent across both paths. No constant is baked in. - -// The performance map serialized to bytes for IBStream (getState). -std::vector serializePerformance(const PerformanceMap& map); - -// The performance map parsed back from IBStream bytes (setState). A v2 blob parses -// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map. -// `projectRate` is the live host/project sample rate (must be > 0) used to convert the -// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. -PerformanceMap deserializePerformance(const std::vector& bytes, - double projectRate); - -// --- Combined component state (VST3 setState/getState, v3 — S10) ------------- -// -// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that -// BOTH persist: the default face is one picked capture (the selection id), and zones are a -// demoted opt-in overlay (the performance map). The component state carries both so a saved -// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an -// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty -// state), never auto-playing sample #1. -// -// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono, -// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a -// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system -// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono -// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 -// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte -// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the -// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the -// instance-owned path + intrinsics + display name per referenced sample; wire shape at -// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE -// length + guid bytes; the minted per-instance identity the usage publisher keys its -// "rsusage_" ext-state record under, see sample_usage.h), then a 4-byte LE -// selection-id length + id bytes, then the CURRENT zones payload (identical to -// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). -// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the -// only v10 addition over v9 — the envelope grows a field, -// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own -// versioning; the two version numbers are independent axes — do NOT bump the zones-payload -// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range -// master-gain double (a corrupt blob) falls back to the field's default rather than silencing -// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to -// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = -// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity -// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the -// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD -// deliberately chosen a mode re-toggles once and the choice persists explicit from then on — -// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path — -// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish): -// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. -// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage. -// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). -// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). -// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). -// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). -// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). -// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). -// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. -// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection. -// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift. -// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state). -// -// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is -// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user -// already got and then manually changed away from: on re-open the instance re-reads the pending -// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see -// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign -// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the -// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. -// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no -// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default. -inline constexpr std::uint8_t kPreviewVelocityDefault = 64; - -struct ComponentState { - std::string selectionId; // the single-capture pick; "" = no pick - PerformanceMap map; // the opt-in zones; empty = no zones - ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E) - // GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle). - // While false (implicit), the shell auto-defaults the mode from the loaded capture's - // channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the - // user's choice is never fought. Pre-v9 blobs lift to false (implicit). - bool channelModeExplicit = false; - std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed - // S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling - // of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's - // chosen strike velocity across saves. Defaults to kPreviewVelocityDefault. - std::uint8_t previewVelocity = kPreviewVelocityDefault; - // Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT - // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an - // older blob lifting to these plays byte-identically. - int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount - VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) - MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato - // FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; - // up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output - // trim applied by process() AFTER the voice sum (engine + drain + preview) — never per - // voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, - // so an older blob lifting to 1.0 plays exactly as it did. - double masterGainLinear = 1.0; - // pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics - // for every bank sample this instance plays (see the SampleRefs block above). setState - // decodes straight from these; NO bridge/extension read is required for playback. A - // pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve - // path once (then re-saves self-contained). - SampleRefs sampleRefs; - // pS-usage (v11): the minted per-instance identity the usage publisher keys its - // "rsusage_" ext-state record under (see sample_usage.h — the prune-protection - // seam). Persisted so the key is stable across sessions (records do not proliferate - // per reopen). Empty = never published (a fresh or pre-v11 instance); the processor - // mints one on first publish, and RE-mints when the publish plan detects this state - // was cloned onto another track (FX copy / track duplication — planUsagePublish). - std::string instanceGuid; -}; - -inline constexpr std::uint32_t kComponentStateVersion = 11; - -// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed -// after the refs table). Mirrors the v10/v9/… series so the version branches in -// deserializeComponentState stay self-describing. -inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11; - -// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table). -// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection -// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE -// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, -// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of -// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length + -// displayName bytes (display-only; the editor label's extension-absent fallback). -inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; - -// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode -// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode. -inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8; - -// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the -// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing. -inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9; - -// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + -// preview velocity + voice system, no master gain). Retained so deserializeComponentState can -// lift a v7 blob to unity master gain. -inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7; - -// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker + -// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a -// v6 blob to the voice defaults {16, Poly, Retrigger}. -inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6; - -// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no -// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity. -inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5; - -// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed -// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. -inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4; - -// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named -// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. -inline constexpr std::uint32_t kSelectionZonesV3Version = 3; - -// The full instance state serialized to bytes for IBStream (getState). -std::vector serializeComponentState(const ComponentState& state); - -// The full instance state parsed back from IBStream bytes (setState). Tolerant of -// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table -// above so already-saved instances restore cleanly. -// `projectRate` is the live host/project sample rate (must be > 0) used to convert the -// legacy v3 wall-clock frame counts to the seconds domain at the read boundary. -ComponentState deserializeComponentState(const std::vector& bytes, - double projectRate); - -// --- Instance state (VST3 setState/getState) -------------------------------- -// -// The instrument's OWN state is which bank sample it plays (D-B: the selection is a -// performance choice, held by the instrument, never written back to the bank). It is a -// single string id. serialize/deserialize keep the on-the-wire form explicit and -// versioned so a future Tier can extend it without breaking already-saved instances. -// -// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No -// length prefix is needed — the id runs to the end of the stream (the host tells us the -// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob -// by returning "" (no selection — under the S10 policy reversal an empty selection is -// SILENCE + the "pick a capture" empty state, not the bank's first sample), never -// throwing across the host boundary. Retained for the v1→v3 back-compat lift in -// deserializeComponentState; the processor's live state is the v3 ComponentState above. - -inline constexpr std::uint32_t kSelectionStateVersion = 1; - -// The selected-sample id serialized to bytes for IBStream (getState). -std::vector serializeSelection(const std::string& sampleId); - -// The selected-sample id parsed back from IBStream bytes (setState). Unknown version, -// too-short, or empty -> "" (graceful no-selection). -std::string deserializeSelection(const std::vector& bytes); - -} // namespace reasampler +} // namespace reasampler::instrument::map diff --git a/src/vst/trigger_seam.cpp b/src/core/instrument/map/trigger_seam.cpp similarity index 87% rename from src/vst/trigger_seam.cpp rename to src/core/instrument/map/trigger_seam.cpp index 0ee61a4..812fb3b 100644 --- a/src/vst/trigger_seam.cpp +++ b/src/core/instrument/map/trigger_seam.cpp @@ -1,10 +1,10 @@ // trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h). -#include "trigger_seam.h" +#include "core/instrument/map/trigger_seam.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::map { std::int64_t triggerPlayLength(double lengthFraction, std::int64_t frameCount, @@ -24,4 +24,4 @@ std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) return static_cast(fadeFraction * static_cast(playLength) + 0.5); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::map diff --git a/src/vst/trigger_seam.h b/src/core/instrument/map/trigger_seam.h similarity index 96% rename from src/vst/trigger_seam.h rename to src/core/instrument/map/trigger_seam.h index d7ce3c3..6539589 100644 --- a/src/vst/trigger_seam.h +++ b/src/core/instrument/map/trigger_seam.h @@ -25,7 +25,7 @@ #include -namespace reasampler::vst { +namespace reasampler::instrument::map { // The source-frame length of the Trigger played span: // postStart = max(0, frameCount - startFrame) @@ -48,4 +48,4 @@ double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength); // Rounds to nearest integer frame. Returns 0 when playLength == 0. std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength); -} // namespace reasampler::vst +} // namespace reasampler::instrument::map diff --git a/src/vst/browser_scroll.cpp b/src/core/instrument/ui/browser_scroll.cpp similarity index 74% rename from src/vst/browser_scroll.cpp rename to src/core/instrument/ui/browser_scroll.cpp index 85be0f8..c88ef1b 100644 --- a/src/vst/browser_scroll.cpp +++ b/src/core/instrument/ui/browser_scroll.cpp @@ -1,12 +1,14 @@ // browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10 // capture_browser. No host types; only the shared Rect + BrowserLayout. -#include "browser_scroll.h" +#include "core/instrument/ui/browser_scroll.h" + +#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth (Q-W2v hoist) #include #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { // The minimum thumb height so a very long bank still yields a grabbable thumb. @@ -26,7 +28,7 @@ int scrollContentHeight(const BrowserLayout& layout, int cardCount) { int scrollMaxOffset(const BrowserLayout& layout, int cardCount) { const int content = scrollContentHeight(layout, cardCount); - const int gridH = (std::max)(0, layout.grid.height()); + const int gridH = (std::max)(0, layout.grid.height); return (std::max)(0, content - gridH); } @@ -41,7 +43,7 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of VisibleRange vr; if (cardCount <= 0) return vr; const int columns = (std::max)(1, layout.columns); - const int gridH = (std::max)(0, layout.grid.height()); + const int gridH = (std::max)(0, layout.grid.height); if (gridH <= 0 || kBrowserCardHeight <= 0) { vr.first = 0; vr.last = 0; @@ -66,21 +68,21 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) { Rect r = cardCellRect(layout, index); - if (r.right <= r.left && r.bottom <= r.top) return r; // empty (negative index) stays empty - return Rect{r.left, r.top - offset, r.right, r.bottom - offset}; + if (r.right() <= r.x && r.bottom() <= r.y) return r; // empty (negative index) stays empty + return Rect::ltrb(r.x, r.y - offset, r.right(), r.bottom() - offset); } Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) { const int content = scrollContentHeight(layout, cardCount); - const int gridH = (std::max)(0, layout.grid.height()); + const int gridH = (std::max)(0, layout.grid.height); if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar const int maxOff = content - gridH; if (offset < 0) offset = 0; if (offset > maxOff) offset = maxOff; - const int trackRight = layout.grid.right; + const int trackRight = layout.grid.right(); const int trackLeft = trackRight - kScrollbarWidth; - const int trackTop = layout.grid.top; + const int trackTop = layout.grid.y; // Thumb height proportional to the visible fraction, floored at a grabbable minimum but // never taller than the track. @@ -95,13 +97,13 @@ Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) { thumbTop = trackTop + static_cast( static_cast(offset) * trackSpan / maxOff); } - return Rect{trackLeft, thumbTop, trackRight, thumbTop + thumbH}; + return Rect::ltrb(trackLeft, thumbTop, trackRight, thumbTop + thumbH); } int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels) { const int content = scrollContentHeight(layout, cardCount); - 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); // Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels. @@ -124,7 +126,7 @@ int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffse Rect searchBoxRect(int w) { if (w <= 0) return Rect{}; - return Rect{0, 0, w, kSearchBoxHeight}; + return Rect::ltrb(0, 0, w, kSearchBoxHeight); } bool nameMatchesQuery(const std::string& name, const std::string& query) { @@ -155,4 +157,25 @@ std::vector filterNameIndices(const std::vector& names, return out; } -} // namespace reasampler::vst +// The Browse-modal regions (hoisted from the editor shell, Q-W2v/T2-06 — body verbatim; +// the band metrics come from editor_geometry, the search height from searchBoxRect). +BrowseModal computeBrowseModal(int w, int h) { + constexpr int kBrowseFooterH = 30; + BrowseModal m; + const int titleH = (std::min)(kTitleHeight, h); + m.title = Rect::ltrb(0, 0, w, titleH); + m.back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)); + // Search box below the title, spanning the width (searchBoxRect lays it out from 0). + const Rect sb = searchBoxRect(w); + m.search = Rect::ltrb(kPad, titleH, w - kPad, titleH + sb.height); + const int footerTop = (std::max)(m.search.bottom(), h - kBrowseFooterH); + m.content = Rect::ltrb(0, m.search.bottom(), w, footerTop); + // Footer: Cancel (left) + Load (right). + const int fTop = footerTop + 3; + const int fBot = (std::max)(fTop, h - 3); + m.cancel = Rect::ltrb(kPad, fTop, kPad + 90, fBot); + m.confirm = Rect::ltrb(w - kPad - 90, fTop, w - kPad, fBot); + return m; +} + +} // namespace reasampler::instrument::ui diff --git a/src/vst/browser_scroll.h b/src/core/instrument/ui/browser_scroll.h similarity index 86% rename from src/vst/browser_scroll.h rename to src/core/instrument/ui/browser_scroll.h index 81aa831..566ee69 100644 --- a/src/vst/browser_scroll.h +++ b/src/core/instrument/ui/browser_scroll.h @@ -24,9 +24,9 @@ #include #include -#include "capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect +#include "core/instrument/ui/capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect -namespace reasampler::vst { +namespace reasampler::instrument::ui { // The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell // draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so @@ -104,4 +104,21 @@ bool nameMatchesQuery(const std::string& name, const std::string& query); std::vector filterNameIndices(const std::vector& names, const std::string& query); -} // namespace reasampler::vst +// --- The Browse-modal (S-VIEW-5) top-level regions (Q-W2v hoist, T2-06) ------- +// +// A title band with a Back button, the search box, the browser sub-area (tabs + card +// grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker +// covers the full window (F3: full-window overlay). Draw + hit-test both derive from +// this single layout so they never drift. Homed here (not editor_geometry) because the +// search-box height feeds it — browser_scroll already owns the search/scroll geometry. +struct BrowseModal { + Rect title; + Rect back; // the "Back" title-band button + Rect search; // the type-to-filter box (absolute) + Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin + Rect cancel; // footer Cancel + Rect confirm; // footer Load (confirm) +}; +BrowseModal computeBrowseModal(int w, int h); + +} // namespace reasampler::instrument::ui diff --git a/src/vst/capture_browser.cpp b/src/core/instrument/ui/capture_browser.cpp similarity index 68% rename from src/vst/capture_browser.cpp rename to src/core/instrument/ui/capture_browser.cpp index a36f63e..5ed9517 100644 --- a/src/vst/capture_browser.cpp +++ b/src/core/instrument/ui/capture_browser.cpp @@ -1,10 +1,10 @@ // capture_browser.cpp — see capture_browser.h. Pure math; no host types. -#include "capture_browser.h" +#include "core/instrument/ui/capture_browser.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -23,10 +23,10 @@ BrowserLayout layoutBrowser(int w, int h) { BrowserLayout out; const int tabH = std::min(kBrowserTabHeight, ch); - out.tabStrip = Rect{0, 0, cw, tabH}; - out.grid = Rect{0, tabH, cw, ch}; + out.tabStrip = Rect::ltrb(0, 0, cw, tabH); + out.grid = Rect::ltrb(0, tabH, cw, ch); - const int gridW = std::max(0, out.grid.width()); + const int gridW = std::max(0, out.grid.width); out.columns = std::max(1, gridW / kBrowserCardWidth); return out; } @@ -36,38 +36,38 @@ Rect cardCellRect(const BrowserLayout& layout, int index) { const int cols = std::max(1, layout.columns); const int col = index % cols; const int row = index / cols; - const int left = layout.grid.left + col * kBrowserCardWidth; - const int top = layout.grid.top + row * kBrowserCardHeight; - return Rect{left, top, left + kBrowserCardWidth, top + kBrowserCardHeight}; + const int left = layout.grid.x + col * kBrowserCardWidth; + const int top = layout.grid.y + row * kBrowserCardHeight; + return Rect::ltrb(left, top, left + kBrowserCardWidth, top + kBrowserCardHeight); } Rect cardContentRect(const BrowserLayout& layout, int index) { if (index < 0) return Rect{}; const Rect cell = cardCellRect(layout, index); - return Rect{cell.left + kBrowserCardGutter, cell.top + kBrowserCardGutter, - cell.right - kBrowserCardGutter, cell.bottom - kBrowserCardGutter}; + return Rect::ltrb(cell.x + kBrowserCardGutter, cell.y + kBrowserCardGutter, + cell.right() - kBrowserCardGutter, cell.bottom() - kBrowserCardGutter); } Rect cardThumbnailRect(const BrowserLayout& layout, int index) { if (index < 0) return Rect{}; const Rect content = cardContentRect(layout, index); - const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height())); - return Rect{content.left, content.top, content.right, content.top + thumbH}; + const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height)); + return Rect::ltrb(content.x, content.y, content.right(), content.y + thumbH); } Rect cardLabelRect(const BrowserLayout& layout, int index) { if (index < 0) return Rect{}; const Rect content = cardContentRect(layout, index); const Rect thumb = cardThumbnailRect(layout, index); - return Rect{content.left, thumb.bottom, content.right, content.bottom}; + return Rect::ltrb(content.x, thumb.bottom(), content.right(), content.bottom()); } int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) { if (cardCount <= 0) return -1; if (!contains(layout.grid, x, y)) return -1; const int cols = std::max(1, layout.columns); - const int col = (x - layout.grid.left) / kBrowserCardWidth; - const int row = (y - layout.grid.top) / kBrowserCardHeight; + const int col = (x - layout.grid.x) / kBrowserCardWidth; + const int row = (y - layout.grid.y) / kBrowserCardHeight; if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone) const int index = row * cols + col; if (index < 0 || index >= cardCount) return -1; @@ -79,9 +79,9 @@ int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) { Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) { if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{}; const Rect& strip = layout.tabStrip; - const int left = tabEdge(strip.left, std::max(0, strip.width()), index, tabCount); - const int right = tabEdge(strip.left, std::max(0, strip.width()), index + 1, tabCount); - return Rect{left, strip.top, right, strip.bottom}; + const int left = tabEdge(strip.x, std::max(0, strip.width), index, tabCount); + const int right = tabEdge(strip.x, std::max(0, strip.width), index + 1, tabCount); + return Rect::ltrb(left, strip.y, right, strip.bottom()); } int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) { @@ -93,4 +93,4 @@ int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) { return -1; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/capture_browser.h b/src/core/instrument/ui/capture_browser.h similarity index 96% rename from src/vst/capture_browser.h rename to src/core/instrument/ui/capture_browser.h index 864f378..73ecbe7 100644 --- a/src/vst/capture_browser.h +++ b/src/core/instrument/ui/capture_browser.h @@ -20,9 +20,9 @@ #pragma once -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a // peak thumbnail with a name + badge line under it — scannable by eye, not a dense list. @@ -37,12 +37,12 @@ inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside struct BrowserLayout { Rect tabStrip; // top: the bank-filter tabs Rect grid; // below the tabs: where the capture cards tile - int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width() + int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width }; // Divide a (w x h) browser area into its regions and compute the column count. Pure: same // inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never -// exceeds the area); the grid takes the rest. columns = max(1, grid.width()/cardWidth) so a +// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a // browser narrower than one card still lays out a single column. A zero/negative size // yields empty rects + columns==1. BrowserLayout layoutBrowser(int w, int h); @@ -89,4 +89,4 @@ Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index); // tab strip. Pure. int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/curve_popup.cpp b/src/core/instrument/ui/curve_popup.cpp new file mode 100644 index 0000000..29451e1 --- /dev/null +++ b/src/core/instrument/ui/curve_popup.cpp @@ -0,0 +1,41 @@ +// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes. + +#include "core/instrument/ui/curve_popup.h" + +#include + +namespace reasampler::instrument::ui { + +namespace { +int clampDim(int want, int lo, int hi, int windowDim) { + const int clamped = (std::max)(lo, (std::min)(hi, want)); + return (std::min)(clamped, (std::max)(0, windowDim)); +} +} // namespace + +CurvePopupLayout computeCurvePopup(int w, int h) { + CurvePopupLayout out; + const int sheetW = clampDim((w * 60) / 100, kCurvePopupMinW, kCurvePopupMaxW, w); + const int sheetH = clampDim((h * 55) / 100, kCurvePopupMinH, kCurvePopupMaxH, h); + const int left = (w - sheetW) / 2; + const int top = (h - sheetH) / 2; + out.sheet = Rect::ltrb(left, top, left + sheetW, top + sheetH); + + const int titleBottom = out.sheet.y + kCurvePopupTitleH; + const int closeTop = out.sheet.y + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2; + out.close = Rect::ltrb(out.sheet.right() - kCurvePopupPad - kCurvePopupCloseSize, closeTop, + out.sheet.right() - kCurvePopupPad, closeTop + kCurvePopupCloseSize); + out.title = Rect::ltrb(out.sheet.x + kCurvePopupPad, out.sheet.y, + out.close.x - kCurvePopupPad, titleBottom); + + out.curveBox = Rect::ltrb(out.sheet.x + kCurvePopupPad, titleBottom + 2, + out.sheet.right() - kCurvePopupPad, + out.sheet.bottom() - kCurvePopupPad); + return out; +} + +bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) { + return !contains(layout.sheet, x, y); +} + +} // namespace reasampler::instrument::ui diff --git a/src/vst/curve_popup.h b/src/core/instrument/ui/curve_popup.h similarity index 94% rename from src/vst/curve_popup.h rename to src/core/instrument/ui/curve_popup.h index 3909b85..3213ec2 100644 --- a/src/vst/curve_popup.h +++ b/src/core/instrument/ui/curve_popup.h @@ -15,9 +15,9 @@ #pragma once -#include "editor_geometry.h" // Rect, contains +#include "core/instrument/ui/editor_geometry.h" // Rect, contains -namespace reasampler::vst { +namespace reasampler::instrument::ui { // Fixed popup metrics (spec r11), exposed so the shell and tests agree. inline constexpr int kCurvePopupMinW = 360; @@ -45,4 +45,4 @@ CurvePopupLayout computeCurvePopup(int w, int h); // The shell additionally gates on "no drag in flight" (spec). Pure. bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/editor_geometry.cpp b/src/core/instrument/ui/editor_geometry.cpp new file mode 100644 index 0000000..4cbc809 --- /dev/null +++ b/src/core/instrument/ui/editor_geometry.cpp @@ -0,0 +1,308 @@ +// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types. + +#include "core/instrument/ui/editor_geometry.h" + +#include + +namespace reasampler::instrument::ui { + +namespace { + +// Spike editor layout constants. These are the editor's fixed metrics; the real +// editor (S4/S5) will parameterize as its content demands. +constexpr int kTitleBarHeight = 28; +constexpr int kButtonMargin = 10; +constexpr int kButtonWidth = 120; +constexpr int kButtonHeight = 24; + +} // namespace + +// contains() now lives with the shared ui::Rect (core/ui/rect.h) — same half-open +// semantics, re-exported through the header's using-declaration. + +EditorLayout layoutEditor(int w, int h) { + // Clamp the surface to non-negative extents so a degenerate view can't produce + // inverted rects. + const int cw = std::max(0, w); + const int ch = std::max(0, h); + + EditorLayout out; + + // Title bar spans the top, clamped so it never exceeds the client height. + const int titleH = std::min(kTitleBarHeight, ch); + out.titleBar = Rect::ltrb(0, 0, cw, titleH); + + // Canvas is everything below the title bar. + out.canvas = Rect::ltrb(0, titleH, cw, ch); + + // Button sits at the top-left of the canvas, inset by a margin, and is clamped to + // fit inside the canvas so it never overhangs on a small view. + const int bx = out.canvas.x + kButtonMargin; + const int by = out.canvas.y + kButtonMargin; + const int bRight = std::min(bx + kButtonWidth, out.canvas.right()); + const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom()); + out.button = Rect::ltrb(bx, by, std::max(bx, bRight), std::max(by, bBottom)); + + return out; +} + +HitTarget hitTest(const EditorLayout& layout, int x, int y) { + if (contains(layout.button, x, y)) return HitTarget::kButton; + return HitTarget::kNone; +} + +Rect sampleRowRect(const EditorLayout& layout, int index) { + if (index < 0) return Rect{}; + const int top = layout.canvas.y + index * kSampleRowHeight; + return Rect::ltrb(layout.canvas.x, top, layout.canvas.right(), top + kSampleRowHeight); +} + +int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { + if (rowCount <= 0) return -1; + // Must be within the canvas horizontally and at/below its top. + if (x < layout.canvas.x || x >= layout.canvas.right()) return -1; + if (y < layout.canvas.y) return -1; + // Clip at the canvas bottom: clicks in the canvas's dead-zone below the last + // visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom(). + if (y >= layout.canvas.bottom()) return -1; + const int index = (y - layout.canvas.y) / kSampleRowHeight; + if (index < 0 || index >= rowCount) return -1; + // Guard the bottom edge: a click below the last row's bottom is outside. + const Rect r = sampleRowRect(layout, index); + if (y >= r.bottom()) return -1; + return index; +} + +// --- Keymap editor ----------------------------------------------------------- + +KeymapEditorLayout layoutKeymapEditor(int w, int h) { + KeymapEditorLayout out; + out.base = layoutEditor(w, h); + const Rect& canvas = out.base.canvas; + + // Split the canvas vertically: the left column is the bank-sample list, the right + // column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so + // the split point never crosses the canvas edges. + const int canvasW = std::max(0, canvas.width); + const int splitW = canvasW / kZonePanelFraction; // width of the zone panel + const int splitX = std::max(canvas.x, canvas.right() - splitW); + + out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom()); + out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom()); + + // "Add Zone" button spans the top of the zone panel, clamped to its height. + const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height)); + out.addZoneButton = + Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(), + out.zonePanel.y + addH); + + // Zone rows stack below the button. + out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(), + out.zonePanel.right(), out.zonePanel.bottom()); + return out; +} + +Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) { + if (index < 0) return Rect{}; + const int top = layout.sampleList.y + index * kSampleRowHeight; + return Rect::ltrb(layout.sampleList.x, top, layout.sampleList.right(), + top + kSampleRowHeight); +} + +int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) { + if (rowCount <= 0) return -1; + const Rect& list = layout.sampleList; + if (x < list.x || x >= list.right()) return -1; + if (y < list.y || y >= list.bottom()) return -1; + const int index = (y - list.y) / kSampleRowHeight; + if (index < 0 || index >= rowCount) return -1; + const Rect r = keymapSampleRowRect(layout, index); + if (y >= r.bottom()) return -1; + return index; +} + +Rect zoneRowRect(const KeymapEditorLayout& layout, int index) { + if (index < 0) return Rect{}; + const int top = layout.zoneRowArea.y + index * kZoneRowHeight; + return Rect::ltrb(layout.zoneRowArea.x, top, layout.zoneRowArea.right(), + top + kZoneRowHeight); +} + +ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) { + if (zoneCount <= 0) return ZoneHit{}; + const Rect& area = layout.zoneRowArea; + if (x < area.x || x >= area.right()) return ZoneHit{}; + if (y < area.y || y >= area.bottom()) return ZoneHit{}; + const int index = (y - area.y) / kZoneRowHeight; + if (index < 0 || index >= zoneCount) return ZoneHit{}; + const Rect row = zoneRowRect(layout, index); + if (y >= row.bottom()) return ZoneHit{}; + + // Seven mini-buttons pinned to the right edge, right-to-left: + // delete, root+, root-, high+, high-, low+, low- + // Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select"). + // The fields laid out LEFT-TO-RIGHT in slot order 0..6. + const ZoneField fields[7] = { + ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, + ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, + ZoneField::kDelete, + }; + const int slots = 7; + const int ctrlBlockLeft = row.right() - slots * kZoneCtrlWidth; + if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select + const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth; + if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone}; + return ZoneHit{index, fields[slot]}; +} + +bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) { + return contains(layout.addZoneButton, x, y); +} + +// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- +// Bodies moved verbatim from the reasampler_editor shell (behavior-identical); the +// only signature change is clusterRects' `knobSize` parameter (formerly knob_deck's +// kDeckKnobSize read directly — passed in so this module stays knob_deck-free). + +namespace { + +// Fixed band metrics (formerly the editor shell's anon-ns constants). +constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11) +constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle +constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) + +// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial +// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo). +constexpr int kPreviewBtnW = 64; +constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) +constexpr int kCurveBtnSize = 28; // the square curve-preview button + +// The S7 mono/stereo toggle segments. +constexpr int kChanSegW = 52; +constexpr int kChanSegH = 18; + +} // namespace + +// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed +// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- +// anchored). When the window is too short for the floor (below the checkSizeConstraint +// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the +// window bottom gracefully. +SampleBands computeSampleBands(int w, int h, int deckH) { + SampleBands b; + const int titleH = (std::min)(kTitleHeight, h); + b.title = Rect::ltrb(0, 0, w, titleH); + // Two nav buttons right-anchored in the title band (Browse then Zone). + const int navTop = 2; + const int navBot = (std::max)(navTop, titleH - 2); + const Rect zone = Rect::ltrb(w - kPad - kNavButtonWidth, navTop, w - kPad, navBot); + const Rect browse = Rect::ltrb(zone.x - 4 - kNavButtonWidth, navTop, zone.x - 4, navBot); + b.navBrowse = browse; + b.navZone = zone; + + int deckTop = h - kPad - deckH; + int clusterTop = deckTop - kClusterHeight - 4; + int heroBottom = clusterTop - 4; + if (heroBottom - titleH < kHeroMinHeight) { + heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below + clusterTop = heroBottom + 4; + deckTop = clusterTop + kClusterHeight + 4; + } + b.hero = Rect::ltrb(kPad, titleH, w - kPad, heroBottom); + b.cluster = Rect::ltrb(0, clusterTop, w, clusterTop + kClusterHeight); + b.deck = Rect::ltrb(kPad, deckTop, w - kPad, deckTop + deckH); + return b; +} + +// Draw + hit-test both derive from this ONE formula. +ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) { + ClusterRects r; + const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2; + const int stripBot = stripTop + kStripBandHeight; + const int curveTop = cluster.y + (cluster.height - kCurveBtnSize) / 2; + r.curveBtn = Rect::ltrb(chanMono.x - kPad - kCurveBtnSize, curveTop, + chanMono.x - kPad, curveTop + kCurveBtnSize); + r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop, + r.curveBtn.x - kPad, stripBot); + const int knobLeft = r.velCell.x + (kVelCellW - knobSize) / 2; + r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize, + r.velCell.y + knobSize); + r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(), r.velCell.bottom()); + r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop, + r.velCell.x - kPad, stripBot); + r.rootStrip = Rect::ltrb(cluster.x + kPad, stripTop, r.preview.x - kPad, stripBot); + return r; +} + +ChannelToggleRects channelToggleRects(const Rect& area) { + const int top = area.y + (area.height - kChanSegH) / 2; + const int right = area.right() - kPad; + const Rect stereo = Rect::ltrb(right - kChanSegW, top, right, top + kChanSegH); + const Rect mono = Rect::ltrb(stereo.x - kChanSegW, top, stereo.x, top + kChanSegH); + return {mono, stereo}; +} + +Rect zoneContentArea(int w, int h) { + const int titleH = (std::min)(kTitleHeight, h); + return Rect::ltrb(0, titleH, w, h); +} + +Rect zoneBackRect(int w, int h) { + return Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, + (std::max)(2, (std::min)(kTitleHeight, h) - 2)); +} + +Rect zoneAddRect(const Rect& content) { + return Rect::ltrb(content.x + kPad, content.y + 4, content.x + kPad + 96, + content.y + 4 + 20); +} + +Rect zoneDeleteRect(const Rect& addR) { + return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); +} + +// Zone content sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px +// gap, padded kPad horizontally. All call sites use this formula. +Rect zonesStripArea(const Rect& content) { + const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12 + return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad, + stripTop + kStripBandHeight); +} + +// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom +// without re-inlining the strip arithmetic here. +Rect noteEntryFieldsArea(const Rect& content) { + const int stripBottom = zonesStripArea(content).bottom(); + const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8) + return Rect::ltrb(content.x + 8 + 128, top, content.right() - 8, top + 18); +} + +Rect noteEntryFieldRect(const Rect& fields, int f) { + if (f < 0 || f > 2 || fields.width <= 0) return Rect{}; + const int segW = fields.width / 3; + const int left = fields.x + f * segW + (f > 0 ? 4 : 0); // small inter-field gap + const int right = (f == 2) ? fields.right() : fields.x + (f + 1) * segW; + return Rect::ltrb(left, fields.y, right, fields.bottom()); +} + +Rect zonesControlPanel(const Rect& content) { + const Rect strip = zonesStripArea(content); + const int panelTop = strip.bottom() + 8 + 18 + 8; // strip + the 18px legend row + gap + return Rect::ltrb(content.x + kPad, panelTop, content.right() - kPad, + content.bottom() - 4); +} + +// FB2 (R11-F2 parity): the deck lays out from the panel top (top-anchored), with a +// column at the panel's right reserved for the mini curve-preview button so no deck row +// starts inside it. +Rect zonesDeckArea(const Rect& content) { + const Rect panel = zonesControlPanel(content); + return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom()); +} + +Rect zonesCurveButton(const Rect& content) { + const Rect panel = zonesControlPanel(content); + return Rect::ltrb(panel.right() - kCurveBtnSize, panel.y, panel.right(), panel.y + kCurveBtnSize); +} + +} // namespace reasampler::instrument::ui diff --git a/src/vst/editor_geometry.h b/src/core/instrument/ui/editor_geometry.h similarity index 56% rename from src/vst/editor_geometry.h rename to src/core/instrument/ui/editor_geometry.h index 8ca0615..b1e8239 100644 --- a/src/vst/editor_geometry.h +++ b/src/core/instrument/ui/editor_geometry.h @@ -12,24 +12,17 @@ #pragma once -namespace reasampler::vst { +#include "core/ui/rect.h" -// A plain integer rectangle. left/top inclusive, right/bottom exclusive — the same -// half-open convention LICE/SWELL RECTs use, kept REAPER-free here. -struct Rect { - int left = 0; - int top = 0; - int right = 0; - int bottom = 0; +namespace reasampler::instrument::ui { - int width() const { return right - left; } - int height() const { return bottom - top; } -}; - -// Returns true if (x, y) falls inside r under the half-open convention -// (left <= x < right, top <= y < bottom). A zero-or-negative-area rect contains -// nothing. -bool contains(const Rect& r, int x, int y); +// The shared pixel rectangle + containment test (Q-W1, T2-05 ≡ T4-21): the former +// LTRB Rect defined here is folded into the ONE concrete ui::Rect (XYWH storage, +// right()/bottom() accessors, Rect::ltrb() for edge-wise construction, same +// half-open convention). Aliased here so every instrument-ui call site keeps its +// established `Rect` / `contains` spelling. +using Rect = ::reasampler::ui::Rect; +using ::reasampler::ui::contains; // The regions the spike editor draws, derived from the current view size. All are // clamped to the client area so a degenerate (too-small) view never yields a region @@ -93,7 +86,7 @@ inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band heig // The keymap editor's regions, derived from the (w x h) client area. All clamp to the // canvas so a degenerate view yields in-bounds rects. struct KeymapEditorLayout { - EditorLayout base; // title bar + canvas (the sample list uses base.canvas.left half) + EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half) Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here) Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows Rect addZoneButton; // top of the zone panel @@ -146,4 +139,86 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int // True if (x, y) lands on the "Add Zone" button. Pure. bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y); -} // namespace reasampler::vst +// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- +// +// The capture-first editor's band/cluster/zone-surface layout math, hoisted out of the +// reasampler_editor shell where it had accreted untestable (the §2 scope gap). Draw and +// hit-test both derive every rect from these ONE formulas so they can never drift; the +// shell only draws + routes. The Browse-modal layout lives in browser_scroll (its search +// box height feeds it — dependency-clean placement beside its scroll/search siblings). + +// Shared band metrics (the shell's remaining direct uses: horizontal padding + the +// title-band height; everything else is internal to the layout functions below). +inline constexpr int kPad = 8; +inline constexpr int kTitleHeight = 26; +inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons + +// The r11 Sample-face bands (top->bottom): a TITLE band (name + Browse/Zone nav), the +// FULL-WIDTH ELASTIC HERO (absorbs all height left after the fixed bands, floor +// kHeroMinHeight), the ROOT + PREVIEW CLUSTER, and the bottom-anchored KNOB DECK +// (height `deckH` from the pure knob_deck wrap). When the window is too short for the +// hero floor (below the checkSizeConstraint minimum — defensive), the hero keeps its +// floor and the lower bands clip past the window bottom gracefully. +struct SampleBands { + Rect title; // top: name + Browse/Zone nav buttons + Rect navBrowse; // the "Browse" title-band button + Rect navZone; // the "Zone" title-band button + Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay + Rect cluster; // root strip + preview + vel knob + curve button + channel toggle + Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap) +}; +SampleBands computeSampleBands(int w, int h, int deckH); + +// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the +// right side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve +// preview button 28 · Mono|Stereo). `knobSize` is the deck knob square (knob_deck's +// kDeckKnobSize — passed in so this module does not depend on knob_deck). +struct ClusterRects { + Rect rootStrip; // remainder-width fenced root strip + Rect preview; // the preview-trigger button + Rect velCell; // the radial preview-velocity knob cell (knob + label band) + Rect velKnob; // the knob square at the cell's top + Rect velLabel; // the label band beneath it + Rect curveBtn; // the mini curve-preview button (opens the popup) +}; +ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize); + +// The S7 mono/stereo toggle: a two-segment control right-anchored in `area`, vertically +// centered. Returns {mono-segment, stereo-segment}, side by side. +struct ChannelToggleRects { + Rect mono; + Rect stereo; +}; +ChannelToggleRects channelToggleRects(const Rect& area); + +// The Zone-view (S-VIEW-8) content area: the whole window below the title band. +Rect zoneContentArea(int w, int h); + +// The Zone/Browse "Back" title-band button (right-anchored — the same slot the Sample +// face's Zone nav button occupies). +Rect zoneBackRect(int w, int h); + +// The "+ Add Zone" affordance at the top of the Zone content, and the "Delete" button +// beside it (Delete only draws/hits when a zone is selected). +Rect zoneAddRect(const Rect& content); +Rect zoneDeleteRect(const Rect& addR); + +// The Zone-view keyboard strip rect: below the "+ Add Zone" affordance with a 12px gap, +// padded kPad horizontally. +Rect zonesStripArea(const Rect& content); + +// The S12 numeric-entry field ROW area inside the Zones legend (a band to the right of +// the sample label), and the rect of field `f` (0=low, 1=high, 2=root) within it — +// three equal segments left-to-right. An out-of-range index yields an empty rect. +Rect noteEntryFieldsArea(const Rect& content); +Rect noteEntryFieldRect(const Rect& fields, int f); + +// The per-zone parameter panel below the strip + the one-line legend, running to the +// content bottom; the FB2 knob-deck area within it (a column at the right reserved for +// the mini curve-preview button); and that button's rect (the cluster's 28px square, +// right-anchored at the panel top). +Rect zonesControlPanel(const Rect& content); +Rect zonesDeckArea(const Rect& content); +Rect zonesCurveButton(const Rect& content); + +} // namespace reasampler::instrument::ui diff --git a/src/vst/embed_strip.cpp b/src/core/instrument/ui/embed_strip.cpp similarity index 78% rename from src/vst/embed_strip.cpp rename to src/core/instrument/ui/embed_strip.cpp index e1bf0d9..7b8d827 100644 --- a/src/vst/embed_strip.cpp +++ b/src/core/instrument/ui/embed_strip.cpp @@ -1,10 +1,10 @@ // embed_strip.cpp — see embed_strip.h. Pure math; no host types. -#include "embed_strip.h" +#include "core/instrument/ui/embed_strip.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -42,22 +42,22 @@ EmbedLayout layoutEmbed(int w, int h) { } const int keymapBottom = ch - bandH; - out.keymap = Rect{0, 0, cw, keymapBottom}; - out.levelBand = Rect{0, keymapBottom, cw, ch}; + out.keymap = Rect::ltrb(0, 0, cw, keymapBottom); + out.levelBand = Rect::ltrb(0, keymapBottom, cw, ch); return out; } Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) { const Rect& band = layout.keymap; - const int bandWidth = std::max(0, band.width()); + const int bandWidth = std::max(0, band.width); int lo = clampNote(lowNote); int hi = clampNote(highNote); if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts - const int leftX = keyEdgeToX(band.left, bandWidth, lo); - const int rightX = keyEdgeToX(band.left, bandWidth, hi + 1); - return Rect{leftX, band.top, std::max(leftX, rightX), band.bottom}; + const int leftX = keyEdgeToX(band.x, bandWidth, lo); + const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1); + return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom()); } int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, @@ -74,13 +74,13 @@ int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount Rect levelFillRect(const EmbedLayout& layout, double level) { const Rect& band = layout.levelBand; - if (band.width() <= 0 || band.height() <= 0) return Rect{}; + if (band.width <= 0 || band.height <= 0) return Rect{}; double l = level; if (l < 0.0) l = 0.0; if (l > 1.0) l = 1.0; - const int fillW = static_cast(l * band.width()); + const int fillW = static_cast(l * band.width); if (fillW <= 0) return Rect{}; - return Rect{band.left, band.top, band.left + fillW, band.bottom}; + return Rect::ltrb(band.x, band.y, band.x + fillW, band.bottom()); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/embed_strip.h b/src/core/instrument/ui/embed_strip.h similarity index 95% rename from src/vst/embed_strip.h rename to src/core/instrument/ui/embed_strip.h index b16a88d..1c28193 100644 --- a/src/vst/embed_strip.h +++ b/src/core/instrument/ui/embed_strip.h @@ -18,9 +18,9 @@ #pragma once -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom -namespace reasampler::vst { +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. @@ -54,7 +54,7 @@ struct EmbedLayout { EmbedLayout layoutEmbed(int w, int h); // The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote] -// (inclusive). The 128-key span maps linearly across keymap.width(); the returned rect +// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect // spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g. // 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low // is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect, @@ -73,4 +73,4 @@ int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount // (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure. Rect levelFillRect(const EmbedLayout& layout, double level); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/envelope_edit.cpp b/src/core/instrument/ui/envelope_edit.cpp similarity index 88% rename from src/vst/envelope_edit.cpp rename to src/core/instrument/ui/envelope_edit.cpp index 56381fa..9b050f1 100644 --- a/src/vst/envelope_edit.cpp +++ b/src/core/instrument/ui/envelope_edit.cpp @@ -1,24 +1,19 @@ // envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types. -#include "envelope_edit.h" +#include "core/instrument/ui/envelope_edit.h" #include #include // std::abs -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { -double clamp(double v, double lo, double hi) { - if (v < lo) return lo; - if (v > hi) return hi; - return v; -} // 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) { - 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; return totalSeconds / static_cast(w); } @@ -36,7 +31,7 @@ double gateSecondsPerPixel(const Rect& area) { // Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one // pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY. 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; return 1.0 / static_cast(h - 1); } @@ -118,23 +113,23 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect // because every segment stays >= 0), so the [0, max] clamp is the whole constraint. case EnvNode::AttackEnd: out.attackSeconds = - clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); + std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); break; case EnvNode::HoldEnd: - out.holdSeconds = clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); + out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); break; case EnvNode::DecayEnd: { // Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower // level, so subtract the level delta). - out.decaySeconds = 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 dLevel = -static_cast(dyPixels) * lvlPerPx; - out.sustainLevel = clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0); + out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0); break; } case EnvNode::ReleaseEnd: out.releaseSeconds = - clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds); + std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds); break; // --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED @@ -154,7 +149,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0; const double hi = std::min(bounds.maxFadeInFraction, 1.0 - std::max(0.0, grabEnv.fadeOutFraction)); - out.fadeInFraction = clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi)); + out.fadeInFraction = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi)); break; } case EnvNode::FadeOutStart: { @@ -165,13 +160,13 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0; const double hi = std::min(bounds.maxFadeOutFraction, 1.0 - std::max(0.0, grabEnv.fadeInFraction)); - out.fadeOutFraction = clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi)); + out.fadeOutFraction = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi)); break; } case EnvNode::LengthEnd: { // LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it. const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0; - out.lengthFraction = clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction); + out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction); break; } @@ -182,4 +177,4 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect return out; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/envelope_edit.h b/src/core/instrument/ui/envelope_edit.h similarity index 96% rename from src/vst/envelope_edit.h rename to src/core/instrument/ui/envelope_edit.h index abd7c62..7d7699c 100644 --- a/src/vst/envelope_edit.h +++ b/src/core/instrument/ui/envelope_edit.h @@ -42,10 +42,10 @@ #include #include -#include "editor_geometry.h" // Rect -#include "envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY +#include "core/instrument/ui/editor_geometry.h" // Rect +#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY -namespace reasampler::vst { +namespace reasampler::instrument::ui { // The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and // y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a @@ -105,4 +105,4 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect double totalSeconds, const EnvClampBounds& bounds, int dxPixels, int dyPixels); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/envelope_overlay.cpp b/src/core/instrument/ui/envelope_overlay.cpp similarity index 90% rename from src/vst/envelope_overlay.cpp rename to src/core/instrument/ui/envelope_overlay.cpp index f413e41..a4bf59d 100644 --- a/src/vst/envelope_overlay.cpp +++ b/src/core/instrument/ui/envelope_overlay.cpp @@ -1,25 +1,29 @@ // envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types. -#include "envelope_overlay.h" +#include "core/instrument/ui/envelope_overlay.h" + +#include "core/util/clamp01.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { + +using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) int timeToX(const Rect& area, double totalSeconds, double t) { - const int w = std::max(0, area.width()); - if (w <= 0 || totalSeconds <= 0.0) return area.left; + const int w = std::max(0, area.width); + if (w <= 0 || totalSeconds <= 0.0) return area.x; if (t < 0.0) t = 0.0; // Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the - // last in-bounds column area.right-1. Clamp in DOUBLE space BEFORE the integer cast — a huge + // last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge // t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round. double px = (t / totalSeconds) * static_cast(w); if (px > static_cast(w - 1)) px = static_cast(w - 1); - return area.left + static_cast(px + 0.5); + return area.x + static_cast(px + 0.5); } int gateTimedWidth(const Rect& area) { - const int w = std::max(0, area.width()); + const int w = std::max(0, area.width); if (w <= 0) return 0; const int sustainPx = static_cast(kGateSustainDisplayFraction * static_cast(w) + 0.5); @@ -38,25 +42,19 @@ double gatePxPerSecond(const Rect& area) { } int levelToY(const Rect& area, double level) { - const int h = std::max(0, area.height()); - if (h <= 0) return area.top; + const int h = std::max(0, area.height); + if (h <= 0) return area.y; if (level < 0.0) level = 0.0; if (level > 1.0) level = 1.0; // Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The // range spans (h-1) pixels so both endpoints land ON a drawable row. const int span = h - 1; const long dy = static_cast((1.0 - level) * static_cast(span) + 0.5); - return area.top + static_cast(dy); + return area.y + static_cast(dy); } namespace { -double clamp01(double v) { - if (v < 0.0) return 0.0; - if (v > 1.0) return 1.0; - return v; -} - EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) { EnvVertex v; v.node = node; @@ -71,12 +69,12 @@ EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, dou // 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) { - 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 > static_cast(w - 1)) px = static_cast(w - 1); EnvVertex v; v.node = node; - v.x = area.left + static_cast(px + 0.5); + v.x = area.x + static_cast(px + 0.5); v.y = levelToY(area, level); v.level = level; return v; @@ -95,7 +93,7 @@ std::vector gatePolyline(const AmpEnvelope& env, const Rect& area) { // gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually // grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain // plateau is the fixed reserve between DecayEnd and ReleaseStart. - const int W = std::max(1, area.width()); + const int W = std::max(1, area.width); const double sustainPx = static_cast(W - gateTimedWidth(area)); const double sep = static_cast(kGateNodeSepPx); const double pps = gatePxPerSecond(area); @@ -162,7 +160,7 @@ std::vector triggerPolyline(const AmpEnvelope& env, const Rect& area, std::vector buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, double totalSeconds) { - if (area.width() <= 0 || area.height() <= 0 || totalSeconds <= 0.0) { + 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. return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0), vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)}; @@ -173,4 +171,4 @@ std::vector buildEnvelopePolyline(const AmpEnvelope& env, const Rect& : triggerPolyline(env, area, totalSeconds); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h similarity index 91% rename from src/vst/envelope_overlay.h rename to src/core/instrument/ui/envelope_overlay.h index fc3780e..e25e647 100644 --- a/src/vst/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -25,7 +25,7 @@ // 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.left, area.right-1], y in [area.top, area.bottom-1] (half-open rect convention). +// 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. // @@ -33,8 +33,8 @@ // * 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. +// 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 @@ -46,7 +46,7 @@ // 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_trim in). The shell reads the +// 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 @@ -60,9 +60,9 @@ #include #include -#include "editor_geometry.h" // Rect — the shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the // geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases. @@ -173,7 +173,7 @@ inline constexpr int kGateNodeSepPx = 8; // (param clamps are caller-supplied in envelope_edit); only layout does. inline constexpr double kGateStageMaxSeconds = 2.0; -// The pixel width of the Gate timed region: area.width() minus the sustain-plateau reserve, +// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve, // floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0 // for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale. int gateTimedWidth(const Rect& area); @@ -188,7 +188,7 @@ double gatePxPerSecond(const Rect& area); // Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds` // wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive); -// y maps level 0..1 across [area.bottom-1 .. area.top] (level 1 at the TOP). The polyline reads +// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads // left-to-right in draw order, Origin first. // // TIME BASE (FA2). @@ -205,25 +205,25 @@ double gatePxPerSecond(const Rect& area); // 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.left, area.right-1], y in -// [area.top, area.bottom-1]. Nothing maps past area.right (the pre-FA2 release tail is gone). A +// 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 buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, double totalSeconds); -// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.left, t=totalSeconds -> -// area.right-1, linear, CLAMPED on both sides (t < 0 pins to area.left; t past totalSeconds pins -// to area.right-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields -// area.left. Pure — the shared time->x map the Trigger polyline and the node hit-test +// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds -> +// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins +// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields +// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test // (envelope_edit) use, so the drawn handle and its grab region agree. int timeToX(const Rect& area, double totalSeconds, double t); -// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.top, level 0 -> area.bottom-1 +// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.y, level 0 -> area.bottom()-1 // (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is -// clamped to [0,1]. A zero-height area yields area.top. Pure — the shared level->y map the polyline +// 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); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/keyboard_strip.cpp b/src/core/instrument/ui/keyboard_strip.cpp similarity index 86% rename from src/vst/keyboard_strip.cpp rename to src/core/instrument/ui/keyboard_strip.cpp index 98638b1..7b64af4 100644 --- a/src/vst/keyboard_strip.cpp +++ b/src/core/instrument/ui/keyboard_strip.cpp @@ -1,10 +1,10 @@ // keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types. -#include "keyboard_strip.h" +#include "core/instrument/ui/keyboard_strip.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -30,24 +30,24 @@ StripLayout layoutStrip(int w, int h) { const int cw = std::max(0, w); const int ch = std::max(0, h); StripLayout out; - out.keys = Rect{0, 0, cw, ch}; + out.keys = Rect::ltrb(0, 0, cw, ch); return out; } int keyLeftX(const StripLayout& layout, int note) { 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 // get a key's right edge, and 128 maps to the band right. const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); - return keyEdgeToX(band.left, bandWidth, edge); + return keyEdgeToX(band.x, bandWidth, edge); } Rect keyRect(const StripLayout& layout, int note) { const int n = clampNote(note); const int leftX = keyLeftX(layout, n); const int rightX = keyLeftX(layout, n + 1); - return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; + return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom()); } Rect rootMarkerRect(const StripLayout& layout, int rootNote) { @@ -57,11 +57,11 @@ Rect rootMarkerRect(const StripLayout& layout, int rootNote) { int keyAtPoint(const StripLayout& layout, int x, int y) { const Rect& band = layout.keys; 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; // Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide - // the pixel offset back to a key; clamp defensively (a point on band.right-1 maps to 127). - const int offset = x - band.left; + // the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127). + const int offset = x - band.x; int note = (offset * kStripKeyCount) / bandWidth; return clampNote(note); } @@ -72,22 +72,22 @@ Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) { if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts const int leftX = keyLeftX(layout, lo); const int rightX = keyLeftX(layout, hi + 1); - return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; + return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom()); } ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) { const Rect bar = zoneBarRect(layout, lowNote, highNote); 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 click exactly on the midpoint resizes low (deterministic). if (barW < 2 * kStripEdgeGrabWidth) { - const int mid = bar.left + barW / 2; + const int mid = bar.x + barW / 2; return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge; } - if (x < bar.left + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge; - if (x >= bar.right - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge; + if (x < bar.x + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge; + if (x >= bar.right() - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge; return ZoneGrab::kBody; } @@ -127,7 +127,7 @@ bool isNaturalKey(int note) { int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) { 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 // Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag // agree across the full strip, even on non-divisible-by-128 widths. The proportional @@ -146,4 +146,4 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) { return clampNote(startNote + shift); } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/keyboard_strip.h b/src/core/instrument/ui/keyboard_strip.h similarity index 96% rename from src/vst/keyboard_strip.h rename to src/core/instrument/ui/keyboard_strip.h index ac8b4c3..2f843a3 100644 --- a/src/vst/keyboard_strip.h +++ b/src/core/instrument/ui/keyboard_strip.h @@ -23,9 +23,9 @@ #pragma once -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // The full MIDI key span the strip maps across its width: 128 keys (0..127). Named // distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay @@ -42,7 +42,7 @@ inline constexpr int kStripEdgeGrabWidth = 6; // takes the whole area today (a future octave-label lane can carve a sub-band here without // changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect. struct StripLayout { - Rect keys; // the key band: the 128-key span maps linearly across keys.width() + Rect keys; // the key band: the 128-key span maps linearly across keys.width }; // Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or @@ -50,7 +50,7 @@ struct StripLayout { StripLayout layoutStrip(int w, int h); // The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key -// span maps linearly across keys.width(); key N occupies the half-open pixel range +// span maps linearly across keys.width; key N occupies the half-open pixel range // [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's // right edge (so a key's right edge is keyLeftX(note+1)). Pure. int keyLeftX(const StripLayout& layout, int note); @@ -128,4 +128,4 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); // pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types. bool isNaturalKey(int note); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/knob_deck.cpp b/src/core/instrument/ui/knob_deck.cpp similarity index 79% rename from src/vst/knob_deck.cpp rename to src/core/instrument/ui/knob_deck.cpp index 9df5b8c..6eae276 100644 --- a/src/vst/knob_deck.cpp +++ b/src/core/instrument/ui/knob_deck.cpp @@ -1,10 +1,10 @@ // knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes. -#include "knob_deck.h" +#include "core/instrument/ui/knob_deck.h" #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -33,19 +33,20 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { out.id = g.id; out.box = box; - const int captionTop = box.top + kDeckGroupPadY; - const int innerLeft = box.left + kDeckGroupPadX; - const int innerRight = box.right - kDeckGroupPadX; + const int captionTop = box.y + kDeckGroupPadY; + const int innerLeft = box.x + kDeckGroupPadX; + const int innerRight = box.right() - kDeckGroupPadX; // Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home). - out.caption = Rect{innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH}; + out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH); if (g.captionToggle.id >= 0) { const int segW = g.captionToggle.segWidth; const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2; - const Rect seg1{innerRight - segW, togTop, innerRight, togTop + kDeckToggleH}; - const Rect seg0{seg1.left - segW, togTop, seg1.left, togTop + kDeckToggleH}; + const Rect seg1 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH); + const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH); out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1}; - out.caption.right = seg0.left - kDeckToggleGap; // caption text stops at the toggle + // Caption text stops at the toggle: pull the right edge in (XYWH: shrink width). + out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x; } // Knob row: fixed cells left-to-right, then the optional row toggle. @@ -54,12 +55,12 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { for (int id : g.cellIds) { DeckCellLayout c; c.id = id; - c.cell = Rect{x, cellTop, x + kDeckCellW, cellTop + kDeckCellH}; + c.cell = Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH); const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2; const int knobTop = cellTop + 4; - c.knob = Rect{knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize}; + c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize); const int labelTop = knobTop + kDeckKnobSize + 4; - c.label = Rect{c.cell.left, labelTop, c.cell.right, labelTop + kDeckCellLabelH}; + c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH); out.cells.push_back(c); x += kDeckCellW; } @@ -67,8 +68,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { if (!g.cellIds.empty()) x += kDeckToggleGap; const int segW = g.rowToggle.segWidth; const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2; - const Rect seg0{x, togTop, x + segW, togTop + kDeckToggleH}; - const Rect seg1{seg0.right, togTop, seg0.right + segW, togTop + kDeckToggleH}; + const Rect seg0 = Rect::ltrb(x, togTop, x + segW, togTop + kDeckToggleH); + const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH); out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1}; } return out; @@ -120,9 +121,9 @@ DeckLayout layoutDeck(const std::vector& groups, int left, int to rowHasGroup = false; } if (rowHasGroup) x += kDeckGroupGap; - const Rect box{x, y, x + w, y + kDeckGroupH}; + const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH); out.groups.push_back(layoutGroup(g, box)); - x = box.right; + x = box.right(); rowHasGroup = true; } out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap; @@ -152,4 +153,4 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { return {}; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/knob_deck.h b/src/core/instrument/ui/knob_deck.h similarity index 97% rename from src/vst/knob_deck.h rename to src/core/instrument/ui/knob_deck.h index 5a1a890..2d07b70 100644 --- a/src/vst/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -27,9 +27,9 @@ #include -#include "editor_geometry.h" // Rect, contains — the shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — the shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // Fixed deck metrics (spec r11), exposed so the shell and tests agree. inline constexpr int kDeckCellW = 48; // one knob cell @@ -130,4 +130,4 @@ struct DeckHit { // Pure — the shell's routing entry point. DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/param_slider.cpp b/src/core/instrument/ui/param_slider.cpp similarity index 71% rename from src/vst/param_slider.cpp rename to src/core/instrument/ui/param_slider.cpp index a080e43..6aa17cc 100644 --- a/src/vst/param_slider.cpp +++ b/src/core/instrument/ui/param_slider.cpp @@ -1,30 +1,34 @@ // param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16 // editor parameter panel. No host types; only the shared Rect + contains(). -#include "param_slider.h" +#include "core/instrument/ui/param_slider.h" + +#include "core/util/clamp01.h" #include #include -namespace reasampler::vst { +namespace reasampler::instrument::ui { + +using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) std::vector layoutControls(const Rect& panel, const std::vector& controls) { std::vector out; - if (controls.empty() || panel.width() <= 0 || panel.height() <= 0) return out; + if (controls.empty() || panel.width <= 0 || panel.height <= 0) return out; out.reserve(controls.size()); // The label column is clamped so a narrow panel still leaves a control column. - const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width() / 2)); - int rowTop = panel.top; + const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width / 2)); + int rowTop = panel.y; for (const ControlDesc& d : controls) { ControlRow r; r.id = d.id; r.kind = d.kind; const int rowBottom = rowTop + kControlRowHeight; - r.row = Rect{panel.left, rowTop, panel.right, rowBottom}; - r.label = Rect{panel.left, rowTop, panel.left + labelW, rowBottom}; - r.control = Rect{panel.left + labelW, rowTop, panel.right, rowBottom}; + r.row = Rect::ltrb(panel.x, rowTop, panel.right(), rowBottom); + r.label = Rect::ltrb(panel.x, rowTop, panel.x + labelW, rowBottom); + r.control = Rect::ltrb(panel.x + labelW, rowTop, panel.right(), rowBottom); out.push_back(r); rowTop = rowBottom + kControlRowGap; } @@ -33,13 +37,13 @@ std::vector layoutControls(const Rect& panel, Rect toggleSegmentRect(const Rect& control, int seg) { if (seg < 0 || seg >= kToggleSegments) return Rect{}; - const int w = control.width(); - if (w <= 0 || control.height() <= 0) return Rect{}; + const int w = control.width; + if (w <= 0 || control.height <= 0) return Rect{}; const int segW = w / kToggleSegments; - const int left = control.left + seg * segW; + const int left = control.x + seg * segW; // The last segment absorbs the width remainder so the segments tile the whole control. - const int right = (seg == kToggleSegments - 1) ? control.right : left + segW; - return Rect{left, control.top, right, control.bottom}; + const int right = (seg == kToggleSegments - 1) ? control.right() : left + segW; + return Rect::ltrb(left, control.y, right, control.bottom()); } int toggleSegmentHitTest(const Rect& control, int x, int y) { @@ -52,31 +56,31 @@ int toggleSegmentHitTest(const Rect& control, int x, int y) { Rect sliderTrackRect(const Rect& control) { // Inset a half-handle at each end so the handle stays fully inside the control at value - // 0 and 1. The handle CENTER ranges across [track.left, track.right]. + // 0 and 1. The handle CENTER ranges across [track.x, track.right()]. const int half = kSliderHandleWidth / 2; - if (control.width() <= kSliderHandleWidth || control.height() <= 0) return Rect{}; - return Rect{control.left + half, control.top, control.right - half, control.bottom}; + if (control.width <= kSliderHandleWidth || control.height <= 0) return Rect{}; + return Rect::ltrb(control.x + half, control.y, control.right() - half, control.bottom()); } Rect sliderHandleRect(const Rect& control, double value) { const Rect track = sliderTrackRect(control); - if (track.width() <= 0) return Rect{}; + if (track.width <= 0) return Rect{}; if (value < 0.0) value = 0.0; if (value > 1.0) value = 1.0; - const int span = track.width(); // handle-center movable span - const int centerX = track.left + static_cast(value * span + 0.5); + const int span = track.width; // handle-center movable span + const int centerX = track.x + static_cast(value * span + 0.5); const int half = kSliderHandleWidth / 2; - return Rect{centerX - half, control.top, centerX - half + kSliderHandleWidth, - control.bottom}; + return Rect::ltrb(centerX - half, control.y, centerX - half + kSliderHandleWidth, + control.bottom()); } double valueAtPoint(const Rect& control, int x) { const Rect track = sliderTrackRect(control); - const int span = track.width(); + const int span = track.width; if (span <= 0) return 0.0; - if (x <= track.left) return 0.0; - if (x >= track.right) return 1.0; - return static_cast(x - track.left) / static_cast(span); + if (x <= track.x) return 0.0; + if (x >= track.right()) return 1.0; + return static_cast(x - track.x) / static_cast(span); } // --- Radial knob (Wave A FA4) --------------------------------------------------------- @@ -95,16 +99,14 @@ double normDeg(double deg) { return deg; } -double clamp01(double v) { return (std::min)(1.0, (std::max)(0.0, v)); } - } // namespace KnobGeometry computeKnob(const Rect& cell) { - if (cell.width() <= 0 || cell.height() <= 0) return KnobGeometry{}; + if (cell.width <= 0 || cell.height <= 0) return KnobGeometry{}; KnobGeometry g; - g.centerX = (cell.left + cell.right) / 2.0; - g.centerY = (cell.top + cell.bottom) / 2.0; - g.radius = (std::min)(cell.width(), cell.height()) / 2.0; + g.centerX = (cell.x + cell.right()) / 2.0; + g.centerY = (cell.y + cell.bottom()) / 2.0; + g.radius = (std::min)(cell.width, cell.height) / 2.0; return g; } @@ -154,4 +156,4 @@ int controlAtPoint(const std::vector& rows, int x, int y) { return -1; } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/param_slider.h b/src/core/instrument/ui/param_slider.h similarity index 97% rename from src/vst/param_slider.h rename to src/core/instrument/ui/param_slider.h index b853eaa..0720388 100644 --- a/src/vst/param_slider.h +++ b/src/core/instrument/ui/param_slider.h @@ -24,9 +24,9 @@ #include -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom -namespace reasampler::vst { +namespace reasampler::instrument::ui { // Fixed control-panel metrics, exposed so the shell and tests agree. inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap) @@ -81,7 +81,7 @@ int toggleSegmentHitTest(const Rect& control, int x, int y); // The slider track sub-rect inside a slider control's `control` rect: the control inset so the // handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle -// margin at each end). The handle CENTER ranges across [track.left, track.right] as the value +// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value // ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an // empty rect. Pure. Rect sliderTrackRect(const Rect& control); @@ -177,4 +177,4 @@ double knobDragValue(double startValue, int dyPixels, // toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits. int controlAtPoint(const std::vector& rows, int x, int y); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/waveform_view.cpp b/src/core/instrument/ui/waveform_view.cpp similarity index 88% rename from src/vst/waveform_view.cpp rename to src/core/instrument/ui/waveform_view.cpp index 2b6d70c..fc004b9 100644 --- a/src/vst/waveform_view.cpp +++ b/src/core/instrument/ui/waveform_view.cpp @@ -1,11 +1,11 @@ // waveform_view.cpp — see waveform_view.h. Pure math; no host types. -#include "waveform_view.h" +#include "core/instrument/ui/waveform_view.h" #include #include // std::abs (int overload) -namespace reasampler::vst { +namespace reasampler::instrument::ui { namespace { @@ -18,21 +18,21 @@ std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) { } // namespace int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) { - const int w = std::max(0, area.width()); - if (frameCount <= 0 || w <= 0) return area.left; + const int w = std::max(0, area.width); + if (frameCount <= 0 || w <= 0) return area.x; const std::int64_t f = clampFrame(frame, frameCount); // Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line // visually centered on its frame; the divide is exact rational (multiply first). const std::int64_t num = f * static_cast(w) + frameCount / 2; - return area.left + static_cast(num / frameCount); + return area.x + static_cast(num / frameCount); } std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) { - const int w = std::max(0, area.width()); + const int w = std::max(0, area.width); if (frameCount <= 0 || w <= 0) return 0; - if (x <= area.left) return 0; - if (x >= area.right) return frameCount; - const std::int64_t dx = static_cast(x - area.left); + if (x <= area.x) return 0; + if (x >= area.right()) return frameCount; + const std::int64_t dx = static_cast(x - area.x); // Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw // agree at bin granularity. const std::int64_t num = dx * frameCount + static_cast(w) / 2; @@ -54,7 +54,7 @@ std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::in int dxPixels) { const std::int64_t start = clampFrame(startFrame, frameCount); if (dxPixels == 0) return start; - const int w = std::max(0, area.width()); + const int w = std::max(0, area.width); if (frameCount <= 0 || w <= 0) return start; // no room to move // Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame). const std::int64_t magnitude = @@ -96,4 +96,4 @@ std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, return t; // no sign change in the whole buffer -> keep the raw (clamped) target } -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/vst/waveform_view.h b/src/core/instrument/ui/waveform_view.h similarity index 88% rename from src/vst/waveform_view.h rename to src/core/instrument/ui/waveform_view.h index 4831cbf..8b552cf 100644 --- a/src/vst/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -17,17 +17,19 @@ // // 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_trim do +// 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 #include -#include "editor_geometry.h" // Rect, contains — one shared geometry idiom -#include "peaks.h" // AudioSample (float), the mono PCM the snap scans +#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans -namespace reasampler::vst { +namespace reasampler::instrument::ui { + +using audio::AudioSample; // The width (px) of a marker's grab region either side of its x line: a grab within this many // pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab @@ -35,14 +37,14 @@ namespace reasampler::vst { // distinguishable. inline constexpr int kMarkerGrabWidth = 5; -// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.left, -// frame frameCount -> area.right. A frame is clamped to [0, frameCount] before mapping, so an +// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x, +// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an // out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a -// zero-width area pins every frame to area.left (a degenerate, non-inverting result). Pure. +// 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); // The frame a point x (inside `area`) maps to under the inverse linear map, clamped to -// [0, frameCount]. A point left of area.left yields 0; right of area.right yields frameCount. +// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount. // frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips // to the same frame at bin granularity). std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x); @@ -80,4 +82,4 @@ std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::in std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, std::int64_t target); -} // namespace reasampler::vst +} // namespace reasampler::instrument::ui diff --git a/src/core/json/json.cpp b/src/core/json/json.cpp new file mode 100644 index 0000000..ad3d6bb --- /dev/null +++ b/src/core/json/json.cpp @@ -0,0 +1,319 @@ +// core/json implementation — see json.h. The bodies are the (previously +// quintuplicated) bank_model / view_mode_model lexical layer, verbatim; any +// behavioral change here changes five persisted-blob parsers at once. + +#include "core/json/json.h" + +#include +#include +#include +#include + +namespace reasampler::json { + +// --------------------------------------------------------------------------- +// emit helpers +// --------------------------------------------------------------------------- + +void writeEscaped(std::string& out, const std::string& s) { + out += '"'; + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); + out += buf; + } else { + out += c; + } + } + } + out += '"'; +} + +std::string numToStr(double v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", v); + return buf; +} + +std::string numToStr(std::int64_t v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%lld", static_cast(v)); + return buf; +} + +std::string numToStr(int v) { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%d", v); + return buf; +} + +void writeStringArray(std::string& out, const std::vector& v) { + out += '['; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) out += ','; + writeEscaped(out, v[i]); + } + out += ']'; +} + +void writeIntArray(std::string& out, const std::vector& v) { + out += '['; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) out += ','; + out += numToStr(v[i]); + } + out += ']'; +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +void Reader::skipWs() { + while (!eof()) { + char c = s_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; + else break; + } +} + +bool Reader::consume(char c) { + skipWs(); + if (eof() || s_[pos_] != c) return false; + ++pos_; + return true; +} + +// Parses a JSON string literal (with the escapes our writers emit, plus \uXXXX +// for control chars). Positioned before the opening quote (skips leading ws). +bool Reader::parseString(std::string& out) { + skipWs(); + if (eof() || s_[pos_] != '"') return false; + ++pos_; + out.clear(); + while (!eof()) { + char c = s_[pos_++]; + if (c == '"') return true; + if (c == '\\') { + if (eof()) return false; + char e = s_[pos_++]; + switch (e) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': { + // Decode a \uXXXX escape to its code point. + auto readHex4 = [&](unsigned int& cp) -> bool { + if (pos_ + 4 > s_.size()) return false; + cp = 0; + for (int i = 0; i < 4; ++i) { + char h = s_[pos_++]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else return false; + } + return true; + }; + + unsigned int hi = 0; + if (!readHex4(hi)) return false; + + unsigned int codePoint = hi; + if (hi >= 0xD800 && hi <= 0xDBFF) { + // High surrogate — must be followed by \uDC00–\uDFFF. + if (pos_ + 6 > s_.size()) return false; + if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; + pos_ += 2; + unsigned int lo = 0; + if (!readHex4(lo)) return false; + if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate + codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + } else if (hi >= 0xDC00 && hi <= 0xDFFF) { + return false; // unpaired low surrogate — malformed + } + + // Encode codePoint as UTF-8. + if (codePoint <= 0x7F) { + out += static_cast(codePoint); + } else if (codePoint <= 0x7FF) { + out += static_cast(0xC0 | (codePoint >> 6)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else if (codePoint <= 0xFFFF) { + out += static_cast(0xE0 | (codePoint >> 12)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else { + out += static_cast(0xF0 | (codePoint >> 18)); + out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } + break; + } + default: return false; + } + } else { + out += c; + } + } + return false; // unterminated string +} + +bool Reader::parseRawScalar(std::string& out) { + skipWs(); + std::size_t start = pos_; + while (!eof()) { + char c = s_[pos_]; + if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || + c == '\n' || c == '\r') + break; + ++pos_; + } + if (pos_ == start) return false; + out.assign(s_, start, pos_ - start); + return true; +} + +bool Reader::parseDouble(double& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + const char* b = tok.c_str(); + char* end = nullptr; + errno = 0; + double v = std::strtod(b, &end); + if (end != b + tok.size()) return false; + if (errno == ERANGE) return false; // overflow / underflow -> malformed + out = v; + return true; +} + +bool Reader::parseInt64(std::int64_t& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + const char* b = tok.c_str(); + char* end = nullptr; + errno = 0; + long long v = std::strtoll(b, &end, 10); + if (end != b + tok.size()) return false; + if (errno == ERANGE) return false; // overflow -> malformed + out = static_cast(v); + return true; +} + +bool Reader::parseInt(int& out) { + std::int64_t v = 0; + if (!parseInt64(v)) return false; + if (v < INT_MIN || v > INT_MAX) return false; + out = static_cast(v); + return true; +} + +bool Reader::parseBool(bool& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + if (tok == "true") { out = true; return true; } + if (tok == "false") { out = false; return true; } + return false; +} + +bool Reader::expectNullOr(bool& wasNull) { + skipWs(); + if (eof()) return false; + if (s_.compare(pos_, 4, "null") == 0) { + pos_ += 4; + wasNull = true; + } else { + wasNull = false; + } + return true; +} + +bool Reader::parseKey(std::string& key) { + if (!parseString(key)) return false; + return consume(':'); +} + +bool Reader::parseStringArray(std::vector& out) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; // empty array + do { + std::string s; + if (!parseString(s)) return false; + out.push_back(std::move(s)); + } while (consume(',')); + return consume(']'); +} + +bool Reader::parseIntArray(std::vector& out) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; + do { + int v = 0; + if (!parseInt(v)) return false; + out.push_back(v); + } while (consume(',')); + return consume(']'); +} + +bool Reader::skipValue() { + std::string raw; + return captureValue(raw); +} + +// Records the raw source span of one JSON value starting at the current position +// (after whitespace). Handles nested objects/arrays with string-aware brace +// matching (braces inside strings ignored). +bool Reader::captureValue(std::string& raw) { + skipWs(); + if (eof()) return false; + std::size_t start = pos_; + char c = s_[pos_]; + if (c == '"') { + std::string tmp; + if (!parseString(tmp)) return false; + raw.assign(s_, start, pos_ - start); + return true; + } + if (c == '{' || c == '[') { + char open = c, close = (c == '{') ? '}' : ']'; + ++pos_; + int depth = 1; + while (!eof() && depth > 0) { + char d = s_[pos_]; + if (d == '"') { + std::string tmp; + if (!parseString(tmp)) return false; // advances past the string + continue; + } + if (d == open) ++depth; + else if (d == close) --depth; + ++pos_; + } + if (depth != 0) return false; + raw.assign(s_, start, pos_ - start); + return true; + } + // bare scalar (number / true / false / null) + return parseRawScalar(raw); +} + +} // namespace reasampler::json diff --git a/src/core/json/json.h b/src/core/json/json.h new file mode 100644 index 0000000..2af28c3 --- /dev/null +++ b/src/core/json/json.h @@ -0,0 +1,149 @@ +// core/json — the ONE hand-rolled JSON lexical layer (Q-W1; audit T2-02 / §2 +// "Parser ×4"). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. +// +// This module owns the lexical half of the house JSON dialect: the escape-aware +// string literal (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), the bare +// scalar tokens, the number parses (strtod/strtoll with full-token + ERANGE +// rejection), key+':' consumption, unknown-value skipping, and the emit side +// (escaping, %.17g / %d / %lld number rendering, the scoped object writer). +// The DOMAIN grammars — which keys exist, what shape each value takes, what is +// rejected at the model boundary — stay in the consumers (bank_model, bank_book, +// view_mode_model, owned_manifest, tail_control). One lexical definition means +// the five decoders can no longer drift on tolerance or escaping. +// +// Byte-compatibility contract (load-bearing): the emit helpers reproduce the +// prior per-module writers EXACTLY — writeEscaped's escape set, %.17g for +// doubles (shortest form that round-trips every IEEE-754 double bit-for-bit), +// plain decimal for ints — so a re-serialized blob is byte-identical to what +// the pre-extraction writers produced. This was a structural dedupe, not a +// format change; persisted .rpp ext-state must not shift by a byte. + +#pragma once + +#include +#include +#include + +namespace reasampler::json { + +// --------------------------------------------------------------------------- +// emit helpers (writer side) +// --------------------------------------------------------------------------- + +// Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX +// for remaining control chars, everything else verbatim (UTF-8 passes through). +void writeEscaped(std::string& out, const std::string& s); + +// Number rendering. %.17g is the shortest form that round-trips every IEEE-754 +// double exactly, so deserialize(serialize(x)) == x holds bit-for-bit. +std::string numToStr(double v); +std::string numToStr(std::int64_t v); +std::string numToStr(int v); + +// Flat homogeneous arrays: ["a","b"] / [1,2]. Empty vector -> "[]". +void writeStringArray(std::string& out, const std::vector& v); +void writeIntArray(std::string& out, const std::vector& v); + +// Scoped object writer: appends '{' on construction and '}' on destruction, with +// comma separation handled internally. Nested values are written by keyBegin() +// followed by the caller emitting the value (e.g. a nested Writer scope or an +// array). NOTE the destructor-close means an enclosing scope must END (brace +// block) before the built string is returned — see the NRVO note in the +// consumers' serialize() implementations. +class Writer { +public: + explicit Writer(std::string& out) : out_(out) { out_ += '{'; } + ~Writer() { out_ += '}'; } + + Writer(const Writer&) = delete; + Writer& operator=(const Writer&) = delete; + + // "key": — rawValue appended verbatim (numbers, bools, null, + // pre-serialized nested blobs). + void keyRaw(const char* key, const std::string& rawValue) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + out_ += rawValue; + } + // "key":"value" — value escaped. + void keyStr(const char* key, const std::string& value) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + writeEscaped(out_, value); + } + // "key": — caller writes the value immediately after. + void keyBegin(const char* key) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + } + +private: + void sep() { + if (first_) first_ = false; else out_ += ','; + } + std::string& out_; + bool first_ = true; +}; + +// --------------------------------------------------------------------------- +// Reader — the lexical cursor (parser side) +// --------------------------------------------------------------------------- +// +// Every method returns false on malformed input and never reads out of bounds. +// Only the subset the house writers emit is supported. The reader borrows the +// input string — it must outlive the Reader. +class Reader { +public: + explicit Reader(const std::string& s) : s_(s) {} + + bool eof() const { return pos_ >= s_.size(); } + void skipWs(); + + // Consumes `c` (after whitespace). False without advancing past `c` if the + // next non-ws char differs. + bool consume(char c); + + // JSON string literal (escapes + \uXXXX incl. surrogate pairs -> UTF-8). + bool parseString(std::string& out); + + // Bare token (number / true / false / null) up to the next structural char. + bool parseRawScalar(std::string& out); + + // Numbers: full-token parse; trailing bytes or ERANGE reject. parseInt + // additionally rejects values outside [INT_MIN, INT_MAX]. + bool parseDouble(double& out); + bool parseInt64(std::int64_t& out); + bool parseInt(int& out); + + bool parseBool(bool& out); + + // Peeks for the `null` token; consumes it if present (wasNull=true), + // otherwise leaves the position untouched (wasNull=false). Returns false + // only on eof. + bool expectNullOr(bool& wasNull); + + // An object member key + ':'. + bool parseKey(std::string& key); + + // Homogeneous arrays. Appends to `out`; empty array is valid. + bool parseStringArray(std::vector& out); + bool parseIntArray(std::vector& out); + + // Skips one value of any shape (string / object / array / bare scalar) — + // forward-compat for unknown keys. + bool skipValue(); + + // Captures the raw source text of one value verbatim (string-aware brace + // matching), so a nested blob can be handed to its own parser — the + // bank_book -> BankModel::deserialize seam. + bool captureValue(std::string& raw); + +private: + const std::string& s_; + std::size_t pos_ = 0; +}; + +} // namespace reasampler::json diff --git a/src/core/model/bank_book.cpp b/src/core/model/bank_book.cpp new file mode 100644 index 0000000..4700ae6 --- /dev/null +++ b/src/core/model/bank_book.cpp @@ -0,0 +1,462 @@ +#include "core/model/bank_book.h" + +#include +#include + +// bank_book implementation — the registry RULES half: construction, pool +// privileges, bank lifecycle, active bank, sample movement/removal, slot order, +// and the reference queries. The JSON round-trip half (serialize / deserialize — +// Q-W1's golden-literal-pinned byte format) lives in bank_book_json.cpp, compiled +// into the same bank_book target (the slot_map extraction shape: same header, a +// second TU). The one symbol both halves share is the private static +// BankBook::nameKey display-name folding rule (declared in bank_book.h). + +namespace reasampler { + +// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05). + +// --------------------------------------------------------------------------- +// BankBook — construction + bank lookup +// --------------------------------------------------------------------------- + +BankBook::BankBook() { + Bank pool; + pool.id = kPoolBankId; + pool.displayName = kPoolBankName; + pool.ordinal = 0; + banks_.push_back(std::move(pool)); + activeBankId_ = kPoolBankId; +} + +Bank* BankBook::bank(const std::string& id) { + for (auto& b : banks_) + if (b.id == id) return &b; + return nullptr; +} + +const Bank* BankBook::bank(const std::string& id) const { + for (const auto& b : banks_) + if (b.id == id) return &b; + return nullptr; +} + +BankModel* BankBook::index(const std::string& id) { + Bank* b = bank(id); + return b ? &b->index : nullptr; +} + +const BankModel* BankBook::index(const std::string& id) const { + const Bank* b = bank(id); + return b ? &b->index : nullptr; +} + +Bank& BankBook::pool() { + // The pool is seeded on construction and is un-deletable, so it always exists. + return *bank(kPoolBankId); +} + +const Bank& BankBook::pool() const { + return *bank(kPoolBankId); +} + +// --------------------------------------------------------------------------- +// Ordinal normalization +// --------------------------------------------------------------------------- + +void BankBook::normalizeOrdinals() { + // Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a + // contiguous 0..N-1. Stability preserves the caller's relative order among banks + // that share (or, after a reorder shuffle, tie on) an ordinal. + std::stable_sort(banks_.begin(), banks_.end(), [](const Bank& a, const Bank& b) { + if (a.isPool() != b.isPool()) return a.isPool(); // pool always first + return a.ordinal < b.ordinal; + }); + for (std::size_t i = 0; i < banks_.size(); ++i) + banks_[i].ordinal = static_cast(i); +} + +// --------------------------------------------------------------------------- +// Display-name uniqueness (trimmed + case-insensitive, ASCII) +// --------------------------------------------------------------------------- + +// Folds a display name to its uniqueness key: strip leading/trailing ASCII +// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one +// key and cannot coexist. ASCII-only by design — the pure core carries no locale +// facility and must not grow one; bank names are short user labels, not full Unicode +// case-folding candidates. Private static member (Q-W5): the one folding rule shared +// with bank_book_json.cpp's parse-time duplicate-display-name coalesce. +std::string BankBook::nameKey(const std::string& s) { + std::size_t b = 0, e = s.size(); + auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; + while (b < e && isWs(s[b])) ++b; + while (e > b && isWs(s[e - 1])) --e; + std::string out; + out.reserve(e - b); + for (std::size_t i = b; i < e; ++i) { + char c = s[i]; + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + out += c; + } + return out; +} + +// True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The +// exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name. +bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const { + const std::string key = nameKey(name); + for (const auto& b : banks_) + if (b.id != exceptId && nameKey(b.displayName) == key) return true; + return false; +} + +// --------------------------------------------------------------------------- +// Bank lifecycle +// --------------------------------------------------------------------------- + +bool BankBook::createBank(const std::string& id, const std::string& displayName) { + if (id.empty()) return false; // ids key the registry + if (id == kPoolBankId) return false; // reserved pool id + if (bank(id) != nullptr) return false; // duplicate id + // Display names are unique (trimmed + case-insensitive); the pool's "Pool" is a + // reserved name and is caught here like any other collision. + if (displayNameTaken(displayName, /*exceptId=*/id)) return false; + + Bank b; + b.id = id; + b.displayName = displayName; + b.ordinal = static_cast(banks_.size()); // append; normalize compacts it + banks_.push_back(std::move(b)); + normalizeOrdinals(); + return true; +} + +bool BankBook::renameBank(const std::string& id, const std::string& displayName) { + if (id == kPoolBankId) return false; // pool is un-renamable + Bank* b = bank(id); + if (b == nullptr) return false; + // Reject a name already used by a DIFFERENT bank. Renaming a bank to its own + // current name (or a case/space variant of it) is a no-op success, not a + // rejection — exceptId=id excludes the bank itself from the collision scan. + if (displayNameTaken(displayName, /*exceptId=*/id)) return false; + b->displayName = displayName; + return true; +} + +bool BankBook::deleteBank(const std::string& id) { + if (id == kPoolBankId) return false; // pool is un-deletable + auto it = std::find_if(banks_.begin(), banks_.end(), + [&](const Bank& b) { return b.id == id; }); + if (it == banks_.end()) return false; + + banks_.erase(it); + // If the active bank was the one deleted, fall back to the pool (invariant: the + // active id always names a live bank). + if (activeBankId_ == id) activeBankId_ = kPoolBankId; + normalizeOrdinals(); + return true; +} + +bool BankBook::reorderBank(const std::string& id, int newOrdinal) { + if (id == kPoolBankId) return false; // pool is pinned at ordinal 0 + if (bank(id) == nullptr) return false; + + // Work on the named banks as an ordered list (banks_ is already ordinal-sorted + // with the pool first, so named banks are banks_[1..]). Pull the target out and + // re-insert it at the requested position, clamped into the named-bank range + // [1..N], then rewrite ordinals contiguously. This is O(N) and obviously correct. + std::vector named; + named.reserve(banks_.size()); + for (auto& b : banks_) + if (!b.isPool()) named.push_back(std::move(b)); + + auto it = std::find_if(named.begin(), named.end(), + [&](const Bank& b) { return b.id == id; }); + Bank moved = std::move(*it); + named.erase(it); + + // Named ordinals are 1..N; convert to a 0-based insertion index into `named`. + const int hi = static_cast(named.size()); // insert-at range is [0..size] + int insertAt = std::max(0, std::min(newOrdinal - 1, hi)); + named.insert(named.begin() + insertAt, std::move(moved)); + + // Rebuild banks_: pool first, then the reordered named banks. Assign ordinals + // directly by position here — NOT via normalizeOrdinals(), whose stable_sort keys + // on the (now stale) ordinals and would undo the reinsertion order. + std::vector rebuilt; + rebuilt.reserve(named.size() + 1); + rebuilt.push_back(std::move(pool())); + for (auto& b : named) rebuilt.push_back(std::move(b)); + banks_ = std::move(rebuilt); + for (std::size_t i = 0; i < banks_.size(); ++i) + banks_[i].ordinal = static_cast(i); + return true; +} + +bool BankBook::evacuate(const std::string& id) { + if (id == kPoolBankId) return false; // pool is un-evacuable (it is the target) + Bank* src = bank(id); + if (src == nullptr) return false; + + // Move every member into the pool, index-only, observing destination collapse. + // Snapshot the members first, then clear the source — BankModel has no bulk move, + // and adding into the pool must not alias the vector we are draining. + BankModel& poolIndex = pool().index; + const std::vector members = src->index.all(); // copy + for (const auto& s : members) + poolIndex.add(s); // Added or Collapsed; either way the pool now holds the hash + src->index = BankModel{}; // leave the evacuated bank empty + return true; +} + +// --------------------------------------------------------------------------- +// Active bank +// --------------------------------------------------------------------------- + +bool BankBook::setActiveBank(const std::string& id) { + if (bank(id) == nullptr) return false; // unknown id never corrupts state + activeBankId_ = id; + return true; +} + +BankModel& BankBook::activeIndex() { + // activeBankId_ always names a live bank; it falls back to the pool on delete. + return bank(activeBankId_)->index; +} + +const BankModel& BankBook::activeIndex() const { + return bank(activeBankId_)->index; +} + +// --------------------------------------------------------------------------- +// Sample movement (index-only) +// --------------------------------------------------------------------------- + +namespace { + +// Adds `s` to `dest` and maps the BankModel outcome onto the transfer outcome for +// the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes +// (absolute path / empty id) cannot occur here: the sample already passed add() on +// the source side, so its path and id are already valid. +TransferResult applyDestAdd(BankModel& dest, const Sample& s, TransferResult gained) { + return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained; +} + +} // namespace + +TransferResult BankBook::moveSample(const std::string& sampleId, + const std::string& fromBankId, + const std::string& toBankId) { + Bank* from = bank(fromBankId); + Bank* to = bank(toBankId); + if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; + if (fromBankId == toBankId) return TransferResult::RejectedSameBank; + + const Sample* s = from->index.query(sampleId); + if (s == nullptr) return TransferResult::RejectedSampleAbsent; + + // Copy the sample out before removing it: query returns a pointer into the + // source vector that remove() invalidates. + const Sample moved = *s; + from->index.remove(sampleId); // source loses the entry unconditionally on a move + return applyDestAdd(to->index, moved, TransferResult::Moved); +} + +TransferResult BankBook::copySample(const std::string& sampleId, + const std::string& fromBankId, + const std::string& toBankId) { + Bank* from = bank(fromBankId); + Bank* to = bank(toBankId); + if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; + if (fromBankId == toBankId) return TransferResult::RejectedSameBank; + + const Sample* s = from->index.query(sampleId); + if (s == nullptr) return TransferResult::RejectedSampleAbsent; + + const Sample copy = *s; // source entry is left intact + return applyDestAdd(to->index, copy, TransferResult::Copied); +} + +// --------------------------------------------------------------------------- +// Sample removal (index-only) + the last-reference query +// --------------------------------------------------------------------------- + +RemoveResult BankBook::removeSample(const std::string& sampleId, + const std::string& fromBankId, + RemoveScope scope) { + if (scope == RemoveScope::AllBanks) { + // Latent seam: purge the id from every bank that holds it. fromBankId is + // ignored (the id is dropped book-wide). Removed iff at least one drop landed. + bool any = false; + for (auto& b : banks_) + if (b.index.remove(sampleId)) { + b.slots.remove(sampleId); // keep SlotMap in sync: leave an empty gap + any = true; + } + return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent; + } + + // ThisBank (default, the only surfaced verb): drop from the one named source bank. + Bank* from = bank(fromBankId); + if (from == nullptr) return RemoveResult::RejectedUnknownBank; + if (!from->index.remove(sampleId)) return RemoveResult::RejectedSampleAbsent; + from->slots.remove(sampleId); // keep SlotMap in sync: the removed sample's slot becomes a gap + return RemoveResult::Removed; +} + +bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) { + for (auto& b : banks_) + if (b.index.query(sampleId) != nullptr) + return b.index.updateInPlace(sampleId, updated); + return false; // no bank holds the id +} + +// --------------------------------------------------------------------------- +// Sample display order (L7) — SlotMap driven, index membership untouched +// --------------------------------------------------------------------------- + +namespace { + +// The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed. +std::vector indexIds(const BankModel& idx) { + std::vector ids; + for (const auto& s : idx.all()) ids.push_back(s.id); + return ids; +} + +// Squares one bank's SlotMap with its index membership. A map with NO overlap with the +// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from +// insertion order; an existing map is reconciled (drop stale markers, append unmapped). +void reconcileBankSlots(Bank& b) { + const std::vector live = indexIds(b.index); + if (b.slots.empty()) { + b.slots.resetDense(live); // migration / first-population default: dense, no gaps + return; + } + b.slots.reconcile(live); // partial map: keep positions, drop stale, append new +} + +} // namespace + +void BankBook::reconcileSlots() { + for (auto& b : banks_) reconcileBankSlots(b); +} + +std::vector BankBook::orderedSampleIds(const std::string& bankId) { + Bank* b = bank(bankId); + if (b == nullptr) return {}; + reconcileBankSlots(*b); // ensure the map covers all live members + return b->slots.orderedIds(); +} + +bool BankBook::reorderSample(const std::string& id, const std::string& bankId, + int targetSlot) { + Bank* b = bank(bankId); + if (b == nullptr) return false; + if (b->index.query(id) == nullptr) return false; // bank does not hold the sample + reconcileBankSlots(*b); // complete the target space first + return b->slots.reorder(id, targetSlot); // gap-preserving; index untouched +} + +bool BankBook::replaceSample(const std::string& newId, const std::string& oldId, + const std::string& bankId) { + if (newId == oldId) return false; + Bank* b = bank(bankId); + if (b == nullptr) return false; + // Both the dragged sample and the occupant must live in this bank. + if (b->index.query(newId) == nullptr) return false; + if (b->index.query(oldId) == nullptr) return false; + + reconcileBankSlots(*b); // complete the map so oldId's slot is known + + // Capture the target slot BEFORE any mutation so the position survives the removal. + const int targetSlot = b->slots.slotOf(oldId); + if (targetSlot < 0) return false; // occupant not positioned (shouldn't happen post-reconcile) + + // POOL GUARD (settled): the occupant's index-removal must pass the SAME guard the + // remove verb applies. Commit the removal FIRST so a rejection is a true no-op (no + // slot mutation happened yet). removeSample(ThisBank) permits per-sample removal from + // any bank incl. the pool (per-sample remove is not a pool privilege), so it succeeds + // whenever the occupant exists — which we verified — but routing through it means a + // future pool-floor guard added to remove governs replace identically, one rule. + const RemoveResult r = removeSample(oldId, bankId, RemoveScope::ThisBank); + if (r != RemoveResult::Removed) return false; // guard rejected -> nothing changed + + // Occupant gone from the index; now update the slot markers. Drop oldId's now-dangling + // marker to free the target slot, then move newId onto it. reorder onto an EMPTY slot + // places newId there exactly and empties newId's own (source) slot — the slot position + // is preserved and only its occupant changed, exactly the replace contract. + b->slots.remove(oldId); + b->slots.reorder(newId, targetSlot); + return true; +} + +bool BankBook::hashReferencedElsewhere(const std::string& hash, + const std::string& exceptBankId) const { + if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash) + for (const auto& b : banks_) { + if (b.id == exceptBankId) continue; // the removed-from bank is excluded + if (b.index.findByHash(hash) != nullptr) return true; + } + return false; +} + +std::vector BankBook::referencedPaths() const { + // Union across the whole book (pool first, then named banks in ordinal order — + // banks_ is kept ordinal-sorted). De-duplicate by exact string so a file a copy + // put in two banks appears once. Skip empty paths (they reference no file). + std::vector paths; + std::unordered_set seen; + for (const auto& b : banks_) { + for (const auto& s : b.index.all()) { + if (s.relativePath.empty()) continue; + if (seen.insert(s.relativePath).second) + paths.push_back(s.relativePath); + } + } + return paths; +} + +// =========================================================================== +// JSON — serialize / deserialize / adoptBanks live in bank_book_json.cpp +// (Q-W5 extraction; byte-identical format, golden-literal-pinned by the Q-W1 +// test). loadFromPersisted stays here: it is the load-source PRECEDENCE rule +// (banks-blob vs legacy vs empty), not the codec. +// =========================================================================== + +// --------------------------------------------------------------------------- +// Active-bank cycle ordering (pure, free function — mirror of nextModeId) +// --------------------------------------------------------------------------- + +std::string nextBankId(const std::vector& orderedBankIds, + const std::string& currentBankId) { + if (orderedBankIds.empty()) return {}; // nothing to cycle to + for (std::size_t i = 0; i < orderedBankIds.size(); ++i) { + if (orderedBankIds[i] == currentBankId) + return orderedBankIds[(i + 1) % orderedBankIds.size()]; // wrap past the last + } + // Active id not in the list (stale/unknown) — jump to the first id as a sane + // home rather than returning "" (matches nextModeId's fallback). + return orderedBankIds.front(); +} + +BankBook BankBook::loadFromPersisted(const std::string& banksJson, + const std::string& legacyJson) { + // Precedence 1: the authoritative `banks` blob. A present-but-malformed blob is + // an error, not an absence — degrade to an empty book rather than falling through + // to a stale legacy key (which would resurrect superseded single-bank state). + if (!banksJson.empty()) { + auto book = deserialize(banksJson); + return book ? std::move(*book) : BankBook{}; + } + // Precedence 2: no `banks` yet, but a legacy `bank_index` — one-way pool migration + // (deserialize's parse-time legacy path promotes it into the pool). A malformed + // legacy blob likewise degrades to empty. + if (!legacyJson.empty()) { + auto book = deserialize(legacyJson); + return book ? std::move(*book) : BankBook{}; + } + // Precedence 3: a brand-new / never-captured project — a fresh empty book. + return BankBook{}; +} + +} // namespace reasampler diff --git a/src/bank_book.h b/src/core/model/bank_book.h similarity index 78% rename from src/bank_book.h rename to src/core/model/bank_book.h index 9b4c6f8..333d39b 100644 --- a/src/bank_book.h +++ b/src/core/model/bank_book.h @@ -10,9 +10,9 @@ // -- What it is -------------------------------------------------------------- // // An ordered registry of banks. Each bank = { stable id, display name, ordinal, -// BankIndex }. The book WRAPS N BankIndex instances — bank_model / BankIndex are +// 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 BankIndex, add to destination's); files never +// 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) -------------------------------- @@ -42,113 +42,30 @@ #include #include -#include "bank_model.h" +#include "core/model/bank_model.h" +#include "core/model/slot_map.h" namespace reasampler { +// Q-W1 interim: this god module re-namespaces in its own split wave; until then the +// clean model types it wraps live in reasampler::model. +using namespace model; + // The pool's fixed identity. The id is reserved: createBank rejects it, and the // pool is always bank-zero. The name is fixed: renameBank rejects the pool. inline constexpr const char* kPoolBankId = "pool"; inline constexpr const char* kPoolBankName = "Pool"; -// SlotMap — the L7 gap-preserving display-position carrier for ONE bank (F2 settled: -// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a -// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps -// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty -// first row above an occupied second row). At most one id per slot (a slot is never -// double-occupied) and at most one slot per id (an id sits in exactly one place). -// -// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one -// sample into two banks may sit at different slots, so position is a per-bank display -// concern owned by the bank's membership. bank_model / Sample stay untouched. -// -// PURE: standard library only. Hard-tested to the bar of BankIndex's round-trip. -class SlotMap { -public: - // The slot an id occupies, or -1 if the id is not mapped. O(N). - int slotOf(const std::string& id) const; - - // The id occupying `slot`, or "" if the slot is empty. O(N). - std::string idAt(int slot) const; - - // The highest occupied slot, or -1 when the map is empty. Defines the append - // frontier and (with trailing-empty trim) the content extent. - int maxSlot() const; - - // Ids in ASCENDING slot order (the deterministic display order). Empty slots - // produce no entry — the caller iterates occupants; sparse layout is a draw - // concern that reads slotOf/idAt, not this list. - std::vector orderedIds() const; - - // Places `id` at the next free slot after the last occupied one (append). If the - // id is already mapped it is first removed (leaving its old slot empty), then - // appended — an append never fills an earlier gap. No-op guard: empty id ignored. - void append(const std::string& id); - - // Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id - // keeps its position. Returns true if the id was mapped. - bool remove(const std::string& id); - - // Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics): - // * target slot EMPTY -> `id` moves there; its old slot is left empty. - // * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and - // every occupant at slot >= targetSlot (except `id` itself) shifts up by one, - // preserving their relative order and never colliding. Matches file-manager - // reorder. Interior gaps between shifted occupants are preserved as-is - // (shift is +1 on each occupant, so the gap structure above the target is kept). - // * negative targetSlot is clamped to 0. - // Returns false (no mutation) if `id` is not mapped. Deterministic. - bool reorder(const std::string& id, int targetSlot); - - // Rebuilds the map densely from `ids` in the given order (slot i = ids[i]), - // dropping any prior state. The migration path: a pre-L7 bank with no persisted - // slot data is seeded from its BankIndex insertion order, densely packed (no gaps), - // so it is visually identical on first post-L7 load. Empty/duplicate ids skipped. - void resetDense(const std::vector& ids); - - // Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left - // the index) and appends any live id that has NO mapping yet (a sample the index - // gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps - // the map consistent with the bank's membership without a re-pack. Deterministic: - // orphan appends follow `liveIds` order. - void reconcile(const std::vector& liveIds); - - bool empty() const { return entries_.empty(); } - std::size_t size() const { return entries_.size(); } - - bool operator==(const SlotMap& o) const; - - // JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the - // bank envelope's "slots" member by BankBook::serialize; parsed back by its parser. - // Round-trips losslessly with the rest of the bank. - std::string serialize() const; - - // Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces - // the map invariants defensively against a hand-edited blob: a duplicate id keeps - // its FIRST occurrence; a slot already taken by a kept id drops the later pair - // (never double-occupies); an empty id or negative slot is dropped. The result is - // sorted ascending by slot. reconcile() against live membership runs afterward, so - // a lossy repair here degrades gracefully rather than corrupting lookup. - static SlotMap fromEntries(const std::vector>& pairs); - -private: - struct Entry { - std::string id; - int slot = 0; - bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; } - }; - std::vector entries_; // kept sorted ascending by slot (invariant) - - void sortBySlot(); -}; +// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h. +// Included above because Bank carries one per bank. // One bank: a stable id, a display name, an ordinal (tab/display order), and its -// own BankIndex. The pool is the bank whose id == kPoolBankId. +// own BankModel. The pool is the bank whose id == kPoolBankId. struct Bank { std::string id; // stable, persisted; the pool's is kPoolBankId std::string displayName; // mutable for named banks; fixed "Pool" for the pool int ordinal = 0; // display order; pool is 0, named banks 1..N - BankIndex index; // this bank's samples + BankModel index; // this bank's samples SlotMap slots; // L7 display positions of this bank's samples (gap-preserving) bool isPool() const { return id == kPoolBankId; } @@ -251,10 +168,10 @@ public: // bank — an invalid set never corrupts state. bool setActiveBank(const std::string& id); - // The active bank's BankIndex — the index the capture layer adds to. Always + // The active bank's BankModel — the index the capture layer adds to. Always // valid (the active id always names a live bank; it falls back to the pool). - BankIndex& activeIndex(); - const BankIndex& activeIndex() const; + BankModel& activeIndex(); + const BankModel& activeIndex() const; // -- Sample movement (index-only; files never relocate) ------------------ @@ -334,7 +251,7 @@ public: // Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture): // finds the bank holding `sampleId` and replaces its entry with `updated` - // (order-preserving, no dedup — see BankIndex::updateInPlace). Scans banks in + // (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in // ordinal order and updates the FIRST holder (a sample id is unique within a // bank; the same id living in two banks via copy would update the earliest, which // is acceptable — re-capture operates on the panel's focused single selection). @@ -372,9 +289,9 @@ public: Bank* bank(const std::string& id); const Bank* bank(const std::string& id) const; - // The bank's BankIndex by id, or nullptr. Convenience over bank()->index. - BankIndex* index(const std::string& id); - const BankIndex* index(const std::string& id) const; + // The bank's BankModel by id, or nullptr. Convenience over bank()->index. + BankModel* index(const std::string& id); + const BankModel* index(const std::string& id) const; // The pool (always present). Never null. Bank& pool(); @@ -425,6 +342,16 @@ private: std::vector banks_; // ordinal order; banks_[0] is always the pool std::string activeBankId_; // always names a live bank; defaults to pool + // Folds a display name to its uniqueness key: strip leading/trailing ASCII + // whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share + // one key and cannot coexist. ASCII-only by design — the pure core carries no + // locale facility and must not grow one. A private STATIC member (Q-W5, settled) + // because BOTH halves of the split implementation need the ONE folding rule: the + // rules TU (bank_book.cpp, displayNameTaken) and the JSON TU (bank_book_json.cpp, + // deserialize's duplicate-display-name coalesce) — a drifted second copy would let + // a parsed book violate the create/rename uniqueness invariant. + static std::string nameKey(const std::string& s); + // True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key // (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check; // pass exceptId=id to let a bank keep (or re-case/-space) its own name. diff --git a/src/core/model/bank_book_json.cpp b/src/core/model/bank_book_json.cpp new file mode 100644 index 0000000..d3b2289 --- /dev/null +++ b/src/core/model/bank_book_json.cpp @@ -0,0 +1,309 @@ +#include "core/model/bank_book.h" + +#include +#include +#include + +#include "core/json/json.h" + +// bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header, +// compiled into the same bank_book target; the slot_map second-TU shape). The +// registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private +// static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time +// duplicate-display-name coalesce below must fold names EXACTLY as the create/rename +// uniqueness check does, or a parsed book could violate the in-model invariant. +// +// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model +// and view_mode_model. The book blob nests one bank object per bank, each carrying that +// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize), +// so per-bank sample serialization stays owned by bank_model and is not duplicated +// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a +// raw "index" member whose value is the BankModel blob verbatim; the parser splits +// the book envelope, then hands each nested index blob straight to +// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped. +// BYTE-IDENTICAL to the pre-extraction writer — the Q-W1 golden-literal test pins it. + +namespace reasampler { + +// =========================================================================== +// JSON — writer +// =========================================================================== + +namespace { + +// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the +// prior file-local writer carried, so the emitted blob is byte-identical. +std::string intToStr(int v) { return json::numToStr(v); } +using ObjWriter = json::Writer; + +} // namespace + +std::string BankBook::serialize() const { + std::string out; + { + ObjWriter root(out); + root.keyRaw("version", intToStr(1)); + root.keyStr("activeBank", activeBankId_); + + // banks: array of { id, displayName, ordinal, index: }. + // The pool rides in as bank-zero, persisted identically to any named bank. + root.keyBegin("banks"); + out += '['; + for (std::size_t i = 0; i < banks_.size(); ++i) { + if (i) out += ','; + ObjWriter b(out); + b.keyStr("id", banks_[i].id); + b.keyStr("displayName", banks_[i].displayName); + b.keyRaw("ordinal", intToStr(banks_[i].ordinal)); + // The nested index is bank_model's own JSON, emitted verbatim so the + // per-sample shape stays owned by BankModel::serialize (not duplicated). + b.keyRaw("index", banks_[i].index.serialize()); + // L7 display positions (gap-preserving). Absent on a pre-L7 blob; the + // parser defaults such a bank's slots from insertion order on load. + b.keyRaw("slots", banks_[i].slots.serialize()); + } + out += ']'; + } // root closes here (see bank_model note on NRVO + deferred close) + return out; +} + +// =========================================================================== +// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB) +// =========================================================================== + +namespace { + +// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1). +// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot}, +// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive +// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root +// blob, distinguishing the legacy shape (a bare bank_index object: has +// "samples", no "banks") from the book shape (has "banks"): a legacy blob +// yields a single pool bank carrying the migrated index and an empty active id +// (⇒ pool). The member deserialize() adopts the result (duplicate-display-name +// coalesce + ordinal normalize + active resolve — the coalesce lives THERE, not +// here, because it folds through the private BankBook::nameKey these free +// functions cannot reach). +bool parseSlots(json::Reader& r, std::vector>& out); + +bool parseBank(json::Reader& r, Bank& b) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return false; // a bank object must at least carry an id + + bool haveId = false; + bool haveIndex = false; + do { + std::string key; + if (!r.parseKey(key)) return false; + + if (key == "id") { + if (!r.parseString(b.id)) return false; + haveId = true; + } else if (key == "displayName") { + if (!r.parseString(b.displayName)) return false; + } else if (key == "ordinal") { + if (!r.parseInt(b.ordinal)) return false; + } else if (key == "index") { + std::string raw; + if (!r.captureValue(raw)) return false; + auto idx = BankModel::deserialize(raw); + if (!idx) return false; // a malformed nested index fails the whole parse + b.index = std::move(*idx); + haveIndex = true; + } else if (key == "slots") { + // L7 display positions. Absent on a pre-L7 blob (the else-branch skips + // nothing because the key never appears); when present it drives the + // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. + std::vector> pairs; + if (!parseSlots(r, pairs)) return false; + b.slots = SlotMap::fromEntries(pairs); + } else { + if (!r.skipValue()) return false; // forward-compat unknown keys + } + } while (r.consume(',')); + + if (!r.consume('}')) return false; + if (!haveId || b.id.empty()) return false; // id keys the registry + if (!haveIndex) return false; // every bank persists its index + return true; +} + +bool parseSlots(json::Reader& r, std::vector>& out) { + out.clear(); + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; // empty slot array — a bank with no positions yet + do { + if (!r.consume('{')) return false; + std::string id; + int slot = 0; + bool haveId = false, haveSlot = false; + do { + std::string k; + if (!r.parseKey(k)) return false; + if (k == "id") { if (!r.parseString(id)) return false; haveId = true; } + else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; } + else { if (!r.skipValue()) return false; } // forward-compat + } while (r.consume(',')); + if (!r.consume('}')) return false; + if (!haveId || !haveSlot) return false; // a slot entry needs both + out.emplace_back(std::move(id), slot); + } while (r.consume(',')); + return r.consume(']'); +} + +bool parseBook(json::Reader& r, const std::string& raw, std::vector& banks, + std::string& activeBank) { + banks.clear(); + activeBank.clear(); + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return false; // an empty object is neither shape → malformed + + // Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a + // "samples" key with no "banks" ⇒ legacy shape (promote into the pool). + std::vector parsedBanks; + bool sawBanks = false; + bool sawSamples = false; + + do { + std::string key; + if (!r.parseKey(key)) return false; + + if (key == "banks") { + sawBanks = true; + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { + do { + Bank b; + if (!parseBank(r, b)) return false; + parsedBanks.push_back(std::move(b)); + } while (r.consume(',')); + if (!r.consume(']')) return false; + } + } else if (key == "activeBank") { + if (!r.parseString(activeBank)) return false; + } else if (key == "samples") { + // Legacy marker. The legacy index is re-parsed from the whole input below + // (BankModel::deserialize owns that shape); here we only skip the value to + // keep the scan well-formed and note that we saw it. + sawSamples = true; + if (!r.skipValue()) return false; + } else { + if (!r.skipValue()) return false; // version, or unknown + } + } while (r.consume(',')); + + if (!r.consume('}')) return false; + r.skipWs(); + if (!r.eof()) return false; // trailing garbage + + // --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- + if (!sawBanks) { + if (!sawSamples) return false; // neither shape's marker → malformed + auto legacy = BankModel::deserialize(raw); + if (!legacy) return false; + Bank pool; + pool.id = kPoolBankId; + pool.displayName = kPoolBankName; + pool.ordinal = 0; + pool.index = std::move(*legacy); + banks.push_back(std::move(pool)); // { pool } with zero named banks + activeBank.clear(); // ⇒ pool (default) after adoption + return true; + } + + // --- Book shape: the parsed banks ARE the book (pool folded in). --- + // The pool must be present as bank-zero (serialize always emits it). Reject a + // book blob that omits it rather than silently re-seeding — a book without its + // pool is malformed, not a legacy blob. + bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(), + [](const Bank& b) { return b.isPool(); }); + if (!hasPool) return false; + + // Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup). + for (std::size_t i = 0; i < parsedBanks.size(); ++i) + for (std::size_t j = i + 1; j < parsedBanks.size(); ++j) + if (parsedBanks[i].id == parsedBanks[j].id) return false; + + // Force the pool's fixed display name — it is not user-mutable, so we do not + // trust a persisted override for it (keeps kPoolBankName authoritative). + for (auto& b : parsedBanks) + if (b.isPool()) b.displayName = kPoolBankName; + + banks = std::move(parsedBanks); + return true; +} + +} // namespace + +void BankBook::adoptBanks(std::vector&& banks, const std::string& activeBank) { + banks_ = std::move(banks); + normalizeOrdinals(); + // Resolve the active bank defensively: fall back to the pool if the persisted id + // names no bank, so a corrupt active id never leaves a dangling capture target. + activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId); +} + +std::optional BankBook::deserialize(const std::string& blob) { + std::vector banks; + std::string activeBank; + json::Reader r(blob); + if (!parseBook(r, blob, banks, activeBank)) return std::nullopt; + + // --- Coalesce duplicate folded display names (B4 re-review fold-in). -------- + // The in-model create/rename path enforces unique display names under nameKey, + // but a hand-edited .rpp blob can smuggle in two banks whose names fold to the + // same key ("Drums" and " drums "). Rejecting the whole book over one collision + // would degrade the user's entire library to empty, so instead we AUTO- + // DISAMBIGUATE the later duplicate deterministically: scan in parse order, and + // the first time a folded key repeats, suffix that bank's display name (" 2", + // " 3", …) until its folded key is unique among all names seen so far. The FIRST + // bank to carry a key keeps its name verbatim; only subsequent collisions are + // renamed. No bank or sample is lost, and ids are untouched. The pool is included + // in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool" + // is disambiguated away from it, never the reverse. + // + // Hosted HERE (a static member, Q-W5) rather than in the free parseBook because + // it folds through the PRIVATE BankBook::nameKey — the same rule the + // create/rename uniqueness check applies. Runs after parseBook on BOTH shapes; + // the legacy path yields { pool } alone, where the scan is a trivial no-op. + { + std::vector seenKeys; + seenKeys.reserve(banks.size()); + for (auto& b : banks) { + if (b.isPool()) { // pool's name is fixed; reserve its key + seenKeys.push_back(nameKey(b.displayName)); + continue; + } + const auto taken = [&](const std::string& k) { + return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end(); + }; + std::string key = nameKey(b.displayName); + if (taken(key)) { + // Suffix with an ascending integer until the folded key is free. Guard + // against a pathological blob whose base name already ends in a number + // by folding the candidate each attempt (nameKey normalizes it). + const std::string base = b.displayName; + for (int n = 2;; ++n) { + const std::string candidate = base + " " + std::to_string(n); + const std::string candKey = nameKey(candidate); + if (!taken(candKey)) { + b.displayName = candidate; + key = candKey; + break; + } + } + } + seenKeys.push_back(key); + } + } + + BankBook book; + book.adoptBanks(std::move(banks), activeBank); + return book; +} + +} // namespace reasampler diff --git a/src/core/model/bank_model.cpp b/src/core/model/bank_model.cpp new file mode 100644 index 0000000..dee8731 --- /dev/null +++ b/src/core/model/bank_model.cpp @@ -0,0 +1,461 @@ +#include "core/model/bank_model.h" + +#include + +#include "core/json/json.h" + +// bank_model implementation. +// +// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer, +// no per-module Parser copy). The field set is a flat struct of primitives, +// strings, one enum, a small string array, and a few optionals, so a compact +// writer + recursive-descent DOMAIN parser over json::Reader is the simplest +// thing that works. Doubles are emitted with 17 significant digits (%.17g), the +// shortest form that round-trips every IEEE-754 double exactly, so the +// deserialize(serialize(x)) == x invariant holds bit-for-bit. + +namespace reasampler::model { + +// --------------------------------------------------------------------------- +// equality +// --------------------------------------------------------------------------- + +bool SourceRange::operator==(const SourceRange& o) const { + return startSeconds == o.startSeconds && endSeconds == o.endSeconds && + startPpq == o.startPpq && endPpq == o.endPpq; +} + +bool Provenance::operator==(const Provenance& o) const { + return parentSampleId == o.parentSampleId && fxChainSnapshot == o.fxChainSnapshot; +} + +bool Levels::operator==(const Levels& o) const { + return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs; +} + +bool LoopPoints::operator==(const LoopPoints& o) const { + return start == o.start && end == o.end; +} + +bool Sample::operator==(const Sample& o) const { + return id == o.id && displayName == o.displayName && relativePath == o.relativePath && + sourceMode == o.sourceMode && sourceRange == o.sourceRange && + trackGuids == o.trackGuids && wetDry == o.wetDry && + channelCount == o.channelCount && sampleRate == o.sampleRate && + lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats && + captureTempo == o.captureTempo && + captureTimeSigNum == o.captureTimeSigNum && + captureTimeSigDenom == o.captureTimeSigDenom && key == o.key && + rootNote == o.rootNote && loop == o.loop && levels == o.levels && + clipped == o.clipped && tier == o.tier && contentHash == o.contentHash && + provenance == o.provenance && createdTimestamp == o.createdTimestamp; +} + +// --------------------------------------------------------------------------- +// path invariant +// --------------------------------------------------------------------------- + +// DECISION: reject absolute paths rather than normalize them. The pure model has +// no knowledge of the project root, so it cannot correctly relativize an absolute +// path — any "normalization" would be a guess that could point at the wrong file. +// Rejecting at the boundary is honest and deterministic; the capture backend (M3) +// is responsible for handing us an already-relative path. Covers POSIX ("/x"), +// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") +// forms. Any leading : is rejected regardless of the character that follows — +// drive-relative paths ("C:foo.wav") resolve against the drive's current directory, +// not the project root, so they violate the relative-paths-only invariant just as +// much as "C:\foo.wav" does. +static bool isAbsolutePath(const std::string& p) { + if (p.empty()) return false; + if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC + if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') + return true; // Windows drive (C:\, C:/, C:foo, C:) + return false; +} + +// --------------------------------------------------------------------------- +// BankModel +// --------------------------------------------------------------------------- + +AddResult BankModel::add(const Sample& sample) { + if (sample.id.empty()) return AddResult::RejectedEmptyId; + if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath; + + if (findByHash(sample.contentHash) != nullptr) + return AddResult::Collapsed; + + samples_.push_back(sample); + return AddResult::Added; +} + +bool BankModel::remove(const std::string& id) { + for (auto it = samples_.begin(); it != samples_.end(); ++it) { + if (it->id == id) { + samples_.erase(it); + return true; + } + } + return false; +} + +bool BankModel::updateInPlace(const std::string& id, const Sample& updated) { + if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds + for (auto& s : samples_) { + if (s.id == id) { + s = updated; // replace in place — position (insertion order) preserved + return true; + } + } + return false; +} + +const Sample* BankModel::query(const std::string& id) const { + for (const auto& s : samples_) + if (s.id == id) return &s; + return nullptr; +} + +const Sample* BankModel::findByHash(const std::string& contentHash) const { + if (contentHash.empty()) return nullptr; // empty hashes never dedup + for (const auto& s : samples_) + if (s.contentHash == contentHash) return &s; + return nullptr; +} + +bool BankModel::moveTier(const std::string& id, Tier tier) { + for (auto& s : samples_) { + if (s.id == id) { + s.tier = tier; + return true; + } + } + return false; +} + +std::vector BankModel::byTier(Tier tier) const { + std::vector out; + for (const auto& s : samples_) + if (s.tier == tier) out.push_back(s); + return out; +} + +// --------------------------------------------------------------------------- +// JSON writer +// --------------------------------------------------------------------------- + +namespace { + +using json::numToStr; +using json::writeEscaped; +using json::writeStringArray; +using ObjWriter = json::Writer; + +void writeSample(std::string& out, const Sample& s) { + ObjWriter w(out); + w.keyStr("id", s.id); + w.keyStr("displayName", s.displayName); + w.keyStr("relativePath", s.relativePath); + w.keyRaw("sourceMode", numToStr(static_cast(s.sourceMode))); + + w.keyBegin("sourceRange"); + { + ObjWriter r(out); + r.keyRaw("startSeconds", numToStr(s.sourceRange.startSeconds)); + r.keyRaw("endSeconds", numToStr(s.sourceRange.endSeconds)); + r.keyRaw("startPpq", numToStr(s.sourceRange.startPpq)); + r.keyRaw("endPpq", numToStr(s.sourceRange.endPpq)); + } + + w.keyBegin("trackGuids"); + writeStringArray(out, s.trackGuids); + + w.keyRaw("wetDry", numToStr(s.wetDry)); + w.keyRaw("channelCount", numToStr(s.channelCount)); + w.keyRaw("sampleRate", numToStr(s.sampleRate)); + w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds)); + w.keyRaw("lengthBeats", numToStr(s.lengthBeats)); + w.keyRaw("captureTempo", numToStr(s.captureTempo)); + w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum)); + w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom)); + + // Optionals are emitted as null when absent so present/absent round-trips. + w.keyBegin("key"); + if (s.key) writeEscaped(out, *s.key); else out += "null"; + + // Phase S seam fields (D-B). Emitted as null when absent (same shape as `key` + // and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely — + // parses to empty optionals and re-serializes without invention. + w.keyBegin("rootNote"); + if (s.rootNote) out += numToStr(*s.rootNote); else out += "null"; + + w.keyBegin("loop"); + if (s.loop) { + ObjWriter lp(out); + lp.keyRaw("start", numToStr(s.loop->start)); + lp.keyRaw("end", numToStr(s.loop->end)); + } else { + out += "null"; + } + + w.keyBegin("levels"); + { + ObjWriter l(out); + l.keyRaw("peakDb", numToStr(s.levels.peakDb)); + l.keyRaw("rmsDb", numToStr(s.levels.rmsDb)); + l.keyRaw("lufs", numToStr(s.levels.lufs)); + } + + w.keyRaw("clipped", s.clipped ? "true" : "false"); + w.keyRaw("tier", numToStr(static_cast(s.tier))); + w.keyStr("contentHash", s.contentHash); + + w.keyBegin("provenance"); + if (s.provenance) { + ObjWriter p(out); + p.keyStr("parentSampleId", s.provenance->parentSampleId); + p.keyStr("fxChainSnapshot", s.provenance->fxChainSnapshot); + } else { + out += "null"; + } + + w.keyRaw("createdTimestamp", numToStr(s.createdTimestamp)); +} + +} // namespace + +std::string BankModel::serialize() const { + std::string out; + { + ObjWriter root(out); + root.keyRaw("version", numToStr(1)); + root.keyBegin("samples"); + out += '['; + for (std::size_t i = 0; i < samples_.size(); ++i) { + if (i) out += ','; + writeSample(out, samples_[i]); + } + out += ']'; + } // root closes the object here — not deferred to function return (NRVO would + // otherwise let the caller observe `out` before the closing brace is appended) + return out; +} + +// --------------------------------------------------------------------------- +// JSON parser (recursive descent over the shared json::Reader). Returns false +// on any malformed input; never reads out of bounds. Only supports the subset +// our writer emits. The lexical layer (strings, numbers, skip) lives in +// core/json; only the Sample/index DOMAIN grammar lives here. +// --------------------------------------------------------------------------- + +namespace { + +bool parseSample(json::Reader& r, Sample& s) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // empty object (shouldn't happen, but valid) + + do { + std::string key; + if (!r.parseKey(key)) return false; + + if (key == "id") { + if (!r.parseString(s.id)) return false; + } else if (key == "displayName") { + if (!r.parseString(s.displayName)) return false; + } else if (key == "relativePath") { + if (!r.parseString(s.relativePath)) return false; + } else if (key == "sourceMode") { + int v = 0; + if (!r.parseInt(v)) return false; + // Valid range: MasterMix(0) .. Realtime(5). + if (v < static_cast(SourceMode::MasterMix) || + v > static_cast(SourceMode::Realtime)) + return false; + s.sourceMode = static_cast(v); + } else if (key == "sourceRange") { + if (!r.consume('{')) return false; + do { + std::string rk; + if (!r.parseKey(rk)) return false; + double dv = 0.0; + if (!r.parseDouble(dv)) return false; + if (rk == "startSeconds") s.sourceRange.startSeconds = dv; + else if (rk == "endSeconds") s.sourceRange.endSeconds = dv; + else if (rk == "startPpq") s.sourceRange.startPpq = dv; + else if (rk == "endPpq") s.sourceRange.endPpq = dv; + } while (r.consume(',')); + if (!r.consume('}')) return false; + } else if (key == "trackGuids") { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { + do { + std::string g; + if (!r.parseString(g)) return false; + s.trackGuids.push_back(g); + } while (r.consume(',')); + if (!r.consume(']')) return false; + } + } else if (key == "wetDry") { + if (!r.parseDouble(s.wetDry)) return false; + } else if (key == "channelCount") { + if (!r.parseInt(s.channelCount)) return false; + } else if (key == "sampleRate") { + if (!r.parseInt(s.sampleRate)) return false; + } else if (key == "lengthSeconds") { + if (!r.parseDouble(s.lengthSeconds)) return false; + } else if (key == "lengthBeats") { + if (!r.parseDouble(s.lengthBeats)) return false; + } else if (key == "captureTempo") { + if (!r.parseDouble(s.captureTempo)) return false; + } else if (key == "captureTimeSigNum") { + if (!r.parseInt(s.captureTimeSigNum)) return false; + } else if (key == "captureTimeSigDenom") { + if (!r.parseInt(s.captureTimeSigDenom)) return false; + } else if (key == "key") { + bool wasNull = false; + if (!r.expectNullOr(wasNull)) return false; + if (wasNull) { + s.key.reset(); + } else { + std::string k; + if (!r.parseString(k)) return false; + s.key = k; + } + } else if (key == "rootNote") { + bool wasNull = false; + if (!r.expectNullOr(wasNull)) return false; + if (wasNull) { + s.rootNote.reset(); + } else { + int v = 0; + if (!r.parseInt(v)) return false; + // Valid MIDI note range: 0..127 inclusive (boundaries valid). + if (v < 0 || v > 127) return false; + s.rootNote = v; + } + } else if (key == "loop") { + bool wasNull = false; + if (!r.expectNullOr(wasNull)) return false; + if (wasNull) { + s.loop.reset(); + } else { + if (!r.consume('{')) return false; + LoopPoints lp; + do { + std::string lk; + if (!r.parseKey(lk)) return false; + std::int64_t lv = 0; + if (!r.parseInt64(lv)) return false; + if (lk == "start") lp.start = lv; + else if (lk == "end") lp.end = lv; + } while (r.consume(',')); + if (!r.consume('}')) return false; + // Invariant: 0 <= start <= end. start == end is a valid zero-length + // marker; a negative index or start > end is malformed, not silently + // clamped (mirrors the enum-range rejection above). + if (lp.start < 0 || lp.end < lp.start) return false; + s.loop = lp; + } + } else if (key == "levels") { + if (!r.consume('{')) return false; + do { + std::string lk; + if (!r.parseKey(lk)) return false; + double dv = 0.0; + if (!r.parseDouble(dv)) return false; + if (lk == "peakDb") s.levels.peakDb = dv; + else if (lk == "rmsDb") s.levels.rmsDb = dv; + else if (lk == "lufs") s.levels.lufs = dv; + } while (r.consume(',')); + if (!r.consume('}')) return false; + } else if (key == "clipped") { + if (!r.parseBool(s.clipped)) return false; + } else if (key == "tier") { + int v = 0; + if (!r.parseInt(v)) return false; + // Valid range: Scratch(0) .. Archive(1). + if (v < static_cast(Tier::Scratch) || + v > static_cast(Tier::Archive)) + return false; + s.tier = static_cast(v); + } else if (key == "contentHash") { + if (!r.parseString(s.contentHash)) return false; + } else if (key == "provenance") { + bool wasNull = false; + if (!r.expectNullOr(wasNull)) return false; + if (wasNull) { + s.provenance.reset(); + } else { + if (!r.consume('{')) return false; + Provenance p; + do { + std::string pk; + if (!r.parseKey(pk)) return false; + std::string pv; + if (!r.parseString(pv)) return false; + if (pk == "parentSampleId") p.parentSampleId = pv; + else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv; + } while (r.consume(',')); + if (!r.consume('}')) return false; + s.provenance = p; + } + } else if (key == "createdTimestamp") { + if (!r.parseInt64(s.createdTimestamp)) return false; + } else { + if (!r.skipValue()) return false; // forward-compat: ignore unknown + } + } while (r.consume(',')); + + return r.consume('}'); +} + +bool parseIndex(json::Reader& r, BankModel& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // empty object — vacuously an empty index + + std::vector parsed; + do { + std::string key; + if (!r.parseKey(key)) return false; + + if (key == "samples") { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { + do { + Sample s; + if (!parseSample(r, s)) return false; + parsed.push_back(std::move(s)); + } while (r.consume(',')); + if (!r.consume(']')) return false; + } + } else { + if (!r.skipValue()) return false; // version, or unknown keys + } + } while (r.consume(',')); + + if (!r.consume('}')) return false; + + // Trailing garbage after the root object is malformed. + r.skipWs(); + if (!r.eof()) return false; + + // Rebuild via add() so the same invariants (relative-path, dedup) that guard + // live inserts also guard deserialized data. Rejected/collapsed entries are + // dropped silently — a well-formed serialized index never triggers them. + for (auto& s : parsed) out.add(s); + return true; +} + +} // namespace + +std::optional BankModel::deserialize(const std::string& blob) { + BankModel idx; + json::Reader r(blob); + if (!parseIndex(r, idx)) return std::nullopt; + return idx; +} + +} // namespace reasampler::model diff --git a/src/bank_model.h b/src/core/model/bank_model.h similarity index 95% rename from src/bank_model.h rename to src/core/model/bank_model.h index 5ad8472..c7612bc 100644 --- a/src/bank_model.h +++ b/src/core/model/bank_model.h @@ -1,7 +1,7 @@ #pragma once // bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so // it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample -// bank: the `Sample` metadata struct and the `BankIndex` (add / remove / query / +// bank: the `Sample` metadata struct and the `BankModel` (add / remove / query / // tier moves / dedup-by-hash + JSON round-trip to/from std::string). // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO @@ -12,7 +12,7 @@ #include #include -namespace reasampler { +namespace reasampler::model { // How the source audio was obtained. Kept in the pure core (no REAPER coupling); // the capture backends (M3/M8) map their own notion onto these. @@ -81,7 +81,7 @@ struct LoopPoints { // The metadata record for one captured sample. The audio itself lives in a // project-relative file; `relativePath` is ALWAYS relative (enforced at the -// BankIndex::add boundary — see AddResult). +// BankModel::add boundary — see AddResult). struct Sample { std::string id; // stable unique id (assigned by the caller) std::string displayName; @@ -128,7 +128,7 @@ struct Sample { Tier tier = Tier::Scratch; - std::string contentHash; // dedup key (see BankIndex) + std::string contentHash; // dedup key (see BankModel) std::optional provenance; // set only when resampled @@ -141,7 +141,7 @@ struct Sample { bool isAutoPrunable() const { return tier == Tier::Scratch; } }; -// Outcome of BankIndex::add. `add` rejects rather than silently mutating: +// Outcome of BankModel::add. `add` rejects rather than silently mutating: // - RejectedAbsolutePath: relativePath was absolute (precision invariant). // - RejectedEmptyId: id was empty (the collection is keyed by id). // - Collapsed: content hash matched an existing entry; the existing @@ -157,7 +157,7 @@ enum class AddResult { // An ordered, id-keyed collection of Samples with content-hash dedup, tier // moves/filtering, and lossless JSON round-trip. Insertion order is preserved // so a future panel (M5) can iterate in stable order. -class BankIndex { +class BankModel { public: // Adds a sample. Enforces the relative-paths-only invariant and dedups by // content hash (an equal-hash add collapses onto the existing entry rather @@ -200,7 +200,7 @@ public: std::size_t size() const { return samples_.size(); } bool empty() const { return samples_.empty(); } - bool operator==(const BankIndex& o) const { return samples_ == o.samples_; } + bool operator==(const BankModel& o) const { return samples_ == o.samples_; } // Serializes the whole index to a JSON string (lossless round-trip). std::string serialize() const; @@ -208,10 +208,10 @@ public: // Parses a JSON string produced by serialize(). Returns std::nullopt on // malformed / truncated input (error signaled, never UB). On success the // returned index satisfies deserialize(serialize(x)) == x. - static std::optional deserialize(const std::string& json); + static std::optional deserialize(const std::string& json); private: std::vector samples_; // insertion order preserved }; -} // namespace reasampler +} // namespace reasampler::model diff --git a/src/core/model/owned_manifest.cpp b/src/core/model/owned_manifest.cpp new file mode 100644 index 0000000..e2ae900 --- /dev/null +++ b/src/core/model/owned_manifest.cpp @@ -0,0 +1,113 @@ +#include "core/model/owned_manifest.h" + +#include + +#include "core/json/json.h" + +// owned_manifest implementation. +// +// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model / +// bank_book / tail_control). The shape is a single object with one string array: +// +// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]} +// +// so a compact writer + a focused string-array domain parse is all it needs. + +namespace reasampler::model { + +// --------------------------------------------------------------------------- +// path invariant (mirror of bank_model's isAbsolutePath) +// --------------------------------------------------------------------------- + +namespace { + +// Any leading '/' or '\' (POSIX root / UNC), or a leading : (Windows drive, +// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to +// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant +// must match exactly (a path the index accepts must be recordable, and vice versa). +bool isAbsolutePath(const std::string& p) { + if (p.empty()) return false; + if (p[0] == '/' || p[0] == '\\') return true; + if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') + return true; + return false; +} + +} // namespace + +// --------------------------------------------------------------------------- +// mutation / query +// --------------------------------------------------------------------------- + +ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) { + if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath; + if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath; + if (contains(relativePath)) return ManifestAddResult::AlreadyPresent; + paths_.push_back(relativePath); + return ManifestAddResult::Added; +} + +bool OwnedFileManifest::contains(const std::string& relativePath) const { + for (const auto& p : paths_) + if (p == relativePath) return true; + return false; +} + +// --------------------------------------------------------------------------- +// JSON writer (shared core/json escape — byte-identical to the prior local one) +// --------------------------------------------------------------------------- + +std::string OwnedFileManifest::serialize() const { + std::string out = "{\"owned\":["; + for (std::size_t i = 0; i < paths_.size(); ++i) { + if (i) out += ','; + json::writeEscaped(out, paths_[i]); + } + out += "]}"; + return out; +} + +// --------------------------------------------------------------------------- +// JSON parser (string-array-only DOMAIN grammar over the shared core/json +// lexical layer). Tolerates unknown keys (forward-compat) and requires the +// "owned" value to be an array of strings. +// --------------------------------------------------------------------------- + +namespace { + +bool parseManifest(json::Reader& r, OwnedFileManifest& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // empty object -> empty manifest + for (;;) { + std::string key; + if (!r.parseKey(key)) return false; + if (key == "owned") { + std::vector paths; + if (!r.parseStringArray(paths)) return false; + for (auto& p : paths) { + // Feed through add() so the persisted invariants (dedup, reject + // empty/absolute) are re-asserted on load — a hand-edited or corrupt + // blob cannot smuggle an absolute or duplicate path into the manifest. + out.add(p); + } + } else { + if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys + } + r.skipWs(); + if (r.consume(',')) continue; + if (r.consume('}')) return true; + return false; + } +} + +} // namespace + +std::optional OwnedFileManifest::deserialize(const std::string& blob) { + OwnedFileManifest m; + json::Reader r(blob); + if (!parseManifest(r, m)) return std::nullopt; + return m; +} + +} // namespace reasampler::model diff --git a/src/owned_manifest.h b/src/core/model/owned_manifest.h similarity index 92% rename from src/owned_manifest.h rename to src/core/model/owned_manifest.h index fe3a3d7..b1bf611 100644 --- a/src/owned_manifest.h +++ b/src/core/model/owned_manifest.h @@ -3,7 +3,7 @@ // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // vendor/ includes. Standard library only. Unit-tested outside the DAW — the same -// "small pure type + JSON round-trip" pattern as wav_trim / tab_strip. +// "small pure type + JSON round-trip" pattern as wav_codec / tab_strip. // // -- What it is -------------------------------------------------------------- // @@ -23,17 +23,17 @@ // -- The relative-paths-only invariant --------------------------------------- // // A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath -// and the persisted BankIndex). add() rejects an absolute path rather than guess a +// 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 BankIndex::add's rejection). +// a guess that could point at the wrong file (mirror of BankModel::add's rejection). #include #include #include -namespace reasampler { +namespace reasampler::model { -// Outcome of an add(). Mirrors BankIndex::AddResult's honesty — the op reports what +// Outcome of an add(). Mirrors BankModel::AddResult's honesty — the op reports what // happened rather than silently mutating on a bad request. // - Added: the path was new and recorded. // - AlreadyPresent: the path was already in the manifest (dedup no-op). @@ -88,4 +88,4 @@ private: std::vector paths_; // insertion order; deduplicated }; -} // namespace reasampler +} // namespace reasampler::model diff --git a/src/provenance.cpp b/src/core/model/provenance.cpp similarity index 65% rename from src/provenance.cpp rename to src/core/model/provenance.cpp index 6054924..329b66a 100644 --- a/src/provenance.cpp +++ b/src/core/model/provenance.cpp @@ -1,7 +1,8 @@ -#include "provenance.h" +#include "core/model/provenance.h" #include -#include + +#include "core/wire/wire.h" // provenance implementation — pure, self-contained (no third-party lib, mirror of // bank_model's hand-rolled encoding discipline). @@ -24,7 +25,7 @@ // injection-proof on its own and can be embedded whole as one more length-prefixed // field of the fingerprint. -namespace reasampler { +namespace reasampler::model { bool CaptureRecipe::operator==(const CaptureRecipe& o) const { return scope == o.scope && sourceMode == o.sourceMode && @@ -38,12 +39,12 @@ namespace { constexpr const char* kMagic = "rsprov1"; -// Append one length-prefixed field: ':' -void putField(std::string& out, const std::string& field) { - out += std::to_string(field.size()); - out += ':'; - out += field; -} +// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full +// hardening (incl. the fixed fieldInt range check that closes the old strtol +// silent-narrowing TODO). Only the %.17g double rendering stays local — it is +// this writer's convention, shared with the bank model's JSON doubles. +using wire::putField; +using Cursor = wire::Cursor; std::string dblToStr(double v) { char buf[32]; @@ -51,91 +52,6 @@ std::string dblToStr(double v) { return buf; } -// Cursor over the encoded string. All reads are bounds-checked; any short read -// fails the whole parse (ok_ latches false). -class Cursor { -public: - explicit Cursor(const std::string& s) : s_(s) {} - - bool ok() const { return ok_; } - bool atEnd() const { return pos_ >= s_.size(); } - - // Reads one length-prefixed field into `out`. Fails on a missing ':', - // non-numeric length, or a length that runs past the end. - bool field(std::string& out) { - if (!ok_) return false; - std::size_t colon = s_.find(':', pos_); - if (colon == std::string::npos) return fail(); - // Parse the length digits [pos_, colon). - std::size_t len = 0; - if (colon == pos_) return fail(); // empty length token - for (std::size_t i = pos_; i < colon; ++i) { - char c = s_[i]; - if (c < '0' || c > '9') return fail(); - len = len * 10 + static_cast(c - '0'); - } - const std::size_t start = colon + 1; - if (start + len > s_.size()) return fail(); - out.assign(s_, start, len); - pos_ = start + len; - return true; - } - - bool fieldInt(int& out) { - std::string f; - if (!field(f)) return false; - return toInt(f, out); - } - - bool fieldSizeT(std::size_t& out) { - std::string f; - if (!field(f)) return false; - if (f.empty()) return fail(); - std::size_t v = 0; - for (char c : f) { - if (c < '0' || c > '9') return fail(); - v = v * 10 + static_cast(c - '0'); - } - out = v; - return true; - } - - bool fieldDouble(double& out) { - std::string f; - if (!field(f)) return false; - const char* b = f.c_str(); - char* end = nullptr; - double v = std::strtod(b, &end); - if (end != b + f.size()) return fail(); - out = v; - return true; - } - - // Consumes an exact literal at the cursor (the magic tag). Fails if absent. - bool literal(const char* lit) { - if (!ok_) return false; - const std::string l(lit); - if (s_.compare(pos_, l.size(), l) != 0) return fail(); - pos_ += l.size(); - return true; - } - -private: - bool fail() { ok_ = false; return false; } - static bool toInt(const std::string& f, int& out) { - const char* b = f.c_str(); - char* end = nullptr; - long v = std::strtol(b, &end, 10); - if (end != b + f.size() || f.empty()) return false; - out = static_cast(v); - return true; - } - - const std::string& s_; - std::size_t pos_ = 0; - bool ok_ = true; -}; - } // namespace std::string fxChainIdentity(const std::vector& entries) { @@ -196,6 +112,11 @@ std::optional parseFingerprint(const std::string& fingerprint) { std::size_t guidCount = 0; if (!c.fieldSizeT(guidCount)) return std::nullopt; + // Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least + // 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the + // reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge) + // into std::length_error / bad_alloc through the shell. + if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt; r.trackGuids.reserve(guidCount); for (std::size_t i = 0; i < guidCount; ++i) { std::string g; @@ -238,4 +159,4 @@ std::optional detectParent( return parent; } -} // namespace reasampler +} // namespace reasampler::model diff --git a/src/provenance.h b/src/core/model/provenance.h similarity index 98% rename from src/provenance.h rename to src/core/model/provenance.h index d8f5a66..14c4cfb 100644 --- a/src/provenance.h +++ b/src/core/model/provenance.h @@ -2,7 +2,7 @@ // provenance — the REAPER-free core behind Milestone 10 (re-capture from source). // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The shell (main.cpp / actions.cpp) +// vendor/ includes. Standard library only. The shell (main.cpp / the action families) // gathers the raw inputs from REAPER — the source item media-file names, the // source track FX-chain identity (names / GUIDs / enabled flags), the exact // capture range, scope, tail — and hands plain strings/values here. This module @@ -35,7 +35,7 @@ #include #include -namespace reasampler { +namespace reasampler::model { // Capture scope, mirrored from render_settings' CaptureScope but kept independent // here so the pure provenance module does not pull the whole render_settings graph @@ -146,4 +146,4 @@ std::optional detectParent( const std::vector& sourceItemFiles, const std::vector& bankFiles); -} // namespace reasampler +} // namespace reasampler::model diff --git a/src/core/model/slot_map.cpp b/src/core/model/slot_map.cpp new file mode 100644 index 0000000..2d60da7 --- /dev/null +++ b/src/core/model/slot_map.cpp @@ -0,0 +1,145 @@ +#include "core/model/slot_map.h" + +#include + +#include "core/json/json.h" + +// slot_map implementation (extracted from bank_book, Q-W1 T4-05). +// +// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one +// slot per id. Every mutator restores it; queries assume it. serialize rides the +// shared core/json emit helpers — the emitted fragment is byte-identical to the +// pre-extraction bank_book writer. + +namespace reasampler::model { + +void SlotMap::sortBySlot() { + std::stable_sort(entries_.begin(), entries_.end(), + [](const Entry& a, const Entry& b) { return a.slot < b.slot; }); +} + +int SlotMap::slotOf(const std::string& id) const { + for (const auto& e : entries_) + if (e.id == id) return e.slot; + return -1; +} + +std::string SlotMap::idAt(int slot) const { + for (const auto& e : entries_) + if (e.slot == slot) return e.id; + return {}; +} + +int SlotMap::maxSlot() const { + int m = -1; + for (const auto& e : entries_) + if (e.slot > m) m = e.slot; + return m; +} + +std::vector SlotMap::orderedIds() const { + // entries_ is sorted ascending by slot, so a straight walk is display order. + std::vector out; + out.reserve(entries_.size()); + for (const auto& e : entries_) out.push_back(e.id); + return out; +} + +void SlotMap::append(const std::string& id) { + if (id.empty()) return; + remove(id); // an existing id is re-appended, not left in place + entries_.push_back(Entry{id, maxSlot() + 1}); // next free slot after the last occupied + sortBySlot(); +} + +bool SlotMap::remove(const std::string& id) { + for (auto it = entries_.begin(); it != entries_.end(); ++it) { + if (it->id == id) { + entries_.erase(it); // leaves the slot empty — no re-pack + return true; + } + } + return false; +} + +bool SlotMap::reorder(const std::string& id, int targetSlot) { + if (slotOf(id) < 0) return false; // not mapped -> no mutation + if (targetSlot < 0) targetSlot = 0; + if (slotOf(id) == targetSlot) return false; // already there — true no-op + + // Detach the moving id first so the occupancy test below sees the post-move world. + remove(id); + + const bool occupied = !idAt(targetSlot).empty(); + if (occupied) { + // Insert-before-and-shift: every occupant at slot >= targetSlot shifts up by one, + // preserving relative order and interior gaps above the target. The moving id then + // takes targetSlot cleanly. + for (auto& e : entries_) + if (e.slot >= targetSlot) ++e.slot; + } + entries_.push_back(Entry{id, targetSlot}); + sortBySlot(); + return true; +} + +void SlotMap::resetDense(const std::vector& ids) { + entries_.clear(); + int slot = 0; + for (const auto& id : ids) { + if (id.empty()) continue; + if (slotOf(id) >= 0) continue; // skip a duplicate id (one slot per id) + entries_.push_back(Entry{id, slot++}); + } + // Already ascending by construction; no sort needed. +} + +void SlotMap::reconcile(const std::vector& liveIds) { + // Drop markers whose sample left the index. + entries_.erase( + std::remove_if(entries_.begin(), entries_.end(), + [&](const Entry& e) { + return std::find(liveIds.begin(), liveIds.end(), e.id) == + liveIds.end(); + }), + entries_.end()); + // Append live ids that have no mapping yet (out-of-band index growth), in liveIds + // order, each to the next free slot after the current frontier. + for (const auto& id : liveIds) + if (slotOf(id) < 0) append(id); + sortBySlot(); +} + +bool SlotMap::operator==(const SlotMap& o) const { + return entries_ == o.entries_; +} + +SlotMap SlotMap::fromEntries(const std::vector>& pairs) { + SlotMap m; + for (const auto& [id, slot] : pairs) { + if (id.empty() || slot < 0) continue; // drop malformed pair + if (m.slotOf(id) >= 0) continue; // duplicate id: first wins + if (!m.idAt(slot).empty()) continue; // slot taken: never double-occupy + m.entries_.push_back(Entry{id, slot}); + } + m.sortBySlot(); + return m; +} + +std::string SlotMap::serialize() const { + // Array of {id, slot} objects in ascending slot order (entries_ is kept sorted). + // json::Writer + numToStr are the same emit path the pre-extraction writer used, + // so the fragment is byte-identical. + std::string out; + out += '['; + for (std::size_t i = 0; i < entries_.size(); ++i) { + if (i) out += ','; + json::Writer e(out); + e.keyStr("id", entries_[i].id); + e.keyRaw("slot", json::numToStr(entries_[i].slot)); + } + out += ']'; + return out; +} + +} // namespace reasampler::model diff --git a/src/core/model/slot_map.h b/src/core/model/slot_map.h new file mode 100644 index 0000000..59f550a --- /dev/null +++ b/src/core/model/slot_map.h @@ -0,0 +1,106 @@ +#pragma once +// slot_map — the L7 gap-preserving display-position carrier for ONE bank (F2 settled: +// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a +// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps +// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty +// first row above an occupied second row). At most one id per slot (a slot is never +// double-occupied) and at most one slot per id (an id sits in exactly one place). +// +// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one +// sample into two banks may sit at different slots, so position is a per-bank display +// concern owned by the bank's membership. bank_model / Sample stay untouched. +// +// Extracted from bank_book (Q-W1, T4-05): a self-contained ordered-slot container +// with its own serialize, distinct from the multi-bank registry that carries it. +// Behavior covered by bank_book_tests (the round-trip + reorder/reconcile suites); +// a dedicated slot_map_tests target is a welcome follow-up, not a Q-W1 requirement. +// +// PURE: standard library + core/json (serialize) only. + +#include +#include +#include +#include + +namespace reasampler::model { + +class SlotMap { +public: + // The slot an id occupies, or -1 if the id is not mapped. O(N). + int slotOf(const std::string& id) const; + + // The id occupying `slot`, or "" if the slot is empty. O(N). + std::string idAt(int slot) const; + + // The highest occupied slot, or -1 when the map is empty. Defines the append + // frontier and (with trailing-empty trim) the content extent. + int maxSlot() const; + + // Ids in ASCENDING slot order (the deterministic display order). Empty slots + // produce no entry — the caller iterates occupants; sparse layout is a draw + // concern that reads slotOf/idAt, not this list. + std::vector orderedIds() const; + + // Places `id` at the next free slot after the last occupied one (append). If the + // id is already mapped it is first removed (leaving its old slot empty), then + // appended — an append never fills an earlier gap. No-op guard: empty id ignored. + void append(const std::string& id); + + // Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id + // keeps its position. Returns true if the id was mapped. + bool remove(const std::string& id); + + // Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics): + // * target slot EMPTY -> `id` moves there; its old slot is left empty. + // * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and + // every occupant at slot >= targetSlot (except `id` itself) shifts up by one, + // preserving their relative order and never colliding. Matches file-manager + // reorder. Interior gaps between shifted occupants are preserved as-is + // (shift is +1 on each occupant, so the gap structure above the target is kept). + // * negative targetSlot is clamped to 0. + // Returns false (no mutation) if `id` is not mapped. Deterministic. + bool reorder(const std::string& id, int targetSlot); + + // Rebuilds the map densely from `ids` in the given order (slot i = ids[i]), + // dropping any prior state. The migration path: a pre-L7 bank with no persisted + // slot data is seeded from its BankModel insertion order, densely packed (no gaps), + // so it is visually identical on first post-L7 load. Empty/duplicate ids skipped. + void resetDense(const std::vector& ids); + + // Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left + // the index) and appends any live id that has NO mapping yet (a sample the index + // gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps + // the map consistent with the bank's membership without a re-pack. Deterministic: + // orphan appends follow `liveIds` order. + void reconcile(const std::vector& liveIds); + + bool empty() const { return entries_.empty(); } + std::size_t size() const { return entries_.size(); } + + bool operator==(const SlotMap& o) const; + + // JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the + // bank envelope's "slots" member by BankBook::serialize; parsed back by its parser. + // Round-trips losslessly with the rest of the bank. + std::string serialize() const; + + // Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces + // the map invariants defensively against a hand-edited blob: a duplicate id keeps + // its FIRST occurrence; a slot already taken by a kept id drops the later pair + // (never double-occupies); an empty id or negative slot is dropped. The result is + // sorted ascending by slot. reconcile() against live membership runs afterward, so + // a lossy repair here degrades gracefully rather than corrupting lookup. + static SlotMap fromEntries(const std::vector>& pairs); + +private: + struct Entry { + std::string id; + int slot = 0; + bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; } + }; + std::vector entries_; // kept sorted ascending by slot (invariant) + + void sortBySlot(); +}; + +} // namespace reasampler::model diff --git a/src/prune_reconcile.cpp b/src/core/reclaim/prune_reconcile.cpp similarity index 97% rename from src/prune_reconcile.cpp rename to src/core/reclaim/prune_reconcile.cpp index d605aad..464c6b9 100644 --- a/src/prune_reconcile.cpp +++ b/src/core/reclaim/prune_reconcile.cpp @@ -1,4 +1,4 @@ -#include "prune_reconcile.h" +#include "core/reclaim/prune_reconcile.h" #include @@ -7,7 +7,7 @@ // keeping a path iff it is owned AND not referenced. Walking `present` (not owned) // gives the ∩-present clause for free and yields output in folder-enumeration order. -namespace reasampler { +namespace reasampler::reclaim { std::vector pruneOrphans(const std::vector& present, const std::vector& referenced, @@ -83,4 +83,4 @@ std::vector pruneDeletePlan(const std::vector& confirm return plan; } -} // namespace reasampler +} // namespace reasampler::reclaim diff --git a/src/prune_reconcile.h b/src/core/reclaim/prune_reconcile.h similarity index 99% rename from src/prune_reconcile.h rename to src/core/reclaim/prune_reconcile.h index a44660c..e744827 100644 --- a/src/prune_reconcile.h +++ b/src/core/reclaim/prune_reconcile.h @@ -32,7 +32,7 @@ // -- Path representation: EXACT-STRING match (safety-critical) ----------------- // // Every path in the model is a project-relative string compared VERBATIM: Sample. -// relativePath, OwnedFileManifest::contains (p == relativePath), and BankIndex all +// relativePath, OwnedFileManifest::contains (p == relativePath), and BankModel all // use raw std::string equality — no separator normalization, no case-folding, no // trailing-slash trimming. This core MATCHES that convention exactly: it compares // the raw strings the shell supplies. Feeding a consistent spelling across the three @@ -46,7 +46,7 @@ #include #include -namespace reasampler { +namespace reasampler::reclaim { // The dry-run prune result (Phase R, Wave 2 — report only, no deletion). The thin // prune shell (persist) fills this from pruneOrphans() + a per-file size stat and hands @@ -175,4 +175,4 @@ PruneReport buildPruneReport(const std::vector& orphans, std::vector pruneDeletePlan(const std::vector& confirmed, const std::vector& freshOrphans); -} // namespace reasampler +} // namespace reasampler::reclaim diff --git a/src/action_bar.cpp b/src/core/ui/action_bar.cpp similarity index 98% rename from src/action_bar.cpp rename to src/core/ui/action_bar.cpp index 3f7535c..36da30b 100644 --- a/src/action_bar.cpp +++ b/src/core/ui/action_bar.cpp @@ -1,10 +1,10 @@ // action_bar — pure implementation. See action_bar.h. NO REAPER / SWELL / LICE / vendor. -#include "action_bar.h" +#include "core/ui/action_bar.h" #include -namespace reasampler { +namespace reasampler::ui { namespace { @@ -151,4 +151,4 @@ int hitTestActionBar(int px, int py, const ActionBarRect& bar, return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/action_bar.h b/src/core/ui/action_bar.h similarity index 96% rename from src/action_bar.h rename to src/core/ui/action_bar.h index 8e48a7e..bb754c2 100644 --- a/src/action_bar.h +++ b/src/core/ui/action_bar.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's // TASK-GROUPED toolbars (Phase L, L2 + L4 + L6). L2's dock-panel layout redesign (DS-3: a // thorough layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact @@ -35,7 +36,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // The task cluster a button belongs to (the L2 "group by task" mandate). The order here is // NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum @@ -58,16 +59,7 @@ enum class ActionCluster { // The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the // top-left corner; width/height are the bar extents. The panel reserves this as a fixed-height // band (its own judgment where — above the tail footer, below the split body). -struct ActionBarRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const ActionBarRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +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 // position in the caller's flat action list (the caller supplies actions in cluster order, so @@ -156,4 +148,4 @@ std::vector computeBarSlots(const ActionBarRect& bar, int hitTestActionBar(int px, int py, const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/bank_grid.cpp b/src/core/ui/bank_grid.cpp similarity index 98% rename from src/bank_grid.cpp rename to src/core/ui/bank_grid.cpp index 1cc3a53..2522527 100644 --- a/src/bank_grid.cpp +++ b/src/core/ui/bank_grid.cpp @@ -1,11 +1,11 @@ // bank_grid — pure implementation. See bank_grid.h. NO REAPER / SWELL / vendor. -#include "bank_grid.h" +#include "core/ui/bank_grid.h" #include #include -namespace reasampler { +namespace reasampler::ui { namespace { @@ -224,4 +224,4 @@ float compressAmplitudeForDisplay(float linear) { return linear < 0.0f ? -clamped : clamped; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/bank_grid.h b/src/core/ui/bank_grid.h similarity index 95% rename from src/bank_grid.h rename to src/core/ui/bank_grid.h index 3616e96..c6845a6 100644 --- a/src/bank_grid.h +++ b/src/core/ui/bank_grid.h @@ -1,6 +1,7 @@ #pragma once +#include "core/ui/rect.h" // bank_grid — the REAPER-free layout math and cache-key logic behind the docked -// bank_panel (M5, Wave A). The panel shell (bank_panel.cpp) owns the SWELL window, +// bank_panel (M5, Wave A). The panel shell (shell/panel/) owns the SWELL window, // LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What // is NOT DAW-bound — how N sample cells tile a panel of a given pixel size, and // the key that identifies a cached thumbnail — lives here so it is unit-tested @@ -14,22 +15,13 @@ #include #include -namespace reasampler { +namespace reasampler::ui { // A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE // convention). (x, y) is the top-left corner; width/height are the cell extents. // These are the draw bounds for one sample's thumbnail; the panel draws its // waveform envelope inside this rect (minus any internal padding it applies). -struct CellRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const CellRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +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 // TARGET cell size; the layout fits as many whole columns as the panel width @@ -88,7 +80,7 @@ std::string thumbnailKeyString(const ThumbnailKey& key); // --- Interaction (M5 Wave B): hit-test, selection, keyboard nav -------------- // // All REAPER-free so the panel's interaction LOGIC is unit-tested outside the DAW, -// exactly as the layout math is. The panel shell (bank_panel.cpp) reads live mouse +// 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. @@ -183,4 +175,4 @@ constexpr float kDisplayFloorDb = -60.0f; // (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f. float compressAmplitudeForDisplay(float linear); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/card_drag.cpp b/src/core/ui/card_drag.cpp similarity index 97% rename from src/card_drag.cpp rename to src/core/ui/card_drag.cpp index afe5971..6f14fd9 100644 --- a/src/card_drag.cpp +++ b/src/core/ui/card_drag.cpp @@ -1,8 +1,8 @@ // card_drag — pure implementation. See card_drag.h. NO REAPER / SWELL / LICE / OS / vendor. -#include "card_drag.h" +#include "core/ui/card_drag.h" -namespace reasampler { +namespace reasampler::ui { namespace { @@ -93,4 +93,4 @@ int hitTestSlot(int px, int py, const std::vector& rects) { return -1; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/card_drag.h b/src/core/ui/card_drag.h similarity index 97% rename from src/card_drag.h rename to src/core/ui/card_drag.h index ef0681b..1e2b379 100644 --- a/src/card_drag.h +++ b/src/core/ui/card_drag.h @@ -2,7 +2,7 @@ // card_drag — the REAPER-free decision logic behind the L7 in-grid reorder drag. Three // pure concerns live here so they are unit-tested outside the DAW (CLAUDE.md §load-bearing // split); the SWELL wiring, SetCursor call, cursor resources, and drop-target draw stay in -// the shell (bank_panel.cpp). Mirror of drag_out::decideGesture. +// 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 // strict precedence the shell evaluates on every mouse-move / at drop: @@ -30,10 +30,10 @@ #include -#include "bank_grid.h" // CellRect -#include "drag_out.h" // PanelClientRect, DragState +#include "core/ui/bank_grid.h" // CellRect +#include "core/ui/drag_out.h" // PanelClientRect, DragState -namespace reasampler { +namespace reasampler::ui { // Which drop region the pointer currently sits over WITHIN the client rect. The shell // classifies the live pointer against its own region geometry (tab strip / other bank @@ -144,4 +144,4 @@ std::vector computeSlotRectsForDrop(int maxSlot, int panelWidth, // callers reason in model slots. int hitTestSlot(int px, int py, const std::vector& rects); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/card_meta.cpp b/src/core/ui/card_meta.cpp similarity index 96% rename from src/card_meta.cpp rename to src/core/ui/card_meta.cpp index b2e44e9..8987dbf 100644 --- a/src/card_meta.cpp +++ b/src/core/ui/card_meta.cpp @@ -1,11 +1,11 @@ // card_meta — pure implementation. See card_meta.h. NO REAPER / SWELL / LICE / vendor. -#include "card_meta.h" +#include "core/ui/card_meta.h" #include #include -namespace reasampler { +namespace reasampler::ui { std::string formatBarsBeats(const MusicalLength& m) { // No derivable musical read-out without a positive tempo AND a stamped meter. @@ -61,4 +61,4 @@ std::string formatSecondsMs(double lengthSeconds) { return buf; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/card_meta.h b/src/core/ui/card_meta.h similarity index 98% rename from src/card_meta.h rename to src/core/ui/card_meta.h index 1c97fad..a561cd3 100644 --- a/src/card_meta.h +++ b/src/core/ui/card_meta.h @@ -11,7 +11,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // The musical length inputs, taken straight off a Sample (L7 F1 capture-time stamp): // lengthSeconds — captured length in wall-clock seconds (>= 0). @@ -52,4 +52,4 @@ std::string formatBarsBeats(const MusicalLength& m); // * negative length is clamped to "0.000" (a length is never negative; defensive). std::string formatSecondsMs(double lengthSeconds); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/component_geometry.cpp b/src/core/ui/component_geometry.cpp similarity index 97% rename from src/component_geometry.cpp rename to src/core/ui/component_geometry.cpp index 65c1d0e..3e9d521 100644 --- a/src/component_geometry.cpp +++ b/src/core/ui/component_geometry.cpp @@ -1,9 +1,9 @@ // component_geometry — pure implementation. See component_geometry.h. NO REAPER / SWELL / // LICE / vendor. Standard library only. -#include "component_geometry.h" +#include "core/ui/component_geometry.h" -namespace reasampler { +namespace reasampler::ui { bool hitTestBox(int px, int py, const KitBox& box) { if (box.empty()) return false; @@ -105,4 +105,4 @@ int waveformColumnCount(const KitBox& box) { return w > 0 ? w : 0; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/component_geometry.h b/src/core/ui/component_geometry.h similarity index 95% rename from src/component_geometry.h rename to src/core/ui/component_geometry.h index ff8e30f..7455c47 100644 --- a/src/component_geometry.h +++ b/src/core/ui/component_geometry.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // component_geometry — the REAPER-free, LICE-free geometry + hit-test math for the shared // drawing kit's generic components (Phase L, L1): a button box, a slider's track/handle, // and a list row. These are the kit-level primitives that DON'T already have a pure owner: @@ -20,23 +21,12 @@ // 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 { +namespace reasampler::ui { // A generic pixel box, top-left origin (SWELL/LICE convention). Shared shape for the kit // component rects below. A zero-area box (empty()) means "nothing to draw / hit" — the // same graceful-suppression convention prune_button uses. -struct KitBox { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool empty() const { return width <= 0 || height <= 0; } - - bool operator==(const KitBox& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +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) — // the same discipline as every sibling hit-test so draw and hit-test never double-claim a @@ -136,4 +126,4 @@ int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCou // identical whether bins == columns or bins == k*columns) and wastes memory and CPU. int waveformColumnCount(const KitBox& box); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/drag_out.cpp b/src/core/ui/drag_out.cpp similarity index 95% rename from src/drag_out.cpp rename to src/core/ui/drag_out.cpp index db58c65..06a10e3 100644 --- a/src/drag_out.cpp +++ b/src/core/ui/drag_out.cpp @@ -1,10 +1,10 @@ // drag_out — pure implementation. See drag_out.h. NO REAPER / SWELL / OS / vendor. -#include "drag_out.h" +#include "core/ui/drag_out.h" #include -namespace reasampler { +namespace reasampler::ui { namespace { @@ -51,4 +51,4 @@ PathList assemblePathList(const std::vector& resolved) { return out; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/drag_out.h b/src/core/ui/drag_out.h similarity index 96% rename from src/drag_out.h rename to src/core/ui/drag_out.h index eacf47f..aee7a2e 100644 --- a/src/drag_out.h +++ b/src/core/ui/drag_out.h @@ -1,8 +1,9 @@ #pragma once +#include "core/ui/rect.h" // drag_out — the REAPER-free / OS-free decision logic behind the bank_panel's native OS // drag-out (Milestone 11, the final polish point). Two pure concerns live here so they are // unit-tested outside the DAW (CLAUDE.md §load-bearing split); the OLE / SWELL initiation -// and the bank_panel gesture hook stay in the shell (drag_out_win.* + bank_panel.cpp). +// 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 // already runs an INTERNAL drag: press a selected cell, cross a threshold, drop onto @@ -29,7 +30,7 @@ #include #include -namespace reasampler { +namespace reasampler::ui { // --- Gesture boundary --------------------------------------------------------- @@ -37,16 +38,7 @@ namespace reasampler { // LICE convention). width/height are the extents; a point (px, py) is INSIDE when // x <= px < x + width and y <= py < y + height (half-open, matching the panel's other // hit-tests so the edge is claimed consistently). -struct PanelClientRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const PanelClientRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +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: // whether a drag is currently active (threshold crossed) and whether the armed payload @@ -137,4 +129,4 @@ struct PathList { // case-insensitive dedup on Windows — the pure layer does not guess a platform rule). PathList assemblePathList(const std::vector& resolved); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/footer_bar.cpp b/src/core/ui/footer_bar.cpp similarity index 96% rename from src/footer_bar.cpp rename to src/core/ui/footer_bar.cpp index b1e6824..e4b683f 100644 --- a/src/footer_bar.cpp +++ b/src/core/ui/footer_bar.cpp @@ -1,8 +1,8 @@ // footer_bar — pure implementation. See footer_bar.h. NO REAPER / SWELL / LICE / vendor. -#include "footer_bar.h" +#include "core/ui/footer_bar.h" -namespace reasampler { +namespace reasampler::ui { namespace { @@ -66,4 +66,4 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) { return FooterHit::None; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/footer_bar.h b/src/core/ui/footer_bar.h similarity index 87% rename from src/footer_bar.h rename to src/core/ui/footer_bar.h index bc24342..fb608ca 100644 --- a/src/footer_bar.h +++ b/src/core/ui/footer_bar.h @@ -1,8 +1,9 @@ #pragma once +#include "core/ui/rect.h" // footer_bar — the REAPER-free, LICE-free layout + hit-test math for the bank_panel's L4 // footer LEFT group: the narrowed [Arrange|Design] mode toggle, its compact per-mode count // label, and the Tail button, laid out left-to-right at the footer's left. The panel shell -// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the click dispatch (cycle tail / +// (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. @@ -24,32 +25,21 @@ // 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. // -// NAME NOTE (brief §name-collision): ButtonRect / FooterRect / SegmentRect / ActionBarRect / -// KitBox / KitButtonBox are already owned in this namespace; grep-checked FooterBar* / FooterHit -// FREE before minting. FooterRect (prune_button) is the input strip type and is REUSED here -// (same concept — the footer strip); the new output/spec/hit types carry the FooterBar* prefix. +// 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 "prune_button.h" // FooterRect — the footer strip input type (shared, not re-minted) +#include "core/ui/prune_button.h" // FooterRect — the footer strip input type (shared, not re-minted) -namespace reasampler { +namespace reasampler::ui { // One placed affordance's pixel rectangle within the footer, top-left origin. A zero-area rect // (empty()) means "not placed" (the footer was too narrow to host it after the ones before it), // so the shell draws/hit-tests nothing for it — graceful degradation, mirroring prune_button. -struct FooterBarRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool empty() const { return width <= 0 || height <= 0; } - - bool operator==(const FooterBarRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +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 // button box, in left-to-right order. Any box may be empty (suppressed) when the footer is @@ -113,4 +103,4 @@ FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& // 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); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/mode_enable.cpp b/src/core/ui/mode_enable.cpp similarity index 77% rename from src/mode_enable.cpp rename to src/core/ui/mode_enable.cpp index 84df3ce..0f31bd8 100644 --- a/src/mode_enable.cpp +++ b/src/core/ui/mode_enable.cpp @@ -1,10 +1,10 @@ // mode_enable — pure implementation. See mode_enable.h. NO REAPER / SWELL / LICE / vendor. -#include "mode_enable.h" +#include "core/ui/mode_enable.h" -#include "view_mode_model.h" // kArrangeModeId / kDesignModeId — the ONE home for the mode ids +#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ONE home for the mode ids -namespace reasampler { +namespace reasampler::ui { bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) { // The target's own mode id, so the rule is a single "target != active" compare. @@ -18,4 +18,4 @@ bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) { return activeModeId != targetId; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/mode_enable.h b/src/core/ui/mode_enable.h similarity index 97% rename from src/mode_enable.h rename to src/core/ui/mode_enable.h index 8391a46..0132f5e 100644 --- a/src/mode_enable.h +++ b/src/core/ui/mode_enable.h @@ -17,7 +17,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // A tag button's TARGET mode — the mode it sends the selection to when fired. Arrange = the // untagged default (returning the selection to Arrange), Design = tagged into the Design mode. @@ -36,4 +36,4 @@ enum class TagTarget { // 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); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/overflow_menu.cpp b/src/core/ui/overflow_menu.cpp similarity index 94% rename from src/overflow_menu.cpp rename to src/core/ui/overflow_menu.cpp index 04b1d3b..e6dd9b6 100644 --- a/src/overflow_menu.cpp +++ b/src/core/ui/overflow_menu.cpp @@ -1,8 +1,8 @@ // overflow_menu — pure implementation. See overflow_menu.h. NO REAPER / SWELL / LICE / vendor. -#include "overflow_menu.h" +#include "core/ui/overflow_menu.h" -namespace reasampler { +namespace reasampler::ui { int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) { if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return 0; @@ -39,4 +39,4 @@ bool hitTestMenuButton(int px, int py, const MenuButtonRect& button) { py >= button.y && py < button.y + button.height; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/overflow_menu.h b/src/core/ui/overflow_menu.h similarity index 87% rename from src/overflow_menu.h rename to src/core/ui/overflow_menu.h index 88764fc..31972ee 100644 --- a/src/overflow_menu.h +++ b/src/core/ui/overflow_menu.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // overflow_menu — the REAPER-free layout math behind the bank_panel TOP toolbar's "⋯ / More" // overflow-menu button (Phase L, L5, refinement 1). The rare capture variants (Batch Items / // Batch Razor / Capture RT) move OFF the always-visible top bar into a popup opened by a small @@ -15,39 +16,19 @@ // 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 { +namespace reasampler::ui { // The toolbar band the button is drawn into, top-left origin (SWELL/LICE convention). The // shell derives this from topToolbarRect(). A distinct type from action_bar::ActionBarRect so // this module stands alone (same shape; deliberate — the two modules are not coupled). -struct MenuBarRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const MenuBarRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +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 // (width <= 0 or height <= 0) means "no button" — the band is degenerate or too narrow to // place the button clear of its left inset; the caller must not draw or hit-test it. The // three variants stay reachable via their bindable commands, so a suppressed button is // graceful, not a lost affordance. -struct MenuButtonRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool empty() const { return width <= 0 || height <= 0; } - - bool operator==(const MenuButtonRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +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 // metrics; the shell passes its own so draw and hit-test share one source of truth. @@ -84,4 +65,4 @@ MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& s // 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); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/prune_button.cpp b/src/core/ui/prune_button.cpp similarity index 94% rename from src/prune_button.cpp rename to src/core/ui/prune_button.cpp index 424c327..71db3a2 100644 --- a/src/prune_button.cpp +++ b/src/core/ui/prune_button.cpp @@ -1,11 +1,11 @@ -#include "prune_button.h" +#include "core/ui/prune_button.h" // prune_button implementation — right-anchored button placement in the footer strip, // with a left-collision suppression rule. Trivially auditable arithmetic; the safety // property (a suppressed/empty button never claims a click) is a pure predicate tested // outside the DAW. -namespace reasampler { +namespace reasampler::ui { ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec) { if (footer.width <= 0 || footer.height <= 0) return ButtonRect{}; // degenerate footer @@ -36,4 +36,4 @@ bool hitTestPruneButton(int px, int py, const ButtonRect& button) { py >= button.y && py < button.y + button.height; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/prune_button.h b/src/core/ui/prune_button.h similarity index 87% rename from src/prune_button.h rename to src/core/ui/prune_button.h index ff0e414..cf7f853 100644 --- a/src/prune_button.h +++ b/src/core/ui/prune_button.h @@ -1,8 +1,9 @@ #pragma once +#include "core/ui/rect.h" // prune_button — the REAPER-free layout math behind the bank_panel's Prune button // (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's // tail-footer strip that fires the "Prune bank folder" command. The panel shell -// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the Main_OnCommand +// (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 @@ -24,44 +25,24 @@ // is always reachable via its bindable command, so a hidden button is a graceful // degradation, not a lost affordance. -namespace reasampler { +namespace reasampler::ui { // The footer strip the button is drawn into, top-left origin (SWELL/LICE // convention). (x, y) is the top-left corner; width/height are the strip extents. // bank_panel derives this from panelFooter() and passes it here. -struct FooterRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const FooterRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +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 // (width <= 0 or height <= 0) means "no button" — the footer is too narrow to place // it, or the footer itself is degenerate; the caller must not draw or hit-test it. -struct ButtonRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool empty() const { return width <= 0 || height <= 0; } - - bool operator==(const ButtonRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using ButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // Layout inputs for the prune button, in pixels. Defaults match the bank_panel footer // metrics; the shell passes its own so draw and hit-test share one source of truth. // * buttonWidth — the button's fixed width. // * rightInset — gap from the footer's right edge to the button's right edge (the // button sits left of this inset, clearing the right-aligned version -// readout). COUPLED TO drawFooter (bank_panel.cpp): the version readout +// 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 @@ -98,4 +79,4 @@ ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& s // so a suppressed button cannot be accidentally clicked. bool hitTestPruneButton(int px, int py, const ButtonRect& button); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/core/ui/rect.h b/src/core/ui/rect.h new file mode 100644 index 0000000..92e64bd --- /dev/null +++ b/src/core/ui/rect.h @@ -0,0 +1,56 @@ +#pragma once +// rect.h — the ONE concrete pixel rectangle (Q-W1, T2-05 ≡ T4-21). +// +// Before Q-W1 the codebase carried 12+ byte-identical {x, y, width, height} structs +// (ButtonRect / FooterRect / CellRect / KitBox / ...) plus a second LTRB grammar on +// the VST side (editor_geometry's left/top/right/bottom Rect). This is the single +// owner: one CONCRETE type (deliberately NOT a template — the role types differed in +// name only, so a template would model nothing), with per-role aliases at the old +// definition sites so call sites keep their semantic names +// (`using ButtonRect = ui::Rect;`). +// +// Grammar: XYWH storage (the majority grammar — every extension role struct), with +// right()/bottom() accessors and an ltrb() factory so the former LTRB call sites +// convert mechanically. Half-open on both axes: a rect covers +// [x, x+width) × [y, y+height) — the same convention LICE/SWELL RECTs use, and the +// one every hitTest* in the codebase already implements. +// +// PURE MODULE: standard library only. Header-only; behavior is covered by the role +// modules' own test executables (prune_button / footer_bar / bank_grid / ... and the +// instrument-ui suites), which exercise every alias against these semantics. + +namespace reasampler::ui { + +struct Rect { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + + // Exclusive edges (half-open convention). + int right() const { return x + width; } + int bottom() const { return y + height; } + + // A zero-or-negative-area rect means "not placed / suppressed": the caller must + // not draw or hit-test it (the shared graceful-degradation contract). + bool empty() const { return width <= 0 || height <= 0; } + + // The former LTRB grammar's constructor (editor_geometry and friends): edges in, + // extents stored. right/bottom exclusive, matching right()/bottom(). + static Rect ltrb(int left, int top, int right, int bottom) { + return Rect{left, top, right - left, bottom - top}; + } + + bool operator==(const Rect& o) const { + return x == o.x && y == o.y && width == o.width && height == o.height; + } + bool operator!=(const Rect& o) const { return !(*this == o); } +}; + +// True iff (px, py) falls inside r under the half-open convention. An empty rect +// contains nothing, so a suppressed affordance can never claim a click. +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; +} + +} // namespace reasampler::ui diff --git a/src/tab_strip.cpp b/src/core/ui/tab_strip.cpp similarity index 98% rename from src/tab_strip.cpp rename to src/core/ui/tab_strip.cpp index 68c57a2..c0d4db7 100644 --- a/src/tab_strip.cpp +++ b/src/core/ui/tab_strip.cpp @@ -1,10 +1,10 @@ // tab_strip — pure implementation. See tab_strip.h. NO REAPER / SWELL / vendor. -#include "tab_strip.h" +#include "core/ui/tab_strip.h" #include -namespace reasampler { +namespace reasampler::ui { TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset) { @@ -109,4 +109,4 @@ TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, return miss; // track dead space (no tab under the point) } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/tab_strip.h b/src/core/ui/tab_strip.h similarity index 94% rename from src/tab_strip.h rename to src/core/ui/tab_strip.h index 7932070..ea11b38 100644 --- a/src/tab_strip.h +++ b/src/core/ui/tab_strip.h @@ -1,4 +1,5 @@ #pragma once +#include "core/ui/rect.h" // tab_strip — the REAPER-free layout + hit-test math behind the bank_panel's // named-banks tab strip (Phase B, Wave 4 — B4). The named-banks region of the // vertical-split bank window is a LICE-drawn tab strip (one tab per named bank, @@ -7,7 +8,7 @@ // 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 (bank_panel.cpp) owns 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. // @@ -16,21 +17,12 @@ #include -namespace reasampler { +namespace reasampler::ui { // The strip the tabs are drawn into, top-left origin (SWELL/LICE convention). // (x, y) is the top-left corner; width/height are the strip extents. The panel // reserves this as a fixed-height band at the top of the named-banks region. -struct TabStripRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const TabStripRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +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 // so the strip reads as a uniform segmented control and overflow math stays simple — @@ -132,4 +124,4 @@ struct TabHit { TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/theme.cpp b/src/core/ui/theme.cpp similarity index 99% rename from src/theme.cpp rename to src/core/ui/theme.cpp index 5e4bad3..e80ee62 100644 --- a/src/theme.cpp +++ b/src/core/ui/theme.cpp @@ -1,11 +1,11 @@ // theme — pure implementation. See theme.h. NO REAPER / SWELL / LICE / vendor. -#include "theme.h" +#include "core/ui/theme.h" #include #include -namespace reasampler { +namespace reasampler::ui { namespace { @@ -190,4 +190,4 @@ double textFloor(TextClass cls) { return cls == TextClass::Body ? 4.5 : 3.0; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/theme.h b/src/core/ui/theme.h similarity index 99% rename from src/theme.h rename to src/core/ui/theme.h index 2137113..0d6f5ba 100644 --- a/src/theme.h +++ b/src/core/ui/theme.h @@ -24,7 +24,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // A straight 8-bit-per-channel RGBA color, LICE-free. The draw shell converts this to a // LICE_pixel via LICE_RGBA at the boundary (draw_kit); nothing here depends on LICE's @@ -115,4 +115,4 @@ double contrastRatio(const KitColor& a, const KitColor& b); // pair the kit actually draws. double textFloor(TextClass cls); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/tooltip.cpp b/src/core/ui/tooltip.cpp similarity index 95% rename from src/tooltip.cpp rename to src/core/ui/tooltip.cpp index b9d902a..5f09110 100644 --- a/src/tooltip.cpp +++ b/src/core/ui/tooltip.cpp @@ -1,8 +1,8 @@ // tooltip — pure implementation. See tooltip.h. NO REAPER / SWELL / LICE / vendor. -#include "tooltip.h" +#include "core/ui/tooltip.h" -namespace reasampler { +namespace reasampler::ui { std::string stripActionPrefix(const std::string& fullName, const std::string& prefix) { if (prefix.empty()) return fullName; @@ -50,4 +50,4 @@ TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, return box; } -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/tooltip.h b/src/core/ui/tooltip.h similarity index 98% rename from src/tooltip.h rename to src/core/ui/tooltip.h index 38be963..1682597 100644 --- a/src/tooltip.h +++ b/src/core/ui/tooltip.h @@ -13,7 +13,7 @@ #include -namespace reasampler { +namespace reasampler::ui { // The tooltip's box (top-left origin, SWELL/LICE convention). A zero-area rect means "do not // draw" (degenerate inputs); the caller checks empty() before drawing. @@ -59,4 +59,4 @@ TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, int textW, int textH, int clientW, int clientH, const TooltipSpec& spec); -} // namespace reasampler +} // namespace reasampler::ui diff --git a/src/core/util/clamp01.h b/src/core/util/clamp01.h new file mode 100644 index 0000000..0a141a0 --- /dev/null +++ b/src/core/util/clamp01.h @@ -0,0 +1,14 @@ +#pragma once +// clamp01 — the ONE unit-interval clamp (Q-W1, T4-24). Replaces the per-module +// static copies (master_gain / param_slider / envelope_overlay / reasampler_editor). +// Deliberately the ternary form: comparisons with NaN are false, so a NaN input +// passes through unchanged rather than silently collapsing to a bound — the +// behavior of the majority of the retired copies. +// +// PURE: standard library only (not even that). + +namespace reasampler::util { + +constexpr double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } + +} // namespace reasampler::util diff --git a/src/core/util/file_bytes.cpp b/src/core/util/file_bytes.cpp new file mode 100644 index 0000000..b66608c --- /dev/null +++ b/src/core/util/file_bytes.cpp @@ -0,0 +1,21 @@ +// core/util/file_bytes implementation — see file_bytes.h. + +#include "core/util/file_bytes.h" + +#include + +namespace reasampler::util { + +std::vector readFileBytes(const std::string& path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return {}; + const std::streamoff size = f.tellg(); + if (size <= 0) return {}; + std::vector bytes(static_cast(size)); + f.seekg(0); + f.read(reinterpret_cast(bytes.data()), size); + if (!f) return {}; + return bytes; +} + +} // namespace reasampler::util diff --git a/src/core/util/file_bytes.h b/src/core/util/file_bytes.h new file mode 100644 index 0000000..5214850 --- /dev/null +++ b/src/core/util/file_bytes.h @@ -0,0 +1,19 @@ +// core/util/file_bytes — the ONE whole-file byte loader (Q-W1; audit T2-03). +// Pure standard library — NO REAPER, NO SWELL, NO VST3 — but it does blocking +// file I/O: NEVER call it on the audio thread (off-thread only, the same rule +// every prior hand-rolled copy carried). Linked by both artifacts. + +#pragma once + +#include +#include +#include + +namespace reasampler::util { + +// Reads the whole file at `path` into a byte buffer. Empty on ANY failure — +// unopenable, empty file, or short read — so the caller has exactly one +// "nothing to work with" branch. +std::vector readFileBytes(const std::string& path); + +} // namespace reasampler::util diff --git a/src/app_version.cpp b/src/core/version/app_version.cpp similarity index 98% rename from src/app_version.cpp rename to src/core/version/app_version.cpp index 247c24a..568f02d 100644 --- a/src/app_version.cpp +++ b/src/core/version/app_version.cpp @@ -6,13 +6,13 @@ // parse/compare/classify logic (V1). No #ifdef forks leak beyond this file; the shells // consume the accessors below, so channel identity is one auditable definition. -#include "app_version.h" +#include "core/version/app_version.h" #include #include "version_generated.h" // REASAMPLER_VERSION_STRING + REASAMPLER_CHANNEL_IS_BETA -namespace reasampler { +namespace reasampler::version { namespace { // The one channel predicate every derivation below branches on — the single point the @@ -166,4 +166,4 @@ WritingVersion classifyWritingVersion(const std::string& rawStamp) { return wv; } -} // namespace reasampler +} // namespace reasampler::version diff --git a/src/app_version.h b/src/core/version/app_version.h similarity index 99% rename from src/app_version.h rename to src/core/version/app_version.h index f940e6f..8ef85bf 100644 --- a/src/app_version.h +++ b/src/core/version/app_version.h @@ -36,7 +36,7 @@ #include #include -namespace reasampler { +namespace reasampler::version { // --- Channel identity (V4, beta-in-isolation) --------------------------------------- // @@ -140,7 +140,7 @@ const std::string& vstPluginName(); // --- Channel-qualified action id / name builders ------------------------------------ // -// The two composition helpers every action-registering shell (main.cpp, actions.cpp) +// The two composition helpers every action-registering shell (main.cpp, action_registry) // funnels through, so command ids and Actions-list names are qualified IDENTICALLY on // every channel from ONE definition — no shell re-implements the concatenation. // @@ -199,4 +199,4 @@ struct WritingVersion { // with the raw ext-state read and never has to reason about the cases itself. WritingVersion classifyWritingVersion(const std::string& rawStamp); -} // namespace reasampler +} // namespace reasampler::version diff --git a/src/version_generated.h.in b/src/core/version/version_generated.h.in similarity index 100% rename from src/version_generated.h.in rename to src/core/version/version_generated.h.in diff --git a/src/guid_diff.cpp b/src/core/view/guid_diff.cpp similarity index 94% rename from src/guid_diff.cpp rename to src/core/view/guid_diff.cpp index 9a8253c..303f94e 100644 --- a/src/guid_diff.cpp +++ b/src/core/view/guid_diff.cpp @@ -1,11 +1,11 @@ // guid_diff implementation — pure set arithmetic for new-content detection. See // guid_diff.h. No REAPER, no SWELL — std only. -#include "guid_diff.h" +#include "core/view/guid_diff.h" #include -namespace reasampler { +namespace reasampler::view { std::vector newGuids(const std::set& previous, const std::set& current) { @@ -41,4 +41,4 @@ void GuidBaseline::reset() { primed_ = false; // next observe() re-baselines (first-poll guard re-armed) } -} // namespace reasampler +} // namespace reasampler::view diff --git a/src/guid_diff.h b/src/core/view/guid_diff.h similarity index 98% rename from src/guid_diff.h rename to src/core/view/guid_diff.h index fc4d4c8..5026ec2 100644 --- a/src/guid_diff.h +++ b/src/core/view/guid_diff.h @@ -17,7 +17,7 @@ #include #include -namespace reasampler { +namespace reasampler::view { // The GUIDs present in `current` but absent from `previous` — i.e. new since the // previous poll. Order is the set's ascending order (deterministic; the caller does @@ -59,4 +59,4 @@ private: bool primed_ = false; // false ⇒ next observe() sets the baseline }; -} // namespace reasampler +} // namespace reasampler::view diff --git a/src/lane_keys.cpp b/src/core/view/lane_keys.cpp similarity index 95% rename from src/lane_keys.cpp rename to src/core/view/lane_keys.cpp index 41dcf41..b003925 100644 --- a/src/lane_keys.cpp +++ b/src/core/view/lane_keys.cpp @@ -1,10 +1,10 @@ // lane_keys implementation — pure string convention, no REAPER. See lane_keys.h. -#include "lane_keys.h" +#include "core/view/lane_keys.h" #include -namespace reasampler { +namespace reasampler::view { namespace { // Does `s` start with the managed-lane prefix? @@ -48,4 +48,4 @@ bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName) { return !hasManagedPrefix(laneName); } -} // namespace reasampler +} // namespace reasampler::view diff --git a/src/lane_keys.h b/src/core/view/lane_keys.h similarity index 98% rename from src/lane_keys.h rename to src/core/view/lane_keys.h index a2a6446..36cb133 100644 --- a/src/lane_keys.h +++ b/src/core/view/lane_keys.h @@ -33,7 +33,7 @@ #include #include -namespace reasampler { +namespace reasampler::view { // The prefix the tool stamps on every lane NAME it mints. A lane name carrying this // prefix is a managed lane the tool created; any other name (or an empty/unnamed lane) @@ -82,4 +82,4 @@ std::optional modeIdFromLaneName(const std::string& laneName); // inputs (I_FREEMODE result, P_LANENAME string) and never re-derives this logic. bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName); -} // namespace reasampler +} // namespace reasampler::view diff --git a/src/mode_switch.cpp b/src/core/view/mode_switch.cpp similarity index 96% rename from src/mode_switch.cpp rename to src/core/view/mode_switch.cpp index 204c481..6bc8c57 100644 --- a/src/mode_switch.cpp +++ b/src/core/view/mode_switch.cpp @@ -1,10 +1,10 @@ // mode_switch — pure implementation. See mode_switch.h. NO REAPER / SWELL / vendor. -#include "mode_switch.h" +#include "core/view/mode_switch.h" #include -namespace reasampler { +namespace reasampler::view { namespace { @@ -61,4 +61,4 @@ int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount) { return -1; } -} // namespace reasampler +} // namespace reasampler::view diff --git a/src/mode_switch.h b/src/core/view/mode_switch.h similarity index 81% rename from src/mode_switch.h rename to src/core/view/mode_switch.h index 4813a8c..0b85942 100644 --- a/src/mode_switch.h +++ b/src/core/view/mode_switch.h @@ -1,8 +1,9 @@ #pragma once +#include "core/ui/rect.h" // mode_switch — the REAPER-free layout math behind the bank_panel's Design-View // mode switch (Phase D, Wave 4 — D5). A segmented control `[ Arrange | Design ]` // (N-mode general, one segment per registered mode) drawn in a fixed-height header -// strip at the top of the docked panel. The panel shell (bank_panel.cpp) owns the +// strip at the top of the docked panel. The panel shell (shell/panel/) owns the // SWELL window, LICE drawing, and the live ViewModeModel read + mode activation — // all REAPER-bound, DAW-verified. What is NOT DAW-bound — how N segments tile a // header rectangle, and which segment a click lands in — lives here so it is @@ -13,35 +14,17 @@ #include -namespace reasampler { +namespace reasampler::view { // The header strip the switch is drawn into, top-left origin (SWELL/LICE // convention). (x, y) is the top-left corner; width/height are the strip extents. // The panel reserves this at the top of its client area and offsets the grid below. -struct HeaderRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const HeaderRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using HeaderRect = ui::Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // One segment's pixel rectangle within the header, top-left origin. These are the // draw bounds for one mode's button; the panel draws the mode's display name inside // it and lights it when it is the active mode. -struct SegmentRect { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - - bool operator==(const SegmentRect& o) const { - return x == o.x && y == o.y && width == o.width && height == o.height; - } -}; +using SegmentRect = ui::Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // Divides `header` into `segmentCount` equal segments left-to-right, in the caller's // order (the panel passes modes in ordinal order). Returns exactly segmentCount @@ -63,4 +46,4 @@ std::vector computeSegmentRects(const HeaderRect& header, // the panel drew there. int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount); -} // namespace reasampler +} // namespace reasampler::view diff --git a/src/view_mode_model.cpp b/src/core/view/view_mode_model.cpp similarity index 71% rename from src/view_mode_model.cpp rename to src/core/view/view_mode_model.cpp index 9b6af0b..f2cfbfc 100644 --- a/src/view_mode_model.cpp +++ b/src/core/view/view_mode_model.cpp @@ -1,20 +1,17 @@ -#include "view_mode_model.h" +#include "core/view/view_mode_model.h" #include #include -#include -#include -#include -#include #include #include -#include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention +#include "core/json/json.h" +#include "core/view/lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention // view_mode_model implementation. // -// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief: -// keep the pure core dependency-free — no third-party JSON lib). A compact writer +// JSON rides on the shared core/json lexical layer (Q-W1), mirroring bank_model. +// A compact writer // plus a recursive-descent parser covers the field set: the mode registry, the // GUID-keyed membership map, per-track snapshots (with a variable-length per-FX // offline vector), and the active mode. Ints are emitted plainly; strings are @@ -22,6 +19,10 @@ namespace reasampler { +// Q-W1 interim: laneNameForMode lives in reasampler::view now; this god module +// re-namespaces in its own split wave. +using view::laneNameForMode; + // --------------------------------------------------------------------------- // equality // --------------------------------------------------------------------------- @@ -493,73 +494,12 @@ bool ViewModeModel::operator==(const ViewModeModel& o) const { namespace { -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -std::string intToStr(int v) { - char buf[16]; - std::snprintf(buf, sizeof(buf), "%d", v); - return buf; -} - -void writeIntArray(std::string& out, const std::vector& v) { - out += '['; - for (std::size_t i = 0; i < v.size(); ++i) { - if (i) out += ','; - out += intToStr(v[i]); - } - out += ']'; -} - -class ObjWriter { -public: - explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } - ~ObjWriter() { out_ += '}'; } - - void keyRaw(const char* key, const std::string& rawValue) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - out_ += rawValue; - } - void keyStr(const char* key, const std::string& value) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - writeEscaped(out_, value); - } - void keyBegin(const char* key) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - } - -private: - void sep() { if (first_) first_ = false; else out_ += ','; } - std::string& out_; - bool first_ = true; -}; +// Shared core/json emit helpers (Q-W1): same escape set + %d rendering as the +// prior file-local writer, so the emitted blob is byte-identical. +using json::writeEscaped; +using json::writeIntArray; +std::string intToStr(int v) { return json::numToStr(v); } +using ObjWriter = json::Writer; } // namespace @@ -660,250 +600,64 @@ std::string ViewModeModel::serialize() const { namespace { -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - bool parseModel(ViewModeModel& out); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - bool parseString(std::string& out); - bool parseRawScalar(std::string& out); - bool parseInt(int& out); - bool parseBool(bool& out); - bool parseKey(std::string& key); - bool skipValue(); - - bool parseModes(ModeRegistry& reg); - bool parseMembership(MembershipIndex& idx); - bool parseSnapshots(std::map& snaps); - bool parseLanes(LaneOwnershipIndex& idx); - bool parseIntArray(std::vector& out); -}; - -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - unsigned int hi = 0; - if (!readHex4(hi)) return false; - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; - } - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated -} - -bool Parser::parseRawScalar(std::string& out) { - skipWs(); - std::size_t start = pos_; - while (!eof()) { - char c = s_[pos_]; - if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || - c == '\n' || c == '\r') - break; - ++pos_; - } - if (pos_ == start) return false; - out.assign(s_, start, pos_ - start); - return true; -} - -bool Parser::parseInt(int& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - const char* b = tok.c_str(); - char* end = nullptr; - errno = 0; - long long v = std::strtoll(b, &end, 10); - if (end != b + tok.size()) return false; - if (errno == ERANGE) return false; - if (v < INT_MIN || v > INT_MAX) return false; - out = static_cast(v); - return true; -} - -bool Parser::parseBool(bool& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - if (tok == "true") { out = true; return true; } - if (tok == "false") { out = false; return true; } - return false; -} - -bool Parser::parseKey(std::string& key) { - if (!parseString(key)) return false; - return consume(':'); -} - -bool Parser::skipValue() { - skipWs(); - if (eof()) return false; - char c = s_[pos_]; - if (c == '"') { std::string tmp; return parseString(tmp); } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - ++pos_; - int depth = 1; - while (!eof() && depth > 0) { - char d = s_[pos_]; - if (d == '"') { std::string tmp; if (!parseString(tmp)) return false; continue; } - if (d == open) ++depth; - else if (d == close) --depth; - ++pos_; - } - return depth == 0; - } - std::string tmp; - return parseRawScalar(tmp); -} - -bool Parser::parseIntArray(std::vector& out) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; - do { - int v = 0; - if (!parseInt(v)) return false; - out.push_back(v); - } while (consume(',')); - return consume(']'); -} +// The model DOMAIN grammar over the shared core/json lexical layer (Q-W1). // The registry starts seeded (Arrange + Design). Deserialization must reproduce the // serialized set exactly, so we replace the seeded contents with the parsed ones — // add() dedups by id, so a serialized Arrange/Design would otherwise be rejected as // duplicates and the ordinals/names would not round-trip. We therefore parse into a // fresh vector and swap. `reg` is passed empty (see parseModel). -bool Parser::parseModes(ModeRegistry& reg) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; // empty array (unusual, but valid) +bool parseModes(json::Reader& r, ModeRegistry& reg) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; // empty array (unusual, but valid) do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; Mode m; bool haveId = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "id") { if (!parseString(m.id)) return false; haveId = true; } - else if (k == "displayName") { if (!parseString(m.displayName)) return false; } - else if (k == "ordinal") { if (!parseInt(m.ordinal)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "id") { if (!r.parseString(m.id)) return false; haveId = true; } + else if (k == "displayName") { if (!r.parseString(m.displayName)) return false; } + else if (k == "ordinal") { if (!r.parseInt(m.ordinal)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveId || !reg.add(m)) return false; // malformed / duplicate id - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseMembership(MembershipIndex& idx) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; +bool parseMembership(json::Reader& r, MembershipIndex& idx) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string guid; Membership mem; bool haveGuid = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } + if (!r.parseKey(k)) return false; + if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; } else if (k == "modes") { - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { do { std::string id; - if (!parseString(id)) return false; + if (!r.parseString(id)) return false; mem.modeIds.insert(id); - } while (consume(',')); - if (!consume(']')) return false; + } while (r.consume(',')); + if (!r.consume(']')) return false; } } - else if (k == "showBoth") { if (!parseBool(mem.showBoth)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + else if (k == "showBoth") { if (!r.parseBool(mem.showBoth)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveGuid || guid.empty()) return false; // Install the entry verbatim (tag() would clear a multi-mode set and drop // show-both). A serialized entry is trusted to already satisfy the model's @@ -918,55 +672,55 @@ bool Parser::parseMembership(MembershipIndex& idx) { // mode that no longer exists has an immediate behavioral consequence, so it // is caught and the parse is rejected. if (!idx.restore(guid, mem)) return false; - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseSnapshots(std::map& snaps) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; +bool parseSnapshots(json::Reader& r, std::map& snaps) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string guid; TrackSnapshot snap; bool haveGuid = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } - else if (k == "showInTcp") { if (!parseInt(snap.showInTcp)) return false; } - else if (k == "showInMixer") { if (!parseInt(snap.showInMixer)) return false; } - else if (k == "mainSend") { if (!parseInt(snap.mainSend)) return false; } - else if (k == "fxEnable") { if (!parseInt(snap.fxEnable)) return false; } - else if (k == "fxOffline") { if (!parseIntArray(snap.fxOffline)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; } + else if (k == "showInTcp") { if (!r.parseInt(snap.showInTcp)) return false; } + else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; } + else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; } + else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; } + else if (k == "fxOffline") { if (!r.parseIntArray(snap.fxOffline)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveGuid || guid.empty()) return false; snaps[guid] = snap; - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseLanes(LaneOwnershipIndex& idx) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; +bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string trackGuid, laneKey, mode; bool haveTrack = false, haveLane = false, managed = false, haveManaged = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "trackGuid") { if (!parseString(trackGuid)) return false; haveTrack = true; } - else if (k == "laneKey") { if (!parseString(laneKey)) return false; haveLane = true; } - else if (k == "managed") { if (!parseBool(managed)) return false; haveManaged = true; } - else if (k == "mode") { if (!parseString(mode)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "trackGuid") { if (!r.parseString(trackGuid)) return false; haveTrack = true; } + else if (k == "laneKey") { if (!r.parseString(laneKey)) return false; haveLane = true; } + else if (k == "managed") { if (!r.parseBool(managed)) return false; haveManaged = true; } + else if (k == "mode") { if (!r.parseString(mode)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; // Both keys mandatory and non-empty (they form the lane's identity). A managed // lane must carry a non-empty mode; a manual lane must not claim one. Enforcing // this on parse keeps a round-tripped index byte-for-byte identical to the @@ -980,14 +734,14 @@ bool Parser::parseLanes(LaneOwnershipIndex& idx) { if (!mode.empty()) return false; // manual lane must not carry a mode if (!idx.setManual(trackGuid, laneKey)) return false; } - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseModel(ViewModeModel& out) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // lenient empty root ⇒ default-seeded model +bool parseModel(json::Reader& r, ViewModeModel& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // lenient empty root ⇒ default-seeded model ModeRegistry reg; // seeded default; REPLACED if a modes array is present bool haveModes = false; @@ -999,33 +753,33 @@ bool Parser::parseModel(ViewModeModel& out) { do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "activeMode") { - if (!parseString(activeMode)) return false; + if (!r.parseString(activeMode)) return false; haveActive = true; } else if (key == "modes") { ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own - if (!parseModes(fresh)) return false; + if (!parseModes(r, fresh)) return false; reg = fresh; haveModes = true; } else if (key == "membership") { - if (!parseMembership(membership)) return false; + if (!parseMembership(r, membership)) return false; } else if (key == "snapshots") { - if (!parseSnapshots(snaps)) return false; + if (!parseSnapshots(r, snaps)) return false; } else if (key == "lanes") { - if (!parseLanes(lanes)) return false; + if (!parseLanes(r, lanes)) return false; } else { // Unknown keys and the "version" field are skipped here. // "version" is serialized as a forward-compat placeholder — there is no // active version gate yet; all persisted data is parsed the same way // regardless of the value. A future gate would add a version branch here. - if (!skipValue()) return false; + if (!r.skipValue()) return false; } - } while (consume(',')); + } while (r.consume(',')); - if (!consume('}')) return false; - skipWs(); - if (!eof()) return false; // trailing garbage + if (!r.consume('}')) return false; + r.skipWs(); + if (!r.eof()) return false; // trailing garbage if (haveModes) out.modes() = reg; out.membership() = membership; @@ -1039,10 +793,10 @@ bool Parser::parseModel(ViewModeModel& out) { } // namespace -std::optional ViewModeModel::deserialize(const std::string& json) { +std::optional ViewModeModel::deserialize(const std::string& blob) { ViewModeModel vm; - Parser p(json); - if (!p.parseModel(vm)) return std::nullopt; + json::Reader r(blob); + if (!parseModel(r, vm)) return std::nullopt; return vm; } diff --git a/src/view_mode_model.h b/src/core/view/view_mode_model.h similarity index 100% rename from src/view_mode_model.h rename to src/core/view/view_mode_model.h diff --git a/src/view_tree.cpp b/src/core/view/view_tree.cpp similarity index 93% rename from src/view_tree.cpp rename to src/core/view/view_tree.cpp index 3f5a966..6356ec0 100644 --- a/src/view_tree.cpp +++ b/src/core/view/view_tree.cpp @@ -1,8 +1,8 @@ // view_tree — pure folder-depth walk. See view_tree.h. -#include "view_tree.h" +#include "core/view/view_tree.h" -namespace reasampler { +namespace reasampler::view { FolderTree buildFolderTree(const std::vector& entries) { FolderTree tree; @@ -38,4 +38,4 @@ FolderTree buildFolderTree(const std::vector& entries) { return tree; } -} // namespace reasampler +} // namespace reasampler::view diff --git a/src/view_tree.h b/src/core/view/view_tree.h similarity index 93% rename from src/view_tree.h rename to src/core/view/view_tree.h index f349d84..7d2d968 100644 --- a/src/view_tree.h +++ b/src/core/view/view_tree.h @@ -11,9 +11,9 @@ #include #include -#include "view_mode_model.h" +#include "core/view/view_mode_model.h" -namespace reasampler { +namespace reasampler::view { // One track's contribution to the folder walk, read from REAPER in arrange order. // folderDepth is I_FOLDERDEPTH verbatim: 0 = normal, 1 = folder parent (opens a @@ -30,4 +30,4 @@ struct TrackFolderEntry { // is clamped to empty) so a corrupt/stale project can never fault the shell. FolderTree buildFolderTree(const std::vector& entries); -} // namespace reasampler +} // namespace reasampler::view diff --git a/src/core/wire/assignment_request.cpp b/src/core/wire/assignment_request.cpp new file mode 100644 index 0000000..ab05c53 --- /dev/null +++ b/src/core/wire/assignment_request.cpp @@ -0,0 +1,43 @@ +// assignment_request.cpp — see assignment_request.h. Pure: standard library only. + +#include "core/wire/assignment_request.h" + +#include "core/wire/wire.h" + +namespace reasampler::wire { + +namespace { + +constexpr const char* kMagic = "rsassign1"; + +// The shared core/wire codec (Q-W1, T2-01b) — the same field grammar + hardening +// this file previously carried as its own Cursor copy. "never UB, never a +// partial value" is upheld in the codec. +using wire::putField; +using Cursor = wire::Cursor; + +} // namespace + +std::string encodeAssignmentRequest(const AssignmentRequest& req) { + std::string out = kMagic; + putField(out, req.bankId); + putField(out, req.sampleId); + putField(out, std::to_string(req.generation)); + return out; +} + +std::optional decodeAssignmentRequest(const std::string& wire) { + Cursor cur(wire); + if (!cur.literal(kMagic)) return std::nullopt; + + AssignmentRequest req; + if (!cur.field(req.bankId)) return std::nullopt; + if (!cur.field(req.sampleId)) return std::nullopt; + if (!cur.fieldInt64(req.generation)) return std::nullopt; + + // Reject trailing garbage: a well-formed value ends exactly at the last field. + if (!cur.ok() || !cur.atEnd()) return std::nullopt; + return req; +} + +} // namespace reasampler::wire diff --git a/src/assignment_request.h b/src/core/wire/assignment_request.h similarity index 97% rename from src/assignment_request.h rename to src/core/wire/assignment_request.h index 46c1e03..12e469a 100644 --- a/src/assignment_request.h +++ b/src/core/wire/assignment_request.h @@ -41,11 +41,11 @@ #include #include -namespace reasampler { +namespace reasampler::wire { // One assignment request: the ingested sample's identity + a monotonic disambiguator. // bankId — the bank the sample was ingested into (the active/target bank). -// sampleId — the ingested Sample's stable id (BankIndex key). +// sampleId — the ingested Sample's stable id (BankModel key). // generation — a monotonic value the reader compares to detect a NEW request. The // writer supplies a unix-epoch-seconds stamp; the reader treats it as an // opaque "did this change?" token, not a wall-clock it interprets. @@ -86,4 +86,4 @@ std::string encodeAssignmentRequest(const AssignmentRequest& req); // silently, never crashing or selecting a nonexistent entry. std::optional decodeAssignmentRequest(const std::string& wire); -} // namespace reasampler +} // namespace reasampler::wire diff --git a/src/core/wire/bytes.h b/src/core/wire/bytes.h new file mode 100644 index 0000000..8b6c5fa --- /dev/null +++ b/src/core/wire/bytes.h @@ -0,0 +1,110 @@ +// core/wire/bytes.h — the ONE little-endian byte codec (Q-W2v; audit T4-20). +// Pure, header-only: standard library only — NO REAPER, NO SWELL, NO VST3. +// +// Five hand-rolled LE copies existed at the Q-W0 census (sample_map's +// putU32le/putU64le + ByteReader, capture_realtime's writeU32LE, capture_paths' +// readU32LE lambda, ingest's putU32 lambda, instrument_drop's appendU32LE). This +// template is the single survivor: compile-time dispatched, zero runtime cost, +// entirely off hot paths (serialization / file I/O only). The ComponentState +// codec (component_state_io) is its biggest consumer; the remaining hand-rolled +// copies rewire opportunistically in the waves that already open their files. +// +// Wire formats are FROZEN: putLE/putLE emit exactly the bytes the +// retired putU32le/putU64le emitted (LSB first, fixed width), and ByteReader +// preserves the latch-on-truncation contract (once a read runs past the end, +// ok latches false and every subsequent read yields zeros/empties — a truncated +// blob degrades to a partial parse, never out-of-bounds). + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace reasampler::wire { + +// Append `v` little-endian (LSB first, sizeof(T) bytes). Unsigned integral types +// only — signed values go on the wire as their two's-complement unsigned image +// (cast at the call site, the established idiom: u32 for int, u64 for int64). +template +inline void putLE(std::vector& out, T v) { + static_assert(std::is_unsigned_v && !std::is_same_v, "putLE takes the unsigned wire image"); + for (std::size_t b = 0; b < sizeof(T); ++b) { + out.push_back(static_cast((v >> (b * 8)) & 0xFF)); + } +} + +// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined +// type-pun in C++17). Doubles ride the wire as their u64 bit image via putLE. +inline std::uint64_t doubleToBits(double d) { + std::uint64_t bits; + std::memcpy(&bits, &d, sizeof(bits)); + return bits; +} +inline double bitsToDouble(std::uint64_t bits) { + double d; + std::memcpy(&d, &bits, sizeof(d)); + return d; +} + +// A bounded little-endian reader over a byte blob. Every read is length-checked; +// once a read runs past the end the reader latches `ok=false` and yields zeros, +// so a truncated blob degrades to a partial/empty parse rather than reading out +// of bounds. (The class formerly private to sample_map.cpp, promoted here as the +// codec's tested primitive — T4-20.) +struct ByteReader { + const std::vector& bytes; + std::size_t pos = 0; + bool ok = true; + + explicit ByteReader(const std::vector& b) : bytes(b) {} + + // Read one unsigned integral little-endian (fixed sizeof(T) width). + template + T readLE() { + static_assert(std::is_unsigned_v, "readLE yields the unsigned wire image"); + if (!ok || pos + sizeof(T) > bytes.size()) { + ok = false; + return 0; + } + T v = 0; + for (std::size_t b = 0; b < sizeof(T); ++b) { + v |= static_cast(bytes[pos + b]) << (b * 8); + } + pos += sizeof(T); + return v; + } + + std::uint8_t u8() { return readLE(); } + std::uint32_t u32() { return readLE(); } + std::uint64_t u64() { return readLE(); } + // Signed ints ride the wire as fixed-width two's-complement unsigned images. + int i32() { return static_cast(static_cast(u32())); } + std::int64_t i64() { return static_cast(u64()); } + + std::string str(std::uint32_t len) { + if (!ok || pos + len > bytes.size()) { + ok = false; + return {}; + } + std::string s(reinterpret_cast(bytes.data() + pos), len); + pos += len; + return s; + } + + // Non-consuming peek of the next u32 (format-marker probes). Yields 0 and + // latches nothing when fewer than 4 bytes remain — the caller treats a short + // blob as "no marker" and falls through to its (also-guarded) fallback read. + std::uint32_t peekU32() const { + if (!ok || pos + 4 > bytes.size()) return 0; + return static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + } +}; + +} // namespace reasampler::wire diff --git a/src/core/wire/ext_state_read.h b/src/core/wire/ext_state_read.h new file mode 100644 index 0000000..0f0ec75 --- /dev/null +++ b/src/core/wire/ext_state_read.h @@ -0,0 +1,70 @@ +#pragma once +// ext_state_read — the GetProjExtState GROW-LOOP retry policy (T2-04; rehomed to +// core/wire in Q-W6 — its consumers are the extension's persist/usage-scan shells +// AND the instrument's bridge, so it lives on the neutral wire seam rather than in +// the instrument-side bridge_marshal decode helper it started in). +// +// GetProjExtState writes into a caller-supplied buffer with no documented +// query-the-size call, so a large value (bank blob, usage record) must be read by +// growing a buffer until the value fits strictly inside it. Three shells carried +// hand-rolled copies of that loop (persist's ext-state reads, usage_scan's +// prune-safety-adjacent record read, reaper_bridge's VST-side bank read); the ONE +// policy lives here so the retry/termination rules cannot drift. The fiddly part +// is the termination taxonomy, which each caller folds differently: +// +// * Absent — the API returned <= 0 on some attempt: the key holds no value. +// (persist -> "" empty bank; usage_scan / bridge -> nullopt) +// * Complete — the written C string fits STRICTLY inside the buffer (size+1 < +// cap), so it cannot have been clipped: `value` is the whole value. +// * Overflow — the value never fit under the 16 MB ceiling: it is unreadable +// WHOLE, which is NOT the same as absent. (persist warns on the +// console; usage_scan folds it to the prune fail-safe abort) +// +// `read` is one GetProjExtState-shaped attempt: int read(char* buf, int cap), +// returning the API's int. A template, statically dispatched per call site — no +// virtual calls, no std::function (the §3 performance guardrail); the caller binds +// the project/namespace/key (or a resolved function pointer, VST side) in a lambda. + +#include +#include +#include + +namespace reasampler::wire { + +struct GrowingExtStateRead { + enum class Status { Absent, Complete, Overflow }; + Status status = Status::Absent; + int apiReturn = 0; // the FINAL attempt's return (<= 0 iff Absent); feeds + // decodeGetProjExtState on the bridge path unchanged + std::string value; // the whole value; meaningful only when Complete +}; + +template +GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) { + // Start generous; grow ×4 if REAPER reports the value may have been clipped + // (the return is the value length; equal-to-capacity-minus-NUL is ambiguous, + // so only a strict fit terminates). Ceiling 16 MB — give up rather than loop + // forever on a pathological value. + GrowingExtStateRead result; + for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { + std::vector buf(static_cast(cap), '\0'); + const int rv = read(buf.data(), cap); + result.apiReturn = rv; + if (rv <= 0) { + result.status = GrowingExtStateRead::Status::Absent; + return result; + } + buf[static_cast(cap) - 1] = '\0'; // defensive: guard against a read() that fills the buffer without honoring NUL-termination within cap + std::string s(buf.data()); + if (static_cast(s.size()) + 1 < cap) { + result.status = GrowingExtStateRead::Status::Complete; + result.value = std::move(s); + return result; + } + // else: possibly truncated -> grow and retry. + } + result.status = GrowingExtStateRead::Status::Overflow; + return result; +} + +} // namespace reasampler::wire diff --git a/src/instrument_drop.cpp b/src/core/wire/instrument_drop.cpp similarity index 77% rename from src/instrument_drop.cpp rename to src/core/wire/instrument_drop.cpp index 9db7320..78c1b0a 100644 --- a/src/instrument_drop.cpp +++ b/src/core/wire/instrument_drop.cpp @@ -1,31 +1,25 @@ // instrument_drop — pure implementation. See instrument_drop.h. // NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer and -// the SDK-free UID macros (vst/reasampler_uid.h). +// the SDK-free UID macros (core/wire/reasampler_uid.h). -#include "instrument_drop.h" +#include "core/wire/instrument_drop.h" #include -#include "vst/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID -#include "vst/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer) +#include "core/wire/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID +#include "core/instrument/map/component_state_io.h" // ComponentState + serializeComponentState (the SHARED writer, Q-W2v codec split) +#include "core/wire/bytes.h" // putLE — the ONE LE byte codec (T4-20) -namespace reasampler { +namespace reasampler::wire { + +using instrument::map::ComponentState; +using instrument::map::serializeComponentState; namespace { -// Little-endian appenders — the .vstpreset container stores its integers little-endian on -// disk (public.sdk vstpresetfile.cpp swaps only on big-endian hosts). -void appendU32LE(std::vector& out, std::uint32_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - out.push_back(static_cast((v >> 16) & 0xFF)); - out.push_back(static_cast((v >> 24) & 0xFF)); -} - -void appendU64LE(std::vector& out, std::uint64_t v) { - for (int i = 0; i < 8; ++i) - out.push_back(static_cast((v >> (8 * i)) & 0xFF)); -} +// The .vstpreset container stores its integers little-endian on disk (public.sdk +// vstpresetfile.cpp swaps only on big-endian hosts) — putLE (core/wire/bytes.h) is +// exactly that byte order; the former appendU32LE/appendU64LE copies are retired (T4-20). void appendFourCC(std::vector& out, const char id[4]) { out.insert(out.end(), id, id + 4); @@ -58,19 +52,19 @@ std::vector buildVstPresetBytes( out.reserve(static_cast(listOffset) + 4 + 4 + (4 + 8 + 8)); appendFourCC(out, "VST3"); - appendU32LE(out, 1); // kFormatVersion + putLE(out, 1); // kFormatVersion out.insert(out.end(), classIdHex32.begin(), classIdHex32.end()); - appendU64LE(out, listOffset); + putLE(out, listOffset); // Data area: the one 'Comp' chunk's bytes, at offset kHeaderSize. out.insert(out.end(), componentState.begin(), componentState.end()); // Chunk list: 'List' + entry count + one entry {'Comp', offset, size}. appendFourCC(out, "List"); - appendU32LE(out, 1); + putLE(out, 1); appendFourCC(out, "Comp"); - appendU64LE(out, kHeaderSize); - appendU64LE(out, compSize); + putLE(out, kHeaderSize); + putLE(out, compSize); return out; } @@ -106,4 +100,4 @@ bool infoNamesFxHotspot(const std::string& info) { return startsWith("fx_") || startsWith("tcp.fx") || startsWith("mcp.fx"); } -} // namespace reasampler +} // namespace reasampler::wire diff --git a/src/instrument_drop.h b/src/core/wire/instrument_drop.h similarity index 96% rename from src/instrument_drop.h rename to src/core/wire/instrument_drop.h index 7576114..25fec92 100644 --- a/src/instrument_drop.h +++ b/src/core/wire/instrument_drop.h @@ -3,7 +3,7 @@ // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK, // NO vendor/ includes. Standard library only (+ the pure sample_map it reuses and the -// SDK-free UID macros in vst/reasampler_uid.h). Unit-tested outside the DAW — the same +// SDK-free UID macros in core/wire/reasampler_uid.h). Unit-tested outside the DAW — the same // "small pure builder + round-trip proof" pattern as assignment_request / provenance. // // -- What it is (the S17 seam, extension side) -------------------------------- @@ -36,14 +36,14 @@ #include #include -namespace reasampler { +namespace reasampler::wire { // The 32-char uppercase-hex class-ID string of THIS build's channel-active ReaSampler 9000 // VST3 class UID — exactly what Steinberg::FUID::toString renders and what a .vstpreset // header carries (public.sdk vstpresetfile: "ASCII-encoded FUID"). On both COM-compatible // (Windows GUID byte order) and plain layouts, FUID::toString reduces to the four // INLINE_UID uint32 words printed "%08X" in order, so this derivation is platform-stable. -// Sourced from the FROZEN macros in vst/reasampler_uid.h (the same constants the factory +// Sourced from the FROZEN macros in core/wire/reasampler_uid.h (the same constants the factory // registers), channel-selected by the one REASAMPLER_CHANNEL_IS_BETA bit — a beta extension // writes presets only the beta VST class accepts, preserving the S18 pairing invariant. std::string vstClassIdHex(); @@ -111,4 +111,4 @@ bool infoNamesFxHotspot(const std::string& info); // its setState expects. Not called by the shell (which uses the .vstpreset image). std::vector instrumentDropStateBytes(const std::string& sampleId); -} // namespace reasampler +} // namespace reasampler::wire diff --git a/src/vst/reasampler_uid.h b/src/core/wire/reasampler_uid.h similarity index 100% rename from src/vst/reasampler_uid.h rename to src/core/wire/reasampler_uid.h diff --git a/src/sample_usage.cpp b/src/core/wire/sample_usage.cpp similarity index 78% rename from src/sample_usage.cpp rename to src/core/wire/sample_usage.cpp index 8414564..0489650 100644 --- a/src/sample_usage.cpp +++ b/src/core/wire/sample_usage.cpp @@ -1,92 +1,24 @@ // sample_usage.cpp — see sample_usage.h. Pure: standard library only. -#include "sample_usage.h" +#include "core/wire/sample_usage.h" #include -#include -#include -namespace reasampler { +#include "core/wire/wire.h" + +namespace reasampler::wire { namespace { constexpr const char* kMagic = "rsusage1"; -// Append one length-prefixed field: ':' . The same wire idiom as -// assignment_request / provenance — one grammar across every ext-state seam. -void putField(std::string& out, const std::string& field) { - out += std::to_string(field.size()); - out += ':'; - out += field; -} - -// Bounds-checked cursor over the encoded string (the assignment_request Cursor, trimmed -// to the two field kinds this record needs). A short read latches ok_ false. -class Cursor { -public: - explicit Cursor(const std::string& s) : s_(s) {} - - bool ok() const { return ok_; } - bool atEnd() const { return pos_ >= s_.size(); } - - bool field(std::string& out) { - if (!ok_) return false; - const std::size_t colon = s_.find(':', pos_); - if (colon == std::string::npos) return fail(); - if (colon == pos_) return fail(); // empty length token - if (colon - pos_ > 20u) return fail(); // SIZE_MAX is 20 decimal digits - std::size_t len = 0; - for (std::size_t i = pos_; i < colon; ++i) { - const char c = s_[i]; - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - if (len > (std::numeric_limits::max() - digit) / 10u) - return fail(); - len = len * 10u + digit; - } - const std::size_t start = colon + 1; - if (start > s_.size() || len > s_.size() - start) return fail(); - out.assign(s_, start, len); - pos_ = start + len; - return true; - } - - // Consumes an exact literal at the cursor (the magic tag). Fails if absent. - bool literal(const char* lit) { - if (!ok_) return false; - std::size_t i = 0; - for (; lit[i] != '\0'; ++i) { - if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); - } - pos_ += i; - return true; - } - - // A length-prefixed unsigned decimal (the hold count). Fails on empty, non-digit, - // or a value past a sane ceiling (a record cannot hold more entries than bytes). - bool fieldCount(std::size_t& out) { - std::string f; - if (!field(f)) return false; - if (f.empty() || f.size() > 10u) return fail(); - std::size_t v = 0; - for (const char c : f) { - if (c < '0' || c > '9') return fail(); - v = v * 10u + static_cast(c - '0'); - } - out = v; - return true; - } - -private: - bool fail() { - ok_ = false; - return false; - } - - const std::string& s_; - std::size_t pos_ = 0; - bool ok_ = true; -}; +// The shared core/wire codec (Q-W1, T2-01b) — one grammar across every +// ext-state seam. The former local fieldCount (10-digit cap) is subsumed by the +// codec's fieldSizeT (20-digit cap + overflow-guarded accumulate): every count +// the old cap accepted decodes identically, and any larger count is rejected by +// the count-vs-wire-size sanity bound at the call site below. +using wire::putField; +using Cursor = wire::Cursor; } // namespace @@ -115,7 +47,7 @@ std::optional decodeUsageRecord(const std::string& wire) { else if (unionedField == "0") rec.unioned = false; else return std::nullopt; // anything else is corruption -> reject whole std::size_t count = 0; - if (!c.fieldCount(count)) return std::nullopt; + if (!c.fieldSizeT(count)) return std::nullopt; // Each hold needs at least 4 wire bytes ("0:0:"), so a count past wire.size()/4 is // provably bogus — reject before looping rather than iterating a crafted huge count. if (count > wire.size() / 4u + 1u) return std::nullopt; @@ -298,4 +230,4 @@ bool identityMatches(const std::string& identity, const std::string& uidHexUpper return !nameUpper.empty() && up.find(nameUpper) != std::string::npos; } -} // namespace reasampler +} // namespace reasampler::wire diff --git a/src/sample_usage.h b/src/core/wire/sample_usage.h similarity index 99% rename from src/sample_usage.h rename to src/core/wire/sample_usage.h index 3e90fee..8553bf0 100644 --- a/src/sample_usage.h +++ b/src/core/wire/sample_usage.h @@ -105,7 +105,7 @@ #include #include -namespace reasampler { +namespace reasampler::wire { // One held capture: the bank sample id (attribution/debugging) + the project-relative // WAV path (the prune-protection payload — compared by EXACT string against the prune @@ -250,4 +250,4 @@ bool identityMatches(const std::string& identity, const std::string& uidHexUpper // ASCII-only uppercase (shared by the matcher and the shell's needle preparation). std::string toUpperAscii(const std::string& s); -} // namespace reasampler +} // namespace reasampler::wire diff --git a/src/core/wire/wire.cpp b/src/core/wire/wire.cpp new file mode 100644 index 0000000..80c08b4 --- /dev/null +++ b/src/core/wire/wire.cpp @@ -0,0 +1,134 @@ +// core/wire implementation — see wire.h. The bodies are the hardened +// assignment_request / sample_usage / provenance (post Q-W0 T2-01a backport) +// cursor, unified; any behavioral change here changes every ext-state wire +// seam at once. + +#include "core/wire/wire.h" + +#include +#include + +namespace reasampler::wire { + +void putField(std::string& out, const std::string& field) { + out += std::to_string(field.size()); + out += ':'; + out += field; +} + +bool parseUnsignedDecimal(const std::string& s, std::int64_t& out) { + if (s.empty()) return false; + std::int64_t value = 0; + constexpr std::int64_t kMax = std::numeric_limits::max(); + for (const char c : s) { + if (c < '0' || c > '9') return false; // any non-digit -> reject whole + const int digit = c - '0'; + // Guard value*10 + digit against overflow before performing it. + if (value > (kMax - digit) / 10) return false; + value = value * 10 + digit; + } + out = value; + return true; +} + +bool Cursor::literal(const char* lit) { + if (!ok_) return false; + std::size_t i = 0; + for (; lit[i] != '\0'; ++i) { + if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); + } + pos_ += i; + return true; +} + +bool Cursor::field(std::string& out) { + if (!ok_) return false; + const std::size_t colon = s_.find(':', pos_); + if (colon == std::string::npos) return fail(); + if (colon == pos_) return fail(); // empty length token + // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. + if (colon - pos_ > 20u) return fail(); + std::size_t len = 0; + for (std::size_t i = pos_; i < colon; ++i) { + const char c = s_[i]; + if (c < '0' || c > '9') return fail(); + const std::size_t digit = static_cast(c - '0'); + // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. + if (len > (std::numeric_limits::max() - digit) / 10u) + return fail(); + len = len * 10u + digit; + } + const std::size_t start = colon + 1; + // Subtraction-first form: start + len cannot wrap on a huge len. + if (start > s_.size() || len > s_.size() - start) return fail(); + out.assign(s_, start, len); + pos_ = start + len; + return true; +} + +bool Cursor::fieldInt64(std::int64_t& out) { + std::string f; + if (!field(f)) return false; + if (f.empty()) return fail(); + std::size_t i = 0; + bool neg = false; + if (f[0] == '-') { + neg = true; + i = 1; + if (f.size() == 1) return fail(); // bare "-" + } + // Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit + // positive value would overflow INT64_MAX; a 20-digit negative might be valid + // (INT64_MIN) but is conservatively rejected too — see header. + if (f.size() - i > 19u) return fail(); + std::int64_t v = 0; + for (; i < f.size(); ++i) { + const char c = f[i]; + if (c < '0' || c > '9') return fail(); + const std::int64_t digit = static_cast(c - '0'); + // Overflow guard: v * 10 + digit must not exceed INT64_MAX. + if (v > (std::numeric_limits::max() - digit) / 10) + return fail(); + v = v * 10 + digit; + } + out = neg ? -v : v; + return true; +} + +bool Cursor::fieldInt(int& out) { + std::int64_t v = 0; + if (!fieldInt64(v)) return false; + if (v < std::numeric_limits::min() || v > std::numeric_limits::max()) + return fail(); + out = static_cast(v); + return true; +} + +bool Cursor::fieldSizeT(std::size_t& out) { + std::string f; + if (!field(f)) return false; + if (f.empty() || f.size() > 20u) return fail(); + std::size_t v = 0; + for (const char c : f) { + if (c < '0' || c > '9') return fail(); + const std::size_t digit = static_cast(c - '0'); + if (v > (std::numeric_limits::max() - digit) / 10u) + return fail(); + v = v * 10u + digit; + } + out = v; + return true; +} + +bool Cursor::fieldDouble(double& out) { + std::string f; + if (!field(f)) return false; + const char* b = f.c_str(); + char* end = nullptr; + double v = std::strtod(b, &end); + if (end != b + f.size()) return fail(); + out = v; + return true; +} + +} // namespace reasampler::wire diff --git a/src/core/wire/wire.h b/src/core/wire/wire.h new file mode 100644 index 0000000..9cdde10 --- /dev/null +++ b/src/core/wire/wire.h @@ -0,0 +1,88 @@ +// core/wire — the ONE length-prefixed ext-state wire codec (Q-W1; audit +// T2-01(b)). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. +// +// The `':'` field grammar ("one grammar across every +// ext-state seam") was previously implemented as three near-identical +// putField + Cursor copies (provenance / assignment_request / sample_usage) +// plus a fourth guarded decimal accumulate (bank_sync::parseBankGeneration) — +// and the copies drifted on the hardening. This is the single survivor, +// carrying the FULL hardening everywhere: +// - length digit-run capped at 20 (SIZE_MAX's decimal width) so a crafted +// digit run cannot accumulate past SIZE_MAX via repeated multiply; +// - overflow guard on every accumulate (multiply+add checked BEFORE applied); +// - subtraction-first bounds check so a huge len cannot wrap `start + len`; +// - fieldInt/fieldInt64 parse sign+digits manually with an INT64 overflow +// guard and an int range check — an out-of-range field FAILS the parse +// (closing the strtol errno/range gap the provenance copy carried). +// +// Wire formats on disk / ext-state are FROZEN: encode is byte-identical to the +// pre-collapse writers (std::to_string length + ':' + bytes), decode is +// tolerant-identical for every value a house writer can emit. "Never UB, never +// a partial value" is the parse-integrity promise. + +#pragma once + +#include +#include +#include + +namespace reasampler::wire { + +// Append one length-prefixed field: ':' +void putField(std::string& out, const std::string& field); + +// Whole-string, non-negative decimal parse WITHOUT exceptions or locale +// surprises (the bank_sync generation-stamp core). False on empty, any +// non-digit (incl. a leading '+'/'-'), or overflow past INT64_MAX; the +// accumulate is overflow-guarded so a pathologically long digit run can never +// wrap into a bogus small value. +bool parseUnsignedDecimal(const std::string& s, std::int64_t& out); + +// Bounds-checked cursor over an encoded string. All reads are bounds-checked; +// any short read fails the whole parse (ok_ latches false — every subsequent +// read also fails, so a caller may check ok() once at the end). +class Cursor { +public: + explicit Cursor(const std::string& s) : s_(s) {} + + bool ok() const { return ok_; } + bool atEnd() const { return pos_ >= s_.size(); } + + // Consumes an exact literal at the cursor (the magic tag). Fails if absent. + bool literal(const char* lit); + + // Reads one length-prefixed field into `out`. Fails on a missing ':', an + // empty or non-numeric length, a length that would overflow SIZE_MAX, or a + // length that runs past the end. + bool field(std::string& out); + + // Length-prefixed signed 64-bit decimal (optional leading '-'). Digit run + // capped at 19 (INT64_MAX's decimal width); overflow fails the parse. A + // 20-digit negative (only INT64_MIN itself) is conservatively rejected — + // house writers emit generation timestamps and small enums, never that. + bool fieldInt64(std::int64_t& out); + + // fieldInt64 narrowed to int; a value outside [INT_MIN, INT_MAX] FAILS the + // parse (the fixed form of the provenance copy's silent strtol narrowing). + bool fieldInt(int& out); + + // Length-prefixed unsigned decimal (element counts). Digit run capped at + // 20; overflow-guarded accumulate. Callers still apply their own + // count-vs-wire-size sanity bound BEFORE any reserve() on the result. + bool fieldSizeT(std::size_t& out); + + // Length-prefixed %.17g double. Full-token strtod; trailing bytes fail. + // Deliberately NO errno/ERANGE rejection: the writers emit %.17g of live + // doubles (incl. "inf"), and those must decode back — same accept set as + // every prior copy. + bool fieldDouble(double& out); + +private: + bool fail() { ok_ = false; return false; } + + const std::string& s_; + std::size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace reasampler::wire diff --git a/src/ext_keys.h b/src/ext_keys.h index 674e1f9..6064c4a 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -1,6 +1,6 @@ #pragma once // ext_keys — the SINGLE SOURCE OF TRUTH for the "reasampler" project ext-state -// namespace + key names, shared by the extension (writer, via persist.h) and the +// namespace + key names, shared by the extension (writer, via shell/persist) and the // VST3 instrument (reader, via the bridge). Both sides include this header so the // wire contract cannot drift between the two artifacts (the S4 reviewer flagged the // spike's duplicated constants as a drift risk). @@ -12,10 +12,10 @@ // either SDK. // // FOREVER-STABLE once shipped: these strings key every already-saved project's -// stored state. Changing any of them orphans that state. See persist.h for the +// stored state. Changing any of them orphans that state. See shell/persist/ext_state_io.h for the // per-key retirement / migration semantics — this header only owns the spellings. -#include "app_version.h" +#include "core/version/app_version.h" namespace reasampler { @@ -26,10 +26,10 @@ namespace reasampler { // because the value is fixed by the channel bit at build time. This is the wire-contract // reconciliation between S4 (shared ext_keys) and V4 (channel-isolated namespace): without // it a beta instrument would read the stable namespace and see empty state. -inline const char* kProjExtNamespace() { return extStateNamespace().c_str(); } +inline const char* kProjExtNamespace() { return version::extStateNamespace().c_str(); } // The multi-bank key: the whole serialized BankBook (pool + named banks). This is -// the key the VST3 instrument reads to see the live bank (read-only, S4). persist.h +// the key the VST3 instrument reads to see the live bank (read-only, S4). ext_state_io // documents its authority + the legacy-key migration around it. inline constexpr const char* kProjExtBanksKey = "banks"; diff --git a/src/ingest.cpp b/src/ingest.cpp index 9dd4395..43e288c 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -15,18 +15,18 @@ #include #include -#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path) -#include "app_version.h" // channelCommandId / channelActionName -#include "assignment_request.h" // pure (bankId, sampleId, generation) encode -#include "bank_book.h" // BankBook, Bank, activeBankId / activeIndex -#include "bank_model.h" // Sample, AddResult, findByHash -#include "bank_panel.h" // bankPanelRefresh -#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent -#include "instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) -#include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) -#include "persist.h" // ReaSamplerSession +#include "core/version/app_version.h" // channelCommandId / channelActionName +#include "core/wire/assignment_request.h" // pure (bankId, sampleId, generation) encode +#include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex +#include "core/model/bank_model.h" // Sample, AddResult, findByHash +#include "shell/panel/panel_input.h" // bankPanelRefresh +#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) +#include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) +#include "shell/persist/session.h" // ReaSamplerSession -#include "wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path +#include "core/capture/wav_codec.h" // parseWavLayout (32f fast-path validator), buildFloat32Wav, hashWavContent #include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) @@ -46,6 +46,22 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the interim core/namespaces.h shim +// is retired; each symbol names its Q-W1 home explicitly). +using capture::BankPaths; +using capture::buildFloat32Wav; +using capture::deriveBankPaths; +using capture::hashWavContent; +using capture::parseWavLayout; +using capture::projectDirOfRpp; +using capture::WavLayout; +using util::readFileBytes; +using version::channelActionName; +using version::channelCommandId; +using wire::AssignmentRequest; +using wire::buildInstrumentDropPreset; +using wire::encodeAssignmentRequest; + namespace { // The live session the ingest paths mutate. Set once by ingestRegisterActions and read by @@ -81,19 +97,8 @@ std::string currentProjectDir() { return projectDirOfRpp(std::string(buf.data())); } -// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror -// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy. -std::vector readFileBytes(const std::string& path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return {}; - const std::streamsize n = f.tellg(); - if (n <= 0) return {}; - std::vector bytes(static_cast(n)); - f.seekg(0); - f.read(reinterpret_cast(bytes.data()), n); - if (!f) return {}; - return bytes; -} +// Whole-file reads (source read + bank-copy validate/hash) go through the shared +// core/util readFileBytes (Q-W1, T2-03): empty on any failure (missing / unreadable). // Writes a byte buffer to a file. Returns true on success. The caller is responsible for // ensuring the directory exists before calling. @@ -105,71 +110,11 @@ bool writeFileBytes(const std::string& path, const std::vector& by return f.good(); } -// Builds a minimal 32-bit-float RIFF/WAVE byte buffer from interleaved double samples. -// The output is a canonical WAV the bank and wav_trim can read: -// RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, 16-byte body), -// data chunk (interleaved little-endian float32, one float per sample per channel). -// `nch` channels, `rate` Hz sample rate, `frameCount` frames (total samples = frameCount*nch). -// Each ReaSample (double) is narrowed to float by assignment — the instrument expects -// 32-bit float; the reduction is intentional and matches how the bank contract is defined -// (capture.cpp kRenderFormatWavFloat32; wav_trim.h FORMAT ASSUMPTION). -std::vector buildFloat32Wav(int nch, std::uint32_t rate, - std::size_t frameCount, - const std::vector& interleaved) { - const std::size_t sampleCount = frameCount * static_cast(nch); - const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32 - - // The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload. - // Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount. - const std::uint32_t riffSize = - static_cast(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data - - std::vector out; - out.reserve(44u + dataBytesCount); - - auto putU16 = [&](std::uint16_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - }; - auto putU32 = [&](std::uint32_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - out.push_back(static_cast((v >> 16) & 0xFF)); - out.push_back(static_cast((v >> 24) & 0xFF)); - }; - auto putTag = [&](const char* t) { - for (int i = 0; i < 4; ++i) - out.push_back(static_cast(t[i])); - }; - auto putF32 = [&](float f) { - std::uint8_t tmp[4]; - std::memcpy(tmp, &f, 4); - for (int i = 0; i < 4; ++i) out.push_back(tmp[i]); - }; - - // RIFF header - putTag("RIFF"); - putU32(riffSize); - putTag("WAVE"); - - // fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003) - putTag("fmt "); - putU32(16u); // chunk body size - putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT - putU16(static_cast(nch)); - putU32(rate); - putU32(rate * static_cast(nch) * 4u); // avgBytesPerSec - putU16(static_cast(nch * 4)); // blockAlign - putU16(32u); // bitsPerSample - - // data chunk - putTag("data"); - putU32(static_cast(dataBytesCount)); - for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i) - putF32(static_cast(interleaved[i])); - - return out; -} +// The 32f WAV build itself lives in the pure wav_codec module (Q-W3, audit §4e / +// T4-10 — one owner of the RIFF layout, CTest-covered): buildFloat32Wav takes the +// interleaved ReaSample (double) frames decoded below and yields the canonical +// bank-format bytes (capture.cpp kRenderFormatWavFloat32; wav_codec.h FORMAT +// ASSUMPTION — the double→float narrowing is the intentional bank contract). // Decodes ALL samples from `src` into interleaved double-precision frames. // Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0). @@ -223,7 +168,7 @@ struct ImportResult { // Imports one OS-native source file into the ACTIVE bank: convert-if-needed to 32-bit- // float WAV, write to the project-relative bank folder, index-add, hash-dedup applied. // -// BANK CONTRACT: the instrument (wav_trim) expects every bank file to be a canonical +// BANK CONTRACT: the instrument (wav_codec parse) expects every bank file to be a canonical // 32-bit-float WAV (WAVE_FORMAT_IEEE_FLOAT, 32 bits). A verbatim copy of a non-WAV (or // an integer-PCM or double-float WAV) would be unplayable. This function therefore: // 1. Checks whether the source IS already a valid 32f WAV (parseWavLayout fast path). @@ -504,7 +449,7 @@ void doImportFromMediaExplorer() { // The generation is bumped only when something NEW landed (a dedup collapse mutated nothing, // so it needs neither a bump nor a persist to resolve — the sample is already in ext-state). // If saveToActiveProject() no-ops (unsaved project), close with an empty label + zero flag so - // REAPER discards the undo entry (the house pattern from actions.cpp). importFileIntoActiveBank + // REAPER discards the undo entry (the house pattern from persistBankOp). importFileIntoActiveBank // already refuses on an unsaved project, so in practice the persist here succeeds. Undo_BeginBlock2(nullptr); bool persisted = true; // true when nothing needed persisting (dedup) — governs the label path @@ -577,7 +522,7 @@ void ingestDroppedFiles(const std::vector& absolutePaths) { // One undo point for the whole drop, opened only if a NEW index entry was created (a // drop that only re-hit existing content mutated nothing on the index). // If saveToActiveProject() no-ops (unsaved project), we close with an empty label + zero - // flag so REAPER discards the undo entry (house pattern from actions.cpp). + // flag so REAPER discards the undo entry (house pattern from persistBankOp). if (importedNew > 0) { Undo_BeginBlock2(nullptr); // S9: one coalesced generation bump for the whole drop (>=1 new sample landed) so diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index 668eba4..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,1897 +0,0 @@ -// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers. -// -// This file is the entire contract between REAPER and the extension: -// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and -// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry -// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro). -// * REAPER calls it, handing over `rec` — a small dispatch struct. -// - rec->GetFunc(name) resolves any REAPER API function to a pointer -// - rec->Register(what,ptr) plugs OUR callbacks into REAPER -// * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and -// fills in every global function pointer (ShowConsoleMsg, InsertMedia...). -// -// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates -// storage for those global pointers. Every other .cpp includes -// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. - -#define REAPERAPI_IMPLEMENT -#include "reaper_plugin.h" -#include "reaper_plugin_functions.h" - -#include -#include -#include -#include -#include - -#include - -#include "actions.h" -#include "app_version.h" -#include "bank_model.h" -#include "bank_panel.h" -#include "batch_capture.h" -#include "capture.h" -#include "ingest.h" -#include "insert.h" -#include "persist.h" -#include "provenance.h" -#include "provenance_shell.h" -#include "render_settings.h" -#include "track_guid.h" -#include "view.h" - -#include // project-dir derivation for provenance parent resolution - -// Persistent action-id family (Phase V, V4 — channel-qualified). Every bindable action -// mints its command id from commandIdPrefix() + a per-action SUFFIX, and its Actions-list -// name from actionDisplayPrefix() + a phrase, both derived from the ONE channel bit in the -// pure app_version module (channelCommandId / channelActionName). Stable rebuilds the exact -// shipped id ("CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); beta yields the isolated forever- -// family id ("CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK"). FOREVER-STABLE per channel: a -// shipped suffix is as permanent as the prefix; user keybindings key off the composed id. -// -// The composed id strings are held here for the module's lifetime (idStore) so both the -// register call and the mirroring '-command_id' unregister pass the SAME stable pointer. -// A std::deque (NOT vector) is used deliberately: it never invalidates references to -// existing elements on push_back, so a c_str() handed out early stays valid after later -// interning — the unload path re-presents these same pointers. -static std::deque g_idStore; - -// Interns a composed command-id string for the module lifetime and returns its C string. -// Appended-to only during startup registration and read on unload; never cleared until -// process exit, and deque guarantees the returned pointer stays valid. -static const char* internCmdId(const std::string& suffix) { - g_idStore.push_back(reasampler::channelCommandId(suffix)); - return g_idStore.back().c_str(); -} - -// Globals other files reference via `extern`. -REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle -reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct - -// ---- Capture action family (two FX scopes) --------------------------------- -// Two bindable SCOPE actions from captureActionTable() (render_settings, pure): -// capture item / track. Each infers its range (razor-else-time) and enforces the -// FX-scope invariant via FX-bypass-around-render (FxBypassGuard): -// Item -> take/item FX only (bypass the item's track + ancestors + master). -// Track -> item FX + track's own FX (bypass ancestors + master). -// There is NO master scope — to capture the master you render a track. (The master -// track's FX/gain/pan are STILL neutralized for both scopes as the out-of-scope -// chain — master is a bypass target, not a capture scope.) The retired M7 -// CAPTURE_TRACKS_WET / CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids AND the removed -// CAPTURE_MASTER / CAPTURE_MASTER_REALTIME ids are mirror-unregistered on unload so -// old keybindings clear cleanly. -// -// The minted command ids parallel the table rows 1:1 (same index). gaccel storage -// must outlive registration (REAPER holds each pointer), so both vectors are file- -// scope and sized to the table. FOREVER-STABLE id strings live in the table. -static std::vector g_captureCmdIds; -static std::vector g_captureAccels; -// Channel-qualified capture-action labels, one per table row. REAPER holds each gaccel's -// `desc` pointer, so the composed strings live here for the module lifetime (parallel to -// g_captureAccels; never resized after the registration loop sets it). -static std::vector g_captureDescs; - -// Retired capture-action command-id SUFFIXES. Kept ONLY to mirror-unregister them on -// unload so a user's stale keybindings are cleaned up. Never re-register these. Composed -// through the channel prefix at unload (channelCommandId) so a beta unload clears beta- -// qualified retired ids and a stable unload clears stable's — each channel cleans up only -// its own family. -// * The M7 four-mode ids (tracks/items/razor WET). -// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the -// master realtime action are REMOVED (capture is now item + track only; realtime -// taps the selected track). Their shipped ids are retired so old keybindings clear. -// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants -// are REMOVED; tail is now a panel-setting toggle, not a paired action. Retired so -// old keybindings clear. -static const char* const kRetiredCaptureCmdSuffixes[] = { - "CAPTURE_TRACKS_WET", - "CAPTURE_ITEMS_WET", - "CAPTURE_RAZOR_WET", - "CAPTURE_MASTER", - "CAPTURE_MASTER_REALTIME", - "CAPTURE_ITEM_TAIL", - "CAPTURE_TRACK_TAIL", -}; - -// Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string. -// The docked grid window is display-only this wave (Wave A) — the action just -// shows/hides it; it never captures, inserts, or mutates the bank. -static int g_cmdToggleBankPanel = 0; - -// Command ids for the M6 insert actions. FOREVER-STABLE strings. Two variants that -// differ ONLY in the InsertOptions they build: the default inserts at native length -// (no stretch, no conform); the "conform" variant is the EXPLICIT opt-in to REAPER's -// try-to-match-project-tempo path (CONTEXT.md §insert: conform is opt-in, never -// silent). Both read the bank panel's current selection and place at the edit cursor. -static int g_cmdInsertSelected = 0; -static int g_cmdInsertSelectedConform = 0; - -// Command ids for the M11 batch-capture actions. NEW FOREVER-STABLE strings. One action -// fires N captures: CAPTURE_BATCH_ITEMS -> one bank sample per selected item (item scope); -// CAPTURE_BATCH_RAZOR -> one bank sample per razor area (track scope, each area's range). -// Each unit honors every precision invariant; the original selection is restored on every -// exit path. Bank-only, never places on the timeline (load-bearing principle). -static int g_cmdCaptureBatchItems = 0; -static int g_cmdCaptureBatchRazor = 0; - -// Command id for the "capture selected track (realtime)" action. NEW FOREVER-STABLE -// string. Records the selected track's OWN output in realtime (transport-driven) into -// a hidden temp track via RealtimeRecordBackend, then moves the recorded file into the -// bank. The realtime SIBLING of the offline CAPTURE_TRACK scope action: same range -// logic (razor-else-time), same track selection, same bank/persist path, different -// backend. Dialog-free. (Replaces the removed CAPTURE_MASTER_REALTIME action.) -static int g_cmdCaptureTrackRealtime = 0; - -// Command id for the M10 "re-capture from source" action. NEW FOREVER-STABLE string -// (suffix RECAPTURE_FROM_SOURCE). Regenerates the bank panel's selected PROVENANCED -// sample from its recorded source's current state and updates the Sample in place — -// BANK-ONLY, never places on the timeline (load-bearing principle). Reports the no- -// provenance / vanished-source / drift cases to the console (a direct response to an -// explicit action, allowed by the console policy). -static int g_cmdRecaptureFromSource = 0; - -// Command id for the S8 "capture selected item / time-selection into bank + assign" -// action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline -// Item-scope capture path (RunCapture) verbatim — same razor-else-time range, same -// FX-scope neutralize, same bank/persist landing — then writes an S8 assignment request -// so the active sampler instance plays the just-captured sample on its next reload. NEVER -// inserts a timeline item (the capture/placement separation holds; assign is a bank-index -// + instance-selection act). Lives in the capture family (not the ingest family) because -// it leans on main.cpp's capture render machinery, which is not exposed cross-module. -static int g_cmdCaptureItemAssign = 0; - -// Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string. -// Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user -// who started a long capture can bail without waiting for the range end or hunting for -// the transport-stop. No-op (with a note) when nothing is in flight. -static int g_cmdCancelRealtime = 0; - -// Command id for the Phase V "show version" action. FOREVER-STABLE string. On demand -// ONLY — prints the CMake-sourced version string to the console when fired. This is the -// SOLE new console output the versioning wave adds; there is no unconditional startup -// version print (routine console chatter was deliberately removed — it pops the console -// window). The user copies this line into a bug report. -static int g_cmdShowVersion = 0; - -// The persistence session (M4): owns the in-memory BankIndex and bridges it to -// project ext state. A timer tick drives g_session.poll() to detect project -// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to -// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize -// the book back into the active project's ext state (the `banks` key) so it travels -// with the .rpp. Replaces the M3 session-only g_bank. -static reasampler::ReaSamplerSession g_session; - -// --- M8 in-flight realtime capture (async, timer-driven) -------------------- -// A realtime record spans many timer ticks (it takes end-start wall-clock seconds -// and must NOT block REAPER's UI). The action STARTS it (g_rtBackend.begin), which -// returns immediately with the in-flight state owned here; OnTimer drives it -// (g_rtBackend.tick) each tick until a terminal verdict; then this pointer is -// cleared. Non-null == a capture is in progress (used to reject a second one, and to -// abort on project switch / unload). -static reasampler::RealtimeRecordBackend g_rtBackend; -static reasampler::RealtimeCaptureHandle g_rtCapture; - -// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets -// OnTimer detect a project switch mid-capture and abort+restore rather than leak the -// temp track/arm/transport into or across projects. Only meaningful when -// g_rtCapture != nullptr. -static ReaProject* g_rtCaptureProject = nullptr; - -// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the -// Sample to the ACTIVE bank (g_session.bank() resolves to book.activeIndex() — B2), -// persist + MarkProjectDirty. Shared by the tick-completion path and the abort -// paths. On a non-Ok result, logs the failure only. -static void CommitRealtimeResult(const reasampler::CaptureResult& res) -{ - if (res.status != reasampler::CaptureStatus::Ok) - { - ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str()); - return; - } - g_session.bank().add(res.sample); - // B-cap: record the file the capture created in the owned-file manifest, at the same - // point the Sample is added and before the same persist. Recorded regardless of the - // index AddResult — even a hash-collapse still WROTE a file the tool owns, and the - // manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index). - g_session.owned().add(res.sample.relativePath); - // S9: a capture add changes what a live instance could play (a new sample landed in the - // active bank) -> bump before the persist so the stamped generation refreshes instances. - g_session.bumpBankGeneration(); - g_session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp) -} - -// Advance any in-flight realtime capture one tick. Cheap when none is running (a -// null check) and fast even mid-record (tick() only reads the transport until the -// terminal tick). Detects a project switch mid-capture and aborts+restores so the -// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so -// poll's project-switch handling sees a cleaned-up project. -static void DriveRealtimeCapture() -{ - if (!g_rtCapture) return; - - // Project switch guard: if the active project is no longer the one the capture - // belongs to, a new/other project became active mid-record — abort + restore - // (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize - // into the new project. - ReaProject* active = EnumProjects(-1, nullptr, 0); - if (active != g_rtCaptureProject) - { - reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); - // Only commit if the ORIGINAL project is still open and active would be it — - // on a switch we restored into the original but must not persist into the - // now-active foreign project. Log the outcome without persisting. On a Failed - // abort surface abort()'s own message — it distinguishes a clean tab-switch - // abort from the closed-project DROP (the captured project was closed mid-record, - // review §1: nothing restored because the pointers were already freed). - if (r.status == reasampler::RealtimeTickStatus::Done) - ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- " - "captured audio restored into the original project; not " - "persisted to avoid crossing projects.\n"); - else - ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record -- " + - r.result.message + "\n").c_str()); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; - return; - } - - reasampler::RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture); - if (r.status == reasampler::RealtimeTickStatus::InProgress) return; - - // Terminal (Done or Failed): commit/log and drop the in-flight state. - CommitRealtimeResult(r.result); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; -} - -// The timer callback REAPER runs periodically (registered via "timer"). It only -// forwards to the session poll — cheap per tick (reads the active project id and -// its .rpp path, acts only on a change). -static void OnTimer() -{ - // Advance any in-flight realtime capture FIRST, so a project switch is caught and - // the capture torn down/restored before session.poll() reacts to that switch. - DriveRealtimeCapture(); - - g_session.poll(); - - // D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view - // model but deliberately does NOT apply visibility — that would couple persist - // to the view shell). Instead poll() raises a one-shot load signal; here — the - // integration layer that already drives both persist and the view shell — we - // drain it and reapply the SAVED active mode's visibility/processing so opening a - // project saved in Design mode parks the Arrange tracks automatically, no manual - // toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks - // skip it. proj = nullptr -> REAPER's active project (the one poll just loaded). - // - // The SAME signal re-arms the bank panel's new-content detector: a load must - // re-baseline the detector against the just-loaded project's content so its - // pre-existing tracks are never mis-detected as "new" and mass-tagged into the - // active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's - // re-arm and the model restore ride the one authoritative load event. - if (g_session.consumeLoadSignal()) { - reasampler::bankPanelNotifyProjectLoaded(); - // Reconcile the restored lane-ownership index against the live project's lanes - // FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth), - // so a saved lane-split project's managed/manual classification is correct - // before the active mode's lane visibility is reapplied. Never re-mints, never - // mass-tags — it only records managed ownership recovered from lane names. - reasampler::reconcileManagedLanes(g_session.view(), nullptr); - reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); - } - - // Reflect a live bank change (capture / project load) in the docked grid. - // Cheap when the bank is unchanged (a fingerprint compare); repaints only on - // 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 but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's -// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view -// would stay stale until close+reopen. REAPER's projectconfig extension fires -// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore -// (isUndo == true for both). We hook it to drive a session reload. -// -// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as -// firing BEFORE any state restore. Reading GetProjExtState synchronously here would -// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request -// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which -// point REAPER has finished restoring the block and GetProjExtState returns -// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling. -// -// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false); -// we ignore that here so a normal open flows solely through the timer's identity-transition -// Load path (no double load). Only undo/redo (isUndo=true) requests the reload. -static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) -{ - if (isUndo) - g_session.requestReload(); -} - -// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores -// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"), -// which REAPER persists in its own RPP block — NOT via this extension's own -// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo -// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER -// keeps dispatching (we claim none). SaveExtensionConfig writes nothing. -static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/, - bool /*isUndo*/, project_config_extension_t* /*reg*/) -{ - return false; // we own no project lines — ext state carries our data -} - -static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/, - project_config_extension_t* /*reg*/) -{ - // Nothing to write: our data rides in ext state, not project lines. -} - -// Storage must outlive registration — REAPER holds this pointer until we unregister it. -static project_config_extension_t g_projectConfig{ - &OnProcessExtensionLine, - &OnSaveExtensionConfig, - &OnBeginLoadProjectState, - nullptr, // userData -}; - -// --- Scope-action source resolution ----------------------------------------- -// The three scope actions (item / track / master) each resolve to (1) an exact -// render range in project seconds — razor-else-time, inferred here — and (2) the -// set of source TRACKS whose ancestor chains drive the FX-bypass plan. All reads -// are non-destructive: selection, razor, and time selection are read, never -// mutated. Returning false means "nothing to capture" (empty selection / no -// range); the caller reports it and writes nothing. - -// The resolved source: exact bounds + the source tracks (for FX-bypass + Sample -// provenance GUIDs). `sourceTracks` holds the item-owning tracks (Item scope) or the -// selected tracks (Track scope). -struct ResolvedSource -{ - double startSeconds = 0.0; - double endSeconds = 0.0; - std::vector sourceTracks; // item-owning tracks / selected tracks - std::vector trackGuids; // canonical GUIDs of sourceTracks -}; - -// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false, -// isLoop=false) reads the current time selection. -static bool resolveTimeSelection(double& start, double& end) -{ - start = 0.0; end = 0.0; - GetSet_LoopTimeRange(false, false, &start, &end, false); - return end > start; -} - -// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of -// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits), -// and returns the union bound. Reads only — never clears the razor selection. -// Returns false when no track-audio razor area exists on any track. -static bool resolveRazorRange(double& start, double& end) -{ - std::vector allRanges; - const int n = CountTracks(nullptr); - for (int i = 0; i < n; ++i) - { - MediaTrack* tr = GetTrack(nullptr, i); - if (!tr) continue; - std::vector buf(8192, '\0'); - if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) - continue; - std::vector ranges = - reasampler::parseRazorEdits(std::string(buf.data())); - for (auto& r : ranges) allRanges.push_back(r); - } - if (allRanges.empty()) return false; - reasampler::RazorRange u = reasampler::razorUnionBounds(allRanges); - start = u.startSeconds; - end = u.endSeconds; - return end > start; -} - -// Infers the render RANGE for any scope: razor union when a razor area is present, -// else the time selection (pure inferRangeSource decides which). Orthogonal to -// scope. Returns false (with a reason) when neither yields a non-empty range. -static bool resolveRange(double& start, double& end, std::string& why) -{ - double rzStart = 0.0, rzEnd = 0.0; - const bool hasRazor = resolveRazorRange(rzStart, rzEnd); - if (reasampler::inferRangeSource(hasRazor) == reasampler::RangeSource::Razor) - { - start = rzStart; end = rzEnd; - return true; // resolveRazorRange already verified end > start - } - if (resolveTimeSelection(start, end)) return true; - why = "make a razor area or a time selection first"; - return false; -} - -// Current project's directory (parent of its .rpp), forward-slashed, no trailing -// slash — the same derivation capture.cpp does internally, needed here so M10 can -// resolve the bank's relative paths to absolute for parent detection. Empty for an -// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank -// file resolve empty -> no false parentage. Read-only; mutates nothing. -static std::string currentProjectDir() -{ - std::vector buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(buf.size())); - const std::string rpp(buf.data()); - if (rpp.empty()) return {}; - namespace fs = std::filesystem; - std::string dir = fs::path(rpp).parent_path().string(); - for (char& c : dir) if (c == '\\') c = '/'; - if (dir.size() > 1 && dir.back() == '/') dir.pop_back(); - return dir; -} - -// Maps a capture FX scope onto the pure provenance scope (kept decoupled so the -// pure provenance module does not depend on render_settings). -static reasampler::ProvenanceScope provenanceScopeFor(reasampler::CaptureScope scope) -{ - return scope == reasampler::CaptureScope::Item ? reasampler::ProvenanceScope::Item - : reasampler::ProvenanceScope::Track; -} - -// Builds the M10 provenance for a capture IF it genuinely resamples from a bank -// sample, else returns nullopt (the common, non-resample case). Detection rule -// (stated honestly): the capture's source item media file(s) must all resolve, by -// exact normalized absolute path, to ONE bank sample's file (detectParent). On a -// match, records that sample's id as the parent plus a THIN capture-recipe -// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels + -// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from -// source" can replay the request and report drift. NEVER a serialized chain to -// restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per -// selected item, combined in item order; Track scope reads the track FX chain. -static std::optional buildCaptureProvenance( - const reasampler::CaptureRequest& req, - reasampler::CaptureScope scope, - const ResolvedSource& src) -{ - const std::string projectDir = currentProjectDir(); - const std::vector bankFiles = - reasampler::bankFileRefs(g_session.book(), projectDir); - - // The "what audio is being captured" source set depends on scope: item scope uses - // the SELECTED items (the user picked them); track scope uses the range-overlapping - // items ON the source tracks (the user picked the track, not the item). - const std::vector sourceFiles = - scope == reasampler::CaptureScope::Item - ? reasampler::selectedItemSourceFiles() - : reasampler::trackItemSourceFiles(src.sourceTracks, req.startSeconds, - req.endSeconds); - - const std::optional parentId = - reasampler::detectParent(sourceFiles, bankFiles); - if (!parentId) return std::nullopt; // not a resample-from-sample — no provenance - - reasampler::CaptureRecipe recipe; - recipe.scope = provenanceScopeFor(scope); - recipe.sourceMode = static_cast(req.sourceMode); - recipe.startSeconds = req.startSeconds; - recipe.endSeconds = req.endSeconds; - recipe.tailMode = static_cast(req.tailMode); - recipe.tailMs = req.tailMs; - recipe.sampleRate = req.sampleRate; - recipe.channelCount = req.channelCount; - recipe.trackGuids = req.trackGuids; - // The in-scope FX-chain identity: - // Track scope — per-track chains combined in track order (TrackFX_*). - // Item scope — per-item active-take chains combined in item order (TakeFX_*); - // the owning track's FX chain is OUT OF SCOPE for an item capture and must - // not be fingerprinted here (it is bypassed during render, not heard). - if (scope == reasampler::CaptureScope::Item) { - const int n = CountSelectedMediaItems(nullptr); - std::vector items; - items.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (it) items.push_back(it); - } - recipe.fxChainIdentity = reasampler::fxChainIdentityForItems(items); - } else { - std::vector perTrack; - perTrack.reserve(src.sourceTracks.size()); - for (MediaTrack* tr : src.sourceTracks) - perTrack.push_back(reasampler::fxChainIdentityForTrack(tr)); - recipe.fxChainIdentity = reasampler::combineChainIdentities(perTrack); - } - - reasampler::Provenance prov; - prov.parentSampleId = *parentId; - prov.fxChainSnapshot = reasampler::buildFingerprint(recipe); - return prov; -} - -// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. -static bool collectSelectedTracks(ResolvedSource& out) -{ - const int n = CountSelectedTracks(nullptr); // nullptr = active project - if (n <= 0) return false; - for (int i = 0; i < n; ++i) - { - MediaTrack* tr = GetSelectedTrack(nullptr, i); - if (!tr) continue; - out.sourceTracks.push_back(tr); - std::string g = reasampler::guidString(tr); - if (!g.empty()) out.trackGuids.push_back(std::move(g)); - } - return !out.sourceTracks.empty(); -} - -// Collects the tracks that own the selected items (Item scope) into -// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an -// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning -// track (SDK header, verify). GUIDs recorded for provenance. -static bool collectSelectedItemTracks(ResolvedSource& out) -{ - const int n = CountSelectedMediaItems(nullptr); - if (n <= 0) return false; - for (int i = 0; i < n; ++i) - { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (!it) continue; - MediaTrack* tr = GetMediaItem_Track(it); - if (!tr) continue; - // Dedup: several selected items can share a track. - bool seen = false; - for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; } - if (seen) continue; - out.sourceTracks.push_back(tr); - std::string g = reasampler::guidString(tr); - if (!g.empty()) out.trackGuids.push_back(std::move(g)); - } - return !out.sourceTracks.empty(); -} - -// Resolves the source for a scope: the selection tracks (item/track), plus the -// inferred range. Returns false with a reason on nothing to do. -static bool ResolveScopeSource(reasampler::CaptureScope scope, - ResolvedSource& out, std::string& why) -{ - using reasampler::CaptureScope; - switch (scope) - { - case CaptureScope::Item: - if (!collectSelectedItemTracks(out)) { - why = "select at least one media item"; return false; - } - break; - case CaptureScope::Track: - if (!collectSelectedTracks(out)) { - why = "select at least one track"; return false; - } - break; - } - return resolveRange(out.startSeconds, out.endSeconds, why); -} - -// --- FX-bypass + full parent-chain neutralize around render (RAII, non-destr.) -- -// For every track a scope must NOT hear the FX of, this ALSO neutralizes that -// track's fader gain AND its full pan chain (pan/width/law/mode) for the render — -// because a Track/Item capture renders via master and would otherwise sum through -// the parent/folder/master FADERS and PAN/WIDTH/LAW, printing their gain and pan -// coloring into the file (Daniel: the capture is likely re-routed through that -// same chain later, so parent/master level and pan must not be baked in). The -// neutralize set is IDENTICAL to the FX-bypass set: -// Item -> own track + all ancestors + master (take vol/pan kept: item content). -// Track -> all ancestors + master (selected track's OWN vol/pan kept). -// (Master is a bypass TARGET for both scopes — never a scope of its own.) -// -// Per track in that set we snapshot & set the full parent-chain-independence set, -// so a Track/Item capture is uncolored by the parent/folder/master it renders -// through — no FX, no fader, and no pan/width/law/mode coloring: -// I_FXEN -> 0 (FX bypassed; SDK ~2194) -// D_VOL -> 1.0 (unity trim volume; SDK ~2226 "1=+0dB") -// D_PAN -> 0.0 (center; SDK ~2227 "trim pan of track, -1..1") -// D_WIDTH -> 1.0 (full/neutral stereo width; SDK ~2228 "width, -1..1", -// 1.0 = full width = no narrowing/collapse) -// D_PANLAW -> 1.0 (no coloring; SDK ~2232 "1=+0dB" — pan-law applies no gain) -// I_PANMODE -> 5 (stereo pan; SDK ~2231 "0=classic,3=balance,5=stereo,6=dual") -// All are restored to their ORIGINAL values on EVERY exit path (RAII). -// -// Why also force I_PANMODE (pan mode). D_PAN's effect is mode-dependent. In modes -// 0/3/5, D_PAN=0 + D_WIDTH=1 is a provable pass-through. But in mode 6 (dual pan) -// D_PAN/D_WIDTH are ignored — routing is governed instead by D_DUALPANL/D_DUALPANR -// (SDK ~2229-2230, live only when I_PANMODE==6), whose neutral pass-through the -// header does not state as such. Rather than snapshot two more mode-conditional -// params and infer their neutral values, we force I_PANMODE=5 (stereo pan) for the -// render, where D_PAN=0 + D_WIDTH=1 is unambiguously uncolored, then restore the -// original mode. This fully neutralizes pan for every original mode with no -// residual — the "handle it fully" the brief requires. (See Snap dual-pan note.) -// -// Structurally non-destructive: no takes, no items, no project restructuring — -// only transient FX-enable + trim-volume toggles, always restored. -class FxBypassGuard -{ -public: - // scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose - // ancestor chains (walked via GetParentTrack) + the master are bypassed per the - // plan. proj is the active project (for GetMasterTrack). - FxBypassGuard(reasampler::CaptureScope scope, - const std::vector& sourceTracks, - ReaProject* proj) - { - const reasampler::FxBypassPlan plan = reasampler::fxBypassPlanFor(scope); - - for (MediaTrack* tr : sourceTracks) - { - if (!tr) continue; - if (plan.bypassSelfFx) bypass(tr); - if (plan.bypassAncestorFx) - { - // Walk parents to the top: GetParentTrack returns the immediate - // parent (folder) track, nullptr at the outermost level (SDK - // header ~2407). The master is NOT returned here — handled below. - for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p)) - bypass(p); - } - } - if (plan.bypassMaster) - { - // GetMasterTrack(proj) -> the master track (SDK header ~1925). bypass() - // neutralizes its FX (I_FXEN), gain (D_VOL) AND pan/width/law/mode on it - // just like any other in-scope track; only the master's summing/routing - // topology (the mix bus itself) remains — that is not a per-track param. - if (MediaTrack* master = GetMasterTrack(proj)) bypass(master); - } - } - - ~FxBypassGuard() - { - // Restore in reverse for symmetry (order is not load-bearing — each track - // appears once, snapshots are independent). EVERY snapshotted param is - // restored to its ORIGINAL value on this (every) exit path. Restore - // I_PANMODE before the pan values so any mode-conditional params (e.g. dual - // pan) settle under the original mode. - for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it) - { - SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen); - SetMediaTrackInfo_Value(it->track, "D_VOL", it->vol); - SetMediaTrackInfo_Value(it->track, "I_PANMODE", it->panmode); - SetMediaTrackInfo_Value(it->track, "D_PAN", it->pan); - SetMediaTrackInfo_Value(it->track, "D_WIDTH", it->width); - SetMediaTrackInfo_Value(it->track, "D_PANLAW", it->panlaw); - } - } - - FxBypassGuard(const FxBypassGuard&) = delete; - FxBypassGuard& operator=(const FxBypassGuard&) = delete; - -private: - // One snapshot per bypassed track: all params we neutralize, at their originals. - // panmode captures I_PANMODE so we can force stereo-pan for the render and put - // the original mode back — which also makes D_DUALPANL/D_DUALPANR (live only when - // I_PANMODE==6, SDK ~2229-2230) irrelevant during the render without us having to - // touch or guess neutral values for them. - struct Snap - { - MediaTrack* track; - double fxen; - double vol; - double pan; - double width; - double panlaw; - double panmode; - }; - std::vector snapshots_; - - // Snapshot every neutralized param once per track (dedup: an ancestor shared by - // two selected tracks must be restored to its ORIGINAL values, not to a - // re-snapshot of the already-neutralized state), then read ALL originals, push - // one Snap, and set all to neutral — bypass FX, unity gain, uncolored pan chain. - void bypass(MediaTrack* tr) - { - for (const Snap& s : snapshots_) if (s.track == tr) return; // already done - // Read ALL originals first (atomic snapshot), then push, then neutralize. - const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN"); - const double vol = GetMediaTrackInfo_Value(tr, "D_VOL"); - const double pan = GetMediaTrackInfo_Value(tr, "D_PAN"); - const double width = GetMediaTrackInfo_Value(tr, "D_WIDTH"); - const double panlaw = GetMediaTrackInfo_Value(tr, "D_PANLAW"); - const double panmode = GetMediaTrackInfo_Value(tr, "I_PANMODE"); - snapshots_.push_back({tr, fxen, vol, pan, width, panlaw, panmode}); - SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194) - SetMediaTrackInfo_Value(tr, "D_VOL", 1.0); // 1.0 = unity gain (SDK ~2226) - SetMediaTrackInfo_Value(tr, "I_PANMODE", 5.0); // 5 = stereo pan (SDK ~2231) - SetMediaTrackInfo_Value(tr, "D_PAN", 0.0); // 0.0 = center (SDK ~2227) - SetMediaTrackInfo_Value(tr, "D_WIDTH", 1.0); // 1.0 = full width (SDK ~2228) - SetMediaTrackInfo_Value(tr, "D_PANLAW", 1.0); // 1.0 = +0dB, no law (SDK ~2232) - } -}; - -// Renders one CaptureRequest through the offline backend under the scope's -// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and -// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE -// place: the out-of-scope FX / fader / pan chain is snapshotted, neutralized for the -// render, and fully restored on every path (RAII). Non-destructive; touches no -// timeline item (load-bearing principle) — it writes a file only. -static reasampler::CaptureResult renderOffline( - reasampler::CaptureScope scope, - const std::vector& sourceTracks, - const reasampler::CaptureRequest& req) -{ - ReaProject* proj = EnumProjects(-1, nullptr, 0); - FxBypassGuard fxGuard(scope, sourceTracks, proj); - reasampler::OfflineRenderBackend backend; - return backend.capture(req); -} - -// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, -// and adds the resulting Sample to the ACTIVE bank + records the created file in the -// owned-file manifest — WITHOUT persisting. The caller persists once (single-capture: -// right after; batch: once at the end) so a batch does not write ext state N times. -// -// Provenance is read from the LIVE selection here, so a batch that transiently -// selects exactly one item per unit gets per-unit-correct provenance. `src` supplies -// the source tracks (FX bypass + Sample GUIDs); `scope` drives the bypass plan and -// provenance scope. Returns the backend's CaptureResult (status + message) so the -// caller can report success/failure. Load-bearing principle holds: writes a file + -// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the -// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard), -// and the backend restores every RENDER_* setting. -// -// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id -// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8 -// capture+assign path can target the sample actually in the bank. Batch callers ignore -// it; the plain capture actions are unaffected. -static reasampler::CaptureResult captureAndIndexOne( - reasampler::CaptureScope scope, - const ResolvedSource& src, - const std::string& baseName, - double startSeconds, - double endSeconds) -{ - // The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action - // variant: the capture actions apply whatever the panel is set to. Default is None - // (exact bounds / byte-identical to today) until the user opts in via the toggle. - const reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); - - reasampler::CaptureRequest req; - req.sourceMode = reasampler::sourceModeForScope(scope); - req.startSeconds = startSeconds; // exact bounds — no rounding - req.endSeconds = endSeconds; - req.wetDry = 1.0; // wet post the FX left enabled by the scope - req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle - req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto - req.sampleRate = 0; // follow project rate - req.channelCount = 2; - req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither - req.baseName = baseName; - req.trackGuids = src.trackGuids; // recorded on the Sample (provenance) - - // M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain — - // the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and - // the source selection is still live here. Returns nullopt unless this capture - // genuinely resamples from a bank sample (detectParent). Read-only. - const std::optional prov = - buildCaptureProvenance(req, scope, src); - - // Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain - // neutralized for the render, fully restored on every path). Writes a file only. - reasampler::CaptureResult res = renderOffline(scope, src.sourceTracks, req); - if (res.status != reasampler::CaptureStatus::Ok) - return res; - - // Stamp provenance onto the captured Sample (only set when this was a genuine - // resample-from-sample; otherwise the optional stays empty, per M1's contract). - res.sample.provenance = prov; - - // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). The - // AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can - // target the sample actually in the bank (the existing entry on a collapse). - const reasampler::AddResult addResult = g_session.bank().add(res.sample); - // B-cap: record the created file in the owned-file manifest, at the same point the - // Sample is added. Recorded regardless of the index AddResult — even a hash-collapse - // still WROTE a file the tool owns, and the manifest dedups a repeat path itself - // (Phase R prune reconciles manifest vs index later). - g_session.owned().add(res.sample.relativePath); - - // Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new - // id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a - // Collapsed (the file we just rendered deduped onto an already-present sample — assign - // THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged. - if (addResult == reasampler::AddResult::Collapsed && !res.sample.contentHash.empty()) - { - if (const reasampler::Sample* existing = - g_session.bank().findByHash(res.sample.contentHash)) - res.sample.id = existing->id; - } - return res; -} - -// Runs one capture-action-table row: resolve its scope source + range, render + add + -// record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle -// holds structurally — this path writes a file + a bank index entry ONLY; it never -// calls InsertMedia or touches the arrange/timeline. -// Returns the bank-index id of the sample the capture landed on: the newly-added id on a -// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign -// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange -// capture+assign path reads this to write an assignment request; the plain capture actions -// ignore it (their behaviour is unchanged — capture still writes a file + index entry only). -static std::string RunCapture(const reasampler::CaptureActionDef& def) -{ - ResolvedSource src; - std::string why; - if (!ResolveScopeSource(def.scope, src, why)) - { - ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str()); - return {}; - } - - reasampler::CaptureResult res = - captureAndIndexOne(def.scope, src, def.baseName, src.startSeconds, src.endSeconds); - if (res.status != reasampler::CaptureStatus::Ok) - { - ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str()); - return {}; - } - - // captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE - // bank, and recorded the created file in the owned-file manifest (WITHOUT persisting). - // Persist the updated book AND manifest into the active project's ext state (the - // `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and - // travels with the .rpp. saveToActiveProject also clears the retired legacy key and - // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys. - // S9: a capture add is a bank-content change -> bump before the persist so an assigned - // live instance refreshes hands-free (the S8 capture+assign path builds on this). - g_session.bumpBankGeneration(); - g_session.saveToActiveProject(); - - // Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne - // resolved res.sample.id to the fresh id on a new add or the existing entry's id on a - // hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above). - return res.sample.id; -} - -// S8 arrange ingest: capture the selected item / time-selection into the active bank -// (reusing the Item-scope capture path verbatim) and, on success, write an assignment -// request so the active sampler instance plays the new sample on its next reload. The -// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER -// inserts a timeline item (load-bearing principle); the only addition here is the -// bank-index-id -> assignment-request write after the sample lands. If the capture -// failed / no-op'd (empty id), no assignment is written (nothing to assign). -// -// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the -// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are -// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically. -// An undo that removes the captured sample also clears the assign_request that named it, -// preventing a stale request from pointing at a removed sample. The block uses the house -// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero -// flag) matching the bank-op family in actions.cpp. -static void RunCaptureItemAssign() -{ - // Reuse the Item-scope def from the capture table (index 0) — same range logic, same - // FX-scope neutralize, same bank/persist landing as the plain "capture item" action. - Undo_BeginBlock2(nullptr); - - const std::string sampleId = - RunCapture(reasampler::captureActionTable()[0]); - if (sampleId.empty()) - { - // Capture failed or no-op'd — RunCapture already reported. Discard the empty point. - Undo_EndBlock2(nullptr, "", 0); - return; - } - - // Assign inside the same block so undo clears both keys together. - reasampler::ingestAssignActiveInstance(g_session.book().activeBankId(), sampleId); - Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance", - UNDO_STATE_MISCCFG); - - reasampler::bankPanelRefresh(); - ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " - "instance.\n"); -} - -// --- M11: batch capture (per selected item / per razor area) ---------------- -// -// One action fires N captures — one bank sample per selected item (item scope) or per -// razor area (track scope, each area's own range). Each individual capture honors every -// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan -// neutralize, relative paths, channel preservation) and M10 provenance stamping applies -// per capture where its detection rule matches. The load-bearing principle holds: each -// unit writes a file + a bank index entry ONLY; nothing lands in the arrange. -// -// Per-unit FILE NAMING: the offline backend's unique tag is 1-second-granular. The -// per-item render already takes real wall-clock time (REAPER's offline-render dialog per -// unit), so consecutive units naturally land in distinct seconds; belt-and-braces, each -// unit's baseName also carries its ordinal ("item-1", "item-2", ...) so two units are -// never asked to write the same stem within one batch. (Residual, DAW-verify: two BATCHES -// fired within the same wall-clock second with identical ordinals could still collide — -// unreachable in practice given the per-unit render latency, noted for completeness.) - -// RAII snapshot/restore of the project's media-item selection. Batch item capture must -// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is -// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including -// a mid-batch failure or early return — because selection restoration is part of the -// non-destructive invariant. Snapshot on construct (the currently-selected item set), -// restore on destruct (deselect everything, then re-select exactly the snapshot). -class ItemSelectionGuard -{ -public: - ItemSelectionGuard() - { - const int n = CountSelectedMediaItems(nullptr); - for (int i = 0; i < n; ++i) - if (MediaItem* it = GetSelectedMediaItem(nullptr, i)) - selected_.push_back(it); - } - - ~ItemSelectionGuard() - { - // Deselect every item in the project, then re-select the snapshot — restoring the - // exact original set regardless of what the batch selected in between. Iterate ALL - // items (not just the currently-selected) so any transient selection is cleared. - const int total = CountMediaItems(nullptr); - for (int i = 0; i < total; ++i) - if (MediaItem* it = GetMediaItem(nullptr, i)) - SetMediaItemSelected(it, false); - for (MediaItem* it : selected_) - SetMediaItemSelected(it, true); - UpdateArrange(); // reflect the restored selection in the arrange view - } - - ItemSelectionGuard(const ItemSelectionGuard&) = delete; - ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete; - -private: - std::vector selected_; -}; - -// Selects exactly `item` (deselect-all then select-one) so the offline render's -// selected-items bit (&32) captures a single item. Used inside the batch loop under the -// ItemSelectionGuard, which restores the user's original selection afterward. -static void selectOnlyItem(MediaItem* item) -{ - const int total = CountMediaItems(nullptr); - for (int i = 0; i < total; ++i) - if (MediaItem* it = GetMediaItem(nullptr, i)) - SetMediaItemSelected(it, it == item); -} - -// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the -// selection (RAII restore on every path), then for each selected item transiently selects -// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the -// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for -// the whole batch). Reports a mixed-result summary (explicit-action response — allowed). -static void RunBatchCaptureItems() -{ - // Read the selected items up front (pointers stay valid — batch mutates only selection - // flags, never adds/removes items). Also capture each item's exact bounds and owning - // track NOW, while the full selection is live, before any transient re-selection. - struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; }; - std::vector itemUnits; - { - const int n = CountSelectedMediaItems(nullptr); - for (int i = 0; i < n; ++i) - { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (!it) continue; - MediaTrack* tr = GetMediaItem_Track(it); - if (!tr) continue; - const double pos = GetMediaItemInfo_Value(it, "D_POSITION"); - const double len = GetMediaItemInfo_Value(it, "D_LENGTH"); - itemUnits.push_back({it, tr, pos, pos + len}); - } - } - if (itemUnits.empty()) - { - ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n"); - return; - } - - // Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/ - // inverted item ranges (a zero-length item) are dropped here so no stray render runs. - std::vector ranges; - ranges.reserve(itemUnits.size()); - for (const ItemUnit& u : itemUnits) - ranges.push_back({u.start, u.end}); - const std::vector plan = reasampler::planCaptureUnits(ranges); - - reasampler::BatchOutcome outcome; - bool anyAdded = false; - { - // Restore the user's ORIGINAL item selection on every exit path (incl. early - // return / mid-batch failure) — non-destructive invariant. - ItemSelectionGuard selGuard; - - // The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only - // for those whose range survived planning (same drop rule), matching by ordinal. - std::size_t planIdx = 0; - for (const ItemUnit& u : itemUnits) - { - if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep - const reasampler::CaptureUnit& unit = plan[planIdx++]; - - // Transiently select ONLY this item so the item-scope render captures exactly it. - selectOnlyItem(u.item); - - ResolvedSource src; - src.startSeconds = unit.startSeconds; - src.endSeconds = unit.endSeconds; - src.sourceTracks.push_back(u.track); - if (std::string g = reasampler::guidString(u.track); !g.empty()) - src.trackGuids.push_back(std::move(g)); - - const std::string baseName = "item-" + std::to_string(unit.ordinal); - reasampler::CaptureResult res = captureAndIndexOne( - reasampler::CaptureScope::Item, src, baseName, - unit.startSeconds, unit.endSeconds); - - const bool ok = (res.status == reasampler::CaptureStatus::Ok); - outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); - if (ok) anyAdded = true; - } - } // selGuard restores the original selection here, on every path - - // Persist ONCE for the whole batch (one ext-state write) — only if something landed. - // S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample, - // so a single increment past the last-seen value is enough to trigger one instance reload. - if (anyAdded) { - g_session.bumpBankGeneration(); - g_session.saveToActiveProject(); - } - - ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); -} - -// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving -// track order then area order — the batch analog of resolveRazorRange, which unions them. -// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser. -static std::vector> collectRazorAreas() -{ - std::vector> areas; - const int n = CountTracks(nullptr); - for (int i = 0; i < n; ++i) - { - MediaTrack* tr = GetTrack(nullptr, i); - if (!tr) continue; - std::vector buf(8192, '\0'); - if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) - continue; - for (const reasampler::RazorRange& r : - reasampler::parseRazorEdits(std::string(buf.data()))) - areas.push_back({tr, r}); - } - return areas; -} - -// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must -// transiently select exactly the area's owning track per render (track scope's &128 bit -// renders whatever TRACKS are selected); the user's original track selection is restored -// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard. -class TrackSelectionGuard -{ -public: - TrackSelectionGuard() - { - const int n = CountSelectedTracks(nullptr); - for (int i = 0; i < n; ++i) - if (MediaTrack* tr = GetSelectedTrack(nullptr, i)) - selected_.push_back(tr); - } - - ~TrackSelectionGuard() - { - // Deselect every track, then re-select the snapshot — the exact original set. - const int total = CountTracks(nullptr); - for (int i = 0; i < total; ++i) - if (MediaTrack* tr = GetTrack(nullptr, i)) - SetTrackSelected(tr, false); - for (MediaTrack* tr : selected_) - SetTrackSelected(tr, true); - } - - TrackSelectionGuard(const TrackSelectionGuard&) = delete; - TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete; - -private: - std::vector selected_; -}; - -// Batch razor capture: one bank sample per razor AREA, track scope over that area's own -// range (the area's owning track is the source track). Track scope renders the selected -// TRACKS via master (&128), so each unit transiently selects ONLY its owning track -// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original -// track selection on every path. The razor selection itself is read-only and left intact. -// Persists ONCE at the end. Reports a mixed-result summary. -static void RunBatchCaptureRazor() -{ - const std::vector> areas = - collectRazorAreas(); - if (areas.empty()) - { - ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n"); - return; - } - - std::vector ranges; - ranges.reserve(areas.size()); - for (const auto& a : areas) - ranges.push_back({a.second.startSeconds, a.second.endSeconds}); - const std::vector plan = reasampler::planCaptureUnits(ranges); - - reasampler::BatchOutcome outcome; - bool anyAdded = false; - { - // Restore the user's ORIGINAL track selection on every exit path. - TrackSelectionGuard selGuard; - - std::size_t planIdx = 0; - for (const auto& a : areas) - { - if (!(a.second.endSeconds > a.second.startSeconds)) continue; // dropped — lockstep - const reasampler::CaptureUnit& unit = plan[planIdx++]; - MediaTrack* tr = a.first; - - // Transiently select ONLY this track so the track-scope render (&128) captures - // exactly it via master (over the custom time bounds we set per unit). - SetOnlyTrackSelected(tr); - - ResolvedSource src; - src.startSeconds = unit.startSeconds; - src.endSeconds = unit.endSeconds; - src.sourceTracks.push_back(tr); - if (std::string g = reasampler::guidString(tr); !g.empty()) - src.trackGuids.push_back(std::move(g)); - - const std::string baseName = "razor-" + std::to_string(unit.ordinal); - reasampler::CaptureResult res = captureAndIndexOne( - reasampler::CaptureScope::Track, src, baseName, - unit.startSeconds, unit.endSeconds); - - const bool ok = (res.status == reasampler::CaptureStatus::Ok); - outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); - if (ok) anyAdded = true; - } - } // selGuard restores the original track selection here, on every path - - // S9: one coalesced bump for the whole razor batch (see the item-batch note above). - if (anyAdded) { - g_session.bumpBankGeneration(); - g_session.saveToActiveProject(); - } - - ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str()); -} - -// --- M10: re-capture from source -------------------------------------------- -// -// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT -// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and -// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the -// load-bearing capture-never-places line, structurally visible: this function has no -// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore -// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places -// manually if they want the new version on the timeline. -// -// Failure modes are handled explicitly and reported to the user (a direct response -// to an explicit action is allowed by the console policy): -// * the selected sample has no provenance (not a resample) -> reported, no-op. -// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op. -// * the recorded source track(s) no longer exist -> reported, no-op. -// * the render itself fails to satisfy the recorded request -> reported, no-op. -// On success, if the source FX chain drifted since capture (recorded vs current -// identity differ) the user is told — the re-capture still reflects the source AS IT -// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source). -static void RunRecaptureFromSource() -{ - const std::vector selected = reasampler::bankPanelSelectedSampleIds(); - if (selected.empty()) - { - ShowConsoleMsg("ReaSampler re-capture: select a sample in the bank panel first.\n"); - return; - } - if (selected.size() > 1) - { - ShowConsoleMsg("ReaSampler re-capture: select a single sample to re-capture.\n"); - return; - } - const std::string sampleId = selected.front(); - - // Resolve the sample from the bank it lives in (the focused region's displayed bank). - const std::string srcBankId = reasampler::bankPanelSelectedSourceBankId(); - const reasampler::Bank* bank = g_session.book().bank(srcBankId); - const reasampler::Sample* orig = bank ? bank->index.query(sampleId) : nullptr; - if (!orig) - { - ShowConsoleMsg("ReaSampler re-capture: the selected sample is no longer in the bank.\n"); - return; - } - if (!orig->provenance) - { - ShowConsoleMsg("ReaSampler re-capture: this sample has no provenance " - "(it was not resampled from a bank sample).\n"); - return; - } - - // Parse the recorded capture recipe from the fingerprint. A legacy / corrupt - // string fails gracefully — never a partial re-capture. - const std::string recordedParentId = orig->provenance->parentSampleId; - const std::string recordedFingerprint = orig->provenance->fxChainSnapshot; - const std::optional recipe = - reasampler::parseFingerprint(recordedFingerprint); - if (!recipe) - { - ShowConsoleMsg("ReaSampler re-capture: this sample's provenance is unreadable " - "(recorded by an older/incompatible build); cannot re-capture.\n"); - return; - } - - // Resolve the recorded source track GUID(s) to live tracks. Any missing track is a - // hard failure — we will not silently re-capture a different source. - std::vector sourceTracks; - for (const std::string& g : recipe->trackGuids) - { - MediaTrack* tr = reasampler::trackByGuid(g); - if (!tr) - { - ShowConsoleMsg("ReaSampler re-capture: a recorded source track no longer " - "exists in this project; cannot re-capture from source.\n"); - return; - } - sourceTracks.push_back(tr); - } - if (sourceTracks.empty()) - { - // The recipe recorded no source tracks (e.g. an item-scope capture whose source - // tracks were not track-scoped). Without a resolvable source we cannot re-run. - ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this " - "sample; cannot re-capture from source.\n"); - return; - } - - const reasampler::CaptureScope scope = - recipe->scope == reasampler::ProvenanceScope::Item - ? reasampler::CaptureScope::Item - : reasampler::CaptureScope::Track; - - // Rebuild the capture request verbatim from the recorded recipe — the SAME request, - // re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate, - // channels, bit depth all match the original so an unchanged source produces a - // byte-identical file (bit-identical-repeats invariant, consumed as a feature). - reasampler::CaptureRequest req; - req.sourceMode = static_cast(recipe->sourceMode); - req.startSeconds = recipe->startSeconds; - req.endSeconds = recipe->endSeconds; - req.wetDry = 1.0; - req.tailMode = static_cast(recipe->tailMode); - req.tailMs = recipe->tailMs; - req.sampleRate = recipe->sampleRate; - req.channelCount = recipe->channelCount; - req.bitDepth = reasampler::WavBitDepth::Float32; - req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName; - req.trackGuids = recipe->trackGuids; - - // Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to - // compare against the recorded identity for drift reporting. Mirror the same - // scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*; - // track scope reads the track FX chain via TrackFX_*. - std::string currentIdentity; - if (scope == reasampler::CaptureScope::Item) { - const int n = CountSelectedMediaItems(nullptr); - std::vector items; - items.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (it) items.push_back(it); - } - currentIdentity = reasampler::fxChainIdentityForItems(items); - } else { - std::vector perTrackNow; - perTrackNow.reserve(sourceTracks.size()); - for (MediaTrack* tr : sourceTracks) - perTrackNow.push_back(reasampler::fxChainIdentityForTrack(tr)); - currentIdentity = reasampler::combineChainIdentities(perTrackNow); - } - const bool drifted = (currentIdentity != recipe->fxChainIdentity); - - // Render (bank-only; renderOffline never touches the timeline). - reasampler::CaptureResult res = renderOffline(scope, sourceTracks, req); - if (res.status != reasampler::CaptureStatus::Ok) - { - ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str()); - return; - } - - // Update the Sample IN PLACE: keep its identity (id) and its provenance thread - // (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but - // adopt the regenerated file's path / hash / length / rate / timestamp. The - // fingerprint is rebuilt from the recipe with the CURRENT FX identity so a - // subsequent re-capture measures drift from this point, not the original. - reasampler::CaptureRecipe refreshed = *recipe; - refreshed.fxChainIdentity = currentIdentity; - - reasampler::Sample updated = *orig; // copy: preserves id, displayName, tier, key - updated.relativePath = res.sample.relativePath; - updated.contentHash = res.sample.contentHash; - updated.sourceMode = res.sample.sourceMode; - updated.sourceRange = res.sample.sourceRange; - updated.channelCount = res.sample.channelCount; - updated.sampleRate = res.sample.sampleRate; - updated.lengthSeconds = res.sample.lengthSeconds; - updated.captureTempo = res.sample.captureTempo; - updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp - updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter - updated.trackGuids = res.sample.trackGuids; - updated.createdTimestamp = res.sample.createdTimestamp; - // NOTE: levels, clipped, and lengthBeats are carried from the original (via the - // *orig copy above) because the offline backend does not populate them today - // (res.sample leaves them at defaults). If a later milestone populates these - // fields at capture time, refresh them here from res.sample instead. - reasampler::Provenance prov; - prov.parentSampleId = recordedParentId; - prov.fxChainSnapshot = reasampler::buildFingerprint(refreshed); - updated.provenance = prov; - - // Single batched undo point around the in-place bank mutation (mirrors the bank - // action family's R-B pattern). The mutation is index-only ext-state; the render - // wrote a new file but placed nothing on the timeline. - Undo_BeginBlock2(nullptr); - const bool changed = g_session.book().updateSampleInPlace(sampleId, updated); - if (changed) - { - // Record the regenerated file in the owned manifest (a new file the tool wrote); - // the superseded old file becomes an orphan reclaimed by Phase R prune. - g_session.owned().add(updated.relativePath); - // S9: re-capture-in-place regenerates the SAME id's audio — the exact case the - // hands-free refresh exists for (an instance referencing this id keeps playing the - // OLD audio until it reloads). Bump inside the undo block so undo rolls back the - // generation with the rest of the blob. - g_session.bumpBankGeneration(); - const bool persisted = g_session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty - Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", - persisted ? UNDO_STATE_MISCCFG : 0); - } - else - { - Undo_EndBlock2(nullptr, "", 0); // nothing mutated -> discard the empty point - } - - reasampler::bankPanelRefresh(); // reflect the regenerated file in the docked grid - - if (drifted) - ShowConsoleMsg("ReaSampler re-capture: the source FX chain changed since the " - "original capture -- the sample was regenerated from the source's " - "current state.\n"); -} - -// STARTS the REALTIME track capture and returns immediately — the record runs across -// timer ticks (DriveRealtimeCapture), so REAPER's UI stays responsive. Resolves the -// selected tracks + the range (razor-else-time, the same orthogonal range logic as the -// offline scopes) and starts recording each selected track's OWN output into a hidden -// temp track via RealtimeRecordBackend::begin (a send FROM each source track INTO the -// temp — see capture_realtime.cpp §TAP); OnTimer drives it to completion, then adds the -// Sample and persists. TRACK scope only this increment (item realtime is deferred). -// Dialog-free. Non-bit-identical by nature (it is realtime) — offline stays the -// deterministic default. FxBypassGuard is NOT used here — the track-output tap is -// PRE-parent by construction (§TAP), so there is no live chain to neutralize. The -// load-bearing principle holds structurally — this writes a file + a bank entry ONLY; -// the temp track is a transient sink removed by the backend, nothing lands in arrange. -// -// A SECOND realtime capture requested while one is in progress is REJECTED — the -// first keeps running (we own the transport for its window; starting a second would -// collide on the transport and the temp-track/arm snapshot). -static void RunCaptureRealtimeTrack() -{ - if (g_rtCapture) - { - ShowConsoleMsg("ReaSampler realtime capture: a capture is already in " - "progress -- let it finish (or stop the transport) first.\n"); - return; - } - - // Resolve the selected tracks + range exactly as the offline Track scope does. - // No track selected -> refuse (same no-op as offline track scope). - ResolvedSource src; - std::string why; - if (!ResolveScopeSource(reasampler::CaptureScope::Track, src, why)) - { - ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str()); - return; - } - - // The tail mode is the SAME panel setting the offline capture actions read (the - // docked bank panel's toggle). Realtime honors it via a parallel path: the backend - // records a generous window past the range end, then trims by PCM decay-scan (T2 / - // capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None - // keeps realtime exact-bounds / byte-identical to today. - const reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); - - reasampler::CaptureRequest req; - req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope - req.startSeconds = src.startSeconds; // exact bounds — no rounding - req.endSeconds = src.endSeconds; - req.wetDry = 1.0; // fully wet (post-fader tap) - req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle - req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto - req.sampleRate = 0; // follow project rate - req.channelCount = 2; - req.bitDepth = reasampler::WavBitDepth::Float32; - req.baseName = "realtime"; - req.trackGuids = src.trackGuids; // provenance on the Sample - - reasampler::CaptureResult failure; - reasampler::RealtimeCaptureHandle st = - g_rtBackend.begin(req, src.sourceTracks, failure); - if (!st) - { - // begin() validated/failed and already restored anything it touched. - ShowConsoleMsg(("ReaSampler realtime capture failed: " + failure.message + "\n").c_str()); - return; - } - - // Started. Store the in-flight state + its project; OnTimer drives it to - // completion across ticks (UI stays responsive). - g_rtCaptureProject = EnumProjects(-1, nullptr, 0); - g_rtCapture = std::move(st); -} - -// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates -// via abort() — stop the transport + restore ALL snapshotted state (non-destructive), -// committing whatever audio was already captured (best effort) so a cancel near the end -// still keeps the take. Runs only against the record's OWN project (abort() self-guards -// the closed-project case, review §1). No-op with a note when nothing is in flight. -static void RunCancelRealtime() -{ - if (!g_rtCapture) - { - ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n"); - return; - } - reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); - if (r.status == reasampler::RealtimeTickStatus::Done) - CommitRealtimeResult(r.result); // Ok: keep what was captured up to the cancel - else - ShowConsoleMsg(("ReaSampler realtime capture cancelled -- " + - r.result.message + "\n").c_str()); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; -} - -// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor -// via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match -// variant (never silent — it fires only from the distinct "conform" action). This -// is the INTENDED placement path: it adds items to the arrange on purpose -// (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action. -static void RunInsertSelected(bool conform) -{ - reasampler::InsertRequest req; - // target defaults to CurrentTrack (InsertOptions::target) — inserts onto the - // user's currently-selected track(s) at the edit cursor. - req.options.conform = - conform ? reasampler::TempoConform::Ratio1x : reasampler::TempoConform::None; - // preservePitch stays true: a tempo conform matches tempo without varispeeding - // pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.) - - reasampler::InsertResult res = reasampler::runInsert(&g_session, req); - - switch (res.status) - { - case reasampler::InsertStatus::Ok: - break; // success — no console chatter - case reasampler::InsertStatus::NoSelection: - // "select a track first" is printed by runInsert when no track is - // selected; this branch covers the no-panel-selection case. - ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n"); - break; - case reasampler::InsertStatus::NoProject: - ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n"); - break; - case reasampler::InsertStatus::NothingResolved: - ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n"); - break; - } -} - -// REAPER calls this for EVERY action fired anywhere; claim only our own id, -// return false otherwise so REAPER keeps looking. -static bool OnHookCommand(int command, int /*flag*/) -{ - if (command == 0) return false; - // Three-scope capture family: command ids parallel captureActionTable() 1:1 by index. - // Claim the fired id if it is one of ours and route to its table row. - for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i) - if (command == g_captureCmdIds[i]) - { - RunCapture(reasampler::captureActionTable()[i]); - return true; - } - if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; } - if (command == g_cmdCaptureItemAssign) { RunCaptureItemAssign(); return true; } - if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; } - if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } - if (command == g_cmdCaptureBatchItems) { RunBatchCaptureItems(); return true; } - if (command == g_cmdCaptureBatchRazor) { RunBatchCaptureRazor(); return true; } - if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; } - if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; } - if (command == g_cmdRecaptureFromSource) { RunRecaptureFromSource(); return true; } - if (command == g_cmdShowVersion) - { - // On-demand version readout — the ONLY version output on any path. - ShowConsoleMsg(("ReaSampler " + reasampler::appVersion() + "\n").c_str()); - return true; - } - // Design View action family (D4). Claims only its own ids; returns false for the - // rest so this hook keeps looking (per the contract). - if (reasampler::designViewHandleCommand(command)) return true; - // Multi-bank action family (B3). Same contract: claims only its own ids. - if (reasampler::bankHandleCommand(command)) return true; - // S8 ingest action family (Media-Explorer import). Same contract. - if (reasampler::ingestHandleCommand(command)) return true; - return false; -} - -// REAPER polls this to render each of OUR actions' checked state in menus/toolbars. -// Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract). -static int OnToggleAction(int command) -{ - if (command == g_cmdToggleBankPanel) - return reasampler::bankPanelIsOpen() ? 1 : 0; - return -1; // not ours / non-toggling -} - -// gaccel storage must outlive registration — REAPER holds the pointer. -// (The capture family's accels live in g_captureAccels, sized to the table.) -static gaccel_register_t g_accelToggleBankPanel{}; -static gaccel_register_t g_accelCaptureItemAssign{}; -static gaccel_register_t g_accelInsertSelected{}; -static gaccel_register_t g_accelInsertSelectedConform{}; -static gaccel_register_t g_accelCaptureBatchItems{}; -static gaccel_register_t g_accelCaptureBatchRazor{}; -static gaccel_register_t g_accelCaptureTrackRealtime{}; -static gaccel_register_t g_accelCancelRealtime{}; -static gaccel_register_t g_accelRecaptureFromSource{}; -static gaccel_register_t g_accelShowVersion{}; - -// gaccel desc storage. The Actions-list label is channel-qualified at runtime -// (channelActionName) so it cannot be a string literal; REAPER holds the gaccel's `desc` -// pointer, so each label lives here for the module lifetime. Composed once at registration. -static std::string g_descToggleBankPanel; -static std::string g_descCaptureItemAssign; -static std::string g_descInsertSelected; -static std::string g_descInsertSelectedConform; -static std::string g_descCaptureBatchItems; -static std::string g_descCaptureBatchRazor; -static std::string g_descCaptureTrackRealtime; -static std::string g_descCancelRealtime; -static std::string g_descRecaptureFromSource; -static std::string g_descShowVersion; - -// Composed command-id strings (channel-qualified), interned so register and the mirroring -// '-command_id' unregister pass the SAME pointer. Set during registration; read on unload. -static const char* g_idToggleBankPanel = nullptr; -static const char* g_idCaptureItemAssign = nullptr; -static const char* g_idInsertSelected = nullptr; -static const char* g_idInsertSelectedConform = nullptr; -static const char* g_idCaptureBatchItems = nullptr; -static const char* g_idCaptureBatchRazor = nullptr; -static const char* g_idCaptureTrackRealtime = nullptr; -static const char* g_idCancelRealtime = nullptr; -static const char* g_idRecaptureFromSource = nullptr; -static const char* g_idShowVersion = nullptr; - -extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( - REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec) -{ - if (!rec) - { - // rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every - // callback with the same strings prefixed '-' (per the contract). - if (g_rec) - { - // Abort any in-flight realtime capture FIRST, while the API pointers are - // still live — finalize-or-abort + restore so we never leave a temp track, - // an armed track, or an altered transport/cursor in the user's project on - // unload. Commit whatever was captured (best effort) before tearing down. - if (g_rtCapture) - { - reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); - CommitRealtimeResult(r.result); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; - } - - g_rec->Register("-timer", (void*)&OnTimer); - g_rec->Register("-projectconfig", (void*)&g_projectConfig); - g_rec->Register("-toggleaction", (void*)&OnToggleAction); - g_rec->Register("-hookcommand", (void*)&OnHookCommand); - // Tear down the Design View action family (D4) — mirror-unregisters each - // gaccel + command_id with '-'-prefixed strings. After the hook is gone. - reasampler::designViewUnregisterActions(g_rec); - // Tear down the multi-bank action family (B3) — same mirror-unregister. - reasampler::bankUnregisterActions(g_rec); - // Tear down the S8 ingest action family — same mirror-unregister. - reasampler::ingestUnregisterActions(g_rec); - // Each '-command_id' re-presents the SAME interned, channel-qualified pointer - // used at register (g_id*), so the mirror-unregister matches exactly. - g_rec->Register("-gaccel", (void*)&g_accelShowVersion); - g_rec->Register("-command_id", (void*)g_idShowVersion); - g_rec->Register("-gaccel", (void*)&g_accelRecaptureFromSource); - g_rec->Register("-command_id", (void*)g_idRecaptureFromSource); - g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime); - g_rec->Register("-command_id", (void*)g_idCancelRealtime); - g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime); - g_rec->Register("-command_id", (void*)g_idCaptureTrackRealtime); - g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchRazor); - g_rec->Register("-command_id", (void*)g_idCaptureBatchRazor); - g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchItems); - g_rec->Register("-command_id", (void*)g_idCaptureBatchItems); - g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform); - g_rec->Register("-command_id", (void*)g_idInsertSelectedConform); - g_rec->Register("-gaccel", (void*)&g_accelInsertSelected); - g_rec->Register("-command_id", (void*)g_idInsertSelected); - g_rec->Register("-gaccel", (void*)&g_accelCaptureItemAssign); - g_rec->Register("-command_id", (void*)g_idCaptureItemAssign); - g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel); - g_rec->Register("-command_id", (void*)g_idToggleBankPanel); - // Mirror-unregister the capture family: gaccel + command_id per row, with - // '-'-prefixed strings (per the contract). The command id is re-composed from - // the same suffix + channel prefix used at register — identical string. - { - const auto& table = reasampler::captureActionTable(); - for (std::size_t i = 0; i < table.size(); ++i) - { - if (i < g_captureAccels.size()) - g_rec->Register("-gaccel", (void*)&g_captureAccels[i]); - const std::string id = - reasampler::channelCommandId(table[i].commandSuffix); - g_rec->Register("-command_id", (void*)id.c_str()); - } - } - // Retire the removed M7 command ids (command_id only — we never held a gaccel - // for them this session). Clears stale user keybindings on unload. Composed - // per channel so a beta clears beta-qualified retired ids, stable clears its own. - for (const char* suffix : kRetiredCaptureCmdSuffixes) - { - const std::string id = reasampler::channelCommandId(suffix); - g_rec->Register("-command_id", (void*)id.c_str()); - } - } - // Destroy the docked window and release cached thumbnails before we drop - // the API pointers (DockWindowRemove/DestroyWindow need them live). - reasampler::bankPanelShutdown(); - g_rec = nullptr; - return 0; - } - - // ABI guard: the struct layout we compiled against must match this REAPER. - if (rec->caller_version != REAPER_PLUGIN_VERSION) - return 0; - - // Resolve every REAPER API function pointer. Returns the number that FAILED - // to load; 0 == success. Non-zero usually means REAPER is older than our SDK. - if (REAPERAPI_LoadAPI(rec->GetFunc) != 0) - return 0; - - g_hInst = hInstance; - g_rec = rec; - - // Register the three-scope capture action family (command_id -> gaccel per table row). - // The single hookcommand below routes every fired id back to its row by index. - // g_captureAccels must be sized BEFORE the loop and never reallocated after — - // REAPER holds a pointer to each element until we mirror-unregister it. - { - const auto& table = reasampler::captureActionTable(); - g_captureCmdIds.assign(table.size(), 0); - g_captureAccels.assign(table.size(), gaccel_register_t{}); - g_captureDescs.assign(table.size(), std::string{}); - for (std::size_t i = 0; i < table.size(); ++i) - { - // Compose the channel-qualified id (prefix + suffix) and label - // ("ReaSampler[ beta]: " + phrase). The id is interned so unregister re-presents - // the same pointer; the label lives in g_captureDescs for the gaccel's lifetime. - const int cmd = - rec->Register("command_id", (void*)internCmdId(table[i].commandSuffix)); - g_captureCmdIds[i] = cmd; - if (cmd) - { - g_captureDescs[i] = - reasampler::channelActionName(table[i].descriptionPhrase); - g_captureAccels[i].accel.cmd = cmd; - g_captureAccels[i].desc = g_captureDescs[i].c_str(); - rec->Register("gaccel", (void*)&g_captureAccels[i]); - } - } - } - - // Point the bank panel at the live session BEFORE registering its action, so - // a toggle firing immediately has a session to read (M5). Does not open the - // window — only stores the session pointer. - reasampler::bankPanelInit(&g_session); - - // Register the M5 "toggle bank panel" action (command_id -> gaccel -> - // hookcommand + toggleaction for the checked state). Id + label are channel-qualified. - g_idToggleBankPanel = internCmdId("TOGGLE_BANK_PANEL"); - g_cmdToggleBankPanel = rec->Register("command_id", (void*)g_idToggleBankPanel); - if (g_cmdToggleBankPanel) - { - g_descToggleBankPanel = reasampler::channelActionName("toggle bank panel"); - g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel; - g_accelToggleBankPanel.desc = g_descToggleBankPanel.c_str(); - rec->Register("gaccel", (void*)&g_accelToggleBankPanel); - rec->Register("toggleaction", (void*)&OnToggleAction); - } - - // Register the S8 "capture selected item / time-selection into bank + assign" action - // (command_id -> gaccel -> hookcommand). Reuses the Item-scope offline capture path and - // writes an assignment request so the active instance plays the new sample. Channel- - // qualified FOREVER-STABLE id (suffix CAPTURE_ITEM_ASSIGN). MIDI-bindable like every - // capture action. Registered in the capture family (main.cpp) because it leans on the - // capture render machinery here; the other two ingest surfaces live in the ingest family - // (Media-Explorer import) and the panel drop callback. - g_idCaptureItemAssign = internCmdId("CAPTURE_ITEM_ASSIGN"); - g_cmdCaptureItemAssign = rec->Register("command_id", (void*)g_idCaptureItemAssign); - if (g_cmdCaptureItemAssign) - { - g_descCaptureItemAssign = reasampler::channelActionName( - "capture selected item into bank + assign to active instance"); - g_accelCaptureItemAssign.accel.cmd = g_cmdCaptureItemAssign; - g_accelCaptureItemAssign.desc = g_descCaptureItemAssign.c_str(); - rec->Register("gaccel", (void*)&g_accelCaptureItemAssign); - } - - // Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two - // variants: native-length (default, no stretch) and the EXPLICIT conform-to- - // tempo opt-in. Both read the bank panel selection and place at the edit cursor. - g_idInsertSelected = internCmdId("INSERT_SELECTED"); - g_cmdInsertSelected = rec->Register("command_id", (void*)g_idInsertSelected); - if (g_cmdInsertSelected) - { - g_descInsertSelected = - reasampler::channelActionName("insert selected sample at edit cursor"); - g_accelInsertSelected.accel.cmd = g_cmdInsertSelected; - g_accelInsertSelected.desc = g_descInsertSelected.c_str(); - rec->Register("gaccel", (void*)&g_accelInsertSelected); - } - - g_idInsertSelectedConform = internCmdId("INSERT_SELECTED_CONFORM"); - g_cmdInsertSelectedConform = rec->Register("command_id", (void*)g_idInsertSelectedConform); - if (g_cmdInsertSelectedConform) - { - g_descInsertSelectedConform = reasampler::channelActionName( - "insert selected sample at edit cursor (conform to tempo)"); - g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform; - g_accelInsertSelectedConform.desc = g_descInsertSelectedConform.c_str(); - rec->Register("gaccel", (void*)&g_accelInsertSelectedConform); - } - - // Register the M11 batch-capture actions (command_id -> gaccel -> hookcommand). Each - // fires N captures (one bank sample per selected item / per razor area), honoring every - // precision invariant per unit and restoring the original selection on every path. - // Channel-qualified FOREVER-STABLE ids. - g_idCaptureBatchItems = internCmdId("CAPTURE_BATCH_ITEMS"); - g_cmdCaptureBatchItems = rec->Register("command_id", (void*)g_idCaptureBatchItems); - if (g_cmdCaptureBatchItems) - { - g_descCaptureBatchItems = - reasampler::channelActionName("batch capture selected items (one per item)"); - g_accelCaptureBatchItems.accel.cmd = g_cmdCaptureBatchItems; - g_accelCaptureBatchItems.desc = g_descCaptureBatchItems.c_str(); - rec->Register("gaccel", (void*)&g_accelCaptureBatchItems); - } - - g_idCaptureBatchRazor = internCmdId("CAPTURE_BATCH_RAZOR"); - g_cmdCaptureBatchRazor = rec->Register("command_id", (void*)g_idCaptureBatchRazor); - if (g_cmdCaptureBatchRazor) - { - g_descCaptureBatchRazor = - reasampler::channelActionName("batch capture razor areas (one per area)"); - g_accelCaptureBatchRazor.accel.cmd = g_cmdCaptureBatchRazor; - g_accelCaptureBatchRazor.desc = g_descCaptureBatchRazor.c_str(); - rec->Register("gaccel", (void*)&g_accelCaptureBatchRazor); - } - - // Register the "capture selected track (realtime)" action (command_id -> gaccel -> - // hookcommand). Realtime sibling of the offline CAPTURE_TRACK scope: records the - // selected track's own output in realtime into a hidden temp track, moves it into - // the bank. Dialog-free. Channel-qualified FOREVER-STABLE id. - g_idCaptureTrackRealtime = internCmdId("CAPTURE_TRACK_REALTIME"); - g_cmdCaptureTrackRealtime = rec->Register("command_id", (void*)g_idCaptureTrackRealtime); - if (g_cmdCaptureTrackRealtime) - { - g_descCaptureTrackRealtime = - reasampler::channelActionName("capture selected track (realtime)"); - g_accelCaptureTrackRealtime.accel.cmd = g_cmdCaptureTrackRealtime; - g_accelCaptureTrackRealtime.desc = g_descCaptureTrackRealtime.c_str(); - rec->Register("gaccel", (void*)&g_accelCaptureTrackRealtime); - } - - // Cancel-in-flight sibling: aborts a running realtime capture (stop + restore). - // Channel-qualified FOREVER-STABLE id. - g_idCancelRealtime = internCmdId("CANCEL_REALTIME_CAPTURE"); - g_cmdCancelRealtime = rec->Register("command_id", (void*)g_idCancelRealtime); - if (g_cmdCancelRealtime) - { - g_descCancelRealtime = reasampler::channelActionName("cancel realtime capture"); - g_accelCancelRealtime.accel.cmd = g_cmdCancelRealtime; - g_accelCancelRealtime.desc = g_descCancelRealtime.c_str(); - rec->Register("gaccel", (void*)&g_accelCancelRealtime); - } - - // Register the M10 "re-capture from source" action (command_id -> gaccel -> - // hookcommand). Regenerates the selected provenanced sample from its recorded - // source's current state; bank-only, never places on the timeline. Channel- - // qualified FOREVER-STABLE id (suffix RECAPTURE_FROM_SOURCE). - g_idRecaptureFromSource = internCmdId("RECAPTURE_FROM_SOURCE"); - g_cmdRecaptureFromSource = rec->Register("command_id", (void*)g_idRecaptureFromSource); - if (g_cmdRecaptureFromSource) - { - g_descRecaptureFromSource = reasampler::channelActionName("re-capture from source"); - g_accelRecaptureFromSource.accel.cmd = g_cmdRecaptureFromSource; - g_accelRecaptureFromSource.desc = g_descRecaptureFromSource.c_str(); - rec->Register("gaccel", (void*)&g_accelRecaptureFromSource); - } - - // Register the Phase V "show version" action (command_id -> gaccel -> hookcommand). - // On-demand only — prints the CMake-sourced version to the console when fired; no - // startup print. Channel-qualified FOREVER-STABLE id; label carries the channel prefix - // so a beta's "show version" is distinguishable from stable's in the Actions list. - g_idShowVersion = internCmdId("SHOW_VERSION"); - g_cmdShowVersion = rec->Register("command_id", (void*)g_idShowVersion); - if (g_cmdShowVersion) - { - g_descShowVersion = reasampler::channelActionName("show version"); - g_accelShowVersion.accel.cmd = g_cmdShowVersion; - g_accelShowVersion.desc = g_descShowVersion.c_str(); - rec->Register("gaccel", (void*)&g_accelShowVersion); - } - - // Register the Design View action family (D4): toggle/activate mode, tag/untag/ - // show-both selected tracks. Each mints its own command_id + gaccel; the single - // hookcommand below routes them via designViewHandleCommand. Registered before - // the hook so every id is minted first. - reasampler::designViewRegisterActions(rec, &g_session); - - // Register the multi-bank action family (B3): create/rename/delete/evacuate bank, - // activate (cycle + pool), move/copy selected samples to a bank, and the two - // full-height layout toggles. Shares g_session with the Design View family; routed - // by the same hookcommand via bankHandleCommand. Registered before the hook. - reasampler::bankRegisterActions(rec, &g_session); - - // Register the S8 ingest action family: the Media-Explorer import-into-bank+assign - // action. Shares g_session with the other families; routed by the same hookcommand via - // ingestHandleCommand. (The arrange capture+assign action is registered in the capture - // family above; the drop path is a bank_panel callback, not a bindable action.) - reasampler::ingestRegisterActions(rec, &g_session); - - // One hookcommand routes every ReaSampler action (spike + toggle + Design View). - // Registered once, after all command ids are minted. - rec->Register("hookcommand", (void*)&OnHookCommand); - - // Drive project-load / Save-As detection (M4 persist). The timer polls the - // active project each tick; on a project load it reloads the bank from ext - // state, on a Save-As it relocates the bank folder under the new .rpp. - rec->Register("timer", (void*)&OnTimer); - - // Register the projectconfig hook so an UNDO/REDO state restore reloads the - // session's book + view from the restored ext state (R-B). The timer's identity - // poll cannot see an undo (same project identity), so this hook owns undo/redo; it - // requests a deferred reload that the next timer tick drains (see the hook comment). - rec->Register("projectconfig", (void*)&g_projectConfig); - - return 1; // success — REAPER keeps us loaded -} diff --git a/src/owned_manifest.cpp b/src/owned_manifest.cpp deleted file mode 100644 index 08c4798..0000000 --- a/src/owned_manifest.cpp +++ /dev/null @@ -1,315 +0,0 @@ -#include "owned_manifest.h" - -#include -#include - -// owned_manifest implementation. -// -// JSON is hand-rolled and self-contained (project convention: the pure core is -// dependency-free — no third-party JSON lib, 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"]} -// -// so a compact writer + a focused string-array parser is all it needs — far smaller -// than bank_model's full recursive-descent parser, because there is exactly one key -// and one value kind. - -namespace reasampler { - -// --------------------------------------------------------------------------- -// path invariant (mirror of bank_model's isAbsolutePath) -// --------------------------------------------------------------------------- - -namespace { - -// Any leading '/' or '\' (POSIX root / UNC), or a leading : (Windows drive, -// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to -// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant -// must match exactly (a path the index accepts must be recordable, and vice versa). -bool isAbsolutePath(const std::string& p) { - if (p.empty()) return false; - if (p[0] == '/' || p[0] == '\\') return true; - if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') - return true; - return false; -} - -} // namespace - -// --------------------------------------------------------------------------- -// mutation / query -// --------------------------------------------------------------------------- - -ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) { - if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath; - if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath; - if (contains(relativePath)) return ManifestAddResult::AlreadyPresent; - paths_.push_back(relativePath); - return ManifestAddResult::Added; -} - -bool OwnedFileManifest::contains(const std::string& relativePath) const { - for (const auto& p : paths_) - if (p == relativePath) return true; - return false; -} - -// --------------------------------------------------------------------------- -// JSON writer -// --------------------------------------------------------------------------- - -namespace { - -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", - static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -} // namespace - -std::string OwnedFileManifest::serialize() const { - std::string out = "{\"owned\":["; - for (std::size_t i = 0; i < paths_.size(); ++i) { - if (i) out += ','; - writeEscaped(out, paths_[i]); - } - out += "]}"; - return out; -} - -// --------------------------------------------------------------------------- -// JSON parser (string-array only) -// --------------------------------------------------------------------------- - -namespace { - -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - - // Parse the manifest object into `out`. Tolerates unknown keys (forward-compat) - // and requires the "owned" value to be an array of strings. - bool parseManifest(OwnedFileManifest& out); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - - bool parseString(std::string& out); - bool parseStringArray(std::vector& out); - bool skipValue(); // for forward-compat unknown keys -}; - -// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX for -// control chars). Positioned before the opening quote (skips leading whitespace). -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - - unsigned int hi = 0; - if (!readHex4(hi)) return false; - - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; // unpaired low surrogate - } - - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated string -} - -bool Parser::parseStringArray(std::vector& out) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; // empty array - for (;;) { - std::string s; - if (!parseString(s)) return false; - out.push_back(std::move(s)); - skipWs(); - if (consume(',')) continue; - if (consume(']')) return true; - return false; // neither separator nor terminator — malformed - } -} - -// Skip a single JSON value (string / array / object / bare scalar) so an unknown key -// does not abort the parse. Minimal: enough for forward-compat siblings we don't know. -bool Parser::skipValue() { - skipWs(); - if (eof()) return false; - char c = s_[pos_]; - if (c == '"') { - std::string tmp; - return parseString(tmp); - } - if (c == '[' || c == '{') { - // Balance nested brackets of either kind, ignoring bracket chars inside - // strings. Enough to step over an unknown nested value; not a full validator. - int depth = 0; - bool inStr = false; - while (!eof()) { - char d = s_[pos_]; - if (inStr) { - if (d == '\\') { pos_ += 2; continue; } - if (d == '"') inStr = false; - ++pos_; - continue; - } - if (d == '"') { inStr = true; ++pos_; continue; } - if (d == '[' || d == '{') ++depth; - else if (d == ']' || d == '}') { - --depth; - if (depth == 0) { ++pos_; return true; } - } - ++pos_; - } - return false; - } - // bare scalar (number / true / false / null) — read to the next structural char - while (!eof()) { - char d = s_[pos_]; - if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' || - d == '\n' || d == '\r') - break; - ++pos_; - } - return true; -} - -bool Parser::parseManifest(OwnedFileManifest& out) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // empty object -> empty manifest - for (;;) { - std::string key; - if (!parseString(key)) return false; - if (!consume(':')) return false; - if (key == "owned") { - std::vector paths; - if (!parseStringArray(paths)) return false; - for (auto& p : paths) { - // Feed through add() so the persisted invariants (dedup, reject - // empty/absolute) are re-asserted on load — a hand-edited or corrupt - // blob cannot smuggle an absolute or duplicate path into the manifest. - out.add(p); - } - } else { - if (!skipValue()) return false; // forward-compat: tolerate unknown keys - } - skipWs(); - if (consume(',')) continue; - if (consume('}')) return true; - return false; - } -} - -} // namespace - -std::optional OwnedFileManifest::deserialize(const std::string& json) { - OwnedFileManifest m; - Parser p(json); - if (!p.parseManifest(m)) return std::nullopt; - return m; -} - -} // namespace reasampler diff --git a/src/persist.cpp b/src/persist.cpp deleted file mode 100644 index d553925..0000000 --- a/src/persist.cpp +++ /dev/null @@ -1,852 +0,0 @@ -// persist.cpp — REAPER-facing implementation of the BankIndex <-> project -// ext-state bridge (M4). See persist.h for the contract. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API -// pointers; here they are extern (CLAUDE.md §contract). -// -// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the -// whole BankBook (pool as bank-zero + named banks) is written under key "banks" -// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on -// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre- -// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so -// the banks travel with the project automatically (CONTEXT.md §Persistence & paths). -// The only thing that does NOT travel for free is the physical bank folder; on -// Save-As to a new directory we relocate it so the indices' relative paths still -// resolve. -// -// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism): -// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active -// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext -// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the -// secondary disambiguator (classifyProjectTransition owns the exact order): -// * different stored GUID -> a different project of record -> LOAD its index; -// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's -// address, so a reopened/new project can present the previous pointer with a -// different GUID), new/unsaved<->saved, and switching between distinct saved -// projects. -// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via -// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings -// diverge going forward. -// * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new -// location -> relocate the bank folder from the old dir to the new one, then -// re-GUID. -// Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only) -// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a -// fork and its parent share a GUID on disk; switching between them read as a -// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer -// RECYCLING — a reopened/new project reusing the previous project's address read -// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first -// catches recycling; the pointer then separates a fork (same GUID, different -// object -> Load) from a Save-As (same GUID, same object, new path -> relocate). -// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` -// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free -// and testable; poll() executes the verdict. -// -// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no -// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not -// a cross-open identity), so we MINT one with genGuid/guidToString and store it -// under kProjExtGuidKey. On Save-As REAPER copies the whole .rpp incl. our ext -// state, so the new project initially shares the old GUID; poll() re-GUIDs it -// (after relocating, or on the forked-sibling Load branch) so identities diverge. -// -// Rationale for the timer: the brief mandates ext-state storage (rules out the -// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with -// ext-state while covering identity-transition load + Save-As detection in one -// place. -// -// DIVISION OF LABOUR (R-B undo): -// * Identity-transition poll (this file, classifyProjectTransition) owns -// open / tab-switch / new / forked-sibling / Save-As-relocation — every case -// where the project OF RECORD changes. -// * The `projectconfig` hook (main.cpp registers project_config_extension_t; -// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project -// identity is unchanged but its ext state rolled back/forward on disk. The -// identity poll sees NoOp there and would never re-read ext state, so the hook -// requests a reload (requestReload) that poll() drains on the next tick, once -// REAPER has restored the block. See requestReload / the poll drain. -// The hook fires on undo AND redo (isUndo true for both), and on normal open -// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open -// flows solely through the identity-transition Load path and never double-loads. -// -// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY -// our own reasampler_bank/ folder. It never touches the user's media, items, or -// other ext-state namespaces. - -#include "persist.h" - -#include -#include -#include -#include -#include -#include - -// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is -// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK -// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, -// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL -// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the -// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing. -#ifdef _WIN32 -#include -#include -#endif - -#include "app_version.h" -#include "capture_paths.h" -#include "prune_reconcile.h" -#include "usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`) -#include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) - -#define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_EnumProjects -#define REAPERAPI_WANT_GetProjExtState -#define REAPERAPI_WANT_MarkProjectDirty -#define REAPERAPI_WANT_SetProjExtState -#define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_genGuid -#define REAPERAPI_WANT_guidToString -#include "reaper_plugin_functions.h" - -namespace reasampler { - -namespace { - -namespace fs = std::filesystem; - -// Read the active project pointer and its .rpp path in one shot. idx=-1 is the -// current project tab (SDK header line ~1262). The out-buffer receives the full -// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel — -// same fact capture.cpp relies on). Returns nullptr proj only when there is no -// active project at all. -void* readActiveProject(std::string& rppPathOut) { - std::vector buf(4096, '\0'); - ReaProject* proj = EnumProjects(-1, buf.data(), static_cast(buf.size())); - rppPathOut.assign(buf.data()); - return proj; -} - -// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> -// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the -// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp -// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths -// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both -// artifacts share one implementation rather than duplicating the parent-of-.rpp step. -std::string projectDirOf(const std::string& rppPath) { - return projectDirOfRpp(rppPath); -} - -// GetProjExtState needs a caller-supplied buffer; the index JSON can be large -// (many samples). Query the required size first (a NULL/zero call is not part of -// the documented contract, so we grow a buffer until it fits). Returns "" when -// the key is absent (GetProjExtState returns <=0) — an absent key is a valid -// empty bank, not an error. -std::string getProjExtStateString(ReaProject* proj, const char* ns, - const char* key) { - // Start generous; grow if REAPER reports the value was truncated. The return - // value is the length of the value (SDK: "returns length"); if it equals the - // buffer capacity minus the NUL, the value may have been clipped, so retry. - for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { - std::vector buf(static_cast(cap), '\0'); - int rv = GetProjExtState(proj, ns, key, buf.data(), cap); - if (rv <= 0) return {}; // absent / empty -> empty bank - // If the written string fits strictly inside the buffer it is complete. - std::string s(buf.data()); - if (static_cast(s.size()) + 1 < cap) return s; - // else: possibly truncated -> grow and retry. - } - // Pathologically large (>16 MB) — give up rather than loop forever. Warn on - // the console so this reads as "too large to load", not silent data loss - // (mirrors the malformed-JSON warning in loadFromProject). - ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) + - "' exceeds the 16 MB read ceiling -- ignoring (bank not " - "loaded).\n").c_str()); - return {}; -} - -// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. -// guidToString wants a >=64-char destination (SDK header line ~3846). -std::string genProjectGuidString() { - GUID g{}; - genGuid(&g); - char buf[64] = {0}; - guidToString(&g, buf); - return std::string(buf); -} - -// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not -// move — see the handoff for the copy-vs-move rationale). Overwrites existing -// files at the destination so a re-save is idempotent. Best-effort: filesystem -// errors are swallowed and reported to the console rather than thrown across the -// REAPER boundary. Returns true if the copy ran (source existed). -bool relocateBankFolder(const std::string& oldBankDir, - const std::string& newBankDir) { - std::error_code ec; - if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) { - return false; // nothing at the old location to relocate - } - if (oldBankDir == newBankDir) return false; // defensive; plan guards this too - - fs::create_directories(newBankDir, ec); - fs::copy(oldBankDir, newBankDir, - fs::copy_options::recursive | fs::copy_options::overwrite_existing, - ec); - if (ec) { - ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir + - "' failed: " + ec.message() + "\n").c_str()); - return false; - } - return true; -} - -} // namespace - -bool ReaSamplerSession::saveToActiveProject() { - std::string rppPath; - void* proj = readActiveProject(rppPath); - if (!proj) return false; // no active project — nothing to persist - if (rppPath.empty()) return false; // unsaved project — no .rpp to store into - - // Phase B: the whole book (pool as bank-zero + named banks) is authoritative and - // rides in the `banks` key. - const std::string banksJson = book_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtBanksKey, banksJson.c_str()); - - // Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty - // value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This - // realizes retirement concretely — after any save, a formerly-legacy project - // carries `banks` and NO `bank_index`, and going forward the legacy key is never - // written. Cheap and idempotent when the key is already absent. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtIndexKey, ""); - - // Additive: the Design-View model rides alongside the banks in its own key. - // Independent write — does not disturb the `banks` blob above. - const std::string viewJson = view_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtViewKey, viewJson.c_str()); - - // Additive: the docked panel's tail setting rides alongside in its own key, so the - // tail choice travels inside the .rpp. Independent write — does not disturb the - // bank_index or view_state above. - const std::string tailJson = serializeTailSetting(tail_); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtTailKey, tailJson.c_str()); - - // Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own - // `owned_files` key. Independent write — does not disturb the blobs above. Written - // on EVERY save so a capture's manifest record survives Save / Save-As / reopen, - // and so the manifest and the bank stay in lockstep on disk (both persisted by the - // same saveToActiveProject the capture add-path calls). Uses the channel-derived - // namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too. - const std::string ownedJson = owned_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtOwnedKey, ownedJson.c_str()); - - // Phase V (V1/V4): stamp the WRITING version — the build producing this save — under - // the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty - // stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is - // the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as - // Stamped on read-back and stays byte-identical to stable regardless of channel; the - // channel is already carried by the isolated namespace (projExtNamespace) this writes to. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtVersionKey, stampVersion().c_str()); - - // S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME - // seam so the counter and MarkProjectDirty stay paired. The value is whatever - // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so - // every content mutation's own save carries the fresh generation the instrument reads. The - // format is the SHARED pure encoder (vst::formatBankGeneration) so writer and reader agree - // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtBankGenKey, - vst::formatBankGeneration(bankGeneration_).c_str()); - - MarkProjectDirty(static_cast(proj)); - return true; -} - -bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { - std::string rppPath; - void* proj = readActiveProject(rppPath); - if (!proj) return false; // no active project — nothing to signal - if (rppPath.empty()) return false; // unsaved project — no .rpp to store into - - // One-shot write of the ingest assignment request under its own key (S8). Independent - // of the book/view/tail blobs — this is a transient signal to the instrument, not - // session state that must ride every save. Uses the channel-derived namespace - // (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta - // instrument reads only a beta extension's assignment requests. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtAssignKey, wire.c_str()); - MarkProjectDirty(static_cast(proj)); - return true; -} - -namespace { - -// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always -// exact (tallied over the full orphan set), but the enumerated file list handed to the -// console is clipped to this many entries so a project with thousands of orphans does -// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can -// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling. -constexpr std::size_t kPruneListDisplayCap = 64; - -// A fresh enumerate + pure-core prune compute for the active project. Shared by the -// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion -// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path -// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the -// active project, enumerates the folder) but writes nothing. -// -// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty -// when there is no active/saved project, no project dir, or no folder on -// disk yet -> the caller treats an empty dir as "nothing to reclaim". -// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration -// order, untruncated. The pure core decides; this only supplies inputs. -// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd). -// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could -// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY — -// the prune must halt rather than proceed with degraded protection. -// An empty orphan set is itself the delete-side guarantee (every -// consumer of this scan deletes at most `orphans ∩ ...`), the flag is -// what lets the action TELL the user instead of claiming "no orphans". -struct PruneScan { - std::string bankDirAbs; - std::vector orphans; - std::unordered_map sizeByRel; - bool abortedUnreadableUsage = false; - std::vector offendingUsageKeys; // non-empty iff abortedUnreadableUsage -}; - -// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem -// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI. -PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned) { - PruneScan scan; - - std::string rppPath; - void* proj = readActiveProject(rppPath); - if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan - - // Resolve the CURRENT bank folder the same way the index does (M4): project dir of - // the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a - // Save-As relocation is followed automatically. resolveBankFile is the shared M4 - // arithmetic; feeding it the bank subfolder as the "relative path" yields the folder. - const std::string projectDir = projectDirOf(rppPath); - const std::string bankDir = resolveBankFile(projectDir, kBankSubfolder); - if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan - - std::error_code ec; - if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) { - return scan; // no bank folder captured yet -> nothing to reclaim - } - - // Enumerate the folder into project-relative index-spelled paths, spelled the SAME - // way the capture path spelled them (bankRelativeForName == deriveBankPaths's - // convention) so the pure core's exact-string match lines up with referencedPaths() - // and the manifest. Non-recursive: the bank folder is flat (capture writes files - // directly here); skip any subdirectory. Size is stat'd here and cached by relative - // path so the report's byte tally reuses the same on-disk read. - // Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration - // failure (file removed, permission flip) breaks out with a best-effort partial list - // rather than propagating std::filesystem_error across REAPER's C ABI. - std::vector present; - fs::directory_iterator it(bankDir, ec); - for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) { - const auto& entry = *it; - std::error_code reg_ec; - if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials - const std::string name = entry.path().filename().string(); - const std::string rel = bankRelativeForName(name); - if (rel.empty()) continue; - present.push_back(rel); - std::error_code sz_ec; - const std::uintmax_t sz = entry.file_size(sz_ec); - scan.sizeByRel[rel] = sz_ec ? 0 : static_cast(sz); - } - - // The decision lives in the pure core — read-only inputs from the book and manifest. - // referencedPaths() unions across the whole book (pool included); owned().paths() is - // the manifest set. pS-usage: the referenced set additionally unions every LIVE - // ReaSampler 9000 instance's held captures (usage_scan reads the per-instance - // rsusage_* records + the live FX enumeration; sample_usage decides liveness, - // including the protect-all net when zero instances were identified) — a capture - // any live instance holds can NEVER be an orphan, even when its bank entry was - // deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY, - // preserving this scan's no-write contract. This shell only enumerates, resolves, - // and stats. - scan.bankDirAbs = bankDir; - const UsageScanResult usage = liveInstanceHeldPaths(proj); - if (usage.abortPrune) { - // FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the - // protected set is unknowable. Compute NO orphans — every downstream consumer - // (dry-run report, confirm set, fresh-recompute delete plan) then deletes - // nothing. The flag + key names surface the reason so the action can name each - // offending key for operator recovery. - scan.abortedUnreadableUsage = true; - scan.offendingUsageKeys = usage.offendingKeys; - return scan; - } - scan.orphans = pruneOrphans( - present, mergeReferenced(book.referencedPaths(), usage.heldPaths), - owned.paths()); - return scan; -} - -} // namespace - -PruneReport ReaSamplerSession::pruneDryRun() const { - const PruneScan scan = scanPruneOrphans(book_, owned_); - // buildPruneReport tallies count / byte-sum / display-truncation — no report logic - // re-implemented here. An empty scan (no project / no folder) yields a zero report. - PruneReport report = - buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap); - // pS-usage fail-safe: surface the unreadable-record abort so the action halts with - // an explicit message instead of reporting "no orphaned files" (the count IS zero — - // the scan computed nothing — but the user must know the prune refused to run). - // The offending key names propagate so the action can name each one for recovery. - report.abortedUnreadableUsage = scan.abortedUnreadableUsage; - report.offendingUsageKeys = scan.offendingUsageKeys; - return report; -} - -std::vector ReaSamplerSession::pruneOrphanSet() const { - return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated -} - -namespace { - -// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the -// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases: -// * `outAlreadyAbsent` set true — the file was already gone before we touched it; -// the caller folds this into the stale/staleness tally, NOT reclaimedCount. -// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error); -// the caller folds this into skippedCount. -// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception -// may cross the C ABI. -// -// Per-platform routing: -// * Windows — SHFileOperationW(FO_DELETE, pFrom=, FOF_ALLOWUNDO | -// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the -// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our -// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true. -// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this -// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3 -// confirm guardrail. `outUsedTrash` left as-is (false). -bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, - bool& outAlreadyAbsent) { -#ifdef _WIN32 - // Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string. - // SHFileOperation's pFrom is a list; a single path still needs the extra terminating - // NUL. Backslashes are required (shell APIs reject forward slashes in some cases). - std::string win = absPath; - for (char& c : win) if (c == '/') c = '\\'; - - const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0); - if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false) - std::vector wbuf(static_cast(wlen) + 1, L'\0'); // +1 for list NUL - MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen); - // wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen] - // makes it the double-NUL-terminated single-element list SHFileOperation wants. - - SHFILEOPSTRUCTW op{}; - op.hwnd = nullptr; - op.wFunc = FO_DELETE; - op.pFrom = wbuf.data(); - op.pTo = nullptr; - op.fFlags = static_cast(FOF_ALLOWUNDO | FOF_NOCONFIRMATION | - FOF_SILENT | FOF_NOERRORUI); - const int rv = SHFileOperationW(&op); - if (rv == 0 && !op.fAnyOperationsAborted) { - outUsedTrash = true; - return true; // deleted this call -> reclaimed - } - // SHFileOperation failed (e.g. file already gone yields a nonzero code on some - // versions, or a lock). Distinguish "already absent" from a real failure so the - // caller can tally them separately (absent -> staleness skip; failure -> locked skip). - std::error_code ec; - if (!fs::exists(absPath, ec)) { - outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim - } - return false; -#else - // No portable trash surface on SWELL platforms -> hard unlink behind the confirm. - std::error_code ec; - const bool removed = fs::remove(absPath, ec); - if (removed) return true; // deleted this call -> reclaimed - if (ec) return false; // a real failure (locked / permission) -> skip - // remove returned false with no error == the file did not exist -> already gone. - outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim - return false; -#endif -} - -} // namespace - -PruneDeletionResult ReaSamplerSession::pruneReclaim( - const std::vector& confirmed) const { - PruneDeletionResult result; - - // Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets - // exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became - // referenced between confirm and delete drops out of freshOrphans and is skipped; a - // newly-appeared orphan not in `confirmed` is never swept without its own confirm. - // Because freshOrphans is itself a pure-core output, the plan can contain NO referenced - // and NO hand-dropped file — the R-C/R-D safety survives the recompute. - // pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an - // EMPTY orphan set, so the plan below intersects to empty and nothing is deleted — - // the fail-safe holds even in the confirm→delete window, with no extra branch here. - const PruneScan scan = scanPruneOrphans(book_, owned_); - if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing - - const std::vector plan = pruneDeletePlan(confirmed, scan.orphans); - - // Staleness skip count: entries the user confirmed that are no longer fresh orphans - // (vanished or became referenced between confirm and delete). pruneDeletePlan already - // de-dups confirmed internally, so compute the unique-confirmed size to avoid counting - // de-duplicated entries as stale — that would be dishonest. - const std::size_t uniqueConfirmedCount = - std::unordered_set(confirmed.begin(), confirmed.end()).size(); - result.skippedCount += uniqueConfirmedCount - plan.size(); - - for (const std::string& rel : plan) { - // Reconstruct the absolute path from the resolved bank dir + the entry's file name. - // rel is index-spelled "/"; the name is the tail after '/'. - const std::string::size_type slash = rel.find_last_of('/'); - const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1); - if (name.empty()) { ++result.skippedCount; continue; } - const std::string absPath = scan.bankDirAbs + "/" + name; - - const auto szIt = scan.sizeByRel.find(rel); - const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0; - - bool alreadyAbsent = false; - if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) { - ++result.reclaimedCount; - result.reclaimedBytes += bytes; - } else if (alreadyAbsent) { - // File vanished between plan and delete — treat as staleness, same as the - // confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it). - ++result.skippedCount; - } else { - ++result.skippedCount; // locked / conversion failure -> recorded, not thrown - } - } - return result; -} - -namespace { - -// Load the Design-View model from a project's view_state key, or return a fresh -// default. An absent/empty key (older project with no view state) yields a -// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful, -// never a crash. Malformed JSON is warned and also falls back to default, mirroring -// the bank's malformed-index handling. The whole model round-trips: modes, -// membership, show-both, snapshots, and active mode all ride inside the one blob. -ViewModeModel loadViewModel(ReaProject* proj) { - if (!proj) return ViewModeModel{}; - const std::string viewJson = - getProjExtStateString(proj, projExtNamespace(), kProjExtViewKey); - if (viewJson.empty()) return ViewModeModel{}; // no stored view state -> default - std::optional loaded = ViewModeModel::deserialize(viewJson); - if (!loaded) { - ShowConsoleMsg("ReaSampler: stored view state is malformed -- ignoring.\n"); - return ViewModeModel{}; - } - return std::move(*loaded); -} - -// Load the tail setting from a project's tail_setting key, or return the default. An -// absent/empty key (older / never-adjusted project) yields the default setting (None / -// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back -// to default, mirroring the bank's and view's malformed handling. -TailSetting loadTailSetting(ReaProject* proj) { - if (!proj) return TailSetting{}; - const std::string tailJson = - getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey); - if (tailJson.empty()) return TailSetting{}; // no stored setting -> default - std::optional loaded = deserializeTailSetting(tailJson); - if (!loaded) { - ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n"); - return TailSetting{}; - } - return *loaded; -} - -// Load the owned-file manifest from a project's owned_files key, or return an empty -// manifest. An absent/empty key (older / never-captured project) yields an empty -// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to -// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then -// sees an empty ownership record and (safely) attributes nothing until the next capture -// rebuilds it — losing the record degrades safety, never correctness. -OwnedFileManifest loadOwnedManifest(ReaProject* proj) { - if (!proj) return OwnedFileManifest{}; - const std::string ownedJson = - getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey); - if (ownedJson.empty()) return OwnedFileManifest{}; // no stored manifest -> empty - std::optional loaded = OwnedFileManifest::deserialize(ownedJson); - if (!loaded) { - ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n"); - return OwnedFileManifest{}; - } - return std::move(*loaded); -} - -} // namespace - -void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { - // Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the - // single choke point for every load path (prime, project switch/open, forked- - // sibling load), so setting it here — and NOT on the Save-As branch, which keeps - // the in-memory model as-is — makes the signal fire exactly when a fresh view - // model has been installed and its active mode's visibility needs reapplying. - // main.cpp drains it via consumeLoadSignal() on the same tick. - loadPending_ = true; - - // The view model is restored on EVERY load path (peer-symmetry with the bank - // reset below): switching to a project with no view state must clear stale - // in-memory state, not inherit the previous project's. D3 restores MODEL STATE - // only — no visibility/processing is applied here (that is D4). - view_ = loadViewModel(static_cast(proj)); - - // The tail setting is restored on EVERY load path too (peer-symmetry): switching - // to a project with no stored setting must fall back to the default, not inherit - // the previous project's choice (this REPLACES the old session-carry behavior). - tail_ = loadTailSetting(static_cast(proj)); - - // The owned-file manifest is restored on EVERY load path too (peer-symmetry with the - // bank/view/tail resets): switching to a project with no stored manifest must reset - // to empty, not inherit the previous project's ownership record; an undo/redo reload - // (R-B) must re-read the restored manifest so it matches the rolled-back bank state. - owned_ = loadOwnedManifest(static_cast(proj)); - - // Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry - // with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed - // one as Unknown — both silent, no console warning (a pre-versioning project is not - // an error). getProjExtStateString returns "" for an absent key, which is exactly the - // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default. - writingVersion_ = classifyWritingVersion( - proj ? getProjExtStateString(static_cast(proj), projExtNamespace(), - kProjExtVersionKey) - : std::string{}); - - // S9: recover the bank-generation counter on EVERY load path (peer-symmetry with - // writingVersion_/tail_/view_ above), so it continues monotonic from the stored value - // rather than resetting to 0 on reopen — a next bump then reads > the stored value. A - // project switch reads THAT project's counter, not the previous one's; an absent/malformed - // stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0. - bankGeneration_ = vst::parseBankGeneration( - proj ? getProjExtStateString(static_cast(proj), projExtNamespace(), - kProjExtBankGenKey) - : std::string{}); - - if (!proj) { - book_ = BankBook{}; - return; - } - - // Read both possible sources: the authoritative `banks` blob and the retired-but- - // possibly-still-present legacy `bank_index`. The precedence + migration decision - // (`banks` wins; else the legacy index migrates into the pool; else an empty book) - // is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only - // so a malformed `banks` blob can be warned on the console (single parse) — a corrupt - // blob must read as "ignored", not silent loss, mirroring the prior malformed-index - // warning. A malformed `banks` degrades to an empty book and does NOT fall back to - // the stale legacy key (which would resurrect superseded single-bank state). - const std::string banksJson = - getProjExtStateString(static_cast(proj), projExtNamespace(), - kProjExtBanksKey); - if (!banksJson.empty()) { - std::optional loaded = BankBook::deserialize(banksJson); - if (!loaded) { - ShowConsoleMsg("ReaSampler: stored banks are malformed -- ignoring.\n"); - book_ = BankBook{}; - } else { - book_ = std::move(*loaded); - } - } else { - // No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool - // by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or- - // empty tail; passing "" for banksJson takes exactly that branch. - const std::string legacyJson = - getProjExtStateString(static_cast(proj), projExtNamespace(), - kProjExtIndexKey); - book_ = BankBook::loadFromPersisted(std::string{}, legacyJson); - } - - // L7 slot migration: seed every bank's display-position SlotMap from its index - // insertion order when the loaded blob carried none (a pre-L7 project -> dense, - // gap-free, visually identical on first post-L7 load), and reconcile a partial map - // (drop stale markers, append unmapped samples) for a blob written by an earlier L7 - // build. One-way: once the book is re-saved the reconciled slot data is authoritative. - // Idempotent, so a fresh empty book is a cheap no-op. - book_.reconcileSlots(); - - // Project-relative resolution is a READ-time concern: every BankIndex in the book - // stores only relative paths (invariant, enforced per-bank at add()), and consumers - // (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via - // resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to - // absolute here — that would break the relative-only invariant and travel-with-.rpp. - // projectDir is threaded through for those consumers; nothing to do at load time - // beyond replacing the in-memory book. - (void)projectDir; -} - -namespace { - -// Ensure a SAVED project carries a stored GUID, minting and writing one if it -// has none yet (a project saved before this feature shipped, or a brand-new -// first save). Returns the effective GUID: the existing one, the freshly minted -// one, or "" for an unsaved project (no .rpp to store ext state into — the same -// gate SetProjExtState/saveToActiveProject already respect on empty path). -// Called from BOTH prime and the Load branch so identity is established the same -// way on every entry to a project (peer-symmetry: no path skips the mint). -std::string ensureProjectGuid(void* proj, const std::string& rppPath, - const std::string& currentGuid) { - if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID - if (!currentGuid.empty()) return currentGuid; - const std::string minted = genProjectGuidString(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtGuidKey, minted.c_str()); - return minted; -} - -} // namespace - -bool ReaSamplerSession::consumeLoadSignal() { - const bool pending = loadPending_; - loadPending_ = false; - return pending; -} - -void ReaSamplerSession::requestReload() { - // Set-only; poll() drains it on the next tick (see the poll() drain block for why - // the read is deferred past the projectconfig callback). Cheap and idempotent — - // multiple undo/redo callbacks before the next tick collapse to one reload. - reloadRequested_ = true; -} - -void ReaSamplerSession::poll() { - std::string rppPath; - void* proj = readActiveProject(rppPath); - const std::string currentGuid = - proj ? getProjExtStateString(static_cast(proj), - projExtNamespace(), kProjExtGuidKey) - : std::string{}; - - if (!primed_) { - // First observation: adopt current identity and load its index, without - // treating it as a "change" (avoids a spurious relocation on startup). - primed_ = true; - loadFromProject(proj, projectDirOf(rppPath)); - lastProject_ = proj; - lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); - lastRppPath_ = rppPath; - reloadRequested_ = false; // priming already loaded — a co-tick request is moot - return; - } - - // Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier - // below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID, - // and .rpp path — so classifyProjectTransition would return NoOp and never re-read - // ext state, leaving book_/view_ stale after the on-disk ext state rolled back. - // The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_ - // one or more ticks ago; by NOW REAPER has finished restoring the project's - // block, so GetProjExtState returns the POST-undo value. Reload from the - // current active project and identity-adopt it (no relocation — the path is - // unchanged), then return. loadFromProject raises loadPending_, so the existing - // consumeLoadSignal() glue re-baselines the panel detector and reapplies the active - // mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is - // the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect - // an undo (Daniel's directive: the hook drives it, not a poll heuristic). - if (reloadRequested_) { - reloadRequested_ = false; - loadFromProject(proj, projectDirOf(rppPath)); - lastProject_ = proj; - lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); - lastRppPath_ = rppPath; - return; - } - - // Pointer identity is the primary signal: a genuine Save-As keeps the SAME - // ReaProject* (one object saved elsewhere); a tab-switch/open is a different - // object. Passing the bool (not the pointer) keeps the classifier pure. - const bool sameProjectObject = (proj == lastProject_); - const ProjectTransition transition = classifyProjectTransition( - sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath); - - switch (transition) { - case ProjectTransition::NoOp: - return; - - case ProjectTransition::Load: { - // A different project of record is active (open / tab switch / new / - // reopened / recycled pointer / forked sibling). Load ITS index; never - // relocate. - // - // Forked-sibling divergence: gate on `!sameProjectObject` so this fires - // ONLY for a step-2 Load (same GUID, different object) — a Save-As fork - // that copied our GUID and never re-saved (its fresh GUID was runtime- - // only on the sibling we came from). A recycled-pointer Load (step 1: - // currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct - // identity. currentGuid == lastGuid_ can only hold here when step 1 did - // NOT fire, i.e. this is the fork case; the explicit !sameProjectObject - // makes that intent load-bearing rather than incidental. Do this BEFORE - // loadFromProject reads the index (order is irrelevant — GUID and - // bank_index are distinct keys — but self-contained is clearest). - if (proj && !sameProjectObject && !currentGuid.empty() && - currentGuid == lastGuid_ && !rppPath.empty()) { - const std::string fresh = genProjectGuidString(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtGuidKey, fresh.c_str()); - MarkProjectDirty(static_cast(proj)); - loadFromProject(proj, projectDirOf(rppPath)); - lastProject_ = proj; - lastGuid_ = fresh; - lastRppPath_ = rppPath; - return; - } - - // Normal load: establish identity the same way prime does. - loadFromProject(proj, projectDirOf(rppPath)); - lastProject_ = proj; - lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); - lastRppPath_ = rppPath; - return; - } - - case ProjectTransition::SaveAsRelocate: { - // SAME project object + new .rpp path: a genuine Save-As (the pointer - // proves it — a fork tab-switch is a DIFFERENT object and took the Load - // branch above). Relocate the bank folder from the old dir to the new - // one so the wavs sit under the new .rpp and the index's relative paths - // still resolve. Keep the in-memory bank as-is (Save-As copied our ext - // state, the relative paths are unchanged) — do NOT reload. - const std::string oldDir = projectDirOf(lastRppPath_); - const std::string newDir = projectDirOf(rppPath); - const BankRelocation plan = deriveRelocationPlan(oldDir, newDir); - if (plan.needed) { - relocateBankFolder(plan.oldBankDir, plan.newBankDir); - } - - // Save-As duplicated our ext state, so the new project B currently - // shares A's GUID. Mint a FRESH GUID for B and write it, so A and B - // no longer collide on identity when reopened later. Adopt the fresh - // GUID as our last-seen identity. Mark dirty so the fresh GUID flushes - // to the new .rpp on the next normal save / close-prompt. - const std::string fresh = genProjectGuidString(); - if (proj) { - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtGuidKey, fresh.c_str()); - MarkProjectDirty(static_cast(proj)); - } - lastProject_ = proj; // unchanged (same object) — set for symmetry - lastGuid_ = fresh; - lastRppPath_ = rppPath; - return; - } - } -} - -} // namespace reasampler diff --git a/src/resource.h b/src/resource.h index 0cad811..1064b9a 100644 --- a/src/resource.h +++ b/src/resource.h @@ -6,5 +6,5 @@ // numeric ids stable and unique across the extension. // The docked bank panel (M5). A bare owner-drawn child dialog: it carries no -// controls — bank_panel.cpp paints the whole client area with LICE. +// controls — the panel shell (shell/panel/panel_render.cpp) paints the whole client area with LICE. #define IDD_BANK_PANEL 1000 diff --git a/src/shell/actions/action_registry.cpp b/src/shell/actions/action_registry.cpp new file mode 100644 index 0000000..00ece1a --- /dev/null +++ b/src/shell/actions/action_registry.cpp @@ -0,0 +1,98 @@ +// action_registry.cpp — shared registration plumbing (Q-W4) + the registration +// table (Q-W6). See action_registry.h. Needs no REAPER API pointers: rec->Register +// is a member call on the dispatch struct REAPER hands the entry point. + +#include "shell/actions/action_registry.h" + +#include +#include +#include + +#include "core/version/app_version.h" // channelCommandId / channelActionName + +namespace reasampler { + +namespace { + +using version::channelActionName; +using version::channelCommandId; + +// Durable store of composed, channel-qualified strings (ids + labels). A std::deque +// never invalidates references on push_back, so a c_str() handed to REAPER (a +// command_id at register, a gaccel desc for its lifetime) stays valid until process +// exit. Memoized by suffix so register and the mirror-unregister get the SAME id +// pointer for a given action. +std::deque g_strStore; + +// One registered table row: the row data plus the registry-owned registration +// artifacts (interned id, minted cmd, gaccel storage REAPER holds a pointer to). +// A std::deque so element addresses never move after push_back — REAPER keeps each +// &accel until the mirror-unregister. +struct TableEntry { + ActionTableRow row; + const char* id = nullptr; // interned channel-qualified command id + int cmd = 0; // minted command id (0 = mint failed / inert row) + gaccel_register_t accel{}; // Actions-list entry; address handed to REAPER +}; +std::deque g_table; + +} // namespace + +const char* channelIdFor(const char* suffix) { + const std::string composed = channelCommandId(suffix); + for (const std::string& s : g_strStore) + if (s == composed) return s.c_str(); + g_strStore.push_back(composed); + return g_strStore.back().c_str(); +} + +int registerAction(reaper_plugin_info_t* rec, const char* suffix, + gaccel_register_t& accel, const char* phrase) { + const char* id = channelIdFor(suffix); + const int cmd = rec->Register("command_id", (void*)id); + if (cmd) { + g_strStore.push_back(channelActionName(phrase)); + accel.accel.cmd = cmd; + accel.desc = g_strStore.back().c_str(); + rec->Register("gaccel", (void*)&accel); + } + return cmd; +} + +void registerActionTable(reaper_plugin_info_t* rec, const ActionTableRow* rows, + std::size_t count) { + g_table.clear(); // idempotent-by-construction: a re-register never doubles-up rows + for (std::size_t i = 0; i < count; ++i) { + g_table.push_back(TableEntry{rows[i]}); + TableEntry& e = g_table.back(); + e.id = channelIdFor(e.row.suffix); + e.cmd = registerAction(rec, e.row.suffix, e.accel, e.row.phrase); + } +} + +bool actionTableHandleCommand(int command) { + if (command == 0) return false; + for (const TableEntry& e : g_table) + if (e.cmd != 0 && command == e.cmd) { + e.row.run(e.row.arg); + return true; + } + return false; +} + +int actionTableCommandId(const char* suffix) { + for (const TableEntry& e : g_table) + if (std::strcmp(e.row.suffix, suffix) == 0) return e.cmd; + return 0; +} + +void unregisterActionTable(reaper_plugin_info_t* rec) { + // Reverse table order, mirroring the register loop. Each '-command_id' + // re-presents the SAME interned pointer channelIdFor handed out at register. + for (auto it = g_table.rbegin(); it != g_table.rend(); ++it) { + rec->Register("-gaccel", (void*)&it->accel); + rec->Register("-command_id", (void*)it->id); + } +} + +} // namespace reasampler diff --git a/src/shell/actions/action_registry.h b/src/shell/actions/action_registry.h new file mode 100644 index 0000000..9f45fbe --- /dev/null +++ b/src/shell/actions/action_registry.h @@ -0,0 +1,83 @@ +#pragma once +// action_registry — shared registration plumbing + the Q-W6 registration TABLE. +// +// Two layers, one TU: +// +// * The Q-W4 plumbing (channelIdFor / registerAction): the durable interned-string +// store the action families register through, so a composed command id keeps ONE +// stable pointer from register to the mirror-unregister, and the +// register-a-command_id-then-gaccel sequence has one implementation. The +// design_view / bank / ingest families still register row-by-row through this. +// +// * The Q-W6 registration TABLE (ActionTableRow + registerActionTable / +// actionTableHandleCommand / actionTableCommandId / unregisterActionTable): the +// data-driven home of main.cpp's own action family (capture scopes, panel toggle, +// insert, batch, realtime, recapture, version). One row = one action (FOREVER- +// STABLE id suffix, display phrase, flat function-pointer handler); registration +// iterates the rows, hookcommand dispatch walks the same rows, and unload +// mirror-unregisters from them — adding an action touches the table only (OCP). +// Handlers are plain function pointers (a static dispatch walk, no std::function, +// no virtual — the §3 performance guardrail); gaccel + interned-id storage is +// owned here for the module lifetime, so REAPER's held pointers stay valid and +// the '-command_id' unregister re-presents the IDENTICAL pointer registered. +// +// Includes reaper_plugin.h (gaccel_register_t / reaper_plugin_info_t full defs); +// only the action-family TUs and main.cpp include this header. + +#include + +#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t + +namespace reasampler { + +// Returns the channel-qualified command id for `suffix`, interning it once for the +// process lifetime. Called by BOTH registerAction and each family's unregister path, +// so a '-command_id' presents the IDENTICAL string pointer registered earlier. +const char* channelIdFor(const char* suffix); + +// Mints a command id from a channel-qualified SUFFIX and registers its gaccel +// (Actions-list entry with a channel-qualified label PHRASE). Returns the command id +// (0 on failure). Both the composed id and label are interned durably — REAPER holds +// the desc pointer, and the id must survive to the mirror-unregister. The gaccel +// storage itself is caller-owned (file-scope in the family TU). +int registerAction(reaper_plugin_info_t* rec, const char* suffix, + gaccel_register_t& accel, const char* phrase); + +// --- The registration table (Q-W6) ------------------------------------------- + +// One bindable action. `suffix` and `phrase` are the channel-AGNOSTIC pieces (the +// registry composes the full id/label via channelCommandId / channelActionName); +// both must have static storage duration (string literals, or a pure static table +// like captureActionTable()). `run` fires when the minted command does; `arg` is an +// opaque per-row value passed through to it (e.g. a captureActionTable row index, or +// a bool-like flag), so sibling actions can share one handler without captures. +struct ActionTableRow { + const char* suffix; // FOREVER-STABLE command-id suffix — never change shipped + const char* phrase; // Actions-list display phrase (after the channel prefix) + void (*run)(int arg); // handler — a flat function pointer, no state + int arg = 0; // opaque per-row handler argument +}; + +// Registers every row (command_id -> gaccel, via the same interning plumbing as +// registerAction) in table order. Rows are COPIED into registry-owned storage whose +// element addresses never move (REAPER holds each gaccel pointer until unload). +// Call once at load; a failed command_id mint (cmd 0) leaves that row inert but +// still mirror-unregistered on unload (harmless, matches the pre-table behavior). +void registerActionTable(reaper_plugin_info_t* rec, const ActionTableRow* rows, + std::size_t count); + +// Dispatches one fired command: fires the matching row's handler and returns true; +// false when the command belongs to no table row (caller's hookcommand keeps +// looking, per the claim-only contract). A flat walk over the registered rows. +bool actionTableHandleCommand(int command); + +// The minted command id for `suffix` (0 when unregistered / mint failed). For the +// callers that need a raw command id outside dispatch — e.g. the toggleaction +// checked-state hook resolving TOGGLE_BANK_PANEL once at load. +int actionTableCommandId(const char* suffix); + +// Mirror-unregisters every table row (reverse table order): '-gaccel' with the same +// held storage, '-command_id' with the SAME interned pointer used at register. +void unregisterActionTable(reaper_plugin_info_t* rec); + +} // namespace reasampler diff --git a/src/shell/actions/bank_actions.cpp b/src/shell/actions/bank_actions.cpp new file mode 100644 index 0000000..32e854d --- /dev/null +++ b/src/shell/actions/bank_actions.cpp @@ -0,0 +1,368 @@ +// bank_actions.cpp — the multi-bank bindable action family (Phase B3; Q-W4 split of +// actions.cpp). See bank_actions.h. +// +// Q-W4 dedupe / Q-W6 seam: each mutating handler is a THIN UX SKIN — text prompts +// (promptBankName), name resolution, and console feedback — over the promptless +// bankOp* inner verbs homed in shell/bank_ops (model op + persistBankOp, one bank op +// = one Ctrl-Z), driven against this family's registered session. The book's rules +// (pool privileges, collapse-by-hash, active-fallback-to-pool) all live in +// bank_book; these handlers only drive the verbs and react to the boolean. +// +// REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index +// return a reference INTO the book's internal vector, which a create/delete can +// reallocate. No handler here caches a BankModel& (or a Bank*) across a structural +// mutation — each resolves ids to strings up front and re-resolves after any +// create/delete. Move/copy pass ids (not references) straight to the verbs. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers (CLAUDE.md §contract). +// The action ids are minted from FOREVER-STABLE strings; user keybindings key off +// them, so they must never change after ship. + +#include "shell/actions/bank_actions.h" + +#include +#include + +#include "shell/actions/action_registry.h" // channelIdFor / registerAction (shared plumbing) +#include "shell/actions/prune_action.h" // doBankPruneFolder — the guarded prune body + +#include "core/model/bank_book.h" // BankBook, nextBankId, kPoolBankId (B1) +#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs (Q-W6 non-UI seam) +#include "shell/panel/panel_bank_ops.h" // promptBankName + the panel selection seam +#include "shell/panel/panel_layout.h" // full-height toggles (B3) +#include "shell/persist/session.h" // ReaSamplerSession (owns book()) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_ShowMessageBox +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// FOREVER-STABLE multi-bank action-id SUFFIXES (Phase V, V4). The channel family prefix is +// prepended at register via channelCommandId (as with the Design View family) — stable +// rebuilds the shipped id, beta the isolated one. NEVER change a shipped suffix. +// Each suffix + the stable prefix must byte-match the pre-V4 shipped literal exactly +// (e.g. "BANK_REMOVE_SELECTED" -> "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED"). +constexpr const char* kIdBankCreate = "BANK_CREATE"; +constexpr const char* kIdBankRename = "BANK_RENAME"; +constexpr const char* kIdBankDelete = "BANK_DELETE"; +constexpr const char* kIdBankEvacuate = "BANK_EVACUATE"; +constexpr const char* kIdBankActivateNext = "BANK_ACTIVATE_NEXT"; +constexpr const char* kIdBankActivatePool = "BANK_ACTIVATE_POOL"; +constexpr const char* kIdBankMoveSel = "BANK_MOVE_SELECTED"; +constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED"; +constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED"; +constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT"; +constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT"; +// Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so +// in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion), +// and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id. +constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER"; + +// The live session the actions read (name resolution, member counts, prune) and +// pass to the bankOp* verbs by reference (bankHandleCommand guards it non-null +// before any handler runs). Set once by bankRegisterActions; not owned here. +ReaSamplerSession* g_session = nullptr; + +int g_cmdBankCreate = 0; +int g_cmdBankRename = 0; +int g_cmdBankDelete = 0; +int g_cmdBankEvacuate = 0; +int g_cmdBankActivateNext = 0; +int g_cmdBankActivatePool = 0; +int g_cmdBankMoveSel = 0; +int g_cmdBankCopySel = 0; +int g_cmdBankRemoveSel = 0; +int g_cmdBankPoolFull = 0; +int g_cmdBankBanksFull = 0; +int g_cmdBankPruneFolder = 0; + +// gaccel storage must outlive registration — REAPER holds each pointer until we +// mirror-unregister it. One per action. +gaccel_register_t g_accelBankCreate{}; +gaccel_register_t g_accelBankRename{}; +gaccel_register_t g_accelBankDelete{}; +gaccel_register_t g_accelBankEvacuate{}; +gaccel_register_t g_accelBankActivateNext{}; +gaccel_register_t g_accelBankActivatePool{}; +gaccel_register_t g_accelBankMoveSel{}; +gaccel_register_t g_accelBankCopySel{}; +gaccel_register_t g_accelBankRemoveSel{}; +gaccel_register_t g_accelBankPoolFull{}; +gaccel_register_t g_accelBankBanksFull{}; +gaccel_register_t g_accelBankPruneFolder{}; + +// Resolves a user-typed bank reference (a display name) to a bank id, scanning the +// book's banks in ordinal order. Exact match on displayName; "Pool" resolves the pool. +// Returns "" when no bank carries that name. Kept in the action layer (not the model) +// — it is UI name-resolution, not a model rule. First-match is unambiguous BY +// CONSTRUCTION: the model enforces unique display names (trimmed + case-insensitive), +// so at most one bank can carry a given name — no duplicate can shadow another here. +std::string bankIdByDisplayName(const std::string& name) { + for (const Bank& b : g_session->book().banks()) + if (b.displayName == name) return b.id; + return {}; +} + +// -- Action bodies (thin UX skins over the bankOp* verbs) ------------------- + +// Create a named bank: prompt for a display name; the verb mints a stable GUID id, +// creates it in the model, persists. The new bank is NOT auto-activated (create and +// activate are distinct acts — mirrors capture/placement separation). The model +// rejects a duplicate display name (trimmed + case-insensitive, incl. "Pool"); the +// create then fails and the user is told the name is taken. +void doBankCreate() { + std::string name; + if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; + if (bankOpCreate(*g_session, name).empty()) { + ShowConsoleMsg( + ("ReaSampler: could not create bank \"" + name + + "\" (a bank with that name already exists).\n") + .c_str()); + } +} + +// Rename a bank: prompt for which bank (by current display name) and the new name. +// The pool is un-renamable (the model rejects it). Two prompts keep the bindable form +// self-contained; the panel renames in place on a tab. +void doBankRename() { + std::string which; + if (!promptBankName("ReaSampler: rename bank", "Bank to rename (current name):", "", + which)) + return; + const std::string id = bankIdByDisplayName(which); + if (id.empty()) { + ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); + return; + } + std::string newName; + if (!promptBankName("ReaSampler: rename bank", "New name:", which, newName)) return; + if (!bankOpRename(*g_session, id, newName)) { + // The verb rejects the pool (un-renamable) or a name already used by another + // bank (unique display names, trimmed + case-insensitive). + ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable, " + "or another bank already uses that name).\n"); + } +} + +// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail: +// prompt for the bank; if it holds members, a YESNO ShowMessageBox names evacuate as +// the alternative before dropping them (a plain delete orphans those members' files +// until prune — CONTEXT.md §delete). An empty bank deletes with no prompt. The richer +// panel confirm (naming evacuate inline, with a one-click evacuate) lives in the panel. +void doBankDelete() { + std::string which; + if (!promptBankName("ReaSampler: delete bank", "Bank to delete:", "", which)) return; + const std::string id = bankIdByDisplayName(which); + if (id.empty()) { + ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); + return; + } + // Pool early-out: the pool is un-deletable (the model rejects it). Catch it here, + // BEFORE the non-empty confirm, so typing "Pool" never shows a misleading + // "delete anyway?" prompt for an operation the model will refuse regardless. + if (id == kPoolBankId) { + ShowConsoleMsg("ReaSampler: the pool cannot be deleted.\n"); + return; + } + // Read member count BEFORE deleting (the Bank* is invalidated by the delete; we do + // not cache it — resolve size to an int up front). + const Bank* b = g_session->book().bank(id); + if (!b) return; // race-safe: id resolved above but re-check + const std::size_t members = b->index.size(); + if (members > 0) { + const std::string msg = + "\"" + which + "\" holds " + std::to_string(members) + + (members == 1 ? " sample" : " samples") + + ".\n\nDeleting drops them from every bank (their files are NOT deleted, " + "but no bank will reference them until prune).\n\nTo keep the samples, " + "cancel and Evacuate the bank to the pool first.\n\nDelete anyway?"; + const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 4); + if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544) + } + // S9: bump only when the deleted bank held samples — dropping them changes what a live + // instance referencing one could play. Deleting an EMPTY bank is purely organizational. + if (!bankOpDelete(*g_session, id, /*bumpGeneration=*/members > 0)) { + ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); + } +} + +// Evacuate a named bank: move every member back to the pool (index-only, collapse by +// hash), leaving the bank empty. The pool is un-evacuable (the verb rejects it). The +// intended "keep the samples" companion to delete. +void doBankEvacuate() { + std::string which; + if (!promptBankName("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", + which)) + return; + const std::string id = bankIdByDisplayName(which); + if (id.empty()) { + ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); + return; + } + if (!bankOpEvacuate(*g_session, id)) { + ShowConsoleMsg("ReaSampler: cannot evacuate that bank (the pool is the " + "destination, not a source).\n"); + } +} + +// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), +// via the pure nextBankId helper. Activating a bank changes the CAPTURE TARGET (the +// next capture lands in the newly-active bank — B2's book().activeIndex() seam) and +// never touches the timeline. The verb persists so the active id travels with the .rpp. +void doBankActivateNext() { + std::vector ids; + ids.reserve(g_session->book().size()); + for (const Bank& b : g_session->book().banks()) ids.push_back(b.id); + const std::string target = nextBankId(ids, g_session->book().activeBankId()); + if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded) + bankOpActivate(*g_session, target); +} + +// Activate the pool directly (the common "back to the default target" jump). Bindable +// direct-by-id form; a general activate-bank-by-name/menu is a panel affordance. +void doBankActivatePool() { + bankOpActivate(*g_session, kPoolBankId); +} + +// Move or copy the panel's selected samples into a named destination bank (prompted +// by display name). The SOURCE is the bank the selection lives in — the focused +// region's displayed bank (bankPanelSelectedSourceBankId), which under B4's vertical +// split is NOT necessarily the active/capture-target bank (active ≠ shown). Both are +// index-only (files never relocate); the verb owns the verb-aware no-op guardrail and +// destination collapse-by-hash. The panel's "move to bank" menu drives the same verb +// with a menu-chosen destination — this bindable form is the same operation with a +// text-prompt destination. +void doBankTransferSelected(bool copy) { + const std::vector selected = bankPanelSelectedSampleIds(); + if (selected.empty()) { + ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to " + "move/copy.\n"); + return; + } + const char* verb = copy ? "copy" : "move"; + const std::string title = std::string("ReaSampler: ") + verb + " selected samples"; + std::string destName; + if (!promptBankName(title.c_str(), "Destination bank:", "", destName)) return; + const std::string destId = bankIdByDisplayName(destName); + if (destId.empty()) { + ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str()); + return; + } + // Source = the bank the selection lives in (the focused region's displayed bank). + const std::string srcId = bankPanelSelectedSourceBankId(); + if (srcId == destId) { + ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n"); + return; + } + bankOpTransfer(*g_session, selected, srcId, destId, copy); +} + +// Remove the panel's selected samples from the SOURCE bank (the focused region's +// displayed bank — same source as move/copy). Index-only and non-destructive to the +// file (orphaned until Phase R prune); silent, with the batched undo as recovery — +// see bankOpRemove for the full contract. +void doBankRemoveSelected() { + const std::vector selected = bankPanelSelectedSampleIds(); + if (selected.empty()) { + ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to remove.\n"); + return; + } + const std::string srcId = bankPanelSelectedSourceBankId(); + if (g_session->book().bank(srcId) == nullptr) { + ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n"); + return; + } + bankOpRemove(*g_session, selected, srcId); +} + +} // namespace + +void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { + g_session = session; // same live session as the Design View family + + g_cmdBankCreate = registerAction(rec, kIdBankCreate, g_accelBankCreate, + "create bank"); + g_cmdBankRename = registerAction(rec, kIdBankRename, g_accelBankRename, + "rename bank"); + g_cmdBankDelete = registerAction(rec, kIdBankDelete, g_accelBankDelete, + "delete bank"); + g_cmdBankEvacuate = registerAction(rec, kIdBankEvacuate, g_accelBankEvacuate, + "evacuate bank to pool"); + g_cmdBankActivateNext = registerAction(rec, kIdBankActivateNext, g_accelBankActivateNext, + "activate next bank (cycle)"); + g_cmdBankActivatePool = registerAction(rec, kIdBankActivatePool, g_accelBankActivatePool, + "activate pool"); + g_cmdBankMoveSel = registerAction(rec, kIdBankMoveSel, g_accelBankMoveSel, + "move selected samples to bank"); + g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel, + "copy selected samples to bank"); + g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel, + "remove selected samples"); + g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull, + "toggle pool full-height"); + g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, + "toggle banks full-height"); + // Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends + // the confirm-and-delete step behind this SAME forever-stable id). + g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder, + "prune bank folder"); +} + +bool bankHandleCommand(int command) { + if (command == 0 || !g_session) return false; + + if (command == g_cmdBankCreate) { doBankCreate(); return true; } + if (command == g_cmdBankRename) { doBankRename(); return true; } + if (command == g_cmdBankDelete) { doBankDelete(); return true; } + if (command == g_cmdBankEvacuate) { doBankEvacuate(); return true; } + if (command == g_cmdBankActivateNext) { doBankActivateNext(); return true; } + if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; } + if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; } + if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; } + if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; } + if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; } + if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; } + if (command == g_cmdBankPruneFolder) { doBankPruneFolder(*g_session); return true; } + + return false; // not ours — caller's hookcommand keeps looking +} + +int bankPruneCommandId() { return g_cmdBankPruneFolder; } + +void bankUnregisterActions(reaper_plugin_info_t* rec) { + // Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each + // '-command_id' re-presents the same interned channel-qualified id (channelIdFor). + rec->Register("-gaccel", (void*)&g_accelBankPruneFolder); + rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder)); + rec->Register("-gaccel", (void*)&g_accelBankBanksFull); + rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull)); + rec->Register("-gaccel", (void*)&g_accelBankPoolFull); + rec->Register("-command_id", (void*)channelIdFor(kIdBankPoolFull)); + rec->Register("-gaccel", (void*)&g_accelBankRemoveSel); + rec->Register("-command_id", (void*)channelIdFor(kIdBankRemoveSel)); + rec->Register("-gaccel", (void*)&g_accelBankCopySel); + rec->Register("-command_id", (void*)channelIdFor(kIdBankCopySel)); + rec->Register("-gaccel", (void*)&g_accelBankMoveSel); + rec->Register("-command_id", (void*)channelIdFor(kIdBankMoveSel)); + rec->Register("-gaccel", (void*)&g_accelBankActivatePool); + rec->Register("-command_id", (void*)channelIdFor(kIdBankActivatePool)); + rec->Register("-gaccel", (void*)&g_accelBankActivateNext); + rec->Register("-command_id", (void*)channelIdFor(kIdBankActivateNext)); + rec->Register("-gaccel", (void*)&g_accelBankEvacuate); + rec->Register("-command_id", (void*)channelIdFor(kIdBankEvacuate)); + rec->Register("-gaccel", (void*)&g_accelBankDelete); + rec->Register("-command_id", (void*)channelIdFor(kIdBankDelete)); + rec->Register("-gaccel", (void*)&g_accelBankRename); + rec->Register("-command_id", (void*)channelIdFor(kIdBankRename)); + rec->Register("-gaccel", (void*)&g_accelBankCreate); + rec->Register("-command_id", (void*)channelIdFor(kIdBankCreate)); + + g_session = nullptr; +} + +} // namespace reasampler diff --git a/src/shell/actions/bank_actions.h b/src/shell/actions/bank_actions.h new file mode 100644 index 0000000..f8221e3 --- /dev/null +++ b/src/shell/actions/bank_actions.h @@ -0,0 +1,45 @@ +#pragma once +// bank_actions — the multi-bank bindable action family (Phase B3; Q-W4 split of +// actions.h). The bindable action set that drives the multi-bank workflow: create / +// rename / delete / evacuate a bank, activate a bank (direct pool + cycle), move / +// copy / remove the panel's selected samples, the two vertical-split full-height +// toggles, and the Phase R prune action's registration + dispatch (its guarded body +// lives in prune_action). Q-W4 dedupe: every mutating handler here is a THIN UX skin +// (text prompts + console messages) over the promptless bankOp* verbs homed in +// panel_bank_ops — one implementation home for each mutation, two UX skins (this +// family prompts for which bank; the panel acts on a clicked tab). +// +// Same registration/routing/unload contract as the Design View family +// (design_view_actions); both share main.cpp's single hookcommand, and each family's +// Handle claims only its own ids. This header is SDK-free. + +// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef) so this +// header stays SDK-free; the .cpp includes the real definition. +struct reaper_plugin_info_t; + +namespace reasampler { + +class ReaSamplerSession; + +// Registers the multi-bank family against `rec`. `session` is the live session (must +// outlive registration). Call exactly once at load — pass the SAME session pointer +// the Design View family receives. +void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); + +// Services one fired command for the multi-bank family. True iff it was one of this +// family's ids (and handled); false otherwise so the caller's hookcommand keeps +// looking. Safe for any command. +bool bankHandleCommand(int command); + +// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on +// rec==nullptr (before the session is torn down). +void bankUnregisterActions(reaper_plugin_info_t* rec); + +// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 +// before registration. The bank_panel prune button fires the action THROUGH this id +// via Main_OnCommand (fork R-E: the button dispatches the command, it does not call +// the session directly) so the panel affordance and the bindable action share one +// guarded code path. +int bankPruneCommandId(); + +} // namespace reasampler diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp new file mode 100644 index 0000000..a05dfb9 --- /dev/null +++ b/src/shell/actions/design_view_actions.cpp @@ -0,0 +1,383 @@ +// design_view_actions.cpp — the Design View action family (Phase D4; Q-W4 split of +// actions.cpp). See design_view_actions.h. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers +// (CLAUDE.md §contract). The action ids are minted from FOREVER-STABLE strings (the +// same CEREBELLUM_REASAMPLER_ family prefix main.cpp uses); user keybindings key off +// them, so they must never change after ship. +// +// Each action: +// 1. mutates the session's ViewModeModel (membership tag/untag/show-both, or the +// active mode via toggle/activate) — the pure D1 state, +// 2. reapplies the active mode through the D2 view shell (applyMode) so the change +// takes visible effect immediately (tagging a track into Design while in Arrange +// parks it right away; a mode change re-partitions and re-parks in one step). +// +// Selection-driven mutations iterate the CURRENT REAPER track selection +// (CountSelectedTracks/GetSelectedTrack — both ignore the master, which is correct: +// the master is never tagged) and resolve each track to its canonical GUID key via +// the shared guidString helper, so the keys match exactly what the D2 shell / view +// tree key on (the cross-module key contract). + +#include "shell/actions/design_view_actions.h" + +#include +#include + +#include "shell/actions/action_registry.h" // channelIdFor / registerAction (shared plumbing) + +#include "core/view/lane_keys.h" // view::isOnManualLane — the single managed/manual predicate +#include "core/view/view_mode_model.h" +#include "shell/persist/session.h" // ReaSamplerSession (owns view() model) +#include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) +#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key +#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint +#include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountSelectedTracks +#define REAPERAPI_WANT_GetSelectedTrack +#define REAPERAPI_WANT_CountSelectedMediaItems +#define REAPERAPI_WANT_GetSelectedMediaItem +#define REAPERAPI_WANT_GetMediaItemTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_Main_SaveProject +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +using view::isOnManualLane; + +namespace { + +// FOREVER-STABLE action-id SUFFIXES (Phase V, V4). The channel family prefix is prepended +// at register time via channelCommandId (app_version), so stable rebuilds the exact shipped +// id ("CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE") and beta yields the isolated forever-family +// id ("CEREBELLUM_REASAMPLER_BETA_VIEW_TOGGLE_MODE"). Each composed id is minted into a +// persistent command id user keybindings key off — NEVER change a shipped suffix after ship. +constexpr const char* kIdToggleMode = "VIEW_TOGGLE_MODE"; +constexpr const char* kIdActivateArrange = "VIEW_ACTIVATE_ARRANGE"; +constexpr const char* kIdActivateDesign = "VIEW_ACTIVATE_DESIGN"; +constexpr const char* kIdTagDesign = "VIEW_TAG_DESIGN"; +constexpr const char* kIdTagArrange = "VIEW_TAG_ARRANGE"; +constexpr const char* kIdUntag = "VIEW_UNTAG"; +constexpr const char* kIdShowBoth = "VIEW_SHOW_BOTH"; +// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same +// FOREVER-STABLE contract (suffix composed with the channel prefix) — NEVER change these. +constexpr const char* kIdMoveItemsDesign = "VIEW_MOVE_ITEMS_DESIGN"; +constexpr const char* kIdMoveItemsArrange = "VIEW_MOVE_ITEMS_ARRANGE"; +constexpr const char* kIdUntagItems = "VIEW_UNTAG_ITEMS"; + +// The live session the actions mutate. Set once by designViewRegisterActions and +// read by the hookcommand handler. Not owned here (main.cpp owns g_session). +ReaSamplerSession* g_session = nullptr; + +// Minted command ids (0 until registration succeeds). Compared in the handler. +int g_cmdToggleMode = 0; +int g_cmdActivateArrange = 0; +int g_cmdActivateDesign = 0; +int g_cmdTagDesign = 0; +int g_cmdTagArrange = 0; +int g_cmdUntag = 0; +int g_cmdShowBoth = 0; +int g_cmdMoveItemsDesign = 0; +int g_cmdMoveItemsArrange = 0; +int g_cmdUntagItems = 0; + +// gaccel storage must outlive registration — REAPER holds each pointer until we +// mirror-unregister it. One per action. +gaccel_register_t g_accelToggleMode{}; +gaccel_register_t g_accelActivateArrange{}; +gaccel_register_t g_accelActivateDesign{}; +gaccel_register_t g_accelTagDesign{}; +gaccel_register_t g_accelTagArrange{}; +gaccel_register_t g_accelUntag{}; +gaccel_register_t g_accelShowBoth{}; +gaccel_register_t g_accelMoveItemsDesign{}; +gaccel_register_t g_accelMoveItemsArrange{}; +gaccel_register_t g_accelUntagItems{}; + +// Collects the canonical GUID keys of the current track selection. Empty if nothing +// is selected. CountSelectedTracks/GetSelectedTrack ignore the master (SDK), which is +// exactly right — the master is never a tagged leaf. +std::vector selectedTrackGuids() { + std::vector guids; + const int n = CountSelectedTracks(nullptr); // nullptr = active project + guids.reserve(static_cast(n < 0 ? 0 : n)); + for (int i = 0; i < n; ++i) { + MediaTrack* tr = GetSelectedTrack(nullptr, i); + if (!tr) continue; + std::string g = guidString(tr); + if (!g.empty()) guids.push_back(std::move(g)); + } + return guids; +} + +// Reapplies the model's CURRENT active mode to the active project so a membership +// mutation takes visible effect immediately (park/unpark/re-derive parents). Called +// after every tag/untag/show-both. `proj = nullptr` -> REAPER's active project. +void reapplyActiveMode() { + applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); +} + +// Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to +// decide whether an item's lane name is meaningful for the manual-lane read. +constexpr int kFreeModeFixedLanes = 2; + +// Collects the current media-item selection as the pure decision's input: each selected +// item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned). +// The manual-lane read follows the shared pure predicate exactly as the shell's readers +// do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal +// track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped. +// Items whose GUID cannot be read are dropped (an empty GUID must never be retagged). +std::vector selectedRetagItems() { + std::vector items; + const int n = CountSelectedMediaItems(nullptr); // nullptr = active project + items.reserve(static_cast(n < 0 ? 0 : n)); + for (int i = 0; i < n; ++i) { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (!it) continue; + std::string g = itemGuid(it); + if (g.empty()) continue; + + MediaTrack* tr = GetMediaItemTrack(it); + const bool fixedLane = + tr && static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; + // Only read the lane name on a fixed-lane track; the pure predicate handles the + // normal-track case (returns false) so we pass an empty name and skip the read. + const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{}; + items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)}); + } + return items; +} + +// Persists both the bank and the Design-View model to the active project's ext +// state. Called after every state-changing Design View action so the view model +// is not lost across save/close/reopen. Marking the project dirty is correct — +// a Design View mutation is a project-level change the user should be prompted +// to save. +// +// When the membership index is non-empty AND the project is unsaved, we prompt +// the user to Save-As before persisting — mirroring the flow capture uses. +// Gate: if membership is empty (no tracks tagged), skip the prompt entirely; +// saveToActiveProject will no-op for an unsaved project, which is correct. +// +// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save-As dialog and +// blocks until the user dismisses it. The blocking behaviour and dialog +// appearance can only be confirmed in a running REAPER (same caveat as capture). +void persistViewState() { + if (!g_session->view().membership().empty()) { + // At least one track is tagged — worth persisting. Check whether the + // project is saved and, if not, prompt Save-As so saveToActiveProject + // can write ext state. Mirrors capture's readRppPath idiom exactly. + ReaProject* proj = EnumProjects(-1, nullptr, 0); + if (proj) { + auto readRppPath = [&]() -> std::string { + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + return std::string(buf.data()); + }; + + if (readRppPath().empty()) { + // Project is unsaved — prompt Save-As. + Main_SaveProject(proj, true); + // Re-read: still empty means the user cancelled. + if (readRppPath().empty()) { + ShowConsoleMsg( + "ReaSampler: Design View state will not persist until " + "the project is saved.\n"); + // The in-session tag state is left as-is — the mode change + // already applied and remains valid for this session. + return; + } + } + } + } + g_session->saveToActiveProject(); +} + +// -- Action bodies --------------------------------------------------------- + +// Toggle: cycle to the next mode in ordinal order (Arrange <-> Design with two +// seeds; scales to cycle-through-all for >2 modes with no change here). applyMode +// itself sets the model's active mode, so we only compute the target and apply. +void doToggleMode() { + const std::string target = + nextModeId(g_session->view().modes(), g_session->view().activeModeId()); + if (target.empty()) return; // no modes to cycle to (degenerate) + applyMode(g_session->view(), target, nullptr); + persistViewState(); + bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately +} + +// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if +// the id is unregistered, so an absent mode fails safe. +void doActivateMode(const std::string& modeId) { + applyMode(g_session->view(), modeId, nullptr); + persistViewState(); + bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately +} + +// Tag the selection's leaves into `modeId`, then reapply so the change is immediate. +// tag() replaces any prior single-mode membership (a leaf lives in one mode; the +// cross-mode case is show-both), matching the D1 contract. +void doTag(const std::string& modeId) { + for (const std::string& g : selectedTrackGuids()) + g_session->view().membership().tag(g, modeId); + reapplyActiveMode(); + persistViewState(); +} + +// Untag the selection entirely (return each to the Arrange default). This is the +// shared body behind both "Untag selected" and "Tag -> Arrange" (Arrange = the +// absence of a tag), so the two actions are the same act by definition. +void doUntag() { + for (const std::string& g : selectedTrackGuids()) + g_session->view().membership().untag(g); + reapplyActiveMode(); + persistViewState(); +} + +// Toggle the per-track show-both pin for the selection. Read the CURRENT pin of each +// track and flip it independently (a mixed selection converges toward "all on" then +// "all off" only if uniform; per-track flip is the honest semantics of a toggle on a +// multi-selection). show-both leaves are never parked (D1), so reapply reflects the +// change immediately. +void doShowBoth() { + MembershipIndex& m = g_session->view().membership(); + for (const std::string& g : selectedTrackGuids()) + m.setShowBoth(g, !m.isShowBoth(g)); + reapplyActiveMode(); + persistViewState(); +} + +// -- Item-level mode moves (D2 Wave 3-B) ----------------------------------- +// +// Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default), +// then re-drive the minting + apply path so each moved item lands on its target mode's +// managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag +// decides which selected items to retag (manual-lane items are EXEMPT — never retagged, +// never re-laned), upholding the managed-lanes-only invariant even under this explicit +// user action. The whole structural act is wrapped in ONE Undo block with a descriptive +// label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it). +// +// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which +// must NOT sit inside the Undo block, so we close the block first, then persist — the same +// separation the track actions rely on (they persist outside applyMode's own block). +void doMoveItems(const std::string& targetMode) { + const std::vector selected = selectedRetagItems(); + const std::vector ops = planItemRetag(selected, targetMode); + if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty + + MembershipIndex& membership = g_session->view().membership(); + + Undo_BeginBlock2(nullptr); + // Apply the pure decision's membership writes: tag into targetMode, or untag. + for (const ItemRetagOp& op : ops) { + if (op.untag) membership.untag(op.guid); + else membership.tag(op.guid, op.modeId); + } + // Re-drive the SAME minting/apply path auto-tag uses: mint/split lanes for any track + // whose items now span modes and assign each moved item to its mode's managed lane, + // then reassert the active mode's lane visibility. Manual lanes stay untouched + // (mintManagedLanes reports their items exempt and never mints over them). + mintManagedLanes(g_session->view(), nullptr); + reapplyActiveMode(); + + const std::string label = + targetMode.empty() + ? std::string("ReaSampler: untag selected items") + : std::string("ReaSampler: move selected items -> ") + targetMode; + Undo_EndBlock2(nullptr, label.c_str(), -1); + + persistViewState(); +} + +} // namespace + +void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { + g_session = session; + + // command_id -> gaccel for each. The single hookcommand that routes these lives + // in main.cpp (one hook per extension); designViewHandleCommand services them. + g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode, + "toggle Design View mode"); + g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange, + "activate mode Arrange"); + g_cmdActivateDesign = registerAction(rec, kIdActivateDesign, g_accelActivateDesign, + "activate mode Design"); + g_cmdTagDesign = registerAction(rec, kIdTagDesign, g_accelTagDesign, + "tag selected tracks -> Design"); + g_cmdTagArrange = registerAction(rec, kIdTagArrange, g_accelTagArrange, + "tag selected tracks -> Arrange"); + g_cmdUntag = registerAction(rec, kIdUntag, g_accelUntag, + "untag selected tracks"); + g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth, + "show both for selected tracks"); + + // Item-level mode moves (D2 W3-B): the item analog of the track tag family. + g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign, + "move selected items -> Design"); + g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange, + "move selected items -> Arrange"); + g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems, + "untag selected items"); +} + +bool designViewHandleCommand(int command) { + if (command == 0 || !g_session) return false; + + if (command == g_cmdToggleMode) { doToggleMode(); return true; } + if (command == g_cmdActivateArrange) { doActivateMode(kArrangeModeId); return true; } + if (command == g_cmdActivateDesign) { doActivateMode(kDesignModeId); return true; } + if (command == g_cmdTagDesign) { doTag(kDesignModeId); return true; } + // Tag -> Arrange and Untag are the same act (Arrange = the absence of a tag). + if (command == g_cmdTagArrange) { doUntag(); return true; } + if (command == g_cmdUntag) { doUntag(); return true; } + if (command == g_cmdShowBoth) { doShowBoth(); return true; } + + // Item-level moves. Move -> Arrange and Untag items collapse to the same act (an + // empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above. + if (command == g_cmdMoveItemsDesign) { doMoveItems(kDesignModeId); return true; } + if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; } + if (command == g_cmdUntagItems) { doMoveItems(std::string{}); return true; } + + return false; // not ours — caller's hookcommand keeps looking +} + +void designViewUnregisterActions(reaper_plugin_info_t* rec) { + // Mirror-unregister with '-'-prefixed strings, per the contract's unload rule. + // gaccel first, then the command_id string (reverse of registration order — the item + // moves registered last, so they tear down first). + // Each '-command_id' re-presents the SAME interned, channel-qualified id (channelIdFor + // returns the memoized pointer registered above), so the unregister matches exactly. + rec->Register("-gaccel", (void*)&g_accelUntagItems); + rec->Register("-command_id", (void*)channelIdFor(kIdUntagItems)); + rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange); + rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsArrange)); + rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign); + rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsDesign)); + rec->Register("-gaccel", (void*)&g_accelShowBoth); + rec->Register("-command_id", (void*)channelIdFor(kIdShowBoth)); + rec->Register("-gaccel", (void*)&g_accelUntag); + rec->Register("-command_id", (void*)channelIdFor(kIdUntag)); + rec->Register("-gaccel", (void*)&g_accelTagArrange); + rec->Register("-command_id", (void*)channelIdFor(kIdTagArrange)); + rec->Register("-gaccel", (void*)&g_accelTagDesign); + rec->Register("-command_id", (void*)channelIdFor(kIdTagDesign)); + rec->Register("-gaccel", (void*)&g_accelActivateDesign); + rec->Register("-command_id", (void*)channelIdFor(kIdActivateDesign)); + rec->Register("-gaccel", (void*)&g_accelActivateArrange); + rec->Register("-command_id", (void*)channelIdFor(kIdActivateArrange)); + rec->Register("-gaccel", (void*)&g_accelToggleMode); + rec->Register("-command_id", (void*)channelIdFor(kIdToggleMode)); + + g_session = nullptr; +} + +} // namespace reasampler diff --git a/src/shell/actions/design_view_actions.h b/src/shell/actions/design_view_actions.h new file mode 100644 index 0000000..4006516 --- /dev/null +++ b/src/shell/actions/design_view_actions.h @@ -0,0 +1,38 @@ +#pragma once +// design_view_actions — the Design View action family (Phase D4; Q-W4 split of +// actions.h). Registers the bindable actions that drive the mode workflow and wires +// them end-to-end: toggle/activate a mode, tag/untag/show-both the current track +// selection, and the item-level mode moves (D2 W3-B). Each action mutates the +// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies +// the active mode through the view shell (D2) so the change takes effect immediately. +// +// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This +// header is SDK-free; main.cpp calls register/handle/unregister and nothing else. + +// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct +// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real +// definition. Declared before the namespace so it is the global type, not a +// namespace-local shadow. +struct reaper_plugin_info_t; + +namespace reasampler { + +class ReaSamplerSession; + +// Registers the Design View action family against `rec` (command_id + gaccel + +// hookcommand-routing is owned by the caller's single hookcommand). `session` is the +// live session the actions mutate; it must outlive the registration. Idempotent is +// NOT promised — call exactly once at load, mirror-unregister once at unload. +void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); + +// Services one fired command. Returns true iff `command` is one of this module's +// action ids (and it was handled); false otherwise so the caller's hookcommand keeps +// looking (per the contract: claim only our own ids). Safe to call for any command. +bool designViewHandleCommand(int command); + +// Mirror-unregisters everything designViewRegisterActions registered, with the +// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr. +void designViewUnregisterActions(reaper_plugin_info_t* rec); + +} // namespace reasampler diff --git a/src/drag_out_win.cpp b/src/shell/actions/drag_out_win.cpp similarity index 99% rename from src/drag_out_win.cpp rename to src/shell/actions/drag_out_win.cpp index e555f24..8316a86 100644 --- a/src/drag_out_win.cpp +++ b/src/shell/actions/drag_out_win.cpp @@ -9,7 +9,7 @@ // Compiled into the reaper_reasampler MODULE. No REAPER API is used here (pure OS/COM); it // is a leaf the bank_panel calls. -#include "drag_out_win.h" +#include "shell/actions/drag_out_win.h" #ifdef _WIN32 diff --git a/src/drag_out_win.h b/src/shell/actions/drag_out_win.h similarity index 100% rename from src/drag_out_win.h rename to src/shell/actions/drag_out_win.h diff --git a/src/instrument_drop_win.cpp b/src/shell/actions/instrument_drop_win.cpp similarity index 93% rename from src/instrument_drop_win.cpp rename to src/shell/actions/instrument_drop_win.cpp index 363c9c8..fa2503d 100644 --- a/src/instrument_drop_win.cpp +++ b/src/shell/actions/instrument_drop_win.cpp @@ -3,7 +3,7 @@ // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT // REAPERAPI_IMPLEMENT (main.cpp owns the pointers; here they are extern via the WANT list). -#include "instrument_drop_win.h" +#include "shell/actions/instrument_drop_win.h" #include #include @@ -13,8 +13,8 @@ #include #include -#include "app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing) -#include "instrument_drop.h" // infoNamesFxHotspot — the PURE, unit-tested hotspot classifier +#include "core/version/app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing) +#include "core/wire/instrument_drop.h" // infoNamesFxHotspot — the PURE, unit-tested hotspot classifier #include "reaper_plugin.h" @@ -29,6 +29,10 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using version::vstPluginName; +using wire::infoNamesFxHotspot; + namespace { // Write `bytes` to a fresh uniquely-named .vstpreset in the OS temp dir and return its path; @@ -42,7 +46,7 @@ namespace { // // Non-throwing: every std::filesystem call uses the error_code overload. The whole body is // wrapped in try/catch to guarantee no exception crosses the REAPER C callback boundary -// (the same discipline persist.cpp uses — see its non-throwing scanPruneOrphans comment). +// (the same discipline the prune shell uses — see prune_fs.cpp's non-throwing scan comment). // // Returns the path object (not a narrow string) so the caller can: // (a) pass path.u8string() to TrackFX_SetPreset — UTF-8 on MSVC, not ACP-converted, diff --git a/src/instrument_drop_win.h b/src/shell/actions/instrument_drop_win.h similarity index 100% rename from src/instrument_drop_win.h rename to src/shell/actions/instrument_drop_win.h diff --git a/src/shell/actions/prune_action.cpp b/src/shell/actions/prune_action.cpp new file mode 100644 index 0000000..a0b037d --- /dev/null +++ b/src/shell/actions/prune_action.cpp @@ -0,0 +1,102 @@ +// prune_action.cpp — the "Prune bank folder" action body (Phase R3; Q-W4 split of +// actions.cpp). See prune_action.h for the contract this TU preserves. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). + +#include "shell/actions/prune_action.h" + +#include +#include + +#include "shell/persist/session.h" // ReaSamplerSession — pruneDryRun / pruneOrphanSet / pruneReclaim + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_ShowMessageBox +#include "reaper_plugin_functions.h" + +namespace reasampler { + +// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE +// file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only), +// then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest +// (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then +// on explicit Yes delete EXACTLY that set (session.pruneReclaim, which recomputes the +// pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback). +// Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing. +// +// The full (untruncated) orphan set is captured here for the delete; the dry-run's +// truncated list is only the confirm's readout. No ext-state is written and no undo point +// is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project +// state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files. +void doBankPruneFolder(ReaSamplerSession& session) { + const reclaim::PruneReport report = session.pruneDryRun(); + + // pS-usage FAIL-SAFE: a present instance-usage record could not be read — the + // protected set is unknowable, so the prune HALTS outright (deletes nothing) rather + // than proceed with degraded protection. Distinct from "no orphans": the user must + // know the prune refused to run and why. + if (report.abortedUnreadableUsage) { + std::string msg = + "ReaSampler prune: ABORTED -- one or more instance usage records could not " + "be read or decoded. Nothing was deleted.\n" + "If the owning instance is still loaded it will republish its record on the " + "next poll tick, clearing the abort. If the instance no longer exists (the " + "key is an orphaned corrupt record), clear it manually via ReaScript:\n" + " reaper.SetProjExtState(0, \"reasampler\", \"\", \"\")\n" + "Offending key(s):\n"; + for (const std::string& key : report.offendingUsageKeys) { + msg += " " + key + "\n"; + } + ShowConsoleMsg(msg.c_str()); + return; + } + + if (report.count == 0) { + ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n"); + return; + } + + // The EXACT set the delete will target — full, untruncated, so what the confirm + // summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the + // confirm so the confirm and the delete reason about the same enumeration. + const std::vector orphanSet = session.pruneOrphanSet(); + + // Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped + // list (the same clip the R2 readout used), with a "N more not shown" tail when clipped. + std::string msg = + "ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) + + " orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n" + "These files are no longer referenced by any bank and were created by ReaSampler.\n" + "They will be moved to the Recycle Bin on Windows (recoverable), or deleted on " + "other platforms.\n\n"; + for (const std::string& rel : report.orphans) msg += " " + rel + "\n"; + if (report.truncated) { + msg += " ... (" + std::to_string(report.count - report.orphans.size()) + + " more not shown)\n"; + } + msg += "\nReclaim these files now?"; + + const int r = ShowMessageBox(msg.c_str(), "ReaSampler: prune bank folder", 4); + if (r != 6) { // 6 == YES; anything else cancels -> delete NOTHING (SDK ~6544) + ShowConsoleMsg("ReaSampler prune: cancelled -- nothing deleted.\n"); + return; + } + + // Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped). + const reclaim::PruneDeletionResult del = session.pruneReclaim(orphanSet); + + std::string done = "ReaSampler prune: reclaimed " + + std::to_string(del.reclaimedCount) + " file(s), " + + std::to_string(del.reclaimedBytes) + " bytes" + + (del.usedTrash ? " (to Recycle Bin)" : " (deleted)") + "."; + if (del.skippedCount > 0) { + done += " " + std::to_string(del.skippedCount) + + " file(s) skipped (locked, or changed since the report)."; + } + done += "\n"; + ShowConsoleMsg(done.c_str()); +} + +} // namespace reasampler diff --git a/src/shell/actions/prune_action.h b/src/shell/actions/prune_action.h new file mode 100644 index 0000000..fed17df --- /dev/null +++ b/src/shell/actions/prune_action.h @@ -0,0 +1,22 @@ +#pragma once +// prune_action — the "Prune bank folder" action body (Phase R3; Q-W4 split of +// actions.cpp). This is the SOLE file-deletion action in ReaSampler, isolated in its +// own TU so the deletion authority is one obvious module on the actions side (its +// persist-side counterpart concentrates into prune_fs in Q-W5). Registration and +// hookcommand routing for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay with the +// bank family (bank_actions) — one registration flow, one guarded body here. +// +// Contract (preserve exactly): dry-run first; abort outright on unreadable usage +// records (pS-usage fail-safe); confirm-with-manifest before any deletion; opens NO +// undo point and writes NO ext state (file deletion is not REAPER-undoable). Routes +// to persist's public session API only (pruneDryRun / pruneOrphanSet / pruneReclaim). + +namespace reasampler { + +class ReaSamplerSession; + +// Runs the guarded prune flow against `session`. Called by the bank family's +// hookcommand handler when the BANK_PRUNE_FOLDER action fires. +void doBankPruneFolder(ReaSamplerSession& session); + +} // namespace reasampler diff --git a/src/shell/bank_ops/bank_ops.cpp b/src/shell/bank_ops/bank_ops.cpp new file mode 100644 index 0000000..55dfe4e --- /dev/null +++ b/src/shell/bank_ops/bank_ops.cpp @@ -0,0 +1,183 @@ +// bank_ops.cpp — the promptless bank-verb seam (Q-W6 lift; see bank_ops.h for the +// contract). The ONE implementation home of the bank verbs (create / rename / +// delete / evacuate / activate / move / copy / remove): each mutates the given +// session's book() then persists via persistBankOp() (one bank op = one Ctrl-Z; a +// true index no-op opens NO undo point). It DOES mutate the bank BOOK — but only +// the index/model + ext-state, never the arrange, never a sample file on disk +// (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank). +// REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL mutation any +// Bank*/BankModel& is invalid — verbs take ids and resolve fresh per model call. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include "shell/bank_ops/bank_ops.h" + +#include +#include + +#include "core/model/bank_book.h" // BankBook / TransferResult / RemoveScope +#include "shell/persist/session.h" // ReaSamplerSession — the session the verbs mutate + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_genGuid +#define REAPERAPI_WANT_guidToString +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model +// design: ids are caller-supplied and stable; the model stays pure and mints none). +// Distinct from a track GUID by origin only — both are canonical guidToString output. +std::string mintBankId() { + GUID g{}; + genGuid(&g); + char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract) + guidToString(&g, buf); + return std::string(buf); +} + +} // namespace + +// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — +// one bank op = one Ctrl-Z. +// +// WHY THIS WRAPS AND saveToActiveProject() DOES NOT: a bank verb mutates ONLY our +// project ext-state (SetProjExtState under "reasampler"), which REAPER's undo system +// captures iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK +// documents MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h +// ~1544, ~1199). We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the +// item-move family does): a bank verb touches no tracks, FX, items, or envelopes, so +// snapshotting them would be both heavier and semantically wrong. The persist runs +// INSIDE the block so the post-mutation ext-state is the block's "after" image. +// +// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project saveToActiveProject() +// no-ops (nothing is written to ext state). We must still CLOSE the block we opened, +// but with an EMPTY label and a zero flag so REAPER DISCARDS the point instead of +// recording a no-effect undo entry — mirroring view.cpp's empty-plan close. The +// in-session model change stands and persists on the user's next save; it just earns +// no undo point until there is a project to persist into (undo of an unsaved bank op +// has nothing to roll back to anyway). The Begin/End must still be balanced, hence +// the close-either-way. (Quiet persist by design — mirrors the CAPTURE path, NOT the +// Design-View path; deliberately NO Save-As prompt.) +void persistBankOp(ReaSamplerSession& session, const char* label, + bool bumpGeneration) { + Undo_BeginBlock2(nullptr); + // S9: bump the bank-generation counter INSIDE the block, before the persist, so the + // fresh generation rides the same ext-state write (saveToActiveProject() stamps + // bankGeneration()). Bumped only for content-changing verbs (the caller decides); a + // pure-organizational verb passes false and leaves the counter be, so a + // rename/activate does not needlessly refresh live instances. + if (bumpGeneration) session.bumpBankGeneration(); + const bool persisted = session.saveToActiveProject(); + if (persisted) + Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); + else + Undo_EndBlock2(nullptr, "", 0); // no ext-state write -> discard the empty point +} + +// --- Promptless inner bank verbs (one home) ------------------------------------ +// Model op + persistBankOp only; NO UX. Callers own prompts/confirms/nudges and the +// session-liveness question. Each verb persists ONLY after the model accepted — a +// rejected op opens no undo point. + +std::string bankOpCreate(ReaSamplerSession& session, const std::string& name) { + const std::string id = mintBankId(); + if (!session.book().createBank(id, name)) return {}; // duplicate display name (model rule) + persistBankOp(session, "ReaSampler: create bank"); + return id; +} + +bool bankOpRename(ReaSamplerSession& session, const std::string& bankId, + const std::string& newName) { + if (!session.book().renameBank(bankId, newName)) return false; // pool / name in use + persistBankOp(session, "ReaSampler: rename bank"); + return true; +} + +bool bankOpDelete(ReaSamplerSession& session, const std::string& bankId, + bool bumpGeneration) { + if (!session.book().deleteBank(bankId)) return false; // pool un-deletable (model rule) + persistBankOp(session, "ReaSampler: delete bank", bumpGeneration); + return true; +} + +bool bankOpEvacuate(ReaSamplerSession& session, const std::string& bankId) { + if (!session.book().evacuate(bankId)) return false; // pool is a destination, not a source + // S9: evacuate moves members between banks (bank membership changes) -> bump. + persistBankOp(session, "ReaSampler: evacuate bank", /*bumpGeneration=*/true); + return true; +} + +bool bankOpActivate(ReaSamplerSession& session, const std::string& bankId) { + if (!session.book().setActiveBank(bankId)) return false; // rejects an unknown id + persistBankOp(session, "ReaSampler: activate bank"); + return true; +} + +// NO-OP GUARDRAIL — VERB-AWARE (a collapse means different things per verb): +// * MOVE collapse: the source entry WAS removed (bank_book moveSample removes +// unconditionally before the dest add collapses on hash), so the index DID +// mutate — it counts toward opening an undo point. +// * COPY collapse: the source is left intact AND the dest already held the hash, +// so NOTHING changed — a true index no-op. It must NOT open an undo point. +// Hence: copy counts only real gains; move counts gains OR collapses. Ids pass +// straight to the model op — no BankModel& cached across the loop's mutations. +bool bankOpTransfer(ReaSamplerSession& session, + const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy) { + BankBook& b = session.book(); + if (sampleIds.empty() || srcBankId == destBankId) return false; + if (!b.bank(srcBankId) || !b.bank(destBankId)) return false; + int ok = 0, collapsed = 0; + for (const std::string& sid : sampleIds) { + const TransferResult r = + copy ? b.copySample(sid, srcBankId, destBankId) + : b.moveSample(sid, srcBankId, destBankId); + switch (r) { + case TransferResult::Moved: + case TransferResult::Copied: ++ok; break; + case TransferResult::Collapsed: ++collapsed; break; + case TransferResult::RejectedUnknownBank: + case TransferResult::RejectedSampleAbsent: + case TransferResult::RejectedSameBank: break; + } + } + const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); + if (!mutated) return false; // nothing changed — no persist, no undo point + // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an + // instance may reference) -> bump so assigned instances refresh hands-free. + persistBankOp(session, + copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)", + /*bumpGeneration=*/true); + return true; +} + +// Index-only, this-bank scope (fork R-A: the sole surfaced verb; RemoveScope::AllBanks +// stays latent in the model). Non-destructive to the file: a last-reference remove +// leaves the file on disk, orphaned until Phase R prune — remove NEVER deletes bytes +// (the manifest is untouched). Silent: recoverability is the batched undo (R-B). +bool bankOpRemove(ReaSamplerSession& session, + const std::vector& sampleIds, + const std::string& srcBankId) { + BankBook& b = session.book(); + if (sampleIds.empty() || !b.bank(srcBankId)) return false; + int removed = 0; + for (const std::string& sid : sampleIds) + if (b.removeSample(sid, srcBankId, RemoveScope::ThisBank) == + RemoveResult::Removed) + ++removed; + if (removed == 0) return false; // every id already absent — no undo point + // S9: a remove drops a sample from a bank (an instance referencing it must refresh — + // it will resolve to silence, per the stale-id policy) -> bump. + persistBankOp(session, "ReaSampler: remove sample(s)", /*bumpGeneration=*/true); + return true; +} + +} // namespace reasampler diff --git a/src/shell/bank_ops/bank_ops.h b/src/shell/bank_ops/bank_ops.h new file mode 100644 index 0000000..659efa0 --- /dev/null +++ b/src/shell/bank_ops/bank_ops.h @@ -0,0 +1,83 @@ +#pragma once +// bank_ops — the promptless bank-verb seam (Q-W6 lift of the Q-W4 single-owner +// verbs out of shell/panel/panel_bank_ops into a NON-UI home). Each verb is a model +// op on the given session's BankBook + persistBankOp (undo-batched ext-state +// persist) — NO prompts, NO message boxes, NO panel-state nudges, NO panel-global +// reads. The two UX surfaces consume these as thin skins: +// +// * shell/panel/panel_bank_ops — the panel's menu handlers (prompts / confirms / +// repaints), passing the panel's live session. +// * shell/actions/bank_actions — the bindable family (text prompts / console +// feedback), passing its registered session. +// +// The session arrives BY REFERENCE: there is exactly one session pointer question +// per call site (the caller's), so a missing session can never be half-reported as +// a model rejection from in here (the Q-W4 review's fail-safe-collapse concern). +// Every verb returns whether the model accepted the mutation — a rejected op +// persists nothing and opens no undo point. +// +// REAPER-facing (persist + undo blocks + GUID minting) but SDK-free in this header. + +#include +#include + +namespace reasampler { + +class ReaSamplerSession; + +// Mints a stable GUID bank id, creates `name` in the book. Returns the new bank id, +// or "" when the model rejects the name (duplicate, trimmed + case-insensitive). +// Create is purely organizational — no generation bump. +std::string bankOpCreate(ReaSamplerSession& session, const std::string& name); + +// Renames `bankId`. False when the model rejects (pool un-renamable / name in use). +bool bankOpRename(ReaSamplerSession& session, const std::string& bankId, + const std::string& newName); + +// Deletes `bankId`. False when the model rejects (pool un-deletable). The caller +// passes `bumpGeneration` from the member count it read BEFORE any evacuate/delete +// (an evacuate-then-delete flow must still bump on the ORIGINAL membership). +bool bankOpDelete(ReaSamplerSession& session, const std::string& bankId, + bool bumpGeneration); + +// Evacuates `bankId`'s members to the pool. False when the model rejects (the pool +// itself). Bumps the generation (membership changed). +bool bankOpEvacuate(ReaSamplerSession& session, const std::string& bankId); + +// Activates `bankId` as the capture target. False on an unknown id. No bump. +bool bankOpActivate(ReaSamplerSession& session, const std::string& bankId); + +// Moves (copy=false) or copies (copy=true) `sampleIds` from `srcBankId` to +// `destBankId` (index-only; files never relocate). Returns whether the index +// actually mutated — the verb-aware no-op guardrail: a COPY collapse changes +// nothing (no undo point); a MOVE collapse did remove the source entry (counts). +// Persists ONE undo point ("move/copy sample(s)") only when mutated. +bool bankOpTransfer(ReaSamplerSession& session, + const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy); + +// Removes `sampleIds` from `srcBankId` (index-only, this-bank scope; never deletes +// bytes). Returns whether anything was removed; persists one undo point when so. +bool bankOpRemove(ReaSamplerSession& session, + const std::vector& sampleIds, + const std::string& srcBankId); + +// Persists a completed bank-index verb as a single REAPER undo point (R-B). +// Wraps the session persist (SetProjExtState) in a Begin/End block with +// UNDO_STATE_MISCCFG so the bank op is one Ctrl-Z. On an unsaved / no-active project +// the persist no-ops and the block is closed with an empty label + zero flag (REAPER +// discards it). Callers must invoke this ONLY after a successful/effective mutation — +// rejected ops (duplicate name, un-deletable pool, etc.) must return before reaching +// here so no empty undo point is ever opened for a no-op. +// +// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a +// live instance would PLAY — move / copy / remove / evacuate / delete-with-members. Leave +// it false (the default) for a PURELY ORGANIZATIONAL verb — create / rename / activate / +// reorder. The bump (when requested) happens INSIDE the block, BEFORE the persist, so +// the stamped counter rides the same ext-state write and undo captures the pre/post +// generation with the rest of the blob. +void persistBankOp(ReaSamplerSession& session, const char* label, + bool bumpGeneration = false); + +} // namespace reasampler diff --git a/src/capture.cpp b/src/shell/capture/capture.cpp similarity index 83% rename from src/capture.cpp rename to src/shell/capture/capture.cpp index 61c9113..b68fd8a 100644 --- a/src/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -1,4 +1,5 @@ -// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend). +// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend) plus +// the shared backend helpers (makeUniqueTag / stampCaptureSample — Q-W3 riders). // // Compiled into the reaper_reasampler MODULE. Includes // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU @@ -33,8 +34,9 @@ // is the realtime-record backend (M8), which captures the master bus output to a // temp track during playback and never invokes the offline render pipeline. -#include "capture.h" +#include "shell/capture/capture.h" +#include #include #include #include @@ -42,8 +44,10 @@ #include #include -#include "capture_paths.h" -#include "render_settings.h" +#include "core/capture/capture_paths.h" +#include "core/capture/wav_codec.h" // hashWavContent — the one WAV/RIFF owner +#include "core/util/file_bytes.h" +#include "core/capture/render_settings.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects @@ -56,7 +60,7 @@ #define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime #include "reaper_plugin_functions.h" -namespace reasampler { +namespace reasampler::capture { namespace { @@ -217,32 +221,90 @@ struct ScopedRenderSettings { ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete; }; -// A monotonic, filesystem-safe timestamp tag so repeated captures in one session -// do not collide on the file name. NOTE: the tag varies the file NAME, not the -// audio bytes — bit-identical-repeat is about identical *content* for identical -// requests; two deliberate captures naturally live in two files. -std::string makeUniqueTag() { - std::time_t now = std::time(nullptr); - return std::to_string(static_cast(now)); -} - -// Reads the whole file into a byte buffer. Returns an empty vector on any I/O -// failure (the caller then leaves contentHash empty — the safe, confirm-eliciting -// direction for an unreadable file). -std::vector readFileBytes(const std::string& path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return {}; - const std::streamoff size = f.tellg(); - if (size <= 0) return {}; - std::vector bytes(static_cast(size)); - f.seekg(0); - f.read(reinterpret_cast(bytes.data()), size); - if (!f) return {}; - return bytes; -} +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03): +// empty on any I/O failure (the caller then leaves contentHash empty — the safe, +// confirm-eliciting direction for an unreadable file). } // namespace +// --- Shared backend helpers (Q-W3 riders — see capture.h) -------------------- + +std::string makeUniqueTag(const std::string& prefix) { + // Timestamp + PER-SESSION MONOTONIC counter (T1-11 fix). The timestamp alone + // had one-second resolution: two captures of the same baseName within the same + // wall-clock second derived the same file stem, so the second render silently + // overwrote the first file and minted two Samples with colliding ids — + // reachable in practice via batch capture. The counter (shared across both + // backends — this is the one definition both call) makes every tag of a + // session distinct regardless of timing. NOTE: the tag varies the file NAME, + // not the audio bytes — bit-identical-repeat is about identical *content* for + // identical requests; two deliberate captures naturally live in two files. + // RESIDUAL (Q-W3 review follow-up): the counter is per-process, starting over + // at 0 on every REAPER launch/extension reload, so two separate REAPER + // instances (or a reload mid-session) can still mint the same timestamp+counter + // pair in the same wall-clock second — a same-second cross-process collision + // remains theoretically possible. Scoped to per-session deliberately: this fix + // targets the reachable-in-practice single-process batch-capture case above. + static std::atomic counter{0}; + const std::time_t now = std::time(nullptr); + return prefix + std::to_string(static_cast(now)) + "-" + + std::to_string(++counter); +} + +void stampCaptureSample(Sample& s, const CaptureRequest& req, + ReaProject* rateProj, ReaProject* timeSigProj, + const std::string& absolutePath) { + // Track GUIDs + channel count: echoed from the request (the caller resolved + // the selection; the backends stay source-agnostic). + s.trackGuids = req.trackGuids; + s.channelCount = req.channelCount; + + // Resolved sample rate: the request's pinned rate, else PROJECT_SRATE read + // from the caller's project handle. PROJECT_SRATE can read 0 on a project that + // never explicitly pinned a rate — the value stays 0 (the Sample zero-value) + // rather than a bogus literal (the honest "unknown" both backends shared). + s.sampleRate = (req.sampleRate > 0) + ? req.sampleRate + : static_cast(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false)); + + s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651) + + // Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime + // (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time, + // int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at + // that project time, so a sample captured under 3/4 keeps a 3/4 read-out even + // if the project later switches to 4/4. `timeSigProj` is the CALLER's project + // pin — offline passes nullptr (the active project); realtime pins the record's + // own project (the T2-09 divergence, kept caller-visible as this argument). + // tempoOut is ignored — captureTempo already carries the master tempo. Leaves + // 0/0 (unstamped) if the API is somehow unavailable; the formatter renders a + // blank musical read-out. + { + int tsNum = 0, tsDenom = 0; + double tsTempo = 0.0; + TimeMap_GetTimeSigAtTime(timeSigProj, req.startSeconds, &tsNum, &tsDenom, &tsTempo); + s.captureTimeSigNum = tsNum; + s.captureTimeSigDenom = tsDenom; + } + + // Content hash: WAV-aware FNV-1a over the finished file's fmt+data chunks so + // hashReferencedElsewhere can identify copies in other banks and suppress the + // last-reference confirm when another bank still holds the same file. Using + // hashWavContent (not the raw hashBytes) skips render-varying metadata chunks + // (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders/records of + // identical audio collapse to the same hash. Best-effort: an unreadable file + // leaves contentHash empty — the safe, confirm-eliciting direction (bank_model + // treats "" as non-participating in dedup). + { + const std::vector fileBytes = util::readFileBytes(absolutePath); + if (!fileBytes.empty()) { + s.contentHash = hashWavContent(fileBytes); + } + } + + s.createdTimestamp = static_cast(std::time(nullptr)); +} + CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { CaptureResult result; @@ -349,9 +411,9 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { }(); // Compute the unique tag ONCE so the file stem and Sample.id carry the same - // timestamp. Calling makeUniqueTag() twice could yield different values if a - // second boundary crosses between the two calls (bug: id and filename diverge). - const std::string uniqueTag = makeUniqueTag(); + // tag. Calling makeUniqueTag() twice would yield different values (the counter + // advances per call — bug: id and filename diverge). + const std::string uniqueTag = makeUniqueTag(""); const BankPaths paths = deriveBankPaths(projectDir, request.baseName, uniqueTag); @@ -486,50 +548,16 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // Seconds are the authoritative source for the render. Do NOT add DAW- // unverifiable PPQ resolution here — it requires a live REAPER to validate. s.wetDry = request.wetDry; - // Track GUIDs for track-scoped captures (empty for master/items/razor). The - // caller resolved the selection to canonical GUID strings; we record them so a - // "re-capture from source" (M10) knows which tracks the sample came from. - s.trackGuids = request.trackGuids; - s.channelCount = request.channelCount; - // Store the resolved sample rate only when it is known (> 0). If the project - // never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE - // either, so the render ran at REAPER's project default — an unknown value from - // this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather - // than store a bogus literal; M6/M7 can fill it in by probing the rendered file. - s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown s.lengthSeconds = request.endSeconds - request.startSeconds; - s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651) - // Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime - // (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time, - // int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at that - // project time, so a sample captured under 3/4 keeps a 3/4 read-out even if the - // project later switches to 4/4. proj=nullptr => the active project (matches the - // Master_GetTempo() call above, which is also active-project). The tempoOut is - // ignored — captureTempo already carries the master tempo. Leaves 0/0 (unstamped) - // if the API is somehow unavailable; the formatter renders a blank musical read-out. - { - int tsNum = 0, tsDenom = 0; - double tsTempo = 0.0; - TimeMap_GetTimeSigAtTime(nullptr, request.startSeconds, &tsNum, &tsDenom, &tsTempo); - s.captureTimeSigNum = tsNum; - s.captureTimeSigDenom = tsDenom; - } - s.tier = Tier::Scratch; // captures land in scratch by default - // Content hash: WAV-aware FNV-1a over the rendered file's fmt+data chunks so - // hashReferencedElsewhere can identify copies in other banks and suppress the - // last-reference confirm when another bank still holds the same file. Using - // hashWavContent (not the raw hashBytes) skips render-varying metadata chunks - // (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders of identical - // audio collapse to the same hash. Best-effort: an unreadable file leaves - // contentHash empty — the safe, confirm-eliciting direction (bank_model treats - // "" as non-participating in dedup, which is the existing fallback semantics). - { - const std::vector fileBytes = readFileBytes(expectedPath); - if (!fileBytes.empty()) { - s.contentHash = hashWavContent(fileBytes); - } - } - s.createdTimestamp = static_cast(std::time(nullptr)); + s.tier = model::Tier::Scratch; // captures land in scratch by default + // The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount + // (request echo), resolved sampleRate (request rate else PROJECT_SRATE(proj) — + // 0 stays 0 when the project never pinned a rate; we did not force RENDER_SRATE + // either, so the render ran at REAPER's default), captureTempo, the capture- + // start time signature (timeSigProj = nullptr => the active project — matching + // the Master_GetTempo read, which is also active-project), the WAV-aware + // contentHash of the rendered file, and createdTimestamp. + stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath); // Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a // master mix / track / time-selection is not a single played note, so no root // note is derivable here — we do NOT guess one. Loop points are set later by an @@ -546,4 +574,4 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // guard's dtor restores every RENDER_* setting here. } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h new file mode 100644 index 0000000..e02aa56 --- /dev/null +++ b/src/shell/capture/capture.h @@ -0,0 +1,158 @@ +#pragma once +// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split). +// +// This header declares the SHARED capture seam (Q-W6 split of the former fat +// header — the realtime backend's async begin/tick/abort surface now lives in +// capture_realtime_shell.h): +// * CaptureRequest / CaptureResult — everything a capture needs and yields, +// source-mode-agnostic; the types BOTH backends speak. +// * OfflineRenderBackend — the deterministic default; a plain CONCRETE class +// (the former ICaptureBackend interface was deleted in +// Q-W3, T4-26 — it had one deriver and zero polymorphic +// call sites; every construction site instantiates the +// concrete type). +// * makeUniqueTag / stampCaptureSample — the shared file-tag mint and the shared +// finished-capture metadata stamp both backends call +// (Q-W3 riders T1-11 / T2-09). +// +// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER +// headers — the .cpp is the REAPER-facing translation unit. Keeping this header +// REAPER-free lets callers (the capture orchestration TUs) depend on the seam +// without dragging the SDK into every include site. + +#include +#include + +#include "core/model/bank_model.h" +#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract + +// MediaTrack / ReaProject are forward-declared (like track_guid.h) so this header +// stays REAPER-free while RealtimeRecordBackend::begin can take the resolved source +// MediaTrack* to tap and stampCaptureSample can take the project handles its reads +// pin. The pointers are opaque here — never dereferenced in a pure/header context; +// only the REAPER-facing capture TUs touch them. +class MediaTrack; +class ReaProject; + +namespace reasampler::capture { + +using model::Sample; + +// Audio bit-depth for the rendered wav. 32-bit float is the M3 default — +// rationale lives in capture.cpp next to the sink-config bytes. +enum class WavBitDepth { + Int16, + Int24, + Float32, +}; + +// One capture, independent of source mode. Populated by the caller (the action +// handler in M3; the action family in M7) and consumed by a backend. +// +// M3 fills only the fields the master-mix/time-selection path needs; the rest +// are declared now so M7/M8 do not reshape the struct (they are the seam). +struct CaptureRequest { + SourceMode sourceMode = SourceMode::MasterMix; + + // Sample-accurate render bounds in project seconds. For the M3 spike these + // come straight from the time selection (GetSet_LoopTimeRange) — NO rounding. + double startSeconds = 0.0; + double endSeconds = 0.0; + + // 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet). + // The field is kept as the seam for future true-dry work (M10 null test): + // true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires + // FX-bypass-around-render or the M8 realtime pre-FX path, and will be + // designed alongside the M10 null test. Also recorded on the Sample. + double wetDry = 1.0; + + // Track GUID(s) the capture came from, when the source mode is track-scoped + // (SelectedTracks). Empty for master/items/razor. The action layer (M7) + // resolves the selection to canonical GUID strings and passes them here; the + // backend copies them onto the Sample (it does NOT itself read the selection — + // it stays source-agnostic, driven entirely by the request). + std::vector trackGuids; + + // Render tail (docs/product/capture-tail.md §The three tail states). Default + // None: exact bounds, no added silence — the precision invariant, and the only + // mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for + // TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the + // 8 s cap + -72 dB trim internally, None ignores it. + TailMode tailMode = TailMode::None; + double tailMs = 0.0; + + // Output format. 0 sampleRate => follow project rate (deterministic: the + // project rate is fixed for a given project). + int sampleRate = 0; + int channelCount = 2; + WavBitDepth bitDepth = WavBitDepth::Float32; + + // Human base name for the file stem; sanitized by capture_paths. The unique + // tag (disambiguator) is supplied separately by the backend caller so the + // pure naming logic stays testable. + std::string baseName = "capture"; + std::string uniqueTag; // e.g. a timestamp/counter; may be empty +}; + +// Outcome of a capture attempt. `Ok` carries the populated Sample; every failure +// is an explicit code (never a thrown exception across the REAPER boundary) so +// the action handler can log a precise reason. +enum class CaptureStatus { + Ok, + NoProject, // no active project to render / resolve a bank folder + EmptyRange, // start >= end: nothing to render + UnsupportedMode, // backend does not implement this source mode (M3 scope) + UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only) + RenderFailed, // the render action ran but produced no output file + TransportBusy, // realtime backend: transport already playing/recording — refused +}; + +struct CaptureResult { + CaptureStatus status = CaptureStatus::RenderFailed; + Sample sample; // valid only when status == Ok + std::string message; // human-readable detail for the console log +}; + +// Deterministic offline-render backend. Drives the full offline source family — +// master mix / time selection, selected tracks, selected items, razor area — all +// wet-only (render_settings.h) with optional tail. The source selection + range +// are resolved by the caller (the action layer) and handed in via the +// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection +// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend). +// Non-destructive: restores every RENDER_* setting it touches on every path. +// A plain concrete class — the former ICaptureBackend interface was deleted +// (Q-W3, T4-26): it had one deriver, zero polymorphic call sites, and the async +// realtime backend deliberately never implemented it (see SEAM CHOICE below). +class OfflineRenderBackend { +public: + CaptureResult capture(const CaptureRequest& request); +}; + +// --- Shared backend helpers (Q-W3 riders) ------------------------------------ + +// Mints the filesystem-safe disambiguating tag for one capture's file stem + +// Sample id: "-" where is a PER-SESSION +// MONOTONIC counter (T1-11 fix). The wall-clock second alone had a collision +// window: two captures of the same baseName within one second derived the same +// stem, so the second render silently overwrote the first file (reachable via +// batch capture driving short renders back-to-back). The counter makes every tag +// of a session distinct regardless of timing. `prefix` is the backend's family +// marker ("" offline, "rt-" realtime). +std::string makeUniqueTag(const std::string& prefix); + +// Stamps the SHARED finished-capture metadata onto `s` (T2-09 dedupe — this stamp +// was copy-pasted per backend and had silently diverged): trackGuids + +// channelCount (echoed from the request), the resolved sampleRate (request rate, +// else PROJECT_SRATE read from `rateProj`; 0 stays 0 when unknown), captureTempo +// (Master_GetTempo), the capture-start time signature (TimeMap_GetTimeSigAtTime +// against `timeSigProj` — the offline path passes nullptr = active project, the +// realtime path pins the record's own project; the divergence stays caller-visible +// as this argument), the WAV-aware contentHash of the finished file at +// `absolutePath` (left empty when unreadable — the safe, confirm-eliciting +// direction), and createdTimestamp (now). The per-backend bits (id, paths, bounds, +// tier, realtime's recorded-length override) stay with each caller. +void stampCaptureSample(Sample& s, const CaptureRequest& req, + ReaProject* rateProj, ReaProject* timeSigProj, + const std::string& absolutePath); + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp new file mode 100644 index 0000000..e22e4f9 --- /dev/null +++ b/src/shell/capture/capture_batch.cpp @@ -0,0 +1,524 @@ +// capture_batch.cpp — the M11 batch-capture family + the M10 re-capture-from-source +// action (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded +// as a parameter). See the header. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/capture_batch.h" + +#include +#include +#include +#include +#include + +#include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId +#include "shell/panel/panel_input.h" // bankPanelRefresh +#include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome +#include "core/model/bank_book.h" // BankBook / Bank +#include "core/model/provenance.h" // recipe parse/build, fingerprint +#include "shell/persist/session.h" // ReaSamplerSession +#include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline +#include "shell/capture/provenance_shell.h" // fxChainIdentity* / trackByGuid +#include "shell/capture/scope_resolve.h" // ResolvedSource +#include "shell/capture/track_guid.h" // guidString + +#include "reaper_plugin.h" // UNDO_STATE_MISCCFG + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountSelectedMediaItems +#define REAPERAPI_WANT_GetSelectedMediaItem +#define REAPERAPI_WANT_GetMediaItem_Track +#define REAPERAPI_WANT_GetMediaItemInfo_Value +#define REAPERAPI_WANT_CountMediaItems +#define REAPERAPI_WANT_GetMediaItem +#define REAPERAPI_WANT_SetMediaItemSelected +#define REAPERAPI_WANT_UpdateArrange +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#define REAPERAPI_WANT_CountSelectedTracks +#define REAPERAPI_WANT_GetSelectedTrack +#define REAPERAPI_WANT_SetTrackSelected +#define REAPERAPI_WANT_SetOnlyTrackSelected +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +// --- M11: batch capture (per selected item / per razor area) ---------------- +// +// One action fires N captures — one bank sample per selected item (item scope) or per +// razor area (track scope, each area's own range). Each individual capture honors every +// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan +// neutralize, relative paths, channel preservation) and M10 provenance stamping applies +// per capture where its detection rule matches. The load-bearing principle holds: each +// unit writes a file + a bank index entry ONLY; nothing lands in the arrange. +// +// Per-unit FILE NAMING: each unit's baseName carries its ordinal ("item-1", +// "item-2", ...) so two units are never asked to write the same stem within one +// batch, and the shared makeUniqueTag now appends a per-session monotonic counter +// (T1-11 fix) so even same-second units across batches cannot collide. + +namespace { + +// RAII snapshot/restore of the project's media-item selection. Batch item capture must +// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is +// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including +// a mid-batch failure or early return — because selection restoration is part of the +// non-destructive invariant. Snapshot on construct (the currently-selected item set), +// restore on destruct (deselect everything, then re-select exactly the snapshot). +class ItemSelectionGuard +{ +public: + ItemSelectionGuard() + { + const int n = CountSelectedMediaItems(nullptr); + for (int i = 0; i < n; ++i) + if (MediaItem* it = GetSelectedMediaItem(nullptr, i)) + selected_.push_back(it); + } + + ~ItemSelectionGuard() + { + // Deselect every item in the project, then re-select the snapshot — restoring the + // exact original set regardless of what the batch selected in between. Iterate ALL + // items (not just the currently-selected) so any transient selection is cleared. + const int total = CountMediaItems(nullptr); + for (int i = 0; i < total; ++i) + if (MediaItem* it = GetMediaItem(nullptr, i)) + SetMediaItemSelected(it, false); + for (MediaItem* it : selected_) + SetMediaItemSelected(it, true); + UpdateArrange(); // reflect the restored selection in the arrange view + } + + ItemSelectionGuard(const ItemSelectionGuard&) = delete; + ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete; + +private: + std::vector selected_; +}; + +// Selects exactly `item` (deselect-all then select-one) so the offline render's +// selected-items bit (&32) captures a single item. Used inside the batch loop under the +// ItemSelectionGuard, which restores the user's original selection afterward. +void selectOnlyItem(MediaItem* item) +{ + const int total = CountMediaItems(nullptr); + for (int i = 0; i < total; ++i) + if (MediaItem* it = GetMediaItem(nullptr, i)) + SetMediaItemSelected(it, it == item); +} + +// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving +// track order then area order — the batch analog of resolveRazorRange, which unions them. +// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser. +std::vector> collectRazorAreas() +{ + std::vector> areas; + const int n = CountTracks(nullptr); + for (int i = 0; i < n; ++i) + { + MediaTrack* tr = GetTrack(nullptr, i); + if (!tr) continue; + std::vector buf(8192, '\0'); + if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) + continue; + for (const RazorRange& r : parseRazorEdits(std::string(buf.data()))) + areas.push_back({tr, r}); + } + return areas; +} + +// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must +// transiently select exactly the area's owning track per render (track scope's &128 bit +// renders whatever TRACKS are selected); the user's original track selection is restored +// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard. +class TrackSelectionGuard +{ +public: + TrackSelectionGuard() + { + const int n = CountSelectedTracks(nullptr); + for (int i = 0; i < n; ++i) + if (MediaTrack* tr = GetSelectedTrack(nullptr, i)) + selected_.push_back(tr); + } + + ~TrackSelectionGuard() + { + // Deselect every track, then re-select the snapshot — the exact original set. + const int total = CountTracks(nullptr); + for (int i = 0; i < total; ++i) + if (MediaTrack* tr = GetTrack(nullptr, i)) + SetTrackSelected(tr, false); + for (MediaTrack* tr : selected_) + SetTrackSelected(tr, true); + } + + TrackSelectionGuard(const TrackSelectionGuard&) = delete; + TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete; + +private: + std::vector selected_; +}; + +} // namespace + +// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the +// selection (RAII restore on every path), then for each selected item transiently selects +// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the +// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for +// the whole batch). Reports a mixed-result summary (explicit-action response — allowed). +void RunBatchCaptureItems(ReaSamplerSession& session) +{ + // Read the selected items up front (pointers stay valid — batch mutates only selection + // flags, never adds/removes items). Also capture each item's exact bounds and owning + // track NOW, while the full selection is live, before any transient re-selection. + struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; }; + std::vector itemUnits; + { + const int n = CountSelectedMediaItems(nullptr); + for (int i = 0; i < n; ++i) + { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (!it) continue; + MediaTrack* tr = GetMediaItem_Track(it); + if (!tr) continue; + const double pos = GetMediaItemInfo_Value(it, "D_POSITION"); + const double len = GetMediaItemInfo_Value(it, "D_LENGTH"); + itemUnits.push_back({it, tr, pos, pos + len}); + } + } + if (itemUnits.empty()) + { + ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n"); + return; + } + + // Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/ + // inverted item ranges (a zero-length item) are dropped here so no stray render runs. + std::vector ranges; + ranges.reserve(itemUnits.size()); + for (const ItemUnit& u : itemUnits) + ranges.push_back({u.start, u.end}); + const std::vector plan = planCaptureUnits(ranges); + + BatchOutcome outcome; + bool anyAdded = false; + { + // Restore the user's ORIGINAL item selection on every exit path (incl. early + // return / mid-batch failure) — non-destructive invariant. + ItemSelectionGuard selGuard; + + // The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only + // for those whose range survived planning (same drop rule), matching by ordinal. + std::size_t planIdx = 0; + for (const ItemUnit& u : itemUnits) + { + if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep + const CaptureUnit& unit = plan[planIdx++]; + + // Transiently select ONLY this item so the item-scope render captures exactly it. + selectOnlyItem(u.item); + + ResolvedSource src; + src.startSeconds = unit.startSeconds; + src.endSeconds = unit.endSeconds; + src.sourceTracks.push_back(u.track); + if (std::string g = guidString(u.track); !g.empty()) + src.trackGuids.push_back(std::move(g)); + + const std::string baseName = "item-" + std::to_string(unit.ordinal); + CaptureResult res = captureAndIndexOne( + session, CaptureScope::Item, src, baseName, + unit.startSeconds, unit.endSeconds); + + const bool ok = (res.status == CaptureStatus::Ok); + outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); + if (ok) anyAdded = true; + } + } // selGuard restores the original selection here, on every path + + // Persist ONCE for the whole batch (one ext-state write) — only if something landed. + // S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample, + // so a single increment past the last-seen value is enough to trigger one instance reload. + if (anyAdded) { + session.bumpBankGeneration(); + session.saveToActiveProject(); + } + + ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); +} + +// Batch razor capture: one bank sample per razor AREA, track scope over that area's own +// range (the area's owning track is the source track). Track scope renders the selected +// TRACKS via master (&128), so each unit transiently selects ONLY its owning track +// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original +// track selection on every path. The razor selection itself is read-only and left intact. +// Persists ONCE at the end. Reports a mixed-result summary. +void RunBatchCaptureRazor(ReaSamplerSession& session) +{ + const std::vector> areas = collectRazorAreas(); + if (areas.empty()) + { + ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n"); + return; + } + + std::vector ranges; + ranges.reserve(areas.size()); + for (const auto& a : areas) + ranges.push_back({a.second.startSeconds, a.second.endSeconds}); + const std::vector plan = planCaptureUnits(ranges); + + BatchOutcome outcome; + bool anyAdded = false; + { + // Restore the user's ORIGINAL track selection on every exit path. + TrackSelectionGuard selGuard; + + std::size_t planIdx = 0; + for (const auto& a : areas) + { + if (!(a.second.endSeconds > a.second.startSeconds)) continue; // dropped — lockstep + const CaptureUnit& unit = plan[planIdx++]; + MediaTrack* tr = a.first; + + // Transiently select ONLY this track so the track-scope render (&128) captures + // exactly it via master (over the custom time bounds we set per unit). + SetOnlyTrackSelected(tr); + + ResolvedSource src; + src.startSeconds = unit.startSeconds; + src.endSeconds = unit.endSeconds; + src.sourceTracks.push_back(tr); + if (std::string g = guidString(tr); !g.empty()) + src.trackGuids.push_back(std::move(g)); + + const std::string baseName = "razor-" + std::to_string(unit.ordinal); + CaptureResult res = captureAndIndexOne( + session, CaptureScope::Track, src, baseName, + unit.startSeconds, unit.endSeconds); + + const bool ok = (res.status == CaptureStatus::Ok); + outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); + if (ok) anyAdded = true; + } + } // selGuard restores the original track selection here, on every path + + // S9: one coalesced bump for the whole razor batch (see the item-batch note above). + if (anyAdded) { + session.bumpBankGeneration(); + session.saveToActiveProject(); + } + + ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str()); +} + +// --- M10: re-capture from source -------------------------------------------- +// +// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT +// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and +// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the +// load-bearing capture-never-places line, structurally visible: this function has no +// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore +// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places +// manually if they want the new version on the timeline. +// +// Failure modes are handled explicitly and reported to the user (a direct response +// to an explicit action is allowed by the console policy): +// * the selected sample has no provenance (not a resample) -> reported, no-op. +// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op. +// * the recorded source track(s) no longer exist -> reported, no-op. +// * the render itself fails to satisfy the recorded request -> reported, no-op. +// On success, if the source FX chain drifted since capture (recorded vs current +// identity differ) the user is told — the re-capture still reflects the source AS IT +// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source). +void RunRecaptureFromSource(ReaSamplerSession& session) +{ + const std::vector selected = bankPanelSelectedSampleIds(); + if (selected.empty()) + { + ShowConsoleMsg("ReaSampler re-capture: select a sample in the bank panel first.\n"); + return; + } + if (selected.size() > 1) + { + ShowConsoleMsg("ReaSampler re-capture: select a single sample to re-capture.\n"); + return; + } + const std::string sampleId = selected.front(); + + // Resolve the sample from the bank it lives in (the focused region's displayed bank). + const std::string srcBankId = bankPanelSelectedSourceBankId(); + const Bank* bank = session.book().bank(srcBankId); + const model::Sample* orig = bank ? bank->index.query(sampleId) : nullptr; + if (!orig) + { + ShowConsoleMsg("ReaSampler re-capture: the selected sample is no longer in the bank.\n"); + return; + } + if (!orig->provenance) + { + ShowConsoleMsg("ReaSampler re-capture: this sample has no provenance " + "(it was not resampled from a bank sample).\n"); + return; + } + + // Parse the recorded capture recipe from the fingerprint. A legacy / corrupt + // string fails gracefully — never a partial re-capture. + const std::string recordedParentId = orig->provenance->parentSampleId; + const std::string recordedFingerprint = orig->provenance->fxChainSnapshot; + const std::optional recipe = + model::parseFingerprint(recordedFingerprint); + if (!recipe) + { + ShowConsoleMsg("ReaSampler re-capture: this sample's provenance is unreadable " + "(recorded by an older/incompatible build); cannot re-capture.\n"); + return; + } + + // Resolve the recorded source track GUID(s) to live tracks. Any missing track is a + // hard failure — we will not silently re-capture a different source. + std::vector sourceTracks; + for (const std::string& g : recipe->trackGuids) + { + MediaTrack* tr = trackByGuid(g); + if (!tr) + { + ShowConsoleMsg("ReaSampler re-capture: a recorded source track no longer " + "exists in this project; cannot re-capture from source.\n"); + return; + } + sourceTracks.push_back(tr); + } + if (sourceTracks.empty()) + { + // The recipe recorded no source tracks (e.g. an item-scope capture whose source + // tracks were not track-scoped). Without a resolvable source we cannot re-run. + ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this " + "sample; cannot re-capture from source.\n"); + return; + } + + const CaptureScope scope = + recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item + : CaptureScope::Track; + + // Rebuild the capture request verbatim from the recorded recipe — the SAME request, + // re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate, + // channels, bit depth all match the original so an unchanged source produces a + // byte-identical file (bit-identical-repeats invariant, consumed as a feature). + CaptureRequest req; + req.sourceMode = static_cast(recipe->sourceMode); + req.startSeconds = recipe->startSeconds; + req.endSeconds = recipe->endSeconds; + req.wetDry = 1.0; + req.tailMode = static_cast(recipe->tailMode); + req.tailMs = recipe->tailMs; + req.sampleRate = recipe->sampleRate; + req.channelCount = recipe->channelCount; + req.bitDepth = WavBitDepth::Float32; + req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName; + req.trackGuids = recipe->trackGuids; + + // Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to + // compare against the recorded identity for drift reporting. Mirror the same + // scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*; + // track scope reads the track FX chain via TrackFX_*. + std::string currentIdentity; + if (scope == CaptureScope::Item) { + const int n = CountSelectedMediaItems(nullptr); + std::vector items; + items.reserve(static_cast(n < 0 ? 0 : n)); + for (int i = 0; i < n; ++i) { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (it) items.push_back(it); + } + currentIdentity = fxChainIdentityForItems(items); + } else { + std::vector perTrackNow; + perTrackNow.reserve(sourceTracks.size()); + for (MediaTrack* tr : sourceTracks) + perTrackNow.push_back(fxChainIdentityForTrack(tr)); + currentIdentity = model::combineChainIdentities(perTrackNow); + } + const bool drifted = (currentIdentity != recipe->fxChainIdentity); + + // Render (bank-only; renderOffline never touches the timeline). + CaptureResult res = renderOffline(scope, sourceTracks, req); + if (res.status != CaptureStatus::Ok) + { + ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str()); + return; + } + + // Update the Sample IN PLACE: keep its identity (id) and its provenance thread + // (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but + // adopt the regenerated file's path / hash / length / rate / timestamp. The + // fingerprint is rebuilt from the recipe with the CURRENT FX identity so a + // subsequent re-capture measures drift from this point, not the original. + model::CaptureRecipe refreshed = *recipe; + refreshed.fxChainIdentity = currentIdentity; + + model::Sample updated = *orig; // copy: preserves id, displayName, tier, key + updated.relativePath = res.sample.relativePath; + updated.contentHash = res.sample.contentHash; + updated.sourceMode = res.sample.sourceMode; + updated.sourceRange = res.sample.sourceRange; + updated.channelCount = res.sample.channelCount; + updated.sampleRate = res.sample.sampleRate; + updated.lengthSeconds = res.sample.lengthSeconds; + updated.captureTempo = res.sample.captureTempo; + updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp + updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter + updated.trackGuids = res.sample.trackGuids; + updated.createdTimestamp = res.sample.createdTimestamp; + // NOTE: levels, clipped, and lengthBeats are carried from the original (via the + // *orig copy above) because the offline backend does not populate them today + // (res.sample leaves them at defaults). If a later milestone populates these + // fields at capture time, refresh them here from res.sample instead. + model::Provenance prov; + prov.parentSampleId = recordedParentId; + prov.fxChainSnapshot = model::buildFingerprint(refreshed); + updated.provenance = prov; + + // Single batched undo point around the in-place bank mutation (mirrors the bank + // action family's R-B pattern). The mutation is index-only ext-state; the render + // wrote a new file but placed nothing on the timeline. + Undo_BeginBlock2(nullptr); + const bool changed = session.book().updateSampleInPlace(sampleId, updated); + if (changed) + { + // Record the regenerated file in the owned manifest (a new file the tool wrote); + // the superseded old file becomes an orphan reclaimed by Phase R prune. + session.owned().add(updated.relativePath); + // S9: re-capture-in-place regenerates the SAME id's audio — the exact case the + // hands-free refresh exists for (an instance referencing this id keeps playing the + // OLD audio until it reloads). Bump inside the undo block so undo rolls back the + // generation with the rest of the blob. + session.bumpBankGeneration(); + const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty + Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", + persisted ? UNDO_STATE_MISCCFG : 0); + } + else + { + Undo_EndBlock2(nullptr, "", 0); // nothing mutated -> discard the empty point + } + + bankPanelRefresh(); // reflect the regenerated file in the docked grid + + if (drifted) + ShowConsoleMsg("ReaSampler re-capture: the source FX chain changed since the " + "original capture -- the sample was regenerated from the source's " + "current state.\n"); +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_batch.h b/src/shell/capture/capture_batch.h new file mode 100644 index 0000000..f161421 --- /dev/null +++ b/src/shell/capture/capture_batch.h @@ -0,0 +1,32 @@ +#pragma once +// capture_batch — the batch-capture family + re-capture-from-source (Q-W3 hoist +// out of main.cpp; the fourth hoist, T4-02 — recapture is planner-driven like +// batch and shares the RAII selection-guard machinery, so it belongs here, not +// with the single-shot path). Owns: +// * RunBatchCaptureItems — one bank sample per SELECTED item (item scope), the +// user's item selection snapshot/restored on every path (ItemSelectionGuard); +// * RunBatchCaptureRazor — one bank sample per razor AREA (track scope over the +// area's own range), the user's track selection snapshot/restored on every +// path (TrackSelectionGuard); +// * RunRecaptureFromSource — regenerate a PROVENANCED bank sample from its +// recorded source's CURRENT state, updating the Sample in place. BANK-ONLY. +// +// Every unit honors every precision invariant via capture_orchestrator's +// captureAndIndexOne / renderOffline (exact bounds, non-destructive neutralize, +// relative paths); nothing here ever touches the arrange/timeline (load-bearing +// principle). Persist is batched: ONE ext-state write per action. +// +// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::capture { + +void RunBatchCaptureItems(ReaSamplerSession& session); +void RunBatchCaptureRazor(ReaSamplerSession& session); +void RunRecaptureFromSource(ReaSamplerSession& session); + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp new file mode 100644 index 0000000..030e146 --- /dev/null +++ b/src/shell/capture/capture_orchestrator.cpp @@ -0,0 +1,487 @@ +// capture_orchestrator.cpp — the single-capture orchestration + realtime/insert +// action bodies (Q-W3 hoist out of main.cpp; the code moved verbatim, the session +// threaded as a parameter). See the header. FxBypassGuard lives here as a STACK +// RAII object (precision-invariant-critical — it must restore on every exit path +// of exactly one render call). +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/capture_orchestrator.h" + +#include +#include + +#include "shell/panel/panel_input.h" // bankPanelTailSetting / bankPanelRefresh +#include "core/capture/tail_control.h" // TailSetting +#include "core/model/provenance.h" // model::Provenance +#include "ingest.h" // ingestAssignActiveInstance +#include "shell/persist/session.h" // ReaSamplerSession +#include "shell/capture/insert.h" // runInsert / InsertRequest +#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state + +#include "reaper_plugin.h" // UNDO_STATE_MISCCFG + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetParentTrack +#define REAPERAPI_WANT_GetMasterTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_SetMediaTrackInfo_Value +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +namespace { + +// --- FX-bypass + full parent-chain neutralize around render (RAII, non-destr.) -- +// For every track a scope must NOT hear the FX of, this ALSO neutralizes that +// track's fader gain AND its full pan chain (pan/width/law/mode) for the render — +// because a Track/Item capture renders via master and would otherwise sum through +// the parent/folder/master FADERS and PAN/WIDTH/LAW, printing their gain and pan +// coloring into the file (Daniel: the capture is likely re-routed through that +// same chain later, so parent/master level and pan must not be baked in). The +// neutralize set is IDENTICAL to the FX-bypass set: +// Item -> own track + all ancestors + master (take vol/pan kept: item content). +// Track -> all ancestors + master (selected track's OWN vol/pan kept). +// (Master is a bypass TARGET for both scopes — never a scope of its own.) +// +// Per track in that set we snapshot & set the full parent-chain-independence set, +// so a Track/Item capture is uncolored by the parent/folder/master it renders +// through — no FX, no fader, and no pan/width/law/mode coloring: +// I_FXEN -> 0 (FX bypassed; SDK ~2194) +// D_VOL -> 1.0 (unity trim volume; SDK ~2226 "1=+0dB") +// D_PAN -> 0.0 (center; SDK ~2227 "trim pan of track, -1..1") +// D_WIDTH -> 1.0 (full/neutral stereo width; SDK ~2228 "width, -1..1", +// 1.0 = full width = no narrowing/collapse) +// D_PANLAW -> 1.0 (no coloring; SDK ~2232 "1=+0dB" — pan-law applies no gain) +// I_PANMODE -> 5 (stereo pan; SDK ~2231 "0=classic,3=balance,5=stereo,6=dual") +// All are restored to their ORIGINAL values on EVERY exit path (RAII). +// +// Why also force I_PANMODE (pan mode). D_PAN's effect is mode-dependent. In modes +// 0/3/5, D_PAN=0 + D_WIDTH=1 is a provable pass-through. But in mode 6 (dual pan) +// D_PAN/D_WIDTH are ignored — routing is governed instead by D_DUALPANL/D_DUALPANR +// (SDK ~2229-2230, live only when I_PANMODE==6), whose neutral pass-through the +// header does not state as such. Rather than snapshot two more mode-conditional +// params and infer their neutral values, we force I_PANMODE=5 (stereo pan) for the +// render, where D_PAN=0 + D_WIDTH=1 is unambiguously uncolored, then restore the +// original mode. This fully neutralizes pan for every original mode with no +// residual — the "handle it fully" the brief requires. (See Snap dual-pan note.) +// +// Structurally non-destructive: no takes, no items, no project restructuring — +// only transient FX-enable + trim-volume toggles, always restored. +class FxBypassGuard +{ +public: + // scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose + // ancestor chains (walked via GetParentTrack) + the master are bypassed per the + // plan. proj is the active project (for GetMasterTrack). + FxBypassGuard(CaptureScope scope, + const std::vector& sourceTracks, + ReaProject* proj) + { + const FxBypassPlan plan = fxBypassPlanFor(scope); + + for (MediaTrack* tr : sourceTracks) + { + if (!tr) continue; + if (plan.bypassSelfFx) bypass(tr); + if (plan.bypassAncestorFx) + { + // Walk parents to the top: GetParentTrack returns the immediate + // parent (folder) track, nullptr at the outermost level (SDK + // header ~2407). The master is NOT returned here — handled below. + for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p)) + bypass(p); + } + } + if (plan.bypassMaster) + { + // GetMasterTrack(proj) -> the master track (SDK header ~1925). bypass() + // neutralizes its FX (I_FXEN), gain (D_VOL) AND pan/width/law/mode on it + // just like any other in-scope track; only the master's summing/routing + // topology (the mix bus itself) remains — that is not a per-track param. + if (MediaTrack* master = GetMasterTrack(proj)) bypass(master); + } + } + + ~FxBypassGuard() + { + // Restore in reverse for symmetry (order is not load-bearing — each track + // appears once, snapshots are independent). EVERY snapshotted param is + // restored to its ORIGINAL value on this (every) exit path. Restore + // I_PANMODE before the pan values so any mode-conditional params (e.g. dual + // pan) settle under the original mode. + for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it) + { + SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen); + SetMediaTrackInfo_Value(it->track, "D_VOL", it->vol); + SetMediaTrackInfo_Value(it->track, "I_PANMODE", it->panmode); + SetMediaTrackInfo_Value(it->track, "D_PAN", it->pan); + SetMediaTrackInfo_Value(it->track, "D_WIDTH", it->width); + SetMediaTrackInfo_Value(it->track, "D_PANLAW", it->panlaw); + } + } + + FxBypassGuard(const FxBypassGuard&) = delete; + FxBypassGuard& operator=(const FxBypassGuard&) = delete; + +private: + // One snapshot per bypassed track: all params we neutralize, at their originals. + // panmode captures I_PANMODE so we can force stereo-pan for the render and put + // the original mode back — which also makes D_DUALPANL/D_DUALPANR (live only when + // I_PANMODE==6, SDK ~2229-2230) irrelevant during the render without us having to + // touch or guess neutral values for them. + struct Snap + { + MediaTrack* track; + double fxen; + double vol; + double pan; + double width; + double panlaw; + double panmode; + }; + std::vector snapshots_; + + // Snapshot every neutralized param once per track (dedup: an ancestor shared by + // two selected tracks must be restored to its ORIGINAL values, not to a + // re-snapshot of the already-neutralized state), then read ALL originals, push + // one Snap, and set all to neutral — bypass FX, unity gain, uncolored pan chain. + void bypass(MediaTrack* tr) + { + for (const Snap& s : snapshots_) if (s.track == tr) return; // already done + // Read ALL originals first (atomic snapshot), then push, then neutralize. + const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN"); + const double vol = GetMediaTrackInfo_Value(tr, "D_VOL"); + const double pan = GetMediaTrackInfo_Value(tr, "D_PAN"); + const double width = GetMediaTrackInfo_Value(tr, "D_WIDTH"); + const double panlaw = GetMediaTrackInfo_Value(tr, "D_PANLAW"); + const double panmode = GetMediaTrackInfo_Value(tr, "I_PANMODE"); + snapshots_.push_back({tr, fxen, vol, pan, width, panlaw, panmode}); + SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194) + SetMediaTrackInfo_Value(tr, "D_VOL", 1.0); // 1.0 = unity gain (SDK ~2226) + SetMediaTrackInfo_Value(tr, "I_PANMODE", 5.0); // 5 = stereo pan (SDK ~2231) + SetMediaTrackInfo_Value(tr, "D_PAN", 0.0); // 0.0 = center (SDK ~2227) + SetMediaTrackInfo_Value(tr, "D_WIDTH", 1.0); // 1.0 = full width (SDK ~2228) + SetMediaTrackInfo_Value(tr, "D_PANLAW", 1.0); // 1.0 = +0dB, no law (SDK ~2232) + } +}; + +} // namespace + +// Renders one CaptureRequest through the offline backend under the scope's +// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and +// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE +// place: the out-of-scope FX / fader / pan chain is snapshotted, neutralized for the +// render, and fully restored on every path (RAII). Non-destructive; touches no +// timeline item (load-bearing principle) — it writes a file only. +CaptureResult renderOffline(CaptureScope scope, + const std::vector& sourceTracks, + const CaptureRequest& req) +{ + ReaProject* proj = EnumProjects(-1, nullptr, 0); + FxBypassGuard fxGuard(scope, sourceTracks, proj); + OfflineRenderBackend backend; + return backend.capture(req); +} + +// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, +// and adds the resulting Sample to the ACTIVE bank + records the created file in the +// owned-file manifest — WITHOUT persisting. The caller persists once (single-capture: +// right after; batch: once at the end) so a batch does not write ext state N times. +// +// Provenance is read from the LIVE selection here, so a batch that transiently +// selects exactly one item per unit gets per-unit-correct provenance. `src` supplies +// the source tracks (FX bypass + Sample GUIDs); `scope` drives the bypass plan and +// provenance scope. Returns the backend's CaptureResult (status + message) so the +// caller can report success/failure. Load-bearing principle holds: writes a file + +// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the +// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard), +// and the backend restores every RENDER_* setting. +// +// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id +// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8 +// capture+assign path can target the sample actually in the bank. Batch callers ignore +// it; the plain capture actions are unaffected. +CaptureResult captureAndIndexOne(ReaSamplerSession& session, + CaptureScope scope, + const ResolvedSource& src, + const std::string& baseName, + double startSeconds, + double endSeconds) +{ + // The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action + // variant: the capture actions apply whatever the panel is set to. Default is None + // (exact bounds / byte-identical to today) until the user opts in via the toggle. + const TailSetting tail = bankPanelTailSetting(); + + CaptureRequest req; + req.sourceMode = sourceModeForScope(scope); + req.startSeconds = startSeconds; // exact bounds — no rounding + req.endSeconds = endSeconds; + req.wetDry = 1.0; // wet post the FX left enabled by the scope + req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle + req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto + req.sampleRate = 0; // follow project rate + req.channelCount = 2; + req.bitDepth = WavBitDepth::Float32; // deterministic, no dither + req.baseName = baseName; + req.trackGuids = src.trackGuids; // recorded on the Sample (provenance) + + // M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain — + // the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and + // the source selection is still live here. Returns nullopt unless this capture + // genuinely resamples from a bank sample (detectParent). Read-only. + const std::optional prov = + buildCaptureProvenance(session.book(), req, scope, src); + + // Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain + // neutralized for the render, fully restored on every path). Writes a file only. + CaptureResult res = renderOffline(scope, src.sourceTracks, req); + if (res.status != CaptureStatus::Ok) + return res; + + // Stamp provenance onto the captured Sample (only set when this was a genuine + // resample-from-sample; otherwise the optional stays empty, per M1's contract). + res.sample.provenance = prov; + + // Add to the ACTIVE bank: session.bank() resolves to book.activeIndex() (B2). The + // AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can + // target the sample actually in the bank (the existing entry on a collapse). + const model::AddResult addResult = session.bank().add(res.sample); + // B-cap: record the created file in the owned-file manifest, at the same point the + // Sample is added. Recorded regardless of the index AddResult — even a hash-collapse + // still WROTE a file the tool owns, and the manifest dedups a repeat path itself + // (Phase R prune reconciles manifest vs index later). + session.owned().add(res.sample.relativePath); + + // Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new + // id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a + // Collapsed (the file we just rendered deduped onto an already-present sample — assign + // THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged. + if (addResult == model::AddResult::Collapsed && !res.sample.contentHash.empty()) + { + if (const model::Sample* existing = + session.bank().findByHash(res.sample.contentHash)) + res.sample.id = existing->id; + } + return res; +} + +// Runs one capture-action-table row: resolve its scope source + range, render + add + +// record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle +// holds structurally — this path writes a file + a bank index entry ONLY; it never +// calls InsertMedia or touches the arrange/timeline. +// Returns the bank-index id of the sample the capture landed on: the newly-added id on a +// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign +// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange +// capture+assign path reads this to write an assignment request; the plain capture actions +// ignore it (their behaviour is unchanged — capture still writes a file + index entry only). +std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def) +{ + ResolvedSource src; + std::string why; + if (!ResolveScopeSource(def.scope, src, why)) + { + ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str()); + return {}; + } + + CaptureResult res = captureAndIndexOne(session, def.scope, src, def.baseName, + src.startSeconds, src.endSeconds); + if (res.status != CaptureStatus::Ok) + { + ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str()); + return {}; + } + + // captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE + // bank, and recorded the created file in the owned-file manifest (WITHOUT persisting). + // Persist the updated book AND manifest into the active project's ext state (the + // `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and + // travels with the .rpp. saveToActiveProject also clears the retired legacy key and + // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys. + // S9: a capture add is a bank-content change -> bump before the persist so an assigned + // live instance refreshes hands-free (the S8 capture+assign path builds on this). + session.bumpBankGeneration(); + session.saveToActiveProject(); + + // Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne + // resolved res.sample.id to the fresh id on a new add or the existing entry's id on a + // hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above). + return res.sample.id; +} + +// S8 arrange ingest: capture the selected item / time-selection into the active bank +// (reusing the Item-scope capture path verbatim) and, on success, write an assignment +// request so the active sampler instance plays the new sample on its next reload. The +// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER +// inserts a timeline item (load-bearing principle); the only addition here is the +// bank-index-id -> assignment-request write after the sample lands. If the capture +// failed / no-op'd (empty id), no assignment is written (nothing to assign). +// +// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the +// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are +// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically. +// An undo that removes the captured sample also clears the assign_request that named it, +// preventing a stale request from pointing at a removed sample. The block uses the house +// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero +// flag) matching the bank-op family (persistBankOp, panel_bank_ops). +void RunCaptureItemAssign(ReaSamplerSession& session) +{ + // Reuse the Item-scope def from the capture table (index 0) — same range logic, same + // FX-scope neutralize, same bank/persist landing as the plain "capture item" action. + Undo_BeginBlock2(nullptr); + + const std::string sampleId = RunCapture(session, captureActionTable()[0]); + if (sampleId.empty()) + { + // Capture failed or no-op'd — RunCapture already reported. Discard the empty point. + Undo_EndBlock2(nullptr, "", 0); + return; + } + + // Assign inside the same block so undo clears both keys together. + ingestAssignActiveInstance(session.book().activeBankId(), sampleId); + Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance", + UNDO_STATE_MISCCFG); + + bankPanelRefresh(); + ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " + "instance.\n"); +} + +// STARTS the REALTIME track capture and returns immediately — the record runs across +// timer ticks (DriveRealtimeCapture in realtime_lifecycle), so REAPER's UI stays +// responsive. Resolves the selected tracks + the range (razor-else-time, the same +// orthogonal range logic as the offline scopes) and starts recording each selected +// track's OWN output into a hidden temp track via RealtimeRecordBackend::begin (a +// send FROM each source track INTO the temp — see capture_realtime_shell.cpp §TAP); +// OnTimer drives it to completion, then adds the Sample and persists. TRACK scope +// only this increment (item realtime is deferred). Dialog-free. Non-bit-identical +// by nature (it is realtime) — offline stays the deterministic default. +// FxBypassGuard is NOT used here — the track-output tap is PRE-parent by +// construction (§TAP), so there is no live chain to neutralize. The load-bearing +// principle holds structurally — this writes a file + a bank entry ONLY; the temp +// track is a transient sink removed by the backend, nothing lands in arrange. +// +// A SECOND realtime capture requested while one is in progress is REJECTED — the +// first keeps running (we own the transport for its window; starting a second would +// collide on the transport and the temp-track/arm snapshot). +void RunCaptureRealtimeTrack(ReaSamplerSession& session) +{ + (void)session; // start path persists nothing — commit happens on the terminal tick + if (g_rtCapture) + { + ShowConsoleMsg("ReaSampler realtime capture: a capture is already in " + "progress -- let it finish (or stop the transport) first.\n"); + return; + } + + // Resolve the selected tracks + range exactly as the offline Track scope does. + // No track selected -> refuse (same no-op as offline track scope). + ResolvedSource src; + std::string why; + if (!ResolveScopeSource(CaptureScope::Track, src, why)) + { + ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str()); + return; + } + + // The tail mode is the SAME panel setting the offline capture actions read (the + // docked bank panel's toggle). Realtime honors it via a parallel path: the backend + // records a generous window past the range end, then trims by PCM decay-scan (T2 / + // capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None + // keeps realtime exact-bounds / byte-identical to today. + const TailSetting tail = bankPanelTailSetting(); + + CaptureRequest req; + req.sourceMode = SourceMode::SelectedTracks; // realtime track scope + req.startSeconds = src.startSeconds; // exact bounds — no rounding + req.endSeconds = src.endSeconds; + req.wetDry = 1.0; // fully wet (post-fader tap) + req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle + req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto + req.sampleRate = 0; // follow project rate + req.channelCount = 2; + req.bitDepth = WavBitDepth::Float32; + req.baseName = "realtime"; + req.trackGuids = src.trackGuids; // provenance on the Sample + + CaptureResult failure; + RealtimeCaptureHandle st = g_rtBackend.begin(req, src.sourceTracks, failure); + if (!st) + { + // begin() validated/failed and already restored anything it touched. + ShowConsoleMsg(("ReaSampler realtime capture failed: " + failure.message + "\n").c_str()); + return; + } + + // Started. Store the in-flight state + its project; OnTimer drives it to + // completion across ticks (UI stays responsive). + g_rtCaptureProject = EnumProjects(-1, nullptr, 0); + g_rtCapture = std::move(st); +} + +// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates +// via abort() — stop the transport + restore ALL snapshotted state (non-destructive), +// committing whatever audio was already captured (best effort) so a cancel near the end +// still keeps the take. Runs only against the record's OWN project (abort() self-guards +// the closed-project case, review §1). No-op with a note when nothing is in flight. +void RunCancelRealtime(ReaSamplerSession& session) +{ + if (!g_rtCapture) + { + ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n"); + return; + } + RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); + if (r.status == RealtimeTickStatus::Done) + CommitRealtimeResult(session, r.result); // Ok: keep what was captured up to the cancel + else + ShowConsoleMsg(("ReaSampler realtime capture cancelled -- " + + r.result.message + "\n").c_str()); + g_rtCapture.reset(); + g_rtCaptureProject = nullptr; +} + +// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor +// via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match +// variant (never silent — it fires only from the distinct "conform" action). This +// is the INTENDED placement path: it adds items to the arrange on purpose +// (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action. +void RunInsertSelected(ReaSamplerSession& session, bool conform) +{ + InsertRequest req; + // target defaults to CurrentTrack (InsertOptions::target) — inserts onto the + // user's currently-selected track(s) at the edit cursor. + req.options.conform = conform ? TempoConform::Ratio1x : TempoConform::None; + // preservePitch stays true: a tempo conform matches tempo without varispeeding + // pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.) + + InsertResult res = runInsert(&session, req); + + switch (res.status) + { + case InsertStatus::Ok: + break; // success — no console chatter + case InsertStatus::NoSelection: + // "select a track first" is printed by runInsert when no track is + // selected; this branch covers the no-panel-selection case. + ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n"); + break; + case InsertStatus::NoProject: + ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n"); + break; + case InsertStatus::NothingResolved: + ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n"); + break; + } +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_orchestrator.h b/src/shell/capture/capture_orchestrator.h new file mode 100644 index 0000000..d5d9b3b --- /dev/null +++ b/src/shell/capture/capture_orchestrator.h @@ -0,0 +1,73 @@ +#pragma once +// capture_orchestrator — the single-capture orchestration + the realtime/insert +// action bodies (Q-W3 hoist out of main.cpp, T4-02). Owns: +// * renderOffline — ONE offline render under the scope's FxBypassGuard (the +// stack-RAII out-of-scope FX/fader/pan neutralize, defined in the .cpp — +// precision-invariant-critical, shared by single-shot / batch / recapture); +// * captureAndIndexOne — render + provenance stamp + bank add + owned-manifest +// record, WITHOUT persisting (single-shot persists right after; batch persists +// once at the end); +// * RunCapture / RunCaptureItemAssign — the bindable single-capture actions; +// * RunCaptureRealtimeTrack / RunCancelRealtime — the realtime action bodies +// (the in-flight state itself lives in realtime_lifecycle); +// * RunInsertSelected — the M6 placement action body (the INTENDED, explicit +// placement path — the one deliberate exception to capture-never-places). +// +// The session is threaded explicitly (no hidden module state): main.cpp's dispatch +// passes its ReaSamplerSession. The load-bearing principle holds structurally — +// no capture path here calls InsertMedia or touches the arrange/timeline; only +// RunInsertSelected places, on purpose, via the insert shell. +// +// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). + +#include + +#include "shell/capture/capture.h" // CaptureResult / CaptureRequest +#include "shell/capture/scope_resolve.h" // ResolvedSource +#include "core/capture/render_settings.h" // CaptureScope, CaptureActionDef + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::capture { + +// Renders one CaptureRequest through the offline backend under the scope's +// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and +// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE +// place. Non-destructive; touches no timeline item — it writes a file only. +CaptureResult renderOffline(CaptureScope scope, + const std::vector& sourceTracks, + const CaptureRequest& req); + +// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, +// and adds the resulting Sample to the ACTIVE bank + records the created file in +// the owned-file manifest — WITHOUT persisting. On success, res.sample.id carries +// the LANDED bank-index id (fresh add or hash-dedup collapse target — S8). +CaptureResult captureAndIndexOne(ReaSamplerSession& session, + CaptureScope scope, + const ResolvedSource& src, + const std::string& baseName, + double startSeconds, + double endSeconds); + +// Runs one capture-action-table row: resolve, render + add + record, persist + +// mark dirty. Returns the landed bank-index id ("" on failure/no-op) — the S8 +// capture+assign path consumes it; the plain capture actions ignore it. +std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def); + +// S8 arrange ingest: Item-scope capture into the active bank + assignment-request +// write, in one undo block. +void RunCaptureItemAssign(ReaSamplerSession& session); + +// STARTS the realtime track capture (async, timer-driven — the in-flight state is +// realtime_lifecycle's; OnTimer drives it) / cancels the in-flight one. +void RunCaptureRealtimeTrack(ReaSamplerSession& session); +void RunCancelRealtime(ReaSamplerSession& session); + +// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor +// via the insert shell. `conform` selects the explicit opt-in tempo-match variant. +void RunInsertSelected(ReaSamplerSession& session, bool conform); + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_realtime_finalize.cpp b/src/shell/capture/capture_realtime_finalize.cpp new file mode 100644 index 0000000..c2a5e9f --- /dev/null +++ b/src/shell/capture/capture_realtime_finalize.cpp @@ -0,0 +1,252 @@ +// capture_realtime_finalize.cpp — the FILE-SIDE half of the realtime-record shell +// (Q-W3, T4-08 split): recorded-file discovery, move-into-bank, the Auto-tail PCM +// decay-scan trim, and the finished-Sample population. See the header. The async +// record lifecycle lives in capture_realtime_shell.cpp. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/capture_realtime_finalize.h" + +#include +#include +#include +#include +#include + +#include "core/audio/peaks.h" // lastFrameAboveThreshold +#include "core/capture/capture_realtime.h" // RecordedCapture, sampleFromRecordedCapture +#include "core/capture/render_settings.h" // autoTrimEndRatio +#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames, planWavTruncate, patchU32LE +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetTrackNumMediaItems +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_GetMediaItemTake +#define REAPERAPI_WANT_GetMediaItemTake_Source +#define REAPERAPI_WANT_GetMediaSourceFileName +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +namespace { + +std::string normSlashes(std::string s) { + for (char& c : s) if (c == '\\') c = '/'; + if (s.size() > 1 && s.back() == '/') s.pop_back(); + return s; +} + +// ============================================================================ +// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime) +// ============================================================================ +// After the recorded file is stable and moved into the bank (the file we OWN — never +// the project), Auto mode trims the trailing decay: read the WAV, scan the tail +// region (frames AFTER the original range end) backward for the last frame above +// -72 dB, and truncate the file there. Rules (spec): +// * no frame in the tail window above -72 dB -> trim back to the original range end +// * signal never falls below -72 dB in window -> keep the full window (cap did its job) +// * otherwise -> trim one frame past the last audible +// +// Returns the trimmed length in SECONDS (for the Sample), or a negative value to +// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and +// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window) +// rather than risk corrupting the capture — realtime tail is a convenience path. +// +// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit +// float WAV (REAPER project record format — the manual procedure sets it) and is fully +// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees +// that for the normal path; abort()'s best-effort finalize races it, documented). +double trimAutoTailInPlace(const std::string& path, + double rangeStartSeconds, + double rangeEndSeconds) { + constexpr double kNoTrim = -1.0; + + std::vector bytes = util::readFileBytes(path); + if (bytes.empty()) return kNoTrim; + + const WavLayout layout = parseWavLayout(bytes); + if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim + + const std::size_t totalFrames = layout.frameCount(); + if (totalFrames == 0) return kNoTrim; + + // The original range end as a frame index within the file (frame 0 == start). Use + // the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow + // project). Clamp to the file so a rounding overshoot cannot exceed it. + const double rangeSeconds = rangeEndSeconds - rangeStartSeconds; + if (rangeSeconds <= 0.0) return kNoTrim; + std::size_t rangeEndFrame = static_cast( + rangeSeconds * static_cast(layout.sampleRate) + 0.5); + if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; + + // Nothing recorded past the range end (the tail window was empty) -> nothing to + // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.) + if (rangeEndFrame >= totalFrames) return kNoTrim; + + // Scan ONLY the tail region (frames after the original range end). The trim never + // eats into the range body — the scan starts at rangeEndFrame. + const std::size_t tailFrames = totalFrames - rangeEndFrame; + const std::vector tailPcm = + extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); + if (tailPcm.empty()) return kNoTrim; + + const float threshold = static_cast(autoTrimEndRatio()); + const std::size_t lastAbove = audio::lastFrameAboveThreshold( + tailPcm, layout.channelCount, tailFrames, threshold); + + // keptFrames: the total frame count the trimmed file retains. + // no audible tail frame -> trim back to the range end (rangeEndFrame frames) + // an audible frame at idx -> keep range body + up to and including that frame + // The "signal never falls below threshold" case falls out naturally: lastAbove is + // the final tail frame, so keptFrames == totalFrames (the full window is kept). + std::size_t keptFrames; + if (lastAbove == audio::kNoFrameAboveThreshold) { + keptFrames = rangeEndFrame; + } else { + keptFrames = rangeEndFrame + (lastAbove + 1); + } + if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate + + const WavTruncatePlan plan = planWavTruncate(layout, keptFrames); + if (!plan.valid) return kNoTrim; + + // Patch the RIFF + data size fields in the in-memory buffer so they describe the + // kept frame count (wav_codec's patch primitive — the one RIFF owner), then + // rewrite the file as exactly the first newFileByteLength bytes (header + + // patched sizes + retained PCM). A single truncating write is the simplest + // correct truncate — no separate resize step, no partial-write window where the + // on-disk sizes and length disagree. The result is a valid, playable WAV of the + // kept frames (verified by the wav_codec re-parse test). + patchU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); + patchU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize); + + // NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full + // disk, a yanked drive) would leave a short file while we return kNoTrim, so the + // Sample length would overstate the file. Vanishingly unlikely for a just-recorded + // local bank file, and realtime tail is a convenience path, so a temp-file+atomic- + // rename is not warranted here; flagged rather than built. + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file + out.write(reinterpret_cast(bytes.data()), + static_cast(plan.newFileByteLength)); + if (!out) return kNoTrim; + out.close(); + + // The trimmed length in seconds for the Sample metadata. + return static_cast(keptFrames) / static_cast(layout.sampleRate); +} + +} // namespace + +std::string recordedFilePath(MediaTrack* temp) { + if (!temp) return {}; + if (GetTrackNumMediaItems(temp) <= 0) return {}; + MediaItem* item = GetTrackMediaItem(temp, 0); + if (!item) return {}; + MediaItem_Take* take = GetMediaItemTake(item, 0); + if (!take) return {}; + PCM_source* src = GetMediaItemTake_Source(take); + if (!src) return {}; + std::vector buf(4096, '\0'); + GetMediaSourceFileName(src, buf.data(), static_cast(buf.size())); + return normSlashes(std::string(buf.data())); +} + +CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, + const CaptureRequest& request, + const BankPaths& paths, + const std::string& uniqueTag, + double recordWindowEnd) { + CaptureResult result; + + const std::string recorded = recordedFilePath(temp); + if (recorded.empty() || !std::filesystem::exists(recorded)) { + result.status = CaptureStatus::RenderFailed; + result.message = "Realtime record produced no file (check transport/record " + "settings in the DAW)."; + return result; + } + + std::error_code ec; + std::filesystem::create_directories(paths.absoluteDir, ec); + const std::string destPath = paths.absoluteDir + "/" + paths.fileName; + std::filesystem::rename(recorded, destPath, ec); + if (ec) { + // Cross-volume rename can fail; fall back to copy+remove. + ec.clear(); + std::filesystem::copy_file( + recorded, destPath, + std::filesystem::copy_options::overwrite_existing, ec); + if (ec) { + result.status = CaptureStatus::RenderFailed; + result.message = "Recorded file could not be moved into the bank: " + + ec.message(); + return result; + } + std::error_code rmEc; + std::filesystem::remove(recorded, rmEc); // best-effort + } + + // TAIL (Auto): trim the trailing decay of the recorded window in place — on the + // BANK file we now own (destPath), never the project. Best-effort: an unreadable / + // unknown-format / short file skips the trim (keeps the full window) rather than + // corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a + // fixed window (spec §The realtime path). Returns the trimmed length in seconds, + // or < 0 for "no trim applied". + double trimmedLenSeconds = -1.0; + if (request.tailMode == TailMode::Auto) { + trimmedLenSeconds = trimAutoTailInPlace(destPath, + request.startSeconds, + request.endSeconds); + } + + // The pure recorded-capture -> Sample mapping (identity, bounds echo, tier). + RecordedCapture cap; + cap.relativePath = paths.relativePath; + cap.uniqueTag = uniqueTag; + cap.sourceMode = SourceMode::Realtime; + cap.startSeconds = request.startSeconds; + cap.endSeconds = request.endSeconds; + cap.wetDry = request.wetDry; + cap.displayName = request.baseName; + cap.trackGuids = request.trackGuids; + cap.channelCount = request.channelCount; + + result.status = CaptureStatus::Ok; + result.sample = sampleFromRecordedCapture(cap); + + // The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount + // (request echo), resolved sampleRate (request rate else PROJECT_SRATE — read + // against the record's OWN project), captureTempo, the capture-start time + // signature (timeSigProj = proj: the realtime path PINS the record's own + // project — the divergence from offline's active-project read, kept + // caller-visible here), the WAV-aware contentHash of the (possibly trimmed) + // bank file, and createdTimestamp. + stampCaptureSample(result.sample, request, /*rateProj=*/proj, + /*timeSigProj=*/proj, destPath); + + // The recorded file's true length differs from the request range when a tail was + // recorded, so the Sample length must reflect the FILE, not the range: + // Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned. + // Auto with no trim, or Manual -> the full recorded window (end - start). + // None -> the exact range (unchanged; recordWindowEnd == endSeconds). + // sampleFromRecordedCapture already set lengthSeconds = end - start; override it + // to the recorded/trimmed length so downstream (thumbnail, placement) matches disk. + if (trimmedLenSeconds >= 0.0) { + result.sample.lengthSeconds = trimmedLenSeconds; + } else { + result.sample.lengthSeconds = recordWindowEnd - request.startSeconds; + } + + result.message = "Realtime-captured [" + + std::to_string(request.startSeconds) + "s, " + + std::to_string(request.endSeconds) + "s] (recorded " + + std::to_string(result.sample.lengthSeconds) + "s) -> " + + paths.relativePath; + return result; +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_realtime_finalize.h b/src/shell/capture/capture_realtime_finalize.h new file mode 100644 index 0000000..e1a900f --- /dev/null +++ b/src/shell/capture/capture_realtime_finalize.h @@ -0,0 +1,42 @@ +#pragma once +// capture_realtime_finalize — the FILE-SIDE half of the realtime-record shell +// (Q-W3, T4-08 split riding the Q-9 rename): discovering the file REAPER actually +// recorded, moving it into the bank, the Auto-tail PCM decay-scan trim, and the +// finished-Sample population. The async record LIFECYCLE (state snapshot/restore, +// begin/tick/abort) lives in capture_realtime_shell.cpp; this half talks to +// wav_codec and the filesystem, not to the transport. +// +// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). +// MediaTrack / ReaProject are forward-declared (via capture.h) so this header +// stays SDK-lite. + +#include + +#include "shell/capture/capture.h" // CaptureRequest / CaptureResult +#include "core/capture/capture_paths.h" // BankPaths + +namespace reasampler::capture { + +// Discovers the file REAPER actually recorded onto the temp track: the first media +// item's active take's source file, forward-slashed. Empty string if nothing was +// recorded (no item / take / source). Also used by the lifecycle's flush wait +// (size-stable check) before finalize runs. +std::string recordedFilePath(MediaTrack* temp); + +// Builds a CaptureResult for a finalized recording: discover the recorded file, +// move it into the bank at `paths`, Auto-trim the tail decay in place when the +// request asks for it, and populate the Sample (pure sampleFromRecordedCapture + +// the shared stampCaptureSample — both project reads pinned to `proj`, the +// record's OWN project). Returns Ok + Sample on success, or a RenderFailed result. +// Does NOT restore any snapshotted state — the caller restores unconditionally +// afterward (finalize + restore are separate steps so a finalize failure still +// restores). `recordWindowEnd` is the recorded window end in project seconds +// (>= request.endSeconds when a tail was recorded) — the untrimmed-length source. +CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, + const CaptureRequest& request, + const BankPaths& paths, + const std::string& uniqueTag, + double recordWindowEnd); + +} // namespace reasampler::capture diff --git a/src/capture_realtime.cpp b/src/shell/capture/capture_realtime_shell.cpp similarity index 67% rename from src/capture_realtime.cpp rename to src/shell/capture/capture_realtime_shell.cpp index 4a5e16f..9539c61 100644 --- a/src/capture_realtime.cpp +++ b/src/shell/capture/capture_realtime_shell.cpp @@ -1,4 +1,10 @@ -// capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend). +// capture_realtime_shell.cpp — REAPER-facing realtime-record backend +// (RealtimeRecordBackend): the ASYNC record LIFECYCLE — state snapshot/restore + +// begin/tick/abort. (Renamed from capture_realtime.cpp in Q-W3 — the Q-9 naming +// rider: the PURE module owns the capture_realtime stem, this shell takes the +// suffix, matching drag_out ↔ drag_out_win.) The FILE-SIDE half — recorded-file +// discovery, move-into-bank, Auto-tail trim, Sample population — lives in +// capture_realtime_finalize.cpp (T4-08 split). // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API @@ -29,8 +35,9 @@ // completion, user stop, error, second-capture reject, project switch, unload — // funnels through the SAME single restore, safe to call once from whichever fires. // The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the -// completion state machine (advanceRecordPhase) all live in realtime_record.{h,cpp} -// (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe. +// completion state machine (advanceRecordPhase) all live in the pure +// core/capture/capture_realtime.{h,cpp} (unit-tested outside the DAW). This TU +// owns only the REAPER-bound lifecycle recipe. // // ============================================================================ // §TAP — track-output tap (selected track's own output, PRE-parent) @@ -67,29 +74,22 @@ // Item realtime is deferred (UnsupportedMode): item scope would need per-item take // isolation on top of the tap, which is a separate increment. -#include "capture.h" +#include "shell/capture/capture_realtime_shell.h" #include #include -#include -#include #include -#include #include #include -#include "capture_paths.h" // hashBytes, deriveBankPaths -#include "peaks.h" // lastFrameAboveThreshold, AudioSample -#include "realtime_record.h" -#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd -#include "wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate +#include "core/capture/capture_paths.h" // deriveBankPaths +#include "core/capture/capture_realtime.h" // RecordPhase machine, record-mode plan (pure) +#include "core/capture/render_settings.h" // realtimeRecordWindowEnd +#include "shell/capture/capture_realtime_finalize.h" // recordedFilePath, finalizeRecording #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_Main_SaveProject -#define REAPERAPI_WANT_Master_GetTempo -#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime -#define REAPERAPI_WANT_GetSetProjectInfo #define REAPERAPI_WANT_InsertTrackAtIndex #define REAPERAPI_WANT_DeleteTrack #define REAPERAPI_WANT_CountTracks @@ -97,11 +97,6 @@ #define REAPERAPI_WANT_CreateTrackSend #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_SetMediaTrackInfo_Value -#define REAPERAPI_WANT_GetTrackNumMediaItems -#define REAPERAPI_WANT_GetTrackMediaItem -#define REAPERAPI_WANT_GetMediaItemTake -#define REAPERAPI_WANT_GetMediaItemTake_Source -#define REAPERAPI_WANT_GetMediaSourceFileName #define REAPERAPI_WANT_CSurf_OnRecord #define REAPERAPI_WANT_OnStopButtonEx #define REAPERAPI_WANT_GetPlayStateEx @@ -112,16 +107,10 @@ #define REAPERAPI_WANT_ValidatePtr2 #include "reaper_plugin_functions.h" -namespace reasampler { +namespace reasampler::capture { namespace { -// A monotonic, filesystem-safe timestamp tag so repeated captures do not collide. -std::string makeUniqueTag() { - std::time_t now = std::time(nullptr); - return "rt-" + std::to_string(static_cast(now)); -} - std::string normSlashes(std::string s) { for (char& c : s) if (c == '\\') c = '/'; if (s.size() > 1 && s.back() == '/') s.pop_back(); @@ -136,22 +125,6 @@ std::string readRppPath() { return std::string(buf.data()); } -// Discovers the file REAPER actually recorded onto the temp track: the first media -// item's active take's source file. Empty string if nothing was recorded. -std::string recordedFilePath(MediaTrack* temp) { - if (!temp) return {}; - if (GetTrackNumMediaItems(temp) <= 0) return {}; - MediaItem* item = GetTrackMediaItem(temp, 0); - if (!item) return {}; - MediaItem_Take* take = GetMediaItemTake(item, 0); - if (!take) return {}; - PCM_source* src = GetMediaItemTake_Source(take); - if (!src) return {}; - std::vector buf(4096, '\0'); - GetMediaSourceFileName(src, buf.data(), static_cast(buf.size())); - return std::string(buf.data()); -} - // The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no // item/take/source, or the file does not exist on disk this tick). Used by the flush // wait to detect stability (size unchanged across a tick) BEFORE moving the file — a @@ -321,247 +294,9 @@ private: bool finalized_ = false; }; -namespace { - -// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the -// caller treats an unreadable file as "skip the trim" (keep the untrimmed window), -// never as a corruption of the recorded audio. -std::vector readAllBytes(const std::string& path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return {}; - const std::streamoff size = f.tellg(); - if (size <= 0) return {}; - std::vector bytes(static_cast(size)); - f.seekg(0); - f.read(reinterpret_cast(bytes.data()), size); - if (!f) return {}; - return bytes; -} - -// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields). -void writeU32LE(std::vector& bytes, std::size_t off, std::uint32_t v) { - bytes[off + 0] = static_cast(v & 0xFF); - bytes[off + 1] = static_cast((v >> 8) & 0xFF); - bytes[off + 2] = static_cast((v >> 16) & 0xFF); - bytes[off + 3] = static_cast((v >> 24) & 0xFF); -} - -// ============================================================================ -// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime) -// ============================================================================ -// After the recorded file is stable and moved into the bank (the file we OWN — never -// the project), Auto mode trims the trailing decay: read the WAV, scan the tail -// region (frames AFTER the original range end) backward for the last frame above -// -72 dB, and truncate the file there. Rules (spec): -// * no frame in the tail window above -72 dB -> trim back to the original range end -// * signal never falls below -72 dB in window -> keep the full window (cap did its job) -// * otherwise -> trim one frame past the last audible -// -// Returns the trimmed length in SECONDS (for the Sample), or a negative value to -// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and -// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window) -// rather than risk corrupting the capture — realtime tail is a convenience path. -// -// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit -// float WAV (REAPER project record format — the manual procedure sets it) and is fully -// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees -// that for the normal path; abort()'s best-effort finalize races it, documented). -double trimAutoTailInPlace(const std::string& path, - double rangeStartSeconds, - double rangeEndSeconds) { - constexpr double kNoTrim = -1.0; - - std::vector bytes = readAllBytes(path); - if (bytes.empty()) return kNoTrim; - - const reasampler::WavLayout layout = parseWavLayout(bytes); - if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim - - const std::size_t totalFrames = layout.frameCount(); - if (totalFrames == 0) return kNoTrim; - - // The original range end as a frame index within the file (frame 0 == start). Use - // the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow - // project). Clamp to the file so a rounding overshoot cannot exceed it. - const double rangeSeconds = rangeEndSeconds - rangeStartSeconds; - if (rangeSeconds <= 0.0) return kNoTrim; - std::size_t rangeEndFrame = static_cast( - rangeSeconds * static_cast(layout.sampleRate) + 0.5); - if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; - - // Nothing recorded past the range end (the tail window was empty) -> nothing to - // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.) - if (rangeEndFrame >= totalFrames) return kNoTrim; - - // Scan ONLY the tail region (frames after the original range end). The trim never - // eats into the range body — the scan starts at rangeEndFrame. - const std::size_t tailFrames = totalFrames - rangeEndFrame; - const std::vector tailPcm = - extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); - if (tailPcm.empty()) return kNoTrim; - - const float threshold = static_cast(reasampler::autoTrimEndRatio()); - const std::size_t lastAbove = reasampler::lastFrameAboveThreshold( - tailPcm, layout.channelCount, tailFrames, threshold); - - // keptFrames: the total frame count the trimmed file retains. - // no audible tail frame -> trim back to the range end (rangeEndFrame frames) - // an audible frame at idx -> keep range body + up to and including that frame - // The "signal never falls below threshold" case falls out naturally: lastAbove is - // the final tail frame, so keptFrames == totalFrames (the full window is kept). - std::size_t keptFrames; - if (lastAbove == reasampler::kNoFrameAboveThreshold) { - keptFrames = rangeEndFrame; - } else { - keptFrames = rangeEndFrame + (lastAbove + 1); - } - if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate - - const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames); - if (!plan.valid) return kNoTrim; - - // Patch the RIFF + data size fields in the in-memory buffer so they describe the - // kept frame count, then rewrite the file as exactly the first newFileByteLength - // bytes (header + patched sizes + retained PCM). A single truncating write is the - // simplest correct truncate — no separate resize step, no partial-write window - // where the on-disk sizes and length disagree. The result is a valid, playable WAV - // of the kept frames (verified by the wav_trim re-parse test). - writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); - writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize); - - // NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full - // disk, a yanked drive) would leave a short file while we return kNoTrim, so the - // Sample length would overstate the file. Vanishingly unlikely for a just-recorded - // local bank file, and realtime tail is a convenience path, so a temp-file+atomic- - // rename is not warranted here; flagged rather than built. - std::ofstream out(path, std::ios::binary | std::ios::trunc); - if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file - out.write(reinterpret_cast(bytes.data()), - static_cast(plan.newFileByteLength)); - if (!out) return kNoTrim; - out.close(); - - // The trimmed length in seconds for the Sample metadata. - return static_cast(keptFrames) / static_cast(layout.sampleRate); -} - -// Builds a CaptureResult for a finalized recording: discover the recorded file, -// move it into the bank, populate the Sample via the pure mapping. Returns Ok + -// Sample on success, or a RenderFailed result. Does NOT restore — the caller -// restores unconditionally afterward (finalize + restore are separate steps so a -// finalize failure still restores). -CaptureResult finalizeRecording(RealtimeCaptureState& st) { - CaptureResult result; - - const std::string recorded = normSlashes(recordedFilePath(st.temp_)); - if (recorded.empty() || !std::filesystem::exists(recorded)) { - result.status = CaptureStatus::RenderFailed; - result.message = "Realtime record produced no file (check transport/record " - "settings in the DAW)."; - return result; - } - - std::error_code ec; - std::filesystem::create_directories(st.paths_.absoluteDir, ec); - const std::string destPath = st.paths_.absoluteDir + "/" + st.paths_.fileName; - std::filesystem::rename(recorded, destPath, ec); - if (ec) { - // Cross-volume rename can fail; fall back to copy+remove. - ec.clear(); - std::filesystem::copy_file( - recorded, destPath, - std::filesystem::copy_options::overwrite_existing, ec); - if (ec) { - result.status = CaptureStatus::RenderFailed; - result.message = "Recorded file could not be moved into the bank: " + - ec.message(); - return result; - } - std::error_code rmEc; - std::filesystem::remove(recorded, rmEc); // best-effort - } - - // TAIL (Auto): trim the trailing decay of the recorded window in place — on the - // BANK file we now own (destPath), never the project. Best-effort: an unreadable / - // unknown-format / short file skips the trim (keeps the full window) rather than - // corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a - // fixed window (spec §The realtime path). Returns the trimmed length in seconds, - // or < 0 for "no trim applied". - double trimmedLenSeconds = -1.0; - if (st.request_.tailMode == TailMode::Auto) { - trimmedLenSeconds = trimAutoTailInPlace(destPath, - st.request_.startSeconds, - st.request_.endSeconds); - } - - RecordedCapture cap; - cap.relativePath = st.paths_.relativePath; - cap.uniqueTag = st.uniqueTag_; - cap.sourceMode = SourceMode::Realtime; - cap.startSeconds = st.request_.startSeconds; - cap.endSeconds = st.request_.endSeconds; - cap.wetDry = st.request_.wetDry; - cap.displayName = st.request_.baseName; - cap.trackGuids = st.request_.trackGuids; - cap.channelCount = st.request_.channelCount; - cap.sampleRate = (st.request_.sampleRate > 0) - ? st.request_.sampleRate - : static_cast(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false)); - cap.captureTempo = Master_GetTempo(); - // Time signature at the record range's START (L7 F1). TimeMap_GetTimeSigAtTime - // (reaper_plugin_functions.h:7130) reads the meter effective at that project time; - // proj=st.proj_ pins the recording's own project. tempoOut ignored (captureTempo is - // the master tempo above). Leaves 0/0 (unstamped) on any failure. - { - int tsNum = 0, tsDenom = 0; - double tsTempo = 0.0; - TimeMap_GetTimeSigAtTime(st.proj_, st.request_.startSeconds, &tsNum, &tsDenom, &tsTempo); - cap.captureTimeSigNum = tsNum; - cap.captureTimeSigDenom = tsDenom; - } - cap.createdTimestamp = static_cast(std::time(nullptr)); - - result.status = CaptureStatus::Ok; - result.sample = sampleFromRecordedCapture(cap); - - // Content hash: WAV-aware FNV-1a over the (possibly trimmed) bank file's fmt+data - // chunks so hashReferencedElsewhere can identify copies in other banks and suppress - // the last-reference confirm when another bank still holds the same file. Using - // hashWavContent (not the raw hashBytes) skips render-varying metadata chunks - // (bext origination timestamp, iXML, LIST/INFO, etc.) so two records of identical - // audio collapse to the same hash. Best-effort: an unreadable file leaves - // contentHash empty — the safe, confirm-eliciting direction (bank_model treats - // "" as non-participating). - { - const std::vector fileBytes = readAllBytes(destPath); - if (!fileBytes.empty()) { - result.sample.contentHash = hashWavContent(fileBytes); - } - } - - // The recorded file's true length differs from the request range when a tail was - // recorded, so the Sample length must reflect the FILE, not the range: - // Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned. - // Auto with no trim, or Manual -> the full recorded window (end - start). - // None -> the exact range (unchanged; recordWindowEnd_ == endSeconds). - // sampleFromRecordedCapture already set lengthSeconds = end - start; override it - // to the recorded/trimmed length so downstream (thumbnail, placement) matches disk. - if (trimmedLenSeconds >= 0.0) { - result.sample.lengthSeconds = trimmedLenSeconds; - } else { - result.sample.lengthSeconds = - st.recordWindowEnd_ - st.request_.startSeconds; - } - - result.message = "Realtime-captured [" + - std::to_string(st.request_.startSeconds) + "s, " + - std::to_string(st.request_.endSeconds) + "s] (recorded " + - std::to_string(result.sample.lengthSeconds) + "s) -> " + - st.paths_.relativePath; - return result; -} - -} // namespace +// The FILE-SIDE finalize half (recorded-file discovery, move-into-bank, the +// Auto-tail PCM decay-scan trim, and the finished-Sample population) lives in +// capture_realtime_finalize.cpp (T4-08). This TU owns only the async lifecycle. // ============================================================================ // begin — start the record, snapshot, return immediately (no UI block) @@ -634,7 +369,7 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, RealtimeCaptureHandle st(new RealtimeCaptureState()); st->proj_ = proj; st->request_ = request; - st->uniqueTag_ = makeUniqueTag(); + st->uniqueTag_ = makeUniqueTag("rt-"); // shared mint (T1-11 monotonic counter) st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_); // The recorded window end: extended past the range end for a tail mode (Auto/Manual), @@ -797,7 +532,9 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) { // ALL snapshotted state — the non-destructive gate, idempotent + unconditional. CaptureResult res; if (state.phase_ == RecordPhase::Done) { - res = finalizeRecording(state); + res = finalizeRecording(state.proj_, state.temp_, state.request_, + state.paths_, state.uniqueTag_, + state.recordWindowEnd_); } else { res.status = CaptureStatus::RenderFailed; res.message = "Realtime record timed out waiting for the recorded file to " @@ -853,7 +590,9 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) { // is the one that must be flush-safe. state.stopOwnTransport(); - CaptureResult res = finalizeRecording(state); + CaptureResult res = finalizeRecording(state.proj_, state.temp_, state.request_, + state.paths_, state.uniqueTag_, + state.recordWindowEnd_); state.markFinalized(); state.restore(); // the non-destructive gate — always runs @@ -864,4 +603,4 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) { return out; } -} // namespace reasampler +} // namespace reasampler::capture diff --git a/src/shell/capture/capture_realtime_shell.h b/src/shell/capture/capture_realtime_shell.h new file mode 100644 index 0000000..4557b19 --- /dev/null +++ b/src/shell/capture/capture_realtime_shell.h @@ -0,0 +1,121 @@ +#pragma once +// capture_realtime_shell — the ASYNC realtime-record seam (Q-W6 split of the former +// fat capture.h: this header owns the realtime backend's begin/tick/abort surface; +// capture.h keeps the shared CaptureRequest/CaptureResult types, the offline +// backend, and the shared backend helpers). Implemented by +// capture_realtime_shell.cpp; driven by exactly one caller (realtime_lifecycle). +// +// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport +// on REAPER's audio thread and returns immediately — it does NOT block until the +// range completes, which takes (end - start) wall-clock seconds. Blocking the main +// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN +// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called +// from the same OnTimer that runs session.poll()) advances the in-flight record and +// reports when it is done. +// +// SEAM CHOICE (surfaced): the two backends deliberately share NO interface. The +// lifecycles are genuinely different (offline is headless + immediate — one +// synchronous capture() call returns a finished Sample; realtime is +// transport-driven + async — begin/tick/abort across timer ticks), so a shared +// interface would make offline fake a lifecycle it does not have (its tick() +// would always be Done on the first call — dead code / an LSP smell). Offline +// stays synchronous; the realtime backend owns this small bespoke async seam. +// This is the split-sync/async fork, chosen over a unified async interface for +// that reason. (The old synchronous ICaptureBackend interface over +// OfflineRenderBackend was deleted in Q-W3 — T4-26: one deriver, zero polymorphic +// call sites.) +// +// REAPER-free like capture.h: MediaTrack is forward-declared there and never +// dereferenced here; the REAPER-facing TU is capture_realtime_shell.cpp. + +#include +#include + +#include "shell/capture/capture.h" // CaptureRequest / CaptureResult / MediaTrack fwd + +namespace reasampler::capture { + +// One tick's verdict from the in-flight record. +enum class RealtimeTickStatus { + InProgress, // still recording — call tick() again next timer tick + Done, // finished (range end reached, or the user stopped) — `result` is set + Failed, // an error tore the capture down — `result.message` explains +}; + +struct RealtimeTickResult { + RealtimeTickStatus status = RealtimeTickStatus::InProgress; + CaptureResult result; // meaningful only when status == Done or Failed +}; + +// The opaque in-flight capture state. Owns the snapshot of everything to restore +// (temp track + its receive sends from the source tracks, other tracks' I_RECARM, +// transport, edit cursor, time selection) and the record's own project handle. +// Defined in capture_realtime_shell.cpp; the header stays REAPER-free (nothing is +// dereferenced here) by holding it behind a forward-declared type + unique_ptr. +// +// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope +// RAII guard) because the record spans ticks — no single stack frame outlives it. +// Every terminal path (normal completion, user stop, error, project switch, unload) +// funnels through the same single restore, safe to call once from whichever fires. +class RealtimeCaptureState; + +// Out-of-line deleter so callers (realtime_lifecycle) can own a unique_ptr to the +// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the +// delete is compiled in capture_realtime_shell.cpp where the type is complete, +// keeping this header REAPER-free (load-bearing split). +struct RealtimeCaptureStateDeleter { + void operator()(RealtimeCaptureState* p) const noexcept; +}; +using RealtimeCaptureHandle = + std::unique_ptr; + +// Realtime-record backend — captures by RECORDING in realtime (transport-driven) +// into a hidden temp track, then moves the recorded file into the bank as a Sample. +// For sources offline render cannot do (hardware, performed FX) and as the true +// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline +// render has none). Dialog-free: never invokes the offline-render progress window. +// +// Non-bit-identical by nature (it is realtime); offline stays the deterministic +// default. Non-destructive across EVERY terminal path — the review gate — which is +// harder here than offline because the record spans ticks: the snapshot + restore +// live on RealtimeCaptureState, not a function-scope RAII destructor. +// +// SCOPE (this increment): TRACK scope only — records the selected track's OWN +// output (item + that track's own FX + its own fader/pan, PRE-parent), matching +// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's +// output is naturally PRE-parent (the parent has not summed it yet), so the tap is +// chain-independent by construction. Item realtime is deferred (UnsupportedMode). +class RealtimeRecordBackend { +public: + // Starts a realtime record: validates the request (track scope, non-empty range, + // at least one source track, active + saved project, transport idle), snapshots + // all state to restore, creates the hidden temp track, routes a send FROM each + // source track INTO the temp track, arms, and CSurf_OnRecord — then returns + // IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to + // tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free, + // carrying only the provenance GUIDs). On success the returned unique_ptr owns the + // in-flight state; drive it with tick(). On a validation/setup failure returns + // nullptr and fills `outFailure` with the CaptureStatus + message (nothing was + // left mutated — begin() restores on its own failure paths). + RealtimeCaptureHandle begin(const CaptureRequest& request, + const std::vector& sourceTracks, + CaptureResult& outFailure); + + // Advances the in-flight record one tick. Reads the transport (bound to the + // record's OWN project handle so a project switch cannot confuse it), and on a + // terminal verdict stops the transport, finalizes the recorded file into the + // bank Sample (Done) or reports the failure (Failed), then restores ALL + // snapshotted state. Returns InProgress while the record is still running. + // After Done/Failed the state is spent — the caller drops the unique_ptr. + RealtimeTickResult tick(RealtimeCaptureState& state); + + // Force-terminate an in-flight record NOW without waiting for the range end: + // stops the transport, finalizes whatever was captured (best effort) or abandons + // it, and restores ALL snapshotted state. For the shutdown / project-switch + // paths (extension unload, a new project became active) where the record must + // not leak a temp track / armed track / altered transport into the user's + // project. Idempotent — safe even if a prior tick already tore the state down. + RealtimeTickResult abort(RealtimeCaptureState& state); +}; + +} // namespace reasampler::capture diff --git a/src/insert.cpp b/src/shell/capture/insert.cpp similarity index 93% rename from src/insert.cpp rename to src/shell/capture/insert.cpp index 509200b..fc6e5f2 100644 --- a/src/insert.cpp +++ b/src/shell/capture/insert.cpp @@ -30,16 +30,16 @@ // doc-comment says "Set exactly one track selected, deselect all others" — // this is the strongest confirmation we have; flagged for DAW-verification. -#include "insert.h" +#include "shell/capture/insert.h" #include #include #include -#include "bank_model.h" -#include "bank_panel.h" -#include "capture_paths.h" -#include "persist.h" +#include "core/model/bank_model.h" +#include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId +#include "core/capture/capture_paths.h" +#include "shell/persist/session.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedTracks @@ -57,13 +57,19 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using capture::computeInsertMode; +using capture::normalizeSlashes; +using capture::resolveBankFile; +using capture::TempoConform; + namespace { namespace fs = std::filesystem; // The current project's directory (mirrors bank_panel/capture/persist). The bank // index stores relative paths; resolving a bank file needs the current .rpp dir. -// FOLLOW-UP (already noted in bank_panel.cpp): a shared "current project dir" +// FOLLOW-UP (already noted in panel_bank_ops.cpp): a shared "current project dir" // REAPER helper is a clean small refactor now that a fourth consumer exists — out // of scope for M6. std::string currentProjectDir() { @@ -130,8 +136,8 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) // necessarily the active/capture-target bank. Fall back to the active bank when // the source id names no bank (defensive). const std::string srcBankId = bankPanelSelectedSourceBankId(); - const BankIndex* srcIndex = session->book().index(srcBankId); - const BankIndex& bank = srcIndex ? *srcIndex : session->bank(); + const BankModel* srcIndex = session->book().index(srcBankId); + const BankModel& bank = srcIndex ? *srcIndex : session->bank(); const Sample* sample = bank.query(id); if (!sample) { result.status = InsertStatus::NothingResolved; return result; } diff --git a/src/insert.h b/src/shell/capture/insert.h similarity index 95% rename from src/insert.h rename to src/shell/capture/insert.h index b61aee9..cb53934 100644 --- a/src/insert.h +++ b/src/shell/capture/insert.h @@ -17,7 +17,7 @@ // The header is SDK-free: all REAPER API use lives in insert.cpp. The pure // mode-bit arithmetic lives in insert_plan (unit-tested outside the DAW). -#include "insert_plan.h" +#include "core/capture/insert_plan.h" namespace reasampler { @@ -27,7 +27,7 @@ class ReaSamplerSession; // tempo-conform choice) so the two action variants (native-length vs // conform-to-tempo) differ only by this struct — no divergent code paths. struct InsertRequest { - InsertOptions options; // defaults: current track, no conform, native length + capture::InsertOptions options; // defaults: current track, no conform, native length }; // The outcome of an insert action, for the caller to log to the console. diff --git a/src/item_read.cpp b/src/shell/capture/item_read.cpp similarity index 96% rename from src/item_read.cpp rename to src/shell/capture/item_read.cpp index 40edc65..afc640f 100644 --- a/src/item_read.cpp +++ b/src/shell/capture/item_read.cpp @@ -3,7 +3,7 @@ // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU that // defines the API pointers — CLAUDE.md §contract). -#include "item_read.h" +#include "shell/capture/item_read.h" #include diff --git a/src/item_read.h b/src/shell/capture/item_read.h similarity index 100% rename from src/item_read.h rename to src/shell/capture/item_read.h diff --git a/src/provenance_shell.cpp b/src/shell/capture/provenance_shell.cpp similarity index 94% rename from src/provenance_shell.cpp rename to src/shell/capture/provenance_shell.cpp index 111ae6c..632f68e 100644 --- a/src/provenance_shell.cpp +++ b/src/shell/capture/provenance_shell.cpp @@ -19,13 +19,13 @@ // * CountTracks / GetTrack (track scan) // * guidToString (via track_guid) -#include "provenance_shell.h" +#include "shell/capture/provenance_shell.h" #include -#include "bank_book.h" // BankBook, Bank, BankIndex::all -#include "capture_paths.h" // resolveBankFile, normalizeSlashes -#include "track_guid.h" // guidString — the ONE canonical GUID key formatter +#include "core/model/bank_book.h" // BankBook, Bank, BankModel::all +#include "core/capture/capture_paths.h" // resolveBankFile, normalizeSlashes +#include "shell/capture/track_guid.h" // guidString — the ONE canonical GUID key formatter #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_TrackFX_GetCount @@ -51,6 +51,10 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using capture::normalizeSlashes; +using capture::resolveBankFile; + std::string fxChainIdentityForTrack(MediaTrack* tr) { if (!tr) return fxChainIdentity({}); std::vector rows; diff --git a/src/provenance_shell.h b/src/shell/capture/provenance_shell.h similarity index 96% rename from src/provenance_shell.h rename to src/shell/capture/provenance_shell.h index e53888b..ab2cafa 100644 --- a/src/provenance_shell.h +++ b/src/shell/capture/provenance_shell.h @@ -19,7 +19,7 @@ #include #include -#include "provenance.h" +#include "core/model/provenance.h" class MediaTrack; class MediaItem; @@ -28,6 +28,9 @@ namespace reasampler { class BankBook; +// Real-namespace-home using-declaration (Q-W6: the namespaces.h shim is retired). +using model::BankFileRef; + // The in-scope FX-chain identity of a source track (Track scope), folded to the // pure provenance string. Reads the track's own FX chain via TrackFX_GetCount / // TrackFX_GetFXName / TrackFX_GetFXGUID / TrackFX_GetEnabled in chain order. diff --git a/src/shell/capture/realtime_lifecycle.cpp b/src/shell/capture/realtime_lifecycle.cpp new file mode 100644 index 0000000..44f7f2f --- /dev/null +++ b/src/shell/capture/realtime_lifecycle.cpp @@ -0,0 +1,101 @@ +// realtime_lifecycle.cpp — the in-flight realtime-capture state machine + globals +// (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded as a +// parameter). See the header. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/realtime_lifecycle.h" + +#include "shell/persist/session.h" // ReaSamplerSession + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_ShowConsoleMsg +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +// --- M8 in-flight realtime capture (async, timer-driven) -------------------- +RealtimeRecordBackend g_rtBackend; +RealtimeCaptureHandle g_rtCapture; +ReaProject* g_rtCaptureProject = nullptr; + +// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the +// Sample to the ACTIVE bank (session.bank() resolves to book.activeIndex() — B2), +// persist + MarkProjectDirty. Shared by the tick-completion path and the abort +// paths. On a non-Ok result, logs the failure only. +void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res) +{ + if (res.status != CaptureStatus::Ok) + { + ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str()); + return; + } + session.bank().add(res.sample); + // B-cap: record the file the capture created in the owned-file manifest, at the same + // point the Sample is added and before the same persist. Recorded regardless of the + // index AddResult — even a hash-collapse still WROTE a file the tool owns, and the + // manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index). + session.owned().add(res.sample.relativePath); + // S9: a capture add changes what a live instance could play (a new sample landed in the + // active bank) -> bump before the persist so the stamped generation refreshes instances. + session.bumpBankGeneration(); + session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp) +} + +// Advance any in-flight realtime capture one tick. Cheap when none is running (a +// null check) and fast even mid-record (tick() only reads the transport until the +// terminal tick). Detects a project switch mid-capture and aborts+restores so the +// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so +// poll's project-switch handling sees a cleaned-up project. +void DriveRealtimeCapture(ReaSamplerSession& session) +{ + if (!g_rtCapture) return; + + // Project switch guard: if the active project is no longer the one the capture + // belongs to, a new/other project became active mid-record — abort + restore + // (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize + // into the new project. + ReaProject* active = EnumProjects(-1, nullptr, 0); + if (active != g_rtCaptureProject) + { + RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); + // Only commit if the ORIGINAL project is still open and active would be it — + // on a switch we restored into the original but must not persist into the + // now-active foreign project. Log the outcome without persisting. On a Failed + // abort surface abort()'s own message — it distinguishes a clean tab-switch + // abort from the closed-project DROP (the captured project was closed mid-record, + // review §1: nothing restored because the pointers were already freed). + if (r.status == RealtimeTickStatus::Done) + ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- " + "captured audio restored into the original project; not " + "persisted to avoid crossing projects.\n"); + else + ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record -- " + + r.result.message + "\n").c_str()); + g_rtCapture.reset(); + g_rtCaptureProject = nullptr; + return; + } + + RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture); + if (r.status == RealtimeTickStatus::InProgress) return; + + // Terminal (Done or Failed): commit/log and drop the in-flight state. + CommitRealtimeResult(session, r.result); + g_rtCapture.reset(); + g_rtCaptureProject = nullptr; +} + +void AbortRealtimeCaptureForUnload(ReaSamplerSession& session) +{ + if (!g_rtCapture) return; + RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); + CommitRealtimeResult(session, r.result); + g_rtCapture.reset(); + g_rtCaptureProject = nullptr; +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/realtime_lifecycle.h b/src/shell/capture/realtime_lifecycle.h new file mode 100644 index 0000000..d76e58f --- /dev/null +++ b/src/shell/capture/realtime_lifecycle.h @@ -0,0 +1,56 @@ +#pragma once +// realtime_lifecycle — the in-flight realtime-capture state machine + globals +// (Q-W3 hoist out of main.cpp). A realtime record spans many timer ticks (it takes +// end-start wall-clock seconds and must NOT block REAPER's UI): the action STARTS +// it (capture_orchestrator::RunCaptureRealtimeTrack -> g_rtBackend.begin), OnTimer +// drives it here (DriveRealtimeCapture -> g_rtBackend.tick) each tick until a +// terminal verdict, then the handle is cleared. +// +// The three globals are EXPOSED (extern) rather than wrapped: the action bodies in +// capture_orchestrator manipulate them exactly as main.cpp did (zero-behavior-change +// move), and — load-bearing (CONTEXT.md §Phase Q hot-path guardrail) — the timer's +// IDLE FAST-PATH stays a SINGLE POINTER TEST at the call site: +// if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); +// No per-tick cross-TU call, no accessor indirection, when nothing is recording. +// +// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). + +#include "shell/capture/capture_realtime_shell.h" // RealtimeRecordBackend / RealtimeCaptureHandle + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::capture { + +// The realtime backend + the in-flight capture handle. Non-null handle == a +// capture is in progress (used to reject a second one, to drive the per-tick +// advance, and to abort on project switch / unload). +extern RealtimeRecordBackend g_rtBackend; +extern RealtimeCaptureHandle g_rtCapture; + +// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets +// OnTimer detect a project switch mid-capture and abort+restore rather than leak the +// temp track/arm/transport into or across projects. Only meaningful when +// g_rtCapture != nullptr. +extern ReaProject* g_rtCaptureProject; + +// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the +// Sample to the ACTIVE bank, record the owned file, bump the generation, persist + +// MarkProjectDirty. On a non-Ok result, logs the failure only. +void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res); + +// Advance any in-flight realtime capture one tick. Cheap when none is running (a +// null check — though the caller already guards, see the header note) and fast even +// mid-record. Detects a project switch mid-capture and aborts+restores so the +// capture never leaks across projects. Called from OnTimer BEFORE session.poll(). +void DriveRealtimeCapture(ReaSamplerSession& session); + +// Unload teardown: abort any in-flight capture while the API pointers are still +// live — finalize-or-abort + restore so we never leave a temp track, an armed +// track, or an altered transport/cursor in the user's project on unload. Commits +// whatever was captured (best effort) before tearing down. No-op when idle. +void AbortRealtimeCaptureForUnload(ReaSamplerSession& session); + +} // namespace reasampler::capture diff --git a/src/shell/capture/scope_resolve.cpp b/src/shell/capture/scope_resolve.cpp new file mode 100644 index 0000000..6caabe5 --- /dev/null +++ b/src/shell/capture/scope_resolve.cpp @@ -0,0 +1,242 @@ +// scope_resolve.cpp — scope/source resolution for the capture action family +// (Q-W3 hoist out of main.cpp; the code moved verbatim, session state threaded as +// parameters). See the header. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). + +#include "shell/capture/scope_resolve.h" + +#include // project-dir derivation for provenance parent resolution +#include + +#include "shell/capture/provenance_shell.h" // fxChainIdentity* / *SourceFiles / bankFileRefs +#include "shell/capture/track_guid.h" // guidString + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetSet_LoopTimeRange +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_CountSelectedMediaItems +#define REAPERAPI_WANT_GetSelectedMediaItem +#define REAPERAPI_WANT_GetMediaItem_Track +#define REAPERAPI_WANT_CountSelectedTracks +#define REAPERAPI_WANT_GetSelectedTrack +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +namespace { + +// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false, +// isLoop=false) reads the current time selection. +bool resolveTimeSelection(double& start, double& end) +{ + start = 0.0; end = 0.0; + GetSet_LoopTimeRange(false, false, &start, &end, false); + return end > start; +} + +// Maps a capture FX scope onto the pure provenance scope (kept decoupled so the +// pure provenance module does not depend on render_settings). +model::ProvenanceScope provenanceScopeFor(CaptureScope scope) +{ + return scope == CaptureScope::Item ? model::ProvenanceScope::Item + : model::ProvenanceScope::Track; +} + +// Collects the tracks that own the selected items (Item scope) into +// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an +// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning +// track (SDK header, verify). GUIDs recorded for provenance. +bool collectSelectedItemTracks(ResolvedSource& out) +{ + const int n = CountSelectedMediaItems(nullptr); + if (n <= 0) return false; + for (int i = 0; i < n; ++i) + { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (!it) continue; + MediaTrack* tr = GetMediaItem_Track(it); + if (!tr) continue; + // Dedup: several selected items can share a track. + bool seen = false; + for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; } + if (seen) continue; + out.sourceTracks.push_back(tr); + std::string g = guidString(tr); + if (!g.empty()) out.trackGuids.push_back(std::move(g)); + } + return !out.sourceTracks.empty(); +} + +} // namespace + +// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of +// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits), +// and returns the union bound. Reads only — never clears the razor selection. +// Returns false when no track-audio razor area exists on any track. +bool resolveRazorRange(double& start, double& end) +{ + std::vector allRanges; + const int n = CountTracks(nullptr); + for (int i = 0; i < n; ++i) + { + MediaTrack* tr = GetTrack(nullptr, i); + if (!tr) continue; + std::vector buf(8192, '\0'); + if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) + continue; + std::vector ranges = parseRazorEdits(std::string(buf.data())); + for (auto& r : ranges) allRanges.push_back(r); + } + if (allRanges.empty()) return false; + RazorRange u = razorUnionBounds(allRanges); + start = u.startSeconds; + end = u.endSeconds; + return end > start; +} + +// Infers the render RANGE for any scope: razor union when a razor area is present, +// else the time selection (pure inferRangeSource decides which). Orthogonal to +// scope. Returns false (with a reason) when neither yields a non-empty range. +bool resolveRange(double& start, double& end, std::string& why) +{ + double rzStart = 0.0, rzEnd = 0.0; + const bool hasRazor = resolveRazorRange(rzStart, rzEnd); + if (inferRangeSource(hasRazor) == RangeSource::Razor) + { + start = rzStart; end = rzEnd; + return true; // resolveRazorRange already verified end > start + } + if (resolveTimeSelection(start, end)) return true; + why = "make a razor area or a time selection first"; + return false; +} + +// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. +bool collectSelectedTracks(ResolvedSource& out) +{ + const int n = CountSelectedTracks(nullptr); // nullptr = active project + if (n <= 0) return false; + for (int i = 0; i < n; ++i) + { + MediaTrack* tr = GetSelectedTrack(nullptr, i); + if (!tr) continue; + out.sourceTracks.push_back(tr); + std::string g = guidString(tr); + if (!g.empty()) out.trackGuids.push_back(std::move(g)); + } + return !out.sourceTracks.empty(); +} + +// Resolves the source for a scope: the selection tracks (item/track), plus the +// inferred range. Returns false with a reason on nothing to do. +bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why) +{ + switch (scope) + { + case CaptureScope::Item: + if (!collectSelectedItemTracks(out)) { + why = "select at least one media item"; return false; + } + break; + case CaptureScope::Track: + if (!collectSelectedTracks(out)) { + why = "select at least one track"; return false; + } + break; + } + return resolveRange(out.startSeconds, out.endSeconds, why); +} + +// Current project's directory (parent of its .rpp), forward-slashed, no trailing +// slash — the same derivation capture.cpp does internally, needed here so M10 can +// resolve the bank's relative paths to absolute for parent detection. Empty for an +// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank +// file resolve empty -> no false parentage. Read-only; mutates nothing. +std::string currentProjectDir() +{ + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + const std::string rpp(buf.data()); + if (rpp.empty()) return {}; + namespace fs = std::filesystem; + std::string dir = fs::path(rpp).parent_path().string(); + for (char& c : dir) if (c == '\\') c = '/'; + if (dir.size() > 1 && dir.back() == '/') dir.pop_back(); + return dir; +} + +// Builds the M10 provenance for a capture IF it genuinely resamples from a bank +// sample, else returns nullopt (the common, non-resample case). Detection rule +// (stated honestly): the capture's source item media file(s) must all resolve, by +// exact normalized absolute path, to ONE bank sample's file (detectParent). On a +// match, records that sample's id as the parent plus a THIN capture-recipe +// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels + +// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from +// source" can replay the request and report drift. NEVER a serialized chain to +// restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per +// selected item, combined in item order; Track scope reads the track FX chain. +std::optional buildCaptureProvenance( + const BankBook& book, const CaptureRequest& req, + CaptureScope scope, const ResolvedSource& src) +{ + const std::string projectDir = currentProjectDir(); + const std::vector bankFiles = bankFileRefs(book, projectDir); + + // The "what audio is being captured" source set depends on scope: item scope uses + // the SELECTED items (the user picked them); track scope uses the range-overlapping + // items ON the source tracks (the user picked the track, not the item). + const std::vector sourceFiles = + scope == CaptureScope::Item + ? selectedItemSourceFiles() + : trackItemSourceFiles(src.sourceTracks, req.startSeconds, + req.endSeconds); + + const std::optional parentId = + model::detectParent(sourceFiles, bankFiles); + if (!parentId) return std::nullopt; // not a resample-from-sample — no provenance + + model::CaptureRecipe recipe; + recipe.scope = provenanceScopeFor(scope); + recipe.sourceMode = static_cast(req.sourceMode); + recipe.startSeconds = req.startSeconds; + recipe.endSeconds = req.endSeconds; + recipe.tailMode = static_cast(req.tailMode); + recipe.tailMs = req.tailMs; + recipe.sampleRate = req.sampleRate; + recipe.channelCount = req.channelCount; + recipe.trackGuids = req.trackGuids; + // The in-scope FX-chain identity: + // Track scope — per-track chains combined in track order (TrackFX_*). + // Item scope — per-item active-take chains combined in item order (TakeFX_*); + // the owning track's FX chain is OUT OF SCOPE for an item capture and must + // not be fingerprinted here (it is bypassed during render, not heard). + if (scope == CaptureScope::Item) { + const int n = CountSelectedMediaItems(nullptr); + std::vector items; + items.reserve(static_cast(n < 0 ? 0 : n)); + for (int i = 0; i < n; ++i) { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (it) items.push_back(it); + } + recipe.fxChainIdentity = fxChainIdentityForItems(items); + } else { + std::vector perTrack; + perTrack.reserve(src.sourceTracks.size()); + for (MediaTrack* tr : src.sourceTracks) + perTrack.push_back(fxChainIdentityForTrack(tr)); + recipe.fxChainIdentity = model::combineChainIdentities(perTrack); + } + + model::Provenance prov; + prov.parentSampleId = *parentId; + prov.fxChainSnapshot = model::buildFingerprint(recipe); + return prov; +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/scope_resolve.h b/src/shell/capture/scope_resolve.h new file mode 100644 index 0000000..991b614 --- /dev/null +++ b/src/shell/capture/scope_resolve.h @@ -0,0 +1,71 @@ +#pragma once +// scope_resolve — scope/source resolution for the capture action family (Q-W3 +// hoist out of main.cpp). The three concerns every capture entry point shares: +// * RANGE inference — razor union else time selection (razor-else-time), +// orthogonal to scope; +// * SOURCE-TRACK collection — the selected tracks (Track scope) or the selected +// items' owning tracks (Item scope), deduped, with canonical GUIDs; +// * PROVENANCE ASSEMBLY inputs — the M10 resample-from-sample detection + the +// thin capture-recipe fingerprint built from the LIVE (un-bypassed) chain. +// +// All reads are non-destructive: selection, razor, and time selection are read, +// never mutated. REAPER-facing: the .cpp includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md +// §contract). MediaTrack is forward-declared (via capture.h) so this header stays +// SDK-lite. + +#include +#include +#include + +#include "shell/capture/capture.h" // CaptureRequest, MediaTrack fwd +#include "core/capture/render_settings.h" // CaptureScope +#include "core/model/provenance.h" // model::Provenance + +namespace reasampler { +class BankBook; +} + +namespace reasampler::capture { + +// The resolved source: exact bounds + the source tracks (for FX-bypass + Sample +// provenance GUIDs). `sourceTracks` holds the item-owning tracks (Item scope) or the +// selected tracks (Track scope). +struct ResolvedSource +{ + double startSeconds = 0.0; + double endSeconds = 0.0; + std::vector sourceTracks; // item-owning tracks / selected tracks + std::vector trackGuids; // canonical GUIDs of sourceTracks +}; + +// Reads every track's P_RAZOREDITS, parses the track-audio areas (pure +// parseRazorEdits), and returns the union bound. Reads only — never clears the +// razor selection. Returns false when no track-audio razor area exists on any track. +bool resolveRazorRange(double& start, double& end); + +// Infers the render RANGE for any scope: razor union when a razor area is present, +// else the time selection (pure inferRangeSource decides which). Orthogonal to +// scope. Returns false (with a reason) when neither yields a non-empty range. +bool resolveRange(double& start, double& end, std::string& why); + +// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. +bool collectSelectedTracks(ResolvedSource& out); + +// Resolves the source for a scope: the selection tracks (item/track), plus the +// inferred range. Returns false with a reason on nothing to do. +bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why); + +// Current project's directory (parent of its .rpp), forward-slashed, no trailing +// slash. Empty for an unsaved project (no false parentage). Read-only. +std::string currentProjectDir(); + +// Builds the M10 provenance for a capture IF it genuinely resamples from a bank +// sample (detectParent over `book`'s resolved file refs), else returns nullopt (the +// common, non-resample case). Must run BEFORE the FxBypassGuard neutralizes the +// in-scope chain — the source FX-chain identity is read from the LIVE chain. +std::optional buildCaptureProvenance( + const BankBook& book, const CaptureRequest& req, + CaptureScope scope, const ResolvedSource& src); + +} // namespace reasampler::capture diff --git a/src/track_guid.cpp b/src/shell/capture/track_guid.cpp similarity index 95% rename from src/track_guid.cpp rename to src/shell/capture/track_guid.cpp index 8a0de5b..2588952 100644 --- a/src/track_guid.cpp +++ b/src/shell/capture/track_guid.cpp @@ -3,7 +3,7 @@ // reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU // that defines the API pointers — CLAUDE.md §contract). -#include "track_guid.h" +#include "shell/capture/track_guid.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_GetTrackGUID diff --git a/src/track_guid.h b/src/shell/capture/track_guid.h similarity index 91% rename from src/track_guid.h rename to src/shell/capture/track_guid.h index f13bac3..64f2026 100644 --- a/src/track_guid.h +++ b/src/shell/capture/track_guid.h @@ -1,7 +1,7 @@ #pragma once // track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID // string used as a membership-index key. Both the Design View shell (view.cpp) and -// the actions layer (actions.cpp) key membership on this exact string, so the key +// the actions layer (design_view_actions.cpp) key membership on this exact string, so the key // contract lives in a single helper rather than being re-derived (and drifting) at // two call sites (the cross-module key contract flagged in D2 review). // diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp new file mode 100644 index 0000000..7852597 --- /dev/null +++ b/src/shell/instrument/editor_controls.cpp @@ -0,0 +1,398 @@ +// editor_controls.cpp — the ReaSamplerEditor's PARAMETER PLUMBING (Q-W2v split of +// reasampler_editor.cpp, T4-11): the control-value domain maps (controlValue / +// applyControl — seconds/fraction/frames <-> normalized 0..1), the r11 knob-deck +// group descriptors + control-id<->value binding, the S-VIEW-3 envelope pack/unpack +// (the TRIGGER SEAM converter), the curve-popup target resolution, and applyZoneControl. +// Value logic only — no painting, no window plumbing. + +#include "shell/instrument/reasampler_editor.h" + +#include +#include +#include // snprintf (deck value labels) +#include +#include + +#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) +#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters (S-VIEW-3) +#include "core/util/clamp01.h" +#include "shell/instrument/editor_internal.h" // DeckGroup ids +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters +using instrument::ui::EnvMode; // envelope_overlay's mode enum (Q-W6: shim retired) +using instrument::engine::formatMasterGainLabel; +using instrument::engine::masterGainLinearFromNorm; +using instrument::engine::masterGainNormFromLinear; +using util::clamp01; + +namespace { +// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is +// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span +// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build +// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) +// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is +// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent — +// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the +// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate +// ruling). Build-time residual — one place to retune; not persisted. +constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) +constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock) +constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered +constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) + +} // namespace + +double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { + // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over + // the rate-resolved frames ceiling (T3-03). Two domains, kept explicit so neither leaks a rate. + // A stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01 + // pins it) and gets rewritten down on the next knob touch — deliberate, matching the old + // fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200. + const double fadeMax = fadeMaxFrames(); + const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; + const auto framesToNorm = [fadeMax](std::int64_t f) { + // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. + return fadeMax > 0.0 ? clamp01(static_cast(f) / fadeMax) : 0.0; + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; + case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; + case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); + case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); + case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds); + case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); + case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds); + case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction); + case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); + case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); + case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; + case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds); + case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds); + case ParamControl::kPitchEnvDepth: + // Signed depth centered at 0.5 (0.5 == 0 semitones). + return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); + default: return 0.0; + } +} + +void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, + int segment) const { + const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale + const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; + const auto normToFrames = [fadeMax](double v) -> std::int64_t { + // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. + if (fadeMax <= 0.0) return 0; + return static_cast(clamp01(v) * fadeMax + 0.5); + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: + play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; + break; + case ParamControl::kPitchEngine: + play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; + break; + case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break; + case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break; + case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break; + case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; + case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break; + case ParamControl::kTrigLength: + // lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing. + play.trigger.lengthFraction = (std::max)(0.01, clamp01(value)); + break; + case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break; + case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break; + case ParamControl::kPitchEnvEnable: + play.pitchEnv.enabled = (segment == 1); + break; + case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break; + case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break; + case ParamControl::kPitchEnvDepth: + play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; + break; + default: break; + } +} + +double ReaSamplerEditor::liveSampleRate() const { + return processor_ ? processor_->sampleRate() : 0.0; +} + +double ReaSamplerEditor::fadeMaxFrames() const { + // T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) + // resolved against the live rate — the SAME time base the envelope overlay already uses + // to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()), + // and the rate captures are made at (the capture path renders at the project rate). + // Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the + // exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) — + // callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert + // rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only. + const double rate = liveSampleRate(); + if (rate <= 0.0) return 0.0; + return kFadeMaxSeconds * rate; +} + +double ReaSamplerEditor::previewVelocity01() const { + if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; + return static_cast(processor_->previewVelocity()) / 127.0; +} + +std::vector ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const { + // The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders + // exactly these; the Sample face appends the per-instance groups in deckGroupDescs). + // Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger + // leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows + // the neighbouring groups (r11). + std::vector out; + { + DeckGroupDesc amp; + amp.id = kGroupAmpEnv; + amp.captionWidth = 78; + amp.captionToggle = {static_cast(ParamControl::kPlayMode), 44}; + if (play.playMode == PlayMode::Gate) { + amp.cellIds = {static_cast(ParamControl::kAttack), + static_cast(ParamControl::kHold), + static_cast(ParamControl::kDecay), + static_cast(ParamControl::kSustain), + static_cast(ParamControl::kRelease)}; + } else { + // Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out — + // matches the drawn envelope), plus the two reserved blanks. + amp.cellIds = {static_cast(ParamControl::kTrigFadeIn), + static_cast(ParamControl::kTrigLength), + static_cast(ParamControl::kTrigFadeOut), -1, -1}; + } + out.push_back(std::move(amp)); + } + { + DeckGroupDesc pitch; + pitch.id = kGroupPitch; + pitch.captionWidth = 38; + pitch.captionToggle = {static_cast(ParamControl::kPitchEngine), 48}; + pitch.cellIds = {static_cast(ParamControl::kKeyTrack)}; + out.push_back(std::move(pitch)); + } + { + DeckGroupDesc penv; + penv.id = kGroupPitchEnv; + penv.captionWidth = 58; + penv.captionToggle = {static_cast(ParamControl::kPitchEnvEnable), 32}; + penv.cellIds = {static_cast(ParamControl::kPitchEnvAttack), + static_cast(ParamControl::kPitchEnvDecay), + static_cast(ParamControl::kPitchEnvDepth)}; + out.push_back(std::move(penv)); + } + return out; +} + +std::vector ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const { + // The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER + // groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the + // post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays + // OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs. + std::vector out = zoneDeckGroupDescs(play); + { + DeckGroupDesc voice; + voice.id = kGroupVoice; + voice.captionWidth = 38; + voice.captionToggle = {static_cast(ParamControl::kVoiceMode), 40}; + voice.cellIds = {static_cast(ParamControl::kVoiceCount)}; + voice.rowToggle = {static_cast(ParamControl::kMonoTrigger), 44}; + out.push_back(std::move(voice)); + } + { + DeckGroupDesc master; + master.id = kGroupMaster; + master.captionWidth = 46; + master.cellIds = {static_cast(ParamControl::kMasterGain)}; + out.push_back(std::move(master)); + } + return out; +} + +double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const { + if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob + switch (static_cast(id)) { + case ParamControl::kKeyTrack: + return clamp01(zone.keyTrack / kKeyTrackMax); + case ParamControl::kVoiceCount: + return clamp01(static_cast(voiceCount_ - kMinVoiceCount) / + static_cast(kMaxVoiceCount - kMinVoiceCount)); + case ParamControl::kMasterGain: + return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0); + default: + return controlValue(id, zone.play); + } +} + +void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) { + if (!processor_) return; + norm = clamp01(norm); + if (id == -2) { + // Preview velocity: live processor write (persisted per-instance; the setter clamps + // to MIDI 1..127 so the knob's bottom still strikes audibly). + processor_->setPreviewVelocity(static_cast(norm * 127.0 + 0.5)); + return; + } + switch (static_cast(id)) { + case ParamControl::kVoiceCount: { + // Stepped: quantize the continuous drag to the integer count and track it live + // for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on + // WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild, + // not thirty. + const int count = + kMinVoiceCount + + static_cast(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5); + voiceCount_ = count; + return; + } + case ParamControl::kMasterGain: + // Post-mixer gain: one atomic store; the audio thread picks it up next block. + processor_->setMasterGainLinear(masterGainLinearFromNorm(norm)); + return; + default: + applyZoneControl(zoneIndex, id, norm, 0); + return; + } +} + +std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const { + char buf[24]; + buf[0] = '\0'; + const ZonePlaySeconds& play = zone.play; + switch (id == -2 ? ParamControl::kCount : static_cast(id)) { + case ParamControl::kAttack: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break; + case ParamControl::kHold: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break; + case ParamControl::kDecay: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break; + case ParamControl::kSustain: + snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break; + case ParamControl::kRelease: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break; + case ParamControl::kTrigLength: + snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break; + case ParamControl::kTrigFadeIn: + snprintf(buf, sizeof(buf), "%lldf", + static_cast(play.trigger.fadeInFrames)); break; + case ParamControl::kTrigFadeOut: + snprintf(buf, sizeof(buf), "%lldf", + static_cast(play.trigger.fadeOutFrames)); break; + case ParamControl::kPitchEnvAttack: + snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break; + case ParamControl::kPitchEnvDecay: + snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break; + case ParamControl::kPitchEnvDepth: + snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; + case ParamControl::kKeyTrack: + snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break; + case ParamControl::kVoiceCount: + snprintf(buf, sizeof(buf), "%d", voiceCount_); break; + case ParamControl::kMasterGain: + formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break; + default: + // -2 (preview velocity) is labeled at its cluster call site; nothing else here. + break; + } + return std::string(buf); +} + +EnvClampBounds ReaSamplerEditor::envClampBounds() const { + // Match the control-panel sliders' own domains so a node drag can never produce a param a + // slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the + // Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use). + EnvClampBounds b; + b.maxAttackSeconds = kEnvTimeMaxSeconds; + b.maxHoldSeconds = kEnvTimeMaxSeconds; + b.maxDecaySeconds = kEnvTimeMaxSeconds; + b.maxReleaseSeconds = kEnvTimeMaxSeconds; + b.maxFadeInFraction = 1.0; + b.maxFadeOutFraction = 1.0; + b.maxLengthFraction = 1.0; + return b; +} + +AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames, + std::int64_t startFrame) const { + AmpEnvelope env; + env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; + // AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws). + env.attackSeconds = play.adsr.attackSeconds; + env.holdSeconds = play.adsr.holdSeconds; + env.decaySeconds = play.adsr.decaySeconds; + env.sustainLevel = play.adsr.sustainLevel; + env.releaseSeconds = play.adsr.releaseSeconds; + // Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played + // span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start + // point so the fraction denominator matches the voice's actual post-start span. A zero play + // length yields 0 fractions. + env.lengthFraction = play.trigger.lengthFraction; + const std::int64_t playLen = + triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); + env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen); + env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen); + return env; +} + +void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, + std::int64_t startFrame, ZonePlaySeconds& play) const { + if (env.mode == EnvMode::Gate) { + play.adsr.attackSeconds = env.attackSeconds; + play.adsr.holdSeconds = env.holdSeconds; + play.adsr.decaySeconds = env.decaySeconds; + play.adsr.sustainLevel = env.sustainLevel; + play.adsr.releaseSeconds = env.releaseSeconds; + } else { + // Trigger: lengthFraction copies back; the fades convert fractions -> source frames over + // the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's + // effective start point so the frame denominator matches the voice's actual post-start span. + // Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length + // trigger never plays nothing. + play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction); + const std::int64_t playLen = + triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); + play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen); + play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen); + } +} + +PerformanceZone ReaSamplerEditor::popupZone() const { + // The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample + // face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex). + if (view_ == View::kZone && selectedZone_ >= 0 && + selectedZone_ < static_cast(map_.zones.size())) { + return map_.zones[static_cast(selectedZone_)]; + } + return effectiveSampleZone(); +} + +int ReaSamplerEditor::popupZoneIndex() { + // The map_.zones index a popup edit lands on, or -1 when there is no valid target. The + // Zone surface never materializes (the button only shows for an explicit selection); the + // Sample face finds-or-materializes the picked id's one-zone site. + if (view_ == View::kZone) { + return (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) + ? selectedZone_ + : -1; + } + return ensureSampleZone(); +} + +#ifdef _WIN32 +void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) { + if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; + PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; + if (id == static_cast(ParamControl::kKeyTrack)) { + // keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1. + z.keyTrack = clamp01(value) * kKeyTrackMax; + } else { + applyControl(id, z.play, value, segment); + } +} +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_input_browse_zone.cpp b/src/shell/instrument/editor_input_browse_zone.cpp new file mode 100644 index 0000000..1587c23 --- /dev/null +++ b/src/shell/instrument/editor_input_browse_zone.cpp @@ -0,0 +1,439 @@ +// editor_input_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE +// input + the hover resolver (Q-W2v split of reasampler_editor.cpp, T4-11): the L3 hover +// resolution across all three faces, the Browse picker's click branch (tabs, cards, +// select-then-confirm, scroll-thumb grab, search focus), the Zone surface's click branch +// (add/delete, strip drags, numeric-entry focus, per-zone deck + curve button), the +// browser wheel scroll, the type-to-filter / note-entry keystrokes, and the S13 degraded +// drop affordance. Windows-only (D5). + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include + +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12) +#include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover) +#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize +#include "core/instrument/map/note_entry.h" // parseNoteEntry (S12 numeric entry) +#include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover) +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::ui; +using namespace reasampler::instrument::ui; +using namespace reasampler::instrument::map; + +// --- Hover resolution (Phase L, L3) ------------------------------------------ +// +// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an +// idle move is free). Mirrors onMouseDown's hit-test order, but read-only. Windows-only. +void ReaSamplerEditor::resolveHover(int x, int y) { + HoverTarget h; // kNone by default + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int hgt = cr.bottom - cr.top; + + if (view_ == View::kBrowse) { + const BrowseModal bm = computeBrowseModal(w, hgt); + if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1}; + else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1}; + else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1}; + else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1}; + else { + const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); + const int bx = x - bm.content.x; + const int by = y - bm.content.y; + const int tabCount = static_cast(banks_.size()) + 1; + const int tab = filterTabHitTest(bl, tabCount, bx, by); + const int card = (tab >= 0) + ? -1 + : cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); + if (tab >= 0) h = {HoverKind::kFilterTab, tab}; + else if (card >= 0) h = {HoverKind::kCard, card}; + } + } else if (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2) + const CurvePopupLayout pl = computeCurvePopup(w, hgt); + if (contains(pl.close, x, y)) { + h = {HoverKind::kPopupClose, -1}; + } else if (contains(pl.curveBox, x, y)) { + // A curve node under the pointer lights accent-hot. + const int idx = + popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y); + if (idx >= 0) h = {HoverKind::kCurveNode, idx}; + } + } else if (view_ == View::kZone) { + const Rect back = zoneBackRect(w, hgt); + const Rect content = zoneContentArea(w, hgt); + Rect addR = zoneAddRect(content); + Rect delR = zoneDeleteRect(addR); + if (contains(back, x, y)) { + h = {HoverKind::kBack, -1}; + } else if (contains(addR, x, y)) { + h = {HoverKind::kAddZone, -1}; + } else if (selectedZone_ >= 0 && contains(delR, x, y)) { + h = {HoverKind::kDeleteZone, -1}; + } else if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + // FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's + // hover grammar — knobs light + swap label->value). + if (contains(zonesCurveButton(content), x, y)) { + h = {HoverKind::kCurveButton, -1}; + } else { + const ZonePlaySeconds& play = + map_.zones[static_cast(selectedZone_)].play; + const Rect deckArea = zonesDeckArea(content); + const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, + deckArea.y, deckArea.width); + const DeckHit dh = hitTestDeck(dl, x, y); + if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; + } + } + } else { // Sample view (home, r11 recomposition) + const PerformanceZone zone = effectiveSampleZone(); + const std::vector descs = deckGroupDescs(zone.play); + const SampleBands bands = + computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad)); + if (contains(bands.navBrowse, x, y)) { + h = {HoverKind::kNavBrowse, -1}; + } else if (contains(bands.navZone, x, y)) { + h = {HoverKind::kNavZone, -1}; + } else if (selectedId_.empty() && map_.zones.empty()) { + // Empty state — no interactive surfaces beyond the nav. + } else { + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize); + if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1}; + else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1}; + else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1}; + else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1}; + else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1}; + else if (contains(bands.deck, x, y)) { + // A deck knob/toggle under the pointer: knobs light + swap label->value. + const DeckLayout dl = + layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width); + const DeckHit dh = hitTestDeck(dl, x, y); + if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; + } + } + } + + if (h != hover_) { + hover_ = h; + invalidate(); + } +} + +// The Browse-modal branch of the mouse-down dispatch (formerly inline in onMouseDown — +// behavior-identical; see editor_input_sample.cpp for the dispatch). +void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) { + const BrowseModal bm = computeBrowseModal(w, h); + if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) { + // Cancel/Back: discard the pending pick, return to Sample unchanged. + browsePendingId_.clear(); + searchFocused_ = false; + view_ = View::kSample; + invalidate(); + return; + } + if (contains(bm.confirm, x, y)) { + // Load: commit the pending pick (if any) into the loaded selection + reload, then Sample. + if (!browsePendingId_.empty()) { + loadSelection(browsePendingId_); + } + browsePendingId_.clear(); + searchFocused_ = false; + view_ = View::kSample; + invalidate(); + return; + } + if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; } + searchFocused_ = false; + + const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); + const int bx = x - bm.content.x; + const int by = y - bm.content.y; + const int tabCount = static_cast(banks_.size()) + 1; + const int tab = filterTabHitTest(bl, tabCount, bx, by); + if (tab >= 0) { + activeFilterBankId_ = (tab == 0) ? std::string() + : banks_[static_cast(tab - 1)].id; + rebuildVisible(); + invalidate(); + return; + } + const Rect thumb = scrollThumbRect(bl, static_cast(visible_.size()), scrollOffset_); + if (thumb.height > 0 && + contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y, + thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) { + drag_ = DragKind::kScrollThumb; + dragStartY_ = y; + dragStartScrollOffset_ = scrollOffset_; + return; + } + const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); + if (card >= 0) { + // Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card + // is the load accelerator (commit + dismiss). Browse never loads on a single click. + const std::string id = visible_[static_cast(card)].id; + if (lastBrowseClickCard_ == card && browsePendingId_ == id) { + loadSelection(id); + browsePendingId_.clear(); + lastBrowseClickCard_ = -1; + searchFocused_ = false; + view_ = View::kSample; + invalidate(); + } else { + browsePendingId_ = id; + lastBrowseClickCard_ = card; + invalidate(); + } + return; + } + lastBrowseClickCard_ = -1; + return; +} + +// The Zone-surface branch of the mouse-down dispatch (formerly the tail of onMouseDown — +// behavior-identical; the curve popup is modal over the Zone surface too, FB2). +void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { + if (handlePopupMouseDown(w, h, x, y)) return; + const Rect back = zoneBackRect(w, h); + if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; } + const Rect content = zoneContentArea(w, h); + Rect addR = zoneAddRect(content); + if (contains(addR, x, y)) { + // Add a narrow default zone for the picked capture (or the first visible sample as a + // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id + // already exists (pre-fix bleed survivor), select it rather than appending a duplicate + // (mirrors the upsert the root-marker drag path already performs). + // NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped + // to [0,127]) so the new zone is immediately "authored" (narrow) and survives + // reconcileSingleCaptureZones without being treated as a Sample-face full-range zone. + std::string seed = !selectedId_.empty() ? selectedId_ + : (!visible_.empty() ? visible_.front().id : std::string()); + if (seed.empty()) return; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) { + selectedZone_ = i; + invalidate(); + return; + } + } + // Look up the seed's root note from the browser list (absent root defaults to 60). + int seedRoot = 60; + for (const SampleChoice& sc : samples_) { + if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; } + } + const int lo = (std::max)(0, seedRoot - 6); + const int hi = (std::min)(127, seedRoot + 5); + PerformanceZone z; + z.sampleId = seed; + z.lowNote = lo; + z.highNote = hi; + map_.zones.push_back(z); + selectedZone_ = static_cast(map_.zones.size()) - 1; + commitAndReload(); + return; + } + Rect delR = zoneDeleteRect(addR); + if (selectedZone_ >= 0 && contains(delR, x, y)) { + map_.zones.erase(map_.zones.begin() + selectedZone_); + selectedZone_ = -1; + commitAndReload(); + return; + } + + // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the + // selected zone's root. + const Rect stripArea = zonesStripArea(content); + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + const int lx = x - stripArea.x; + const int ly = y - stripArea.y; + + std::vector lows, highs; + lows.reserve(map_.zones.size()); + highs.reserve(map_.zones.size()); + for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); } + const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(), + highs.empty() ? nullptr : highs.data(), + static_cast(map_.zones.size()), lx, ly); + if (hit.zoneIndex >= 0) { + selectedZone_ = hit.zoneIndex; + const PerformanceZone& z = map_.zones[static_cast(hit.zoneIndex)]; + dragStartX_ = x; + dragStartLow_ = z.lowNote; + dragStartHigh_ = z.highNote; + dragStartMap_ = map_; + switch (hit.grab) { + case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break; + case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break; + case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break; + default: drag_ = DragKind::kNone; break; + } + invalidate(); + return; + } + // A bare key-click inside the strip sets the selected zone's root override. + if (contains(stripArea, x, y) && selectedZone_ >= 0 && + selectedZone_ < static_cast(map_.zones.size())) { + const int note = keyAtPoint(sl, lx, ly); + if (note >= 0) { + map_.zones[static_cast(selectedZone_)].rootOverride = note; + commitAndReload(); + } + return; + } + + // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a + // zone is selected. entryText_ starts empty (the user types the full value). + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const Rect fields = noteEntryFieldsArea(content); + for (int f = 0; f < 3; ++f) { + if (contains(noteEntryFieldRect(fields, f), x, y)) { + entryField_ = f; + entryText_.clear(); + invalidate(); + return; + } + } + } + entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry + + // The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the + // SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected + // (the Zone surface has no single-capture fallback — that lives on the Sample face). + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + if (contains(zonesCurveButton(content), x, y)) { + curvePopupOpen_ = true; + invalidate(); + return; + } + const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; + const Rect deckArea = zonesDeckArea(content); + const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y, + deckArea.width); + const DeckHit hit = hitTestDeck(dl, x, y); + if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { + // Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete, + // final edit committed at once (the deck precedent). No per-instance ids reach + // here — VOICE/MASTER are not in the zone group set. + applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment); + commitAndReload(); + return; + } + if (hit.kind == DeckHitKind::Knob) { + // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the + // Sample deck's guard, mirrored. + const bool pitchEnvKnob = + hit.id == static_cast(ParamControl::kPitchEnvAttack) || + hit.id == static_cast(ParamControl::kPitchEnvDecay) || + hit.id == static_cast(ParamControl::kPitchEnvDepth); + if (pitchEnvKnob && !play.pitchEnv.enabled) return; + // GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release. + drag_ = DragKind::kDeckKnob; + dragParamId_ = hit.id; + dragParamZone_ = selectedZone_; + dragStartMap_ = map_; + dragKnobStartValue_ = deckControlNorm( + hit.id, map_.zones[static_cast(selectedZone_)]); + dragStartX_ = x; + dragStartY_ = y; + invalidate(); + } + } +} + +void ReaSamplerEditor::onMouseWheel(int delta) { + // Browser scroll (only in the Browse modal — the sole card grid). One wheel notch + // (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive + // delta (wheel up) scrolls toward the top (smaller offset). + if (view_ != View::kBrowse) return; + const int rows = delta / 120; + if (rows == 0) return; + scrollOffset_ -= rows * kBrowserCardHeight; + if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content + invalidate(); +} + +void ReaSamplerEditor::onSearchChar(unsigned int ch) { + // r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face + // or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search + // cannot hold focus under it). + if (curvePopupOpen_ && ch == 27) { + curvePopupOpen_ = false; + invalidate(); + return; + } + + // S12 numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes + // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when + // focused, owns the keystrokes). + if (view_ == View::kZone && entryField_ >= 0) { + if (ch == 13) { // Enter: parse + commit + if (auto note = parseNoteEntry(entryText_)) { + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote); + else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote); + else z.rootOverride = *note; + commitAndReload(); + } + } + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 27) { // Escape cancels + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 8) { // backspace + if (!entryText_.empty()) entryText_.pop_back(); + invalidate(); + } else if (ch >= 32 && ch < 127) { + entryText_.push_back(static_cast(ch)); + invalidate(); + } + return; + } + + // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace + // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search). + if (view_ != View::kBrowse || !searchFocused_) return; + if (ch == 8) { // backspace + if (!searchQuery_.empty()) searchQuery_.pop_back(); + } else if (ch == 27) { // escape clears + defocuses + searchQuery_.clear(); + searchFocused_ = false; + } else if (ch >= 32 && ch < 127) { + searchQuery_.push_back(static_cast(ch)); + } else { + return; // ignore other control chars + } + scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list + rebuildVisible(); + invalidate(); +} + +void ReaSamplerEditor::onFilesDropped(int droppedCount) { + // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact + // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff + // decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a + // timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing + // at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs + // each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0. + (void)droppedCount; // count is informational; the banner text is drop-count-agnostic + dropHintTicks_ = 6; +#ifdef _WIN32 + invalidate(); +#endif +} + +} // namespace reasampler::vst + +#endif // _WIN32 diff --git a/src/shell/instrument/editor_input_sample.cpp b/src/shell/instrument/editor_input_sample.cpp new file mode 100644 index 0000000..b236d76 --- /dev/null +++ b/src/shell/instrument/editor_input_sample.cpp @@ -0,0 +1,586 @@ +// editor_input_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE input + the drag-state +// machine (Q-W2v split of reasampler_editor.cpp, T4-11): the mouse-down dispatch (the +// Sample-face branch inline; Browse/Zone branches delegate to editor_input_browse_zone), +// the curve-popup/curve-box click machinery, the live drag resolution (onMouseMove — deck +// knobs, root marker, envelope nodes, curve nodes, wave markers, scroll thumb, zone +// edges), the release commit (onMouseUp), and the popup right-click delete. Windows-only +// (D5). All hit-test math is pure; this TU routes and mutates editor state only. + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include + +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag) +#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet (r11) +#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag (S-VIEW-3) +#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize +#include "core/instrument/ui/param_slider.h" // knobDragValue (FA4 grab-anchored drag) +#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap (S11) +#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::ui; +using namespace reasampler::instrument::ui; +using namespace reasampler::instrument::map; + +bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) { + // The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home + // (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash + // click dismiss (outside only when no drag is in flight, per the spec); in-box clicks + // route to the shared curve machinery against popupZoneIndex(); anything else on the + // sheet is swallowed. + if (!curvePopupOpen_) return false; + const CurvePopupLayout pl = computeCurvePopup(w, h); + if (contains(pl.close, x, y)) { + curvePopupOpen_ = false; + invalidate(); + return true; + } + if (contains(pl.curveBox, x, y)) { + const int zi = popupZoneIndex(); + if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y); + return true; + } + if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) { + curvePopupOpen_ = false; + invalidate(); + } + return true; +} + +void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) { + if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; + const VelocityCurve::Box box = curveBoxFromRect(r); + if (box.width <= 0 || box.height <= 1) return; + PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; + + int idx = z.velocityCurve.pointAtPixel(box, x, y); + + // Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once + // (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op). + if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) { + if (z.velocityCurve.deletePoint(static_cast(idx))) { + selectedZone_ = zoneIndex; + commitAndReload(); + } + return; + } + + // Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight + // ADD (mirror of the other map-editing drags' dragStartMap_ contract). + dragStartMap_ = map_; + + // Empty-space click inside the MAPPING BOX: add a control point at the cursor via the pure + // inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller + // gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the + // caption band — must not add a point; a click there would clamp to velocity 0/127 and + // produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab + // an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is + // handled above; only the add path is box-gated here. + if (idx < 0) { + const bool inBox = (x >= box.left && x < box.left + box.width && + y >= box.top && y < box.top + box.height); + if (inBox) { + const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y); + idx = static_cast(z.velocityCurve.addPoint(p.velocity, p.amp)); + } + } + + if (idx < 0) return; // ring click with no node hit — nothing to grab + + drag_ = DragKind::kCurveNode; + curvePointIndex_ = idx; + dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base + dragCurveRect_ = r; + dragCurveZone_ = zoneIndex; + dragStartX_ = x; + dragStartY_ = y; + selectedZone_ = zoneIndex; + invalidate(); // live feedback; the commit lands on WM_LBUTTONUP +} + +// --- Input: the drag-state machine ------------------------------------------- + +void ReaSamplerEditor::onMouseDown(int x, int y) { + if (!processor_) return; + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + + // ---- Browse modal (S-VIEW-5): the face branch lives in editor_input_browse_zone ---- + if (view_ == View::kBrowse) { + mouseDownBrowse(w, h, x, y); + return; + } + + // ---- Sample home (S-VIEW-2 / r11) ---- + if (view_ == View::kSample) { + // r11 curve popup: while open the sheet is modal — it owns every left-click. + if (handlePopupMouseDown(w, h, x, y)) return; + + const PerformanceZone probeZone = effectiveSampleZone(); + const std::vector deckDescs = deckGroupDescs(probeZone.play); + const SampleBands bands = + computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); + if (contains(bands.navBrowse, x, y)) { + // Open the Browse modal; seed its pending pick from the loaded id so the current + // capture reads as pre-selected. + browsePendingId_ = selectedId_; + lastBrowseClickCard_ = -1; + view_ = View::kBrowse; + invalidate(); + return; + } + if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; } + if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only + + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize); + + // Preview-trigger button: fire the loaded capture at its root through the voice engine + // (momentary — note-on on press, note-off on release). + if (contains(cr.preview, x, y)) { + const int note = effectiveRoot(); + if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_); + previewingNote_ = note; + processor_->previewNoteOn(note); + invalidate(); + return; + } + // Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself + // never jumps the value (FA4); the delta from the grab point maps via knobDragValue. + if (contains(cr.velCell, x, y)) { + drag_ = DragKind::kDeckKnob; + dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param) + dragParamZone_ = -1; + dragKnobStartValue_ = previewVelocity01(); + dragStartX_ = x; + dragStartY_ = y; + invalidate(); + return; + } + // The mini curve-preview button: summon the popup editor. + if (contains(cr.curveBtn, x, y)) { + curvePopupOpen_ = true; + invalidate(); + return; + } + // Channel toggle. + if (contains(chan.mono, x, y)) { + channelMode_ = ChannelMode::Mono; + processor_->setChannelMode(ChannelMode::Mono); + invalidate(); + return; + } + if (contains(chan.stereo, x, y)) { + channelMode_ = ChannelMode::Stereo; + processor_->setChannelMode(ChannelMode::Stereo); + invalidate(); + return; + } + + // The knob deck (r11): toggles commit at once (a discrete, final edit — the slider + // precedent); knobs start a grab-anchored vertical drag. The deck band swallows its + // clicks (no fall-through to the hero/markers). + if (contains(bands.deck, x, y)) { + const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y, + bands.deck.width); + const DeckHit hit = hitTestDeck(dl, x, y); + if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { + switch (static_cast(hit.id)) { + case ParamControl::kVoiceMode: { + // Processor-side per-instance param: live setter (engine rebuild via + // the drain-slot swap — tails survive), local snapshot in step. + const VoiceMode m = + (hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly; + if (m != voiceMode_) { + voiceMode_ = m; + processor_->setVoiceMode(m); + } + invalidate(); + break; + } + case ParamControl::kMonoTrigger: { + if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly + const MonoTrigger t = + (hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; + if (t != monoTrigger_) { + monoTrigger_ = t; + processor_->setMonoTrigger(t); + } + invalidate(); + break; + } + default: { + // Zone-param toggles (play mode / pitch engine / pitch-env enable): + // materialize the one-zone site, apply, commit. + const int zi = ensureSampleZone(); + if (zi >= 0) { + applyZoneControl(zi, hit.id, 0.0, hit.segment); + selectedZone_ = zi; + commitAndReload(); + } + break; + } + } + return; + } + if (hit.kind == DeckHitKind::Knob) { + // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off. + const bool pitchEnvKnob = + hit.id == static_cast(ParamControl::kPitchEnvAttack) || + hit.id == static_cast(ParamControl::kPitchEnvDecay) || + hit.id == static_cast(ParamControl::kPitchEnvDepth); + if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return; + if (hit.id == static_cast(ParamControl::kVoiceCount) || + hit.id == static_cast(ParamControl::kMasterGain)) { + // Processor-side knobs: transient live writes, no map edit, no reload. + drag_ = DragKind::kDeckKnob; + dragParamId_ = hit.id; + dragParamZone_ = -1; + dragKnobStartValue_ = deckControlNorm(hit.id, probeZone); + } else { + // Zone-param knobs: live-drag the map, commit on release. + const int zi = ensureSampleZone(); + if (zi < 0) return; + drag_ = DragKind::kDeckKnob; + dragParamId_ = hit.id; + dragParamZone_ = zi; + selectedZone_ = zi; + dragStartMap_ = map_; + dragKnobStartValue_ = + deckControlNorm(hit.id, map_.zones[static_cast(zi)]); + } + dragStartX_ = x; + dragStartY_ = y; + invalidate(); + } + return; + } + + // Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers. + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + const Rect waveArea = bands.hero; + if (frames > 0) { + const double rate = liveSampleRate(); + if (rate > 0.0) { + const PerformanceZone zone = effectiveSampleZone(); + const std::int64_t startFrame = zone.startPoint.value_or(0); + const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); + const double totalSeconds = static_cast(frames) / rate; + const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y); + if (nh.hit) { + drag_ = DragKind::kEnvNode; + envNode_ = nh.node; + dragStartX_ = x; + dragStartY_ = y; + dragStartEnv_ = env; + dragSampleFrames_ = frames; + dragStartFrame_ = startFrame; + dragStartMap_ = map_; + return; // node moves once the cursor drags + } + } + const SetupMarkers m = pickedMarkers(frames); + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y); + if (hit >= 0) { + drag_ = DragKind::kWaveMarker; + waveMarker_ = static_cast(hit); + dragStartX_ = x; + dragStartMarkers_ = m; + dragSampleFrames_ = frames; + dragStartMap_ = map_; + return; + } + } + + // Fenced root strip: grab the root marker (remainder-width since r11). + if (cr.rootStrip.width > 0) { + const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); + const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y); + if (note >= 0) { + drag_ = DragKind::kRootMarker; + dragStartX_ = x; + dragStartRoot_ = note; + dragStartMap_ = map_; + onMouseMove(x, y); // apply the click as the first delta==0 set + return; + } + } + return; + } + + // ---- Zone surface (S-VIEW-8 / FB2): the face branch lives in editor_input_browse_zone ---- + mouseDownZone(w, h, x, y); +} + +void ReaSamplerEditor::onMouseMove(int x, int y) { + if (drag_ == DragKind::kNone) return; + dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn) + dragCurY_ = y; + RECT rc{}; + GetClientRect(childHwnd_, &rc); + const int w = rc.right - rc.left; + const int h = rc.bottom - rc.top; + const int dx = x - dragStartX_; + + if (drag_ == DragKind::kDeckKnob) { + // r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from + // the value at grab (up = increase), so the value tracks relative motion and never + // jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP. + const int dy = y - dragStartY_; + applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy)); + invalidate(); + return; + } + + // r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted + // below the kDeckKnob early-return — that branch uses neither deckDescs nor bands. + const std::vector deckDescs = deckGroupDescs(effectiveSampleZone().play); + const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); + + if (drag_ == DragKind::kRootMarker) { + // The fenced root strip on the Sample cluster band. Setting the root materializes a + // full-keyboard zone carrying the override on the picked id (the D-B override vehicle) — + // upsert by id so a repeated drag edits the same zone rather than stacking duplicates. + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const Rect stripArea = clusterRects(bands.cluster, chan.mono, kDeckKnobSize).rootStrip; + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + const int note = resolveDragNote(sl, dragStartRoot_, dx); + bool found = false; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == selectedId_) { + z.rootOverride = note; + selectedZone_ = i; + found = true; + break; + } + } + if (!found) { + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.rootOverride = note; + map_.zones.push_back(z); + selectedZone_ = static_cast(map_.zones.size()) - 1; + } + invalidate(); // live feedback; the commit lands on WM_LBUTTONUP + return; + } + + if (drag_ == DragKind::kEnvNode) { + // S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through + // the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the + // picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_) + // so the delta is absolute. Materialize the zone if needed (mirror of the marker path). + const std::int64_t frames = dragSampleFrames_; + const double rate = liveSampleRate(); + if (frames <= 0 || rate <= 0.0) return; + const double totalSeconds = static_cast(frames) / rate; + const int dy = y - dragStartY_; + const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero, + totalSeconds, envClampBounds(), dx, dy); + const int zi = ensureSampleZone(); + if (zi >= 0) { + unpackEnvelope(edited, frames, dragStartFrame_, + map_.zones[static_cast(zi)].play); + selectedZone_ = zi; + } + invalidate(); // live feedback; commit on WM_LBUTTONUP + return; + } + + if (drag_ == DragKind::kCurveNode) { + // S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure + // inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + + // box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the + // commit lands on WM_LBUTTONUP. + if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast(map_.zones.size())) return; + if (curvePointIndex_ < 0) return; + const int dy = y - dragStartY_; + map_.zones[static_cast(dragCurveZone_)].velocityCurve = + VelocityCurve::resolvePointDrag(dragStartCurve_, + static_cast(curvePointIndex_), + curveBoxFromRect(dragCurveRect_), dx, dy); + invalidate(); + return; + } + + if (drag_ == DragKind::kWaveMarker) { + // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap + // it against the decoded PCM, apply the inter-marker clamps, and write the override live. + const Rect waveArea = bands.hero; + const std::int64_t frames = dragSampleFrames_; + if (frames <= 0) return; + + // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab). + const int idx = static_cast(waveMarker_); + const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, + dragStartMarkers_.loopEnd}; + std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx); + + // Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware + // requirement). Pure over the cached mono frames — no host types, no file I/O. + const std::vector& pcm = monoPcmFor(selectedId_); + if (!pcm.empty()) { + newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), + newFrame); + } + + // Build the edited marker set from the snapshot, moving only the grabbed marker, then + // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop. + SetupMarkers m = dragStartMarkers_; + if (waveMarker_ == WaveMarker::kStart) { + m.start = newFrame; + } else if (waveMarker_ == WaveMarker::kLoopStart) { + m.loopStart = (std::min)(newFrame, m.loopEnd); + m.hasLoop = true; + } else { // kLoopEnd + m.loopEnd = (std::max)(newFrame, m.loopStart); + m.hasLoop = true; + } + if (m.start < 0) m.start = 0; + if (m.start > frames - 1) m.start = frames - 1; + + // Upsert the override on the picked id (mirror of the root-marker path); commit lands on + // release, this is live feedback. Set selectedZone_ so the control panel stays visible + // after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty + // map hides controls after the first marker drag on the single-capture face). + selectedZone_ = upsertPickedOverride(m); + invalidate(); + return; + } + + if (drag_ == DragKind::kScrollThumb) { + // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag + // only happens in the Browse modal (the sole card grid). The visible-card window recomputes + // at paint from scrollOffset_. + const int dyThumb = y - dragStartY_; + const BrowseModal bm = computeBrowseModal(w, h); + const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); + scrollOffset_ = thumbDragToOffset(bl, static_cast(visible_.size()), + dragStartScrollOffset_, dyThumb); + invalidate(); + return; + } + + // Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached + // in the Zone surface where selectedZone_ is set + the strip lives under its content area. + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + const Rect stripArea = zonesStripArea(zoneContentArea(w, h)); + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (drag_ == DragKind::kZoneLow) { + z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote); + } else if (drag_ == DragKind::kZoneHigh) { + z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote); + } else if (drag_ == DragKind::kZoneBody) { + // Move the whole span: apply the SAME delta to both edges so the span is preserved, + // clamping so neither edge escapes [0,127] (the span shifts, never shrinks). + const int newLow = resolveDragNote(sl, dragStartLow_, dx); + const int newHigh = resolveDragNote(sl, dragStartHigh_, dx); + const int span = dragStartHigh_ - dragStartLow_; + if (newLow < 0) { z.lowNote = 0; z.highNote = span; } + else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; } + else { z.lowNote = newLow; z.highNote = newHigh; } + } + invalidate(); +} + +void ReaSamplerEditor::onMouseUp(int x, int y) { + // Release a held preview note first (the preview button is a momentary key: note-off on up). + // This runs regardless of drag state — the preview press does not start a drag. + if (previewingNote_ >= 0) { + if (processor_) processor_->previewNoteOff(previewingNote_); + previewingNote_ = -1; + invalidate(); + } + if (drag_ == DragKind::kNone) return; + const DragKind kind = drag_; + const int paramId = dragParamId_; + const int curveIdx = curvePointIndex_; + const int curveZone = dragCurveZone_; + const Rect curveRect = dragCurveRect_; + drag_ = DragKind::kNone; + dragParamId_ = -1; + dragParamZone_ = -1; + curvePointIndex_ = -1; + dragCurveZone_ = -1; + // A scrollbar drag is transient UI (no map change), and the processor-side knobs (the + // preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that + // don't reload the instrument via the map path. Master gain is an atomic the audio thread + // reads directly. Voice count: the label/needle tracks live during the drag but the engine + // rebuild (setVoiceCount) fires ONCE here on release — not per integer step. + const bool deckTransient = + kind == DragKind::kDeckKnob && + (paramId == -2 || paramId == static_cast(ParamControl::kVoiceCount) || + paramId == static_cast(ParamControl::kMasterGain)); + if (kind == DragKind::kScrollThumb || deckTransient) { + // Commit the voice count now that the drag is complete (one rebuild per full drag). + if (deckTransient && processor_ && + paramId == static_cast(ParamControl::kVoiceCount)) + processor_->setVoiceCount(voiceCount_); + invalidate(); + return; + } + // S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the + // dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain + // move — its amp keeps the last clamped drag value). + if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 && + curveZone < static_cast(map_.zones.size())) { + const bool off = x < curveRect.x - kCurveDragOffMargin || + x > curveRect.right() + kCurveDragOffMargin || + y < curveRect.y - kCurveDragOffMargin || + y > curveRect.bottom() + kCurveDragOffMargin; + if (off) { + map_.zones[static_cast(curveZone)].velocityCurve.deletePoint( + static_cast(curveIdx)); + hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint + } + } + commitAndReload(); +} + +void ReaSamplerEditor::onMouseRDown(int x, int y) { + // r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete + // affordance; Alt-click and drag-off remain as landed alternates. Commits immediately + // through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint + // right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the + // Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) — + // and never during an in-flight left drag. + if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return; + if (drag_ != DragKind::kNone) return; + RECT rc{}; + GetClientRect(childHwnd_, &rc); + const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top); + if (!contains(pl.curveBox, x, y)) return; + // Hit-test first (read-only, via popupZone) so a right-click that lands between nodes + // does not materialize an uncommitted zone in map_. Materialize only on an actual hit. + const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox); + const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y); + if (idx < 0) return; + const int zi = popupZoneIndex(); + if (zi < 0) return; + PerformanceZone& z = map_.zones[static_cast(zi)]; + if (z.velocityCurve.deletePoint(static_cast(idx))) { + selectedZone_ = zi; + hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node + commitAndReload(); + } +} + +} // namespace reasampler::vst + +#endif // _WIN32 diff --git a/src/shell/instrument/editor_internal.h b/src/shell/instrument/editor_internal.h new file mode 100644 index 0000000..438eadf --- /dev/null +++ b/src/shell/instrument/editor_internal.h @@ -0,0 +1,239 @@ +// editor_internal.h — INTERNAL shared helpers for the ReaSamplerEditor TU family +// (Q-W2v: the eight face-axis TUs split out of the former reasampler_editor.cpp). +// Included ONLY by the editor's own shell TUs (editor_session / editor_controls / +// editor_paint_* / editor_input_* / editor_platform) — never a public seam. Holds the +// former god-TU's anonymous-namespace helpers that more than one split TU needs: the +// Rect<->kit adapters, the small draw primitives (knob face / spectral strip / root +// marker / title band), the label helpers, the deck group ids, and the velocity-curve +// box derivation. All inline; behavior-identical to the pre-split definitions. + +#pragma once + +#include +#include +#include +#include + +#include "core/instrument/engine/velocity_curve.h" // VelocityCurve::Box (curveBoxFromRect) +#include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel) +#include "core/instrument/ui/editor_geometry.h" // Rect (the shared sub-rect type) + +#ifdef _WIN32 +#include "wdltypes.h" +#include "lice/lice.h" + +#include "core/audio/peaks.h" // Envelope (drawEnvelope) +#include "core/instrument/ui/capture_browser.h" // BrowserLayout / cardThumbnailRect (thumbBins) +#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace, FA4) +#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / isNaturalKey (spectral strip) +#include "core/ui/component_geometry.h" // KitBox / waveformColumnCount +#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor +#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text/drawWaveform/toLice +#endif + +namespace reasampler::vst { + +// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck +// order. Shared by the deck-desc builders (editor_controls) and the deck painter. +enum DeckGroup { + kGroupAmpEnv = 0, + kGroupPitch, + kGroupPitchEnv, + kGroupVoice, + kGroupMaster, +}; + +// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the +// curve in the POPUP (curve_popup), each summoned from its own mini preview button. The +// INSET keeps node handles + the pick radius inside the border so an endpoint at amp 0/1 +// stays grabbable — the ONE curveBoxFromRect grammar the popup derives its mapping box +// through. Drag-off: release beyond box+margin deletes the dragged node. +inline constexpr int kVelCurveInset = 14; +inline constexpr int kCurveDragOffMargin = 24; + +// The pure-module mapping Box for a drawn curve rect: inset from the border so node +// handles and the pick radius stay inside the box. Every consumer (paint, hit-test, add, +// drag) derives the Box through this ONE formula, so drawn nodes and grabs never drift. +inline instrument::engine::VelocityCurve::Box curveBoxFromRect( + const instrument::ui::Rect& r) { + return instrument::engine::VelocityCurve::Box{ + r.x + kVelCurveInset, r.y + kVelCurveInset, + (std::max)(0, r.width - 2 * kVelCurveInset), + (std::max)(0, r.height - 2 * kVelCurveInset)}; +} + +// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the +// common DAW convention REAPER uses). +inline std::string noteLabel(int note) { + static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"}; + if (note < 0) note = 0; + if (note > 127) note = 127; + const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4 + return std::string(kNames[note % 12]) + std::to_string(octave); +} + +// A display name for a bank sample id: the snapshotted bank list first, then the +// instance-OWNED ref's displayName (pS — the label survives with the extension absent / +// bank unreadable). "?" only when neither source knows the id. +inline std::string sampleLabel(const std::vector& samples, + const instrument::map::SampleRefs& refs, + const std::string& id) { + for (const instrument::map::SampleChoice& c : samples) { + if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; + } + for (const instrument::map::SampleRefEntry& e : refs) { + if (e.sampleId == id && !e.displayName.empty()) return e.displayName; + } + return "?"; +} + +#ifdef _WIN32 + +// --- Rect <-> kit adapters (Phase L, L3) ------------------------------------- +// +// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against +// `KitBox` (component_geometry). This is the single boundary that bridges them so every +// draw routes through the L1 kit (theme roles + draw_kit). +inline ui::KitBox toKitBox(const instrument::ui::Rect& r) { + return ui::KitBox{r.x, r.y, r.width, r.height}; +} + +// Kit text in a palette ROLE (the common case). Left/Right/Center via Align. +inline void kitText(LICE_IBitmap* bmp, const instrument::ui::Rect& r, const char* s, + Font font, ui::Role role, Align align = Align::Left) { + text(bmp, toKitBox(r), s, font, role, align); +} + +inline void kitTextCentered(LICE_IBitmap* bmp, const instrument::ui::Rect& r, + const char* s, Font font, ui::Role role) { + text(bmp, toKitBox(r), s, font, role, Align::Center); +} + +// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3). +inline void drawEnvelope(LICE_IBitmap* bmp, const instrument::ui::Rect& r, + const audio::Envelope& env) { + drawWaveform(bmp, toKitBox(r), env); +} + +// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the +// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins. +// thumbnailFor clamps the request to the decoded frame count. +inline int thumbBins(const instrument::ui::BrowserLayout& layout) { + return (std::max)(1, kWaveformOversample * + ui::waveformColumnCount(toKitBox( + instrument::ui::cardThumbnailRect(layout, 0)))); +} + +// Draw the title band with the live readout. Shared by the Sample face (nav visible) — +// Browse/Zone draw their own back button in place of the nav. +inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title, + const std::string& readout) { + fillSurface(bmp, toKitBox(title), ui::Role::BgPanel, ui::InteractionState::Rest); + instrument::ui::Rect titleText = + instrument::ui::Rect::ltrb(title.x + 8, title.y, title.right() - 8, title.bottom()); + kitText(bmp, titleText, readout.c_str(), Font::Title, ui::Role::TextPrimary); +} + +// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle +// map; this turns it into LICE calls through the kit's palette roles. LICE's arc +// convention matches param_slider's (angle 0 = 12 o'clock, positive clockwise) — but LICE +// takes RADIANS, and drawing the 7->5 o'clock sweep THROUGH the top needs a continuous +// angle span, so the degrees convert as (deg - 360) * pi/180, mapping 210..510 onto +// -150..+150 degrees. One conversion, both arcs. +inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect, + double value01, ui::InteractionState st) { + using instrument::ui::KnobArc; + using instrument::ui::KnobGeometry; + using instrument::ui::KnobPoint; + const KnobGeometry kg = instrument::ui::computeKnob(knobRect); + if (kg.radius <= 1.0) return; + constexpr double kDegToRad = 3.14159265358979323846 / 180.0; + const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep + const float cx = static_cast(kg.centerX); + const float cy = static_cast(kg.centerY); + const float rOuter = static_cast(kg.radius) - 0.5f; + const bool disabled = (st == ui::InteractionState::Disabled); + const bool hot = (st == ui::InteractionState::Dragging || st == ui::InteractionState::Hover); + + // Face: a filled circle in the cell surface color under the interaction state. + LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(ui::roleColorState(ui::Role::BgCell, st)), + 1.0f, 0, true); + // Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare). + const float a0 = static_cast((arc.startDeg - 360.0) * kDegToRad); + const float a1 = static_cast( + (arc.startDeg + instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad); + LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(ui::roleColor(ui::Role::LineHairline)), 1.0f, 0, + true); + // Value arc: start -> the value's angle, in the live accent (hot while under the pointer / + // dragging, dim when disabled). + const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01); + if (v > 0.0) { + const float av = static_cast( + (arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad); + const ui::Role valueRole = disabled ? ui::Role::TextDim + : (hot ? ui::Role::AccentHot : ui::Role::AccentPrimary); + LICE_Arc(bmp, cx, cy, rOuter, a0, av, toLice(ui::roleColor(valueRole)), 1.0f, 0, true); + } + // Needle: from ~35% radius out to the rim at the value's angle. + const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v); + const float ix = cx + static_cast((tip.x - kg.centerX) * 0.35); + const float iy = cy + static_cast((tip.y - kg.centerY) * 0.35); + const ui::Role needleRole = disabled ? ui::Role::TextDim : ui::Role::TextPrimary; + LICE_Line(bmp, static_cast(ix + 0.5f), static_cast(iy + 0.5f), + static_cast(tip.x + 0.5f), static_cast(tip.y + 0.5f), + toLice(ui::roleColor(needleRole)), 1.0f, 0, true); +} + +// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature +// surface. Fills each MIDI key column with its spectral hue, then draws faint per-octave +// hairline ticks. Shared by the setup face + the Zones strip so both read as the same +// spectrum. S-VIEW-7: accidentals get a dark bg/base wash over the hue (an OVERLAY, not +// a keyboard shape) so pitch position reads as a keyboard at a glance. +inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) { + using instrument::ui::StripLayout; + if (stripArea.width <= 0 || stripArea.height <= 0) return; + const StripLayout sl = instrument::ui::layoutStrip(stripArea.width, stripArea.height); + const int sx = stripArea.x; + const int sy = stripArea.y; + const int h = stripArea.height; + const LICE_pixel darkKey = toLice(ui::roleColor(ui::Role::BgBase)); + for (int n = 0; n <= 127; ++n) { + const instrument::ui::Rect k = instrument::ui::keyRect(sl, n); + const int x0 = k.x + sx; + const int x1 = + (n < 127) ? instrument::ui::keyRect(sl, n + 1).x + sx : stripArea.right(); + const int cw = (std::max)(1, x1 - x0); + const ui::KitColor hue = ui::spectralColor(static_cast(n) / 127.0); + LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0); + if (!instrument::ui::isNaturalKey(n)) { + LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0); + } + } + // Faint per-octave key ticks (hairline role) for orientation. + const LICE_pixel tick = toLice(ui::roleColor(ui::Role::LineHairline)); + for (int n = 0; n <= 127; n += 12) { + const instrument::ui::Rect k = instrument::ui::keyRect(sl, n); + LICE_Line(bmp, k.x + sx, sy, k.x + sx, sy + h, tick, 1.0f, 0, false); + } +} + +// Draw the single-capture root marker on the strip: an accent-primary bar with a soft +// STATIC glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. +inline void drawRootMarker(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea, + const instrument::ui::StripLayout& sl, int root) { + const int sx = stripArea.x; + const int sy = stripArea.y; + const int h = stripArea.height; + const instrument::ui::Rect marker = instrument::ui::rootMarkerRect(sl, root); + const int mw = (std::max)(2, marker.width); + const LICE_pixel accent = toLice(ui::roleColor(ui::Role::AccentPrimary)); + const LICE_pixel glow = toLice(ui::roleColor(ui::Role::AccentHot)); + // Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse). + LICE_FillRect(bmp, marker.x + sx - 3, sy, mw + 6, h, glow, 0.30f, 0); + LICE_FillRect(bmp, marker.x + sx, sy, mw, h, accent, 1.0f, 0); +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_paint_browse_zone.cpp b/src/shell/instrument/editor_paint_browse_zone.cpp new file mode 100644 index 0000000..60c5d82 --- /dev/null +++ b/src/shell/instrument/editor_paint_browse_zone.cpp @@ -0,0 +1,277 @@ +// editor_paint_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE +// painting (Q-W2v split of reasampler_editor.cpp, T4-11): the full-window select-then- +// confirm picker (S-VIEW-5 — wash, search box, filter tabs, card grid, scrollbar, +// footer) and the Zone keymap surface (S-VIEW-8/FB2 — add/delete, the spectral zones +// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only +// (D5). Shares the Sample face's painters (title band / empty state / deck / curve +// button / popup) via the class + editor_internal.h. + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include + +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12) +#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout (FB2) +#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::ui; // kit vocabulary +using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface geometry +using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs + +void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { + // A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying + // Sample face with a bg/base wash, then draw the picker opaque on top. + LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0); + const BrowseModal bm = computeBrowseModal(w, h); + + // Title band + Back button (returns to Sample, discarding any pending pick). + drawTitleBand(bmp, bm.title, "Browse - pick a capture"); + { + const KitButtonBox box{toKitBox(bm.back)}; + const InteractionState st = + isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Back", st, /*warn=*/false); + } + + // Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover. + const Rect searchAbs = bm.search; + const InteractionState searchState = + searchFocused_ ? InteractionState::Focus + : (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover + : InteractionState::Rest); + fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState); + if (searchFocused_) { + LICE_DrawRect(bmp, searchAbs.x, searchAbs.y, searchAbs.width - 1, + searchAbs.height - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0); + } + { + std::string sb = searchQuery_.empty() + ? std::string("Search captures...") + : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); + Rect sbText = Rect::ltrb(searchAbs.x + 6, searchAbs.y, searchAbs.right() - 6, searchAbs.bottom()); + kitText(bmp, sbText, sb.c_str(), Font::Label, + searchQuery_.empty() ? Role::TextDim : Role::TextPrimary); + } + + // Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset). + const Rect browserArea = bm.content; + const BrowserLayout bl = layoutBrowser(browserArea.width, browserArea.height); + const int ox = browserArea.x; + const int oy = browserArea.y; + scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); + + const int tabCount = static_cast(banks_.size()) + 1; + for (int i = 0; i < tabCount; ++i) { + Rect t = filterTabRect(bl, tabCount, i); + t = Rect::ltrb(t.x + ox, t.y + oy, t.right() + ox, t.bottom() + oy); + const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; + const bool active = (i == 0) ? activeFilterBankId_.empty() + : (banks_[static_cast(i - 1)].id == activeFilterBankId_); + const InteractionState state = + active ? InteractionState::Active + : (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover + : InteractionState::Rest); + fillSurface(bmp, toKitBox(t), Role::BgCell, state); + kitTextCentered(bmp, t, label.c_str(), Font::Label, + active ? Role::BgBase : Role::TextPrimary); + } + + // Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_) + // is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border. + const int bins = thumbBins(bl); + const int cardCount = static_cast(visible_.size()); + const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); + for (int i = vr.first; i < vr.last; ++i) { + Rect content = cardContentRect(bl, i); + Rect thumb = cardThumbnailRect(bl, i); + Rect labelR = cardLabelRect(bl, i); + content = Rect::ltrb(content.x + ox, content.y + oy - scrollOffset_, + content.right() + ox, content.bottom() + oy - scrollOffset_); + thumb = Rect::ltrb(thumb.x + ox, thumb.y + oy - scrollOffset_, + thumb.right() + ox, thumb.bottom() + oy - scrollOffset_); + labelR = Rect::ltrb(labelR.x + ox, labelR.y + oy - scrollOffset_, + labelR.right() + ox, labelR.bottom() + oy - scrollOffset_); + + const SampleChoice& s = visible_[static_cast(i)]; + const bool pending = (s.id == browsePendingId_); + const bool loaded = (s.id == selectedId_); + const InteractionState cardState = + isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest; + fillSurface(bmp, toKitBox(content), Role::BgCell, cardState); + const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary) + : (loaded ? roleColor(Role::AccentTertiary) + : roleColor(Role::LineHairline)); + LICE_DrawRect(bmp, content.x, content.y, content.width - 1, content.height - 1, + toLice(cardBorder), 1.0f, 0); + drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); + + std::string caption = s.displayName.empty() ? s.id : s.displayName; + Rect nameR = Rect::ltrb(labelR.x + 3, labelR.y, labelR.right() - 3, labelR.y + labelR.height / 2); + Rect badgeR = Rect::ltrb(labelR.x + 3, nameR.bottom(), labelR.right() - 3, labelR.bottom()); + kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary); + std::string badge; + if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); + else if (s.key) badge = *s.key; + else badge = "root -"; + kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim); + } + + // Scrollbar thumb. + { + const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); + if (thumb.height > 0) { + const bool dragging = (drag_ == DragKind::kScrollThumb); + const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary); + LICE_FillRect(bmp, thumb.x + ox, thumb.y + oy, thumb.width, thumb.height, + toLice(tc), 0.8f, 0); + } + } + + if (visible_.empty()) paintEmptyState(bmp, browserArea); + + // Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert + // (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar. + Rect footer = Rect::ltrb(0, bm.content.bottom(), w, h); + fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest); + { + const KitButtonBox box{toKitBox(bm.cancel)}; + const InteractionState st = + isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Cancel", st, /*warn=*/false); + } + { + const KitButtonBox box{toKitBox(bm.confirm)}; + const bool armed = !browsePendingId_.empty(); + const InteractionState st = armed + ? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active) + : InteractionState::Rest; + drawButton(bmp, box, "Load", st, /*warn=*/false); + } +} + +void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { + // Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns + // to the Sample home on close. + const Rect title = Rect::ltrb(0, 0, w, (std::min)(kTitleHeight, h)); + drawTitleBand(bmp, title, "Zone - keyboard map"); + { + const Rect back = zoneBackRect(w, h); + const KitButtonBox box{toKitBox(back)}; + const InteractionState st = + isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Back", st, /*warn=*/false); + } + + const Rect content = zoneContentArea(w, h); + + // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip + // with one bar per zone. Delete is a small × on the selected zone (keystroke also). + Rect addR = zoneAddRect(content); + { + const KitButtonBox box{toKitBox(addR)}; + const InteractionState state = + isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false); + } + + Rect delR = zoneDeleteRect(addR); + if (selectedZone_ >= 0) { + const KitButtonBox box{toKitBox(delR)}; + const InteractionState state = + isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest; + // Deleting a zone is not a byte-destroying act (no file removed — the bank is + // read-only here), so it is a normal button, not `warn`. + drawButton(bmp, box, "Delete", state, /*warn=*/false); + } + + // The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per + // zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which + // zone is live"); the rest take the categorical secondary hue at low alpha. + const Rect stripArea = zonesStripArea(content); + drawSpectralStrip(bmp, stripArea); + const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); + const int sx = stripArea.x; + const int sy = stripArea.y; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + Rect bar = zoneBarRect(sl, z.lowNote, z.highNote); + const int bw = (std::max)(2, bar.width); + const bool sel = (i == selectedZone_); + if (sel) { + // Static glow halo behind the live zone, then the crisp accent-primary bar. + LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height, + toLice(roleColor(Role::AccentHot)), 0.30f, 0); + LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height, + toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); + } else { + LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height, + toLice(roleColor(Role::AccentSecondary)), 0.55f, 0); + } + } + + // A one-line legend of the selected zone below the strip, with three click-to-type numeric + // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it + // (entryField_) and typed text commits via parseNoteEntry on Enter. + const int legendTop = stripArea.bottom() + 8; + Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18); + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()), + sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{}, + z.sampleId) + .c_str(), + Font::Label, Role::TextPrimary); + // Three fields laid out left-to-right after the sample label. A focused field lifts to + // the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter. + const Rect fields = noteEntryFieldsArea(content); + const char* names[3] = {"Low", "High", "Root"}; + const std::string vals[3] = { + noteLabel(z.lowNote), noteLabel(z.highNote), + z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")}; + for (int f = 0; f < 3; ++f) { + const Rect fr = noteEntryFieldRect(fields, f); + const bool editing = (entryField_ == f); + fillSurface(bmp, toKitBox(fr), Role::BgCell, + editing ? InteractionState::Focus : InteractionState::Rest); + const KitColor border = + editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1, + toLice(border), 1.0f, 0); + std::string cap = std::string(names[f]) + ": " + + (editing ? (entryText_ + "_") : vals[f]); + kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(), + Font::ValueMono, Role::TextPrimary); + } + } else if (map_.zones.empty()) { + kitText(bmp, infoR, + "No zones. Add Zone maps the picked capture across the keyboard.", + Font::Label, Role::TextDim); + } + + // The per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck + + // curve-preview-button/popup grammar as the Sample face — one control language over the one + // storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only + // the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live + // on the Sample deck only. + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play)); + paintCurveButton(bmp, zonesCurveButton(content), z); + } + + // The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST — + // the same modal grammar as the Sample face. + if (curvePopupOpen_) paintCurvePopup(bmp, w, h); +} + +} // namespace reasampler::vst + +#endif // _WIN32 diff --git a/src/shell/instrument/editor_paint_sample.cpp b/src/shell/instrument/editor_paint_sample.cpp new file mode 100644 index 0000000..6194f9d --- /dev/null +++ b/src/shell/instrument/editor_paint_sample.cpp @@ -0,0 +1,518 @@ +// editor_paint_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE painting (Q-W2v split of +// reasampler_editor.cpp, T4-11): the WM_PAINT dispatch, the r11 Sample home face (title +// band + elastic hero waveform + root/preview cluster + bottom-anchored knob deck), the +// S-VIEW-3 envelope overlay, the velocity-curve editor + mini preview button + popup +// sheet (shared painters the Zone surface reuses, FB2), and the empty state. Windows-only +// (D5); draws through the L1 kit by palette role. All layout math is pure +// (editor_geometry / knob_deck / curve_popup) — this TU only draws. + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include +#include + +#include "core/audio/peaks.h" // computeEnvelope (hero waveform binning) +#include "core/instrument/ui/curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) +#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize +#include "core/instrument/ui/waveform_view.h" // frameToX (S11 markers) +#include "core/version/app_version.h" // vstPluginName (channel-derived title band, S18) +#include "shell/instrument/editor_internal.h" // kit adapters + knob face/spectral strip/root marker +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …) +using namespace reasampler::instrument::ui; // pure geometry (bands / cluster / deck / popup / strip) +using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback) +using audio::computeEnvelope; + +namespace { +// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette: start = teal +// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple. +constexpr Role kRoleStartMarker = Role::AccentSecondary; +constexpr Role kRoleLoopMarker = Role::AccentTertiary; +} // namespace + +void ReaSamplerEditor::paint(HDC hdc) { + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + if (w <= 0 || h <= 0) return; + + LICE_SysBitmap bmp(w, h); + LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); + + // S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn + // OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the + // modal reads as a sheet layered over the home face (the "picker over the document" grammar). + if (view_ == View::kZone) { + paintZone(&bmp, w, h); + } else { + paintSample(&bmp, w, h); + if (view_ == View::kBrowse) paintBrowse(&bmp, w, h); + } + + // S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window. + // It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn + // LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_). + if (dropHintTicks_ > 0) { + const int bannerTop = (std::min)(kTitleHeight, h); + const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop)); + Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH); + // A transient notice, not the live layer — draw it on the accent-tertiary categorical + // hue with a dark label so it reads as "attention, not action". + fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest); + kitTextCentered(&bmp, banner, + "Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.", + Font::Label, Role::BgBase); + } + + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); +} + +void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { + // r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP + // ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it). + const PerformanceZone deckZone = effectiveSampleZone(); + const std::vector deckDescs = deckGroupDescs(deckZone.play); + const SampleBands bands = + computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); + + // Title: product name + live readout. Standard B palette — the beta channel gets NO distinct + // accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable + // signal. + std::string title = version::vstPluginName(); // channel-derived (S18) + if (processor_ && processor_->bridge().isConnected()) { + // The instance's OWN loaded state outranks bank availability (pS: the bank is a + // browser source, not the instrument's identity) — a self-contained instance names + // its sound (refs displayName fallback) even when the bank snapshot is empty. + if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; + else if (!selectedId_.empty()) + title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]"; + else if (samples_.empty()) title += " [bank empty]"; + else title += " [pick a capture]"; + } else { + title += " [host: no bridge]"; + } + drawTitleBand(bmp, bands.title, title); + + // Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap + // surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw + // it Active (accent-primary) so it reads as "start here". + const bool empty = selectedId_.empty() && map_.zones.empty(); + { + const KitButtonBox box{toKitBox(bands.navBrowse)}; + const InteractionState st = empty ? InteractionState::Active + : (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest); + drawButton(bmp, box, "Browse", st, /*warn=*/false); + } + { + const KitButtonBox box{toKitBox(bands.navZone)}; + const InteractionState st = + isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Zone", st, /*warn=*/false); + } + + // Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing + // at Browse (which is lit above). No hero waveform / controls to draw. + if (empty) { + Rect body = Rect::ltrb(bands.hero.x, bands.hero.y, bands.hero.right(), bands.deck.bottom()); + paintEmptyState(bmp, body); + return; + } + + // Resolve the effective single-capture zone: the picked id's one-zone override when present, + // else the product-default play params (S15-F2 — the single capture is a one-zone map). This + // is the ONE storage site both Sample and Zone edit. + const PerformanceZone& zone = deckZone; + + // --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay ----------- + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + const Rect waveArea = bands.hero; + fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest); + if (frames > 0 && waveArea.width > 0) { + // FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this + // multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact + // partition — extra bins produce no visible change. Clamped to frame count below. + const std::int64_t wantBins = + static_cast((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) * + kWaveformOversample; + const std::size_t bins = + static_cast(wantBins < frames ? wantBins : frames); + const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins); + drawEnvelope(bmp, waveArea, env); + + const SetupMarkers m = pickedMarkers(frames); + if (m.hasLoop && m.loopEnd > m.loopStart) { + const int lx = frameToX(waveArea, frames, m.loopStart); + const int rx = frameToX(waveArea, frames, m.loopEnd); + if (rx > lx) { + LICE_FillRect(bmp, lx, waveArea.y, rx - lx, waveArea.height, + toLice(roleColor(kRoleLoopMarker)), 0.20f, 0); + } + } + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; + for (int i = 0; i < 3; ++i) { + const int mx = frameToX(waveArea, frames, markerFrames[i]); + const bool loopMarker = (i != 0); + const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; + LICE_FillRect(bmp, mx - 1, waveArea.y, 2, waveArea.height, + toLice(roleColor(markerRoles[i])), alpha, 0); + } + + // S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero. + paintEnvelopeOverlay(bmp, waveArea, zone, frames); + } else { + kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim); + } + + // --- Root + preview cluster (r11: remainder-width root strip, preview button, radial + // velocity knob, mini curve-preview button, channel toggle) ----------------------------- + fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest); + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize); + int root = effectiveRoot(); + if (cr.rootStrip.width > 0) { + drawSpectralStrip(bmp, cr.rootStrip); + const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); + drawRootMarker(bmp, cr.rootStrip, sl, root); + } + + // Preview-trigger button (fires the loaded capture at root through the live voice engine). + { + const KitButtonBox box{toKitBox(cr.preview)}; + const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active + : (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest); + drawButton(bmp, box, "Preview", st, /*warn=*/false); + } + // Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same + // persisted previewVelocity seam. Label swaps to the live value during hover/drag. + { + const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2); + const bool hov = isHovered(HoverKind::kVelKnob, -1); + const InteractionState st = dragging ? InteractionState::Dragging + : (hov ? InteractionState::Hover + : InteractionState::Rest); + drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st); + if (dragging || hov) { + char buf[8]; + snprintf(buf, sizeof(buf), "%d", + static_cast(previewVelocity01() * 127.0 + 0.5)); + kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim); + } else { + kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim); + } + } + // The mini curve-preview button (r11): opens the popup editor. Shared painter with the + // Zone panel's button (FB2 — one grammar on both surfaces). + paintCurveButton(bmp, cr.curveBtn, zone); + // Mono | Stereo output-mode toggle. + { + const bool isStereo = (channelMode_ == ChannelMode::Stereo); + const InteractionState monoState = !isStereo ? InteractionState::Active + : (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest); + const InteractionState stereoState = isStereo ? InteractionState::Active + : (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover : InteractionState::Rest); + fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState); + fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState); + kitTextCentered(bmp, chan.mono, "Mono", Font::Label, !isStereo ? Role::BgBase : Role::TextPrimary); + kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary); + } + + // --- The knob deck (r11: the fenced control groups, bottom-anchored) ------------------- + paintKnobDeck(bmp, bands.deck, zone, deckDescs); + + // --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ---- + if (curvePopupOpen_) paintCurvePopup(bmp, w, h); +} + +void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, + const PerformanceZone& zone, std::int64_t frames) { + if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return; + const double rate = liveSampleRate(); + if (rate <= 0.0) return; + const double totalSeconds = static_cast(frames) / rate; + const std::int64_t startFrame = zone.startPoint.value_or(0); + const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); + const std::vector poly = buildEnvelopePolyline(env, waveArea, totalSeconds); + + // Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct + // curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right). + const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); + for (std::size_t i = 1; i < poly.size(); ++i) { + const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x)); + const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x)); + LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true); + } + // Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw- + // only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is + // in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd + // at area.right()-1 MUST get handles); the handle SQUARE is additionally clamped inside the + // hero rect so a 6px box on an edge node never overhangs into the neighbouring bands. + const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); + const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); + for (const EnvVertex& v : poly) { + if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue; + const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); + const int r = 3; + const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x)); + const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y)); + LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0); + } +} + +void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, + const PerformanceZone& zone) { + if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect) + + // The bordered box: a panel surface + hairline border, drawn by palette role. No corner + // caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the + // popup is the only host). + fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest); + LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + + const VelocityCurve::Box box = curveBoxFromRect(r); + if (box.width <= 0 || box.height <= 1) return; + const VelocityCurve& curve = zone.velocityCurve; + + // Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical + // secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity + // and amp -> y mappings both go through the pure module so the trace, the node handles, and + // the hit-test all share one coordinate system. + const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); + int prevX = 0, prevY = 0; + for (int px = 0; px <= box.width; ++px) { + const int cx = box.left + px; + const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity; + const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y; + if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true); + prevX = cx; + prevY = cy; + } + + // Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted + // to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor + // has passed kCurveDragOffMargin outside the box — release will delete the node). + const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); + const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); + const LICE_pixel handleWarn = toLice(roleColor(Role::Warn)); + // Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin? + const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x && + dragCurveRect_.y == r.y) && + (dragCurX_ < r.x - kCurveDragOffMargin || + dragCurX_ > r.right() + kCurveDragOffMargin || + dragCurY_ < r.y - kCurveDragOffMargin || + dragCurY_ > r.bottom() + kCurveDragOffMargin); + for (std::size_t i = 0; i < curve.points().size(); ++i) { + const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]); + const bool grabbed = (drag_ == DragKind::kCurveNode && + curvePointIndex_ == static_cast(i)); + const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast(i)); + // A grabbed node in drag-off territory draws warn to signal "release will delete." + const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn + : (hot ? handleHot : handle); + const int nr = 3; + LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0); + } +} + +void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, + const PerformanceZone& zone, + const std::vector& descs) { + if (deckArea.width <= 0 || deckArea.height <= 0) return; + const DeckLayout dl = layoutDeck(descs, deckArea.x, deckArea.y, deckArea.width); + const ZonePlaySeconds& play = zone.play; + const bool isMono = (voiceMode_ == VoiceMode::Mono); + const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); + + // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled + // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. + const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1, + bool seg1Active, bool disabled) { + const bool hov = !disabled && isHovered(HoverKind::kControl, t.id); + const InteractionState st0 = + disabled ? InteractionState::Disabled + : (!seg1Active ? InteractionState::Active + : (hov ? InteractionState::Hover : InteractionState::Rest)); + const InteractionState st1 = + disabled ? InteractionState::Disabled + : (seg1Active ? InteractionState::Active + : (hov ? InteractionState::Hover : InteractionState::Rest)); + fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0); + fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1); + kitTextCentered(bmp, t.seg0, s0, Font::Micro, + disabled ? Role::TextDim + : (!seg1Active ? Role::BgBase : Role::TextPrimary)); + kitTextCentered(bmp, t.seg1, s1, Font::Micro, + disabled ? Role::TextDim + : (seg1Active ? Role::BgBase : Role::TextPrimary)); + }; + + // The knob's short name label (swapped for the live value during hover/drag — r11: no + // third line, no permanent value clutter). + const auto knobName = [](ParamControl c) -> const char* { + switch (c) { + case ParamControl::kAttack: return "Attack"; + case ParamControl::kHold: return "Hold"; + case ParamControl::kDecay: return "Decay"; + case ParamControl::kSustain: return "Sustain"; + case ParamControl::kRelease: return "Release"; + case ParamControl::kTrigFadeIn: return "Fade In"; + case ParamControl::kTrigLength: return "Len %"; + case ParamControl::kTrigFadeOut: return "Fade Out"; + case ParamControl::kKeyTrack: return "Key Trk"; + case ParamControl::kPitchEnvAttack: return "P.Att"; + case ParamControl::kPitchEnvDecay: return "P.Dec"; + case ParamControl::kPitchEnvDepth: return "P.Depth"; + case ParamControl::kVoiceCount: return "Voices"; + case ParamControl::kMasterGain: return "Gain"; + default: return ""; + } + }; + + for (const DeckGroupLayout& g : dl.groups) { + // The fence: a bg/panel box with a hairline border, caption micro-caps left. + fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest); + LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1, + hairline, 1.0f, 0); + const char* caption = ""; + switch (g.id) { + case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; + case kGroupPitch: caption = "PITCH"; break; + case kGroupPitchEnv: caption = "PITCH ENV"; break; + case kGroupVoice: caption = "VOICE"; break; + case kGroupMaster: caption = "MASTER"; break; + default: break; + } + kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); + + // The compact caption toggle (r11: right-anchored IN the caption row, never full-width). + if (g.captionToggle.id >= 0) { + switch (static_cast(g.captionToggle.id)) { + case ParamControl::kPlayMode: + drawToggle(g.captionToggle, "Gate", "Trigger", + play.playMode == PlayMode::Trigger, false); + break; + case ParamControl::kPitchEngine: + drawToggle(g.captionToggle, "Varisp", "Presrv", + play.pitchEngine == PitchEngine::Preserve, false); + break; + case ParamControl::kPitchEnvEnable: + drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false); + break; + case ParamControl::kVoiceMode: + drawToggle(g.captionToggle, "Poly", "Mono", isMono, false); + break; + default: break; + } + } + // The row toggle (VOICE group's Retrig|Legato) — live only in Mono. + if (g.rowToggle.id >= 0) { + drawToggle(g.rowToggle, "Retrig", "Legato", + monoTrigger_ == MonoTrigger::Legato, !isMono); + } + + // The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off — + // stable geometry (r11). + for (const DeckCellLayout& c : g.cells) { + if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares) + const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled); + const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id); + const bool hov = !disabled && isHovered(HoverKind::kControl, c.id); + const InteractionState st = + disabled ? InteractionState::Disabled + : (dragging ? InteractionState::Dragging + : (hov ? InteractionState::Hover : InteractionState::Rest)); + drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st); + const std::string label = (dragging || hov) + ? deckValueLabel(c.id, zone) + : std::string(knobName(static_cast(c.id))); + kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim); + } + } +} + +void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, + const PerformanceZone& zone) { + if (r.width <= 0 || r.height <= 0) return; + // The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone + // panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced + // in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE + // (accent-primary border) while its popup is open, and re-renders live as the popup + // edits the curve (same zone, re-read each paint). + const bool hov = isHovered(HoverKind::kCurveButton, -1); + fillSurface(bmp, toKitBox(r), Role::BgCell, + hov ? InteractionState::Hover : InteractionState::Rest); + const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary) + : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0); + const VelocityCurve& curve = zone.velocityCurve; + const int inset = 3; + const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset, + r.height - 2 * inset}; + if (mini.width > 1 && mini.height > 1) { + const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary)); + int prevX = 0, prevY = 0; + for (int px = 0; px <= mini.width; ++px) { + const int mx = mini.left + px; + const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity; + const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y; + if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true); + prevX = mx; + prevY = my; + } + } +} + +void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) { + // The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the + // Sample face stays legible behind it), then the centered sheet. + LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0); + const CurvePopupLayout pl = computeCurvePopup(w, h); + fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest); + LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1, + pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); + kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim); + { + const KitButtonBox box{toKitBox(pl.close)}; + const InteractionState st = isHovered(HoverKind::kPopupClose, -1) + ? InteractionState::Hover + : InteractionState::Rest; + drawButton(bmp, box, "x", st, /*warn=*/false); + } + // The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so + // trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() — + // the picked capture's one-zone site on the Sample face, the selected zone on the Zone + // surface (FB2). + paintVelocityCurve(bmp, pl.curveBox, popupZone()); +} + +void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { + // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from + // a bank filter that hides everything. Either way it is the "pick a capture" empty state. + const char* msg = samples_.empty() + ? "No captures in this project yet - capture audio into the bank to play it here." + : "No captures in this bank filter. Choose another bank tab above."; + // Split the area so the primary line sits centered and the S13 ingest affordance sits just + // below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept + // discoverable here regardless of whether a drop ever lands on THIS window. + Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2); + Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom()); + kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim); + kitTextCentered(bmp, hint, + "To add a sample: drop a file onto the ReaSampler bank panel (the docked window).", + Font::Micro, Role::TextDim); +} + +} // namespace reasampler::vst + +#endif // _WIN32 diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp new file mode 100644 index 0000000..3c569df --- /dev/null +++ b/src/shell/instrument/editor_platform.cpp @@ -0,0 +1,271 @@ +// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing (Q-W2v +// split of reasampler_editor.cpp, T4-11): platform-type/resize negotiation, the child +// window class + creation/destruction, the S9/S8 sync timer lifetime, the WM_* dispatch +// (wndProc — paint, mouse, keyboard, capture-loss rollback, drop-accept, timer), and the +// non-Windows stubs (D5 makes Windows the only build target; the TU still compiles +// elsewhere). + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 +#include // GET_X_LPARAM / GET_Y_LPARAM +#include // DragAcceptFiles / DragQueryFile / DragFinish — S13 drop-accept +#endif + +#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only) +#include "shell/instrument/reasampler_processor.h" + +using namespace Steinberg; + +namespace reasampler::vst { + +#ifdef _WIN32 +namespace { +constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; + +// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency +// UI-thread timer: responsive enough that a recapture/ingest/assign refreshes "within a +// bounded cadence" (the S9 verify criterion) yet cheap — three small ext-state reads per +// tick, coalescing many bumps between ticks into one reload. 500 ms is a deliberate +// build-time residual. The id is a per-window SetTimer id (any nonzero). +constexpr UINT_PTR kSyncTimerId = 1; +constexpr UINT kSyncTimerIntervalMs = 500; +} // namespace +#endif + +tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) { +#ifdef _WIN32 + if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue; +#endif + return kResultFalse; +} + +tresult PLUGIN_API ReaSamplerEditor::canResize() { + return kResultTrue; +} + +tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) { + // Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before + // every resize; clamp the proposed rect in place and return kResultTrue so the host applies the + // (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title + // + hero waveform + cluster + a few control rows visible (the control strip clips gracefully + // below the panel bottom); anything smaller would clip essential UI. The default 840×620 is + // above this floor. + constexpr int kMinW = 560; + constexpr int kMinH = 460; + if (!rect) return kResultFalse; + if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW; + if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH; + return kResultTrue; +} + +#ifdef _WIN32 + +void ReaSamplerEditor::invalidate() { + if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); +} + +void ReaSamplerEditor::attachedToParent() { + HWND parent = static_cast(systemWindow); + if (!parent) return; + + HINSTANCE hInst = + reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); + if (!hInst) hInst = GetModuleHandle(nullptr); + + static bool classRegistered = false; + if (!classRegistered) { + WNDCLASSW wc{}; + wc.lpfnWndProc = &ReaSamplerEditor::wndProc; + wc.hInstance = hInst; + wc.lpszClassName = kChildClassName; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.style = CS_HREDRAW | CS_VREDRAW; + RegisterClassW(&wc); + classRegistered = true; + } + + // Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a + // reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on + // editor close: the embed strip in the SAME binary shares the kit's process-global font + // set, so a per-view shutdown could free fonts still in use by the other view. The tiny + // static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note. + kitFontsInit(); + + refreshFromBank(); + + const ViewRect& r = getRect(); + childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, + r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); + if (childHwnd_) { + SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); + // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT + // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show + // the "drop on the panel" affordance instead of the OS bouncing the drop silently. + DragAcceptFiles(childHwnd_, TRUE); + // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's + // lifetime — created here, killed in removedFromParent — so an instance whose editor + // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). + SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr); + // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor + // was closed) or a bank change applies the instant the editor opens, rather than waiting + // up to one timer interval. refreshFromBank above already primed the view; this folds in + // any pending assign/generation so the just-opened editor shows the assigned capture. + onSyncTimer(); + } +} + +void ReaSamplerEditor::removedFromParent() { + if (childHwnd_) { + KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +} + +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + tresult res = CPluginView::onSize(newSize); + if (childHwnd_ && newSize) { + MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); + thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them + } + return res; +} + +LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, + LPARAM lParam) { + auto* self = + reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); + switch (msg) { + case WM_PAINT: { + PAINTSTRUCT ps{}; + HDC hdc = BeginPaint(hwnd, &ps); + if (self) self->paint(hdc); + EndPaint(hwnd, &ps); + return 0; + } + case WM_LBUTTONDOWN: + if (self) { + SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child + SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) + self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + } + return 0; + case WM_MOUSEMOVE: + if (self) { + const int mx = GET_X_LPARAM(lParam); + const int my = GET_Y_LPARAM(lParam); + // Hover feedback (Phase L, L3): resolve the element under the pointer and + // repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover + // clears when the pointer leaves the child (TrackMouseEvent is one-shot). + if (!self->mouseTracking_) { + TRACKMOUSEEVENT tme{}; + tme.cbSize = sizeof(tme); + tme.dwFlags = TME_LEAVE; + tme.hwndTrack = hwnd; + TrackMouseEvent(&tme); + self->mouseTracking_ = true; + } + // While a drag is in flight the drag owns the surface; skip hover resolution + // (a hover repaint mid-drag would fight the live drag feedback). + if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my); + self->onMouseMove(mx, my); + } + return 0; + case WM_MOUSELEAVE: + if (self) { + self->mouseTracking_ = false; + if (self->hover_.kind != HoverKind::kNone) { + self->hover_ = HoverTarget{}; + self->invalidate(); + } + } + return 0; + case WM_MOUSEWHEEL: + // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. + if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); + return 0; + case WM_CHAR: + // S12 type-to-filter search keystrokes (only acted on when the search box is focused). + if (self) self->onSearchChar(static_cast(wParam)); + return 0; + case WM_GETDLGCODE: + // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). + return DLGC_WANTCHARS | DLGC_WANTARROWS; + case WM_LBUTTONUP: + if (self) { + self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + ReleaseCapture(); + } + return 0; + case WM_RBUTTONDOWN: + // r11: right-click — the curve popup's primary node-delete affordance (issue 3c). + // Routed explicitly (the child wndproc historically handled only left-button). + if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_RBUTTONUP: + return 0; // claimed so the pair never reaches DefWindowProc (no context menu) + case WM_CAPTURECHANGED: + // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its + // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset + // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. + // Mirror of the panel shell's WM_CAPTURECHANGED handler (panel_window.cpp). + if (self) { + // A held preview note must be released here too (peer of WM_LBUTTONUP) — capture + // loss otherwise leaves the momentary-key voice hung with no note-off. + if (self->previewingNote_ >= 0) { + if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_); + self->previewingNote_ = -1; + self->invalidate(); + } + if (self->drag_ != DragKind::kNone) { + // A scrollbar drag + the processor-side deck knobs (preview velocity -2 / + // voice count / master gain) are transient (no map mutation; dragStartMap_ + // not snapshotted) — reset drag state only, never touch map_. Every + // map-editing drag rolls its live mutation back to the snapshot. + const bool transient = self->drag_ == DragKind::kScrollThumb || + (self->drag_ == DragKind::kDeckKnob && + (self->dragParamId_ == -2 || + self->dragParamId_ == static_cast(ParamControl::kVoiceCount) || + self->dragParamId_ == static_cast(ParamControl::kMasterGain))); + if (!transient) self->map_ = self->dragStartMap_; + self->drag_ = DragKind::kNone; + self->dragParamId_ = -1; + self->dragParamZone_ = -1; + self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset) + self->dragCurveZone_ = -1; + self->invalidate(); + } + } + return 0; + case WM_DROPFILES: { + // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT + // read/ingest the paths (the instrument never ingests — the relay to the extension is + // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner. + HDROP drop = reinterpret_cast(wParam); + const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); + DragFinish(drop); + if (self) self->onFilesDropped(static_cast(count)); + return 0; + } + case WM_TIMER: + if (self && wParam == kSyncTimerId) self->onSyncTimer(); + return 0; + case WM_ERASEBKGND: + return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase + default: + return DefWindowProcW(hwnd, msg, wParam, lParam); + } +} + +#else // non-Windows: not a build target (D5), but keep the TU compilable. + +void ReaSamplerEditor::attachedToParent() {} +void ReaSamplerEditor::removedFromParent() {} +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + return CPluginView::onSize(newSize); +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp new file mode 100644 index 0000000..5042ac1 --- /dev/null +++ b/src/shell/instrument/editor_session.cpp @@ -0,0 +1,371 @@ +// editor_session.cpp — the ReaSamplerEditor's SESSION/BRIDGE state (Q-W2v split of +// reasampler_editor.cpp, T4-11): construction, the live-bank snapshot (refreshFromBank / +// rebuildVisible), the S9/S8 sync tick, the commit-and-reload seam, selection loading, +// the picked-capture marker resolution/upsert helpers, and the decoded-PCM + peak +// thumbnail caches (the mirror of bank_panel's, keyed through the pure ThumbnailKey — +// T2-10 rider). UI thread only; every edit commits OFF the audio thread via the +// processor's reloadInstrument. + +#include "shell/instrument/reasampler_editor.h" + +#include +#include +#include +#include + +#include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail) +#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames +#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (T2-10: the pure key) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "ext_keys.h" +#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (S12 type-to-filter) +#include "shell/instrument/reaper_bridge.h" +#include "shell/instrument/reasampler_processor.h" + +using namespace Steinberg; + +namespace reasampler::vst { + +using namespace reasampler::instrument::map; // sample_map vocabulary (selectSample / listSamples / …) +using audio::computeEnvelope; +using capture::WavLayout; +using capture::extractFloatFrames; +using capture::parseWavLayout; +using capture::resolveBankFile; +using instrument::ui::nameMatchesQuery; +using ui::ThumbnailKey; +using ui::thumbnailKeyString; +using util::readFileBytes; + +ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) + : CPluginView(nullptr), processor_(processor) { + // Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample + // home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate + // mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll + // on a 1080p screen with headroom. Wide enough that the control strip's label + value columns + // read comfortably. + ViewRect r(0, 0, 840, 620); + setRect(r); +} + +void ReaSamplerEditor::refreshFromBank() { + // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). + thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks + pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source) + if (!processor_) { + samples_.clear(); + banks_.clear(); + visible_.clear(); + selectedId_.clear(); + map_.zones.clear(); + selectedZone_ = -1; + return; + } + auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banksJson ? listSamples(*banksJson) : std::vector{}; + banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; + selectedId_ = processor_->selectedSampleId(); + const auto prevZoneCount = static_cast(map_.zones.size()); + map_ = processor_->performanceMap(); + channelMode_ = processor_->channelMode(); + voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot + voiceMode_ = processor_->voiceMode(); + monoTrigger_ = processor_->monoTrigger(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; + // r11: a refresh that emptied the selection (a bank change on the sync tick) closes the + // curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible + // modal would swallow clicks. + if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false; + // FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list + // shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list + // deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget). + if (view_ == View::kZone && curvePopupOpen_) { + const auto newZoneCount = static_cast(map_.zones.size()); + if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false; + } + // Drop a filter that names a bank no longer present. + if (!activeFilterBankId_.empty()) { + bool found = false; + for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; + if (!found) activeFilterBankId_.clear(); + } + rebuildVisible(); +} + +void ReaSamplerEditor::rebuildVisible() { + // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search + // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). + visible_.clear(); + for (const SampleChoice& s : samples_) { + const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; + if (!inBank) continue; + const std::string& name = s.displayName.empty() ? s.id : s.displayName; + if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); + } + // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel + // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it + // must not reset the user's scroll here. +} + +#ifdef _WIN32 +// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared +// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes +// Windows the only build target, but the TU must still compile elsewhere). +void ReaSamplerEditor::onSyncTimer() { + // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the + // processor (off the audio thread — the poll itself never touches process()). NEVER while a + // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the + // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. + if (!processor_) return; + if (drag_ != DragKind::kNone) return; // defer past the in-flight edit + + // An open editor marks THIS instance the focused assignment target (the thundering-herd + // policy — only an editor-open instance applies a pending assign; see the handoff). Pass + // true so this instance consumes the request; instances with no editor open do not poll at + // all (the timer is bound to the child window), so they never contend for the request. + const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); + + // Re-snapshot the editor's own view only when something changed (a reload from a bank + // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the + // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM + // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. + if (r.reloaded || r.applied) { + refreshFromBank(); + invalidate(); + } + + // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. + if (dropHintTicks_ > 0) { + --dropHintTicks_; + invalidate(); + } +} +#endif // _WIN32 + +void ReaSamplerEditor::commitAndReload() { + // UI thread only. Publish the edited selection + zones to the processor, then rebuild + // the instrument off the audio thread (reloadInstrument bakes them into the live Keymap). + // pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank + // blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the + // moment the instance becomes self-contained for that sample. + if (!processor_) return; + processor_->setSelectedSampleId(selectedId_); + processor_->setPerformanceMap(map_); + processor_->reloadInstrument(); + // GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's + // channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode + // the engine actually decoded with. + channelMode_ = processor_->channelMode(); +#ifdef _WIN32 + invalidate(); +#endif +} + +void ReaSamplerEditor::loadSelection(const std::string& id) { + // Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous + // sample's materialized full-range zone must not linger — first-match resolve would + // keep playing it while the editor draws the new pick's zone (matched by sampleId, + // order-blind). Authored Zone-view maps (any narrow key range) are left untouched. + selectedId_ = id; + if (reconcileSingleCaptureZones(map_, selectedId_)) { + selectedZone_ = map_.zones.empty() ? -1 : 0; + } + commitAndReload(); +} + +ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { + SetupMarkers m; + // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override + // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic + // from the live bank blob (the same path selectSample uses); when that is not readable + // (extension absent / not yet parsed) the instance-OWNED ref carries the same intrinsics + // (pS fallback). The override lives in map_. + if (processor_) { + std::optional sel; + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) sel = selectSample(*banksJson, selectedId_); + if (!sel) { + const SampleRefs refs = processor_->sampleRefs(); + if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r; + } + if (sel && sel->loop.hasLoop) { + m.hasLoop = true; + m.loopStart = sel->loop.start; + m.loopEnd = sel->loop.end; + } + } + // The override (loop + start) on a zone for the picked id supersedes the intrinsic. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId != selectedId_) continue; + if (z.loopOverride) { + m.hasLoop = z.loopOverride->hasLoop; + m.loopStart = z.loopOverride->start; + m.loopEnd = z.loopOverride->end; + } + if (z.startPoint) m.start = *z.startPoint; + break; + } + // Default an unset loop's end to the sample length so the loop markers have somewhere sane + // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; + // the markers are still drawn (drag one to CREATE a loop). + if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; + return m; +} + +int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { + // Find-or-append the zone for selectedId_ and write the loop/start override fields. + // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must + // be non-empty; callers are responsible for that guard. + // Returns the zone index (0-based) so callers can update selectedZone_. + SampleLoop loop; + loop.hasLoop = m.hasLoop; + loop.start = m.loopStart; + loop.end = m.loopEnd; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == selectedId_) { + z.loopOverride = loop; + z.startPoint = m.start; + return i; + } + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.loopOverride = loop; + z.startPoint = m.start; + map_.zones.push_back(z); + return static_cast(map_.zones.size()) - 1; +} + +PerformanceZone ReaSamplerEditor::effectiveSampleZone() const { + // The picked id's one-zone override, if the map already carries one; else a product-default + // zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes + // it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) return z; + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + return z; +} + +int ReaSamplerEditor::effectiveRoot() const { + int root = 60; + for (const SampleChoice& s : samples_) { + if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; + } + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride; + } + return root; +} + +int ReaSamplerEditor::ensureSampleZone() { + if (selectedId_.empty()) return -1; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + if (map_.zones[static_cast(i)].sampleId == selectedId_) return i; + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + map_.zones.push_back(z); + return static_cast(map_.zones.size()) - 1; +} + +void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { + // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, + // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays + // identically to the un-zoned single capture (one chromatic zone) and round-trips through + // the component state; the zone becomes visible if the user opens the Zones panel. The bank + // intrinsic is NEVER written (read-only bank consumer, D-B). + if (selectedId_.empty()) return; + upsertPickedOverride(m); + commitAndReload(); +} + +const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { + auto it = pcmCache_.find(sampleId); + if (it != pcmCache_.end()) return it->second; + + // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so + // resolve the path from the live bank blob (selectSample) and decode via the shared WAV + // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY + // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width- + // independent) — the thumbnail bins this at whatever width, the snap scans it directly. + std::string relativePath; + std::vector mono; + if (processor_) { + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; + } + if (relativePath.empty()) { + // pS fallback: the bank blob is not readable (extension absent / not yet parsed) + // or the id went stale there — the instance-OWNED ref still carries the path, so + // a self-contained instance draws its loaded sound's waveform regardless. + const SampleRefs refs = processor_->sampleRefs(); + if (const SelectedSample* r = findRef(refs, sampleId)) { + relativePath = r->relativePath; + } + } + if (!relativePath.empty()) { + const std::string projectDir = processor_->bridge().activeProjectDir(); + const std::string abs = resolveBankFile(projectDir, relativePath); + // Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure. + const std::vector bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + mono = downmixToMono(interleaved, layout.channelCount); + } + } + } + auto ins = pcmCache_.emplace(sampleId, std::move(mono)); + return ins.first->second; +} + +const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { + // T2-10 rider: key through the PURE ThumbnailKey (bank_grid) instead of the former + // ad-hoc "id|binCount" concat, so both thumbnail pipelines share one tested key + // grammar (length-prefixed id — collision-proof). The editor invalidates by wholesale + // clear() on refresh/resize, so the bank generation carries no information here — 0. + const std::string key = + thumbnailKeyString(ThumbnailKey{sampleId, binCount, /*generation=*/0}); + auto it = thumbCache_.find(key); + if (it != thumbCache_.end()) return it->second; + + // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by + // every thumbnail width AND the S11 waveform surface + snap. + const std::vector& mono = monoPcmFor(sampleId); + Envelope env; + if (!mono.empty()) { + // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with + // trailing empty {0,0} bins, which would render a very short sample as a comb of + // spikes over flat gaps. + const std::size_t bins = + (std::min)(static_cast((std::max)(1, binCount)), mono.size()); + env = computeEnvelope(mono, 1, mono.size(), bins); + } + auto ins = thumbCache_.emplace(key, std::move(env)); + return ins.first->second; +} + +ReaSamplerEditor::~ReaSamplerEditor() { +#ifdef _WIN32 + if (childHwnd_) { + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +#endif +} + +} // namespace reasampler::vst diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp new file mode 100644 index 0000000..0aa5cff --- /dev/null +++ b/src/shell/instrument/processor_reload.cpp @@ -0,0 +1,495 @@ +// processor_reload.cpp — the ReaSamplerProcessor's OFF-AUDIO-THREAD instrument +// lifecycle: reloadInstrument (self-contained refs resolve + WAV decode + keymap +// build), the safety-critical publishBuiltLocked drain-slot swap, the voice-param +// light rebuild, idle-drain retirement, the pre-v10 legacy-lift gate, the S9/S8 +// bank-sync poll, and the pS-usage publish. Split out of reasampler_processor.cpp +// (Q-W2v, T4-12). NOTHING here runs on the audio thread — process() (the lifecycle +// TU) only touches the atomics this family publishes; the atomic-pointer-swap +// pattern deliberately gains NO virtual seam (T4-29). + +#include "shell/instrument/reasampler_processor.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse) +#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision +#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (pS self-contained) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) +#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) +#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey + +namespace reasampler::vst { + +using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives +using namespace reasampler::wire; // assignment_request + sample_usage wire records +// Q-W6 (shim retired): the shared WAV parse + file loader by their real homes. +using capture::extractFloatFrames; +using capture::parseWavLayout; +using capture::resolveBankFile; +using capture::WavLayout; +using util::readFileBytes; + +namespace { + +// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is +// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather +// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice +// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the +// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays +// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. +constexpr std::size_t kPreserveVoiceCap = 8; + +// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy +// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the +// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic +// strength) is the requirement: two instances sharing a key is the copy-collision +// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision +// vanishingly unlikely. Off-thread only. +std::string mintUsageInstanceGuid() { + std::random_device rd; + std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); + std::uniform_int_distribution dist; + char buf[33] = {0}; + std::snprintf(buf, sizeof(buf), "%016llx%016llx", + static_cast(dist(gen)), + static_cast(dist(gen))); + return std::string(buf); +} + +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03). +// Off-thread only (blocking file I/O). Empty on any failure — the caller treats +// an unreadable WAV as "nothing to play". + +// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file +// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode +// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for +// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path +// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no +// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by +// the zoned build and the single-capture path so both decode identically for the active mode. +std::optional decodeRelative(const std::string& projectDir, + const std::string& relativePath, + ChannelMode mode) { + const std::string abs = resolveBankFile(projectDir, relativePath); + if (abs.empty()) return std::nullopt; + const std::vector bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (!layout.valid) return std::nullopt; + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, + static_cast(layout.sampleRate)); + if (out.monoFrames.empty()) return std::nullopt; + return out; +} + +} // namespace + +std::string ReaSamplerProcessor::reloadInstrument() { + // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so + // the retired-slot free is single-writer. This mutex is NEVER taken on the audio + // thread — process() only touches the atomic. + std::lock_guard lock(reloadMutex_); + + // Mint this reload's generation number first so we can stamp the built instrument + // with it before publishing. Under reloadMutex_ no other reload races here. + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + + // 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of + // truth for what to decode. The live bank blob, WHEN readable, is folded into the + // table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in + // mechanism and the S9 recapture sync in one — but its absence changes NOTHING + // below: a project restored before the extension's PROJEXTSTATE parses (or with + // the extension absent entirely) resolves + plays from the persisted refs. The + // project dir comes from REAPER itself (EnumProjects), not from the extension. + const std::string selId = selectedSampleId(); + const PerformanceMap map = performanceMap(); + const std::vector ids = referencedSampleIds(selId, map); + SampleRefs refs; + { + std::optional banksJson = + bridge_.readReasamplerExtState(kProjExtBanksKey); + std::lock_guard rl(refsMutex_); + if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); + // The LOAD path never prunes the owned table: dropping entries here on a transient + // bank miss could destroy the owned intrinsics of the previous selection — the ONE + // copy that survives with the extension absent. Entries for de-referenced ids stay + // in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary, + // where getState filters its snapshot via retainRefs to what the instance plays. + refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) + } + const std::string projectDir = bridge_.activeProjectDir(); + // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). + // Read once under its mutex, off the audio thread, before the decode loop. The single- + // capture branch below may auto-default it (GA) before its decode. + ChannelMode mode = channelMode(); + // Phase S: snapshot the voice-system parameters once — they are baked into the built + // engine's construction (the engine's config is immutable; a later change rebuilds). + int builtVoiceCount = kDefaultVoiceCount; + VoiceMode builtVoiceMode = VoiceMode::Poly; + MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; + { + std::lock_guard vp(voiceParamsMutex_); + builtVoiceCount = voiceCount_; + builtVoiceMode = voiceMode_; + builtMonoTrigger = monoTrigger_; + } + + std::string resolvedId; + std::unique_ptr built; + Keymap km; + bool haveKeymap = false; + + // 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its + // zones against the OWNED refs (an id with no ref drops cleanly), decode each + // zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays + // its sample repitched from its effective root note (override > ref intrinsic > + // C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped + // (not the whole map): the defined no-play, no crash, no retry loop. + if (!map.empty()) { + const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map); + if (!resolved.zones.empty()) { + std::vector decoded; + std::vector kept; + decoded.reserve(resolved.zones.size()); + kept.reserve(resolved.zones.size()); + for (const ResolvedZone& rz : resolved.zones) { + std::optional pcm = + decodeRelative(projectDir, rz.relativePath, mode); + if (!pcm) continue; // unreadable/missing WAV -> drop this zone + kept.push_back(rz); + decoded.push_back(std::move(*pcm)); + } + km = buildZonedKeymap(kept, decoded); + haveKeymap = !km.zones.empty(); + } + } + + // 3. Single-capture fast path (S10): an empty performance map plays the ONE + // deliberately-selected capture chromatically across the whole keyboard, resolved + // against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a + // selection with no ref) resolves to nothing, so an un-picked instrument stays + // SILENT (the editor shows its "pick a capture" empty state) rather than + // auto-playing sample #1 (S10 policy reversal of the S4 convenience default). + if (!haveKeymap) { + if (const SelectedSample* sel = findRef(refs, selId)) { + // GA auto-default: channelModeFor computes the mode from the loaded capture's + // REQUESTED channel count (always 2 for extension captures; mono only for + // ingest-imported mono files). An unknown count (0) or explicit user choice + // returns the current mode unchanged. Decode-only: the output bus is fixed + // stereo, so no bus work follows a flip. + { + std::lock_guard cm(channelModeMutex_); + channelMode_ = channelModeFor(sel->channelCount, channelMode_, + channelModeExplicit_); + mode = channelMode_; + } + std::optional pcm = + decodeRelative(projectDir, sel->relativePath, mode); + if (pcm) { + km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, + sel->rootNote, sel->loop, + std::move(pcm->framesR)); + haveKeymap = true; + resolvedId = selId; // the concrete pick that resolved + } + } + } + + if (haveKeymap) { + // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). + // Every voice's shifter is pre-sized to this off-thread here, so process()-time + // note-on never allocates. Floored at 2 so a valid window is always a real ring + // (which also covers a pathological host rate <= 0 — no rate literal needed). + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; + built = std::make_unique( + std::move(km), static_cast(builtVoiceCount), gen, + kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); + } + + // 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the + // DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices — + // a reload never cuts a sounding note; the next note-on plays the new state. The + // instrument evicted FROM the drain slot (two reloads old) goes to the graveyard + // (process may still be mid-block reading it). A null `built` (no ref / unreadable + // WAV) installs silence while the displaced tails still ring out via the drain. + // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted + // pointer is re-owned by the graveyard. + publishBuiltLocked(std::move(built)); + + // 5. pS-usage: publish this instance's held captures so the extension's prune can + // never reclaim them (see publishUsage). AFTER the instrument swap, still off the + // audio thread and under reloadMutex_. Publishes regardless of decode success: + // the holds are the refs the instance RETAINS (its play-set), not what decoded — + // a transiently unreadable WAV must stay protected. + publishUsage(refs, ids); + return resolvedId; +} + +void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, + const std::vector& ids) { + if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do + + UsageRecord mine; + mine.trackGuid = bridge_.currentTrackGuid(); + for (const std::string& id : ids) { + if (const SelectedSample* ref = findRef(refs, id)) { + if (!ref->relativePath.empty()) { + mine.holds.push_back(UsageHold{id, ref->relativePath}); + } + } + } + + std::lock_guard lock(usageMutex_); + // A never-published instance with nothing held writes nothing — no key litter for + // fresh/empty instances. Once an identity exists, empties DO publish (they release + // holds the prune would otherwise keep protecting). + if (instanceGuid_.empty() && mine.holds.empty()) return; + if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); + // The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so + // planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track + // sibling's byte-identical hold set can never pass as ours (its nonce differs), so + // siblings always union and never clean-replace over each other's held paths. + if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid(); + mine.ownerNonce = usageNonce_; + + const std::optional existing = + bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); + const UsagePublishPlan plan = planUsagePublish(existing, mine); + if (plan.remint) { + // This state was cloned onto another track (FX copy / track duplication): take a + // fresh identity and leave the original's record untouched. The abandoned old + // identity's record dies by the extension's liveness rule when its track no + // longer hosts an instance. getState persists the new guid on the next save. + instanceGuid_ = mintUsageInstanceGuid(); + } else if (plan.skipWrite) { + return; // idle tick, or a union that adds nothing — no ext-state churn + } + bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire); +} + +void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { + // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by + // reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance. + // + // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is + // the minimum installedAt process() published over the pointers it holds. Both + // slots are monotone in installedAt, so seen is monotone and any future process() + // load yields installedAt >= seen — an entry below seen is provably unreachable + // (see the header proof). Remaining entries drain at setActive(false) / terminate() + // when process is guaranteed stopped. + const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); + graveyard_.erase( + std::remove_if(graveyard_.begin(), graveyard_.end(), + [seen](const std::unique_ptr& e) { + return e->installedAt < seen; + }), + graveyard_.end()); + LoadedInstrument* prev = live_.exchange(built.release()); + LoadedInstrument* evicted = draining_.exchange(prev); + if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); +} + +void ReaSamplerProcessor::rebuildVoiceEngine() { + // OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract: + // a voice-param change touches NO audio data, so this rebuilds the engine + // around a COPY of the live instrument's already-decoded keymap — no bridge, no disk — + // and publishes through the same drain-slot swap, so ringing tails survive. + std::lock_guard lock(reloadMutex_); + LoadedInstrument* cur = live_.load(std::memory_order_acquire); + if (!cur) return; // nothing loaded: the new params bake into the next real reload. + + int builtVoiceCount = kDefaultVoiceCount; + VoiceMode builtVoiceMode = VoiceMode::Poly; + MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; + { + std::lock_guard vp(voiceParamsMutex_); + builtVoiceCount = voiceCount_; + builtVoiceMode = voiceMode_; + builtMonoTrigger = monoTrigger_; + } + + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + // Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate). + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; + + // Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap + // is immutable after construction, and under reloadMutex_ nobody can free `cur`. + Keymap km = cur->keymap; + auto built = std::make_unique( + std::move(km), static_cast(builtVoiceCount), gen, + kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); + publishBuiltLocked(std::move(built)); +} + +void ReaSamplerProcessor::retireIdleDrain() { + // Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or + // it still sounds" — the common case costs one relaxed load and no mutex. + const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); + if (idleGen == 0) return; + std::lock_guard lock(reloadMutex_); + LoadedInstrument* drain = draining_.load(std::memory_order_acquire); + // Retire ONLY if the publication names the drain currently in the slot. A stale value + // (about an already-evicted, older drain) can never match the newer occupant's + // installedAt — the slot is monotone in generation — so a mid-swap race is closed by + // this identity check, not by timing. + if (!drain || drain->installedAt != idleGen) return; + draining_.store(nullptr, std::memory_order_release); + graveyard_.push_back(std::unique_ptr(drain)); + // Prune what is now provably unreachable — the same monotone-generation proof as the + // reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be + // held by process() now or ever again. The just-parked drain frees here immediately when + // process() has already published past it; otherwise on the next reload/retire/deactivate. + const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); + graveyard_.erase( + std::remove_if(graveyard_.begin(), graveyard_.end(), + [seen](const std::unique_ptr& e) { + return e->installedAt < seen; + }), + graveyard_.end()); +} + +bool ReaSamplerProcessor::legacyLiftShouldRun() { + // #A terminating guard for the pre-v10 legacy lift. The caller has already established + // refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before + // paying for a full reload. Once concluded, the steady state is this one relaxed load — + // no bank read, no parse, no reload churn. + if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; + const LegacyLiftDecision decision = legacyLiftDecision( + bridge_.readReasamplerExtState(kProjExtBanksKey), + referencedSampleIds(selectedSampleId(), performanceMap())); + if (decision == LegacyLiftDecision::Stale) { + // Provably stale (the bank parses and knows none of the referenced ids): give up + // PERMANENTLY. A later bank change that re-introduces an id bumps the generation, + // and the genChanged reload refreshes the refs without consulting this latch. + legacyLiftConcluded_.store(true, std::memory_order_relaxed); + return false; + } + return true; // Retry (blob not readable yet) or Lift (a ref can be copied in) +} + +ReaSamplerProcessor::BankSyncResult +ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { + // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call + // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER + // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. + BankSyncResult result; + + // Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer + // cadence that drives reloads — an edited-away instrument stops costing memory as soon + // as its tails die instead of squatting in the drain slot until the next reload. + retireIdleDrain(); + + // --- S8: assignment-request consume FIRST ------------------------------------- + // Decode the pending assignment request (nullopt when absent/malformed). Resolve its + // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when + // the sampleId names an existing sample (the reader requirement — an unresolvable pair is + // dropped). Then run the pure consume decision against this instance's persisted marker. + std::optional request; + if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { + request = decodeAssignmentRequest(*raw); + } + + bool resolves = false; + if (request) { + // Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request + // whose sample was rolled back by an extension undo resolves to nullopt -> dropped). + if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { + resolves = selectSample(*banksJson, request->sampleId).has_value(); + } + } + + // Read lastConsumed and conditionally write it back under a single lock scope so there + // is no interleave window between the read and the write (a concurrent getState could + // otherwise observe a stale marker between the two separate lock acquisitions). + std::int64_t lastConsumed = 0; + const AssignConsumeDecision decision = [&] { + std::lock_guard lock(assignMarkerMutex_); + lastConsumed = lastConsumedAssignGeneration_; + const AssignConsumeDecision d = + consumeDecision(request, lastConsumed, resolves, isFocusedTarget); + // Advance the persisted consumed marker whenever the decision consumed the request + // (applied OR dropped-as-seen). getState will persist it on the next project save so + // a re-open does not re-apply. A non-target instance leaves the marker (decision + // returns it unchanged) so it stays eligible if focus later lands here. + if (d.consumedGeneration != lastConsumed) { + lastConsumedAssignGeneration_ = d.consumedGeneration; + } + return d; + }(); + + if (decision.apply) { + // Apply the assignment as this instance's own selection (the same path a user card-pick + // takes) — the instrument updates its OWN state, never the bank. reloadInstrument below + // rebuilds against the new selection, so skip a redundant reload here. + setSelectedSampleId(decision.sampleId); + // Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone + // materialized for the previously loaded sample would shadow the assigned pick under + // first-match resolve. Authored maps (any narrow key range) are untouched. + PerformanceMap reconciled = performanceMap(); + if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) { + setPerformanceMap(reconciled); + } + result.applied = true; + } + + // --- S9: bank-generation change-detection ------------------------------------- + // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll + // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — + // setState already loaded the instrument from its OWNED refs (pS), so a redundant reload + // on open would only churn. A later + // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the + // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced). + std::int64_t currentGen = kBankGenerationAbsent; + if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { + currentGen = parseBankGeneration(*rawGen); + } + const bool firstPoll = (lastSeenBankGeneration_ < 0); + const bool genChanged = + !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); + lastSeenBankGeneration_ = currentGen; + + // LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones) + // but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had + // nothing to decode unless the bank happened to be readable already. Reload on this + // editor tick until the lift lands: reloadInstrument folds the bank blob into the refs + // when readable, after which the table is non-empty and this never fires again (the + // next save is then self-contained). A deliberately-empty instance has no intent and + // never churns; a bank that is not readable YET retries a cheap null publish on the + // editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob + // PARSES and no referenced id resolves in it, the ids are provably stale — there is + // nothing to lift, so the lift concludes permanently instead of churning a full bank + // read + reload every tick forever. This is a MIGRATION convenience for old projects, + // NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS). + bool legacyLift = false; + if (!genChanged && !result.applied && sampleRefs().empty()) { + const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty(); + legacyLift = hasIntent && legacyLiftShouldRun(); + } + + if (genChanged || result.applied || legacyLift) { + reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) + // Report the reload distinctly from an S8 apply so the editor re-snapshots its bank + // view. A legacy lift counts only when it actually landed an instrument (otherwise + // every retry tick would churn the editor's caches for nothing). + result.reloaded = + genChanged || + (legacyLift && live_.load(std::memory_order_acquire) != nullptr); + } + return result; +} + +} // namespace reasampler::vst diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp new file mode 100644 index 0000000..5f8bbe1 --- /dev/null +++ b/src/shell/instrument/processor_state.cpp @@ -0,0 +1,312 @@ +// processor_state.cpp — the ReaSamplerProcessor's COMPONENT-STATE I/O (setState / +// getState against the component_state_io codec) and its UI-thread parameter +// accessors/setters (selection, performance map, channel mode, preview velocity, +// voice-system params, master gain, preview-note mailbox posts). Split out of +// reasampler_processor.cpp (Q-W2v, T4-12). Everything here runs OFF the audio +// thread (UI / host load-save); the setters hand work to the reload family +// (processor_reload.cpp) or store atomics process() picks up at block start. + +#include "shell/instrument/reasampler_processor.h" + +#include +#include +#include + +#include "pluginterfaces/base/ibstream.h" + +#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) +#include "core/instrument/map/component_state_io.h" // the ComponentState codec (Q-W2v split) +#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace reasampler::vst { + +using namespace instrument::map; // the codec + resolution vocabulary this TU marshals +using instrument::engine::masterGainMaxLinear; // FB1 taper ceiling (Q-W6: shim retired) + +tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { + if (!state) return kResultFalse; + // Read the whole component-state blob (the performance map, versioned). The blob is + // small; read in one shot into a growable buffer. + std::vector bytes; + std::uint8_t chunk[256]; + int32 got = 0; + while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { + bytes.insert(bytes.end(), chunk, chunk + got); + } + // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The + // selection and the zones are DISTINCT — the default face is one picked capture, zones + // are a demoted overlay — so both are restored explicitly (no more inferring a selection + // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only + // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so + // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 + // silent empty state (no first-sample fallback in reloadInstrument). + // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at + // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing + // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a + // pre-setup call would assert inside readZonesPayload (a programming error, not a field case). + const ComponentState cs = deserializeComponentState(bytes, sampleRate_); + setSelectedSampleId(cs.selectionId); + // Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a + // pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the + // saved selection under first-match resolve. Reconciling here restores "the sample the + // editor shows is the sample the engine plays" for already-affected projects; authored + // Zone-view maps (any narrow key range) pass through untouched. + PerformanceMap restored = cs.map; + reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load + setPerformanceMap(restored); + // S8: restore the last-consumed assignment generation so a re-open does not re-apply a + // stale assign_request (the user may have manually changed the selection after the assign). + { + std::lock_guard lock(assignMarkerMutex_); + lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; + } + // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see + // initialize) — the mode only governs how the reload below decodes, so no bus work here. + { + std::lock_guard lock(channelModeMutex_); + channelMode_ = cs.channelMode; + channelModeExplicit_ = cs.channelModeExplicit; + } + // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 + // the editor's velocity knob is a concurrent UI-thread writer. + { + std::lock_guard lock(previewMutex_); + previewVelocity_ = cs.previewVelocity; + } + // Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly, + // Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the + // reload below so the rebuilt engine is born with the saved polyphony/mode. + { + std::lock_guard lock(voiceParamsMutex_); + voiceCount_ = cs.voiceCount; + voiceMode_ = cs.voiceMode; + monoTrigger_ = cs.monoTrigger; + } + // FB1: restore the post-mixer master gain (v8; older blobs lift to unity in + // deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks + // it up at the next block start. + setMasterGainLinear(cs.masterGainLinear); + // pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the + // reload so it decodes straight from them — no bank read required to play. A pre-v10 + // blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob + // becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift), + // after which the next save is self-contained. + { + std::lock_guard lock(refsMutex_); + sampleRefs_ = cs.sampleRefs; + } + // pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty — + // minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME + // for the copy-collision analysis (the fresh nonce means this incarnation can never + // be mistaken for the previous one's writes — or for a copy-sibling's). + { + std::lock_guard lock(usageMutex_); + instanceGuid_ = cs.instanceGuid; + usageNonce_.clear(); + } + // A new blob is new facts: a staleness proof latched against the PREVIOUS state does + // not carry over (#A — the legacy lift gets one fresh run per restored state). + legacyLiftConcluded_.store(false, std::memory_order_relaxed); + // Rebuild from the restored state (off-thread — setState is a load-time call). + reloadInstrument(); + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { + if (!state) return kResultFalse; + // Persist the full instance state (v3, S10): the single-capture selection id AND the + // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" + // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} + // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing + // sample #1. + ComponentState state_out; + state_out.selectionId = selectedSampleId(); + state_out.map = performanceMap(); + { + // S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9). + std::lock_guard lock(channelModeMutex_); + state_out.channelMode = channelMode_; + state_out.channelModeExplicit = channelModeExplicit_; + } + { + std::lock_guard lock(assignMarkerMutex_); + state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker + } + state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity + { + // Phase S: persist the voice-system parameters (component state v7). + std::lock_guard lock(voiceParamsMutex_); + state_out.voiceCount = voiceCount_; + state_out.voiceMode = voiceMode_; + state_out.monoTrigger = monoTrigger_; + } + state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8) + // pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to + // decode + play with no extension present. Filtered (on the snapshot copy, the member is + // untouched) to exactly what the instance currently plays, so the table cannot grow with + // browsing history. + state_out.sampleRefs = sampleRefs(); + retainRefs(state_out.sampleRefs, + referencedSampleIds(state_out.selectionId, state_out.map)); + // pS-usage: persist the publish identity (v11) so the instance's usage key is + // stable across sessions (records do not proliferate per reopen). + { + std::lock_guard lock(usageMutex_); + state_out.instanceGuid = instanceGuid_; + } + const std::vector bytes = serializeComponentState(state_out); + if (!bytes.empty()) { + const tresult wr = state->write(const_cast(bytes.data()), + static_cast(bytes.size()), nullptr); + if (wr != kResultOk) return wr; + } + return kResultOk; +} + +std::string ReaSamplerProcessor::selectedSampleId() { + std::lock_guard lock(selectionMutex_); + return selectedSampleId_; +} + +void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { + std::lock_guard lock(selectionMutex_); + selectedSampleId_ = id; +} + +PerformanceMap ReaSamplerProcessor::performanceMap() { + std::lock_guard lock(performanceMutex_); + return performanceMap_; +} + +void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { + std::lock_guard lock(performanceMutex_); + performanceMap_ = map; +} + +SampleRefs ReaSamplerProcessor::sampleRefs() { + std::lock_guard lock(refsMutex_); + return sampleRefs_; +} + +ChannelMode ReaSamplerProcessor::channelMode() { + std::lock_guard lock(channelModeMutex_); + return channelMode_; +} + +std::uint8_t ReaSamplerProcessor::previewVelocity() { + std::lock_guard lock(previewMutex_); + return previewVelocity_; +} + +void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { + // Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview + // strike must sound). The editor's knob maps its 0..1 domain into this range before calling. + if (velocity < 1) velocity = 1; + if (velocity > 127) velocity = 127; + std::lock_guard lock(previewMutex_); + previewVelocity_ = velocity; +} + +int ReaSamplerProcessor::voiceCount() { + std::lock_guard lock(voiceParamsMutex_); + return voiceCount_; +} + +void ReaSamplerProcessor::setVoiceCount(int count) { + // Clamp to the shared pure-core range so the engine, the state bytes, and the editor's + // control can never disagree about the legal polyphony span. + if (count < kMinVoiceCount) count = kMinVoiceCount; + if (count > kMaxVoiceCount) count = kMaxVoiceCount; + { + std::lock_guard lock(voiceParamsMutex_); + if (voiceCount_ == count) return; // no-op: don't churn a rebuild + voiceCount_ = count; + } + // LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from + // the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change + // touches no audio data) and the displaced instrument keeps rendering its ringing tails, + // so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every + // zone from disk. Same contract for the mode/trigger setters below. + rebuildVoiceEngine(); +} + +VoiceMode ReaSamplerProcessor::voiceMode() { + std::lock_guard lock(voiceParamsMutex_); + return voiceMode_; +} + +void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { + { + std::lock_guard lock(voiceParamsMutex_); + if (voiceMode_ == mode) return; + voiceMode_ = mode; + } + rebuildVoiceEngine(); +} + +MonoTrigger ReaSamplerProcessor::monoTrigger() { + std::lock_guard lock(voiceParamsMutex_); + return monoTrigger_; +} + +void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { + { + std::lock_guard lock(voiceParamsMutex_); + if (monoTrigger_ == trigger) return; + monoTrigger_ = trigger; + } + rebuildVoiceEngine(); +} + +void ReaSamplerProcessor::setMasterGainLinear(double linear) { + // Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap = + // +24 dB). One relaxed atomic store — the audio thread reads it at the next block start; + // no rebuild, no lock (a post-sum output trim is not a keymap fact). + if (!(linear >= 0.0)) linear = 0.0; // also catches NaN + const double maxLin = masterGainMaxLinear(); + if (linear > maxLin) linear = maxLin; + masterGain_.store(static_cast(linear), std::memory_order_relaxed); +} + +void ReaSamplerProcessor::previewNoteOn(int note) { + if (note < 0) note = 0; + if (note > 127) note = 127; + const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request + // Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as + // long as we never land back on the exact value the audio thread last consumed in one step — + // 16 bits gives 65535 posts between collisions, unreachable at UI-click rates). + const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_; + const std::uint32_t packed = (static_cast(seq) << 16) | + (static_cast(vel) << 8) | + static_cast(note & 0xFF); + previewOnRequest_.store(packed, std::memory_order_release); +} + +void ReaSamplerProcessor::previewNoteOff(int note) { + if (note < 0) note = 0; + if (note > 127) note = 127; + const std::uint16_t seq = ++previewOffSeq_ == 0 ? ++previewOffSeq_ : previewOffSeq_; + const std::uint32_t packed = (static_cast(seq) << 16) | + static_cast(note & 0xFF); + previewOffRequest_.store(packed, std::memory_order_release); +} + +void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { + { + std::lock_guard lock(channelModeMutex_); + // The editor toggle is a DELIBERATE choice either way: latch explicit even on a + // same-mode click (the user confirmed the mode; the GA auto-default stops fighting it). + channelModeExplicit_ = true; + if (channelMode_ == mode) return; // no decode change: don't churn a reload + channelMode_ = mode; + } + // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no + // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread + // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering. + reloadInstrument(); +} + +} // namespace reasampler::vst diff --git a/src/vst/reaper_bridge.cpp b/src/shell/instrument/reaper_bridge.cpp similarity index 83% rename from src/vst/reaper_bridge.cpp rename to src/shell/instrument/reaper_bridge.cpp index 0c5dc49..92062df 100644 --- a/src/vst/reaper_bridge.cpp +++ b/src/shell/instrument/reaper_bridge.cpp @@ -1,11 +1,12 @@ // reaper_bridge.cpp — see reaper_bridge.h. The DAW-facing edge; keep it thin. -#include "reaper_bridge.h" +#include "shell/instrument/reaper_bridge.h" #include -#include "bridge_marshal.h" -#include "capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation) +#include "core/instrument/map/bridge_marshal.h" +#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04 grow-loop policy) +#include "core/capture/capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation) #include "ext_keys.h" // kProjExtNamespace (shared wire contract) // The VST3 base types must be included before REAPER's VST3 interface header, which @@ -38,6 +39,10 @@ DEF_CLASS_IID(Steinberg::IReaperHostApplication) namespace reasampler::vst { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using capture::projectDirOfRpp; +using instrument::map::decodeGetProjExtState; + bool ReaperBridge::connect(Steinberg::FUnknown* context) { getProjExtState_ = nullptr; enumProjExtState_ = nullptr; @@ -62,7 +67,8 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { enumProjExtState_ = reinterpret_cast( reaper->getReaperApi("EnumProjExtState")); // EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call - // persist.cpp uses, so the instrument derives the project directory identically. + // the persist shell (ext_state_io.cpp) uses, so the instrument derives the project + // directory identically. enumProjects_ = reinterpret_cast( reaper->getReaperApi("EnumProjects")); // pS-usage: the (prefix-guarded) usage publish write + the track-identity pair the @@ -92,21 +98,19 @@ std::optional ReaperBridge::readReasamplerExtState(const std::strin // GetProjExtState writes into a caller buffer; the bank blob can be large (many // samples), so grow the buffer until the value fits rather than risk a silent - // truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The - // return value is the value length; if it fits strictly inside the buffer it is - // complete, else grow and retry up to a 16 MB ceiling. - for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) { - std::vector buf(static_cast(cap), '\0'); - const int rv = getProjExtState_(proj, kProjExtNamespace(), key.c_str(), - buf.data(), cap); - if (rv <= 0) return std::nullopt; // absent / empty key - std::string s(buf.data()); - if (static_cast(s.size()) + 1 < cap) { - return decodeGetProjExtState(rv, s); - } - // else: possibly truncated -> grow and retry. - } - return std::nullopt; // pathologically large (>16 MB) — give up rather than loop + // truncation. The retry policy is the SHARED pure wire::readProjExtStateGrowing + // (T2-04 — one loop for the + // extension's persist/usage reads and this bridge read; the rules cannot drift): + // absent (rv <= 0) and the >16 MB ceiling both fold to nullopt here, and a + // complete value still runs through decodeGetProjExtState (the stale/empty-buffer + // guard) exactly as before. + const auto read = wire::readProjExtStateGrowing( + [&](char* buf, int cap) { + return getProjExtState_(proj, kProjExtNamespace(), key.c_str(), buf, cap); + }); + if (read.status != wire::GrowingExtStateRead::Status::Complete) + return std::nullopt; // absent / empty key, or pathologically large (>16 MB) + return decodeGetProjExtState(read.apiReturn, read.value); } bool ReaperBridge::writeUsageExtState(const std::string& usageKey, @@ -148,7 +152,7 @@ std::string ReaperBridge::currentTrackGuid() { std::string ReaperBridge::activeProjectDir() { if (!enumProjects_) return {}; // idx=-1 is the current project tab; the out-buffer receives the full .rpp path, - // EMPTY for a never-saved project. Same call + convention as persist.cpp; the pure + // EMPTY for a never-saved project. Same call + convention as the persist shell; the pure // projectDirOfRpp turns the .rpp path into the project directory (parent, forward- // slashed) and keeps an unsaved project's empty path empty (no default-location // fallback — the tool's invariant). diff --git a/src/vst/reaper_bridge.h b/src/shell/instrument/reaper_bridge.h similarity index 98% rename from src/vst/reaper_bridge.h rename to src/shell/instrument/reaper_bridge.h index 78582de..29bf057 100644 --- a/src/vst/reaper_bridge.h +++ b/src/shell/instrument/reaper_bridge.h @@ -86,7 +86,8 @@ private: int valOut_sz); // EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path (SDK line // ~1264). The instrument uses idx=-1 (current tab) so it follows the active project, - // and reads the .rpp path from the out-buffer exactly as persist.cpp does. + // and reads the .rpp path from the out-buffer exactly as the persist shell + // (ext_state_io.cpp) does. using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz); // SetProjExtState(proj, extname, key, value) -> int (SDK line ~6290). Used ONLY by // writeUsageExtState (prefix-guarded) — see the read-only-bank note there. diff --git a/src/vst/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h similarity index 93% rename from src/vst/reasampler_editor.h rename to src/shell/instrument/reasampler_editor.h index 238a2f8..3de98fd 100644 --- a/src/vst/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -30,13 +30,13 @@ #include "public.sdk/source/common/pluginview.h" -#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) -#include "envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit) -#include "envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam) -#include "knob_deck.h" // DeckGroupDesc / DeckLayout (r11 knob deck — Sample FB1, Zone FB2) -#include "peaks.h" // Envelope (the cached peak thumbnail) -#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) -#include "velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state) +#include "core/instrument/ui/editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) +#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit) +#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam) +#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (r11 knob deck — Sample FB1, Zone FB2) +#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail) +#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) +#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state) #ifdef _WIN32 #include @@ -46,6 +46,25 @@ class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only namespace reasampler::vst { +// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim +// is retired from the editor family; engine symbols — ChannelMode, VoiceMode, MonoTrigger, +// the voice-count constants, VelocityCurve via the engine re-export — stay in flat +// `reasampler` and resolve via the enclosing namespace). +using audio::AudioSample; +using audio::Envelope; +using instrument::map::BankChoice; +using instrument::map::PerformanceMap; +using instrument::map::PerformanceZone; +using instrument::map::SampleChoice; +using instrument::map::SampleRefEntry; +using instrument::map::SampleRefs; +using instrument::map::ZonePlaySeconds; +using instrument::ui::AmpEnvelope; +using instrument::ui::DeckGroupDesc; +using instrument::ui::EnvClampBounds; +using instrument::ui::EnvNode; +using instrument::ui::Rect; + class ReaSamplerProcessor; class ReaSamplerEditor : public Steinberg::CPluginView { @@ -201,6 +220,12 @@ private: bool handlePopupMouseDown(int w, int h, int x, int y); void onMouseDown(int x, int y); + // The Browse-modal and Zone-surface halves of the mouse-down dispatch (Q-W2v: the + // input TUs split along the face axis — onMouseDown keeps the Sample-face branch and + // delegates these two; bodies in editor_input_browse_zone.cpp). Behavior-identical + // to the former inline branches. + void mouseDownBrowse(int w, int h, int x, int y); + void mouseDownZone(int w, int h, int x, int y); void onMouseMove(int x, int y); void onMouseUp(int x, int y); // r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only @@ -318,6 +343,11 @@ private: // into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place. void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const; + // The Trigger fade-in/out knob full-scale, in SOURCE frames: kFadeMaxSeconds (2 s + // wall-clock) resolved against the live rate at use (Q-W0 T3-03 — never a baked-in + // rate). 44.1 kHz fallback before setupProcessing has run. Storage stays frames. + double fadeMaxFrames() const; + // --- S-VIEW-3 envelope overlay seam (frames <-> fraction converter) ---------- // // envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the diff --git a/src/vst/reasampler_embed.cpp b/src/shell/instrument/reasampler_embed.cpp similarity index 84% rename from src/vst/reasampler_embed.cpp rename to src/shell/instrument/reasampler_embed.cpp index 6e739fd..53e9d8d 100644 --- a/src/vst/reasampler_embed.cpp +++ b/src/shell/instrument/reasampler_embed.cpp @@ -2,21 +2,21 @@ // Windows-only (D5); guarded so a non-Windows build degrades to a stub that reports // "not supported" and draws nothing. -#include "reasampler_embed.h" +#include "shell/instrument/reasampler_embed.h" #include #include -#include "app_version.h" // vstPluginName (channel-derived embed label, S18) -#include "bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh) -#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) -#include "draw_kit.h" // the L1 draw kit: fillSurface/text (L3) -#include "editor_geometry.h" // Rect (shared with embed_strip) -#include "embed_strip.h" // the pure strip layout + hit-test +#include "core/version/app_version.h" // vstPluginName (channel-derived embed label, S18) +#include "core/instrument/map/bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh) +#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) +#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text (L3) +#include "core/instrument/ui/editor_geometry.h" // Rect (shared with embed_strip) +#include "core/instrument/ui/embed_strip.h" // the pure strip layout + hit-test #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey -#include "reaper_bridge.h" -#include "reasampler_processor.h" -#include "theme.h" // Role / InteractionState / spectralColor (L3) +#include "shell/instrument/reaper_bridge.h" +#include "shell/instrument/reasampler_processor.h" +#include "core/ui/theme.h" // Role / InteractionState / spectralColor (L3) // wdltypes.h first: it defines INT_PTR portably (and pulls on Windows), which // reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type. @@ -42,13 +42,21 @@ DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface) namespace reasampler::vst { +// Real-namespace-home using-directives (Q-W6: the namespaces.h shim is retired): +// the embed strip speaks the map vocabulary (listSamples / parseBankGeneration) and +// the pure UI layout (embed_strip / editor_geometry Rect) wholesale. +using namespace reasampler::instrument::map; +using namespace reasampler::instrument::ui; +using reasampler::ui::spectralColor; +using version::vstPluginName; + namespace { #ifdef _WIN32 // Kit adapter (Phase L, L3): the embed shell's Rect (editor_geometry) -> the kit's KitBox // (component_geometry). Every embed surface now draws by palette ROLE via the L1 kit, retiring // the local pre-L1 forest-green palette + raw GDI DrawTextA. KitBox toKitBox(const Rect& r) { - return KitBox{r.left, r.top, r.width(), r.height()}; + return KitBox{r.x, r.y, r.width, r.height}; } // A short display name for a bank sample id, from the snapshotted list (the editor's helper, @@ -198,12 +206,12 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { if (map_.zones.empty()) { // No opt-in zones authored: a faint bg/cell band spanning the keymap area so the strip // reads as "present, no zones" — the default single-capture face lives in the editor. - LICE_FillRect(bmp, layout.keymap.left, layout.keymap.top, layout.keymap.width(), - layout.keymap.height(), toLice(roleColor(Role::BgCell)), 0.5f, 0); - const std::string label = reasampler::vstPluginName() + // channel-derived (S18) + LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width, + layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0); + const std::string label = version::vstPluginName() + // channel-derived (S18) (samples_.empty() ? " (bank empty)" : " (no zones)"); - const Rect labelR{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right, - layout.keymap.bottom}; + const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(), + layout.keymap.bottom()); text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left); } else { // Draw each zone as a segment across the keymap span, first-match order (so the painted @@ -214,26 +222,26 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { for (int i = 0; i < static_cast(map_.zones.size()); ++i) { const PerformanceZone& z = map_.zones[i]; const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote); - if (r.width() <= 0) continue; + if (r.width <= 0) continue; const bool sel = (i == selectedZone_); if (sel) { // Static glow halo, then the crisp accent-primary fill. - LICE_FillRect(bmp, r.left - 2, r.top, r.width() + 4, r.height(), + LICE_FillRect(bmp, r.x - 2, r.y, r.width + 4, r.height, toLice(roleColor(Role::AccentHot)), 0.30f, 0); - LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(), + LICE_FillRect(bmp, r.x, r.y, r.width, r.height, toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); } else { const double t = ((z.lowNote + z.highNote) * 0.5) / 127.0; - LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(), + LICE_FillRect(bmp, r.x, r.y, r.width, r.height, toLice(spectralColor(t)), 0.65f, 0); } - LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, + LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); // Label the segment with the sample name when it is wide enough to read. The // selected (accent-fill) segment draws its label in bg/base for contrast (the // tight text-on-pastel pair, §4); the rest in text/primary. - if (r.width() >= 24) { - const Rect lr{r.left + 3, r.top, r.right - 2, r.bottom}; + if (r.width >= 24) { + const Rect lr = Rect::ltrb(r.x + 3, r.y, r.right() - 2, r.bottom()); text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(), Font::Label, sel ? Role::BgBase : Role::TextPrimary, Align::Left); } @@ -242,12 +250,12 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { // The level band: a recessed bg/cell channel with an accent-primary fill following the // live activity level (a direct level follow — the one permitted "motion", §3.5). - if (layout.levelBand.height() > 0) { + if (layout.levelBand.height > 0) { fillSurface(bmp, toKitBox(layout.levelBand), Role::BgCell, InteractionState::Pressed); const double level = processor_ ? processor_->embedActivityLevel() : 0.0; const Rect fill = levelFillRect(layout, level); - if (fill.width() > 0) { - LICE_FillRect(bmp, fill.left, fill.top, fill.width(), fill.height(), + if (fill.width > 0) { + LICE_FillRect(bmp, fill.x, fill.y, fill.width, fill.height, toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); } } diff --git a/src/vst/reasampler_embed.h b/src/shell/instrument/reasampler_embed.h similarity index 95% rename from src/vst/reasampler_embed.h rename to src/shell/instrument/reasampler_embed.h index 33a017c..bcc31c1 100644 --- a/src/vst/reasampler_embed.h +++ b/src/shell/instrument/reasampler_embed.h @@ -38,7 +38,7 @@ #include "pluginterfaces/base/funknown.h" -#include "sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects) +#include "core/instrument/map/sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects) // REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is // pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host @@ -51,6 +51,10 @@ namespace reasampler::vst { class ReaSamplerProcessor; +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using instrument::map::PerformanceMap; +using instrument::map::SampleChoice; + // Implements IReaperUIEmbedInterface. Lifetime is OWNED by the processor (the processor // holds the sole unique_ptr and hands out AddRef'd references from queryInterface); the // back-pointer to the processor is therefore always valid while this lives. diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp new file mode 100644 index 0000000..8f11876 --- /dev/null +++ b/src/shell/instrument/reasampler_processor.cpp @@ -0,0 +1,425 @@ +// reasampler_processor.cpp — see reasampler_processor.h. Since Q-W2v (T4-12) this TU is +// the VST3 LIFECYCLE + the REAL-TIME process() path ONLY: factory/queryInterface, +// initialize/terminate/setActive, bus setup, and the block render (MIDI marshal, preview +// mailbox drain, engine + drain sum, master-gain ramp). Component-state I/O + parameter +// accessors live in processor_state.cpp; the off-thread reload/publish family lives in +// processor_reload.cpp. process() and its per-block work stay ONE TU (T4-29): no virtual +// seam, no cross-TU call on the per-sample path. + +#include "shell/instrument/reasampler_processor.h" + +#include +#include +#include + +#include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivstevents.h" +#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) +#include "pluginterfaces/vst/vstspeaker.h" + +#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor +#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace reasampler::vst { + +namespace { + +// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a +// linear per-sample step derived from this at setupProcessing (gainRampStep_ = +// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing +// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as +// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is +// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_ +// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks. +constexpr double kGainRampSeconds = 0.020; + +} // namespace + +FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { + // The host owns the returned reference. Cast up to the combined interface the SDK + // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. + return static_cast(new ReaSamplerProcessor()); +} + +// Out-of-line so unique_ptr sees the complete type here. +ReaSamplerProcessor::~ReaSamplerProcessor() = default; + +tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { + // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for + // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed + // shell. We own the shell (unique_ptr); the borrowed reference is valid because the + // processor outlives it. All other iids fall through to the SDK's queryInterface. + if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { + if (!embed_) embed_ = std::make_unique(this); + embed_->addRef(); + *obj = static_cast(embed_.get()); + return kResultOk; + } + return SingleComponentEffect::queryInterface(iid, obj); +} + +tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { + tresult result = SingleComponentEffect::initialize(context); + if (result != kResultOk) return result; + + // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the + // instrument still loads, it just has no live bank to play. + bridge_.connect(context); + + // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no + // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of + // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders + // dual-mono through the stereo bus (both channels equal, centered), which is audibly + // identical to a mono bus but never asks the host to re-map a live instance's pins. The + // prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every + // mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The + // in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer + // write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's + // re-routing of the live instance's pins across the arrangement change. A fixed arrangement + // is the maximally-standard VSTi shape and removes that whole negotiation surface. + addEventInput(STR16("MIDI In"), 16); + addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); + + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::terminate() { + // process() is not running at terminate: free the live + draining instruments and + // drain the graveyard. Take the pointers out of the atomics first so nothing else + // races them. + std::lock_guard lock(reloadMutex_); + delete live_.exchange(nullptr); + delete draining_.exchange(nullptr); + graveyard_.clear(); + return SingleComponentEffect::terminate(); +} + +tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { + // Activating: build the instrument from the currently-selected sample so the first + // block after activation can play. Deactivating: process is now GUARANTEED stopped by + // the host, so this is the safe point to reclaim the graveyard (the displaced engines + // no reload could free while active). The build/drain are off the audio thread — + // setActive is a main/UI-thread call. + if (state) { + // Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED + // sample refs — it needs no bank read, so it plays regardless of whether the + // extension's PROJEXTSTATE has parsed yet (or the extension exists at all). + // + // #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a + // pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic + // refreshRefsFromBank copies the refs in when the bank blob is readable by + // activation time, so an upgraded project plays on load without the instrument + // ever being opened (and the next save is self-contained). Residual load-order + // race, DAW-verifiable only: if the host activates this instance BEFORE the + // project's ext-state lines parse, the lift misses here and — with no editor open — + // nothing retries until the next activation or editor tick. MIGRATION NOTE: open a + // pre-v10 instrument once after upgrading if it restores silent. + reloadInstrument(); + } else { + std::lock_guard lock(reloadMutex_); + // process is guaranteed stopped: free EVERYTHING. The live instrument too — its + // voices are frozen mid-flight, and if it survived deactivation the reactivate + // reload would displace it into the DRAIN slot, resurrecting stale sustained + // voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above), + // so nothing is lost by clearing here. + delete live_.exchange(nullptr); + delete draining_.exchange(nullptr); + graveyard_.clear(); + } + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { + sampleRate_ = setup.sampleRate; + maxBlockSize_ = setup.maxSamplesPerBlock; + // T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at + // every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host + // guarantees setupProcessing never overlaps process), read on the audio thread only. + if (sampleRate_ > 0.0) { + gainRampStep_ = static_cast(1.0 / (kGainRampSeconds * sampleRate_)); + } + return SingleComponentEffect::setupProcessing(setup); +} + +tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( + SpeakerArrangement* inputs, int32 numIns, + SpeakerArrangement* outputs, int32 numOuts) { + // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a + // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected. + // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse) + // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a + // proposal keeps a valid arrangement of its own) — the host adapts its routing to us. + if (numIns < 0 || numOuts < 0) return kInvalidArgument; + if (numIns > 0) return kResultFalse; // no audio input bus to arrange + if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue; + return kResultFalse; +} + +tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { + // REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments + // once for the whole block (two atomic acquires), then publish the MINIMUM installedAt + // over the pointers held so the off-thread graveyard pruner knows exactly which + // generations this block is holding (see the header proof). + // + // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an + // ordering race: reading reloadGeneration_ after the slots could observe a generation + // newer than the pointers we actually hold, causing the pruner to free an instrument + // process is still reading. installedAt was set on the reload path before the atomic + // exchange that made the instrument visible. + // + // The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the + // last reload: its already-sounding voices keep rendering (and receive note-offs) so a + // curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons. + // A racing reload can briefly leave the same pointer in both slots (live_ was loaded + // before the swap, draining_ after); collapse that to live-only so one engine is never + // advanced twice per frame. + LoadedInstrument* inst = live_.load(std::memory_order_acquire); + LoadedInstrument* drain = draining_.load(std::memory_order_acquire); + if (drain == inst) drain = nullptr; + std::uint64_t heldGen = 0; + if (inst && drain) { + heldGen = inst->installedAt < drain->installedAt ? inst->installedAt + : drain->installedAt; + } else if (inst) { + heldGen = inst->installedAt; + } else if (drain) { + heldGen = drain->installedAt; + } + processGeneration_.store(heldGen, std::memory_order_release); + + // Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine + // voice silent) by naming its OWN installedAt (0 = no drain / still + // sounding). Evaluated at block START — idleness is monotone for a drain (it receives no + // note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply + // publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe. + drainIdleGeneration_.store( + (drain && drain->fullyIdle()) ? drain->installedAt : 0, + std::memory_order_relaxed); + + // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps + // events at block granularity (no per-event sample-offset split) — audible timing is + // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. + // Note-offs also route to the DRAIN engine so a note held across a reload releases + // its old-snapshot voice too (otherwise it would sustain until the next reload). + if (data.inputEvents) { + const int32 count = data.inputEvents->getEventCount(); + for (int32 i = 0; i < count; ++i) { + Event e; + if (data.inputEvents->getEvent(i, e) != kResultOk) continue; + if (e.type == Event::kNoteOnEvent) { + // A note-on with velocity 0 is a note-off by MIDI convention. + const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); + if (vel <= 0) { + if (inst) inst->engine.noteOff(e.noteOn.pitch); + if (drain) drain->engine.noteOff(e.noteOn.pitch); + } else if (inst) { + inst->engine.noteOn(e.noteOn.pitch, vel); + } + } else if (e.type == Event::kNoteOffEvent) { + if (inst) inst->engine.noteOff(e.noteOff.pitch); + if (drain) drain->engine.noteOff(e.noteOff.pitch); + } else if (e.type == Event::kLegacyMIDICCOutEvent) { + // PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a + // VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism + // — the type is nominally an output event; DAW-verify, see handoff). + // CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR + // release tail; Trigger one-shots play through their bounded play length. + // CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless + // of play mode, including Trigger one-shots that ignore CC 123. This is the + // true "panic" for a ringing one-shot (e.g. a full-length capture). + // Both clear the mono held stack. Both apply to live AND drain. A ringing + // preview note is a real engine voice since the PreviewCard retirement, so + // the panics cover it with no separate routing. allNotesOff / allSoundsOff + // are RT-safe (no allocation, bounded scans). + const auto cc = static_cast(e.midiCCOut.controlNumber); + if (cc == kCtrlAllSoundsOff) { + if (inst) inst->engine.allSoundsOff(); + if (drain) drain->engine.allSoundsOff(); + } else if (cc == kCtrlAllNotesOff) { + if (inst) inst->engine.allNotesOff(); + if (drain) drain->engine.allNotesOff(); + } + } + } + } + + // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed + // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last + // one we consumed; fire it once, then latch the sequence so the same request never re-fires. + // Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact + // noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real + // voice: it counts against the voice count, can steal / be stolen, and respects + // Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's + // isolation). The editor posts the root note, so it plays at unity. + // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument + // is loaded does not re-fire stale on the next instrument load. + { + const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire); + const std::uint16_t onSeq = static_cast(on >> 16); + if (onSeq != 0 && onSeq != previewOnConsumed_) { + previewOnConsumed_ = onSeq; + if (inst) { + const int vel = static_cast((on >> 8) & 0xFF); + const int note = static_cast(on & 0xFF); + if (vel > 0) inst->engine.noteOn(note, vel); + } + } + } + { + const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); + const std::uint16_t offSeq = static_cast(off >> 16); + if (offSeq != 0 && offSeq != previewOffConsumed_) { + // Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending + // while nothing was loaded would otherwise survive until a (heal) reload lands + // and release the NEXT preview press in the same block. + previewOffConsumed_ = offSeq; + // Route the preview note-off to BOTH engines (mirror of the host note-off): a + // preview held across a reload — e.g. a curve edit committed mid-press — must + // release the old-snapshot voice now draining, not just the (fresh) live one. + // NOTE: preview shares the host-MIDI note space — noteOff releases the newest + // voice at that pitch, so a preview release can release a host-held note at + // the same pitch (inherent to routing preview through the real note path). + if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); + if (drain) drain->engine.noteOff(static_cast(off & 0xFF)); + } + } + + if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { + embedPeak_.store(0.f, std::memory_order_relaxed); + return kResultOk; + } + AudioBusBuffers& out = data.outputs[0]; + const int32 frames = data.numSamples; + + // 64-bit host processing is not supported by the mono float core; emit silence + // rather than mis-render. REAPER runs 32-bit float by default. + if (data.symbolicSampleSize != kSample32) { + embedPeak_.store(0.f, std::memory_order_relaxed); + for (int32 ch = 0; ch < out.numChannels; ++ch) { + if (double* buf = out.channelBuffers64[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; + } + } + out.silenceFlags = (out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1); + return kResultOk; + } + + // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked + // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here + // we simply match the buffers the host handed us: >=2 channels -> true stereo render into + // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the + // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. + float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; + float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; + if (ch0 && ch1) { + // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo + // path (both channels equal), so a mono capture in stereo mode is centered, not silent. + // The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer). + for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } + if (inst) inst->engine.render(ch0, ch1, static_cast(frames)); + if (drain) drain->engine.render(ch0, ch1, static_cast(frames)); + // FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so + // continuous knob drags produce no zipper noise and the true-zero bottom causes no click. + // Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the + // actual output. Branch-free inner loop; early-out when already at target. RT-safe. + { + const float gTarget = masterGain_.load(std::memory_order_relaxed); + const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gSnap = 0.5f * gStep; + const float diff = gTarget - gainCurrent_; + if (diff < -gSnap || diff > gSnap) { + // Ramp toward target: step per sample, then apply the per-sample gain. + for (int32 i = 0; i < frames; ++i) { + const float d = gTarget - gainCurrent_; + if (d > gStep) gainCurrent_ += gStep; + else if (d < -gStep) gainCurrent_ -= gStep; + else gainCurrent_ = gTarget; + ch0[i] *= gainCurrent_; + ch1[i] *= gainCurrent_; + } + } else { + gainCurrent_ = gTarget; + if (gTarget != 1.f) { + for (int32 i = 0; i < frames; ++i) { ch0[i] *= gTarget; ch1[i] *= gTarget; } + } + } + } + // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). + for (int32 ch = 2; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; + if (a0 > peak) peak = a0; + if (a1 > peak) peak = a1; + } + embedPeak_.store(peak, std::memory_order_relaxed); + } else if (ch0) { + // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; + // the replicate is defensive for a host that still hands >1 channel on a mono bus). + for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; + if (inst) inst->engine.render(ch0, static_cast(frames)); + if (drain) drain->engine.render(ch0, static_cast(frames)); + // FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch: + // post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe. + { + const float gTarget = masterGain_.load(std::memory_order_relaxed); + const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gSnap = 0.5f * gStep; + const float diff = gTarget - gainCurrent_; + if (diff < -gSnap || diff > gSnap) { + for (int32 i = 0; i < frames; ++i) { + const float d = gTarget - gainCurrent_; + if (d > gStep) gainCurrent_ += gStep; + else if (d < -gStep) gainCurrent_ -= gStep; + else gainCurrent_ = gTarget; + ch0[i] *= gainCurrent_; + } + } else { + gainCurrent_ = gTarget; + if (gTarget != 1.f) { + for (int32 i = 0; i < frames; ++i) ch0[i] *= gTarget; + } + } + } + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + if (a > peak) peak = a; + } + embedPeak_.store(peak, std::memory_order_relaxed); + for (int32 ch = 1; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + } + + // Report silence only when nothing is loaded (lets the host optimize when idle). + // With an instrument loaded — or a drain snapshot still ringing out — we clear the + // flag so a ringing voice is not skipped. + out.silenceFlags = (inst || drain) ? 0 + : ((out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1)); + return kResultOk; +} + +IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { + if (name && FIDStringsEqual(name, ViewType::kEditor)) { + return new ReaSamplerEditor(this); + } + return nullptr; +} + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h similarity index 96% rename from src/vst/reasampler_processor.h rename to src/shell/instrument/reasampler_processor.h index 703884c..3c01c6c 100644 --- a/src/vst/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -35,12 +35,22 @@ #include "public.sdk/source/vst/vstsinglecomponenteffect.h" -#include "reaper_bridge.h" -#include "sample_map.h" // PerformanceMap (the instrument's owned zoned keymap) -#include "sampler_core.h" +#include "shell/instrument/reaper_bridge.h" +#include "core/instrument/map/sample_map.h" // PerformanceMap (the instrument's owned zoned keymap) +#include "core/instrument/map/component_state_io.h" // ComponentState codec (Q-W2v split) +#include "core/instrument/engine/sampler_core.h" namespace reasampler::vst { +// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim +// is retired from the processor family; the engine family's symbols — Keymap, VoiceEngine, +// ChannelMode, VoiceMode, MonoTrigger, the voice-count constants — still live in flat +// `reasampler` and resolve via the enclosing namespace). +using instrument::map::ComponentState; +using instrument::map::PerformanceMap; +using instrument::map::SampleRefs; +using instrument::map::kPreviewVelocityDefault; + class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface) // One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice @@ -454,13 +464,18 @@ private: // FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free // atomic — the target the UI thread writes; the audio thread ramps gainCurrent_ toward - // it per-sample each block (linear interpolation, ~20 ms at 48 kHz / 256-frame block) + // it per-sample each block (linear interpolation, ~20 ms wall-clock at every host rate) // so sudden knob moves produce no zipper noise and the true-zero bottom causes no click. std::atomic masterGain_{1.0f}; // The audio-thread running gain value: tracks masterGain_ across blocks, stepping at - // most kGainRampRate per sample toward the target. Starts at unity (pre-FB1 default). + // most gainRampStep_ per sample toward the target. Starts at unity (pre-FB1 default). // Written and read exclusively on the audio thread — no atomics needed. float gainCurrent_ = 1.0f; + // T3-01: the per-sample ramp step, derived from kGainRampSeconds (20 ms wall-clock) + // against the live host rate in setupProcessing — never a baked-in rate. The default is + // the 48 kHz value so behavior before the first setupProcessing is unchanged. Written in + // setupProcessing (host-serialized against process), read on the audio thread. + float gainRampStep_ = 1.0f / 960.0f; // --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) --------- // The editor's preview-trigger button posts a note-on/off request from the UI thread; process() diff --git a/src/vst/reasampler_vst.h b/src/shell/instrument/reasampler_vst.h similarity index 96% rename from src/vst/reasampler_vst.h rename to src/shell/instrument/reasampler_vst.h index abd5fc8..3993279 100644 --- a/src/vst/reasampler_vst.h +++ b/src/shell/instrument/reasampler_vst.h @@ -21,7 +21,7 @@ #include "pluginterfaces/base/funknown.h" -#include "reasampler_uid.h" // the FROZEN UID macros + channel selection (SDK-free values) +#include "core/wire/reasampler_uid.h" // the FROZEN UID macros + channel selection (SDK-free values) namespace reasampler::vst { diff --git a/src/vst/vst_entry.cpp b/src/shell/instrument/vst_entry.cpp similarity index 92% rename from src/vst/vst_entry.cpp rename to src/shell/instrument/vst_entry.cpp index ea49a15..0ca6ed3 100644 --- a/src/vst/vst_entry.cpp +++ b/src/shell/instrument/vst_entry.cpp @@ -21,10 +21,10 @@ #include "pluginterfaces/vst/ivstaudioprocessor.h" // kVstAudioEffectClass, PlugType -#include "app_version.h" // vstPluginName / appVersion — the channel-derived identity +#include "core/version/app_version.h" // vstPluginName / appVersion — the channel-derived identity #include "ext_keys.h" // kProjExtNamespace — the pairing-surface assertion target -#include "reasampler_processor.h" -#include "reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*) +#include "shell/instrument/reasampler_processor.h" +#include "shell/instrument/reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*) // CHANNEL PAIRING INVARIANT (S18). The instrument's PLUGIN identity forks by the ONE channel // bit (REASAMPLER_CHANNEL_IS_BETA — the class UID selected in reasampler_vst.h, the filename @@ -73,10 +73,10 @@ DEF_CLASS2(INLINE_UID(REASAMPLER_ACTIVE_UID_1, REASAMPLER_ACTIVE_UID_2, REASAMPLER_ACTIVE_UID_3, REASAMPLER_ACTIVE_UID_4), Steinberg::PClassInfo::kManyInstances, // cardinality kVstAudioEffectClass, // component category (fixed) - reasampler::vstPluginName().c_str(), // plug-in display name (channel-derived) + reasampler::version::vstPluginName().c_str(), // plug-in display name (channel-derived) 0, // single-component => 0 Steinberg::Vst::PlugType::kInstrumentSynthSampler, // subcategory - reasampler::appVersion().c_str(), // plug-in version (channel: -beta render) + reasampler::version::appVersion().c_str(), // plug-in version (channel: -beta render) kVstVersionString, // VST3 SDK version (fixed) reasampler::vst::ReaSamplerProcessor::createInstance) diff --git a/src/draw_kit.cpp b/src/shell/panel/draw_kit.cpp similarity index 96% rename from src/draw_kit.cpp rename to src/shell/panel/draw_kit.cpp index 4c935fa..c734d01 100644 --- a/src/draw_kit.cpp +++ b/src/shell/panel/draw_kit.cpp @@ -4,14 +4,14 @@ // touches LICE + SWELL. All colors come from the pure `theme` module; all geometry from // the pure `component_geometry` module. DAW-verified, not unit-tested. -#include "draw_kit.h" +#include "shell/panel/draw_kit.h" #include -#include "bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure) +#include "core/ui/bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure) // SWELL / LICE. On Windows use native Win32 (windows.h first); on mac/linux SWELL is -// provided by the host. Mirrors bank_panel.cpp's include discipline. +// provided by the host. Mirrors the panel TUs' (shell/panel/) include discipline. #ifdef _WIN32 #include #else @@ -23,6 +23,15 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using audio::ChannelEnvelope; +using audio::columnMinMax; +using audio::MinMax; +using ui::compressAmplitudeForDisplay; +using ui::roleColor; +using ui::roleColorState; +using ui::spectralColor; + // --- KitColor <-> LICE boundary ---------------------------------------------- // The one place a pure KitColor becomes a LICE_pixel. Verified packing: LICE_RGBA(r,g,b,a) diff --git a/src/draw_kit.h b/src/shell/panel/draw_kit.h similarity index 91% rename from src/draw_kit.h rename to src/shell/panel/draw_kit.h index ba69dae..311d308 100644 --- a/src/draw_kit.h +++ b/src/shell/panel/draw_kit.h @@ -23,9 +23,9 @@ // DOUBLE-BUFFER DISCIPLINE (§3.5 "zero-jank"): every function here draws into the caller's // offscreen LICE_IBitmap; the caller BitBlt's once. Nothing here draws direct-to-DC. -#include "component_geometry.h" // KitBox / SliderGeometry — the pure geometry the shell draws -#include "peaks.h" // Envelope — the waveform primitive's input -#include "theme.h" // Role / InteractionState / KitColor / TextClass +#include "core/ui/component_geometry.h" // KitBox / SliderGeometry — the pure geometry the shell draws +#include "core/audio/peaks.h" // Envelope — the waveform primitive's input +#include "core/ui/theme.h" // Role / InteractionState / KitColor / TextClass // LICE types at the boundary (this is the shell half). LICE_IBitmap is forward-declared // to keep the header light. LICE_pixel is a typedef (unsigned int) — not forward-declarable @@ -40,6 +40,19 @@ class LICE_IBitmap; namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the interim core/namespaces.h shim +// is retired; the kit's pure vocabulary names its Q-W1 homes explicitly). These are +// deliberate re-exports: every draw_kit consumer speaks these types at the call +// boundary, so they surface here exactly as panel_state.h surfaces the panel's. +using audio::Envelope; +using ui::InteractionState; +using ui::KitBox; +using ui::KitButtonBox; +using ui::KitColor; +using ui::ListRowBox; +using ui::Role; +using ui::SliderGeometry; + // The kit's four cached fonts (§3.1 type scale). Consumers pass a Font to text() to pick // the size/weight; the kit maps it to the matching LICE_CachedFont. enum class Font { diff --git a/src/shell/panel/panel_audition.cpp b/src/shell/panel/panel_audition.cpp new file mode 100644 index 0000000..d05136f --- /dev/null +++ b/src/shell/panel/panel_audition.cpp @@ -0,0 +1,117 @@ +// panel_audition.cpp — the audition/preview engine seam of the docked bank panel +// (Q-W2 split of bank_panel.cpp; M5 Wave B). HOT PATH GUARDRAIL (T4-28 / Q-W2): the +// preview path stays a DIRECT free-function call-through — no interface, no virtual +// dispatch, no added header->TU indirection; the idle path is unchanged in shape. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include + +#include "shell/panel/panel_state.h" + +// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h): +// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the +// STOCK symbols (not SWS-only) — see the audition section below. +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_PlayPreview +#define REAPERAPI_WANT_StopPreview +#define REAPERAPI_WANT_PCM_Source_CreateFromFile +#define REAPERAPI_WANT_PCM_Source_Destroy +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +// --- Audition preview --------------------------------------------------------- +// +// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW +// playback only. It NEVER inserts into the arrange, creates items/tracks, or +// mutates the project or bank. PlayPreview streams a caller-owned PCM_source +// through REAPER's preview bus and touches nothing in the project. +// +// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by +// signature/struct, not semantics — DAW-verify): +// 1. REAPER's audio thread reads the preview_register_t by POINTER while the +// preview is active (the struct's own comment mandates a cs/mutex we init), +// so the register must outlive playback — we hold it in g_panel (static), +// never on the stack. +// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it +// returns, making it safe to PCM_Source_Destroy the source immediately after. +// This is the conventional contract (SWS' preview helpers rely on it) but is +// NOT documented in the header — flagged. If a rare race surfaced, the fix is +// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence). +// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not +// set mono (&1024). volume 1.0, loop false, curpos 0. + +void initPreview() { + if (g_panel.previewInited) return; +#ifdef _WIN32 + InitializeCriticalSection(&g_panel.preview.cs); +#else + pthread_mutex_init(&g_panel.preview.mutex, nullptr); +#endif + g_panel.previewInited = true; +} + +void stopAudition() { + if (g_panel.previewActive) { + StopPreview(&g_panel.preview); + g_panel.previewActive = false; + } + if (g_panel.previewSrc) { + PCM_Source_Destroy(g_panel.previewSrc); + g_panel.previewSrc = nullptr; + } + g_panel.preview.src = nullptr; +} + +void deinitPreview() { + if (!g_panel.previewInited) return; +#ifdef _WIN32 + DeleteCriticalSection(&g_panel.preview.cs); +#else + pthread_mutex_destroy(&g_panel.preview.mutex); +#endif + g_panel.previewInited = false; +} + +// Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank. +// L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw +// BankModel position. +void startAudition(int idx) { + stopAudition(); + + const BankModel* index = indexForRegion(g_panel.focusedRegion); + if (!index) return; + const RegionDisplay disp = focusedDisplay(); + if (idx < 0 || idx >= disp.occupiedCount()) return; + const Sample* s = index->query(disp.orderedIds[static_cast(idx)]); + if (!s) return; + + const std::string projectDir = currentProjectDir(); + const std::string abs = resolveBankFile(projectDir, s->relativePath); + if (abs.empty()) return; + + PCM_source* src = PCM_Source_CreateFromFile(abs.c_str()); + if (!src) return; + + g_panel.preview.src = src; + g_panel.preview.m_out_chan = 0; + g_panel.preview.curpos = 0.0; + g_panel.preview.loop = false; + g_panel.preview.volume = 1.0; + g_panel.preview.peakvol[0] = 0.0; + g_panel.preview.peakvol[1] = 0.0; + g_panel.preview.preview_track = nullptr; + + if (PlayPreview(&g_panel.preview) != 0) { + g_panel.previewSrc = src; + g_panel.previewActive = true; + } else { + PCM_Source_Destroy(src); + g_panel.preview.src = nullptr; + } +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp new file mode 100644 index 0000000..9b4fce0 --- /dev/null +++ b/src/shell/panel/panel_bank_ops.cpp @@ -0,0 +1,441 @@ +// panel_bank_ops.cpp — the bank-CRUD-UX + menus seam of the docked bank panel +// (Q-W2 split of the former bank_panel god-module; Phase B4/B5). Since Q-W6 the +// promptless bank verbs live in shell/bank_ops (model op + persistBankOp, taking +// ReaSamplerSession&); this TU is the panel's THIN UX SKIN over them — the menu +// handlers (prompts / confirms / message boxes / panel-state nudges / repaint), +// the book/bank accessors, the popup menus that drive them, and the selection-id / +// OS-drag path resolvers. The bindable bank_actions family is the sibling skin. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include +#include +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_bank_ops.h" + +#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs (the Q-W6 non-UI seam) +#include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetUserInputs +#define REAPERAPI_WANT_ShowMessageBox +#define REAPERAPI_WANT_Main_OnCommand +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +namespace fs = std::filesystem; + +// --- Current-project directory (mirrors the persist shell's derivation, ext_state_io.cpp) +std::string currentProjectDir() { + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + std::string rpp(buf.data()); + if (rpp.empty()) return {}; + return normalizeSlashes(fs::path(rpp).parent_path().string()); +} + +// --- Book / bank accessors ---------------------------------------------------- + +BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; } + +// The BankModel a region currently displays. Pool region -> the pool; banks region -> +// the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved +// FRESH every call (never cached across a mutation). +const BankModel* indexForRegion(Region r) { + BankBook* b = book(); + if (!b) return nullptr; + if (r == Region::Pool) return &b->pool().index; + if (g_panel.shownBankId.empty()) return nullptr; + return b->index(g_panel.shownBankId); +} + +// The bank id a region displays (pool id, or the shown tab's id; "" when none). +std::string bankIdForRegion(Region r) { + if (r == Region::Pool) return std::string(kPoolBankId); + return g_panel.shownBankId; +} + +// The named banks in ordinal order (pool excluded) — the tabs. Resolved fresh. +std::vector namedBanks() { + std::vector out; + BankBook* b = book(); + if (!b) return out; + for (const Bank& bk : b->banks()) + if (!bk.isPool()) out.push_back(&bk); + return out; +} + +// --- Bank management ops (id-keyed; THIN UX SKINS over the bankOp* verbs) ------ +// +// Q-W4/Q-W6: each handler here owns only the panel's UX (prompts / confirms / +// message boxes / panel-state nudges / repaint); the model op + persist is the +// shared bankOp* inner verb (shell/bank_ops), which takes the live session by +// reference — the book() check answers the one session-liveness question per +// handler. After a STRUCTURAL mutation (create/delete/evacuate) any +// Bank*/BankModel& is invalid — we resolve fresh, pass ids, and let the next +// refreshFingerprint repaint. On an unsaved project the empty-close discard in +// persistBankOp ensures no stale state survives (matches the capture/B3 +// quiet-persist idiom). + +void doCreateBank() { + if (!book()) return; + std::string name; + if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; + const std::string id = bankOpCreate(*g_panel.session, name); + if (id.empty()) { + ShowMessageBox("A bank with that name already exists.", + "ReaSampler: create bank", 0); + return; + } + g_panel.shownBankId = id; // show the freshly-created bank + g_panel.focusedRegion = Region::Banks; + invalidatePanel(); +} + +namespace { + +void doRenameBank(const std::string& bankId) { + if (!book()) return; + const Bank* bk = book()->bank(bankId); + if (!bk || bk->isPool()) return; + const std::string current = bk->displayName; // copy before any mutation + std::string newName; + if (!promptBankName("ReaSampler: rename bank", "New name:", current, newName)) return; + if (!bankOpRename(*g_panel.session, bankId, newName)) { + ShowMessageBox("Another bank already uses that name.", + "ReaSampler: rename bank", 0); + return; + } + invalidatePanel(); +} + +// Delete with the RICHER confirm-on-non-empty affordance (B4): the confirm names the +// member count AND offers evacuate as the one-click alternative (Yes=delete anyway, +// No=evacuate-then-keep, Cancel=abort) — richer than B3's basic YESNO. +void doDeleteBank(const std::string& bankId) { + if (!book()) return; + const Bank* bk = book()->bank(bankId); + if (!bk || bk->isPool()) return; + const std::size_t members = bk->index.size(); // read BEFORE any mutation + const std::string name = bk->displayName; + + if (members > 0) { + const std::string msg = + "\"" + name + "\" holds " + std::to_string(members) + + (members == 1 ? " sample" : " samples") + + ".\n\nYes -- delete the bank AND drop its samples (files are kept on disk " + "but no bank references them until prune).\nNo -- Evacuate them to the " + "pool first, then delete the empty bank (keeps the samples).\nCancel -- " + "do nothing."; + // 3 == MB_YESNOCANCEL. 6=Yes, 7=No, 2=Cancel (SDK). + const int r = ShowMessageBox(msg.c_str(), + "ReaSampler: delete non-empty bank", 3); + if (r == 2) return; // Cancel + if (r == 7) { // No -> evacuate, then delete empty + if (!book()->evacuate(bankId)) return; + // book() may have reallocated; re-resolve nothing (we pass the id again). + } + // r == 6 (Yes) falls through to a plain delete (drops members). + } + // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then- + // delete path moved/dropped members) — both change what a live instance could play. An + // empty-bank delete is purely organizational, no bump. The ORIGINAL member count decides + // (the No-path evacuated them moments ago, but the membership still changed). + if (!bankOpDelete(*g_panel.session, bankId, /*bumpGeneration=*/members > 0)) return; + // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, + // nudge focus to the pool so the selection has a valid home. + if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; + invalidatePanel(); +} + +void doEvacuateBank(const std::string& bankId) { + if (!book()) return; + const Bank* bk = book()->bank(bankId); + if (!bk || bk->isPool()) return; + if (!bankOpEvacuate(*g_panel.session, bankId)) return; + invalidatePanel(); +} + +void doActivateBank(const std::string& bankId) { + if (!book()) return; // no live session — nothing to activate against + if (!bankOpActivate(*g_panel.session, bankId)) return; // rejects an unknown id + invalidatePanel(); +} + +} // namespace + +// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Thin panel +// skin over bankOpTransfer (the one-home verb owns the loop, the verb-aware no-op +// guardrail, and the undo-batched persist); this layer clears the stale selection +// and repaints on an actual mutation. +void transferSamples(const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy) { + if (!book()) return; // no live session — nothing to transfer within + if (!bankOpTransfer(*g_panel.session, sampleIds, srcBankId, destBankId, copy)) + return; // nothing changed — no persist, no undo point + // The selection indexed into the source; after a move those indices are stale, so + // clear it (the fingerprint pass will also clear, but do it now for immediacy). + g_panel.selection = Selection{}; + invalidatePanel(); +} + +// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Thin panel skin +// over bankOpRemove — see the verb for the never-deletes-bytes / silent-remove / +// one-Ctrl-Z contract. Clears the stale selection and repaints on an actual removal. +void removeSamples(const std::vector& sampleIds, + const std::string& srcBankId) { + if (!book()) return; // no live session — nothing to remove from + if (!bankOpRemove(*g_panel.session, sampleIds, srcBankId)) + return; // nothing changed — no persist, no undo point + // The selection indexed into the source; after a remove those indices are stale, so + // clear it (the fingerprint pass will also clear, but do it now for immediacy). + g_panel.selection = Selection{}; + invalidatePanel(); +} + +// The selection's sample ids resolved against the FOCUSED region's bank (source of a +// move/copy). Returns ids in bank order; empty when nothing selected. +std::vector focusedSelectionIds() { + // L7: selection ordinals index the DISPLAY (slot) order, not BankModel insertion order. + // orderedIds[i] is the id at selection ordinal i. + std::vector ids; + const RegionDisplay disp = focusedDisplay(); + const int count = disp.occupiedCount(); + for (int i : g_panel.selection.indices) + if (i >= 0 && i < count) ids.push_back(disp.orderedIds[static_cast(i)]); + return ids; +} + +// Resolves the ARMED drag payload (g_panel.dragSampleIds, from g_panel.dragSourceBankId) to +// the absolute, existing-file path list for a native OS drag-out (M11). Reuses the SAME M4 +// path machinery the panel uses for audition/insert (resolveBankFile over the current +// project dir) — no temp copies; the drag points straight at the on-disk bank files. Each +// id is looked up in its SOURCE bank's index (the payload's origin, not the focused region, +// which can differ once the pointer roams), resolved, stat'd, then handed to the pure +// drag_out::assemblePathList for dedupe + skip-missing/unresolved policy. Read-only: no +// mutation of sample / index / selection (invariant #2). +std::vector resolveDragPathsForOs() { + std::vector resolved; + BankBook* b = book(); + if (!b) return {}; + const BankModel* idx = b->index(g_panel.dragSourceBankId); + if (!idx) return {}; + + const std::string projectDir = currentProjectDir(); + resolved.reserve(g_panel.dragSampleIds.size()); + for (const std::string& sid : g_panel.dragSampleIds) { + const Sample* s = idx->query(sid); + if (!s) continue; // stale id — the pure layer would skip it anyway; nothing to resolve + ResolvedSample rs; + rs.absolutePath = resolveBankFile(projectDir, s->relativePath); + rs.fileExists = !rs.absolutePath.empty() && fs::exists(fs::path(rs.absolutePath)); + resolved.push_back(std::move(rs)); + } + return assemblePathList(resolved).paths; +} + +// --- Popup menus -------------------------------------------------------------- +// +// SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu +// -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of +// (label -> small int command), track it at screen coords, and switch on the return. +// Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD +// hands the chosen id straight back, so no hookcommand routing is involved. + +namespace { + +// Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both +// accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative +// position appending. Win32 and SWELL both treat pos < 0 as an append. +void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) { + UINT flags = MF_BYPOSITION | MF_STRING; + if (grayed) flags |= MF_GRAYED; + InsertMenu(menu, -1, flags, id, text); +} +void menuSeparator(HMENU menu) { + InsertMenu(menu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr); +} + +// Menu command ids (local to a popup). +enum : unsigned int { + kMenuNone = 0, + kMenuActivate = 100, + kMenuRename, + kMenuDelete, + kMenuEvacuate, + kMenuCreate, + kMenuRemove, // remove selected sample(s) from the source bank (B5) + kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index + kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index +}; + +} // namespace + +// Shows the right-click context menu for a named-bank TAB: activate / rename / delete +// / evacuate that bank, plus a create entry. Drives the id-keyed ops. +void showTabMenu(int screenX, int screenY, const std::string& bankId) { + if (!book()) return; + const Bank* bk = book()->bank(bankId); + if (!bk || bk->isPool()) return; + const bool isActive = book()->activeBankId() == bankId; + const bool nonEmpty = !bk->index.empty(); + + HMENU menu = CreatePopupMenu(); + menuAppend(menu, kMenuActivate, + isActive ? "Active (capture target)" : "Activate (make capture target)", + /*grayed=*/isActive); + menuSeparator(menu); + menuAppend(menu, kMenuRename, "Rename..."); + menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty); + menuAppend(menu, kMenuDelete, "Delete..."); + menuSeparator(menu); + menuAppend(menu, kMenuCreate, "New bank..."); + + const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, + g_panel.hwnd, nullptr); + DestroyMenu(menu); + + switch (cmd) { + case kMenuActivate: doActivateBank(bankId); break; + case kMenuRename: doRenameBank(bankId); break; + case kMenuEvacuate: doEvacuateBank(bankId); break; + case kMenuDelete: doDeleteBank(bankId); break; + case kMenuCreate: doCreateBank(); break; + default: break; + } +} + +// Opens the top-toolbar overflow ("⋯" More) popup at the button's screen position and fires the +// chosen rare-capture variant's command (L5 refinement 1). Menu ids are LOCAL to the popup +// (1-based ordinal into overflowMenuRows); TPM_RETURNCMD hands the chosen id back, then we +// resolve + fire the corresponding registered command id via the SAME contract the visible +// buttons use. Defined here (after menuAppend/menuSeparator); forward-declared above. +void showMoreMenu() { + if (!g_panel.hwnd) return; + const std::vector rows = overflowMenuRows(); + if (rows.empty()) return; + + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const MenuButtonRect mb = topMenuButtonRect(cr.right - cr.left); + if (mb.empty()) return; + + HMENU menu = CreatePopupMenu(); + for (std::size_t i = 0; i < rows.size(); ++i) { + const int cmd = resolveBarCommandId(rows[i]); + // Grey a variant not registered on this channel (defensive — all three are registered). + menuAppend(menu, static_cast(i + 1), rows[i].fullName.c_str(), + /*grayed=*/cmd == 0); + } + + // Anchor the popup at the button's bottom-left, in screen coords. + POINT pt{mb.x, mb.y + mb.height}; + ClientToScreen(g_panel.hwnd, &pt); + const int chosen = TrackPopupMenu(menu, TPM_RETURNCMD, pt.x, pt.y, 0, g_panel.hwnd, nullptr); + DestroyMenu(menu); + + if (chosen >= 1 && chosen <= static_cast(rows.size())) { + const int cmd = resolveBarCommandId(rows[static_cast(chosen - 1)]); + if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); + } +} + +// Shows the move/copy menu for the current selection (the SOURCE is the focused +// region's bank). Lists every OTHER bank (pool + named) as a move destination, then a +// copy submenu-free flat list (copy entries follow the move block). Move is the +// default (listed first); copy is the deliberate secondary act. +void showSelectionMenu(int screenX, int screenY) { + const std::vector sel = focusedSelectionIds(); + if (sel.empty()) return; + const std::string srcId = bankIdForRegion(g_panel.focusedRegion); + + // Destinations: pool + named banks, excluding the source. Ordinal order. + struct Dest { std::string id; std::string name; }; + std::vector dests; + if (srcId != std::string(kPoolBankId)) + dests.push_back({std::string(kPoolBankId), std::string(kPoolBankName)}); + for (const Bank* bk : namedBanks()) + if (bk->id != srcId) dests.push_back({bk->id, bk->displayName}); + + const std::string label = std::to_string(sel.size()) + + (sel.size() == 1 ? " sample" : " samples"); + + HMENU menu = CreatePopupMenu(); + // Move/copy blocks appear only when there is another bank to transfer to; Remove is + // always offered (it needs no destination — it drops the entry from the source). + if (!dests.empty()) { + menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); + for (std::size_t i = 0; i < dests.size(); ++i) + menuAppend(menu, kMenuMoveBase + static_cast(i), + (" " + dests[i].name).c_str()); + menuSeparator(menu); + menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true); + for (std::size_t i = 0; i < dests.size(); ++i) + menuAppend(menu, kMenuCopyBase + static_cast(i), + (" " + dests[i].name).c_str()); + menuSeparator(menu); + } + menuAppend(menu, kMenuRemove, ("Remove " + label + "...").c_str()); + + const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, + g_panel.hwnd, nullptr); + DestroyMenu(menu); + if (cmd == static_cast(kMenuRemove)) { + removeSamples(sel, srcId); + } else if (cmd >= static_cast(kMenuMoveBase) && + cmd < static_cast(kMenuMoveBase + dests.size())) { + transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false); + } else if (cmd >= static_cast(kMenuCopyBase) && + cmd < static_cast(kMenuCopyBase + dests.size())) { + transferSamples(sel, srcId, dests[cmd - kMenuCopyBase].id, /*copy=*/true); + } +} + +} // namespace reasampler::panel + +// --- Public API (panel_bank_ops.h) --------------------------------------------- + +namespace reasampler { + +// One home (Q-W4) for the former actions/panel byte-identical twins. +// COMMA GUARD: GetUserInputs splits returned values on a separator defaulting to ',', +// so the return separator is overridden to \x1f (un-typeable) via the documented +// `separator=X` trailing pseudo-caption (SDK ~3806) — any printable name round-trips. +bool promptBankName(const char* title, const char* caption, const std::string& initial, + std::string& out) { + std::vector buf(512, '\0'); + // Pre-fill: GetUserInputs seeds the field from the retvals buffer's initial value. + std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); + const std::string captions = std::string(caption) + ",separator=\x1f"; + if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), + static_cast(buf.size()))) + return false; // user cancelled + std::string s(buf.data()); + if (s.empty()) return false; // an empty name is not a valid bank name + out = std::move(s); + return true; +} + +// --- Selection read seam -------------------------------------------------------- + +std::vector bankPanelSelectedSampleIds() { + return panel::focusedSelectionIds(); +} + +std::string bankPanelSelectedSourceBankId() { + // The focused region's displayed bank is the move/copy source. Default to the + // pool (a safe source) when nothing is selected / the panel never opened. + if (panel::g_panel.selection.empty()) return std::string(kPoolBankId); + const std::string id = panel::bankIdForRegion(panel::g_panel.focusedRegion); + return id.empty() ? std::string(kPoolBankId) : id; +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_bank_ops.h b/src/shell/panel/panel_bank_ops.h new file mode 100644 index 0000000..73877d0 --- /dev/null +++ b/src/shell/panel/panel_bank_ops.h @@ -0,0 +1,55 @@ +#pragma once +// panel_bank_ops — the bank-CRUD-UX + selection-read seam of the bank panel (Q-W2 +// split of the former bank_panel god-module; Phase B4/B5). Since Q-W6 the +// promptless bank verbs themselves live in the NON-UI shell/bank_ops seam +// (bankOp* + persistBankOp, taking ReaSamplerSession&); this TU is the panel's +// thin UX skin over them — prompts / confirms / message boxes / panel-state +// nudges / repaints — plus the popup menus that drive them. The bank_actions +// bindable family is the sibling skin over the same verbs. This header carries +// the shared prompt helper and the panel's public selection-read surface. +// +// The selection reads are REAPER-free; the prompt helper is REAPER-facing (stock +// dialogs) but SDK-free in this header. + +#include +#include + +namespace reasampler { + +// Prompts the user for a single line of text via REAPER's stock input dialog +// (GetUserInputs). `initial` pre-fills the field. Returns false (leaving `out` +// untouched) on cancel or an empty entry. COMMA GUARD: the return separator is +// overridden to \x1f (un-typeable) via the documented `separator=X` pseudo-caption, +// so any printable name — commas included — round-trips whole (SDK ~3806/3808). +// One home (Q-W4) for the former actions/panel byte-identical twins; shared by the +// panel menus and the bank_actions bindable family. +bool promptBankName(const char* title, const char* caption, const std::string& initial, + std::string& out); + +// The stable ids of the currently-selected samples, in bank (insertion) order. +// Empty when nothing is selected or the panel has never opened. This is the clean +// seam the `insert` action reads to know WHAT to place — it returns ids (not grid +// indices) so the caller resolves against the live bank and is unaffected by the +// panel's internal index bookkeeping. READ of panel state only; no mutation. +// +// Note: the panel's selection is cleared on a bank change (capture / project +// load), so a returned id always names a sample present in the current bank at +// the moment of the call; the caller still tolerates an absent id gracefully. +// +// Phase B4 (vertical split): the selection lives in whichever REGION the user last +// interacted with (the pool grid on top or a named-bank grid below), which is NOT +// necessarily the active/capture-target bank. The returned ids therefore name +// samples in the FOCUSED region's displayed bank — the bank the user visibly +// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those +// ids belong to (the move/copy source). +std::vector bankPanelSelectedSampleIds(); + +// The bank id the current selection belongs to — the displayed bank of the region +// the user last interacted with (pool region -> the pool id; named-banks region -> +// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current +// selection, and it is distinct from the active/capture-target bank (active ≠ shown). +// Returns the pool id when nothing is selected or the panel has never opened (a safe +// default source). READ of panel state only; no mutation. +std::string bankPanelSelectedSourceBankId(); + +} // namespace reasampler diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp new file mode 100644 index 0000000..8edda00 --- /dev/null +++ b/src/shell/panel/panel_drag.cpp @@ -0,0 +1,511 @@ +// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel +// (Q-W2 split of bank_panel.cpp; the T4-01 NEW seam; M11/L7/S17). Owns WM_MOUSEMOVE +// (hover resolution + tooltip timing + the live drag), the drop-target/gesture +// classification, the cursor cues, button-up drop dispatch (reorder / replace / +// move / copy / instrument-drop / OS drag-out), and right-click menu routing. Its +// PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test) with +// core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only +// the live rects, modifier state, and side effects. +// +// PER-MOUSE-MOVE GUARDRAIL (T4-28): everything on the move path stays plain +// free-function calls — no interface, no virtual dispatch. +// +// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called +// directly here (the FX-hotspot / OS-drag / instrument-drop shells own theirs); +// REAPER SDK types arrive via panel_state.h. + +#include // std::abs (drag threshold) +#include +#include + +#include "shell/panel/panel_state.h" + +#include "shell/bank_ops/bank_ops.h" // persistBankOp — shared undo-block wrapper (R-B) +#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) +#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop (S17) + +namespace reasampler::panel { + +// --- Drag (move between regions/onto a tab) ----------------------------------- + +constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag + +namespace { + +// Resolves the drop target under client (x, y) during a drag, updating dropKind / +// dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank; +// anywhere else -> none. +void updateDropTarget(int x, int y) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + + if (banksShown()) { + const RECT br = banksRegionRect(w, h); + const TabStripRect strip = banksTabStripRect(br); + const std::vector tabs = namedBanks(); + const TabHit hit = hitTestTabStrip(x, y, strip, + static_cast(tabs.size()), kTabSpec, + g_panel.tabScroll); + if (hit.kind == TabHitKind::Tab) { + g_panel.dropKind = DropKind::Tab; + g_panel.dropBankId = tabs[static_cast(hit.index)]->id; + return; + } + // Tab takes precedence over the region; if the point is in the banks region but + // not on a specific tab, treat the whole grid as a drop zone for the shown bank. + // No valid target when there are no named banks or no shown bank. + if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) { + if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) { + g_panel.dropKind = DropKind::BanksRegion; + g_panel.dropBankId = g_panel.shownBankId; + return; + } + } + } + if (poolShown()) { + const RECT pr = poolRegionRect(w, h); + const RECT grid = regionGridRect(pr, false); + if (x >= grid.left && x < grid.right && y >= grid.top && y < grid.bottom) { + g_panel.dropKind = DropKind::PoolRegion; + return; + } + } +} + +// The destination bank id under the current drop target (pool id for PoolRegion; the tab/ +// shown-bank id for Tab/BanksRegion; "" for no target). Derived from updateDropTarget's +// dropKind/dropBankId — the single source of "what bank is under the pointer". +std::string dropTargetBankId() { + switch (g_panel.dropKind) { + case DropKind::PoolRegion: return std::string(kPoolBankId); + case DropKind::Tab: + case DropKind::BanksRegion: return g_panel.dropBankId; + case DropKind::None: return {}; + } + return {}; +} + +// L7: classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop) +// the target slot, updating g_panel.cardGesture / dragTargetSlot. Call AFTER updateDropTarget +// so dropKind/dropBankId are current. The pure card_drag::decideCardGesture owns the +// precedence (leave-client -> OS; other-bank -> move/copy; same-bank grid -> reorder/replace); +// the shell only supplies the region verdict, the same-bank target slot + occupancy, and the +// live modifier state. The OS-drag-out boundary is handled by the existing decideGesture path +// in onMouseMove BEFORE this runs, so here the pointer is always inside the client. +void classifyCardDrag(int x, int y) { + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const PanelClientRect client{cr.left, cr.top, w, h}; + + const std::string destBank = dropTargetBankId(); + DragModifiers mods; + mods.ctrl = ctrlDown(); + mods.alt = altDown(); + + if (!destBank.empty() && destBank == g_panel.dragSourceBankId) { + // Same-bank grid: a reorder/replace target. Resolve the slot the pointer sits over + // in the SOURCE bank's own region display + whether it is occupied. + // Uses computeSlotRectsForDrop (one trailing row past maxSlot) so a drop beyond + // the last occupied card resolves to a valid trailing slot, not a -1 miss. + mods.region = DropRegion::SameBankGrid; + const bool isBanks = g_panel.dragSourceRegion == Region::Banks; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); + const RECT grid = regionGridRect(region, isBanks); + const int gridW = grid.right - grid.left; + const std::vector dropRects = + computeSlotRectsForDrop(disp.bank ? disp.bank->slots.maxSlot() : -1, + gridW, kGrid); + // Translate the drop rects to client space (matching regionDisplay's translation). + std::vector dropRectsClient = dropRects; + for (SlotCellRect& r : dropRectsClient) { r.x += grid.left; r.y += grid.top; } + const int slot = hitTestSlot(x, y, dropRectsClient); + mods.targetSlot = slot; + mods.slotOccupied = slot >= 0 && !disp.idAtSlot(slot).empty(); + g_panel.dragTargetSlot = slot; + } else if (!destBank.empty()) { + mods.region = DropRegion::OtherBankOrTab; // move/copy to a different bank/tab + } else { + mods.region = DropRegion::DeadSpace; // header/footer/gap — a no-op drop + } + + const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; + g_panel.cardGesture = decideCardGesture(x, y, client, st, mods); +} + +// Maps the pure L7 cursor cue to a SWELL stock cursor and sets it. The cue DECISION is pure +// (card_drag::cursorForGesture); the shell owns only this SetCursor call + the resource choice. +// Stock SWELL cursors (vendor/WDL/WDL/swell/swell-types.h:1320-1329, mirroring the Win32 OCR_* +// set): Reorder -> IDC_SIZEALL (four-way move, the file-manager reorder idiom); Move -> +// IDC_HAND (grab-and-place to another bank/tab); Copy -> IDC_UPARROW (no stock copy cursor +// exists cross-platform — this is the closest distinct stock cue; a bespoke copy cursor would +// need a resource file, deliberately NOT added); Replace -> IDC_SIZEWE (a distinct "swap +// occupant" cue, shown ONLY when the pure result is Replace, i.e. Alt over an occupied slot); +// OsDragOut -> the OS drag loop owns the cursor once handed off, so leave it (arrow here is +// never seen — the handoff happens before this runs); Default/None -> IDC_ARROW. +void applyDragCursor(CardGesture g) { + const char* idc = IDC_ARROW; + switch (cursorForGesture(g)) { + case CursorCue::Reorder: idc = IDC_SIZEALL; break; + case CursorCue::Move: idc = IDC_HAND; break; + case CursorCue::Copy: idc = IDC_UPARROW; break; + case CursorCue::Replace: idc = IDC_SIZEWE; break; + case CursorCue::OsDragOut: return; // OS drag owns the cursor; do not fight it + case CursorCue::Default: idc = IDC_ARROW; break; + } + SetCursor(LoadCursor(nullptr, idc)); +} + +// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring +// handleClick's precedence exactly (so the element that lights on hover is the one a click +// would hit). Returns HoverKind::None for the grid / dead space / a point outside the client +// (the grid cells carry their own selection/focus chrome, not a kit hover surface). Pure +// resolution over the same pure geometry the click path uses. +Hover resolveHover(int x, int y) { + if (!g_panel.hwnd) return Hover{}; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + + // TOP toolbar: the far-right More button, then the frequent buttons (matching the click + // order — first zone top-to-bottom). + { + const MenuButtonRect mb = topMenuButtonRect(w); + if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1}; + const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows()); + if (hit >= 0) return Hover{HoverKind::TopBarButton, hit}; + } + // Footer: mode-toggle segments, Tail button, then Prune (matching the click order). + { + const int seg = footerToggleSegmentHit(x, y, w, h); + if (seg >= 0) return Hover{HoverKind::ModeSegment, seg}; + const FooterBarLayout fb = footerBarLayoutFor(w, h); + if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1}; + const ButtonRect pb = pruneButtonRectFor(w, h); + if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1}; + } + // BOTTOM toolbar buttons. + { + const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows()); + if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit}; + } + // Region chrome: full-height toggles, create button, tabs. + if (poolShown()) { + const RECT pr = poolRegionRect(w, h); + const RECT ftb = fullHtBtnRect(pr); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) + return Hover{HoverKind::FullHtPool, -1}; + } + if (banksShown()) { + const RECT br = banksRegionRect(w, h); + const RECT ftb = fullHtBtnRect(br); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) + return Hover{HoverKind::FullHtBanks, -1}; + const RECT cb = createBtnRect(br); + if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) + return Hover{HoverKind::CreateBank, -1}; + const TabStripRect strip = banksTabStripRect(br); + const std::vector tabs = namedBanks(); + const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), + kTabSpec, g_panel.tabScroll); + if (hit.kind == TabHitKind::Tab) return Hover{HoverKind::Tab, hit.index}; + } + return Hover{}; +} + +// Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no +// per-move jank — the "speed is the selling point" repaint discipline). L5: a hover CHANGE also +// resets the tooltip timer (hoverSinceTick) and hides any shown tooltip, so the tooltip only +// appears after the pointer rests kTooltipDelayMs on ONE element (the delay is applied by the +// poll tick in maybeShowTooltip). A move within the SAME element leaves the timer running. +void updateHover(int x, int y) { + const Hover next = resolveHover(x, y); + if (next != g_panel.hovered) { + g_panel.hovered = next; + g_panel.hoverSinceTick = GetTickCount(); + if (g_panel.tooltipShown) { g_panel.tooltipShown = false; } + invalidatePanel(); + } +} + +} // namespace + +// Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past +// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the +// OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer; +// WM_MOUSEMOVE's updateHover resets the timer, so a moving pointer never trips it. No-op when the +// current hover has no tooltip (grid / chrome / the More button). +void maybeShowTooltip() { + if (g_panel.tooltipShown) return; + const HoverKind k = g_panel.hovered.kind; + if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return; + const unsigned int now = GetTickCount(); + if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) { + g_panel.tooltipShown = true; + invalidatePanel(); + } +} + +void onMouseMove(int x, int y) { + // Hover feedback (L2): resolve + repaint-on-change, but NOT during a drag (the drag owns + // the visual feedback then — a drop-target highlight, not a hover). Cleared to None when + // the pointer is over the grid / dead space. + if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); + + if (g_panel.dragArmed && !g_panel.dragging) { + if (std::abs(x - g_panel.dragStartX) > kDragThreshold || + std::abs(y - g_panel.dragStartY) > kDragThreshold) { + // Threshold crossed — begin the drag. Snapshot the payload NOW. + g_panel.dragging = true; + g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); + g_panel.dragSampleIds = focusedSelectionIds(); + // The single card actually grabbed = the focus ordinal's id. This is the L7 + // in-grid reorder/replace subject (see onLBtnUp) — "drag a card" is a single-card + // gesture, distinct from the multi-select move/copy payload in dragSampleIds. + { + const RegionDisplay disp = focusedDisplay(); + const int f = g_panel.selection.focus; + g_panel.dragPrimaryId = + (f >= 0 && f < disp.occupiedCount()) + ? disp.orderedIds[static_cast(f)] : std::string{}; + } + g_panel.hovered = Hover{}; // clear hover — the drag owns the visual feedback now + g_panel.tooltipShown = false; // a drag never shows a tooltip + // SetCapture was already called at drag-arm time (handleClick); no re-capture needed. + } + } + if (g_panel.dragging) { + // M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the + // pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant + // #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture + // splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an + // InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a + // pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays + // Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint. + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top}; + const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom); + + DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; + st.singleCapture = (g_panel.dragSampleIds.size() == 1); + + // Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can + // only arise there) and only for a single-capture payload — the SDK hit-test is skipped + // on the common internal-drag path so it costs nothing there. The screen conversion is + // Windows-only (D5); resolveFxDropTarget owns the REAPER hit query. + FxDropTarget fx; + if (!inside && st.singleCapture) { + POINT sp{x, y}; + ClientToScreen(g_panel.hwnd, &sp); + fx = resolveFxDropTarget(sp.x, sp.y); + st.overReaperUi = fx.overReaperUi; + } + + const DragGesture gesture = decideGesture(x, y, client, st); + + if (gesture == DragGesture::InstrumentDrop) { + // Track the FX hotspot for the release; the highlight is REAPER's own FX-button + // hover feedback under the pointer (the drop is driven on button-up). We keep the + // internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does + // NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the + // panel does not also paint a bank-drop cue while the drag is out over a track. + g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr; + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + invalidatePanel(); + return; + } + + // Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target. + g_panel.instrumentDropTrack = nullptr; + + if (gesture == DragGesture::OsDrag) { + // Resolve the payload to existing on-disk paths BEFORE tearing down internal + // drag state (the resolver reads dragSourceBankId / dragSampleIds). + const std::vector paths = resolveDragPathsForOs(); + + // Reset internal drag state and release capture NOW: DoDragDrop runs its own + // modal loop and takes over mouse capture, so the internal drag must be fully + // wound down first (no stale dragging/dropKind, no lingering SetCapture). A + // cancelled/empty OS drag therefore leaves the panel in a clean, no-op state + // (invariant #2 — nothing mutated). + if (GetCapture() == g_panel.hwnd) ReleaseCapture(); + g_panel.dragArmed = false; + g_panel.dragging = false; + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + g_panel.dragPrimaryId.clear(); + invalidatePanel(); + + // Empty path list -> nothing draggable (all stale/missing); do not start a drag. + if (!paths.empty()) + initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows + return; + } + // Inside the client: classify the in-grid gesture (L7 reorder/replace vs the existing + // move/copy) and reflect it as a cursor cue. updateDropTarget first so dropKind/ + // dropBankId are current for classifyCardDrag's same-vs-other-bank decision. + updateDropTarget(x, y); + classifyCardDrag(x, y); + applyDragCursor(g_panel.cardGesture); + invalidatePanel(); + } +} + +// L7 in-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap- +// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the pure +// BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z (persistBankOp opens the +// batched undo point + saves). A no-op reorder (already at the target, model returns false) +// opens no undo point. Selection reasons over slot order, so it is cleared after — the +// fingerprint pass rebuilds it against the new order. +namespace { + +void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) { + if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return; + if (!book()->reorderSample(id, bankId, targetSlot)) return; // rejected/no-op: no undo point + persistBankOp(*g_panel.session, "ReaSampler: reorder sample"); + g_panel.selection = Selection{}; + invalidatePanel(); +} + +// L7 Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is +// removed from the bank's index (index-only, file untouched — pool guard enforced in the pure +// BankBook::replaceSample). Rejected (pool guard / absent) = a true NO-OP: no fallback insert, +// no undo point (per spec). One drop = one Ctrl-Z on success. +void doReplaceDrop(const std::string& newId, const std::string& oldId, + const std::string& bankId) { + if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return; + if (!book()->replaceSample(newId, oldId, bankId)) return; // pool-guard reject: NO-OP + persistBankOp(*g_panel.session, "ReaSampler: replace sample"); + g_panel.selection = Selection{}; + invalidatePanel(); +} + +} // namespace + +// Clears all drag-state fields to their resting values. Called from every exit path +// (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields +// stays consistent across all four sites. +void resetDragState() { + g_panel.dragArmed = false; + g_panel.dragging = false; + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + g_panel.dragPrimaryId.clear(); + g_panel.instrumentDropTrack = nullptr; +} + +// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides: +// * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each. +// * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy). +// * None -> a drop over dead space / the source-bank gap = no-op. +// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove. +void onLBtnUp(int x, int y) { + if (g_panel.dragging) { + // S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a + // ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move, + // NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank + // drop (the pointer is out over a track, not over a bank region). Single-capture only (the + // gesture never armed for a multi payload), so dragSampleIds.front() is the capture. + if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) { + const std::string sampleId = g_panel.dragSampleIds.front(); + performInstrumentDrop(g_panel.instrumentDropTrack, + buildInstrumentDropPreset(sampleId)); + // Read-only over the bank + arrange: the ONLY mutations are the new FX instance + + // its state (both undoable in performInstrumentDrop). No book change, no ext-state, + // no dirty-mark here. + } else { + updateDropTarget(x, y); + classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) + const CardGesture g = g_panel.cardGesture; + + if (g == CardGesture::Reorder) { + doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId, + g_panel.dragTargetSlot); + } else if (g == CardGesture::Replace) { + // Replace targets the OCCUPANT of the target slot with the single grabbed card. + const bool isBanks = g_panel.dragSourceRegion == Region::Banks; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); + const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot); + // Replace only makes sense for a single grabbed card over a DIFFERENT occupant. + if (!occupant.empty() && occupant != g_panel.dragPrimaryId) + doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId); + } else if (g == CardGesture::Move || g == CardGesture::Copy) { + const std::string destId = dropTargetBankId(); + if (!destId.empty() && destId != g_panel.dragSourceBankId && + !g_panel.dragSampleIds.empty()) { + transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, + /*copy=*/g == CardGesture::Copy); + } + } + } + // CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None). + SetCursor(LoadCursor(nullptr, IDC_ARROW)); // restore the arrow on drop + if (GetCapture() == g_panel.hwnd) ReleaseCapture(); + } else if (g_panel.dragArmed) { + // Press-release on a selected cell with no drag: treat as a plain click that + // collapses the multi-selection to the pressed cell (standard behavior). + // Release capture acquired at arm time (handleClick) — drag never started. + if (GetCapture() == g_panel.hwnd) ReleaseCapture(); + const BankModel* idx = indexForRegion(g_panel.focusedRegion); + const int count = idx ? static_cast(idx->size()) : 0; + const int focus = g_panel.selection.focus; + if (focus >= 0) + g_panel.selection = applyClick(g_panel.selection, focus, false, false, count); + } + resetDragState(); + invalidatePanel(); +} + +// A right-click: on a named tab -> the tab management menu; on a grid cell of the +// focused region with a selection -> the move/copy menu. +void handleRightClick(int x, int y) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + + // Tab management menu. + if (banksShown()) { + const RECT br = banksRegionRect(w, h); + const TabStripRect strip = banksTabStripRect(br); + const std::vector tabs = namedBanks(); + const TabHit hit = hitTestTabStrip(x, y, strip, + static_cast(tabs.size()), kTabSpec, + g_panel.tabScroll); + if (hit.kind == TabHitKind::Tab) { + POINT pt{x, y}; + ClientToScreen(g_panel.hwnd, &pt); + showTabMenu(pt.x, pt.y, tabs[static_cast(hit.index)]->id); + return; + } + } + + // Grid selection menu (move/copy). Only when the right-click lands in the focused + // region's grid and there is a selection. + Region reg = Region::Pool; + if (regionAt(x, y, reg) && reg == g_panel.focusedRegion && + !g_panel.selection.empty()) { + POINT pt{x, y}; + ClientToScreen(g_panel.hwnd, &pt); + showSelectionMenu(pt.x, pt.y); + } +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp new file mode 100644 index 0000000..8a2a1b4 --- /dev/null +++ b/src/shell/panel/panel_input.cpp @@ -0,0 +1,637 @@ +// panel_input.cpp — the input + detection seam of the docked bank panel (Q-W2 split +// of bank_panel.cpp). Owns left-click / wheel / keyboard routing (plain free-function +// calls on the per-event path — T4-28), the accelerator registration, the tail-setting +// read/mutate helpers, and the timer-driven new-content auto-tag detection (D2 Wave 2). +// Mouse-MOVE (hover + the card-drag state machine) lives in panel_drag; the bank-change +// fingerprint pass lives in panel_thumbnails (it owns the cache it invalidates). +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include +#include +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_input.h" + +#include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) +#include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation +#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2) +#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) +#include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) +#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation (D2/D4) + +// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane +// state to classify an item's lane as managed vs manual. +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_CountTrackMediaItems +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_MarkProjectDirty +#define REAPERAPI_WANT_Main_OnCommand +#include "reaper_plugin_functions.h" + +// main.cpp owns REAPER's dispatch struct (the accelerator registers through it). +extern reaper_plugin_info_t* g_rec; + +namespace reasampler::panel { + +// The session's live tail setting (default None / 2 s when no session). Single read +// point so draw, wheel-adjust, and the capture read seam all agree on the source. +TailSetting currentTail() { + return g_panel.session ? g_panel.session->tail() : TailSetting{}; +} + +namespace { + +// Commits the current tail setting to ext state and marks the active project dirty +// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only +// path that calls SetProjExtState for the tail key — calling it here closes the gap +// where toggle/scroll would dirty the project but the new value was never written. +// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h). +// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way. +// NON-DESTRUCTIVE: touches nothing in the bank/arrange. +void markTailDirty() { + if (g_panel.session) g_panel.session->saveToActiveProject(); + ReaProject* proj = EnumProjects(-1, nullptr, 0); + if (proj) MarkProjectDirty(proj); +} + +// Routes a click in a toolbar to the hit button's action, fired through the command-id contract +// (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click +// was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the +// caller stops before grid handling. `rows` is the toolbar's inventory. +bool handleToolbarClick(int x, int y, const ActionBarRect& bar, + const std::vector& rows) { + if (bar.height <= 0) return false; + const int hit = toolbarHit(x, y, bar, rows); + if (hit < 0) { + // Inside the band but in a gap / overflow dead-zone: claim it so it never falls through + // to the grid. Outside the band: not ours. + return y >= bar.y && y < bar.y + bar.height && + x >= bar.x && x < bar.x + bar.width; + } + const ActionBarRow& row = rows[static_cast(hit)]; + // A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the + // action and never falls through to the grid (a dead button reads as inert, not absent). + if (!row.enabled) return true; + const int cmd = resolveBarCommandId(row); + if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); + return true; +} + +// --- New-content detection (D2 Wave 2) ---------------------------------------- +// +// REAPER exposes no "item/track added" callback, so we diff live project state on the +// existing timer. Each tick: enumerate every track GUID and every item GUID, diff +// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new +// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane +// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All +// enumeration is READ-ONLY on the project; the only mutation is to the in-memory +// membership index (persisted by persist on the next save, same as an action-driven tag). + +// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified +// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so +// panel_input.cpp stays self-contained without pulling in view.cpp's private namespace. +constexpr int kFreeModeFixedLanes = 2; + +bool isFixedLaneTrack(MediaTrack* tr) { + return static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; +} + +// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h): +// itemGuid(it) and itemLaneName(tr, it). panel_input.cpp no longer carries its own copies. + +// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set, +// baseline input) and, for each item, records whether it sits on a manual lane so a +// newly-detected item can be exempted from auto-tag without a second project walk. +// `trackItemGuids` additionally maps each track GUID to the item GUIDs it carries, so a +// newly-detected item's PRE-EXISTING siblings can be resolved (the adoption / strand +// guard) without a second project walk. +// +// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack, +// laneName) from lane_keys — the same predicate the apply path consults — so the exemption +// rule is defined in exactly one place and is unit-tested there. +void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, + std::map& itemOnManualLane, + std::map>& trackItemGuids) { + const int trackCount = CountTracks(proj); + for (int t = 0; t < trackCount; ++t) { + MediaTrack* tr = GetTrack(proj, t); + if (!tr) continue; + std::string tg = guidString(tr); + if (!tg.empty()) allGuids.insert(tg); + + // Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a + // track-level attribute and is the same for every item on the track. + const bool fixedLane = isFixedLaneTrack(tr); + + std::vector& itemsOnTrack = trackItemGuids[tg]; + const int itemCount = CountTrackMediaItems(tr); + for (int i = 0; i < itemCount; ++i) { + MediaItem* it = GetTrackMediaItem(tr, i); + if (!it) continue; + std::string ig = itemGuid(it); + if (ig.empty()) continue; + allGuids.insert(ig); + // Classify via the single shared predicate. For a fixed-lane track we read + // the item's lane name; for a normal track we pass "" (isOnManualLane returns + // false immediately for non-fixed-lane tracks regardless of name). + const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; + itemOnManualLane[ig] = isOnManualLane(fixedLane, ln); + itemsOnTrack.push_back(ig); + } + } +} + +// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones +// into the active mode. Runs every timer tick regardless of panel open/close (content +// is created in the arrange). READ-ONLY on the project; mutates only the in-memory +// membership index. +// +// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a +// background metadata update (like setting a label), not a destructive project edit. The +// persist shell (ext_state_io.cpp) writes it on the next project save alongside the bank +// and view state, the same way an action-driven tag is persisted. Wrapping this in an Undo +// block would flood the REAPER undo history with a new entry for every timer tick that sees +// new content. +// Returns true iff this tick tagged at least one new GUID into a mode — the signal the +// caller uses to decide whether to run the lane-minting pass (a track can only newly +// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint. +bool detectNewContent() { + if (!g_panel.session) return false; + + ReaProject* proj = EnumProjects(-1, nullptr, 0); + + // A project (re)load re-arms the first-poll guard so we never diff across two + // projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded() + // on the tick persist restores the project's membership + active mode, which sets + // reloadPending. Draining it here re-baselines against the fully-loaded set (that + // same tick's reapply-active-mode enumerated those tracks, so they are present), + // and the observe() below returns nothing new — pre-existing untagged tracks stay + // Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so + // no separate first-tick handling is needed here. Using persist's GUID-primary load + // signal (not a local pointer compare) is what fixes the reload-mis-tag: the two + // identity checks can no longer diverge on a recycled ReaProject* address. + if (g_panel.reloadPending) { + g_panel.contentBaseline.reset(); + g_panel.reloadPending = false; + } + + std::set live; + std::map itemOnManualLane; + std::map> trackItemGuids; + enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids); + + const std::vector added = g_panel.contentBaseline.observe(live); + if (added.empty()) return false; // first poll after open, or nothing new this tick + + ViewModeModel& model = g_panel.session->view(); + + // Which of `added` are items (the manual-lane map keys every item; track GUIDs never + // appear there). Used below to exclude sibling new items from a track's PRE-EXISTING + // mode set — a drop plus its own new siblings must not count each other as prior. + const std::set newItemGuids = [&] { + std::set s; + for (const std::string& g : added) + if (itemOnManualLane.count(g)) s.insert(g); + return s; + }(); + + // Item guid -> its track guid (reverse of trackItemGuids), so a new item's siblings + // are found in one lookup. + std::map trackOfItem; + for (const auto& [trackGuid, items] : trackItemGuids) + for (const std::string& ig : items) trackOfItem[ig] = trackGuid; + + // The distinct modes the PRE-EXISTING (not-new-this-tick) MANAGED-ELIGIBLE items on + // `trackGuid` resolve to. Untagged siblings resolve to Arrange (leafBelongsToMode's + // default); new siblings are excluded; manual-lane siblings are EXEMPT — exactly as + // planLaneMinting ignores them when computing a track's own-item mode span, so the + // adoption guard's view of the track matches the split decision's. Drives the adoption + // / strand guard in autoTagNewContent. + const auto preExistingTrackModes = + [&](const std::string& trackGuid) -> std::set { + std::set modes; + auto it = trackItemGuids.find(trackGuid); + if (it == trackItemGuids.end()) return modes; + for (const std::string& sib : it->second) { + if (newItemGuids.count(sib)) continue; // a sibling added THIS tick — not prior + auto ml = itemOnManualLane.find(sib); + if (ml != itemOnManualLane.end() && ml->second) continue; // manual lane — exempt + const std::set m = model.membership().modesOf(sib); + if (m.empty()) modes.insert(kArrangeModeId); // untagged ⇒ Arrange default + else modes.insert(m.begin(), m.end()); + } + return modes; + }; + + // Split the new GUIDs into tracks vs items so the pure decision can apply the + // manual-lane exemption to items only. A GUID present in the item-lane map is an + // item; otherwise it is a track (track GUIDs never appear in that map). + std::vector newTracks; + std::vector newItems; + for (const std::string& g : added) { + auto it = itemOnManualLane.find(g); + if (it == itemOnManualLane.end()) { + newTracks.push_back(g); // a track GUID + } else { + NewItem ni{g, it->second, {}}; + auto tk = trackOfItem.find(g); + if (tk != trackOfItem.end()) ni.trackModes = preExistingTrackModes(tk->second); + newItems.push_back(std::move(ni)); // an item; carries exemption + track modes + } + } + + const std::vector tags = + autoTagNewContent(newTracks, newItems, model.activeModeId()); + for (const AutoTag& tag : tags) + model.membership().tag(tag.guid, tag.modeId); + return !tags.empty(); +} + +// The item count the SELECTION reasons over — the focused region's occupied-cell count. +// L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not +// selectable). Occupied count == index size by construction: every index member maps to +// exactly one occupied slot (gaps are empty slots, which the index never backs), so the +// raw index size IS the dense selection-space extent. +int focusedItemCount() { + const BankModel* idx = indexForRegion(g_panel.focusedRegion); + return idx ? static_cast(idx->size()) : 0; +} + +// --- Click routing ------------------------------------------------------------ + +// Handles a header/tab-strip/button click for the banks region. Returns true if the +// click was consumed (a region-chrome hit), false to fall through to grid selection. +bool handleBanksChromeClick(int x, int y, const RECT& region) { + // Full-height toggle button. + const RECT ftb = fullHtBtnRect(region); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { + bankPanelToggledBanksFullHeight(); + return true; + } + // "+" create button. + const RECT cb = createBtnRect(region); + if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) { + doCreateBank(); + return true; + } + // Tab strip: chevrons scroll, a tab click SHOWS that bank (browse — NOT activate). + const TabStripRect strip = banksTabStripRect(region); + const std::vector tabs = namedBanks(); + const int n = static_cast(tabs.size()); + const TabHit hit = hitTestTabStrip(x, y, strip, n, kTabSpec, g_panel.tabScroll); + if (hit.kind == TabHitKind::ScrollLeft || hit.kind == TabHitKind::ScrollRight) { + const TabStripLayout layout = + computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); + const int step = kTabSpec.tabWidth; + const int desired = g_panel.tabScroll + + (hit.kind == TabHitKind::ScrollLeft ? -step : step); + g_panel.tabScroll = clampTabScroll(desired, layout); + invalidatePanel(); + return true; + } + if (hit.kind == TabHitKind::Tab) { + const Bank* bk = tabs[static_cast(hit.index)]; + if (bk->id != g_panel.shownBankId) { + g_panel.shownBankId = bk->id; // browse: show this bank's grid + g_panel.selection = Selection{}; // grid changed — reset selection + stopAudition(); + } + g_panel.focusedRegion = Region::Banks; + invalidatePanel(); + return true; + } + return false; +} + +// Handles the pool region's full-height toggle. Returns true if consumed. +bool handlePoolChromeClick(int x, int y, const RECT& region) { + const RECT ftb = fullHtBtnRect(region); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { + bankPanelToggledPoolFullHeight(); + return true; + } + return false; +} + +} // namespace + +// Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) / +// bottom toolbar / region chrome / grid selection, and arm a potential drag when the click +// lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom. +void handleClick(int x, int y) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + + // TOP toolbar: the far-right More button first (its rect sits in the band's reserved right + // strip, outside the action rect), then the frequent capture/placement buttons. A button + // fires its registered action via the command-id contract; the band is claimed whole (a + // gap/overflow miss is a harmless no-op, never a fall-through). Capture never auto-inserts. + { + const MenuButtonRect mb = topMenuButtonRect(w); + if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; } + } + if (handleToolbarClick(x, y, topToolbarActionRect(w), topBarRows())) return; + // Claim the WHOLE top band (including the reserved right strip between the last button and + // the More button) so a click there is inert chrome, never a fall-through to the grid. + if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return; + + // Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design] + // toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a + // click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar / + // grid so a footer click never selects a cell. + { + const int seg = footerToggleSegmentHit(x, y, w, h); + if (seg >= 0) { + const std::vector& modes = g_panel.session->view().modes().all(); + if (seg < static_cast(modes.size())) { + applyMode(g_panel.session->view(), + modes[static_cast(seg)].id, nullptr); + invalidatePanel(); + } + return; + } + + const FooterBarLayout fb = footerBarLayoutFor(w, h); + if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) { + // Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates + // the SESSION's tail setting (capture reads it; persist saves it with the project) + // and marks the project dirty — touches NOTHING in the bank/arrange. + TailSetting& tail = g_panel.session->tail(); + tail.mode = cycleTailMode(tail.mode); + markTailDirty(); + invalidatePanel(); + return; + } + + // Prune button (R3): fires the "Prune bank folder" action THROUGH its registered + // command id (fork R-E: dispatch the command, not the session directly) so the panel + // affordance and the bindable action share the one guarded dry-run/confirm/delete path + // in doBankPruneFolder. A 0 id (pre-registration) no-ops. + const ButtonRect pb = pruneButtonRectFor(w, h); + if (hitTestPruneButton(x, y, pb)) { + const int cmd = bankPruneCommandId(); + if (cmd != 0) Main_OnCommand(cmd, 0); + return; + } + } + + // BOTTOM toolbar (Design-View verbs): a button fires its registered action via the + // command-id contract. Claimed whole like the top toolbar. + if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return; + + // Region chrome (headers, tab strip, buttons). + if (poolShown()) { + const RECT pr = poolRegionRect(w, h); + if (y >= pr.top && y < regionGridRect(pr, false).top) { + if (handlePoolChromeClick(x, y, pr)) return; + } + } + if (banksShown()) { + const RECT br = banksRegionRect(w, h); + if (y >= br.top && y < regionGridRect(br, true).top) { + if (handleBanksChromeClick(x, y, br)) return; + } + } + + // Grid selection. Resolve which region's grid the point is in. + Region reg = Region::Pool; + if (!regionAt(x, y, reg)) return; + const bool isBanks = reg == Region::Banks; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + // L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An + // empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a + // click on a gap clears selection, exactly like a click in the margin) — empty slots + // are decorative, not selectable. + const RegionDisplay disp = regionDisplay(region, isBanks, reg); + const int hitSlot = hitTestSlot(x, y, disp.slotRects); + const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot); + const int count = disp.occupiedCount(); + + // Switching focus region reseeds the selection there. + if (g_panel.focusedRegion != reg) { + g_panel.focusedRegion = reg; + g_panel.selection = Selection{}; + stopAudition(); + } + + if (hit < 0) { + if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { + g_panel.selection = Selection{}; + stopAudition(); + } + invalidatePanel(); + return; + } + + // Drag-arm disambiguation for plain (no ctrl, no shift) presses on a grid cell: + // + // • Already-selected cell: defer the selection change to LBUTTONUP so a plain + // press on a multi-selection doesn't collapse it before we know whether a drag + // will happen. Arm the drag with the current (multi-)selection as the payload + // candidate; only the caret moves immediately. + // + // • Unselected cell: apply the plain-click selection immediately (collapses to + // the single pressed cell) THEN arm a drag from it — so the user can press-and- + // drag in one gesture without a prior selecting click. The selection is set + // before arming so that focusedSelectionIds() resolves the right payload when + // the threshold is crossed in onMouseMove. + // + // ctrl / shift presses are selection-only gestures — no drag arm in either case. + const bool onSelected = g_panel.selection.contains(hit); + if (!ctrlDown() && !shiftDown()) { + if (!onSelected) { + // Commit the single-cell selection now so the drag payload is correct. + g_panel.selection = applyClick(g_panel.selection, hit, false, false, count); + g_panel.selItemCount = count; + } else { + // Move the caret to the pressed cell; defer collapsing multi-selection. + g_panel.selection.focus = hit; + } + g_panel.dragArmed = true; + g_panel.dragStartX = x; + g_panel.dragStartY = y; + g_panel.dragSourceRegion = reg; + // Capture the mouse NOW so WM_MOUSEMOVE is delivered even when the pointer leaves the + // panel client rect before the drag threshold is crossed. Without capture, outside moves + // are not delivered, so a fast straight-out drag never transitions dragArmed → dragging + // and the OS drag-out never fires on the first pass. The capture is released on button-up + // (no drag: onLBtnUp dragArmed branch; drag: OsDrag path or onLBtnUp dragging branch) + // and on WM_CAPTURECHANGED (stolen or external release — already calls resetDragState). + SetCapture(g_panel.hwnd); + invalidatePanel(); + return; + } + + g_panel.selection = applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count); + g_panel.selItemCount = count; + invalidatePanel(); +} + +// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`. +// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is +// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens, +// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns +// false so the caller can let REAPER/the docker handle the wheel normally). On a real +// change it mutates the SESSION's tail setting, marks the project dirty (so it saves), +// and repaints the live length. Returns true iff the wheel was consumed. +bool handleWheel(int x, int y, int delta) { + if (!g_panel.session) return false; + if (!pointInFooter(x, y)) return false; + + TailSetting& tail = g_panel.session->tail(); + if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only + + // One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad + // that sends fractional deltas still steps predictably. Sign carries direction. + const int notches = delta / 120; + if (notches == 0) return false; // sub-notch movement — nothing to apply yet + + const double before = tail.manualMs; + tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs); + if (tail.manualMs == before) return true; // already at a bound — consumed, no change + + markTailDirty(); + invalidatePanel(); // label shows the new length live + return true; +} + +namespace { + +bool isOurWindow(HWND hwnd) { + for (HWND w = hwnd; w; w = GetParent(w)) + if (w == g_panel.hwnd) return true; + return false; +} + +bool handleKey(int vk) { + const int count = focusedItemCount(); + if (count <= 0) return false; + + switch (vk) { + case VK_LEFT: + case VK_RIGHT: + case VK_UP: + case VK_DOWN: { + const NavKey nk = vk == VK_LEFT ? NavKey::Left + : vk == VK_RIGHT ? NavKey::Right + : vk == VK_UP ? NavKey::Up + : NavKey::Down; + g_panel.selection = navigate(g_panel.selection, nk, + columnsForRegion(g_panel.focusedRegion), + count, shiftDown()); + g_panel.selItemCount = count; + invalidatePanel(); + return true; + } + case VK_RETURN: + case VK_SPACE: + if (g_panel.selection.focus >= 0) + startAudition(g_panel.selection.focus); + return true; + case VK_ESCAPE: + stopAudition(); + return true; + case VK_DELETE: { + // Remove the focused-region selection (B5). Silent; a no-op when nothing + // is selected. + const std::vector sel = focusedSelectionIds(); + if (sel.empty()) return false; // nothing selected — let the key fall through + removeSamples(sel, bankIdForRegion(g_panel.focusedRegion)); + return true; + } + default: + return false; + } +} + +int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) { + if (!msg || msg->message != WM_KEYDOWN) return 0; + if (!g_panel.open || !g_panel.hwnd) return 0; + if (!isOurWindow(GetFocus())) return 0; + return handleKey(static_cast(msg->wParam)) ? 1 : 0; +} + +accelerator_register_t g_accel{translateAccel, true, nullptr}; +bool g_accelRegistered = false; + +} // namespace + +void registerAccel() { + if (g_accelRegistered || !g_rec) return; + g_rec->Register("accelerator", &g_accel); + g_accelRegistered = true; +} + +void unregisterAccel() { + if (!g_accelRegistered || !g_rec) return; + g_rec->Register("-accelerator", &g_accel); + g_accelRegistered = false; +} + +} // namespace reasampler::panel + +// --- Public API (the timer + tail read seam — panel_input.h) ------------------- + +namespace reasampler { + +void bankPanelNotifyProjectLoaded() { + // Persist restored a project's membership + active mode this tick (main.cpp calls + // this from the same consumeLoadSignal() branch that reapplies the active mode). + // Arm the new-content detector to re-baseline on its next tick so the just-loaded + // project's pre-existing content is treated as the baseline (nothing new) rather + // than diffed against the previous project and mass-tagged into the active mode. + // A flag (not an inline reset) because detectNewContent owns the baseline and runs + // later in the SAME OnTimer tick — it drains this and re-baselines against the live + // set in one place, keeping the reset and the observe() adjacent and ordered. + panel::g_panel.reloadPending = true; +} + +void bankPanelRefresh() { + // New-content auto-tag detection runs EVERY tick regardless of panel open/close: + // tracks/items are created in the arrange view, not the panel, so detection must + // not be gated on the dock being visible. READ-ONLY on the project; only mutates + // the in-memory membership index (persist saves it like any action-driven tag). + const bool tagged = panel::detectNewContent(); + + // Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a + // track can only newly become multi-mode when auto-tag placed content on it. Unlike + // the invisible membership tag above, minting is a visible structural mutation + // (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo + // block and only mints for tracks that hold >1 mode's content — a single-mode track + // is left to D1 whole-track parking. Managed lanes only; manual lanes untouched. + if (tagged && panel::g_panel.session) { + ReaProject* proj = EnumProjects(-1, nullptr, 0); + mintManagedLanes(panel::g_panel.session->view(), proj); + } + + if (!panel::g_panel.open || !panel::g_panel.hwnd) return; + + // L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a + // toolbar button has rested under the pointer past the delay, latch + repaint the tooltip. + panel::maybeShowTooltip(); + + if (panel::refreshFingerprint()) + InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); +} + +capture::TailSetting bankPanelTailSetting() { + // The authoritative setting lives in the session (session->tail()) so it travels + // inside the .rpp: it loads per project and saves with the project. This stays the + // read seam for the capture actions. manualMs is clamped here so a caller always + // receives a within-cap length regardless of what was stored/scrolled. + capture::TailSetting s = panel::currentTail(); + s.manualMs = capture::clampManualMs(s.manualMs); + return s; +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_input.h b/src/shell/panel/panel_input.h new file mode 100644 index 0000000..9f08280 --- /dev/null +++ b/src/shell/panel/panel_input.h @@ -0,0 +1,43 @@ +#pragma once +// panel_input — the input + detection seam of the bank panel (Q-W2 split of +// bank_panel.h). The .cpp owns mouse-click / wheel / keyboard routing (plain +// free-function calls per T4-28 — no interface on the per-event path) plus the +// timer-driven detection passes: new-content auto-tag (D2 Wave 2) and the +// hover-delay tooltip latch. This header carries the timer/lifecycle surface +// main.cpp drives and the tail-setting read seam the capture actions consume. +// +// REAPER-free as practical: TailSetting is the pure capture-side type. + +#include "core/capture/tail_control.h" // capture::TailSetting — the panel's tail-mode toggle state + +namespace reasampler { + +// Requests a repaint if the bank changed since the last paint (generation bump). +// Cheap when nothing changed. Driven by the timer so a capture / project load is +// reflected without the panel diffing the bank itself. Also hosts the every-tick +// new-content auto-tag detection (runs whether or not the dock is visible) and the +// tooltip hover-delay latch. +void bankPanelRefresh(); + +// Notifies the panel that persist just (re)loaded a project's view model (membership + +// active mode). main.cpp calls this on the exact tick it drains persist's load signal +// and reapplies the active mode. It re-arms the new-content detector so the just-loaded +// project's PRE-EXISTING content is taken as the baseline (reported as nothing new), +// never diffed against the previously-open project and mass-tagged into the active mode. +// This coordinates the detector's project-identity signal with persist's authoritative +// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address, +// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/ +// arm of panel state only; no project or bank mutation. +void bankPanelNotifyProjectLoaded(); + +// The panel's current tail-mode setting (mode + Manual length), read by the plain +// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture +// applies whatever the panel toggle is set to. Default None (exact bounds) — a +// capture with no explicit choice stays byte-identical to today. The authoritative +// setting lives in ReaSamplerSession (it travels inside the .rpp); the panel mutates +// it via the footer Tail button (cycle) and scroll-wheel (Manual fine-adjust), both +// owned by this input seam. Safe to call before the panel has ever opened (returns +// the default). READ of panel state only. +capture::TailSetting bankPanelTailSetting(); + +} // namespace reasampler diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp new file mode 100644 index 0000000..34d9311 --- /dev/null +++ b/src/shell/panel/panel_layout.cpp @@ -0,0 +1,570 @@ +// panel_layout.cpp — the geometry-glue seam of the docked bank panel (Q-W2 split of +// bank_panel.cpp; the T4-01 NEW seam). Owns the toolbar/footer/menu rects, the +// toolbar row/cluster builders, the vertical-split geometry + region rects, and the +// L7 slot-order display bridge (regionDisplay/focusedDisplay). Every rect is derived +// from the client size + fullHeight state, and BOTH paint (panel_render) and +// hit-testing (panel_input / panel_drag) call these so they never drift. +// +// Also home of the public split-state seam (panel_layout.h): the B3-owned +// BankPanelFullHeight toggles the render derives the region rects from. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested (the PURE tiling / +// hit-test math lives in action_bar / footer_bar / prune_button / overflow_menu / +// tab_strip / mode_switch / bank_grid / card_drag, unit-tested outside the DAW). + +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_layout.h" + +#include "shell/persist/session.h" // ReaSamplerSession — mode/view reads +#include "core/view/view_mode_model.h" // ViewModeModel — modes()/activeModeId() + +// Action-trigger buttons (M11): resolve each button's command id at runtime from the +// composed named-command string and read its current key binding for the tooltip. +// All main-section (SectionFromUniqueID(0)). +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_NamedCommandLookup +#define REAPERAPI_WANT_kbd_getTextFromCmd +#define REAPERAPI_WANT_SectionFromUniqueID +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +// --- Mode toggle (D5; relocated to the footer at L4) -------------------------- + +int modeCount() { + if (!g_panel.session) return 0; + return static_cast(g_panel.session->view().modes().size()); +} + +// --- Top toolbar band (L4; L5 overflow-menu reserve) -------------------------- +// +// The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate +// (height 0) when the client is too short to host it above the split body. The WHOLE band +// (topToolbarRect) is what the far-right More button anchors into; the action_bar's frequent +// buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run +// under the menu button (L5 refinement 1). + +namespace { + +ActionBarRect topToolbarRect(int w) { + ActionBarRect s; + s.x = 0; + s.y = 0; + s.width = w; + s.height = kTopToolbarHeight; + return s; +} + +// The band the More button occupies (the whole top toolbar band as a MenuBarRect). +MenuBarRect topMenuBarRect(int w) { + const ActionBarRect bar = topToolbarRect(w); + return MenuBarRect{bar.x, bar.y, bar.width, bar.height}; +} + +// The More button's rect (right-anchored in the top band). Empty when the band is too narrow +// to place it clear of its left inset — the three variants stay reachable via their bindable +// commands (graceful suppression). +} // namespace + +MenuButtonRect topMenuButtonRect(int w) { + return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec); +} + +// The rect the TOP toolbar's action_bar tiles into: the whole band MINUS the reserve for the +// far-right More button, so the frequent buttons never overlap it. When the More button is +// suppressed (band too narrow) the reserve is still subtracted (the reserve is 0 only for a +// degenerate band), which keeps draw and hit-test consistent whether or not the button shows. +ActionBarRect topToolbarActionRect(int w) { + ActionBarRect bar = topToolbarRect(w); + const int reserve = menuButtonReserve(topMenuBarRect(w), kMenuBtnSpec); + bar.width -= reserve; + if (bar.width < 0) bar.width = 0; + return bar; +} + +// --- Footer (L4) -------------------------------------------------------------- + +RECT panelFooter(int w, int h) { + RECT rc{}; + rc.left = 0; + rc.right = w; + rc.top = h - kFooterHeight; + rc.bottom = h; + // Keep the footer below the top toolbar; if the client is too short, collapse it. + if (rc.top < kTopToolbarHeight) rc.top = rc.bottom; + return rc; +} + +// The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client +// size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate. +FooterBarLayout footerBarLayoutFor(int w, int h) { + const RECT f = panelFooter(w, h); + if (f.top >= f.bottom) return FooterBarLayout{}; + const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; + return computeFooterBar(footer, FooterBarSpec{}); +} + +// The prune button's rect within the footer, derived from the client size. SINGLE source +// of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate +// or too narrow to place the button clear of the footer-left group / version readout — the +// action stays reachable via its bindable command, so a suppressed button is graceful. Kept +// set apart at the RIGHT (footer_bar reserves the matching space at its right so the two +// groups never overlap). See prune_button.h §Placement contract. +ButtonRect pruneButtonRectFor(int w, int h) { + const RECT f = panelFooter(w, h); + if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button + const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; + return computePruneButton(footer, PruneButtonSpec{}); +} + +// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the +// scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The +// Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar). +bool pointInFooter(int x, int y) { + if (!g_panel.hwnd) return false; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top); + return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; +} + +// === Task-grouped toolbars (Phase L, L2 + L4) ================================= +// +// L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin) +// across TWO toolbars, BOTH drawn through the pure action_bar module: +// * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the +// two acts the tool exists for (L4 §1); +// * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the +// footer, in the space capture/placement vacated (L4 §2). +// Each button is drawn with its action name (Font::Label) and live key binding on a Micro +// sub-row (the L2 contract). action_bar owns the cluster tiling, the label/binding sub-rects, +// the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK binding query +// + the NamedCommandLookup/Main_OnCommand dispatch live here. +// +// Each button resolves its command id at RUNTIME from the composed named-command string +// (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable +// and beta and adds NO second registration. A cmd of 0 (action not registered on this channel) +// draws Disabled and no-ops on click. L4 is layout-only: the SAME existing actions fire via the +// SAME contract — no re-wiring, no command-id changes, and capture never auto-inserts. + +// The TOP toolbar inventory (L6 refinement): the FREQUENT acts only — Capture (item / track) +// then Re-capture (Maintenance, set between the two capture verbs and the placement verbs) then +// Placement (insert / insert-conform). The FOUR RARE variants (Batch Items / Batch Razor / +// Capture RT / Cancel RT) are ALL in the far-right "⋯" overflow menu (overflowMenuRows) — +// same registered actions, same command-id contract, just a different home. Capture scopes come +// from captureActionTable() (render_settings, pure); the rest are the registered M11/M10/M8 +// commands. Built once per draw/click. Each row carries its full (prefix-stripped) action name +// for the hover tooltip. +std::vector topBarRows() { + std::vector rows; + // Capture cluster — the primary gesture, leftmost. Face is a terse "Capture Item/Track"; + // the tooltip carries the full descriptionPhrase the action was registered with. + for (const CaptureActionDef& def : captureActionTable()) { + std::string label = def.commandSuffix; + if (label == "CAPTURE_ITEM") label = "Capture Item"; + else if (label == "CAPTURE_TRACK") label = "Capture Track"; + rows.push_back({def.commandSuffix, label, def.descriptionPhrase, + ActionCluster::Capture, true}); + } + // Maintenance cluster — Re-capture from source (M10), placed BETWEEN the capture group and + // the placement group so its position reads "refine the last capture before placing it". + // Cancel RT lives in the overflow menu (both realtime verbs share that home — L6). + rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture", + "re-capture from source", ActionCluster::Maintenance, true}); + // Placement cluster — the second act (still a distinct on-demand act; no auto-insert). + rows.push_back({"INSERT_SELECTED", "Insert", + "insert selected sample at edit cursor", ActionCluster::Placement, true}); + rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", + "insert selected sample at edit cursor (conform to tempo)", + ActionCluster::Placement, true}); + return rows; +} + +// The TOP-toolbar OVERFLOW menu inventory (L6): four items pulled off the visible bar into the +// far-right "⋯" menu button's popup — the three rare batch/realtime capture variants plus +// Cancel RT (both realtime verbs share the menu home). Each fires the SAME existing registered +// command id via the SAME NamedCommandLookup/Main_OnCommand contract — no action changes. The +// fullName is the popup entry text (the terse shortLabel is unused for menu items; the popup has +// room for the full name). Batch entries first, then the two realtime verbs. +std::vector overflowMenuRows() { + return { + {"CAPTURE_BATCH_ITEMS", "Batch Items", + "batch capture selected items (one per item)", ActionCluster::Capture, true}, + {"CAPTURE_BATCH_RAZOR", "Batch Razor", + "batch capture razor areas (one per area)", ActionCluster::Capture, true}, + {"CAPTURE_TRACK_REALTIME", "Capture RT", + "capture selected track (realtime)", ActionCluster::Capture, true}, + {"CANCEL_REALTIME_CAPTURE", "Cancel RT", + "cancel realtime capture", ActionCluster::Maintenance, true}, + }; +} + +namespace { + +// The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for +// "which mode is active"). Empty when no session (every button then falls to fail-open live). +std::string activeModeIdOrEmpty() { + if (!g_panel.session) return {}; + return g_panel.session->view().activeModeId(); +} + +} // namespace + +// The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons +// then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from +// design_view_actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; +// VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not +// paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag). +// The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the +// footer [Arrange|Design] toggle owns mode switching. +// +// OPPOSITE-MODE ENABLEMENT (L5): a tag button is LIVE only for the OPPOSITE of the active mode +// (you tag into the mode you are not in). The pure mode_enable::tagButtonEnabled decides it from +// the active mode id; Show Both is unconditional (not a tag target). enabled=false rows draw +// Disabled and no-op on click. The Item/Track axis is display-only here — both the Item and the +// Track button for a target share the target's enablement. +std::vector bottomBarRows() { + const std::string active = activeModeIdOrEmpty(); + const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange); + const bool designLive = tagButtonEnabled(active, TagTarget::Design); + + std::vector rows; + // Tagging cluster — the four Item/Track x Arrange/Design tag buttons. + rows.push_back({"VIEW_MOVE_ITEMS_ARRANGE", "Item: Arrange", + "move selected items -> Arrange", ActionCluster::Tagging, arrangeLive}); + rows.push_back({"VIEW_MOVE_ITEMS_DESIGN", "Item: Design", + "move selected items -> Design", ActionCluster::Tagging, designLive}); + rows.push_back({"VIEW_TAG_ARRANGE", "Track: Arrange", + "tag selected tracks -> Arrange", ActionCluster::Tagging, arrangeLive}); + rows.push_back({"VIEW_TAG_DESIGN", "Track: Design", + "tag selected tracks -> Design", ActionCluster::Tagging, designLive}); + // Switching cluster — Show Both, set apart (the only survivor of the old switching group). + rows.push_back({"VIEW_SHOW_BOTH", "Show Both", + "show both for selected tracks", ActionCluster::Switching, true}); + return rows; +} + +// The cluster button-count specs for a given row set, in the row list's cluster order (so the +// pure action_bar's flat index lines up with the row list). Handles all five cluster kinds; +// empty clusters contribute a 0-count spec (action_bar skips them, emitting no gap). The spec +// order follows each toolbar's fixed layout order (top: Capture, Maintenance, Placement — +// Re-capture sits between the two capture verbs and the placement verbs; bottom: Tagging, +// Switching). The bottom bar's Maintenance count is 0, so the order change is transparent there. +std::vector actionBarClusters(const std::vector& rows) { + int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0; + for (const ActionBarRow& r : rows) { + switch (r.cluster) { + case ActionCluster::Capture: ++nCap; break; + case ActionCluster::Placement: ++nPlace; break; + case ActionCluster::Maintenance: ++nMaint; break; + case ActionCluster::Tagging: ++nTag; break; + case ActionCluster::Switching: ++nSwitch; break; + } + } + return { + {ActionCluster::Capture, nCap}, + {ActionCluster::Maintenance, nMaint}, + {ActionCluster::Placement, nPlace}, + {ActionCluster::Tagging, nTag}, + {ActionCluster::Switching, nSwitch}, + }; +} + +// The BOTTOM toolbar band: a fixed-height band directly above the footer (below the split +// body). Degenerate (height 0) when the client is too short to host it above the footer. +ActionBarRect bottomToolbarRect(int w, int h) { + ActionBarRect s; + const RECT footer = panelFooter(w, h); + const int footerTop = (footer.top < footer.bottom) ? footer.top : h; + s.x = 0; + s.width = w; + s.height = kBottomToolbarHeight; + s.y = footerTop - kBottomToolbarHeight; + // Keep the bar below the top toolbar; if the client is too short, collapse it. + if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; } + return s; +} + +// Resolves a row's composed named command to its runtime command id (0 if not registered). +// The named-command lookup string is "_" + the channel-qualified id (REAPER's convention). +int resolveBarCommandId(const ActionBarRow& row) { + if (!NamedCommandLookup) return 0; + const std::string named = "_" + channelCommandId(row.suffix); + return NamedCommandLookup(named.c_str()); +} + +namespace { + +// The current key binding string for a command in the MAIN section, or "" (unbound / not +// registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)). +std::string barBindingText(int cmd) { + if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) { + const char* t = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0)); + if (t) return std::string(t); + } + return {}; +} + +} // namespace + +// The flat action index under (x, y) in `bar` for the given row set, or -1 (miss). Pure hit-test. +int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows) { + if (bar.height <= 0) return -1; + return hitTestActionBar(x, y, bar, actionBarClusters(rows), kBarSpec); +} + +// --- Tooltip (L5 refinement 2) ------------------------------------------------ +// +// A custom hover-delay tooltip: the full, prefix-stripped action name of the hovered toolbar +// button. Resolves the hovered element to its (anchor rect, text); returns false when the current +// hover has no tooltip (grid / chrome / the More button — the More button's own popup is its +// affordance). The tooltip DRAW lives in panel_render (drawTooltip); timing +// (kTooltipDelayMs) is applied by the caller. + +// The full (prefix-stripped) tooltip text for the currently hovered toolbar button, plus its +// anchor rect. Returns false when the hover is not a tooltip-bearing toolbar button. +bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah) { + const Hover& hv = g_panel.hovered; + std::vector rows; + ActionBarRect bar{}; + if (hv.kind == HoverKind::TopBarButton) { + rows = topBarRows(); + bar = topToolbarActionRect(w); + } else if (hv.kind == HoverKind::BottomBarButton) { + rows = bottomBarRows(); + bar = bottomToolbarRect(w, h); + } else { + return false; + } + if (hv.index < 0 || hv.index >= static_cast(rows.size())) return false; + + // The hovered button's slot rect (the anchor). computeBarSlots is the same layout the draw + + // hit-test use, so the anchor matches the drawn button exactly. + const std::vector clusters = actionBarClusters(rows); + const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); + const ActionBarSlot* slot = nullptr; + for (const ActionBarSlot& s : slots) + if (s.index == hv.index) { slot = &s; break; } + if (!slot) return false; + + // The full name is stored already prefix-free, but strip defensively in case a source ever + // carries the "ReaSampler:" display prefix (the tooltip must never show it — L5 refinement 2). + // L6: the keybinding sub-row was removed from the button face, so the tooltip now carries + // both the name AND the binding (when bound) — e.g. "capture selected item — F5". When the + // action is unbound the tooltip shows only the name (no "(unbound)" noise in the tooltip). + const std::string phrase = stripActionPrefix(rows[static_cast(hv.index)].fullName, + actionDisplayPrefix()); + const int cmd = resolveBarCommandId(rows[static_cast(hv.index)]); + const std::string binding = barBindingText(cmd); + textOut = binding.empty() ? phrase : phrase + " \xe2\x80\x94 " + binding; // " — " (em dash, UTF-8) + ax = slot->x; ay = slot->y; aw = slot->width; ah = slot->height; + return true; +} + +// --- Split geometry ----------------------------------------------------------- +// +// Every rect below is derived from the client size + fullHeight state, and BOTH paint +// and hit-testing call these so they never drift. All are top-left origin. + +// The body band between the TOP toolbar and the BOTTOM toolbar (L4). Its top edge is below the +// top toolbar; its bottom edge is the bottom toolbar's top. When the bottom bar collapses on a +// short client, bottomToolbarRect returns its y at the footer top, so the body still ends there. +RECT splitBody(int w, int h) { + RECT rc{}; + rc.left = 0; + rc.right = w; + rc.top = kTopToolbarHeight; + const ActionBarRect bar = bottomToolbarRect(w, h); + rc.bottom = bar.y; + if (rc.bottom < rc.top) rc.bottom = rc.top; + return rc; +} + +// True when both regions are shown (the split is live). Otherwise one region fills +// the body. +bool poolShown() { return g_panel.fullHeight != BankPanelFullHeight::BanksOnly; } +bool banksShown() { return g_panel.fullHeight != BankPanelFullHeight::PoolOnly; } + +// The pool region's rect (whole-region: header band + grid). Empty when hidden. +RECT poolRegionRect(int w, int h) { + const RECT body = splitBody(w, h); + if (!poolShown()) return RECT{0, 0, 0, 0}; + if (!banksShown()) return body; // pool full-height: the whole body + // Split: pool gets the top half (minus the divider). + RECT rc = body; + rc.bottom = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; + if (rc.bottom < rc.top) rc.bottom = rc.top; + return rc; +} + +// The named-banks region's rect (whole-region: header band + tab strip + grid). +RECT banksRegionRect(int w, int h) { + const RECT body = splitBody(w, h); + if (!banksShown()) return RECT{0, 0, 0, 0}; + if (!poolShown()) return body; // banks full-height: the whole body + RECT rc = body; + rc.top = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2 + + kSplitDividerHeight; + if (rc.top > rc.bottom) rc.top = rc.bottom; + return rc; +} + +// A region's header band (the top kRegionHeaderHeight of the region). +RECT regionHeaderRect(const RECT& region) { + RECT rc = region; + rc.bottom = region.top + kRegionHeaderHeight; + if (rc.bottom > region.bottom) rc.bottom = region.bottom; + return rc; +} + +// The named-banks region's tab strip (below its header band). +TabStripRect banksTabStripRect(const RECT& region) { + const RECT hdr = regionHeaderRect(region); + TabStripRect s; + s.x = region.left; + s.y = hdr.bottom; + s.width = region.right - region.left; + s.height = kTabStripHeight; + if (s.y + s.height > region.bottom) s.height = region.bottom - s.y; + if (s.height < 0) s.height = 0; + return s; +} + +// A region's grid viewport (below the header band, and below the tab strip for the +// banks region). This is where cells tile. +RECT regionGridRect(const RECT& region, bool isBanks) { + RECT rc = region; + rc.top = region.top + kRegionHeaderHeight; + if (isBanks) rc.top += kTabStripHeight; + if (rc.top > rc.bottom) rc.top = rc.bottom; + return rc; +} + +// The full-height toggle button rect inside a region header (right-aligned). +RECT fullHtBtnRect(const RECT& region) { + const RECT hdr = regionHeaderRect(region); + RECT rc = hdr; + rc.right = hdr.right - 4; + rc.left = rc.right - kFullHtBtnWidth; + rc.top = hdr.top + 2; + rc.bottom = hdr.bottom - 2; + return rc; +} + +// The "+" create-bank button rect inside the named-banks region header (left of the +// full-height button). +RECT createBtnRect(const RECT& region) { + RECT ft = fullHtBtnRect(region); + RECT rc = ft; + rc.right = ft.left - 4; + rc.left = rc.right - kCreateBtnWidth; + return rc; +} + +// Resolves a region's display for the currently-shown bank. Empty (no bank / no width) +// yields an empty display. orderedSampleIds reconciles the bank's SlotMap against live +// membership, so a freshly-migrated or out-of-band-mutated bank always yields a complete +// order (trailing empties are trimmed by the model — maxSlot walks only live occupants). +RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) { + RegionDisplay d; + BankBook* b = book(); + if (!b) return d; + const std::string bankId = bankIdForRegion(reg); + if (bankId.empty()) return d; + d.bank = b->bank(bankId); + if (!d.bank) return d; + + d.orderedIds = b->orderedSampleIds(bankId); // occupied ids, slot order (reconciles) + if (d.orderedIds.empty()) return d; + + const RECT grid = regionGridRect(region, isBanks); + const int w = grid.right - grid.left; + if (w <= 0) return d; + d.slotRects = computeSlotRects(d.bank->slots.maxSlot(), w, kGrid); + for (SlotCellRect& r : d.slotRects) { r.x += grid.left; r.y += grid.top; } + return d; +} + +// The FOCUSED region's display (the slot-order bridge for the region holding the live +// selection). Mirrors columnsForRegion's client read. +RegionDisplay focusedDisplay() { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const bool isBanks = g_panel.focusedRegion == Region::Banks; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + return regionDisplay(region, isBanks, g_panel.focusedRegion); +} + +// Which region (if any) contains client point (x, y); returns false via `out` set to +// Pool by default when the point is in neither region body. +bool regionAt(int x, int y, Region& out) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + if (poolShown()) { + const RECT r = poolRegionRect(w, h); + if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { + out = Region::Pool; return true; + } + } + if (banksShown()) { + const RECT r = banksRegionRect(w, h); + if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { + out = Region::Banks; return true; + } + } + return false; +} + +// The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by +// mode_switch inside footer_bar's toggle box, so both draw and hit-test use the same box. +int footerToggleSegmentHit(int x, int y, int w, int h) { + if (!g_panel.session) return -1; + const FooterBarLayout fb = footerBarLayoutFor(w, h); + if (fb.toggle.empty()) return -1; + const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; + return hitTestSegment(x, y, th, modeCount()); +} + +// The column count for a region's current grid width (nav needs the layout's wrap). +int columnsForRegion(Region reg) { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const RECT region = reg == Region::Banks ? banksRegionRect(w, h) + : poolRegionRect(w, h); + const RECT grid = regionGridRect(region, reg == Region::Banks); + return columnsForWidth(grid.right - grid.left, kGrid); +} + +} // namespace reasampler::panel + +// --- Public API (the split-state seam — panel_layout.h) ------------------------ + +namespace reasampler { + +BankPanelFullHeight bankPanelFullHeight() { + return panel::g_panel.fullHeight; +} + +static void setFullHeight(BankPanelFullHeight target) { + panel::g_panel.fullHeight = + (panel::g_panel.fullHeight == target) ? BankPanelFullHeight::Split : target; + if (panel::g_panel.hwnd) InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); +} + +void bankPanelToggledPoolFullHeight() { + setFullHeight(BankPanelFullHeight::PoolOnly); +} + +void bankPanelToggledBanksFullHeight() { + setFullHeight(BankPanelFullHeight::BanksOnly); +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_layout.h b/src/shell/panel/panel_layout.h new file mode 100644 index 0000000..72ac6ea --- /dev/null +++ b/src/shell/panel/panel_layout.h @@ -0,0 +1,43 @@ +#pragma once +// panel_layout — the vertical-split layout STATE seam of the bank panel (Q-W2 split of +// bank_panel.h; Phase B3/B4). The panel window splits vertically — pool on top, +// named-banks region below — and two toggles collapse the split. This header carries +// that public state surface; the geometry derivation itself (toolbar/footer/menu rects, +// row/cluster builders, region rects, the L7 slot-order display bridge) is internal to +// panel_layout.cpp (see panel_state.h for the intra-panel seam). +// +// REAPER-free: main.cpp / bank_actions.cpp drive these through plain free functions. + +namespace reasampler { + +// The vertical-split full-height layout state (Phase B). The bank window splits +// vertically — pool on top, named-banks region below — and two toggles collapse the +// split: pool full-height (hide the named-banks region) and banks full-height (hide +// the pool). The two are mutually exclusive with the default (both regions shown), +// so one enum captures the whole state. +// +// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives +// beside the tail setting — the other session-level view-layout bit the panel +// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not +// project state, so it must not travel with the .rpp. In-memory for the extension's +// lifetime; resets to Split on unload. +enum class BankPanelFullHeight { + Split, // default: pool region on top, named-banks region below + PoolOnly, // pool full-height — named-banks region hidden + BanksOnly, // banks full-height — pool region hidden +}; + +// The current full-height layout state (default Split). READ by B4's panel to decide +// which region(s) to draw. Safe before the panel has ever opened. +BankPanelFullHeight bankPanelFullHeight(); + +// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from +// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool +// full-height" action. Requests a repaint so an open panel reflects the change. +void bankPanelToggledPoolFullHeight(); + +// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle. +// Bound to the "banks full-height" action. Requests a repaint. +void bankPanelToggledBanksFullHeight(); + +} // namespace reasampler diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp new file mode 100644 index 0000000..e111169 --- /dev/null +++ b/src/shell/panel/panel_render.cpp @@ -0,0 +1,613 @@ +// panel_render.cpp — the LICE draw seam of the docked bank panel (Q-W2 split of +// bank_panel.cpp; M5 Wave A/B + Phase B4 + Phase L). Owns WM_PAINT's full paint: +// the VERTICAL SPLIT (pool grid region on top, named-banks tab-page region below), +// the region headers + tab strip, the two task-grouped toolbars + More button, the +// footer (mode toggle + count + Tail + Prune), the hover-delay tooltip overlay, and +// the per-card thumbnail/metadata draw — everything through the L1 kit by palette +// role (draw_kit), double-buffered, BitBlt'd once. +// +// READ-ONLY: reads panel + session state; the input/drag seams mutate it. All rect +// derivation comes from panel_layout (the single source both draw and hit-test use). +// +// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called +// here (LICE/Win32 only); REAPER SDK types arrive via panel_state.h. + +#include +#include + +#include "shell/panel/panel_state.h" + +#include "shell/panel/draw_kit.h" // kit text()/fillSurface/drawButton/drawWaveform (L1) +#include "shell/persist/session.h" // ReaSamplerSession — mode/view/tail reads +#include "core/view/view_mode_model.h" // ViewModeModel / Mode — the footer toggle's model + +namespace reasampler::panel { + +namespace { + +// --- Drawing: thumbnails (via the kit's shared drawWaveform since FA3) --------- + +// Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT +// (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT +// (wall-clock). Decorative + non-interactive (no hit-test, no hover). Drawn in the kit's +// Micro / ValueMono classes in text/dim, subordinate to the waveform. A blank musical +// read-out (unstamped meter / unknown tempo) simply omits the bottom-left string. +void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) { + MusicalLength ml; + ml.lengthSeconds = s.lengthSeconds; + ml.tempoBpm = s.captureTempo; + ml.timeSigNum = s.captureTimeSigNum; + ml.timeSigDenom = s.captureTimeSigDenom; + const std::string bars = formatBarsBeats(ml); // "" when unstamped/no-tempo + const std::string secs = formatSecondsMs(s.lengthSeconds); + + // A short strip along the card's bottom edge. Left/right halves; text/dim so the + // waveform stays the centerpiece. Micro on the left (musical), ValueMono on the right + // (tabular numbers that must not jitter). + const int stripH = 12; + const int pad = 3; + const int y = rect.y + rect.height - stripH; + if (!bars.empty()) { + const KitBox left{rect.x + pad, y, rect.width / 2 - pad, stripH}; + text(bmp, left, bars.c_str(), Font::Micro, Role::TextDim, Align::Left); + } + const KitBox right{rect.x + rect.width / 2, y, rect.width / 2 - pad, stripH}; + text(bmp, right, secs.c_str(), Font::ValueMono, Role::TextDim, Align::Right); +} + +void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, + bool selected, bool focused, bool hovered, const Sample* sample) { + // Cell surface through the kit (L7 selection restyle): a selected card draws the NORMAL + // cell surface (Rest, or Hover when hovered) — NOT the inverted accent-fill. Selection is + // marked purely by an accent/tertiary (pastel purple) border below; hover stays a fill- + // state change orthogonal to that border, so a hovered selected card still reads selected. + const KitBox cell{rect.x, rect.y, rect.width, rect.height}; + const InteractionState state = hovered ? InteractionState::Hover : InteractionState::Rest; + fillSurface(bmp, cell, Role::BgCell, state); + + // Border (L7): accent/TERTIARY purple when selected (the sole selection signal), else + // hairline. Focus is a distinct text/primary inner ring so a focused-AND-selected card + // reads BOTH — the purple outer border + the inner focus ring — kept visually separate. + const KitColor border = selected ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0); + if (focused) { + const LICE_pixel ring = toLice(roleColor(Role::TextPrimary)); + LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0); + } + + // Waveform plot through the kit's shared primitive (FA3): the SAME per-pixel-column + // min/max envelope draw the VST editor hero + browser cards use — one algorithm, one + // look, everywhere. The oversampled env (see drawRegionGrid's binWidth) collapses per + // column via peaks::columnMinMax inside the kit; an empty env draws just the midline. + drawWaveform(bmp, cell, env); + + // L7 decorative metadata overlay, drawn last so it sits over the waveform. + if (sample) drawCardMeta(bmp, rect, *sample); +} + +// --- Kit draw adapters (Phase L) ---------------------------------------------- +// +// All panel text draws through the kit's cached AA font (draw_kit::text), NOT raw GDI DrawText +// (retired at L1). L2 re-roles every color through the pure `theme` module and draws surfaces +// via the kit (fillSurface / drawButton). These thin adapters bridge the panel's RECT-based +// geometry helpers to the kit's KitBox and give the panel a KitColor->LICE_pixel boundary for +// the few raw borders it still draws over kit surfaces. The kit owns the font lifecycle +// (kitFontsInit/Shutdown, wired at panel open/close below). + +KitBox toKitBox(const RECT& r) { + return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top}; +} + +// KitColor -> LICE_pixel: all sites use the kit's toLice() from draw_kit.h — the single +// conversion boundary the kit enforces. No local alias needed. + +// L2 role/font-aware text: draws through the kit in a palette ROLE color and a chosen kit +// Font (the action bar uses Micro for the keybinding sub-label, Label for the name, Title for +// region headings). Takes a KitBox directly (the pure geometry the L2 modules return). +void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt, + Font font, Role role, Align align) { + text(bmp, box, txt, font, role, align); +} + +// The per-mode membership count that travels with the toggle (L4 §3): the number of leaves +// tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no +// session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.) +// A display-only tally over the model's public membership map — no model semantics duplicated. +int activeModeMemberCount() { + if (!g_panel.session) return 0; + const ViewModeModel& view = g_panel.session->view(); + const std::string& active = view.activeModeId(); + if (active.empty()) return 0; + int n = 0; + for (const auto& [guid, m] : view.membership().all()) + if (m.modeIds.count(active) != 0) ++n; + return n; +} + +// Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design] +// toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and +// the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button +// set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it. +void drawFooter(LICE_IBitmap* bmp, int w, int h) { + const RECT f = panelFooter(w, h); + if (f.top >= f.bottom) return; + + // Footer band + hairline top divider (the base persistent-controls strip). + fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel, + InteractionState::Rest); + LICE_Line(bmp, f.left, f.top, f.right, f.top, + toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); + + const FooterBarLayout fb = footerBarLayoutFor(w, h); + + // [Arrange|Design] toggle — drawn as N mode_switch segments inside footer_bar's toggle box + // (the segment geometry stays owned by the pure mode_switch; footer_bar owns the box). The + // active mode's segment carries the accent; others hover-or-rest bg/cell. + if (!fb.toggle.empty() && g_panel.session) { + const ViewModeModel& view = g_panel.session->view(); + const std::vector& modes = view.modes().all(); + const int n = static_cast(modes.size()); + const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; + const std::vector segs = computeSegmentRects(th, n); + const std::string& activeId = view.activeModeId(); + for (int i = 0; i < static_cast(segs.size()); ++i) { + const SegmentRect& s = segs[static_cast(i)]; + const Mode& mode = modes[static_cast(i)]; + const bool active = mode.id == activeId; + const InteractionState state = + active ? InteractionState::Active + : hoverState(g_panel.hovered, HoverKind::ModeSegment, i); + fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state); + LICE_DrawRect(bmp, s.x, s.y, s.width, s.height, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + const Role tr = active ? Role::BgBase : Role::TextPrimary; + kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(), + Font::Label, tr, Align::Center); + } + } + + // Per-mode member count, a compact dim readout beside the toggle (L4 §3 — "the count + // travels with the toggle"). Passive text, not a control. + if (!fb.count.empty()) { + const int members = activeModeMemberCount(); + const std::string countLabel = + std::to_string(members) + (members == 1 ? " track" : " tracks"); + kitText(bmp, KitBox{fb.count.x, fb.count.y, fb.count.width, fb.count.height}, + countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center); + } + + // Tail BUTTON (L4 §4) — a real kit button with rest/hover states; its click cycles the + // tail mode exactly as the old click-zone did. Label is the pure tailToggleLabel. + if (!fb.tail.empty()) { + const InteractionState state = hoverState(g_panel.hovered, HoverKind::TailButton, -1); + const std::string label = tailToggleLabel(currentTail()); + const KitButtonBox box{KitBox{fb.tail.x, fb.tail.y, fb.tail.width, fb.tail.height}}; + drawButton(bmp, box, label.c_str(), state, /*warn=*/false); + } + + // Version/channel readout (Phase V, V3/V4), right-aligned, unobtrusive. appVersion() + // renders the configured version string on stable and that string plus "-beta" on beta, + // so a beta panel self-identifies. It sits inside the space footer_bar reserves at the + // right (rightReserve) and clears the prune button (prune_button::rightInset). Dim, + // passive identification (V3). + kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top}, + appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right); + + // Prune button — set apart at the far RIGHT (the ONLY warn-colored, byte-deleting control), + // honoring hover. No-op when suppressed (footer too narrow). Order reads left (benign, + // frequent) -> right (destructive, rare) per the L4 footer contract. + const ButtonRect pb = pruneButtonRectFor(w, h); + if (!pb.empty()) { + const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1); + const KitButtonBox box{KitBox{pb.x, pb.y, pb.width, pb.height}}; + drawButton(bmp, box, "Prune", state, /*warn=*/true); + } +} + +// Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button +// as a kit drawButton (rest/hover/disabled) with the action short label on the single-row face. +// Overflow drops WHOLE trailing buttons (the pure layout returns only the buttons that fit), so +// nothing is drawn clipped. `hoverKind` selects which HoverKind this bar's buttons use +// (TopBarButton / BottomBarButton) so the two toolbars' hover states never cross. `topDivider` +// draws a hairline at the band's top edge (the bottom toolbar's elevation over the split body); +// the top toolbar draws it at its bottom edge instead. Key binding help is in the hover tooltip +// (L6), not on the button face — the face shows only shortLabel. +void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar, + const std::vector& rows, HoverKind hoverKind, bool topDivider) { + if (bar.height <= 0 || bar.width <= 0) return; + + const KitBox band{bar.x, bar.y, bar.width, bar.height}; + fillSurface(bmp, band, Role::BgPanel, InteractionState::Rest); + const int dividerY = topDivider ? bar.y : bar.y + bar.height - 1; + LICE_Line(bmp, bar.x, dividerY, bar.x + bar.width, dividerY, + toLice(roleColor(Role::LineHairline)), 0.5f, 0, false); + + const std::vector clusters = actionBarClusters(rows); + const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); + + for (const ActionBarSlot& s : slots) { + if (s.index < 0 || s.index >= static_cast(rows.size())) continue; + const ActionBarRow& row = rows[static_cast(s.index)]; + const int cmd = resolveBarCommandId(row); + + // State: Disabled when the action is not registered on this channel OR the row is gated + // off (L5 opposite-mode enablement — the tag buttons for the ACTIVE mode); else Hover + // when hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.) + InteractionState state = InteractionState::Rest; + if (cmd == 0 || !row.enabled) state = InteractionState::Disabled; + else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index) + state = InteractionState::Hover; + + // The button surface (drawButton draws the micro-gradient + rounded border + honors + // the state). The label is drawn separately so the text role tracks the state correctly; + // pass no label to drawButton. + const KitButtonBox box{KitBox{s.x, s.y, s.width, s.height}}; + drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); + + const Role textRole = + (state == InteractionState::Disabled) ? Role::TextDim : Role::TextPrimary; + const KitBox labelBox{s.labelX, s.labelY, s.labelW, s.labelH}; + kitText(bmp, labelBox, row.shortLabel.c_str(), Font::Label, textRole, Align::Center); + } +} + +// --- Top-toolbar overflow ("⋯" More) menu (L5 refinement 1) ------------------- +// +// The three rare capture variants live only in this popup. The button is drawn kit-style (rest/ +// hover) at the far right of the top band; a click opens a REAPER/host TrackPopupMenu listing the +// variants, each firing its existing registered command id via NamedCommandLookup/Main_OnCommand +// (the SAME contract the visible buttons use — no action changes). A transient OS menu is fine +// for panel-external chrome (brief §1); only the button geometry (overflow_menu) is pure. + +// Draws the far-right More button (rest/hover). No-op when suppressed (band too narrow). +void drawMoreButton(LICE_IBitmap* bmp, int w) { + const MenuButtonRect mb = topMenuButtonRect(w); + if (mb.empty()) return; + const InteractionState state = hoverState(g_panel.hovered, HoverKind::MoreButton, -1); + const KitButtonBox box{KitBox{mb.x, mb.y, mb.width, mb.height}}; + drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); + // The glyph: three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text + // path). Drawn as text so it picks up the kit font + AA. Reads as the conventional "More". + kitText(bmp, KitBox{mb.x, mb.y, mb.width, mb.height}, "...", + Font::Label, Role::TextPrimary, Align::Center); +} + +// Draws the hover-delay tooltip over the given anchor button, if a tooltip is due (the current +// hover is a toolbar button AND it has been hovered past kTooltipDelayMs). Drawn LAST in the +// paint so it overlays the toolbars. The box is placed by the pure tooltip module (below the +// anchor, flipping above near the bottom edge, clamped to the client). +void drawTooltip(LICE_IBitmap* bmp, int w, int h) { + if (!g_panel.tooltipShown) return; + std::string txt; + int ax = 0, ay = 0, aw = 0, ah = 0; + if (!currentTooltip(w, h, txt, ax, ay, aw, ah) || txt.empty()) return; + + const int textW = static_cast(txt.size()) * kTooltipCharPx; + const TooltipBox tb = + computeTooltip(ax, ay, aw, ah, textW, kTooltipTextH, w, h, TooltipSpec{}); + if (tb.empty()) return; + + // The tooltip surface: a raised bg/cell chip with a hairline border, then the AA text. + const KitBox box{tb.x, tb.y, tb.width, tb.height}; + fillSurface(bmp, box, Role::BgCell, InteractionState::Hover); + LICE_DrawRect(bmp, tb.x, tb.y, tb.width, tb.height, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + kitText(bmp, box, txt.c_str(), Font::Label, Role::TextPrimary, Align::Center); +} + +// --- Drawing: a grid region --------------------------------------------------- + +// Draws one region's grid of thumbnails (or an empty-state line) clipped to its +// viewport. `selectionOwner` is true when this region holds the live selection, so +// its cells show selection/focus chrome; the other region draws plain. +void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, + const BankModel* index, const std::string& emptyMsg, + bool selectionOwner, const std::string& projectDir, Region reg) { + const RECT grid = regionGridRect(region, isBanks); + if (grid.bottom <= grid.top) return; + + if (!index || index->empty()) { + kitText(bmp, toKitBox(grid), emptyMsg.c_str(), Font::Label, Role::TextDim, Align::Center); + return; + } + + // L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense + // BankModel insertion order. Selection/focus are keyed by the occupied-ordinal (selection + // space); a slot maps back to its ordinal via selectionForSlot. + const RegionDisplay disp = regionDisplay(region, isBanks, reg); + // FA3 gap-free: request one bin per drawn pixel column; drawWaveform's + // peaks::columnMinMax exact partition makes every column gap-free — overbinning + // produces byte-identical pixels at higher memory/CPU cost. computeThumbnail clamps + // the request to the frame count. + const int binWidth = kWaveformOversample * + waveformColumnCount(KitBox{0, 0, kGrid.cellWidth, kGrid.cellHeight}); + for (const SlotCellRect& r : disp.slotRects) { + if (r.y >= grid.bottom) continue; // below the viewport: skip (no scroll) + const CellRect rect{r.x, r.y, r.width, r.height}; + const std::string id = disp.idAtSlot(r.slot); + if (id.empty()) { + // Interior gap slot: a subtle empty-slot treatment through the kit — a hairline + // outline on bg/cell, clearly NOT a card (decorative, per the L7 spec). No + // selection/focus/waveform, and not a hover or hit target (the grid never tracks + // cell hover; a click on an empty slot clears selection like any grid miss). + fillSurface(bmp, KitBox{rect.x, rect.y, rect.width, rect.height}, + Role::BgCell, InteractionState::Rest); + LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + continue; + } + const Sample* s = index->query(id); + if (!s) continue; // reconciled order should never name a stale id; defensive + const int sel = disp.selectionForSlot(r.slot); + const bool selected = selectionOwner && sel >= 0 && g_panel.selection.contains(sel); + const bool focused = selectionOwner && sel >= 0 && g_panel.selection.focus == sel; + const Envelope& env = thumbnailFor(*s, binWidth, projectDir); + // Grid-cell hover is intentionally not tracked: the cell already carries selection + + // focus chrome (the centerpiece's "bones"); a third transient hover state on every + // cell would add repaint churn + visual noise. Hover lights the chrome/buttons/tabs. + drawThumbnail(bmp, rect, env, selected, focused, /*hovered=*/false, s); + } +} + +// L7: draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but +// ONLY when a same-bank in-grid drag (Reorder or Replace) is live over THIS region (the drag +// source region). An accent/HOT outline (distinct from the accent/tertiary purple selection +// border, per the spec's "must not be confusable" constraint); Replace draws a doubled outline +// so an Alt-over-occupied replace reads as a stronger "swap" cue than a plain reorder. No-op +// for a move/copy/OS drag or when the pointer is off any slot (dragTargetSlot < 0). +void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Region reg) { + if (!g_panel.dragging) return; + if (g_panel.cardGesture != CardGesture::Reorder && + g_panel.cardGesture != CardGesture::Replace) + return; + if (g_panel.dragSourceRegion != reg) return; // highlight only the source bank's grid + if (g_panel.dragTargetSlot < 0) return; + + const RECT grid = regionGridRect(region, isBanks); + const RegionDisplay disp = regionDisplay(region, isBanks, reg); + // Use the drop rects (includes the trailing row past maxSlot) so a beyond-extent + // target slot gets a visible highlight cue, not silence. + const int gridW = grid.right - grid.left; + const int maxSlot = disp.bank ? disp.bank->slots.maxSlot() : -1; + std::vector dropRects = computeSlotRectsForDrop(maxSlot, gridW, kGrid); + for (SlotCellRect& r : dropRects) { r.x += grid.left; r.y += grid.top; } + for (const SlotCellRect& r : dropRects) { + if (r.slot != g_panel.dragTargetSlot) continue; + if (r.y >= grid.bottom) return; // below the viewport (no scroll) + const LICE_pixel hot = toLice(roleColor(Role::AccentHot)); + LICE_DrawRect(bmp, r.x, r.y, r.width, r.height, hot, 1.0f, 0); + if (g_panel.cardGesture == CardGesture::Replace) + LICE_DrawRect(bmp, r.x + 1, r.y + 1, r.width - 2, r.height - 2, hot, 1.0f, 0); + return; + } +} + +// Draws a region header: title, the active-bank readout, and the full-height button. +void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, + const std::string& activeName, bool poolBtnIsPool) { + const RECT hdr = regionHeaderRect(region); + // Region header band (kit bg/panel — a raised region title bar). A hairline underline. + fillSurface(bmp, KitBox{hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top}, + Role::BgPanel, InteractionState::Rest); + LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1, + toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); + + // Title, left (Font::Title — a region heading). The two regions are distinct KINDS of + // container, so the title carries a CATEGORICAL accent (DS-2 revised: secondary/tertiary + // mark kinds, never intensity) — Pool = secondary teal, Banks = tertiary purple. This is + // a category mark, NOT the "what's live" signal (that stays the primary-lime "Active:" + // readout beside it), keeping primary reserved for the live/active layer. + RECT titleRc = hdr; + titleRc.left += 8; + titleRc.right = titleRc.left + 120; + const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary; + kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left); + + // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the PRIMARY + // accent role in BOTH region headers so the active/capture-target bank is legible even when + // it is not the shown tab and even when it is the pool. Primary = "what's live" (DS-2). + const std::string readout = "Active: " + activeName; + RECT actRc = hdr; + actRc.left = titleRc.right + 6; + actRc.right = createBtnRect(region).left - 6; + if (actRc.right > actRc.left) + kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::AccentPrimary, Align::Left); + + // Full-height toggle button: an arrow glyph. In split it means "maximize this region"; + // when this region is already full it means "restore the split". Kit drawButton + hover. + const RECT btn = fullHtBtnRect(region); + const bool thisFull = + poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly) + : (g_panel.fullHeight == BankPanelFullHeight::BanksOnly); + const HoverKind hk = poolBtnIsPool ? HoverKind::FullHtPool : HoverKind::FullHtBanks; + const InteractionState state = + thisFull ? InteractionState::Active : hoverState(g_panel.hovered, hk, -1); + const KitButtonBox box{KitBox{btn.left, btn.top, btn.right - btn.left, + btn.bottom - btn.top}}; + drawButton(bmp, box, thisFull ? "v" : "^", state, /*warn=*/false); +} + +// Draws the named-banks tab strip: one tab per named bank (ordinal order), the SHOWN +// tab highlighted, the ACTIVE bank's tab lit with the accent border, overflow +// chevrons when present, plus the "+" create button in the header. During a drag, +// the tab under the pointer gets the drop-target highlight. +void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { + const TabStripRect strip = banksTabStripRect(region); + if (strip.height <= 0) return; + // Tab strip band (kit bg/base — recessed relative to the region header above it). + fillSurface(bmp, KitBox{strip.x, strip.y, strip.width, strip.height}, + Role::BgBase, InteractionState::Rest); + + const std::vector tabs = namedBanks(); + const int n = static_cast(tabs.size()); + if (n == 0) { + kitText(bmp, KitBox{strip.x + 8, strip.y, strip.width - 8, strip.height}, + "No named banks -- click + to create one.", + Font::Label, Role::TextDim, Align::Left); + return; + } + + const TabStripLayout layout = + computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); + + // Chevrons (drawn first so tabs sit above their inner edges). + if (layout.overflow) { + const KitBox lc{strip.x, strip.y, kTabSpec.chevronWidth, strip.height}; + const KitBox rc{strip.x + strip.width - kTabSpec.chevronWidth, strip.y, + kTabSpec.chevronWidth, strip.height}; + fillSurface(bmp, lc, Role::BgCell, InteractionState::Rest); + fillSurface(bmp, rc, Role::BgCell, InteractionState::Rest); + kitText(bmp, lc, "<", Font::Label, Role::TextPrimary, Align::Center); + kitText(bmp, rc, ">", Font::Label, Role::TextPrimary, Align::Center); + } + + const std::string activeId = book() ? book()->activeBankId() : std::string(); + const std::vector rects = + computeTabRects(strip, n, kTabSpec, g_panel.tabScroll); + for (const TabRect& tr : rects) { + const Bank* bk = tabs[static_cast(tr.index)]; + const bool shown = bk->id == g_panel.shownBankId; + const bool active = bk->id == activeId; + const bool dropHere = g_panel.dragging && + g_panel.dropKind == DropKind::Tab && + g_panel.dropBankId == bk->id; + const bool hovered = g_panel.hovered.kind == HoverKind::Tab && + g_panel.hovered.index == tr.index; + + // Surface state: the ACTIVE bank (capture target) carries the accent (Active); a drag + // drop-target reads Dragging; the SHOWN (browsed) tab reads Pressed (recessed-lit); + // else hover-or-rest bg/cell. + const KitBox tb{tr.x, tr.y, tr.width, tr.height}; + InteractionState state = InteractionState::Rest; + if (active) state = InteractionState::Active; + else if (dropHere) state = InteractionState::Dragging; + else if (shown) state = InteractionState::Pressed; + else if (hovered) state = InteractionState::Hover; + fillSurface(bmp, tb, Role::BgCell, state); + + // The active bank's tab gets a bright accent border (unmistakable), distinct from the + // shown tab's fill — active != shown, made visible (kit accent role). + const KitColor border = active ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, toLice(border), 1.0f, 0); + if (active) + LICE_DrawRect(bmp, tr.x + 1, tr.y + 1, tr.width - 2, tr.height - 2, + toLice(border), 1.0f, 0); + + // Label: bg/base on the accent-active fill for contrast, else text/primary. + const Role trole = active ? Role::BgBase : Role::TextPrimary; + kitText(bmp, KitBox{tr.x + 4, tr.y, tr.width - 8, tr.height}, + bk->displayName.c_str(), Font::Label, trole, Align::Center); + } +} + +// The active bank's display name (for the readout). "Pool" when the pool is active. +std::string activeBankName() { + BankBook* b = book(); + if (!b) return std::string(kPoolBankName); + const Bank* bk = b->bank(b->activeBankId()); + return bk ? bk->displayName : std::string(kPoolBankName); +} + +} // namespace + +// --- Full paint --------------------------------------------------------------- + +void paintPanel(HWND hwnd, HDC hdc) { + RECT cr{}; + GetClientRect(hwnd, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + if (w <= 0 || h <= 0) return; + + LICE_SysBitmap bmp(w, h); + LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); + + const std::string projectDir = currentProjectDir(); + const std::string activeName = activeBankName(); + + // Pool region (top). + if (poolShown()) { + const RECT region = poolRegionRect(w, h); + drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true); + drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool), + "No samples in the pool yet. Capture one to see it here.", + g_panel.focusedRegion == Region::Pool, projectDir, Region::Pool); + // Drop-target highlight for the pool region during a MOVE/COPY drag (a whole-grid + // outline signalling "drop here to move/copy into this bank"). Suppressed for a + // same-bank reorder (that shows a per-SLOT highlight below, not the whole grid). + if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion && + (g_panel.cardGesture == CardGesture::Move || + g_panel.cardGesture == CardGesture::Copy)) { + const RECT grid = regionGridRect(region, false); + LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, + grid.right - grid.left - 2, grid.bottom - grid.top - 2, + toLice(roleColor(Role::AccentHot)), 1.0f, 0); + } + // L7 per-slot reorder/replace target highlight (source = pool). An accent/hot outline + // on the target slot's cell — distinct from the accent/tertiary purple selection + // border, so it is never confusable with a selected card. + drawCardDropTarget(&bmp, region, /*isBanks=*/false, Region::Pool); + } + + // Split divider. + if (poolShown() && banksShown()) { + const RECT body = splitBody(w, h); + const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; + LICE_FillRect(&bmp, 0, dy, w, kSplitDividerHeight, + toLice(roleColor(Role::BgBase)), 1.0f, 0); + } + + // Named-banks region (bottom). + if (banksShown()) { + const RECT region = banksRegionRect(w, h); + drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false); + // "+" create button (drawn as part of the banks header) — kit drawButton + hover. + const RECT cbtn = createBtnRect(region); + const InteractionState createState = + hoverState(g_panel.hovered, HoverKind::CreateBank, -1); + drawButton(&bmp, KitButtonBox{KitBox{cbtn.left, cbtn.top, cbtn.right - cbtn.left, + cbtn.bottom - cbtn.top}}, + "+", createState, /*warn=*/false); + + drawTabStrip(&bmp, region); + drawRegionGrid(&bmp, region, /*isBanks=*/true, indexForRegion(Region::Banks), + g_panel.shownBankId.empty() + ? "Select or create a named bank." + : "This bank is empty. Move samples here from the pool.", + g_panel.focusedRegion == Region::Banks, projectDir, Region::Banks); + // Drop-target highlight for the banks region during a drag. BanksRegion fires + // when the pointer is in the grid but not on a specific tab; Tab draws its own + // highlight on the individual tab (drawTabStrip above handles that case). + if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion && + (g_panel.cardGesture == CardGesture::Move || + g_panel.cardGesture == CardGesture::Copy)) { + const RECT grid = regionGridRect(region, true); + LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, + grid.right - grid.left - 2, grid.bottom - grid.top - 2, + toLice(roleColor(Role::AccentHot)), 1.0f, 0); + } + // L7 per-slot reorder/replace target highlight (source = banks region). + drawCardDropTarget(&bmp, region, /*isBanks=*/true, Region::Banks); + } + + // L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles + // into the band MINUS the far-right More-button reserve; the More button is drawn over the + // band's reserved right strip; the BOTTOM toolbar (four opposite-mode tag buttons + Show + // Both); then the footer (mode toggle + count + Tail button + Prune). Drawn last so they sit + // over the split body's edges. drawToolbar fills only its passed (action) rect, so fill the + // WHOLE top band first — otherwise the reserved right strip behind the More button is bare. + fillSurface(&bmp, KitBox{0, 0, w, kTopToolbarHeight}, Role::BgPanel, InteractionState::Rest); + drawToolbar(&bmp, topToolbarActionRect(w), topBarRows(), HoverKind::TopBarButton, + /*topDivider=*/false); + drawMoreButton(&bmp, w); + drawToolbar(&bmp, bottomToolbarRect(w, h), bottomBarRows(), HoverKind::BottomBarButton, + /*topDivider=*/true); + drawFooter(&bmp, w, h); + + // The custom hover-delay tooltip overlays everything (L5 refinement 2). + drawTooltip(&bmp, w, h); + + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h new file mode 100644 index 0000000..1cc153c --- /dev/null +++ b/src/shell/panel/panel_state.h @@ -0,0 +1,605 @@ +#pragma once +// panel_state — INTERNAL shared state + cross-seam contract of the docked bank panel +// (Q-W2: bank_panel.cpp split into eight TUs under shell/panel/). Included ONLY by the +// panel's own translation units (panel_render / panel_thumbnails / panel_audition / +// panel_input / panel_layout / panel_drag / panel_bank_ops / panel_window) — consumers +// outside the panel use the per-seam public headers (panel_window.h / panel_input.h / +// panel_bank_ops.h / panel_layout.h). +// +// What lives here: +// * PanelState (the one shared state blob, defined in panel_window.cpp) + the small +// enums/structs the seams speak (Region / DropKind / Hover / RegionDisplay / +// ActionBarRow) and the shared layout constants. +// * The cross-seam free-function declarations, grouped by OWNING TU. Everything is a +// plain free function — direct call-through, no interface, no virtual dispatch +// (T4-28: the audition path and the per-mouse-move path must stay direct calls). +// * Explicit using-declarations pulling the pure modules' symbols into +// reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces). The +// interim core/namespaces.h shim is GONE (deleted in Q-W6 with the last split); +// every symbol below names its true home. +// +// REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural +// mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, +// so a BankModel& / Bank* must NEVER be cached across one. Every seam resolves fresh +// AFTER any mutation and passes bank IDS (not references) into the model ops. + +#include +#include +#include +#include + +// SWELL / platform types (HWND, RECT, HMENU). On macOS/Linux SWELL is provided by the +// host (SWELL_PROVIDED_BY_APP); on Windows we use native Win32 (windows.h first, then +// swell.h no-ops on _WIN32). +#ifdef _WIN32 +#include +#else +#include +#endif +#include "wdltypes.h" +#include "swell/swell.h" + +// REAPER SDK types only (preview_register_t, MediaTrack, ReaProject). The API function +// POINTERS are declared per-TU (REAPERAPI_MINIMAL + per-TU WANT list) — main.cpp owns +// the definitions (CLAUDE.md §contract). +#include "reaper_plugin.h" + +#include "core/audio/peaks.h" // audio::Envelope — thumbnail cache payload +#include "core/capture/capture_paths.h" // capture::resolveBankFile / normalizeSlashes +#include "core/capture/render_settings.h" // capture::CaptureActionDef / captureActionTable +#include "core/capture/tail_control.h" // capture::TailSetting — the tail toggle state +#include "core/model/bank_book.h" // BankBook / Bank / SlotMap (flat reasampler until its split wave) +#include "core/model/bank_model.h" // model::BankModel / model::Sample +#include "core/ui/action_bar.h" // ui::ActionBarRect / slots / clusters +#include "core/ui/bank_grid.h" // ui::GridSpec / Selection / ThumbnailKey / CellRect +#include "core/ui/card_drag.h" // ui::CardGesture / SlotCellRect / gesture decisions +#include "core/ui/card_meta.h" // ui::MusicalLength / formatters +#include "core/ui/component_geometry.h" // ui::KitBox / KitButtonBox / waveformColumnCount +#include "core/ui/drag_out.h" // ui::DragState / PanelClientRect / decideGesture +#include "core/ui/footer_bar.h" // ui::FooterBarLayout / computeFooterBar +#include "core/ui/mode_enable.h" // ui::tagButtonEnabled / TagTarget +#include "core/ui/overflow_menu.h" // ui::MenuButtonSpec / computeMenuButton +#include "core/ui/prune_button.h" // ui::ButtonRect / computePruneButton +#include "core/ui/tab_strip.h" // ui::TabStripSpec / layout / hit-test +#include "core/ui/theme.h" // ui::Role / InteractionState / KitColor +#include "core/ui/tooltip.h" // ui::TooltipBox / computeTooltip / stripActionPrefix +#include "core/version/app_version.h" // version::channelCommandId / appVersion / dock identity +#include "core/view/guid_diff.h" // view::GuidBaseline — new-content detection +#include "core/view/lane_keys.h" // view::isOnManualLane +#include "core/view/mode_switch.h" // view::SegmentRect / computeSegmentRects +#include "core/wire/instrument_drop.h" // wire::buildInstrumentDropPreset (S17) + +#include "shell/panel/panel_layout.h" // BankPanelFullHeight — the split-state enum + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::panel { + +// --- Real-namespace-home using-declarations ----------------------------------- +// +// The panel's pre-split internals reference the pure modules' symbols unqualified; +// these explicit per-symbol usings keep those references valid while documenting +// each symbol's Q-W1 home. Flat-`reasampler` symbols (BankBook / ViewModeModel / +// the draw_kit shell / the shell/bank_ops verbs / ...) resolve via the enclosing +// namespace and need no using. + +// core/ui +using ui::ActionBarRect; +using ui::ActionBarSlot; +using ui::ActionBarSpec; +using ui::ActionCluster; +using ui::ButtonRect; +using ui::CardGesture; +using ui::CellRect; +using ui::ClusterSpec; +using ui::CursorCue; +using ui::DragGesture; +using ui::DragModifiers; +using ui::DragState; +using ui::DropRegion; +using ui::FooterBarLayout; +using ui::FooterBarSpec; +using ui::FooterHit; +using ui::FooterRect; +using ui::GridSpec; +using ui::InteractionState; +using ui::KitBox; +using ui::KitButtonBox; +using ui::KitColor; +using ui::MenuBarRect; +using ui::MenuButtonRect; +using ui::MenuButtonSpec; +using ui::MusicalLength; +using ui::NavKey; +using ui::PanelClientRect; +using ui::PruneButtonSpec; +using ui::ResolvedSample; +using ui::Role; +using ui::Selection; +using ui::SlotCellRect; +using ui::TabHit; +using ui::TabHitKind; +using ui::TabRect; +using ui::TabStripLayout; +using ui::TabStripRect; +using ui::TabStripSpec; +using ui::TagTarget; +using ui::ThumbnailKey; +using ui::TooltipBox; +using ui::TooltipSpec; +using ui::applyClick; +using ui::assemblePathList; +using ui::clampTabScroll; +using ui::columnsForWidth; +using ui::computeBarSlots; +using ui::computeFooterBar; +using ui::computeMenuButton; +using ui::computePruneButton; +using ui::computeSlotRects; +using ui::computeSlotRectsForDrop; +using ui::computeTabRects; +using ui::computeTabStripLayout; +using ui::computeTooltip; +using ui::cursorForGesture; +using ui::decideCardGesture; +using ui::decideGesture; +using ui::formatBarsBeats; +using ui::formatSecondsMs; +using ui::hitTestActionBar; +using ui::hitTestFooterBar; +using ui::hitTestMenuButton; +using ui::hitTestPruneButton; +using ui::hitTestSlot; +using ui::hitTestTabStrip; +using ui::menuButtonReserve; +using ui::navigate; +using ui::roleColor; +using ui::stripActionPrefix; +using ui::tagButtonEnabled; +using ui::thumbnailKeyString; +using ui::waveformColumnCount; + +// core/view +using view::GuidBaseline; +using view::HeaderRect; +using view::SegmentRect; +using view::computeSegmentRects; +using view::hitTestSegment; +using view::isOnManualLane; + +// core/capture +using capture::CaptureActionDef; +using capture::TailMode; +using capture::TailSetting; +using capture::adjustManualMs; +using capture::captureActionTable; +using capture::clampManualMs; +using capture::cycleTailMode; +using capture::kManualStepMs; +using capture::normalizeSlashes; +using capture::resolveBankFile; +using capture::tailToggleLabel; + +// core/audio +using audio::Envelope; +using audio::computeEnvelope; + +// core/model +using model::BankModel; +using model::Sample; + +// core/version +using version::actionDisplayPrefix; +using version::appVersion; +using version::channelCommandId; +using version::dockIdent; +using version::dockTitle; + +// core/wire +using wire::buildInstrumentDropPreset; + +// --- Layout constants --------------------------------------------------------- +// +// L2: every panel COLOR comes from the pure `theme` module by ROLE (drawn through the +// L1 kit — fillSurface / drawButton / kit text). Only the pixel LAYOUT metrics (band +// heights, grid/tab specs, insets) live here, shared by the layout/render/input/drag +// seams so draw and hit-test can never drift. + +inline const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; + +// --- Footer (Phase L, L4) ----------------------------------------------------- +// The footer carries a task-cluster of small persistent controls: the narrowed +// [Arrange|Design] mode toggle, a compact per-mode count, the Tail BUTTON (L4 §4 — +// a real kit button, no longer a click-zone), and the set-apart Prune button at the +// right. Taller than the L2 footer to host the toggle segments + button chrome cleanly. +// Layout is the pure footer_bar (left group) + prune_button (right); this is the band height. +inline constexpr int kFooterHeight = 30; + +// --- Toolbars (Phase L, L4) --------------------------------------------------- +// TWO task-grouped toolbars, both drawn through the pure action_bar module: +// * kTopToolbarHeight — the TOP toolbar (capture + placement clusters) at the very top of +// the client, where the eye lands (L4 §1). Replaces the L2 mode-switch header there. +// * kBottomToolbarHeight — the BOTTOM toolbar (Design-View tag/switch verbs) directly above +// the footer (L4 §2). This is the L2 action-bar band, repurposed. +inline constexpr int kTopToolbarHeight = 28; // single-row label face (L6: keybinding sub-row removed) +inline constexpr int kBottomToolbarHeight = 28; // same shape — both bars consistent + +// --- Tooltip (Phase L, L5) ---------------------------------------------------- +// The custom hover-delay tooltip's timing + approximate text metrics. The delay matches the +// platform convention (~0.5 s) so the tooltip is deliberate, not twitchy; it is driven off the +// OnTimer poll (bankPanelRefresh) + WM_MOUSEMOVE, so no dedicated timer is added. The kit font +// is AA and proportional, so the width is estimated from a per-char average (the tooltip box is +// generous — a slight over/under-estimate only pads the box, never clips the text). +inline constexpr unsigned int kTooltipDelayMs = 500; +inline constexpr int kTooltipCharPx = 7; // approx px per char at Font::Label (generous) +inline constexpr int kTooltipTextH = 14; // approx line height at Font::Label + +// --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) ------- +// +// The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) | +// split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer +// (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool +// region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER +// band: a title, the active-bank readout, and a full-height toggle button. The named-banks +// region's header ALSO hosts the LICE tab strip and a "+" create button. +inline constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band +inline constexpr int kTabStripHeight = 26; // the named-banks tab strip band +inline constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions +inline constexpr int kFullHtBtnWidth = 22; // the square full-height toggle button +inline constexpr int kCreateBtnWidth = 22; // the "+" create-bank button + +// Tab strip metrics (the pure tab_strip owns the math; these are its inputs). +inline const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20}; + +// The spec for the far-right More ("⋯") overflow-menu button. One source of truth for its +// geometry + the reserve the action_bar leaves for it (L5). +inline const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6, + /*verticalInset=*/3, /*minLeftInset=*/40}; + +// The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared +// by both toolbars' draw and hit-test (identical button shape top and bottom). L5 refinement 5: +// clusterGap widened 16 -> 24 (a 6:1 inter/intra ratio) so semantic groups read AS groups. L6: +// bindingHeight / minSplitHeight removed — buttons are single-row label-only faces now. +inline const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24, + /*sidePad=*/8, /*verticalInset=*/3}; + +// --- Panel state -------------------------------------------------------------- + +struct CachedThumbnail { + Envelope envelope; + int width = 0; +}; + +// Which of the two split regions currently owns the selection / receives keyboard +// input. The move/copy source is the focused region's displayed bank. +enum class Region { Pool, Banks }; + +// What a drag is dropping onto, resolved live under the pointer during a drag. +// BanksRegion fires when the pointer is anywhere in the named-banks grid that is NOT +// on a specific tab (tab takes precedence — more specific wins). The resolved bank is +// always shownBankId. +enum class DropKind { None, PoolRegion, Tab, BanksRegion }; + +// --- Hover model (Phase L, L2) ------------------------------------------------ +// +// The hovered interactive element, resolved live in WM_MOUSEMOVE so the kit draws its +// hover state on that element only (the "hover on every interactive element" + "sub-frame +// feedback = the perception of speed" L2 constraint). SWELL exposes no WM_MOUSELEAVE (grep +// of vendor/WDL/WDL/swell — none), so hover is cleared by a move that resolves to None +// rather than a leave message; the panel is Windows-only (D5) but this stays portable-safe. +// `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A. +enum class HoverKind { + None, + TopBarButton, // a button in the TOP toolbar (index = flat action index into topBarRows) + BottomBarButton, // a button in the BOTTOM toolbar (index = flat action index into bottomBarRows) + MoreButton, // the TOP toolbar's far-right "⋯" overflow-menu button (L5) + PruneButton, + FullHtPool, // pool region full-height toggle + FullHtBanks, // banks region full-height toggle + CreateBank, // the "+" create-bank button + Tab, // a named-bank tab (index = tab ordinal) + TailButton, // the footer Tail button (L4 §4 — a real button, was a click-zone) + ModeSegment, // a footer mode-toggle segment (index = segment ordinal) +}; + +struct Hover { + HoverKind kind = HoverKind::None; + int index = -1; + + bool operator==(const Hover& o) const { return kind == o.kind && index == o.index; } + bool operator!=(const Hover& o) const { return !(*this == o); } +}; + +// The kit interaction state for an interactive element: Hover when this (kind,index) is the +// live hovered element, else Rest. Active/Pressed are decided per-element by the caller (e.g. +// an active tab draws Active regardless of hover); this is the base rest/hover resolver. +inline InteractionState hoverState(const Hover& hovered, HoverKind kind, int index) { + return (hovered.kind == kind && hovered.index == index) ? InteractionState::Hover + : InteractionState::Rest; +} + +struct PanelState { + ReaSamplerSession* session = nullptr; + + HWND hwnd = nullptr; + bool open = false; + + std::string bankFingerprint; + std::uint64_t generation = 0; + + std::unordered_map cache; + + // --- Selection (per focused region) --------------------------------------- + // One live selection, scoped to `focusedRegion`. Switching regions moves the + // selection with the focus (a click in the other region reseeds it there). + Selection selection; + int selItemCount = 0; + Region focusedRegion = Region::Pool; + + // --- Hover (Phase L, L2) -------------------------------------------------- + // The live hovered interactive element (WM_MOUSEMOVE resolves it; the kit draws its + // hover state). Repaint fires only when this changes (sub-frame, no per-move jank). + Hover hovered; + + // --- Tooltip (Phase L, L5) ------------------------------------------------ + // A custom LICE-kit hover-delay tooltip (NOT the native Win32/SWELL tooltip control): when a + // TOOLTIP-capable element (a toolbar button) stays hovered past kTooltipDelayMs, the panel + // draws a small overlay carrying the full, prefix-stripped action name. hoverSinceTick is the + // GetTickCount() at which the CURRENT hovered element was first entered (reset on every hover + // change); tooltipShown latches once the delay elapses so the OnTimer poll repaints exactly + // once when the tooltip appears. The last-seen pointer pos anchors nothing (the anchor is the + // hovered button's rect), but is kept so the OnTimer path can re-resolve without a live event. + unsigned int hoverSinceTick = 0; + bool tooltipShown = false; + + // --- Vertical-split state ------------------------------------------------- + BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; + + // The named bank whose grid the banks region shows (the SHOWN tab) — DISTINCT + // from the active/capture-target bank (book().activeBankId()). Empty when there + // are no named banks. Reconciled each fingerprint pass so it always names a live + // named bank (or is empty). + std::string shownBankId; + + // Tab-strip horizontal scroll offset (px), clamped to the strip's max each frame. + int tabScroll = 0; + + // --- Drag (sample move between regions/onto a tab) ------------------------ + // A drag begins only after the pointer moves past a threshold from a press that + // landed on a SELECTED grid cell — this is how it is disambiguated from the M5 + // multi-select drag (which begins immediately on any grid press). See handleClick/ + // onMouseMove. dragging is true once the threshold is crossed. + bool dragArmed = false; // pressed on a selected cell; watching for threshold + bool dragging = false; // threshold crossed; a move-drag is in progress + int dragStartX = 0, dragStartY = 0; + Region dragSourceRegion = Region::Pool; + std::string dragSourceBankId; // the bank the dragged samples come from + std::vector dragSampleIds;// snapshot of the selection at drag start + std::string dragPrimaryId; // the single card grabbed (the focus) — the L7 + // reorder/replace subject (see onLBtnUp dispatch) + DropKind dropKind = DropKind::None; // live drop target under the pointer + std::string dropBankId; // destination bank id when dropKind==Tab + + // --- L7 in-grid reorder/replace drag -------------------------------------- + // The live card gesture resolved by the pure card_drag::decideCardGesture each mouse- + // move (drives the cursor cue AND the drop dispatch), plus the same-bank target slot the + // pointer sits over (>= 0 only for a Reorder/Replace over the source bank's own grid; -1 + // otherwise). A Reorder highlights dragTargetSlot's cell; Replace + a live cursor cue + // signal the Alt-over-occupied case. Reset with the rest of the drag state on drop/cancel. + CardGesture cardGesture = CardGesture::None; + int dragTargetSlot = -1; + + // --- S17 drop-and-load (InstrumentDrop) ----------------------------------- + // While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an + // InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX + // hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the + // dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last + // resolved FX-hotspot track (null when the pointer is not over an FX button) — read on + // release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched. + MediaTrack* instrumentDropTrack = nullptr; + + // --- Tail-mode toggle ----------------------------------------------------- + // The authoritative tail setting lives in ReaSamplerSession (session->tail()), + // NOT in panel state, so it travels inside the .rpp (persist serializes it on save, + // restores it on project load). The panel reads it for drawing and mutates it via + // the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the + // project dirty so the choice saves. bankPanelTailSetting is the read seam for the + // capture actions. Held here only through the session pointer above. + + // --- Audition preview ----------------------------------------------------- + preview_register_t preview{}; + PCM_source* previewSrc = nullptr; + bool previewActive = false; + bool previewInited = false; // guards double init / deinit + + // --- New-content detection (D2 Wave 2) ------------------------------------ + // + // Each timer tick diffs the live track+item GUID set against the previous tick to + // auto-tag content created SINCE the last tick into the then-active mode. The + // baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its + // first observe()) so pre-existing content is never mass-tagged (it stays Arrange). + // + // Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a + // pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact + // tick persist restores a project's membership + active mode (the same tick it + // reapplies the active mode); that sets reloadPending so the NEXT detect tick this + // same tick re-baselines against the fully-loaded set and reports nothing new. This + // replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than + // persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto + // a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then + // diffed against the previous project's stale baseline and were mass-tagged into the + // active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the + // two identity checks agree by construction. + // + // Lives for the extension's lifetime alongside the session, independent of panel + // open/close — detection must run whether or not the dock is visible (content is + // created in the arrange, not the panel). + GuidBaseline contentBaseline; + bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick +}; + +// The one shared panel state blob. Defined in panel_window.cpp (the lifecycle owner). +extern PanelState g_panel; + +// --- L7 slot-order display bridge --------------------------------------------- +// +// L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted +// SlotMap order (sparse, gap-preserving), NOT BankModel insertion order. regionDisplay +// (panel_layout.cpp) is the single place that resolves a region's display, composed +// purely from bank_book's slot order (orderedSampleIds) + card_drag's sparse slot rects +// (computeSlotRects) — the shell adds no layout math of its own. +// +// TWO INDEX SPACES the whole panel must keep straight: +// * SLOT — a display position 0..maxSlot; gaps are empty slots that draw as empty +// cells and are valid drop targets. This is what pixels/hit-tests speak. +// * SELECTION — the DENSE occupied-ordinal [0, occupied) space the pure Selection / +// applyClick / navigate reason in. Selection index i <-> orderedIds[i]. +// Keyboard navigation therefore traverses ONLY occupied cells and SKIPS +// gaps (spec: skip-vs-land-on-gap is unspecified -> skip, documented here). +// RegionDisplay carries both plus the translation between them, resolved FRESH each call +// (never cached across a mutation, per the reference-invalidation guardrail). +struct RegionDisplay { + std::vector orderedIds; // occupied ids in slot order (selection space) + std::vector slotRects; // one rect per slot 0..maxSlot, viewport coords + const Bank* bank = nullptr; + + // The id occupying `slot`, or "" for an empty slot / out of range. + std::string idAtSlot(int slot) const { + return bank ? bank->slots.idAt(slot) : std::string{}; + } + // The slot a selection ordinal `sel` maps to, or -1. orderedIds[sel] -> its slot. + int slotForSelection(int sel) const { + if (sel < 0 || sel >= static_cast(orderedIds.size()) || !bank) return -1; + return bank->slots.slotOf(orderedIds[static_cast(sel)]); + } + // The selection ordinal for `slot` (index of its occupant in orderedIds), or -1 when + // the slot is empty. Inverse of slotForSelection. + int selectionForSlot(int slot) const { + const std::string id = idAtSlot(slot); + if (id.empty()) return -1; + for (std::size_t i = 0; i < orderedIds.size(); ++i) + if (orderedIds[i] == id) return static_cast(i); + return -1; + } + int occupiedCount() const { return static_cast(orderedIds.size()); } +}; + +// --- Toolbar row vocabulary (Phase L, L4/L5/L6) -------------------------------- +// +// One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix +// at fire time — never a hardcoded numeric id), its terse on-button FACE label, its full action +// NAME for the hover tooltip (already prefix-stripped — the "ReaSampler:" display prefix is +// dropped at build), and the task cluster it belongs to. The order of a toolbar's row list IS +// the flat action index the pure action_bar slots carry, so each list is built cluster-by-cluster +// in its toolbar's cluster order. Built by panel_layout (topBarRows / bottomBarRows / +// overflowMenuRows); consumed by the render draw, the input click routing, and the drag hover. +struct ActionBarRow { + std::string suffix; + std::string shortLabel; + std::string fullName; + ActionCluster cluster = ActionCluster::Capture; + bool enabled = true; // L5: opposite-mode gate for the bottom-bar tag buttons; always true + // for the top bar (its actions are unconditional triggers). +}; + +// --- Shared one-liner helpers -------------------------------------------------- + +// Modifier state at event time. Alt = the L7 replace modifier. +inline bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } +inline bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } +inline bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; } + +inline void invalidatePanel() { + if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); +} + +// --- Cross-seam contract (grouped by OWNING TU; all plain free functions) ------ + +// panel_bank_ops.cpp — book/bank accessors + the bank-CRUD verbs + menus. +BankBook* book(); +const BankModel* indexForRegion(Region r); +std::string bankIdForRegion(Region r); +std::vector namedBanks(); +std::string currentProjectDir(); +void doCreateBank(); +void transferSamples(const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy); +void removeSamples(const std::vector& sampleIds, + const std::string& srcBankId); +std::vector focusedSelectionIds(); +std::vector resolveDragPathsForOs(); +void showTabMenu(int screenX, int screenY, const std::string& bankId); +void showSelectionMenu(int screenX, int screenY); +void showMoreMenu(); + +// panel_layout.cpp — toolbar/footer/menu rects, row/cluster builders, split geometry, +// region rects, the L7 display bridge. Draw and hit-test both call these so they never drift. +int modeCount(); +MenuButtonRect topMenuButtonRect(int w); +ActionBarRect topToolbarActionRect(int w); +RECT panelFooter(int w, int h); +FooterBarLayout footerBarLayoutFor(int w, int h); +ButtonRect pruneButtonRectFor(int w, int h); +bool pointInFooter(int x, int y); +ActionBarRect bottomToolbarRect(int w, int h); +RECT splitBody(int w, int h); +bool poolShown(); +bool banksShown(); +RECT poolRegionRect(int w, int h); +RECT banksRegionRect(int w, int h); +RECT regionHeaderRect(const RECT& region); +TabStripRect banksTabStripRect(const RECT& region); +RECT regionGridRect(const RECT& region, bool isBanks); +RECT fullHtBtnRect(const RECT& region); +RECT createBtnRect(const RECT& region); +RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg); +RegionDisplay focusedDisplay(); +bool regionAt(int x, int y, Region& out); +int footerToggleSegmentHit(int x, int y, int w, int h); +int columnsForRegion(Region reg); +std::vector topBarRows(); +std::vector bottomBarRows(); +std::vector overflowMenuRows(); +std::vector actionBarClusters(const std::vector& rows); +int resolveBarCommandId(const ActionBarRow& row); +int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows); +bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah); + +// panel_render.cpp — the full LICE paint (drawn last-to-front into the caller's +// double buffer; BitBlt'd once by paintPanel). +void paintPanel(HWND hwnd, HDC hdc); + +// panel_thumbnails.cpp — thumbnail compute + cache, and the bank-change fingerprint +// pass that owns the cache's generation key (bumps generation, clears the cache, and +// reconciles selection/shown-bank on any book mutation). +const Envelope& thumbnailFor(const Sample& sample, int width, const std::string& projectDir); +bool refreshFingerprint(); +void reconcileShownBank(); + +// panel_audition.cpp — the preview engine (HOT PATH: direct call-through, never +// virtual, no added header->TU indirection — T4-28 / Q-W2 guardrail). +void initPreview(); +void deinitPreview(); +void stopAudition(); +void startAudition(int idx); + +// panel_input.cpp — click/wheel/key routing, accelerator, new-content detection, +// and the session-tail read/mutate helpers. +TailSetting currentTail(); +void handleClick(int x, int y); +bool handleWheel(int x, int y, int delta); +void registerAccel(); +void unregisterAccel(); + +// panel_drag.cpp — the card-drag/hover state machine (pure mirror: core/ui/card_drag). +// Per-mouse-move work stays plain free-function calls (T4-28). +void onMouseMove(int x, int y); +void onLBtnUp(int x, int y); +void handleRightClick(int x, int y); +void resetDragState(); +void maybeShowTooltip(); + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_thumbnails.cpp b/src/shell/panel/panel_thumbnails.cpp new file mode 100644 index 0000000..65bc1f5 --- /dev/null +++ b/src/shell/panel/panel_thumbnails.cpp @@ -0,0 +1,163 @@ +// panel_thumbnails.cpp — thumbnail compute + cache seam of the docked bank panel +// (Q-W2 split of bank_panel.cpp; M5/FA3). Owns the per-sample PCM read via PCM_source +// fed to peaks::computeEnvelope (one bin per drawn pixel column) and the in-memory +// thumbnail cache keyed by (sample id, bin width, bank generation) — plus the +// bank-change fingerprint pass that OWNS that generation key: refreshFingerprint bumps +// the generation, clears the cache, resets selection/audition, and reconciles the +// shown bank on any book mutation. (The fingerprint pass lives here rather than in +// panel_input because the cache + generation it invalidates are this seam's state — +// a Q-W2 placement judgment; the T4-01 audit lumped it under the input seam's range.) +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include +#include +#include + +#include "shell/panel/panel_state.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_PCM_Source_CreateFromFile +#define REAPERAPI_WANT_PCM_Source_Destroy +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +namespace { + +constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) + +// --- Thumbnail computation (M5; `width` is a BIN count since FA3 oversampling) -- + +Envelope computeThumbnail(const std::string& absPath, int width) { + if (width <= 0 || absPath.empty()) return {}; + + PCM_source* src = PCM_Source_CreateFromFile(absPath.c_str()); + if (!src) return {}; + + const int nch = src->GetNumChannels(); + const double srate = src->GetSampleRate(); + const double lengthSec = src->GetLength(); + if (nch <= 0 || srate < 1.0 || lengthSec <= 0.0) { + PCM_Source_Destroy(src); + return {}; + } + + std::int64_t totalFrames = static_cast(lengthSec * srate); + if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; } + int frames = totalFrames > kMaxThumbnailFrames + ? kMaxThumbnailFrames + : static_cast(totalFrames); + + std::vector buf(static_cast(frames) * nch, 0.0); + PCM_source_transfer_t block{}; + block.time_s = 0.0; + block.samplerate = srate; + block.nch = nch; + block.length = frames; + block.samples = buf.data(); + block.samples_out = 0; + src->GetSamples(&block); + + PCM_Source_Destroy(src); + + const int got = block.samples_out; + if (got <= 0) return {}; + + const std::size_t sampleCount = static_cast(got) * nch; + std::vector pcm(sampleCount); + for (std::size_t i = 0; i < sampleCount; ++i) + pcm[i] = static_cast(buf[i]); + + // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with + // trailing empty {0,0} bins, which would render a very short sample as a comb of + // spikes over flat gaps. + const int binCount = width < got ? width : got; + return computeEnvelope(pcm, static_cast(nch), + static_cast(got), + static_cast(binCount)); +} + +} // namespace + +const Envelope& thumbnailFor(const Sample& sample, int width, + const std::string& projectDir) { + ThumbnailKey key{sample.id, width, g_panel.generation}; + const std::string ks = thumbnailKeyString(key); + + auto it = g_panel.cache.find(ks); + if (it != g_panel.cache.end()) return it->second.envelope; + + const std::string abs = resolveBankFile(projectDir, sample.relativePath); + CachedThumbnail thumb; + thumb.width = width; + thumb.envelope = computeThumbnail(abs, width); + auto ins = g_panel.cache.emplace(ks, std::move(thumb)); + return ins.first->second.envelope; +} + +// --- Bank-change detection ---------------------------------------------------- + +namespace { + +// A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag +// + per-sample id/path. Catches every mutation the panel must redraw for: capture, +// project load, and B4's own create/rename/delete/move/activate. +std::string bookFingerprint() { + BankBook* b = book(); + if (!b) return {}; + std::string fp = std::to_string(b->size()); + fp += '\x1e'; fp += b->activeBankId(); + for (const Bank& bk : b->banks()) { + fp += '\x1d'; + fp += bk.id; + fp += '\x1c'; + fp += bk.displayName; + for (const Sample& s : bk.index.all()) { + fp += '\x1f'; + fp += s.id; + fp += '\x1f'; + fp += s.relativePath; + } + } + return fp; +} + +} // namespace + +// Reconciles shownBankId against the live named banks: keep it if it still names a +// named bank; otherwise fall to the first named bank (or empty when none). Keeps the +// banks region always showing a valid tab. Never touches the ACTIVE bank. +void reconcileShownBank() { + BankBook* b = book(); + if (!b) { g_panel.shownBankId.clear(); return; } + if (!g_panel.shownBankId.empty()) { + const Bank* bk = b->bank(g_panel.shownBankId); + if (bk && !bk->isPool()) return; // still valid + } + const std::vector named = namedBanks(); + g_panel.shownBankId = named.empty() ? std::string() : named.front()->id; +} + +bool refreshFingerprint() { + if (!book()) return false; + std::string fp = bookFingerprint(); + if (fp == g_panel.bankFingerprint) return false; + g_panel.bankFingerprint = std::move(fp); + ++g_panel.generation; + g_panel.cache.clear(); + // The selection indexes into the OLD order; a change can invalidate those, so + // clear it and stop any audition. + if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { + g_panel.selection = Selection{}; + stopAudition(); + } + reconcileShownBank(); + const BankModel* idx = indexForRegion(g_panel.focusedRegion); + g_panel.selItemCount = idx ? static_cast(idx->size()) : 0; + return true; +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_window.cpp b/src/shell/panel/panel_window.cpp new file mode 100644 index 0000000..d64a9b4 --- /dev/null +++ b/src/shell/panel/panel_window.cpp @@ -0,0 +1,247 @@ +// panel_window.cpp — the window-lifecycle seam of the docked bank panel (Q-W2 split +// of bank_panel.cpp; M5 Wave A). Owns the SWELL dialog (IDD_BANK_PANEL) docked via +// DockWindowAddEx / undocked via DockWindowRemove, the dialog proc that routes +// messages to the input/drag/render/audition seams, the S8 OS drop-target opt-in +// (WM_DROPFILES -> ingest), and the shared PanelState blob's definition. +// +// READ-ONLY of the TIMELINE (load-bearing principle): the panel never inserts into +// the arrange. Channel-qualified dock identity (Phase V, V4): title + persisted- +// position identstr both come from app_version. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). DAW-verified, not unit tested. + +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_window.h" + +#include "shell/panel/draw_kit.h" // kitFontsInit/Shutdown — the kit's cached AA fonts (L1) +#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest + +#ifdef _WIN32 +#include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) +#include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest +#endif + +#include "resource.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_DockWindowAddEx +#define REAPERAPI_WANT_DockWindowActivate +#define REAPERAPI_WANT_DockWindowRemove +#define REAPERAPI_WANT_GetMainHwnd +#include "reaper_plugin_functions.h" + +// main.cpp owns the module instance handle. +extern REAPER_PLUGIN_HINSTANCE g_hInst; + +namespace reasampler::panel { + +// The one shared panel state blob (declared extern in panel_state.h). Defined here — +// the lifecycle seam owns the state's lifetime, mirroring the old single-TU global. +PanelState g_panel; + +// --- Dialog proc + docking ---------------------------------------------------- + +namespace { + +// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands +// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all into the active +// bank (bank-fill only — no assignment to any live instance). Always DragFinish's the HDROP +// (frees the shell-allocated drop buffer) on every path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) +// returns the file count; then each path is queried by index. Both Win32 and SWELL expose +// DragQueryFile/DragFinish with this contract. +void handleDropFiles(HDROP hDrop) { + std::vector paths; + const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0); + paths.reserve(count); + for (UINT i = 0; i < count; ++i) { + // Query the required length first (excludes the NUL), then read into a sized buffer. + const UINT len = DragQueryFile(hDrop, i, nullptr, 0); + if (len == 0) continue; + std::vector buf(static_cast(len) + 1, '\0'); + DragQueryFile(hDrop, i, buf.data(), static_cast(buf.size())); + std::string p(buf.data()); + if (!p.empty()) paths.push_back(std::move(p)); + } + DragFinish(hDrop); + if (!paths.empty()) ingestDroppedFiles(paths); +} + +WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_DROPFILES: + // S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import + // into the active bank (bank-fill only). wParam is the HDROP. + handleDropFiles(reinterpret_cast(wParam)); + return 0; + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hwnd, &ps); + paintPanel(hwnd, hdc); + EndPaint(hwnd, &ps); + return 0; + } + case WM_LBUTTONDOWN: { + SetFocus(hwnd); + handleClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + } + case WM_MOUSEMOVE: + onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_LBUTTONUP: + onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_RBUTTONDOWN: + SetFocus(hwnd); + handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_CAPTURECHANGED: + // Capture lost (pointer left window pre-threshold and released outside, or another + // window stole capture mid-drag) — cancel the whole drag as a NO-OP so no stale + // state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is + // mutated on a cancel; the cursor is restored to the arrow. + if (g_panel.dragArmed || g_panel.dragging) { + resetDragState(); + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + invalidatePanel(); + } + return 0; + case WM_MOUSEWHEEL: { + // Fine-adjust the Manual tail length when the wheel is over the footer. + // UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in + // lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so + // convert to client space before hit-testing the footer. The signed wheel + // delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120, + // matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer + // handler acts, so scrolling elsewhere in the dock still behaves normally. + POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; + ScreenToClient(hwnd, &pt); + const int delta = static_cast(HIWORD(wParam)); + return handleWheel(pt.x, pt.y, delta) ? 1 : 0; + } + case WM_DESTROY: + if (GetCapture() == hwnd) ReleaseCapture(); + stopAudition(); + g_panel.selection = Selection{}; + resetDragState(); + g_panel.hovered = Hover{}; + g_panel.tooltipShown = false; + g_panel.hwnd = nullptr; + g_panel.open = false; + return 0; + default: + break; + } + return 0; +} + +void openPanel() { + if (g_panel.open && g_panel.hwnd) { + DockWindowActivate(g_panel.hwnd); + return; + } + initPreview(); + + // Create the kit's cached AA fonts before the first paint (Phase L, L1). Idempotent, so + // a reopen after closePanel (which leaves the fonts alive) is a cheap no-op; the fonts + // are torn down once at bankPanelShutdown. All panel text draws through these. + kitFontsInit(); + + g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), + GetMainHwnd(), dlgProc, 0); + if (!g_panel.hwnd) return; + + // Channel-qualified dock identity (Phase V, V4). The title and the persisted-position + // identstr both come from app_version, so a beta panel is distinguishable ("ReaSampler + // Bank beta") and does not fight over stable's saved dock slot (the identstr is a + // REAPER-global collision surface — it keys the persisted dock position). + DockWindowAddEx(g_panel.hwnd, dockTitle().c_str(), dockIdent().c_str(), true); + DockWindowActivate(g_panel.hwnd); + g_panel.open = true; + + // S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles). + // DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it, + // so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3 + // instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration + // surface is out of scope for this dispatch. WM_DROPFILES handling itself uses + // DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means + // would still ingest — only the accept opt-in is gated. +#ifdef _WIN32 + DragAcceptFiles(g_panel.hwnd, TRUE); +#endif + + registerAccel(); + + reconcileShownBank(); + refreshFingerprint(); +} + +void closePanel() { + if (GetCapture() == g_panel.hwnd) ReleaseCapture(); + stopAudition(); + g_panel.selection = Selection{}; + resetDragState(); + unregisterAccel(); + if (g_panel.hwnd) { + DockWindowRemove(g_panel.hwnd); + DestroyWindow(g_panel.hwnd); + g_panel.hwnd = nullptr; + } + g_panel.open = false; +} + +} // namespace + +} // namespace reasampler::panel + +// --- Public API (the lifecycle seam — panel_window.h) -------------------------- + +namespace reasampler { + +void bankPanelInit(ReaSamplerSession* session) { + panel::g_panel.session = session; +} + +// Returns true only when the panel window is actually visible to the user right now. +// IsWindowVisible() returns false when the docker is hidden via Alt+D even though the +// HWND and g_panel.open are still live — the live query is the source of truth for +// toggle decisions and the Actions-list checkmark (OnToggleAction in main.cpp). +static bool panelEffectivelyVisible() { + return panel::g_panel.hwnd && IsWindowVisible(panel::g_panel.hwnd); +} + +void bankPanelToggle() { + // Decide from live visibility, not the cached g_panel.open flag. + // Alt+D hides the docker without destroying the window, leaving g_panel.open + // stale (true) while the panel is gone. Using IsWindowVisible avoids the + // double-fire needed to re-show the panel after a docker hide. + if (panelEffectivelyVisible()) + panel::closePanel(); + else + panel::openPanel(); +} + +bool bankPanelIsOpen() { + // Derive from live window state so the Actions-list checkmark stays honest + // even after Alt+D hides the docker without notifying the extension. + return panelEffectivelyVisible(); +} + +void bankPanelInvalidate() { + if (panel::g_panel.hwnd) InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); +} + +void bankPanelShutdown() { + panel::closePanel(); + panel::deinitPreview(); + kitFontsShutdown(); // free the kit's cached AA fonts + their owned HFONTs (L1) + panel::g_panel.cache.clear(); + panel::g_panel.session = nullptr; +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_window.h b/src/shell/panel/panel_window.h new file mode 100644 index 0000000..654727c --- /dev/null +++ b/src/shell/panel/panel_window.h @@ -0,0 +1,42 @@ +#pragma once +// panel_window — the window-lifecycle seam of the docked bank panel (Q-W2 split of +// bank_panel.h; M5, Wave A). REAPER-facing shell: the .cpp owns a SWELL dialog +// (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via DockWindowRemove, +// toggled open/closed, plus the OS drop-target opt-in (S8) and the dialog proc that +// routes messages to the input/drag/render seams. The panel itself NEVER inserts +// into the arrange or mutates the project (CONTEXT.md §load-bearing principle). +// +// The header is REAPER-free: main.cpp drives the panel through these free functions, +// passing the live session so the panel reads the current bank. All SWELL / LICE / +// PCM_source use is confined to the shell/panel/ .cpp seams. + +namespace reasampler { + +class ReaSamplerSession; + +// Wires the panel into main.cpp's lifecycle. Called once after the API pointers +// are loaded, BEFORE the toggle action is registered. `session` must outlive the +// panel (it is the extension-lifetime g_session). Stores the session pointer the +// panel reads on every repaint; does not create the window yet. +void bankPanelInit(ReaSamplerSession* session); + +// Toggles the docked window: creates+docks it if hidden, hides+undocks it if +// shown. Bound to the "toggle bank panel" action. Safe to call before the first +// timer tick. +void bankPanelToggle(); + +// Whether the panel window is currently open/visible. Feeds the action's +// checked-state (toggleaction) so REAPER shows a tick next to the menu entry. +bool bankPanelIsOpen(); + +// Requests an immediate repaint of the panel if it is open. A no-op when the panel +// is closed (safe to call unconditionally). Called by the actions layer after a +// mode change so the footer [Arrange|Design] toggle reflects the new mode without +// requiring a hide/reshow. +void bankPanelInvalidate(); + +// Tears the panel down on extension unload: destroys the window and releases any +// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. +void bankPanelShutdown(); + +} // namespace reasampler diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp new file mode 100644 index 0000000..3f7d92d --- /dev/null +++ b/src/shell/persist/ext_state_io.cpp @@ -0,0 +1,403 @@ +// ext_state_io.cpp — the ext-state ↔ JSON serialization half of the persist seam +// (Q-W5 split of the former persist.cpp; see session.h for the TU map and +// ext_state_io.h for the key contract): the session's save/load/assignment-request +// bridge, plus the shared persist_detail helpers (active-project read, growing +// ext-state read, GUID minting, bank-folder relocation) the sibling TUs call. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). +// +// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the +// whole BankBook (pool as bank-zero + named banks) is written under key "banks" +// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on +// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre- +// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so +// the banks travel with the project automatically (CONTEXT.md §Persistence & paths). +// The only thing that does NOT travel for free is the physical bank folder; on +// Save-As to a new directory we relocate it so the indices' relative paths still +// resolve (poll(), session.cpp, executes the relocation this TU implements). +// +// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY +// our own reasampler_bank/ folder. It never touches the user's media, items, or +// other ext-state namespaces. + +#include "shell/persist/ext_state_io.h" + +#include +#include +#include +#include +#include +#include + +#include "shell/persist/persist_internal.h" +#include "shell/persist/session.h" + +#include "core/capture/capture_paths.h" // projectDirOfRpp (pure path arithmetic) +#include "core/instrument/map/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader) +#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) +#include "core/version/app_version.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetProjExtState +#define REAPERAPI_WANT_MarkProjectDirty +#define REAPERAPI_WANT_SetProjExtState +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_genGuid +#define REAPERAPI_WANT_guidToString +#include "reaper_plugin_functions.h" + +namespace reasampler::persist_detail { + +namespace fs = std::filesystem; + +// Read the active project pointer and its .rpp path in one shot. idx=-1 is the +// current project tab (SDK header line ~1262). The out-buffer receives the full +// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel — +// same fact capture.cpp relies on). Returns nullptr proj only when there is no +// active project at all. +void* readActiveProject(std::string& rppPathOut) { + std::vector buf(4096, '\0'); + ReaProject* proj = EnumProjects(-1, buf.data(), static_cast(buf.size())); + rppPathOut.assign(buf.data()); + return proj; +} + +// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> +// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the +// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp +// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths +// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both +// artifacts share one implementation rather than duplicating the parent-of-.rpp step. +std::string projectDirOf(const std::string& rppPath) { + return capture::projectDirOfRpp(rppPath); +} + +// GetProjExtState needs a caller-supplied buffer; the index JSON can be large +// (many samples). The grow-until-strict-fit retry policy is the SHARED pure +// wire::readProjExtStateGrowing (T2-04 — the same policy the usage_scan and +// VST-bridge reads run); this wrapper binds the REAPER call and +// folds the terminal cases persist's callers expect: "" for an absent key (a valid +// empty bank, not an error) and a console warning + "" for a value exceeding the +// 16 MB ceiling, so an over-large value reads as "too large to load", not silent +// data loss (mirrors the malformed-JSON warning in loadFromProject). +std::string getProjExtStateString(void* proj, const char* ns, const char* key) { + using wire::GrowingExtStateRead; + const GrowingExtStateRead read = wire::readProjExtStateGrowing( + [&](char* buf, int cap) { + return GetProjExtState(static_cast(proj), ns, key, buf, cap); + }); + switch (read.status) { + case GrowingExtStateRead::Status::Complete: + return read.value; + case GrowingExtStateRead::Status::Absent: + return {}; // absent / empty -> empty bank + case GrowingExtStateRead::Status::Overflow: + break; + } + ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) + + "' exceeds the 16 MB read ceiling -- ignoring (bank not " + "loaded).\n").c_str()); + return {}; +} + +// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. +// guidToString wants a >=64-char destination (SDK header line ~3846). +std::string genProjectGuidString() { + GUID g{}; + genGuid(&g); + char buf[64] = {0}; + guidToString(&g, buf); + return std::string(buf); +} + +// Ensure a SAVED project carries a stored GUID, minting and writing one if it +// has none yet (a project saved before this feature shipped, or a brand-new +// first save). Returns the effective GUID: the existing one, the freshly minted +// one, or "" for an unsaved project (no .rpp to store ext state into — the same +// gate SetProjExtState/saveToActiveProject already respect on empty path). +// Called from BOTH prime and the Load branch so identity is established the same +// way on every entry to a project (peer-symmetry: no path skips the mint). +std::string ensureProjectGuid(void* proj, const std::string& rppPath, + const std::string& currentGuid) { + if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID + if (!currentGuid.empty()) return currentGuid; + const std::string minted = genProjectGuidString(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtGuidKey, minted.c_str()); + return minted; +} + +// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not +// move — see the handoff for the copy-vs-move rationale). Overwrites existing +// files at the destination so a re-save is idempotent. Best-effort: filesystem +// errors are swallowed and reported to the console rather than thrown across the +// REAPER boundary. Returns true if the copy ran (source existed). +bool relocateBankFolder(const std::string& oldBankDir, + const std::string& newBankDir) { + std::error_code ec; + if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) { + return false; // nothing at the old location to relocate + } + if (oldBankDir == newBankDir) return false; // defensive; plan guards this too + + fs::create_directories(newBankDir, ec); + fs::copy(oldBankDir, newBankDir, + fs::copy_options::recursive | fs::copy_options::overwrite_existing, + ec); + if (ec) { + ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir + + "' failed: " + ec.message() + "\n").c_str()); + return false; + } + return true; +} + +} // namespace reasampler::persist_detail + +namespace reasampler { + +using persist_detail::getProjExtStateString; +using persist_detail::readActiveProject; + +bool ReaSamplerSession::saveToActiveProject() { + std::string rppPath; + void* proj = readActiveProject(rppPath); + if (!proj) return false; // no active project — nothing to persist + if (rppPath.empty()) return false; // unsaved project — no .rpp to store into + + // Phase B: the whole book (pool as bank-zero + named banks) is authoritative and + // rides in the `banks` key. + const std::string banksJson = book_.serialize(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtBanksKey, banksJson.c_str()); + + // Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty + // value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This + // realizes retirement concretely — after any save, a formerly-legacy project + // carries `banks` and NO `bank_index`, and going forward the legacy key is never + // written. Cheap and idempotent when the key is already absent. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtIndexKey, ""); + + // Additive: the Design-View model rides alongside the banks in its own key. + // Independent write — does not disturb the `banks` blob above. + const std::string viewJson = view_.serialize(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtViewKey, viewJson.c_str()); + + // Additive: the docked panel's tail setting rides alongside in its own key, so the + // tail choice travels inside the .rpp. Independent write — does not disturb the + // bank_index or view_state above. + const std::string tailJson = capture::serializeTailSetting(tail_); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtTailKey, tailJson.c_str()); + + // Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own + // `owned_files` key. Independent write — does not disturb the blobs above. Written + // on EVERY save so a capture's manifest record survives Save / Save-As / reopen, + // and so the manifest and the bank stay in lockstep on disk (both persisted by the + // same saveToActiveProject the capture add-path calls). Uses the channel-derived + // namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too. + const std::string ownedJson = owned_.serialize(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtOwnedKey, ownedJson.c_str()); + + // Phase V (V1/V4): stamp the WRITING version — the build producing this save — under + // the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty + // stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is + // the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as + // Stamped on read-back and stays byte-identical to stable regardless of channel; the + // channel is already carried by the isolated namespace (projExtNamespace) this writes to. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtVersionKey, version::stampVersion().c_str()); + + // S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME + // seam so the counter and MarkProjectDirty stay paired. The value is whatever + // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so + // every content mutation's own save carries the fresh generation the instrument reads. The + // format is the SHARED pure encoder (instrument::map::formatBankGeneration) so writer and reader agree + // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtBankGenKey, + instrument::map::formatBankGeneration(bankGeneration_).c_str()); + + MarkProjectDirty(static_cast(proj)); + return true; +} + +bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { + std::string rppPath; + void* proj = readActiveProject(rppPath); + if (!proj) return false; // no active project — nothing to signal + if (rppPath.empty()) return false; // unsaved project — no .rpp to store into + + // One-shot write of the ingest assignment request under its own key (S8). Independent + // of the book/view/tail blobs — this is a transient signal to the instrument, not + // session state that must ride every save. Uses the channel-derived namespace + // (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta + // instrument reads only a beta extension's assignment requests. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtAssignKey, wire.c_str()); + MarkProjectDirty(static_cast(proj)); + return true; +} + +namespace { + +// Load the Design-View model from a project's view_state key, or return a fresh +// default. An absent/empty key (older project with no view state) yields a +// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful, +// never a crash. Malformed JSON is warned and also falls back to default, mirroring +// the bank's malformed-index handling. The whole model round-trips: modes, +// membership, show-both, snapshots, and active mode all ride inside the one blob. +ViewModeModel loadViewModel(ReaProject* proj) { + if (!proj) return ViewModeModel{}; + const std::string viewJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtViewKey); + if (viewJson.empty()) return ViewModeModel{}; // no stored view state -> default + std::optional loaded = ViewModeModel::deserialize(viewJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored view state is malformed -- ignoring.\n"); + return ViewModeModel{}; + } + return std::move(*loaded); +} + +// Load the tail setting from a project's tail_setting key, or return the default. An +// absent/empty key (older / never-adjusted project) yields the default setting (None / +// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back +// to default, mirroring the bank's and view's malformed handling. +capture::TailSetting loadTailSetting(ReaProject* proj) { + if (!proj) return capture::TailSetting{}; + const std::string tailJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey); + if (tailJson.empty()) return capture::TailSetting{}; // no stored setting -> default + std::optional loaded = + capture::deserializeTailSetting(tailJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n"); + return capture::TailSetting{}; + } + return *loaded; +} + +// Load the owned-file manifest from a project's owned_files key, or return an empty +// manifest. An absent/empty key (older / never-captured project) yields an empty +// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to +// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then +// sees an empty ownership record and (safely) attributes nothing until the next capture +// rebuilds it — losing the record degrades safety, never correctness. +model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) { + if (!proj) return model::OwnedFileManifest{}; + const std::string ownedJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey); + if (ownedJson.empty()) return model::OwnedFileManifest{}; // no stored manifest -> empty + std::optional loaded = + model::OwnedFileManifest::deserialize(ownedJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n"); + return model::OwnedFileManifest{}; + } + return std::move(*loaded); +} + +} // namespace + +void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { + // Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the + // single choke point for every load path (prime, project switch/open, forked- + // sibling load), so setting it here — and NOT on the Save-As branch, which keeps + // the in-memory model as-is — makes the signal fire exactly when a fresh view + // model has been installed and its active mode's visibility needs reapplying. + // main.cpp drains it via consumeLoadSignal() on the same tick. + loadPending_ = true; + + // The view model is restored on EVERY load path (peer-symmetry with the bank + // reset below): switching to a project with no view state must clear stale + // in-memory state, not inherit the previous project's. D3 restores MODEL STATE + // only — no visibility/processing is applied here (that is D4). + view_ = loadViewModel(static_cast(proj)); + + // The tail setting is restored on EVERY load path too (peer-symmetry): switching + // to a project with no stored setting must fall back to the default, not inherit + // the previous project's choice (this REPLACES the old session-carry behavior). + tail_ = loadTailSetting(static_cast(proj)); + + // The owned-file manifest is restored on EVERY load path too (peer-symmetry with the + // bank/view/tail resets): switching to a project with no stored manifest must reset + // to empty, not inherit the previous project's ownership record; an undo/redo reload + // (R-B) must re-read the restored manifest so it matches the rolled-back bank state. + owned_ = loadOwnedManifest(static_cast(proj)); + + // Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry + // with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed + // one as Unknown — both silent, no console warning (a pre-versioning project is not + // an error). getProjExtStateString returns "" for an absent key, which is exactly the + // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default. + writingVersion_ = version::classifyWritingVersion( + proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtVersionKey) + : std::string{}); + + // S9: recover the bank-generation counter on EVERY load path (peer-symmetry with + // writingVersion_/tail_/view_ above), so it continues monotonic from the stored value + // rather than resetting to 0 on reopen — a next bump then reads > the stored value. A + // project switch reads THAT project's counter, not the previous one's; an absent/malformed + // stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0. + bankGeneration_ = instrument::map::parseBankGeneration( + proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtBankGenKey) + : std::string{}); + + if (!proj) { + book_ = BankBook{}; + return; + } + + // Read both possible sources: the authoritative `banks` blob and the retired-but- + // possibly-still-present legacy `bank_index`. The precedence + migration decision + // (`banks` wins; else the legacy index migrates into the pool; else an empty book) + // is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only + // so a malformed `banks` blob can be warned on the console (single parse) — a corrupt + // blob must read as "ignored", not silent loss, mirroring the prior malformed-index + // warning. A malformed `banks` degrades to an empty book and does NOT fall back to + // the stale legacy key (which would resurrect superseded single-bank state). + const std::string banksJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtBanksKey); + if (!banksJson.empty()) { + std::optional loaded = BankBook::deserialize(banksJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored banks are malformed -- ignoring.\n"); + book_ = BankBook{}; + } else { + book_ = std::move(*loaded); + } + } else { + // No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool + // by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or- + // empty tail; passing "" for banksJson takes exactly that branch. + const std::string legacyJson = + getProjExtStateString(proj, projExtNamespace(), kProjExtIndexKey); + book_ = BankBook::loadFromPersisted(std::string{}, legacyJson); + } + + // L7 slot migration: seed every bank's display-position SlotMap from its index + // insertion order when the loaded blob carried none (a pre-L7 project -> dense, + // gap-free, visually identical on first post-L7 load), and reconcile a partial map + // (drop stale markers, append unmapped samples) for a blob written by an earlier L7 + // build. One-way: once the book is re-saved the reconciled slot data is authoritative. + // Idempotent, so a fresh empty book is a cheap no-op. + book_.reconcileSlots(); + + // Project-relative resolution is a READ-time concern: every BankModel in the book + // stores only relative paths (invariant, enforced per-bank at add()), and consumers + // (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via + // resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to + // absolute here — that would break the relative-only invariant and travel-with-.rpp. + // projectDir is threaded through for those consumers; nothing to do at load time + // beyond replacing the in-memory book. + (void)projectDir; +} + +} // namespace reasampler diff --git a/src/shell/persist/ext_state_io.h b/src/shell/persist/ext_state_io.h new file mode 100644 index 0000000..ab5a389 --- /dev/null +++ b/src/shell/persist/ext_state_io.h @@ -0,0 +1,59 @@ +#pragma once +// ext_state_io — the ext-state ↔ JSON serialization half of the persist seam +// (Q-W5 split of the former persist god-TU; session.h holds the ReaSamplerSession +// lifecycle, prune_fs.cpp the prune scan + the single file-deletion authority). +// This header owns the persist-side key spellings and the channel-derived +// namespace accessor; the TU (ext_state_io.cpp) implements the session's +// save/load/assignment-request bridge plus the GUID minting and bank-folder +// relocation helpers the poll executes. +// +// The ext-state namespace + the WIRE-SHARED key names are the contract between this +// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h +// (pure, REAPER-free) and are included here — not duplicated. The namespace is +// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace() +// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte- +// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both +// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace +// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project +// saved by stable shows empty/default state in beta and vice versa; that isolation is the +// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug. +// The per-key semantics persist relies on (spellings owned by ext_keys.h): +// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks). +// AUTHORITATIVE going forward; the VST reads this key to see the live bank. +// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared +// on save); READ once on load to migrate a legacy project into the pool. +// * kProjExtViewKey : the Design-View ViewModeModel JSON. +// * kProjExtTailKey : the docked panel's TailSetting JSON. +// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll() +// tells a Save-As from a recycled-pointer project switch by it). +// All are FOREVER-STABLE once shipped: changing any strands every already-saved +// project's stored state under that key. + +#include "core/version/app_version.h" +#include "ext_keys.h" + +namespace reasampler { + +// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this +// is the const char* the SetProjExtState/GetProjExtState calls pass. Kept as an +// accessor (not a literal) because the string is channel-derived at build time. +inline const char* projExtNamespace() { return version::extStateNamespace().c_str(); } + +// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument +// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h: +// +// owned_files — the owned-file manifest JSON (project-relative files the capture path +// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's +// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT +// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it +// strands every saved project's ownership record (prune falls back to an empty manifest — +// graceful, but the attribution safety net is lost until the next capture rebuilds it). +inline constexpr const char* kProjExtOwnedKey = "owned_files"; + +// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on +// every save, so every saved .rpp records which build produced its state — the seam a +// future within-channel forward migration keys off. An absent key is the explicit +// pre-versioning case, read silently, never an error. FOREVER-STABLE key string. +inline constexpr const char* kProjExtVersionKey = "version"; + +} // namespace reasampler diff --git a/src/shell/persist/persist_internal.h b/src/shell/persist/persist_internal.h new file mode 100644 index 0000000..f574387 --- /dev/null +++ b/src/shell/persist/persist_internal.h @@ -0,0 +1,51 @@ +// persist_internal.h — INTERNAL shared helpers for the persist TU family (Q-W5: +// session / ext_state_io / prune_fs, split out of the former persist.cpp god-TU). +// Included ONLY by those three TUs — never a public seam (mirror of the panel's +// panel_state.h / the editor's editor_internal.h internal-seam precedent). Holds the +// former anonymous-namespace helpers that more than one split TU needs; every +// definition lives in ext_state_io.cpp (they are all ext-state / GUID / path / folder +// machinery). Behavior-identical to the pre-split definitions. +// +// REAPER-FREE HEADER: the project handle crosses this seam as the same opaque void* +// the public session header already uses, so no SDK type leaks; the .cpps cast at +// the API boundary. + +#pragma once + +#include + +namespace reasampler::persist_detail { + +// Read the active project pointer and its .rpp path in one shot (EnumProjects(-1)). +// The out-string receives the full .rpp path, EMPTY for a never-saved project (the +// reliable unsaved sentinel). Returns nullptr only when there is no active project. +void* readActiveProject(std::string& rppPathOut); + +// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> +// empty out. Delegates to the pure capture::projectDirOfRpp — the SAME convention +// the VST3 instrument resolves audio paths by. +std::string projectDirOf(const std::string& rppPath); + +// Growing GetProjExtState read for `key` in namespace `ns` against `proj`. Returns +// "" when the key is absent (a valid empty bank, not an error) and warns on the +// console for a value exceeding the 16 MB read ceiling (unreadable whole, ignored). +// The retry policy itself is the shared pure wire::readProjExtStateGrowing +// (T2-04; rehomed to core/wire in Q-W6); this wrapper binds the REAPER call + persist's fold. +std::string getProjExtStateString(void* proj, const char* ns, const char* key); + +// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. +std::string genProjectGuidString(); + +// Ensure a SAVED project carries a stored GUID, minting and writing one if it has +// none yet. Returns the effective GUID, or "" for an unsaved project. Called from +// BOTH prime and the Load branch (peer-symmetry: no path skips the mint). +std::string ensureProjectGuid(void* proj, const std::string& rppPath, + const std::string& currentGuid); + +// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not +// move). Best-effort: filesystem errors are swallowed and reported to the console. +// Returns true if the copy ran (source existed). +bool relocateBankFolder(const std::string& oldBankDir, + const std::string& newBankDir); + +} // namespace reasampler::persist_detail diff --git a/src/shell/persist/prune_fs.cpp b/src/shell/persist/prune_fs.cpp new file mode 100644 index 0000000..f5c7f27 --- /dev/null +++ b/src/shell/persist/prune_fs.cpp @@ -0,0 +1,304 @@ +// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler +// (Q-W5 split of the former persist.cpp; see session.h for the TU map). +// +// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove on +// SWELL platforms) is the ONLY code in the system that deletes USER files — the sole +// deletion authority over the bank folder's bytes (the R3 prune; shells removing a +// transient scratch file they themselves just created, e.g. the drop path's temp +// .vstpreset, are self-cleanup, not authority over user data). It is deliberately +// file-local (anonymous namespace): nothing outside this TU can reach it. The Q-W5 +// split CONCENTRATES the deletion authority here — it must never +// spread (CONTEXT.md §Phase Q deletion-authority isolation; +// docs/product/code-organization.md §7). The safety-critical "which files are +// orphans" decision stays in the pure core (prune_reconcile); this TU only +// enumerates, resolves, stats, and — after the R3 confirm — executes. +// +// Compiled into the reaper_reasampler MODULE. REAPER-facing only through the +// persist_detail helpers (active-project read) and usage_scan (the pS-usage +// instance-hold reads); this TU itself calls no REAPER API directly. + +#include +#include +#include +#include +#include +#include +#include + +// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is +// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK +// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, +// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL +// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the +// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing. +#ifdef _WIN32 +#include +#include +#endif + +#include "shell/persist/persist_internal.h" +#include "shell/persist/session.h" +#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`) + +#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder +#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies + +namespace reasampler { + +namespace { + +namespace fs = std::filesystem; + +using persist_detail::projectDirOf; +using persist_detail::readActiveProject; + +// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always +// exact (tallied over the full orphan set), but the enumerated file list handed to the +// console is clipped to this many entries so a project with thousands of orphans does +// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can +// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling. +constexpr std::size_t kPruneListDisplayCap = 64; + +// A fresh enumerate + pure-core prune compute for the active project. Shared by the +// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion +// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path +// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the +// active project, enumerates the folder) but writes nothing. +// +// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty +// when there is no active/saved project, no project dir, or no folder on +// disk yet -> the caller treats an empty dir as "nothing to reclaim". +// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration +// order, untruncated. The pure core decides; this only supplies inputs. +// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd). +// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could +// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY — +// the prune must halt rather than proceed with degraded protection. +// An empty orphan set is itself the delete-side guarantee (every +// consumer of this scan deletes at most `orphans ∩ ...`), the flag is +// what lets the action TELL the user instead of claiming "no orphans". +struct PruneScan { + std::string bankDirAbs; + std::vector orphans; + std::unordered_map sizeByRel; + bool abortedUnreadableUsage = false; + std::vector offendingUsageKeys; // non-empty iff abortedUnreadableUsage +}; + +// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem +// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI. +PruneScan scanPruneOrphans(const BankBook& book, + const model::OwnedFileManifest& owned) { + PruneScan scan; + + std::string rppPath; + void* proj = readActiveProject(rppPath); + if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan + + // Resolve the CURRENT bank folder the same way the index does (M4): project dir of + // the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a + // Save-As relocation is followed automatically. resolveBankFile is the shared M4 + // arithmetic; feeding it the bank subfolder as the "relative path" yields the folder. + const std::string projectDir = projectDirOf(rppPath); + const std::string bankDir = + capture::resolveBankFile(projectDir, capture::kBankSubfolder); + if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan + + std::error_code ec; + if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) { + return scan; // no bank folder captured yet -> nothing to reclaim + } + + // Enumerate the folder into project-relative index-spelled paths, spelled the SAME + // way the capture path spelled them (bankRelativeForName == deriveBankPaths's + // convention) so the pure core's exact-string match lines up with referencedPaths() + // and the manifest. Non-recursive: the bank folder is flat (capture writes files + // directly here); skip any subdirectory. Size is stat'd here and cached by relative + // path so the report's byte tally reuses the same on-disk read. + // Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration + // failure (file removed, permission flip) breaks out with a best-effort partial list + // rather than propagating std::filesystem_error across REAPER's C ABI. + std::vector present; + fs::directory_iterator it(bankDir, ec); + for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) { + const auto& entry = *it; + std::error_code reg_ec; + if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials + const std::string name = entry.path().filename().string(); + const std::string rel = capture::bankRelativeForName(name); + if (rel.empty()) continue; + present.push_back(rel); + std::error_code sz_ec; + const std::uintmax_t sz = entry.file_size(sz_ec); + scan.sizeByRel[rel] = sz_ec ? 0 : static_cast(sz); + } + + // The decision lives in the pure core — read-only inputs from the book and manifest. + // referencedPaths() unions across the whole book (pool included); owned().paths() is + // the manifest set. pS-usage: the referenced set additionally unions every LIVE + // ReaSampler 9000 instance's held captures (usage_scan reads the per-instance + // rsusage_* records + the live FX enumeration; sample_usage decides liveness, + // including the protect-all net when zero instances were identified) — a capture + // any live instance holds can NEVER be an orphan, even when its bank entry was + // deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY, + // preserving this scan's no-write contract. This shell only enumerates, resolves, + // and stats. + scan.bankDirAbs = bankDir; + const UsageScanResult usage = liveInstanceHeldPaths(proj); + if (usage.abortPrune) { + // FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the + // protected set is unknowable. Compute NO orphans — every downstream consumer + // (dry-run report, confirm set, fresh-recompute delete plan) then deletes + // nothing. The flag + key names surface the reason so the action can name each + // offending key for operator recovery. + scan.abortedUnreadableUsage = true; + scan.offendingUsageKeys = usage.offendingKeys; + return scan; + } + scan.orphans = reclaim::pruneOrphans( + present, + reclaim::mergeReferenced(book.referencedPaths(), usage.heldPaths), + owned.paths()); + return scan; +} + +// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the +// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases: +// * `outAlreadyAbsent` set true — the file was already gone before we touched it; +// the caller folds this into the stale/staleness tally, NOT reclaimedCount. +// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error); +// the caller folds this into skippedCount. +// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception +// may cross the C ABI. +// +// Per-platform routing: +// * Windows — SHFileOperationW(FO_DELETE, pFrom=, FOF_ALLOWUNDO | +// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the +// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our +// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true. +// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this +// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3 +// confirm guardrail. `outUsedTrash` left as-is (false). +bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, + bool& outAlreadyAbsent) { +#ifdef _WIN32 + // Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string. + // SHFileOperation's pFrom is a list; a single path still needs the extra terminating + // NUL. Backslashes are required (shell APIs reject forward slashes in some cases). + std::string win = absPath; + for (char& c : win) if (c == '/') c = '\\'; + + const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0); + if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false) + std::vector wbuf(static_cast(wlen) + 1, L'\0'); // +1 for list NUL + MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen); + // wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen] + // makes it the double-NUL-terminated single-element list SHFileOperation wants. + + SHFILEOPSTRUCTW op{}; + op.hwnd = nullptr; + op.wFunc = FO_DELETE; + op.pFrom = wbuf.data(); + op.pTo = nullptr; + op.fFlags = static_cast(FOF_ALLOWUNDO | FOF_NOCONFIRMATION | + FOF_SILENT | FOF_NOERRORUI); + const int rv = SHFileOperationW(&op); + if (rv == 0 && !op.fAnyOperationsAborted) { + outUsedTrash = true; + return true; // deleted this call -> reclaimed + } + // SHFileOperation failed (e.g. file already gone yields a nonzero code on some + // versions, or a lock). Distinguish "already absent" from a real failure so the + // caller can tally them separately (absent -> staleness skip; failure -> locked skip). + std::error_code ec; + if (!fs::exists(absPath, ec)) { + outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim + } + return false; +#else + // No portable trash surface on SWELL platforms -> hard unlink behind the confirm. + std::error_code ec; + const bool removed = fs::remove(absPath, ec); + if (removed) return true; // deleted this call -> reclaimed + if (ec) return false; // a real failure (locked / permission) -> skip + // remove returned false with no error == the file did not exist -> already gone. + outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim + return false; +#endif +} + +} // namespace + +reclaim::PruneReport ReaSamplerSession::pruneDryRun() const { + const PruneScan scan = scanPruneOrphans(book_, owned_); + // buildPruneReport tallies count / byte-sum / display-truncation — no report logic + // re-implemented here. An empty scan (no project / no folder) yields a zero report. + reclaim::PruneReport report = + reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap); + // pS-usage fail-safe: surface the unreadable-record abort so the action halts with + // an explicit message instead of reporting "no orphaned files" (the count IS zero — + // the scan computed nothing — but the user must know the prune refused to run). + // The offending key names propagate so the action can name each one for recovery. + report.abortedUnreadableUsage = scan.abortedUnreadableUsage; + report.offendingUsageKeys = scan.offendingUsageKeys; + return report; +} + +std::vector ReaSamplerSession::pruneOrphanSet() const { + return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated +} + +reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( + const std::vector& confirmed) const { + reclaim::PruneDeletionResult result; + + // Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets + // exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became + // referenced between confirm and delete drops out of freshOrphans and is skipped; a + // newly-appeared orphan not in `confirmed` is never swept without its own confirm. + // Because freshOrphans is itself a pure-core output, the plan can contain NO referenced + // and NO hand-dropped file — the R-C/R-D safety survives the recompute. + // pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an + // EMPTY orphan set, so the plan below intersects to empty and nothing is deleted — + // the fail-safe holds even in the confirm→delete window, with no extra branch here. + const PruneScan scan = scanPruneOrphans(book_, owned_); + if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing + + const std::vector plan = + reclaim::pruneDeletePlan(confirmed, scan.orphans); + + // Staleness skip count: entries the user confirmed that are no longer fresh orphans + // (vanished or became referenced between confirm and delete). pruneDeletePlan already + // de-dups confirmed internally, so compute the unique-confirmed size to avoid counting + // de-duplicated entries as stale — that would be dishonest. + const std::size_t uniqueConfirmedCount = + std::unordered_set(confirmed.begin(), confirmed.end()).size(); + result.skippedCount += uniqueConfirmedCount - plan.size(); + + for (const std::string& rel : plan) { + // Reconstruct the absolute path from the resolved bank dir + the entry's file name. + // rel is index-spelled "/"; the name is the tail after '/'. + const std::string::size_type slash = rel.find_last_of('/'); + const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1); + if (name.empty()) { ++result.skippedCount; continue; } + const std::string absPath = scan.bankDirAbs + "/" + name; + + const auto szIt = scan.sizeByRel.find(rel); + const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0; + + bool alreadyAbsent = false; + if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) { + ++result.reclaimedCount; + result.reclaimedBytes += bytes; + } else if (alreadyAbsent) { + // File vanished between plan and delete — treat as staleness, same as the + // confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it). + ++result.skippedCount; + } else { + ++result.skippedCount; // locked / conversion failure -> recorded, not thrown + } + } + return result; +} + +} // namespace reasampler diff --git a/src/shell/persist/session.cpp b/src/shell/persist/session.cpp new file mode 100644 index 0000000..f84b664 --- /dev/null +++ b/src/shell/persist/session.cpp @@ -0,0 +1,222 @@ +// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (Q-W5 +// split of the former persist.cpp; see session.h for the TU map): the poll-driven +// identity-transition detection and the deferred undo/redo reload drain. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers; here they are extern (CLAUDE.md §contract). +// +// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism): +// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active +// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext +// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the +// secondary disambiguator (classifyProjectTransition owns the exact order): +// * different stored GUID -> a different project of record -> LOAD its index; +// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's +// address, so a reopened/new project can present the previous pointer with a +// different GUID), new/unsaved<->saved, and switching between distinct saved +// projects. +// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via +// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings +// diverge going forward. +// * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new +// location -> relocate the bank folder from the old dir to the new one, then +// re-GUID. +// Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only) +// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a +// fork and its parent share a GUID on disk; switching between them read as a +// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer +// RECYCLING — a reopened/new project reusing the previous project's address read +// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first +// catches recycling; the pointer then separates a fork (same GUID, different +// object -> Load) from a Save-As (same GUID, same object, new path -> relocate). +// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` +// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free +// and testable; poll() executes the verdict. +// +// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no +// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not +// a cross-open identity), so we MINT one with genGuid/guidToString and store it +// under kProjExtGuidKey (ext_state_io.cpp owns the minting helpers). On Save-As +// REAPER copies the whole .rpp incl. our ext state, so the new project initially +// shares the old GUID; poll() re-GUIDs it (after relocating, or on the forked- +// sibling Load branch) so identities diverge. +// +// Rationale for the timer: the brief mandates ext-state storage (rules out the +// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with +// ext-state while covering identity-transition load + Save-As detection in one +// place. +// +// DIVISION OF LABOUR (R-B undo): +// * Identity-transition poll (this file, classifyProjectTransition) owns +// open / tab-switch / new / forked-sibling / Save-As-relocation — every case +// where the project OF RECORD changes. +// * The `projectconfig` hook (main.cpp registers project_config_extension_t; +// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project +// identity is unchanged but its ext state rolled back/forward on disk. The +// identity poll sees NoOp there and would never re-read ext state, so the hook +// requests a reload (requestReload) that poll() drains on the next tick, once +// REAPER has restored the block. See requestReload / the poll drain. +// The hook fires on undo AND redo (isUndo true for both), and on normal open +// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open +// flows solely through the identity-transition Load path and never double-loads. + +#include "shell/persist/session.h" + +#include + +#include "shell/persist/ext_state_io.h" +#include "shell/persist/persist_internal.h" + +#include "core/capture/capture_paths.h" // classifyProjectTransition / deriveRelocationPlan (pure) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_MarkProjectDirty +#define REAPERAPI_WANT_SetProjExtState +#include "reaper_plugin_functions.h" + +namespace reasampler { + +using persist_detail::ensureProjectGuid; +using persist_detail::genProjectGuidString; +using persist_detail::getProjExtStateString; +using persist_detail::projectDirOf; +using persist_detail::readActiveProject; +using persist_detail::relocateBankFolder; + +bool ReaSamplerSession::consumeLoadSignal() { + const bool pending = loadPending_; + loadPending_ = false; + return pending; +} + +void ReaSamplerSession::requestReload() { + // Set-only; poll() drains it on the next tick (see the poll() drain block for why + // the read is deferred past the projectconfig callback). Cheap and idempotent — + // multiple undo/redo callbacks before the next tick collapse to one reload. + reloadRequested_ = true; +} + +void ReaSamplerSession::poll() { + std::string rppPath; + void* proj = readActiveProject(rppPath); + const std::string currentGuid = + proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtGuidKey) + : std::string{}; + + if (!primed_) { + // First observation: adopt current identity and load its index, without + // treating it as a "change" (avoids a spurious relocation on startup). + primed_ = true; + loadFromProject(proj, projectDirOf(rppPath)); + lastProject_ = proj; + lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); + lastRppPath_ = rppPath; + reloadRequested_ = false; // priming already loaded — a co-tick request is moot + return; + } + + // Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier + // below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID, + // and .rpp path — so classifyProjectTransition would return NoOp and never re-read + // ext state, leaving book_/view_ stale after the on-disk ext state rolled back. + // The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_ + // one or more ticks ago; by NOW REAPER has finished restoring the project's + // block, so GetProjExtState returns the POST-undo value. Reload from the + // current active project and identity-adopt it (no relocation — the path is + // unchanged), then return. loadFromProject raises loadPending_, so the existing + // consumeLoadSignal() glue re-baselines the panel detector and reapplies the active + // mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is + // the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect + // an undo (Daniel's directive: the hook drives it, not a poll heuristic). + if (reloadRequested_) { + reloadRequested_ = false; + loadFromProject(proj, projectDirOf(rppPath)); + lastProject_ = proj; + lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); + lastRppPath_ = rppPath; + return; + } + + // Pointer identity is the primary signal: a genuine Save-As keeps the SAME + // ReaProject* (one object saved elsewhere); a tab-switch/open is a different + // object. Passing the bool (not the pointer) keeps the classifier pure. + const bool sameProjectObject = (proj == lastProject_); + const capture::ProjectTransition transition = capture::classifyProjectTransition( + sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath); + + switch (transition) { + case capture::ProjectTransition::NoOp: + return; + + case capture::ProjectTransition::Load: { + // A different project of record is active (open / tab switch / new / + // reopened / recycled pointer / forked sibling). Load ITS index; never + // relocate. + // + // Forked-sibling divergence: gate on `!sameProjectObject` so this fires + // ONLY for a step-2 Load (same GUID, different object) — a Save-As fork + // that copied our GUID and never re-saved (its fresh GUID was runtime- + // only on the sibling we came from). A recycled-pointer Load (step 1: + // currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct + // identity. currentGuid == lastGuid_ can only hold here when step 1 did + // NOT fire, i.e. this is the fork case; the explicit !sameProjectObject + // makes that intent load-bearing rather than incidental. Do this BEFORE + // loadFromProject reads the index (order is irrelevant — GUID and + // bank_index are distinct keys — but self-contained is clearest). + if (proj && !sameProjectObject && !currentGuid.empty() && + currentGuid == lastGuid_ && !rppPath.empty()) { + const std::string fresh = genProjectGuidString(); + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtGuidKey, fresh.c_str()); + MarkProjectDirty(static_cast(proj)); + loadFromProject(proj, projectDirOf(rppPath)); + lastProject_ = proj; + lastGuid_ = fresh; + lastRppPath_ = rppPath; + return; + } + + // Normal load: establish identity the same way prime does. + loadFromProject(proj, projectDirOf(rppPath)); + lastProject_ = proj; + lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); + lastRppPath_ = rppPath; + return; + } + + case capture::ProjectTransition::SaveAsRelocate: { + // SAME project object + new .rpp path: a genuine Save-As (the pointer + // proves it — a fork tab-switch is a DIFFERENT object and took the Load + // branch above). Relocate the bank folder from the old dir to the new + // one so the wavs sit under the new .rpp and the index's relative paths + // still resolve. Keep the in-memory bank as-is (Save-As copied our ext + // state, the relative paths are unchanged) — do NOT reload. + const std::string oldDir = projectDirOf(lastRppPath_); + const std::string newDir = projectDirOf(rppPath); + const capture::BankRelocation plan = + capture::deriveRelocationPlan(oldDir, newDir); + if (plan.needed) { + relocateBankFolder(plan.oldBankDir, plan.newBankDir); + } + + // Save-As duplicated our ext state, so the new project B currently + // shares A's GUID. Mint a FRESH GUID for B and write it, so A and B + // no longer collide on identity when reopened later. Adopt the fresh + // GUID as our last-seen identity. Mark dirty so the fresh GUID flushes + // to the new .rpp on the next normal save / close-prompt. + const std::string fresh = genProjectGuidString(); + if (proj) { + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtGuidKey, fresh.c_str()); + MarkProjectDirty(static_cast(proj)); + } + lastProject_ = proj; // unchanged (same object) — set for symmetry + lastGuid_ = fresh; + lastRppPath_ = rppPath; + return; + } + } +} + +} // namespace reasampler diff --git a/src/persist.h b/src/shell/persist/session.h similarity index 78% rename from src/persist.h rename to src/shell/persist/session.h index 52b5cfc..0bec588 100644 --- a/src/persist.h +++ b/src/shell/persist/session.h @@ -1,12 +1,27 @@ #pragma once -// persist — the REAPER-facing bridge between the in-memory BankIndex and project -// ext state (CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & paths). +// session — the ReaSamplerSession lifecycle owner of the persist seam (Q-W5 split of +// the former persist god-TU; CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & +// paths). One class, three implementation TUs by responsibility: // -// Save: serialize the BankIndex JSON -> SetProjExtState under namespace +// * session.cpp — poll() (identity-transition detection: load / Save-As / +// forked sibling / recycled pointer) + the deferred undo/redo reload drain +// (requestReload, raised by main.cpp's projectconfig BeginLoadProjectState hook) +// + the D4 load signal. +// * ext_state_io.cpp — saveToActiveProject / loadFromProject / +// writeAssignmentRequest: the ext-state ↔ JSON serialization bridge, plus GUID +// minting and bank-folder relocation (see ext_state_io.h for the key contract). +// * prune_fs.cpp — pruneDryRun / pruneOrphanSet / pruneReclaim: the prune scan +// and THE SINGLE FILE-DELETION AUTHORITY over USER files in the bank folder in +// ReaSampler (deleteOrphanFile via SHFileOperationW). Nothing else in the system +// deletes bank-folder bytes; a shell's self-cleanup of a transient scratch file +// it just created (the drop path's .vstpreset temp, the realtime finalize temp) +// is excluded from this authority. +// +// Save: serialize the BankModel JSON -> SetProjExtState under namespace // "reasampler" (ext state lives inside the .rpp, so the index travels with the // project for free). // Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory -// BankIndex, then resolve each entry's bank file against the CURRENT project +// BankModel, then resolve each entry's bank file against the CURRENT project // dir (project-relative resolution — a project opened from a new location still // finds its bank). // Save-As: when the project path changes, relocate the physical bank folder so @@ -14,67 +29,23 @@ // // The header is REAPER-free (no SDK types leak here): callers interact through a // ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API -// calls live in persist.cpp. It depends on bank_model (pure) for JSON round-trip +// calls live in the three TUs. It depends on bank_model (pure) for JSON round-trip // and capture_paths (pure) for the path arithmetic it drives. #include #include +#include -#include "app_version.h" -#include "bank_book.h" -#include "bank_model.h" -#include "ext_keys.h" -#include "owned_manifest.h" -#include "prune_reconcile.h" -#include "tail_control.h" -#include "view_mode_model.h" +#include "core/capture/tail_control.h" +#include "core/model/bank_book.h" +#include "core/model/bank_model.h" +#include "core/model/owned_manifest.h" +#include "core/reclaim/prune_reconcile.h" +#include "core/version/app_version.h" +#include "core/view/view_mode_model.h" namespace reasampler { -// The ext-state namespace + the WIRE-SHARED key names are the contract between this -// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h -// (pure, REAPER-free) and are included above — not duplicated here. The namespace is -// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace() -// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte- -// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both -// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace -// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project -// saved by stable shows empty/default state in beta and vice versa; that isolation is the -// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug. -// The per-key semantics persist relies on (spellings owned by ext_keys.h): -// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks). -// AUTHORITATIVE going forward; the VST reads this key to see the live bank. -// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared -// on save); READ once on load to migrate a legacy project into the pool. -// * kProjExtViewKey : the Design-View ViewModeModel JSON. -// * kProjExtTailKey : the docked panel's TailSetting JSON. -// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll() -// tells a Save-As from a recycled-pointer project switch by it). -// All are FOREVER-STABLE once shipped: changing any strands every already-saved -// project's stored state under that key. -// -// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this -// is the const char* the SetProjExtState/GetProjExtState calls in persist.cpp pass. Kept -// as an accessor (not a literal) because the string is channel-derived at build time. -inline const char* projExtNamespace() { return extStateNamespace().c_str(); } - -// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument -// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h: -// -// owned_files — the owned-file manifest JSON (project-relative files the capture path -// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's -// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT -// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it -// strands every saved project's ownership record (prune falls back to an empty manifest — -// graceful, but the attribution safety net is lost until the next capture rebuilds it). -inline constexpr const char* kProjExtOwnedKey = "owned_files"; - -// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on -// every save, so every saved .rpp records which build produced its state — the seam a -// future within-channel forward migration keys off. An absent key is the explicit -// pre-versioning case, read silently, never an error. FOREVER-STABLE key string. -inline constexpr const char* kProjExtVersionKey = "version"; - // Owns the session's BankBook (Phase B: pool + named banks) and drives persistence // against the active REAPER project. One instance lives for the extension's // lifetime (main.cpp). It tracks @@ -100,21 +71,21 @@ public: ReaSamplerSession() = default; // The multi-bank book (Phase B): the pool + named banks, each wrapping a - // BankIndex, plus the active-bank id. The action layer (B3) creates / renames / + // BankModel, plus the active-bank id. The action layer (B3) creates / renames / // reorders / deletes banks and moves samples here; the panel (B4) reads it; // persist serializes it under the `banks` key on save and replaces it on load. BankBook& book() { return book_; } const BankBook& book() const { return book_; } - // The capture add-target: the ACTIVE bank's BankIndex (defaults to the pool). + // The capture add-target: the ACTIVE bank's BankModel (defaults to the pool). // The capture path adds a captured Sample through this seam, so a capture lands // in whichever bank is active — the single behavioural change B2 wires in over // M7/M8 (the capture backends are untouched; only the target index moved). The // panel/insert readers that displayed the single index continue to read it here // unchanged; today it resolves to the pool (default active), matching prior // single-bank behaviour, until B3/B4 let the user switch the active bank. - BankIndex& bank() { return book_.activeIndex(); } - const BankIndex& bank() const { return book_.activeIndex(); } + model::BankModel& bank() { return book_.activeIndex(); } + const model::BankModel& bank() const { return book_.activeIndex(); } // The in-memory Design-View model. The view/action layer mutates it (tag, // toggle, snapshot); persist serializes it on save and replaces it on project @@ -129,8 +100,8 @@ public: // panel reads/writes it through this seam (bank_panel holds the session), and the // capture actions read it via bankPanelTailSetting. Default None / 2 s manual for // an unsaved or pre-feature project (no stored key -> this default survives load). - TailSetting& tail() { return tail_; } - const TailSetting& tail() const { return tail_; } + capture::TailSetting& tail() { return tail_; } + const capture::TailSetting& tail() const { return tail_; } // The owned-file manifest (Phase B B-cap): the set of project-relative files the // capture path itself created. The capture add-path records each created file here @@ -138,8 +109,8 @@ public: // bank; persist serializes it under the `owned_files` key on save and replaces it on // project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it; // B-cap only writes and persists it (no prune logic here). - OwnedFileManifest& owned() { return owned_; } - const OwnedFileManifest& owned() const { return owned_; } + model::OwnedFileManifest& owned() { return owned_; } + const model::OwnedFileManifest& owned() const { return owned_; } // The ReaSampler version that last WROTE the active project, recovered from its // ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no @@ -148,7 +119,7 @@ public: // path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved // or never-loaded session. Exposed so a future migration step (or diagnostics) can // reason about the origin build without re-reading ext state. - const WritingVersion& writingVersion() const { return writingVersion_; } + const version::WritingVersion& writingVersion() const { return writingVersion_; } // The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic // per project: recovered on load (so it continues from the stored value rather than @@ -198,7 +169,7 @@ public: // // Yields an empty report (count 0) when there is no active/saved project or no bank // folder on disk yet — an unsaved or never-captured project has nothing to reclaim. - PruneReport pruneDryRun() const; + reclaim::PruneReport pruneDryRun() const; // The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh // enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no @@ -218,11 +189,12 @@ public: // never wrongly deleted — and a newly-appeared orphan the user did NOT see is never // swept. Deletion routes to the OS trash where a portable move-to-trash is verified // (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to - // std::filesystem unlink behind this confirm guardrail (see persist.cpp for per-platform - // routing). Non-throwing: every filesystem call uses error_code forms; a per-file - // failure (locked, already gone) is recorded and skipped, never thrown across the C ABI. + // std::filesystem unlink behind this confirm guardrail (see prune_fs.cpp for + // per-platform routing). Non-throwing: every filesystem call uses error_code forms; a + // per-file failure (locked, already gone) is recorded and skipped, never thrown across + // the C ABI. // - // Does NOT modify the BankIndex/book (orphans are unreferenced by definition) and does + // Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does // NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present) // algebra naturally once it is off disk — no persist write, so no undo-point question // and no risk to the referenced/owned safety). Writes NO ext-state at all. @@ -230,7 +202,8 @@ public: // No-ops (empty result) when there is no active/saved project, no bank folder, or the // delete plan is empty (everything went stale). The caller is responsible for having // shown the confirm; this method does NOT prompt. - PruneDeletionResult pruneReclaim(const std::vector& confirmed) const; + reclaim::PruneDeletionResult pruneReclaim( + const std::vector& confirmed) const; // Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the // `assign_request` key, namespace "reasampler"): the extension telling the active @@ -292,25 +265,25 @@ private: // The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it // to this default when a project has no stored tail_setting key (older / never- // adjusted project), so an absent key is graceful. Peer to bank_/view_. - TailSetting tail_; + capture::TailSetting tail_; // The owned-file manifest. Default empty; loadFromProject resets it to empty (or the // stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to // a project with no stored manifest must not inherit the previous project's ownership // record, and an undo that rolled back a capture must re-read the restored manifest so // the in-memory set matches disk. Absent key -> empty is graceful (older project). - OwnedFileManifest owned_; + model::OwnedFileManifest owned_; // The writing-version stamp recovered on load (Phase V). Default PreVersioning; // loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so // switching to a pre-versioning project reports PreVersioning rather than inheriting // the previous project's stamp. Read-only to consumers via writingVersion(). - WritingVersion writingVersion_; + version::WritingVersion writingVersion_; // The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path // from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it // continues monotonic from the persisted value across reopen and resets cleanly on a - // project switch (a different project's counter, not the previous project's). bumped by + // project switch (a different project's counter, not the previous one's). bumped by // bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject(). // Default 0 for an unsaved / never-loaded / pre-S9 session. std::int64_t bankGeneration_ = 0; diff --git a/src/usage_scan.cpp b/src/shell/persist/usage_scan.cpp similarity index 86% rename from src/usage_scan.cpp rename to src/shell/persist/usage_scan.cpp index 49b91d8..2c8b261 100644 --- a/src/usage_scan.cpp +++ b/src/shell/persist/usage_scan.cpp @@ -16,7 +16,7 @@ // * TakeFX_GetCount / TakeFX_GetNamedConfigParm (~6710/6774) // * guidToString (via track_guid::guidString) -#include "usage_scan.h" +#include "shell/persist/usage_scan.h" #include #include @@ -25,11 +25,12 @@ #include #include -#include "app_version.h" // vstPluginName / vstOutputName (channel name needles) +#include "core/version/app_version.h" // vstPluginName / vstOutputName (channel name needles) +#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) #include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix -#include "instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex -#include "sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions) -#include "track_guid.h" // guidString — the ONE canonical GUID key formatter +#include "core/wire/instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex +#include "core/wire/sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions) +#include "shell/capture/track_guid.h" // guidString — the ONE canonical GUID key formatter #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjExtState @@ -51,6 +52,14 @@ namespace reasampler { +// Real-namespace-home using-directive (Q-W6: the namespaces.h shim is retired): +// this TU speaks the sample_usage wire vocabulary wholesale (UsageRecord / +// decodeUsageRecord / foldUsageRecords / identityMatches / toUpperAscii) plus the +// channel-identity accessors + the preset class-id hex. +using namespace reasampler::wire; +using version::vstOutputName; +using version::vstPluginName; + namespace { // The three UPPERCASED channel needles identityMatches (pure, sample_usage) checks @@ -165,22 +174,22 @@ bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) { return false; } -// Growing GetProjExtState read (the persist.cpp idiom): the usage record scales with -// the hold count, so a fixed buffer risks a truncated decode. Returns nullopt when the -// key cannot be read WHOLE — absent-after-enumeration (rv <= 0) or pathologically large -// (> 16 MB give-up). The caller only queries keys the enumeration just listed, so a -// nullopt here is a PRESENT-BUT-UNREADABLE record: it folds to abortPrune (fail-safe — -// silently reduced protection is the delete direction). +// Growing GetProjExtState read: the usage record scales with the hold count, so a +// fixed buffer risks a truncated decode. The retry policy is the SHARED pure +// wire::readProjExtStateGrowing (T2-04 — one loop for persist, this +// prune-safety-adjacent read, and the VST bridge; the rules cannot drift). +// Returns nullopt when the key cannot be read WHOLE — absent-after-enumeration +// (rv <= 0) or pathologically large (> 16 MB give-up). The caller only queries keys +// the enumeration just listed, so a nullopt here is a PRESENT-BUT-UNREADABLE record: +// it folds to abortPrune (fail-safe — silently reduced protection is the delete +// direction). std::optional readExtStateValue(ReaProject* proj, const char* key) { - for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) { - std::vector buf(static_cast(cap), '\0'); - const int rv = GetProjExtState(proj, kProjExtNamespace(), key, buf.data(), cap); - if (rv <= 0) return std::nullopt; - std::string s(buf.data()); - if (static_cast(s.size()) + 1 < cap) return s; - // else possibly truncated -> grow and retry - } - return std::nullopt; // > 16 MB — unreadable whole, never "absent" + const GrowingExtStateRead read = readProjExtStateGrowing( + [&](char* buf, int cap) { + return GetProjExtState(proj, kProjExtNamespace(), key, buf, cap); + }); + if (read.status != GrowingExtStateRead::Status::Complete) return std::nullopt; + return read.value; } } // namespace diff --git a/src/usage_scan.h b/src/shell/persist/usage_scan.h similarity index 100% rename from src/usage_scan.h rename to src/shell/persist/usage_scan.h diff --git a/src/view.cpp b/src/shell/view/view.cpp similarity index 98% rename from src/view.cpp rename to src/shell/view/view.cpp index a6c4321..a427f05 100644 --- a/src/view.cpp +++ b/src/shell/view/view.cpp @@ -8,7 +8,7 @@ // module so it is unit-tested outside the DAW; this file owns only the REAPER // reads/writes and the snapshot-before-park ordering. -#include "view.h" +#include "shell/view/view.h" #include #include @@ -18,10 +18,10 @@ #include #include -#include "item_read.h" -#include "lane_keys.h" -#include "track_guid.h" -#include "view_tree.h" +#include "shell/capture/item_read.h" +#include "core/view/lane_keys.h" +#include "shell/capture/track_guid.h" +#include "core/view/view_tree.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountTracks @@ -47,6 +47,13 @@ namespace reasampler { +// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). +using view::buildFolderTree; +using view::isOnManualLane; +using view::managedLaneKey; +using view::modeIdFromLaneName; +using view::TrackFolderEntry; + namespace { // Track fixed-lane mode value (I_FREEMODE=2). See SDK: 0=normal, 1=free item diff --git a/src/view.h b/src/shell/view/view.h similarity index 99% rename from src/view.h rename to src/shell/view/view.h index ae5feb0..9260cd7 100644 --- a/src/view.h +++ b/src/shell/view/view.h @@ -24,7 +24,7 @@ #include -#include "view_mode_model.h" +#include "core/view/view_mode_model.h" // REAPER's opaque project handle. Forward-declared to keep this header SDK-free; // the .cpp includes reaper_plugin_functions.h and sees the real class. diff --git a/src/vst/curve_popup.cpp b/src/vst/curve_popup.cpp deleted file mode 100644 index 07ffe53..0000000 --- a/src/vst/curve_popup.cpp +++ /dev/null @@ -1,41 +0,0 @@ -// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes. - -#include "curve_popup.h" - -#include - -namespace reasampler::vst { - -namespace { -int clampDim(int want, int lo, int hi, int windowDim) { - const int clamped = (std::max)(lo, (std::min)(hi, want)); - return (std::min)(clamped, (std::max)(0, windowDim)); -} -} // namespace - -CurvePopupLayout computeCurvePopup(int w, int h) { - CurvePopupLayout out; - const int sheetW = clampDim((w * 60) / 100, kCurvePopupMinW, kCurvePopupMaxW, w); - const int sheetH = clampDim((h * 55) / 100, kCurvePopupMinH, kCurvePopupMaxH, h); - const int left = (w - sheetW) / 2; - const int top = (h - sheetH) / 2; - out.sheet = Rect{left, top, left + sheetW, top + sheetH}; - - const int titleBottom = out.sheet.top + kCurvePopupTitleH; - const int closeTop = out.sheet.top + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2; - out.close = Rect{out.sheet.right - kCurvePopupPad - kCurvePopupCloseSize, closeTop, - out.sheet.right - kCurvePopupPad, closeTop + kCurvePopupCloseSize}; - out.title = Rect{out.sheet.left + kCurvePopupPad, out.sheet.top, - out.close.left - kCurvePopupPad, titleBottom}; - - out.curveBox = Rect{out.sheet.left + kCurvePopupPad, titleBottom + 2, - out.sheet.right - kCurvePopupPad, - out.sheet.bottom - kCurvePopupPad}; - return out; -} - -bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) { - return !contains(layout.sheet, x, y); -} - -} // namespace reasampler::vst diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp deleted file mode 100644 index f838420..0000000 --- a/src/vst/editor_geometry.cpp +++ /dev/null @@ -1,164 +0,0 @@ -// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types. - -#include "editor_geometry.h" - -#include - -namespace reasampler::vst { - -namespace { - -// Spike editor layout constants. These are the editor's fixed metrics; the real -// editor (S4/S5) will parameterize as its content demands. -constexpr int kTitleBarHeight = 28; -constexpr int kButtonMargin = 10; -constexpr int kButtonWidth = 120; -constexpr int kButtonHeight = 24; - -} // namespace - -bool contains(const Rect& r, int x, int y) { - if (r.width() <= 0 || r.height() <= 0) return false; - return x >= r.left && x < r.right && y >= r.top && y < r.bottom; -} - -EditorLayout layoutEditor(int w, int h) { - // Clamp the surface to non-negative extents so a degenerate view can't produce - // inverted rects. - const int cw = std::max(0, w); - const int ch = std::max(0, h); - - EditorLayout out; - - // Title bar spans the top, clamped so it never exceeds the client height. - const int titleH = std::min(kTitleBarHeight, ch); - out.titleBar = Rect{0, 0, cw, titleH}; - - // Canvas is everything below the title bar. - out.canvas = Rect{0, titleH, cw, ch}; - - // Button sits at the top-left of the canvas, inset by a margin, and is clamped to - // fit inside the canvas so it never overhangs on a small view. - const int bx = out.canvas.left + kButtonMargin; - const int by = out.canvas.top + kButtonMargin; - const int bRight = std::min(bx + kButtonWidth, out.canvas.right); - const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom); - out.button = Rect{bx, by, std::max(bx, bRight), std::max(by, bBottom)}; - - return out; -} - -HitTarget hitTest(const EditorLayout& layout, int x, int y) { - if (contains(layout.button, x, y)) return HitTarget::kButton; - return HitTarget::kNone; -} - -Rect sampleRowRect(const EditorLayout& layout, int index) { - if (index < 0) return Rect{}; - const int top = layout.canvas.top + index * kSampleRowHeight; - return Rect{layout.canvas.left, top, layout.canvas.right, top + kSampleRowHeight}; -} - -int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { - if (rowCount <= 0) return -1; - // Must be within the canvas horizontally and at/below its top. - if (x < layout.canvas.left || x >= layout.canvas.right) return -1; - if (y < layout.canvas.top) return -1; - // Clip at the canvas bottom: clicks in the canvas's dead-zone below the last - // visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom. - if (y >= layout.canvas.bottom) return -1; - const int index = (y - layout.canvas.top) / kSampleRowHeight; - if (index < 0 || index >= rowCount) return -1; - // Guard the bottom edge: a click below the last row's bottom is outside. - const Rect r = sampleRowRect(layout, index); - if (y >= r.bottom) return -1; - return index; -} - -// --- Keymap editor ----------------------------------------------------------- - -KeymapEditorLayout layoutKeymapEditor(int w, int h) { - KeymapEditorLayout out; - out.base = layoutEditor(w, h); - const Rect& canvas = out.base.canvas; - - // Split the canvas vertically: the left column is the bank-sample list, the right - // column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so - // the split point never crosses the canvas edges. - const int canvasW = std::max(0, canvas.width()); - const int splitW = canvasW / kZonePanelFraction; // width of the zone panel - const int splitX = std::max(canvas.left, canvas.right - splitW); - - out.sampleList = Rect{canvas.left, canvas.top, splitX, canvas.bottom}; - out.zonePanel = Rect{splitX, canvas.top, canvas.right, canvas.bottom}; - - // "Add Zone" button spans the top of the zone panel, clamped to its height. - const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height())); - out.addZoneButton = - Rect{out.zonePanel.left, out.zonePanel.top, out.zonePanel.right, - out.zonePanel.top + addH}; - - // Zone rows stack below the button. - out.zoneRowArea = Rect{out.zonePanel.left, out.addZoneButton.bottom, - out.zonePanel.right, out.zonePanel.bottom}; - return out; -} - -Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) { - if (index < 0) return Rect{}; - const int top = layout.sampleList.top + index * kSampleRowHeight; - return Rect{layout.sampleList.left, top, layout.sampleList.right, - top + kSampleRowHeight}; -} - -int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) { - if (rowCount <= 0) return -1; - const Rect& list = layout.sampleList; - if (x < list.left || x >= list.right) return -1; - if (y < list.top || y >= list.bottom) return -1; - const int index = (y - list.top) / kSampleRowHeight; - if (index < 0 || index >= rowCount) return -1; - const Rect r = keymapSampleRowRect(layout, index); - if (y >= r.bottom) return -1; - return index; -} - -Rect zoneRowRect(const KeymapEditorLayout& layout, int index) { - if (index < 0) return Rect{}; - const int top = layout.zoneRowArea.top + index * kZoneRowHeight; - return Rect{layout.zoneRowArea.left, top, layout.zoneRowArea.right, - top + kZoneRowHeight}; -} - -ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) { - if (zoneCount <= 0) return ZoneHit{}; - const Rect& area = layout.zoneRowArea; - if (x < area.left || x >= area.right) return ZoneHit{}; - if (y < area.top || y >= area.bottom) return ZoneHit{}; - const int index = (y - area.top) / kZoneRowHeight; - if (index < 0 || index >= zoneCount) return ZoneHit{}; - const Rect row = zoneRowRect(layout, index); - if (y >= row.bottom) return ZoneHit{}; - - // Seven mini-buttons pinned to the right edge, right-to-left: - // delete, root+, root-, high+, high-, low+, low- - // Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select"). - // The fields laid out LEFT-TO-RIGHT in slot order 0..6. - const ZoneField fields[7] = { - ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, - ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, - ZoneField::kDelete, - }; - const int slots = 7; - const int ctrlBlockLeft = row.right - slots * kZoneCtrlWidth; - if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select - const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth; - if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone}; - return ZoneHit{index, fields[slot]}; -} - -bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) { - return contains(layout.addZoneButton, x, y); -} - -} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp deleted file mode 100644 index a46c222..0000000 --- a/src/vst/reasampler_editor.cpp +++ /dev/null @@ -1,3065 +0,0 @@ -// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the -// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is -// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults. - -#include "reasampler_editor.h" - -#include -#include -#include // snprintf (Phase S voice-count readout) -#include -#include -#include - -#include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry -#include "capture_browser.h" -#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) -#include "curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) -#include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) -#include "editor_geometry.h" // Rect, contains -#include "ext_keys.h" -#include "keyboard_strip.h" -#include "master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) -#include "theme.h" // Role / InteractionState / KitColor / spectralColor (L3) -#include "note_entry.h" // S12 direct numeric note-entry parse -#include "param_slider.h" // the FA4 radial-knob primitive (value<->needle map, drag delta) -#include "peaks.h" // computeEnvelope -#include "reaper_bridge.h" -#include "reasampler_processor.h" -#include "app_version.h" // vstPluginName (channel-derived editor title band, S18) -#include "sample_map.h" -#include "wav_trim.h" // parseWavLayout, extractFloatFrames -#include "trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3) -#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) - -#ifdef _WIN32 -#include // GET_X_LPARAM / GET_Y_LPARAM -#include // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept - -#include "wdltypes.h" -#include "lice/lice.h" -#endif - -using namespace Steinberg; - -namespace reasampler::vst { - -namespace { -#ifdef _WIN32 -constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; - -// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread -// timer: responsive enough that a recapture/ingest/assign refreshes "within a bounded cadence" -// (the S9 verify criterion) yet cheap — three small ext-state reads per tick, coalescing many -// bumps between ticks into one reload. 500 ms is a deliberate build-time residual: fast enough -// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero). -constexpr UINT_PTR kSyncTimerId = 1; -constexpr UINT kSyncTimerIntervalMs = 500; - -// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry is in -// capture_browser / keyboard_strip / knob_deck). r11 Sample face (top->bottom): a TITLE band -// (name + Browse/Zone nav buttons), the FULL-WIDTH ELASTIC HERO (absorbs all height left after -// the fixed bands, floor kHeroMinHeight — the S11 markers + the S-VIEW-3 envelope overlay trace -// over it), the ROOT + PREVIEW CLUSTER (remainder-width root strip + preview-trigger + radial -// velocity knob + mini curve-preview button + Mono/Stereo), and the bottom-anchored KNOB DECK -// (the fenced control groups — the r11 replacement for the slider control strip). Browse + Zone -// reuse the browser grid / zone strip machinery unchanged. -constexpr int kTitleHeight = 26; -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 kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) -constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons - -// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette. The waveform's -// start point + the sustain-loop ends are CATEGORICAL kinds (a distinct affordance class, -// §2.1), not the live/active layer, so they take the categorical accents: start = teal -// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple. -constexpr Role kRoleStartMarker = Role::AccentSecondary; -constexpr Role kRoleLoopMarker = Role::AccentTertiary; - -// --- Rect <-> kit adapters (Phase L, L3) ------------------------------------- -// -// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against `KitBox` -// (component_geometry). This is the single boundary that bridges them so every draw routes -// through the L1 kit (theme roles + draw_kit), retiring the shell's raw LICE_RGBA palette + -// GDI DrawTextA path. -KitBox toKitBox(const Rect& r) { - return KitBox{r.left, r.top, r.width(), r.height()}; -} - -// Kit text in a palette ROLE (the common case). Left/Right/Center via Align. -void kitText(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role, - Align align = Align::Left) { - text(bmp, toKitBox(r), s, font, role, align); -} - -void kitTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role) { - text(bmp, toKitBox(r), s, font, role, Align::Center); -} - -// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the -// common DAW convention REAPER uses). -std::string noteLabel(int note) { - static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", - "F#", "G", "G#", "A", "A#", "B"}; - if (note < 0) note = 0; - if (note > 127) note = 127; - const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4 - return std::string(kNames[note % 12]) + std::to_string(octave); -} - -// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3): -// midline + accent-primary min/max columns with the same dB display compression the dock -// panel thumbnail uses, so a waveform reads identically wherever it is drawn. The caller has -// already filled the surface behind it (bg/panel), matching drawWaveform's contract. -void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) { - drawWaveform(bmp, toKitBox(r), env); -} - -// A display name for a bank sample id: the snapshotted bank list first, then the -// instance-OWNED ref's displayName (pS — the label survives with the extension absent / -// bank unreadable, mirroring the waveform + loop-marker ref fallback). "?" only when -// neither source knows the id (a stale zone naming a deleted sample, or a pre-displayName -// refs table not yet back-filled by a bank refresh). -std::string sampleLabel(const std::vector& samples, const SampleRefs& refs, - const std::string& id) { - for (const SampleChoice& c : samples) { - if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; - } - for (const SampleRefEntry& e : refs) { - if (e.sampleId == id && !e.displayName.empty()) return e.displayName; - } - return "?"; -} - -// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the -// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins. -// thumbnailFor clamps the request to the decoded frame count. -int thumbBins(const BrowserLayout& layout) { - return (std::max)(1, kWaveformOversample * - waveformColumnCount(toKitBox(cardThumbnailRect(layout, 0)))); -} -#endif -} // namespace - -ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) - : CPluginView(nullptr), processor_(processor) { - // Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample - // home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate - // mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll - // on a 1080p screen with headroom. Wide enough that the control strip's label + value columns - // read comfortably. - ViewRect r(0, 0, 840, 620); - setRect(r); -} - -void ReaSamplerEditor::refreshFromBank() { - // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). - thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks - pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source) - if (!processor_) { - samples_.clear(); - banks_.clear(); - visible_.clear(); - selectedId_.clear(); - map_.zones.clear(); - selectedZone_ = -1; - return; - } - auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - samples_ = banksJson ? listSamples(*banksJson) : std::vector{}; - banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; - selectedId_ = processor_->selectedSampleId(); - const auto prevZoneCount = static_cast(map_.zones.size()); - map_ = processor_->performanceMap(); - channelMode_ = processor_->channelMode(); - voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot - voiceMode_ = processor_->voiceMode(); - monoTrigger_ = processor_->monoTrigger(); - if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; - // r11: a refresh that emptied the selection (a bank change on the sync tick) closes the - // curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible - // modal would swallow clicks. - if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false; - // FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list - // shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list - // deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget). - if (view_ == View::kZone && curvePopupOpen_) { - const auto newZoneCount = static_cast(map_.zones.size()); - if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false; - } - // Drop a filter that names a bank no longer present. - if (!activeFilterBankId_.empty()) { - bool found = false; - for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; - if (!found) activeFilterBankId_.clear(); - } - rebuildVisible(); -} - -void ReaSamplerEditor::rebuildVisible() { - // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search - // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). - visible_.clear(); - for (const SampleChoice& s : samples_) { - const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; - if (!inBank) continue; - const std::string& name = s.displayName.empty() ? s.id : s.displayName; - if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); - } - // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel - // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it - // must not reset the user's scroll here. -} - -#ifdef _WIN32 -// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared -// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes -// Windows the only build target, but the TU must still compile elsewhere). -void ReaSamplerEditor::onSyncTimer() { - // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the - // processor (off the audio thread — the poll itself never touches process()). NEVER while a - // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the - // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. - if (!processor_) return; - if (drag_ != DragKind::kNone) return; // defer past the in-flight edit - - // An open editor marks THIS instance the focused assignment target (the thundering-herd - // policy — only an editor-open instance applies a pending assign; see the handoff). Pass - // true so this instance consumes the request; instances with no editor open do not poll at - // all (the timer is bound to the child window), so they never contend for the request. - const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); - - // Re-snapshot the editor's own view only when something changed (a reload from a bank - // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the - // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM - // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. - if (r.reloaded || r.applied) { - refreshFromBank(); - invalidate(); - } - - // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. - if (dropHintTicks_ > 0) { - --dropHintTicks_; - invalidate(); - } -} -#endif // _WIN32 - -void ReaSamplerEditor::commitAndReload() { - // UI thread only. Publish the edited selection + zones to the processor, then rebuild - // the instrument off the audio thread (reloadInstrument bakes them into the live Keymap). - // pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank - // blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the - // moment the instance becomes self-contained for that sample. - if (!processor_) return; - processor_->setSelectedSampleId(selectedId_); - processor_->setPerformanceMap(map_); - processor_->reloadInstrument(); - // GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's - // channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode - // the engine actually decoded with. - channelMode_ = processor_->channelMode(); -#ifdef _WIN32 - invalidate(); -#endif -} - -void ReaSamplerEditor::loadSelection(const std::string& id) { - // Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous - // sample's materialized full-range zone must not linger — first-match resolve would - // keep playing it while the editor draws the new pick's zone (matched by sampleId, - // order-blind). Authored Zone-view maps (any narrow key range) are left untouched. - selectedId_ = id; - if (reconcileSingleCaptureZones(map_, selectedId_)) { - selectedZone_ = map_.zones.empty() ? -1 : 0; - } - commitAndReload(); -} - -ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { - SetupMarkers m; - // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override - // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic - // from the live bank blob (the same path selectSample uses); when that is not readable - // (extension absent / not yet parsed) the instance-OWNED ref carries the same intrinsics - // (pS fallback). The override lives in map_. - if (processor_) { - std::optional sel; - auto banksJson = - processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - if (banksJson) sel = selectSample(*banksJson, selectedId_); - if (!sel) { - const SampleRefs refs = processor_->sampleRefs(); - if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r; - } - if (sel && sel->loop.hasLoop) { - m.hasLoop = true; - m.loopStart = sel->loop.start; - m.loopEnd = sel->loop.end; - } - } - // The override (loop + start) on a zone for the picked id supersedes the intrinsic. - for (const PerformanceZone& z : map_.zones) { - if (z.sampleId != selectedId_) continue; - if (z.loopOverride) { - m.hasLoop = z.loopOverride->hasLoop; - m.loopStart = z.loopOverride->start; - m.loopEnd = z.loopOverride->end; - } - if (z.startPoint) m.start = *z.startPoint; - break; - } - // Default an unset loop's end to the sample length so the loop markers have somewhere sane - // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; - // the markers are still drawn (drag one to CREATE a loop). - if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; - return m; -} - -int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { - // Find-or-append the zone for selectedId_ and write the loop/start override fields. - // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must - // be non-empty; callers are responsible for that guard. - // Returns the zone index (0-based) so callers can update selectedZone_. - SampleLoop loop; - loop.hasLoop = m.hasLoop; - loop.start = m.loopStart; - loop.end = m.loopEnd; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - PerformanceZone& z = map_.zones[static_cast(i)]; - if (z.sampleId == selectedId_) { - z.loopOverride = loop; - z.startPoint = m.start; - return i; - } - } - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - z.loopOverride = loop; - z.startPoint = m.start; - map_.zones.push_back(z); - return static_cast(map_.zones.size()) - 1; -} - -PerformanceZone ReaSamplerEditor::effectiveSampleZone() const { - // The picked id's one-zone override, if the map already carries one; else a product-default - // zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes - // it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean. - for (const PerformanceZone& z : map_.zones) { - if (z.sampleId == selectedId_) return z; - } - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - return z; -} - -int ReaSamplerEditor::effectiveRoot() const { - int root = 60; - for (const SampleChoice& s : samples_) { - if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; - } - for (const PerformanceZone& z : map_.zones) { - if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride; - } - return root; -} - -int ReaSamplerEditor::ensureSampleZone() { - if (selectedId_.empty()) return -1; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - if (map_.zones[static_cast(i)].sampleId == selectedId_) return i; - } - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - map_.zones.push_back(z); - return static_cast(map_.zones.size()) - 1; -} - -namespace { -// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is -// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span -// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build -// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) -// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a -// wall-clock second). Build-time residual — one place to retune; not persisted. -constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) -constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames) -constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered -constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) - -double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } -} // namespace - -double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { - // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over - // the frames ceiling. Two domains, kept explicit so neither leaks a rate. - const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; - const auto framesToNorm = [](std::int64_t f) { - return clamp01(static_cast(f) / kFadeMaxFrames); - }; - switch (static_cast(id)) { - case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; - case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; - case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); - case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); - case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds); - case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); - case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds); - case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction); - case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); - case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); - case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; - case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds); - case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds); - case ParamControl::kPitchEnvDepth: - // Signed depth centered at 0.5 (0.5 == 0 semitones). - return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); - default: return 0.0; - } -} - -void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, - int segment) const { - const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; - const auto normToFrames = [](double v) { - return static_cast(clamp01(v) * kFadeMaxFrames + 0.5); - }; - switch (static_cast(id)) { - case ParamControl::kPlayMode: - play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; - break; - case ParamControl::kPitchEngine: - play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; - break; - case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break; - case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break; - case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break; - case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; - case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break; - case ParamControl::kTrigLength: - // lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing. - play.trigger.lengthFraction = (std::max)(0.01, clamp01(value)); - break; - case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break; - case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break; - case ParamControl::kPitchEnvEnable: - play.pitchEnv.enabled = (segment == 1); - break; - case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break; - case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break; - case ParamControl::kPitchEnvDepth: - play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; - break; - default: break; - } -} - -double ReaSamplerEditor::liveSampleRate() const { - return processor_ ? processor_->sampleRate() : 0.0; -} - -double ReaSamplerEditor::previewVelocity01() const { - if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; - return static_cast(processor_->previewVelocity()) / 127.0; -} - -// --- r11 knob-deck plumbing (FB1) --------------------------------------------- - -namespace { -// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck order. -enum DeckGroup { - kGroupAmpEnv = 0, - kGroupPitch, - kGroupPitchEnv, - kGroupVoice, - kGroupMaster, -}; -} // namespace - -std::vector ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const { - // The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders - // exactly these; the Sample face appends the per-instance groups in deckGroupDescs). - // Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger - // leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows - // the neighbouring groups (r11). - std::vector out; - { - DeckGroupDesc amp; - amp.id = kGroupAmpEnv; - amp.captionWidth = 78; - amp.captionToggle = {static_cast(ParamControl::kPlayMode), 44}; - if (play.playMode == PlayMode::Gate) { - amp.cellIds = {static_cast(ParamControl::kAttack), - static_cast(ParamControl::kHold), - static_cast(ParamControl::kDecay), - static_cast(ParamControl::kSustain), - static_cast(ParamControl::kRelease)}; - } else { - // Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out — - // matches the drawn envelope), plus the two reserved blanks. - amp.cellIds = {static_cast(ParamControl::kTrigFadeIn), - static_cast(ParamControl::kTrigLength), - static_cast(ParamControl::kTrigFadeOut), -1, -1}; - } - out.push_back(std::move(amp)); - } - { - DeckGroupDesc pitch; - pitch.id = kGroupPitch; - pitch.captionWidth = 38; - pitch.captionToggle = {static_cast(ParamControl::kPitchEngine), 48}; - pitch.cellIds = {static_cast(ParamControl::kKeyTrack)}; - out.push_back(std::move(pitch)); - } - { - DeckGroupDesc penv; - penv.id = kGroupPitchEnv; - penv.captionWidth = 58; - penv.captionToggle = {static_cast(ParamControl::kPitchEnvEnable), 32}; - penv.cellIds = {static_cast(ParamControl::kPitchEnvAttack), - static_cast(ParamControl::kPitchEnvDecay), - static_cast(ParamControl::kPitchEnvDepth)}; - out.push_back(std::move(penv)); - } - return out; -} - -std::vector ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const { - // The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER - // groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the - // post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays - // OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs. - std::vector out = zoneDeckGroupDescs(play); - { - DeckGroupDesc voice; - voice.id = kGroupVoice; - voice.captionWidth = 38; - voice.captionToggle = {static_cast(ParamControl::kVoiceMode), 40}; - voice.cellIds = {static_cast(ParamControl::kVoiceCount)}; - voice.rowToggle = {static_cast(ParamControl::kMonoTrigger), 44}; - out.push_back(std::move(voice)); - } - { - DeckGroupDesc master; - master.id = kGroupMaster; - master.captionWidth = 46; - master.cellIds = {static_cast(ParamControl::kMasterGain)}; - out.push_back(std::move(master)); - } - return out; -} - -double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const { - if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob - switch (static_cast(id)) { - case ParamControl::kKeyTrack: - return clamp01(zone.keyTrack / kKeyTrackMax); - case ParamControl::kVoiceCount: - return clamp01(static_cast(voiceCount_ - kMinVoiceCount) / - static_cast(kMaxVoiceCount - kMinVoiceCount)); - case ParamControl::kMasterGain: - return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0); - default: - return controlValue(id, zone.play); - } -} - -void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) { - if (!processor_) return; - norm = clamp01(norm); - if (id == -2) { - // Preview velocity: live processor write (persisted per-instance; the setter clamps - // to MIDI 1..127 so the knob's bottom still strikes audibly). - processor_->setPreviewVelocity(static_cast(norm * 127.0 + 0.5)); - return; - } - switch (static_cast(id)) { - case ParamControl::kVoiceCount: { - // Stepped: quantize the continuous drag to the integer count and track it live - // for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on - // WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild, - // not thirty. - const int count = - kMinVoiceCount + - static_cast(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5); - voiceCount_ = count; - return; - } - case ParamControl::kMasterGain: - // Post-mixer gain: one atomic store; the audio thread picks it up next block. - processor_->setMasterGainLinear(masterGainLinearFromNorm(norm)); - return; - default: - applyZoneControl(zoneIndex, id, norm, 0); - return; - } -} - -std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const { - char buf[24]; - buf[0] = '\0'; - const ZonePlaySeconds& play = zone.play; - switch (id == -2 ? ParamControl::kCount : static_cast(id)) { - case ParamControl::kAttack: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break; - case ParamControl::kHold: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break; - case ParamControl::kDecay: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break; - case ParamControl::kSustain: - snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break; - case ParamControl::kRelease: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break; - case ParamControl::kTrigLength: - snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break; - case ParamControl::kTrigFadeIn: - snprintf(buf, sizeof(buf), "%lldf", - static_cast(play.trigger.fadeInFrames)); break; - case ParamControl::kTrigFadeOut: - snprintf(buf, sizeof(buf), "%lldf", - static_cast(play.trigger.fadeOutFrames)); break; - case ParamControl::kPitchEnvAttack: - snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break; - case ParamControl::kPitchEnvDecay: - snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break; - case ParamControl::kPitchEnvDepth: - snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; - case ParamControl::kKeyTrack: - snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break; - case ParamControl::kVoiceCount: - snprintf(buf, sizeof(buf), "%d", voiceCount_); break; - case ParamControl::kMasterGain: - formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break; - default: - // -2 (preview velocity) is labeled at its cluster call site; nothing else here. - break; - } - return std::string(buf); -} - -EnvClampBounds ReaSamplerEditor::envClampBounds() const { - // Match the control-panel sliders' own domains so a node drag can never produce a param a - // slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the - // Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use). - EnvClampBounds b; - b.maxAttackSeconds = kEnvTimeMaxSeconds; - b.maxHoldSeconds = kEnvTimeMaxSeconds; - b.maxDecaySeconds = kEnvTimeMaxSeconds; - b.maxReleaseSeconds = kEnvTimeMaxSeconds; - b.maxFadeInFraction = 1.0; - b.maxFadeOutFraction = 1.0; - b.maxLengthFraction = 1.0; - return b; -} - -AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames, - std::int64_t startFrame) const { - AmpEnvelope env; - env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; - // AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws). - env.attackSeconds = play.adsr.attackSeconds; - env.holdSeconds = play.adsr.holdSeconds; - env.decaySeconds = play.adsr.decaySeconds; - env.sustainLevel = play.adsr.sustainLevel; - env.releaseSeconds = play.adsr.releaseSeconds; - // Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played - // span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start - // point so the fraction denominator matches the voice's actual post-start span. A zero play - // length yields 0 fractions. - env.lengthFraction = play.trigger.lengthFraction; - const std::int64_t playLen = - triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); - env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen); - env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen); - return env; -} - -void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, - std::int64_t startFrame, ZonePlaySeconds& play) const { - if (env.mode == EnvMode::Gate) { - play.adsr.attackSeconds = env.attackSeconds; - play.adsr.holdSeconds = env.holdSeconds; - play.adsr.decaySeconds = env.decaySeconds; - play.adsr.sustainLevel = env.sustainLevel; - play.adsr.releaseSeconds = env.releaseSeconds; - } else { - // Trigger: lengthFraction copies back; the fades convert fractions -> source frames over - // the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's - // effective start point so the frame denominator matches the voice's actual post-start span. - // Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length - // trigger never plays nothing. - play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction); - const std::int64_t playLen = - triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); - play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen); - play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen); - } -} - -void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { - // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, - // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays - // identically to the un-zoned single capture (one chromatic zone) and round-trips through - // the component state; the zone becomes visible if the user opens the Zones panel. The bank - // intrinsic is NEVER written (read-only bank consumer, D-B). - if (selectedId_.empty()) return; - upsertPickedOverride(m); - commitAndReload(); -} - -const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { - auto it = pcmCache_.find(sampleId); - if (it != pcmCache_.end()) return it->second; - - // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so - // resolve the path from the live bank blob (selectSample) and decode via the shared WAV - // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY - // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width- - // independent) — the thumbnail bins this at whatever width, the snap scans it directly. - std::string relativePath; - std::vector mono; - if (processor_) { - auto banksJson = - processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - if (banksJson) { - if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; - } - if (relativePath.empty()) { - // pS fallback: the bank blob is not readable (extension absent / not yet parsed) - // or the id went stale there — the instance-OWNED ref still carries the path, so - // a self-contained instance draws its loaded sound's waveform regardless. - const SampleRefs refs = processor_->sampleRefs(); - if (const SelectedSample* r = findRef(refs, sampleId)) { - relativePath = r->relativePath; - } - } - if (!relativePath.empty()) { - const std::string projectDir = processor_->bridge().activeProjectDir(); - const std::string abs = resolveBankFile(projectDir, relativePath); - std::vector bytes; - std::ifstream f(abs, std::ios::binary | std::ios::ate); - if (f) { - const std::streamoff size = f.tellg(); - if (size > 0) { - f.seekg(0, std::ios::beg); - bytes.resize(static_cast(size)); - if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); - } - } - const WavLayout layout = parseWavLayout(bytes); - if (layout.valid) { - std::vector interleaved = - extractFloatFrames(bytes, layout, 0, layout.frameCount()); - mono = downmixToMono(interleaved, layout.channelCount); - } - } - } - auto ins = pcmCache_.emplace(sampleId, std::move(mono)); - return ins.first->second; -} - -const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { - const std::string key = sampleId + "|" + std::to_string(binCount); - auto it = thumbCache_.find(key); - if (it != thumbCache_.end()) return it->second; - - // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by - // every thumbnail width AND the S11 waveform surface + snap. - const std::vector& mono = monoPcmFor(sampleId); - Envelope env; - if (!mono.empty()) { - // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with - // trailing empty {0,0} bins, which would render a very short sample as a comb of - // spikes over flat gaps. - const std::size_t bins = - (std::min)(static_cast((std::max)(1, binCount)), mono.size()); - env = computeEnvelope(mono, 1, mono.size(), bins); - } - auto ins = thumbCache_.emplace(key, std::move(env)); - return ins.first->second; -} - -ReaSamplerEditor::~ReaSamplerEditor() { -#ifdef _WIN32 - if (childHwnd_) { - DestroyWindow(childHwnd_); - childHwnd_ = nullptr; - } -#endif -} - -tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) { -#ifdef _WIN32 - if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue; -#endif - return kResultFalse; -} - -tresult PLUGIN_API ReaSamplerEditor::canResize() { - return kResultTrue; -} - -tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) { - // Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before - // every resize; clamp the proposed rect in place and return kResultTrue so the host applies the - // (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title - // + hero waveform + cluster + a few control rows visible (the control strip clips gracefully - // below the panel bottom); anything smaller would clip essential UI. The default 840×620 is - // above this floor. - constexpr int kMinW = 560; - constexpr int kMinH = 460; - if (!rect) return kResultFalse; - if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW; - if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH; - return kResultTrue; -} - -#ifdef _WIN32 - -void ReaSamplerEditor::invalidate() { - if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); -} - -void ReaSamplerEditor::attachedToParent() { - HWND parent = static_cast(systemWindow); - if (!parent) return; - - HINSTANCE hInst = - reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); - if (!hInst) hInst = GetModuleHandle(nullptr); - - static bool classRegistered = false; - if (!classRegistered) { - WNDCLASSW wc{}; - wc.lpfnWndProc = &ReaSamplerEditor::wndProc; - wc.hInstance = hInst; - wc.lpszClassName = kChildClassName; - wc.hCursor = LoadCursor(nullptr, IDC_ARROW); - wc.style = CS_HREDRAW | CS_VREDRAW; - RegisterClassW(&wc); - classRegistered = true; - } - - // Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a - // reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on - // editor close: the embed strip in the SAME binary shares the kit's process-global font - // set, so a per-view shutdown could free fonts still in use by the other view. The tiny - // static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note. - kitFontsInit(); - - refreshFromBank(); - - const ViewRect& r = getRect(); - childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, - r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); - if (childHwnd_) { - SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); - // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT - // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show - // the "drop on the panel" affordance instead of the OS bouncing the drop silently. - DragAcceptFiles(childHwnd_, TRUE); - // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's - // lifetime — created here, killed in removedFromParent — so an instance whose editor - // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). - SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr); - // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor - // was closed) or a bank change applies the instant the editor opens, rather than waiting - // up to one timer interval. refreshFromBank above already primed the view; this folds in - // any pending assign/generation so the just-opened editor shows the assigned capture. - onSyncTimer(); - } -} - -void ReaSamplerEditor::removedFromParent() { - if (childHwnd_) { - KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away - DestroyWindow(childHwnd_); - childHwnd_ = nullptr; - } -} - -tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { - tresult res = CPluginView::onSize(newSize); - if (childHwnd_ && newSize) { - MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); - thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them - } - return res; -} - -// The Sample-view (S-VIEW-2) bands. The TITLE band names the plugin + a live readout and hosts -// the Browse/Zone nav buttons at its right; the HERO band is the enlarged waveform + envelope -// overlay; the CLUSTER band is the fenced root strip + preview + channel toggle; the CONTROL band -// is the param panel. Every band is padded 8px horizontally by its consumers. Browse + Zone views -// derive their own areas from `title` + `content` below. -namespace { -constexpr int kPad = 8; - -// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the curve -// in the POPUP (curve_popup), each summoned from its own mini preview button — the Sample -// cluster's and the Zone panel's (the inline Zone box is retired). The INSET keeps node handles -// + the pick radius inside the border so an endpoint at amp 0/1 stays grabbable — the ONE -// curveBoxFromRect grammar the popup derives its mapping box through. -constexpr int kVelCurveInset = 14; // border -> mapping-box inset: keeps endpoint handles + pick radius inside the border -constexpr int kCurveDragOffMargin = 24; // release beyond box+margin -> drag-off delete - -// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial -// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo). -constexpr int kPreviewBtnW = 64; -constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) -constexpr int kCurveBtnSize = 28; // the square curve-preview button - -struct SampleBands { - Rect title; // top: name + Browse/Zone nav buttons - Rect navBrowse; // the "Browse" title-band button - Rect navZone; // the "Zone" title-band button - Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay (r11) - 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) -}; -// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed -// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- -// anchored). When the window is too short for the floor (below the checkSizeConstraint -// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the -// window bottom gracefully. -SampleBands computeSampleBands(int w, int h, int deckH) { - SampleBands b; - const int titleH = (std::min)(kTitleHeight, h); - b.title = Rect{0, 0, w, titleH}; - // Two nav buttons right-anchored in the title band (Browse then Zone). - const int navTop = 2; - const int navBot = (std::max)(navTop, titleH - 2); - const Rect zone{w - kPad - kNavButtonWidth, navTop, w - kPad, navBot}; - const Rect browse{zone.left - 4 - kNavButtonWidth, navTop, zone.left - 4, navBot}; - b.navBrowse = browse; - b.navZone = zone; - - int deckTop = h - kPad - deckH; - int clusterTop = deckTop - kClusterHeight - 4; - int heroBottom = clusterTop - 4; - if (heroBottom - titleH < kHeroMinHeight) { - heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below - clusterTop = heroBottom + 4; - deckTop = clusterTop + kClusterHeight + 4; - } - b.hero = Rect{kPad, titleH, w - kPad, heroBottom}; - b.cluster = Rect{0, clusterTop, w, clusterTop + kClusterHeight}; - b.deck = Rect{kPad, deckTop, w - kPad, deckTop + deckH}; - return b; -} - -// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the right -// side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve preview -// button 28 · Mono|Stereo). Draw + hit-test both derive from this ONE formula. -struct ClusterRects { - Rect rootStrip; // remainder-width fenced root strip - Rect preview; // the preview-trigger button - Rect velCell; // the radial preview-velocity knob cell (knob + label band) - Rect velKnob; // the 28px knob square at the cell's top - Rect velLabel; // the 12px label band beneath it - Rect curveBtn; // the mini curve-preview button (opens the popup) -}; -ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono) { - ClusterRects r; - const int stripTop = cluster.top + (cluster.height() - kStripBandHeight) / 2; - const int stripBot = stripTop + kStripBandHeight; - const int curveTop = cluster.top + (cluster.height() - kCurveBtnSize) / 2; - r.curveBtn = Rect{chanMono.left - kPad - kCurveBtnSize, curveTop, - chanMono.left - kPad, curveTop + kCurveBtnSize}; - r.velCell = Rect{r.curveBtn.left - kPad - kVelCellW, stripTop, - r.curveBtn.left - kPad, stripBot}; - const int knobLeft = r.velCell.left + (kVelCellW - kDeckKnobSize) / 2; - r.velKnob = Rect{knobLeft, r.velCell.top, knobLeft + kDeckKnobSize, - r.velCell.top + kDeckKnobSize}; - r.velLabel = Rect{r.velCell.left, r.velKnob.bottom, r.velCell.right, r.velCell.bottom}; - r.preview = Rect{r.velCell.left - kPad - kPreviewBtnW, stripTop, - r.velCell.left - kPad, stripBot}; - r.rootStrip = Rect{cluster.left + kPad, stripTop, r.preview.left - kPad, stripBot}; - return r; -} - -// The Zone-view keyboard strip rect. Zone content sits below the "+ Add Zone" affordance -// (top+4, height 20) with a 12px gap, padded 8px horizontally. All call sites use this formula. -Rect zonesStripArea(const Rect& content) { - const int stripTop = content.top + 4 + 20 + 12; // addR.bottom + 12 - return Rect{content.left + kPad, stripTop, content.right - kPad, - stripTop + kStripBandHeight}; -} - -// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the -// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw + -// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom so -// the legend top tracks the strip bottom without re-inlining the strip arithmetic here. -Rect noteEntryFieldsArea(const Rect& content) { - const int stripBottom = zonesStripArea(content).bottom; - const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom + 8) - return Rect{content.left + 8 + 128, top, content.right - 8, top + 18}; -} - -// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal -// segments left-to-right. An out-of-range index yields an empty rect. -Rect noteEntryFieldRect(const Rect& fields, int f) { - if (f < 0 || f > 2 || fields.width() <= 0) return Rect{}; - const int segW = fields.width() / 3; - const int left = fields.left + f * segW + (f > 0 ? 4 : 0); // small inter-field gap - const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW; - return Rect{left, fields.top, right, fields.bottom}; -} - -// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip + -// the one-line selected-zone legend, running to the content bottom. `bands.content` is the -// Zones mode-content area. Both draw + hit-test use this single formula so they never drift. -Rect zonesControlPanel(const Rect& content) { - const Rect strip = zonesStripArea(content); - const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap - return Rect{content.left + kPad, panelTop, content.right - kPad, - content.bottom - 4}; -} - -// FB2 (R11-F2 parity): the Zone panel's per-zone controls render as the SAME knob deck the -// Sample face uses. The deck lays out from the panel top (top-anchored — the Zone panel reads -// top-down, unlike the Sample face's bottom-anchored band), with a column at the panel's right -// reserved for the mini curve-preview button so no deck row starts inside it (layoutDeck places -// the first group of each row unconditionally; collision avoidance relies on the available -// width margin in practice). Both draw + hit-test derive from these two formulas so they never -// drift. -Rect zonesDeckArea(const Rect& content) { - const Rect panel = zonesControlPanel(content); - return Rect{panel.left, panel.top, panel.right - kCurveBtnSize - kPad, panel.bottom}; -} -// The Zone panel's mini curve-preview button (opens the SAME popup editor as the Sample -// cluster's button): the cluster's 28px square, right-anchored at the panel top. -Rect zonesCurveButton(const Rect& content) { - const Rect panel = zonesControlPanel(content); - return Rect{panel.right - kCurveBtnSize, panel.top, panel.right, panel.top + kCurveBtnSize}; -} - -// The pure-module mapping Box for a drawn curve rect: inset from the border so node handles and -// the pick radius stay inside the box. Every consumer (paint, hit-test, add, drag) derives the -// Box through this ONE formula, so drawn nodes and grabs can never drift apart. -VelocityCurve::Box curveBoxFromRect(const Rect& r) { - return VelocityCurve::Box{r.left + kVelCurveInset, r.top + kVelCurveInset, - (std::max)(0, r.width() - 2 * kVelCurveInset), - (std::max)(0, r.height() - 2 * kVelCurveInset)}; -} - -// The S7 mono/stereo toggle (S-VIEW-2: moved here from Browse to the Sample cluster band — it is -// a per-capture output-mode concern, not a choosing concern). A two-segment control right-anchored -// in `area` and vertically centered. Returns {mono-segment, stereo-segment}, each kChanSegW wide, -// kChanSegH tall, side by side. -constexpr int kChanSegW = 52; -constexpr int kChanSegH = 18; -struct ChannelToggleRects { Rect mono; Rect stereo; }; -ChannelToggleRects channelToggleRects(const Rect& area) { - const int top = area.top + (area.height() - kChanSegH) / 2; - const int right = area.right - kPad; - const Rect stereo{right - kChanSegW, top, right, top + kChanSegH}; - const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH}; - return {mono, stereo}; -} - -// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle map; -// this turns it into LICE calls through the kit's palette roles. LICE's arc convention matches -// param_slider's (angle 0 = 12 o'clock, positive clockwise: point = (cx + r*sin(a), cy - -// r*cos(a)), verified in vendor/WDL lice_arc.cpp) — but LICE takes RADIANS, and drawing the -// 7->5 o'clock sweep THROUGH the top needs a continuous angle span, so the degrees convert as -// (deg - 360) * pi/180, mapping 210..510 onto -150..+150 degrees. One conversion, both arcs. -void drawKnobFace(LICE_IBitmap* bmp, const Rect& knobRect, double value01, - InteractionState st) { - const KnobGeometry kg = computeKnob(knobRect); - if (kg.radius <= 1.0) return; - constexpr double kDegToRad = 3.14159265358979323846 / 180.0; - const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep - const float cx = static_cast(kg.centerX); - const float cy = static_cast(kg.centerY); - const float rOuter = static_cast(kg.radius) - 0.5f; - const bool disabled = (st == InteractionState::Disabled); - const bool hot = (st == InteractionState::Dragging || st == InteractionState::Hover); - - // Face: a filled circle in the cell surface color under the interaction state. - LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(roleColorState(Role::BgCell, st)), - 1.0f, 0, true); - // Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare). - const float a0 = static_cast((arc.startDeg - 360.0) * kDegToRad); - const float a1 = static_cast((arc.startDeg + knobSweepDeg(arc) - 360.0) * kDegToRad); - LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(roleColor(Role::LineHairline)), 1.0f, 0, true); - // Value arc: start -> the value's angle, in the live accent (hot while under the pointer / - // dragging, dim when disabled). - const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01); - if (v > 0.0) { - const float av = static_cast( - (arc.startDeg + v * knobSweepDeg(arc) - 360.0) * kDegToRad); - const Role valueRole = disabled ? Role::TextDim - : (hot ? Role::AccentHot : Role::AccentPrimary); - LICE_Arc(bmp, cx, cy, rOuter, a0, av, toLice(roleColor(valueRole)), 1.0f, 0, true); - } - // Needle: from ~35% radius out to the rim at the value's angle. - const KnobPoint tip = knobNeedlePoint(kg, arc, v); - const float ix = cx + static_cast((tip.x - kg.centerX) * 0.35); - const float iy = cy + static_cast((tip.y - kg.centerY) * 0.35); - const Role needleRole = disabled ? Role::TextDim : Role::TextPrimary; - LICE_Line(bmp, static_cast(ix + 0.5f), static_cast(iy + 0.5f), - static_cast(tip.x + 0.5f), static_cast(tip.y + 0.5f), - toLice(roleColor(needleRole)), 1.0f, 0, true); -} - -// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface. -// Fills each MIDI key column with its spectral hue (spectralColor over note/127), then draws -// faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip -// so both read as the same spectrum. `stripArea` is the absolute strip rect. -void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) { - if (stripArea.width() <= 0 || stripArea.height() <= 0) return; - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - const int sx = stripArea.left; - const int sy = stripArea.top; - const int h = stripArea.height(); - // A pastel spectral column per key. Each key's local x from keyRect; fill from this key's - // left to the next key's left so the sweep tiles with no gaps. Low alpha keeps it a quiet - // backdrop the root/zone marks sit over. S-VIEW-7: OVERLAY the two-tone piano-key pattern — - // naturals (white keys) keep the bright spectral fill; accidentals (C#/D#/F#/G#/A#) get a - // dark bg/base wash over the hue, so a glance reads pitch position as a keyboard without - // counting. The pattern is an OVERLAY (not a keyboard shape) per the spec. - const LICE_pixel darkKey = toLice(roleColor(Role::BgBase)); - for (int n = 0; n <= 127; ++n) { - const Rect k = keyRect(sl, n); - const int x0 = k.left + sx; - const int x1 = (n < 127) ? keyRect(sl, n + 1).left + sx : stripArea.right; - const int cw = (std::max)(1, x1 - x0); - const KitColor hue = spectralColor(static_cast(n) / 127.0); - LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0); - if (!isNaturalKey(n)) { - // Darken the accidental over the hue (a semi-opaque bg/base wash) so the black-key - // pattern reads while the spectral tint still shows through. - LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0); - } - } - // Faint per-octave key ticks (hairline role) for orientation. - const LICE_pixel tick = toLice(roleColor(Role::LineHairline)); - for (int n = 0; n <= 127; n += 12) { - const Rect k = keyRect(sl, n); - LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + h, tick, 1.0f, 0, false); - } -} - -// Draw the single-capture root marker on the strip: an accent-primary bar with a soft STATIC -// glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. Never animated. -void drawRootMarker(LICE_IBitmap* bmp, const Rect& stripArea, const StripLayout& sl, int root) { - const int sx = stripArea.left; - const int sy = stripArea.top; - const int h = stripArea.height(); - const Rect marker = rootMarkerRect(sl, root); - const int mw = (std::max)(2, marker.width()); - const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary)); - const LICE_pixel glow = toLice(roleColor(Role::AccentHot)); - // Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse). - LICE_FillRect(bmp, marker.left + sx - 3, sy, mw + 6, h, glow, 0.30f, 0); - LICE_FillRect(bmp, marker.left + sx, sy, mw, h, accent, 1.0f, 0); -} -} // namespace - -void ReaSamplerEditor::paint(HDC hdc) { - RECT cr{}; - GetClientRect(childHwnd_, &cr); - const int w = cr.right - cr.left; - const int h = cr.bottom - cr.top; - if (w <= 0 || h <= 0) return; - - LICE_SysBitmap bmp(w, h); - LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); - - // S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn - // OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the - // modal reads as a sheet layered over the home face (the "picker over the document" grammar). - if (view_ == View::kZone) { - paintZone(&bmp, w, h); - } else { - paintSample(&bmp, w, h); - if (view_ == View::kBrowse) paintBrowse(&bmp, w, h); - } - - // S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window. - // It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn - // LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_). - if (dropHintTicks_ > 0) { - const int bannerTop = (std::min)(kTitleHeight, h); - const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop)); - Rect banner{0, bannerTop, w, bannerTop + bannerH}; - // A transient notice, not the live layer — draw it on the accent-tertiary categorical - // hue with a dark label so it reads as "attention, not action". - fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest); - kitTextCentered(&bmp, banner, - "Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.", - Font::Label, Role::BgBase); - } - - BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); -} - -// A small helper: draw the title band with the live readout + the Browse/Zone nav buttons. Shared -// by the Sample face (nav visible) — Browse/Zone draw their own back button in place of the nav. -namespace { -void drawTitleBand(LICE_IBitmap* bmp, const Rect& title, const std::string& readout) { - fillSurface(bmp, toKitBox(title), Role::BgPanel, InteractionState::Rest); - Rect titleText{title.left + 8, title.top, title.right - 8, title.bottom}; - kitText(bmp, titleText, readout.c_str(), Font::Title, Role::TextPrimary); -} -} // namespace - -void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { - // r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP - // ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it). - const PerformanceZone deckZone = effectiveSampleZone(); - const std::vector deckDescs = deckGroupDescs(deckZone.play); - const SampleBands bands = - computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); - - // Title: product name + live readout. Standard B palette — the beta channel gets NO distinct - // accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable - // signal. - std::string title = reasampler::vstPluginName(); // channel-derived (S18) - if (processor_ && processor_->bridge().isConnected()) { - // The instance's OWN loaded state outranks bank availability (pS: the bank is a - // browser source, not the instrument's identity) — a self-contained instance names - // its sound (refs displayName fallback) even when the bank snapshot is empty. - if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; - else if (!selectedId_.empty()) - title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]"; - else if (samples_.empty()) title += " [bank empty]"; - else title += " [pick a capture]"; - } else { - title += " [host: no bridge]"; - } - drawTitleBand(bmp, bands.title, title); - - // Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap - // surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw - // it Active (accent-primary) so it reads as "start here". - const bool empty = selectedId_.empty() && map_.zones.empty(); - { - const KitButtonBox box{toKitBox(bands.navBrowse)}; - const InteractionState st = empty ? InteractionState::Active - : (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest); - drawButton(bmp, box, "Browse", st, /*warn=*/false); - } - { - const KitButtonBox box{toKitBox(bands.navZone)}; - const InteractionState st = - isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Zone", st, /*warn=*/false); - } - - // Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing - // at Browse (which is lit above). No hero waveform / controls to draw. - if (empty) { - Rect body{bands.hero.left, bands.hero.top, bands.hero.right, bands.deck.bottom}; - paintEmptyState(bmp, body); - return; - } - - // Resolve the effective single-capture zone: the picked id's one-zone override when present, - // else the product-default play params (S15-F2 — the single capture is a one-zone map). This - // is the ONE storage site both Sample and Zone edit. - const PerformanceZone& zone = deckZone; - - // --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay ----------- - const std::vector& pcm = monoPcmFor(selectedId_); - const std::int64_t frames = static_cast(pcm.size()); - const Rect waveArea = bands.hero; - fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest); - if (frames > 0 && waveArea.width() > 0) { - // FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this - // multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact - // partition — extra bins produce no visible change. Clamped to frame count below. - const std::int64_t wantBins = - static_cast((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) * - kWaveformOversample; - const std::size_t bins = - static_cast(wantBins < frames ? wantBins : frames); - const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins); - drawEnvelope(bmp, waveArea, env); - - const SetupMarkers m = pickedMarkers(frames); - if (m.hasLoop && m.loopEnd > m.loopStart) { - const int lx = frameToX(waveArea, frames, m.loopStart); - const int rx = frameToX(waveArea, frames, m.loopEnd); - if (rx > lx) { - LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(), - toLice(roleColor(kRoleLoopMarker)), 0.20f, 0); - } - } - const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; - const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; - for (int i = 0; i < 3; ++i) { - const int mx = frameToX(waveArea, frames, markerFrames[i]); - const bool loopMarker = (i != 0); - const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; - LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(), - toLice(roleColor(markerRoles[i])), alpha, 0); - } - - // S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero. - paintEnvelopeOverlay(bmp, waveArea, zone, frames); - } else { - kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim); - } - - // --- Root + preview cluster (r11: remainder-width root strip, preview button, radial - // velocity knob, mini curve-preview button, channel toggle) ----------------------------- - fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest); - const ChannelToggleRects chan = channelToggleRects(bands.cluster); - const ClusterRects cr = clusterRects(bands.cluster, chan.mono); - int root = effectiveRoot(); - if (cr.rootStrip.width() > 0) { - drawSpectralStrip(bmp, cr.rootStrip); - const StripLayout sl = layoutStrip(cr.rootStrip.width(), cr.rootStrip.height()); - drawRootMarker(bmp, cr.rootStrip, sl, root); - } - - // Preview-trigger button (fires the loaded capture at root through the live voice engine). - { - const KitButtonBox box{toKitBox(cr.preview)}; - const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active - : (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest); - drawButton(bmp, box, "Preview", st, /*warn=*/false); - } - // Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same - // persisted previewVelocity seam. Label swaps to the live value during hover/drag. - { - const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2); - const bool hov = isHovered(HoverKind::kVelKnob, -1); - const InteractionState st = dragging ? InteractionState::Dragging - : (hov ? InteractionState::Hover - : InteractionState::Rest); - drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st); - if (dragging || hov) { - char buf[8]; - snprintf(buf, sizeof(buf), "%d", - static_cast(previewVelocity01() * 127.0 + 0.5)); - kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim); - } else { - kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim); - } - } - // The mini curve-preview button (r11): opens the popup editor. Shared painter with the - // Zone panel's button (FB2 — one grammar on both surfaces). - paintCurveButton(bmp, cr.curveBtn, zone); - // Mono | Stereo output-mode toggle. - { - const bool isStereo = (channelMode_ == ChannelMode::Stereo); - const InteractionState monoState = !isStereo ? InteractionState::Active - : (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest); - const InteractionState stereoState = isStereo ? InteractionState::Active - : (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover : InteractionState::Rest); - fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState); - fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState); - kitTextCentered(bmp, chan.mono, "Mono", Font::Label, !isStereo ? Role::BgBase : Role::TextPrimary); - kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary); - } - - // --- The knob deck (r11: the fenced control groups, bottom-anchored) ------------------- - paintKnobDeck(bmp, bands.deck, zone, deckDescs); - - // --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ---- - if (curvePopupOpen_) paintCurvePopup(bmp, w, h); -} - -void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, - const PerformanceZone& zone, std::int64_t frames) { - if (frames <= 0 || waveArea.width() <= 0 || waveArea.height() <= 0) return; - const double rate = liveSampleRate(); - if (rate <= 0.0) return; - const double totalSeconds = static_cast(frames) / rate; - const std::int64_t startFrame = zone.startPoint.value_or(0); - const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); - const std::vector poly = buildEnvelopePolyline(env, waveArea, totalSeconds); - - // Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct - // curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right). - const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); - for (std::size_t i = 1; i < poly.size(); ++i) { - const int x0 = (std::max)(waveArea.left, (std::min)(waveArea.right - 1, poly[i - 1].x)); - const int x1 = (std::max)(waveArea.left, (std::min)(waveArea.right - 1, poly[i].x)); - LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true); - } - // Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw- - // only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is - // in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd - // at area.right-1 MUST get handles); the handle SQUARE is additionally clamped inside the - // hero rect so a 6px box on an edge node never overhangs into the neighbouring bands. - const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); - const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); - for (const EnvVertex& v : poly) { - if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue; - const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); - const int r = 3; - const int hx = (std::max)(waveArea.left + r, (std::min)(waveArea.right - 1 - r, v.x)); - const int hy = (std::max)(waveArea.top + r, (std::min)(waveArea.bottom - 1 - r, v.y)); - LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0); - } -} - -void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, - const PerformanceZone& zone) { - if (r.width() <= 0 || r.height() <= 0) return; // defensive (degenerate rect) - - // The bordered box: a panel surface + hairline border, drawn by palette role. No corner - // caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the - // popup is the only host). - fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest); - LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, - toLice(roleColor(Role::LineHairline)), 1.0f, 0); - - const VelocityCurve::Box box = curveBoxFromRect(r); - if (box.width <= 0 || box.height <= 1) return; - const VelocityCurve& curve = zone.velocityCurve; - - // Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical - // secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity - // and amp -> y mappings both go through the pure module so the trace, the node handles, and - // the hit-test all share one coordinate system. - const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); - int prevX = 0, prevY = 0; - for (int px = 0; px <= box.width; ++px) { - const int cx = box.left + px; - const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity; - const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y; - if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true); - prevX = cx; - prevY = cy; - } - - // Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted - // to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor - // has passed kCurveDragOffMargin outside the box — release will delete the node). - const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); - const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); - const LICE_pixel handleWarn = toLice(roleColor(Role::Warn)); - // Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin? - const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.left == r.left && - dragCurveRect_.top == r.top) && - (dragCurX_ < r.left - kCurveDragOffMargin || - dragCurX_ > r.right + kCurveDragOffMargin || - dragCurY_ < r.top - kCurveDragOffMargin || - dragCurY_ > r.bottom + kCurveDragOffMargin); - for (std::size_t i = 0; i < curve.points().size(); ++i) { - const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]); - const bool grabbed = (drag_ == DragKind::kCurveNode && - curvePointIndex_ == static_cast(i)); - const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast(i)); - // A grabbed node in drag-off territory draws warn to signal "release will delete." - const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn - : (hot ? handleHot : handle); - const int nr = 3; - LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0); - } -} - -void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, - const PerformanceZone& zone, - const std::vector& descs) { - if (deckArea.width() <= 0 || deckArea.height() <= 0) return; - const DeckLayout dl = layoutDeck(descs, deckArea.left, deckArea.top, deckArea.width()); - const ZonePlaySeconds& play = zone.play; - const bool isMono = (voiceMode_ == VoiceMode::Mono); - const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); - - // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled - // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. - const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1, - bool seg1Active, bool disabled) { - const bool hov = !disabled && isHovered(HoverKind::kControl, t.id); - const InteractionState st0 = - disabled ? InteractionState::Disabled - : (!seg1Active ? InteractionState::Active - : (hov ? InteractionState::Hover : InteractionState::Rest)); - const InteractionState st1 = - disabled ? InteractionState::Disabled - : (seg1Active ? InteractionState::Active - : (hov ? InteractionState::Hover : InteractionState::Rest)); - fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0); - fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1); - kitTextCentered(bmp, t.seg0, s0, Font::Micro, - disabled ? Role::TextDim - : (!seg1Active ? Role::BgBase : Role::TextPrimary)); - kitTextCentered(bmp, t.seg1, s1, Font::Micro, - disabled ? Role::TextDim - : (seg1Active ? Role::BgBase : Role::TextPrimary)); - }; - - // The knob's short name label (swapped for the live value during hover/drag — r11: no - // third line, no permanent value clutter). - const auto knobName = [](ParamControl c) -> const char* { - switch (c) { - case ParamControl::kAttack: return "Attack"; - case ParamControl::kHold: return "Hold"; - case ParamControl::kDecay: return "Decay"; - case ParamControl::kSustain: return "Sustain"; - case ParamControl::kRelease: return "Release"; - case ParamControl::kTrigFadeIn: return "Fade In"; - case ParamControl::kTrigLength: return "Len %"; - case ParamControl::kTrigFadeOut: return "Fade Out"; - case ParamControl::kKeyTrack: return "Key Trk"; - case ParamControl::kPitchEnvAttack: return "P.Att"; - case ParamControl::kPitchEnvDecay: return "P.Dec"; - case ParamControl::kPitchEnvDepth: return "P.Depth"; - case ParamControl::kVoiceCount: return "Voices"; - case ParamControl::kMasterGain: return "Gain"; - default: return ""; - } - }; - - for (const DeckGroupLayout& g : dl.groups) { - // The fence: a bg/panel box with a hairline border, caption micro-caps left. - fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest); - LICE_DrawRect(bmp, g.box.left, g.box.top, g.box.width() - 1, g.box.height() - 1, - hairline, 1.0f, 0); - const char* caption = ""; - switch (g.id) { - case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; - case kGroupPitch: caption = "PITCH"; break; - case kGroupPitchEnv: caption = "PITCH ENV"; break; - case kGroupVoice: caption = "VOICE"; break; - case kGroupMaster: caption = "MASTER"; break; - default: break; - } - kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); - - // The compact caption toggle (r11: right-anchored IN the caption row, never full-width). - if (g.captionToggle.id >= 0) { - switch (static_cast(g.captionToggle.id)) { - case ParamControl::kPlayMode: - drawToggle(g.captionToggle, "Gate", "Trigger", - play.playMode == PlayMode::Trigger, false); - break; - case ParamControl::kPitchEngine: - drawToggle(g.captionToggle, "Varisp", "Presrv", - play.pitchEngine == PitchEngine::Preserve, false); - break; - case ParamControl::kPitchEnvEnable: - drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false); - break; - case ParamControl::kVoiceMode: - drawToggle(g.captionToggle, "Poly", "Mono", isMono, false); - break; - default: break; - } - } - // The row toggle (VOICE group's Retrig|Legato) — live only in Mono. - if (g.rowToggle.id >= 0) { - drawToggle(g.rowToggle, "Retrig", "Legato", - monoTrigger_ == MonoTrigger::Legato, !isMono); - } - - // The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off — - // stable geometry (r11). - for (const DeckCellLayout& c : g.cells) { - if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares) - const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled); - const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id); - const bool hov = !disabled && isHovered(HoverKind::kControl, c.id); - const InteractionState st = - disabled ? InteractionState::Disabled - : (dragging ? InteractionState::Dragging - : (hov ? InteractionState::Hover : InteractionState::Rest)); - drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st); - const std::string label = (dragging || hov) - ? deckValueLabel(c.id, zone) - : std::string(knobName(static_cast(c.id))); - kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim); - } - } -} - -void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, - const PerformanceZone& zone) { - if (r.width() <= 0 || r.height() <= 0) return; - // The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone - // panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced - // in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE - // (accent-primary border) while its popup is open, and re-renders live as the popup - // edits the curve (same zone, re-read each paint). - const bool hov = isHovered(HoverKind::kCurveButton, -1); - fillSurface(bmp, toKitBox(r), Role::BgCell, - hov ? InteractionState::Hover : InteractionState::Rest); - const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary) - : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, toLice(border), 1.0f, 0); - const VelocityCurve& curve = zone.velocityCurve; - const int inset = 3; - const VelocityCurve::Box mini{r.left + inset, r.top + inset, r.width() - 2 * inset, - r.height() - 2 * inset}; - if (mini.width > 1 && mini.height > 1) { - const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary)); - int prevX = 0, prevY = 0; - for (int px = 0; px <= mini.width; ++px) { - const int mx = mini.left + px; - const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity; - const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y; - if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true); - prevX = mx; - prevY = my; - } - } -} - -void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) { - // The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the - // Sample face stays legible behind it), then the centered sheet. - LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0); - const CurvePopupLayout pl = computeCurvePopup(w, h); - fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest); - LICE_DrawRect(bmp, pl.sheet.left, pl.sheet.top, pl.sheet.width() - 1, - pl.sheet.height() - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); - kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim); - { - const KitButtonBox box{toKitBox(pl.close)}; - const InteractionState st = isHovered(HoverKind::kPopupClose, -1) - ? InteractionState::Hover - : InteractionState::Rest; - drawButton(bmp, box, "x", st, /*warn=*/false); - } - // The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so - // trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() — - // the picked capture's one-zone site on the Sample face, the selected zone on the Zone - // surface (FB2). - paintVelocityCurve(bmp, pl.curveBox, popupZone()); -} - -PerformanceZone ReaSamplerEditor::popupZone() const { - // The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample - // face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex). - if (view_ == View::kZone && selectedZone_ >= 0 && - selectedZone_ < static_cast(map_.zones.size())) { - return map_.zones[static_cast(selectedZone_)]; - } - return effectiveSampleZone(); -} - -int ReaSamplerEditor::popupZoneIndex() { - // The map_.zones index a popup edit lands on, or -1 when there is no valid target. The - // Zone surface never materializes (the button only shows for an explicit selection); the - // Sample face finds-or-materializes the picked id's one-zone site. - if (view_ == View::kZone) { - return (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) - ? selectedZone_ - : -1; - } - return ensureSampleZone(); -} - -bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) { - // The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home - // (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash - // click dismiss (outside only when no drag is in flight, per the spec); in-box clicks - // route to the shared curve machinery against popupZoneIndex(); anything else on the - // sheet is swallowed. - if (!curvePopupOpen_) return false; - const CurvePopupLayout pl = computeCurvePopup(w, h); - if (contains(pl.close, x, y)) { - curvePopupOpen_ = false; - invalidate(); - return true; - } - if (contains(pl.curveBox, x, y)) { - const int zi = popupZoneIndex(); - if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y); - return true; - } - if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) { - curvePopupOpen_ = false; - invalidate(); - } - return true; -} - -void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) { - if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; - const VelocityCurve::Box box = curveBoxFromRect(r); - if (box.width <= 0 || box.height <= 1) return; - PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; - - int idx = z.velocityCurve.pointAtPixel(box, x, y); - - // Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once - // (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op). - if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) { - if (z.velocityCurve.deletePoint(static_cast(idx))) { - selectedZone_ = zoneIndex; - commitAndReload(); - } - return; - } - - // Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight - // ADD (mirror of the other map-editing drags' dragStartMap_ contract). - dragStartMap_ = map_; - - // Empty-space click inside the MAPPING BOX: add a control point at the cursor via the pure - // inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller - // gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the - // caption band — must not add a point; a click there would clamp to velocity 0/127 and - // produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab - // an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is - // handled above; only the add path is box-gated here. - if (idx < 0) { - const bool inBox = (x >= box.left && x < box.left + box.width && - y >= box.top && y < box.top + box.height); - if (inBox) { - const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y); - idx = static_cast(z.velocityCurve.addPoint(p.velocity, p.amp)); - } - } - - if (idx < 0) return; // ring click with no node hit — nothing to grab - - drag_ = DragKind::kCurveNode; - curvePointIndex_ = idx; - dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base - dragCurveRect_ = r; - dragCurveZone_ = zoneIndex; - dragStartX_ = x; - dragStartY_ = y; - selectedZone_ = zoneIndex; - invalidate(); // live feedback; the commit lands on WM_LBUTTONUP -} - -void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { - // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from - // a bank filter that hides everything. Either way it is the "pick a capture" empty state. - const char* msg = samples_.empty() - ? "No captures in this project yet - capture audio into the bank to play it here." - : "No captures in this bank filter. Choose another bank tab above."; - // Split the area so the primary line sits centered and the S13 ingest affordance sits just - // below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept - // discoverable here regardless of whether a drop ever lands on THIS window. - Rect primary{area.left, area.top, area.right, area.top + area.height() / 2}; - Rect hint{area.left, primary.bottom, area.right, area.bottom}; - kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim); - kitTextCentered(bmp, hint, - "To add a sample: drop a file onto the ReaSampler bank panel (the docked window).", - Font::Micro, Role::TextDim); -} - -// The Browse-modal (S-VIEW-5) top-level regions: a title band with a Back button, the search box, -// the browser sub-area (tabs + card grid), and a footer with Cancel / Load-confirm. The picker -// covers the full window (F3 resolved: full-window overlay). Both draw + hit-test derive from this -// single layout so they never drift. `content` is the sub-area layoutBrowser lays out over. -namespace { -struct BrowseModal { - Rect title; - Rect back; // the "Back" title-band button - Rect search; // the type-to-filter box (absolute) - Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin - Rect cancel; // footer Cancel - Rect confirm; // footer Load (confirm) -}; -constexpr int kBrowseFooterH = 30; -BrowseModal computeBrowseModal(int w, int h) { - BrowseModal m; - const int titleH = (std::min)(kTitleHeight, h); - m.title = Rect{0, 0, w, titleH}; - m.back = Rect{w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)}; - // Search box below the title, spanning the width (searchBoxRect lays it out from 0). - const Rect sb = searchBoxRect(w); - m.search = Rect{kPad, titleH, w - kPad, titleH + sb.height()}; - const int footerTop = (std::max)(m.search.bottom, h - kBrowseFooterH); - m.content = Rect{0, m.search.bottom, w, footerTop}; - // Footer: Cancel (left) + Load (right). - const int fTop = footerTop + 3; - const int fBot = (std::max)(fTop, h - 3); - m.cancel = Rect{kPad, fTop, kPad + 90, fBot}; - m.confirm = Rect{w - kPad - 90, fTop, w - kPad, fBot}; - return m; -} -} // namespace - -void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { - // A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying - // Sample face with a bg/base wash, then draw the picker opaque on top. - LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0); - const BrowseModal bm = computeBrowseModal(w, h); - - // Title band + Back button (returns to Sample, discarding any pending pick). - drawTitleBand(bmp, bm.title, "Browse - pick a capture"); - { - const KitButtonBox box{toKitBox(bm.back)}; - const InteractionState st = - isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Back", st, /*warn=*/false); - } - - // Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover. - const Rect searchAbs = bm.search; - const InteractionState searchState = - searchFocused_ ? InteractionState::Focus - : (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover - : InteractionState::Rest); - fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState); - if (searchFocused_) { - LICE_DrawRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width() - 1, - searchAbs.height() - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0); - } - { - std::string sb = searchQuery_.empty() - ? std::string("Search captures...") - : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); - Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom}; - kitText(bmp, sbText, sb.c_str(), Font::Label, - searchQuery_.empty() ? Role::TextDim : Role::TextPrimary); - } - - // Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset). - const Rect browserArea = bm.content; - const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); - const int ox = browserArea.left; - const int oy = browserArea.top; - scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); - - const int tabCount = static_cast(banks_.size()) + 1; - for (int i = 0; i < tabCount; ++i) { - Rect t = filterTabRect(bl, tabCount, i); - t = Rect{t.left + ox, t.top + oy, t.right + ox, t.bottom + oy}; - const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; - const bool active = (i == 0) ? activeFilterBankId_.empty() - : (banks_[static_cast(i - 1)].id == activeFilterBankId_); - const InteractionState state = - active ? InteractionState::Active - : (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover - : InteractionState::Rest); - fillSurface(bmp, toKitBox(t), Role::BgCell, state); - kitTextCentered(bmp, t, label.c_str(), Font::Label, - active ? Role::BgBase : Role::TextPrimary); - } - - // Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_) - // is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border. - const int bins = thumbBins(bl); - const int cardCount = static_cast(visible_.size()); - const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); - for (int i = vr.first; i < vr.last; ++i) { - Rect content = cardContentRect(bl, i); - Rect thumb = cardThumbnailRect(bl, i); - Rect labelR = cardLabelRect(bl, i); - content = Rect{content.left + ox, content.top + oy - scrollOffset_, - content.right + ox, content.bottom + oy - scrollOffset_}; - thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_, - thumb.right + ox, thumb.bottom + oy - scrollOffset_}; - labelR = Rect{labelR.left + ox, labelR.top + oy - scrollOffset_, - labelR.right + ox, labelR.bottom + oy - scrollOffset_}; - - const SampleChoice& s = visible_[static_cast(i)]; - const bool pending = (s.id == browsePendingId_); - const bool loaded = (s.id == selectedId_); - const InteractionState cardState = - isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest; - fillSurface(bmp, toKitBox(content), Role::BgCell, cardState); - const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary) - : (loaded ? roleColor(Role::AccentTertiary) - : roleColor(Role::LineHairline)); - LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1, - toLice(cardBorder), 1.0f, 0); - drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); - - std::string caption = s.displayName.empty() ? s.id : s.displayName; - Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2}; - Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom}; - kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary); - std::string badge; - if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); - else if (s.key) badge = *s.key; - else badge = "root -"; - kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim); - } - - // Scrollbar thumb. - { - const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); - if (thumb.height() > 0) { - const bool dragging = (drag_ == DragKind::kScrollThumb); - const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary); - LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(), - toLice(tc), 0.8f, 0); - } - } - - if (visible_.empty()) paintEmptyState(bmp, browserArea); - - // Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert - // (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar. - Rect footer{0, bm.content.bottom, w, h}; - fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest); - { - const KitButtonBox box{toKitBox(bm.cancel)}; - const InteractionState st = - isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Cancel", st, /*warn=*/false); - } - { - const KitButtonBox box{toKitBox(bm.confirm)}; - const bool armed = !browsePendingId_.empty(); - const InteractionState st = armed - ? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active) - : InteractionState::Rest; - drawButton(bmp, box, "Load", st, /*warn=*/false); - } -} - -// The Zone-view (S-VIEW-8) content area: the whole window below the title band. -namespace { -Rect zoneContentArea(int w, int h) { - const int titleH = (std::min)(kTitleHeight, h); - return Rect{0, titleH, w, h}; -} -} // namespace - -void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { - // Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns - // to the Sample home on close. - const Rect title{0, 0, w, (std::min)(kTitleHeight, h)}; - drawTitleBand(bmp, title, "Zone - keyboard map"); - { - const Rect back{w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, title.bottom - 2)}; - const KitButtonBox box{toKitBox(back)}; - const InteractionState st = - isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Back", st, /*warn=*/false); - } - - const Rect content = zoneContentArea(w, h); - const int pad = 8; - - // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip - // with one bar per zone. Delete is a small × on the selected zone (keystroke also). - Rect addR{content.left + pad, content.top + 4, content.left + pad + 96, - content.top + 4 + 20}; - { - const KitButtonBox box{toKitBox(addR)}; - const InteractionState state = - isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false); - } - - Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; - if (selectedZone_ >= 0) { - const KitButtonBox box{toKitBox(delR)}; - const InteractionState state = - isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest; - // Deleting a zone is not a byte-destroying act (no file removed — the bank is - // read-only here), so it is a normal button, not `warn`. - drawButton(bmp, box, "Delete", state, /*warn=*/false); - } - - // The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per - // zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which - // zone is live"); the rest take the categorical secondary hue at low alpha. - const Rect stripArea = zonesStripArea(content); - drawSpectralStrip(bmp, stripArea); - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - const int sx = stripArea.left; - const int sy = stripArea.top; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - const PerformanceZone& z = map_.zones[static_cast(i)]; - Rect bar = zoneBarRect(sl, z.lowNote, z.highNote); - const int bw = (std::max)(2, bar.width()); - const bool sel = (i == selectedZone_); - if (sel) { - // Static glow halo behind the live zone, then the crisp accent-primary bar. - LICE_FillRect(bmp, bar.left + sx - 2, sy, bw + 4, stripArea.height(), - toLice(roleColor(Role::AccentHot)), 0.30f, 0); - LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(), - toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); - } else { - LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(), - toLice(roleColor(Role::AccentSecondary)), 0.55f, 0); - } - } - - // A one-line legend of the selected zone below the strip, with three click-to-type numeric - // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it - // (entryField_) and typed text commits via parseNoteEntry on Enter. - const int legendTop = stripArea.bottom + 8; - Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18}; - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - kitText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom}, - sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{}, - z.sampleId) - .c_str(), - Font::Label, Role::TextPrimary); - // Three fields laid out left-to-right after the sample label. A focused field lifts to - // the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter. - const Rect fields = noteEntryFieldsArea(content); - const char* names[3] = {"Low", "High", "Root"}; - const std::string vals[3] = { - noteLabel(z.lowNote), noteLabel(z.highNote), - z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")}; - for (int f = 0; f < 3; ++f) { - const Rect fr = noteEntryFieldRect(fields, f); - const bool editing = (entryField_ == f); - fillSurface(bmp, toKitBox(fr), Role::BgCell, - editing ? InteractionState::Focus : InteractionState::Rest); - const KitColor border = - editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline); - LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1, - toLice(border), 1.0f, 0); - std::string cap = std::string(names[f]) + ": " + - (editing ? (entryText_ + "_") : vals[f]); - kitText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(), - Font::ValueMono, Role::TextPrimary); - } - } else if (map_.zones.empty()) { - kitText(bmp, infoR, - "No zones. Add Zone maps the picked capture across the keyboard.", - Font::Label, Role::TextDim); - } - - // The per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck + - // curve-preview-button/popup grammar as the Sample face — one control language over the one - // storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only - // the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live - // on the Sample deck only. - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play)); - paintCurveButton(bmp, zonesCurveButton(content), z); - } - - // The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST — - // the same modal grammar as the Sample face. - if (curvePopupOpen_) paintCurvePopup(bmp, w, h); -} - -// --- Hover resolution (Phase L, L3) ------------------------------------------ -// -// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an -// idle move is free — the "sub-frame feedback, zero cost when nothing changed" discipline). -// Mirrors onMouseDown's hit-test order, but read-only: it never mutates selection/map. Only -// the frequently-touched interactive surfaces light on hover; a purely decorative region -// resolves to kNone (clearing any prior hover). Windows-only. -void ReaSamplerEditor::resolveHover(int x, int y) { - HoverTarget h; // kNone by default - RECT cr{}; - GetClientRect(childHwnd_, &cr); - const int w = cr.right - cr.left; - const int hgt = cr.bottom - cr.top; - - if (view_ == View::kBrowse) { - const BrowseModal bm = computeBrowseModal(w, hgt); - if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1}; - else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1}; - else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1}; - else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1}; - else { - const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height()); - const int bx = x - bm.content.left; - const int by = y - bm.content.top; - const int tabCount = static_cast(banks_.size()) + 1; - const int tab = filterTabHitTest(bl, tabCount, bx, by); - const int card = (tab >= 0) - ? -1 - : cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); - if (tab >= 0) h = {HoverKind::kFilterTab, tab}; - else if (card >= 0) h = {HoverKind::kCard, card}; - } - } else if (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2) - const CurvePopupLayout pl = computeCurvePopup(w, hgt); - if (contains(pl.close, x, y)) { - h = {HoverKind::kPopupClose, -1}; - } else if (contains(pl.curveBox, x, y)) { - // A curve node under the pointer lights accent-hot. - const int idx = - popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y); - if (idx >= 0) h = {HoverKind::kCurveNode, idx}; - } - } else if (view_ == View::kZone) { - const Rect back{w - kPad - kNavButtonWidth, 2, - w - kPad, (std::max)(2, (std::min)(kTitleHeight, hgt) - 2)}; - const Rect content = zoneContentArea(w, hgt); - Rect addR{content.left + kPad, content.top + 4, content.left + kPad + 96, content.top + 4 + 20}; - Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; - if (contains(back, x, y)) { - h = {HoverKind::kBack, -1}; - } else if (contains(addR, x, y)) { - h = {HoverKind::kAddZone, -1}; - } else if (selectedZone_ >= 0 && contains(delR, x, y)) { - h = {HoverKind::kDeleteZone, -1}; - } else if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - // FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's - // hover grammar — knobs light + swap label->value). - if (contains(zonesCurveButton(content), x, y)) { - h = {HoverKind::kCurveButton, -1}; - } else { - const ZonePlaySeconds& play = - map_.zones[static_cast(selectedZone_)].play; - const Rect deckArea = zonesDeckArea(content); - const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.left, - deckArea.top, deckArea.width()); - const DeckHit dh = hitTestDeck(dl, x, y); - if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; - } - } - } else { // Sample view (home, r11 recomposition) - const PerformanceZone zone = effectiveSampleZone(); - const std::vector descs = deckGroupDescs(zone.play); - const SampleBands bands = - computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad)); - if (contains(bands.navBrowse, x, y)) { - h = {HoverKind::kNavBrowse, -1}; - } else if (contains(bands.navZone, x, y)) { - h = {HoverKind::kNavZone, -1}; - } else if (selectedId_.empty() && map_.zones.empty()) { - // Empty state — no interactive surfaces beyond the nav. - } else { - const ChannelToggleRects chan = channelToggleRects(bands.cluster); - const ClusterRects cr = clusterRects(bands.cluster, chan.mono); - if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1}; - else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1}; - else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1}; - else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1}; - else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1}; - else if (contains(bands.deck, x, y)) { - // A deck knob/toggle under the pointer: knobs light + swap label->value. - const DeckLayout dl = - layoutDeck(descs, bands.deck.left, bands.deck.top, bands.deck.width()); - const DeckHit dh = hitTestDeck(dl, x, y); - if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; - } - } - } - - if (h != hover_) { - hover_ = h; - invalidate(); - } -} - -// --- Input: the drag-state machine ------------------------------------------- - -void ReaSamplerEditor::onMouseDown(int x, int y) { - if (!processor_) return; - RECT cr{}; - GetClientRect(childHwnd_, &cr); - const int w = cr.right - cr.left; - const int h = cr.bottom - cr.top; - - // ---- Browse modal (S-VIEW-5): pick + confirm/cancel over the Sample face ---- - if (view_ == View::kBrowse) { - const BrowseModal bm = computeBrowseModal(w, h); - if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) { - // Cancel/Back: discard the pending pick, return to Sample unchanged. - browsePendingId_.clear(); - searchFocused_ = false; - view_ = View::kSample; - invalidate(); - return; - } - if (contains(bm.confirm, x, y)) { - // Load: commit the pending pick (if any) into the loaded selection + reload, then Sample. - if (!browsePendingId_.empty()) { - loadSelection(browsePendingId_); - } - browsePendingId_.clear(); - searchFocused_ = false; - view_ = View::kSample; - invalidate(); - return; - } - if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; } - searchFocused_ = false; - - const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height()); - const int bx = x - bm.content.left; - const int by = y - bm.content.top; - const int tabCount = static_cast(banks_.size()) + 1; - const int tab = filterTabHitTest(bl, tabCount, bx, by); - if (tab >= 0) { - activeFilterBankId_ = (tab == 0) ? std::string() - : banks_[static_cast(tab - 1)].id; - rebuildVisible(); - invalidate(); - return; - } - const Rect thumb = scrollThumbRect(bl, static_cast(visible_.size()), scrollOffset_); - if (thumb.height() > 0 && - contains(Rect{thumb.left + bm.content.left, thumb.top + bm.content.top, - thumb.right + bm.content.left, thumb.bottom + bm.content.top}, x, y)) { - drag_ = DragKind::kScrollThumb; - dragStartY_ = y; - dragStartScrollOffset_ = scrollOffset_; - return; - } - const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); - if (card >= 0) { - // Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card - // is the load accelerator (commit + dismiss). Browse never loads on a single click. - const std::string id = visible_[static_cast(card)].id; - if (lastBrowseClickCard_ == card && browsePendingId_ == id) { - loadSelection(id); - browsePendingId_.clear(); - lastBrowseClickCard_ = -1; - searchFocused_ = false; - view_ = View::kSample; - invalidate(); - } else { - browsePendingId_ = id; - lastBrowseClickCard_ = card; - invalidate(); - } - return; - } - lastBrowseClickCard_ = -1; - return; - } - - // ---- Sample home (S-VIEW-2 / r11) ---- - if (view_ == View::kSample) { - // r11 curve popup: while open the sheet is modal — it owns every left-click. - if (handlePopupMouseDown(w, h, x, y)) return; - - const PerformanceZone probeZone = effectiveSampleZone(); - const std::vector deckDescs = deckGroupDescs(probeZone.play); - const SampleBands bands = - computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); - if (contains(bands.navBrowse, x, y)) { - // Open the Browse modal; seed its pending pick from the loaded id so the current - // capture reads as pre-selected. - browsePendingId_ = selectedId_; - lastBrowseClickCard_ = -1; - view_ = View::kBrowse; - invalidate(); - return; - } - if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; } - if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only - - const ChannelToggleRects chan = channelToggleRects(bands.cluster); - const ClusterRects cr = clusterRects(bands.cluster, chan.mono); - - // Preview-trigger button: fire the loaded capture at its root through the voice engine - // (momentary — note-on on press, note-off on release). - if (contains(cr.preview, x, y)) { - const int note = effectiveRoot(); - if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_); - previewingNote_ = note; - processor_->previewNoteOn(note); - invalidate(); - return; - } - // Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself - // never jumps the value (FA4); the delta from the grab point maps via knobDragValue. - if (contains(cr.velCell, x, y)) { - drag_ = DragKind::kDeckKnob; - dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param) - dragParamZone_ = -1; - dragKnobStartValue_ = previewVelocity01(); - dragStartX_ = x; - dragStartY_ = y; - invalidate(); - return; - } - // The mini curve-preview button: summon the popup editor. - if (contains(cr.curveBtn, x, y)) { - curvePopupOpen_ = true; - invalidate(); - return; - } - // Channel toggle. - if (contains(chan.mono, x, y)) { - channelMode_ = ChannelMode::Mono; - processor_->setChannelMode(ChannelMode::Mono); - invalidate(); - return; - } - if (contains(chan.stereo, x, y)) { - channelMode_ = ChannelMode::Stereo; - processor_->setChannelMode(ChannelMode::Stereo); - invalidate(); - return; - } - - // The knob deck (r11): toggles commit at once (a discrete, final edit — the slider - // precedent); knobs start a grab-anchored vertical drag. The deck band swallows its - // clicks (no fall-through to the hero/markers). - if (contains(bands.deck, x, y)) { - const DeckLayout dl = layoutDeck(deckDescs, bands.deck.left, bands.deck.top, - bands.deck.width()); - const DeckHit hit = hitTestDeck(dl, x, y); - if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { - switch (static_cast(hit.id)) { - case ParamControl::kVoiceMode: { - // Processor-side per-instance param: live setter (engine rebuild via - // the drain-slot swap — tails survive), local snapshot in step. - const VoiceMode m = - (hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly; - if (m != voiceMode_) { - voiceMode_ = m; - processor_->setVoiceMode(m); - } - invalidate(); - break; - } - case ParamControl::kMonoTrigger: { - if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly - const MonoTrigger t = - (hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; - if (t != monoTrigger_) { - monoTrigger_ = t; - processor_->setMonoTrigger(t); - } - invalidate(); - break; - } - default: { - // Zone-param toggles (play mode / pitch engine / pitch-env enable): - // materialize the one-zone site, apply, commit. - const int zi = ensureSampleZone(); - if (zi >= 0) { - applyZoneControl(zi, hit.id, 0.0, hit.segment); - selectedZone_ = zi; - commitAndReload(); - } - break; - } - } - return; - } - if (hit.kind == DeckHitKind::Knob) { - // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off. - const bool pitchEnvKnob = - hit.id == static_cast(ParamControl::kPitchEnvAttack) || - hit.id == static_cast(ParamControl::kPitchEnvDecay) || - hit.id == static_cast(ParamControl::kPitchEnvDepth); - if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return; - if (hit.id == static_cast(ParamControl::kVoiceCount) || - hit.id == static_cast(ParamControl::kMasterGain)) { - // Processor-side knobs: transient live writes, no map edit, no reload. - drag_ = DragKind::kDeckKnob; - dragParamId_ = hit.id; - dragParamZone_ = -1; - dragKnobStartValue_ = deckControlNorm(hit.id, probeZone); - } else { - // Zone-param knobs: live-drag the map, commit on release. - const int zi = ensureSampleZone(); - if (zi < 0) return; - drag_ = DragKind::kDeckKnob; - dragParamId_ = hit.id; - dragParamZone_ = zi; - selectedZone_ = zi; - dragStartMap_ = map_; - dragKnobStartValue_ = - deckControlNorm(hit.id, map_.zones[static_cast(zi)]); - } - dragStartX_ = x; - dragStartY_ = y; - invalidate(); - } - return; - } - - // Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers. - const std::vector& pcm = monoPcmFor(selectedId_); - const std::int64_t frames = static_cast(pcm.size()); - const Rect waveArea = bands.hero; - if (frames > 0) { - const double rate = liveSampleRate(); - if (rate > 0.0) { - const PerformanceZone zone = effectiveSampleZone(); - const std::int64_t startFrame = zone.startPoint.value_or(0); - const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); - const double totalSeconds = static_cast(frames) / rate; - const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y); - if (nh.hit) { - drag_ = DragKind::kEnvNode; - envNode_ = nh.node; - dragStartX_ = x; - dragStartY_ = y; - dragStartEnv_ = env; - dragSampleFrames_ = frames; - dragStartFrame_ = startFrame; - dragStartMap_ = map_; - return; // node moves once the cursor drags - } - } - const SetupMarkers m = pickedMarkers(frames); - const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; - const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y); - if (hit >= 0) { - drag_ = DragKind::kWaveMarker; - waveMarker_ = static_cast(hit); - dragStartX_ = x; - dragStartMarkers_ = m; - dragSampleFrames_ = frames; - dragStartMap_ = map_; - return; - } - } - - // Fenced root strip: grab the root marker (remainder-width since r11). - if (cr.rootStrip.width() > 0) { - const StripLayout sl = layoutStrip(cr.rootStrip.width(), cr.rootStrip.height()); - const int note = keyAtPoint(sl, x - cr.rootStrip.left, y - cr.rootStrip.top); - if (note >= 0) { - drag_ = DragKind::kRootMarker; - dragStartX_ = x; - dragStartRoot_ = note; - dragStartMap_ = map_; - onMouseMove(x, y); // apply the click as the first delta==0 set - return; - } - } - return; - } - - // ---- Zone surface (S-VIEW-8 / FB2) ---- - // The curve popup is modal over the Zone surface too (FB2) — it owns every click while - // open, checked before every Zone affordance (incl. Back). - if (handlePopupMouseDown(w, h, x, y)) return; - const Rect back{w - kPad - kNavButtonWidth, 2, - w - kPad, (std::max)(2, (std::min)(kTitleHeight, h) - 2)}; - if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; } - const Rect content = zoneContentArea(w, h); - const int pad = 8; - Rect addR{content.left + pad, content.top + 4, content.left + pad + 96, - content.top + 4 + 20}; - if (contains(addR, x, y)) { - // Add a narrow default zone for the picked capture (or the first visible sample as a - // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id - // already exists (pre-fix bleed survivor), select it rather than appending a duplicate - // (mirrors the upsert the root-marker drag path already performs). - // NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped - // to [0,127]) so the new zone is immediately "authored" (narrow) and survives - // reconcileSingleCaptureZones without being treated as a Sample-face full-range zone. - std::string seed = !selectedId_.empty() ? selectedId_ - : (!visible_.empty() ? visible_.front().id : std::string()); - if (seed.empty()) return; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - const PerformanceZone& z = map_.zones[static_cast(i)]; - if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) { - selectedZone_ = i; - invalidate(); - return; - } - } - // Look up the seed's root note from the browser list (absent root defaults to 60). - int seedRoot = 60; - for (const SampleChoice& sc : samples_) { - if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; } - } - const int lo = (std::max)(0, seedRoot - 6); - const int hi = (std::min)(127, seedRoot + 5); - PerformanceZone z; - z.sampleId = seed; - z.lowNote = lo; - z.highNote = hi; - map_.zones.push_back(z); - selectedZone_ = static_cast(map_.zones.size()) - 1; - commitAndReload(); - return; - } - Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; - if (selectedZone_ >= 0 && contains(delR, x, y)) { - map_.zones.erase(map_.zones.begin() + selectedZone_); - selectedZone_ = -1; - commitAndReload(); - return; - } - - // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the - // selected zone's root. - const Rect stripArea = zonesStripArea(content); - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - const int lx = x - stripArea.left; - const int ly = y - stripArea.top; - - std::vector lows, highs; - lows.reserve(map_.zones.size()); - highs.reserve(map_.zones.size()); - for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); } - const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(), - highs.empty() ? nullptr : highs.data(), - static_cast(map_.zones.size()), lx, ly); - if (hit.zoneIndex >= 0) { - selectedZone_ = hit.zoneIndex; - const PerformanceZone& z = map_.zones[static_cast(hit.zoneIndex)]; - dragStartX_ = x; - dragStartLow_ = z.lowNote; - dragStartHigh_ = z.highNote; - dragStartMap_ = map_; - switch (hit.grab) { - case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break; - case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break; - case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break; - default: drag_ = DragKind::kNone; break; - } - invalidate(); - return; - } - // A bare key-click inside the strip sets the selected zone's root override. - if (contains(stripArea, x, y) && selectedZone_ >= 0 && - selectedZone_ < static_cast(map_.zones.size())) { - const int note = keyAtPoint(sl, lx, ly); - if (note >= 0) { - map_.zones[static_cast(selectedZone_)].rootOverride = note; - commitAndReload(); - } - return; - } - - // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a - // zone is selected. entryText_ starts empty (the user types the full value). - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - const Rect fields = noteEntryFieldsArea(content); - for (int f = 0; f < 3; ++f) { - if (contains(noteEntryFieldRect(fields, f), x, y)) { - entryField_ = f; - entryText_.clear(); - invalidate(); - return; - } - } - } - entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry - - // The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the - // SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected - // (the Zone surface has no single-capture fallback — that lives on the Sample face). - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - if (contains(zonesCurveButton(content), x, y)) { - curvePopupOpen_ = true; - invalidate(); - return; - } - const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; - const Rect deckArea = zonesDeckArea(content); - const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.left, deckArea.top, - deckArea.width()); - const DeckHit hit = hitTestDeck(dl, x, y); - if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { - // Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete, - // final edit committed at once (the deck precedent). No per-instance ids reach - // here — VOICE/MASTER are not in the zone group set. - applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment); - commitAndReload(); - return; - } - if (hit.kind == DeckHitKind::Knob) { - // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the - // Sample deck's guard, mirrored. - const bool pitchEnvKnob = - hit.id == static_cast(ParamControl::kPitchEnvAttack) || - hit.id == static_cast(ParamControl::kPitchEnvDecay) || - hit.id == static_cast(ParamControl::kPitchEnvDepth); - if (pitchEnvKnob && !play.pitchEnv.enabled) return; - // GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release. - drag_ = DragKind::kDeckKnob; - dragParamId_ = hit.id; - dragParamZone_ = selectedZone_; - dragStartMap_ = map_; - dragKnobStartValue_ = deckControlNorm( - hit.id, map_.zones[static_cast(selectedZone_)]); - dragStartX_ = x; - dragStartY_ = y; - invalidate(); - } - } -} - -void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) { - if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; - PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; - if (id == static_cast(ParamControl::kKeyTrack)) { - // keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1. - z.keyTrack = clamp01(value) * kKeyTrackMax; - } else { - applyControl(id, z.play, value, segment); - } -} - -void ReaSamplerEditor::onMouseMove(int x, int y) { - if (drag_ == DragKind::kNone) return; - dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn) - dragCurY_ = y; - RECT rc{}; - GetClientRect(childHwnd_, &rc); - const int w = rc.right - rc.left; - const int h = rc.bottom - rc.top; - const int dx = x - dragStartX_; - - if (drag_ == DragKind::kDeckKnob) { - // r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from - // the value at grab (up = increase), so the value tracks relative motion and never - // jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP. - const int dy = y - dragStartY_; - applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy)); - invalidate(); - return; - } - - // r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted - // below the kDeckKnob early-return — that branch uses neither deckDescs nor bands. - const std::vector deckDescs = deckGroupDescs(effectiveSampleZone().play); - const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); - - if (drag_ == DragKind::kRootMarker) { - // The fenced root strip on the Sample cluster band. Setting the root materializes a - // full-keyboard zone carrying the override on the picked id (the D-B override vehicle) — - // upsert by id so a repeated drag edits the same zone rather than stacking duplicates. - const ChannelToggleRects chan = channelToggleRects(bands.cluster); - const Rect stripArea = clusterRects(bands.cluster, chan.mono).rootStrip; - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - const int note = resolveDragNote(sl, dragStartRoot_, dx); - bool found = false; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - PerformanceZone& z = map_.zones[static_cast(i)]; - if (z.sampleId == selectedId_) { - z.rootOverride = note; - selectedZone_ = i; - found = true; - break; - } - } - if (!found) { - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - z.rootOverride = note; - map_.zones.push_back(z); - selectedZone_ = static_cast(map_.zones.size()) - 1; - } - invalidate(); // live feedback; the commit lands on WM_LBUTTONUP - return; - } - - if (drag_ == DragKind::kEnvNode) { - // S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through - // the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the - // picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_) - // so the delta is absolute. Materialize the zone if needed (mirror of the marker path). - const std::int64_t frames = dragSampleFrames_; - const double rate = liveSampleRate(); - if (frames <= 0 || rate <= 0.0) return; - const double totalSeconds = static_cast(frames) / rate; - const int dy = y - dragStartY_; - const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero, - totalSeconds, envClampBounds(), dx, dy); - const int zi = ensureSampleZone(); - if (zi >= 0) { - unpackEnvelope(edited, frames, dragStartFrame_, - map_.zones[static_cast(zi)].play); - selectedZone_ = zi; - } - invalidate(); // live feedback; commit on WM_LBUTTONUP - return; - } - - if (drag_ == DragKind::kCurveNode) { - // S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure - // inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + - // box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the - // commit lands on WM_LBUTTONUP. - if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast(map_.zones.size())) return; - if (curvePointIndex_ < 0) return; - const int dy = y - dragStartY_; - map_.zones[static_cast(dragCurveZone_)].velocityCurve = - VelocityCurve::resolvePointDrag(dragStartCurve_, - static_cast(curvePointIndex_), - curveBoxFromRect(dragCurveRect_), dx, dy); - invalidate(); - return; - } - - if (drag_ == DragKind::kWaveMarker) { - // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap - // it against the decoded PCM, apply the inter-marker clamps, and write the override live. - const Rect waveArea = bands.hero; - const std::int64_t frames = dragSampleFrames_; - if (frames <= 0) return; - - // Grabbed frame at grab time, from the snapshot (so the delta is measured from grab). - const int idx = static_cast(waveMarker_); - const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, - dragStartMarkers_.loopEnd}; - std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx); - - // Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware - // requirement). Pure over the cached mono frames — no host types, no file I/O. - const std::vector& pcm = monoPcmFor(selectedId_); - if (!pcm.empty()) { - newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), - newFrame); - } - - // Build the edited marker set from the snapshot, moving only the grabbed marker, then - // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop. - SetupMarkers m = dragStartMarkers_; - if (waveMarker_ == WaveMarker::kStart) { - m.start = newFrame; - } else if (waveMarker_ == WaveMarker::kLoopStart) { - m.loopStart = (std::min)(newFrame, m.loopEnd); - m.hasLoop = true; - } else { // kLoopEnd - m.loopEnd = (std::max)(newFrame, m.loopStart); - m.hasLoop = true; - } - if (m.start < 0) m.start = 0; - if (m.start > frames - 1) m.start = frames - 1; - - // Upsert the override on the picked id (mirror of the root-marker path); commit lands on - // release, this is live feedback. Set selectedZone_ so the control panel stays visible - // after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty - // map hides controls after the first marker drag on the single-capture face). - selectedZone_ = upsertPickedOverride(m); - invalidate(); - return; - } - - if (drag_ == DragKind::kScrollThumb) { - // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag - // only happens in the Browse modal (the sole card grid). The visible-card window recomputes - // at paint from scrollOffset_. - const int dyThumb = y - dragStartY_; - const BrowseModal bm = computeBrowseModal(w, h); - const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height()); - scrollOffset_ = thumbDragToOffset(bl, static_cast(visible_.size()), - dragStartScrollOffset_, dyThumb); - invalidate(); - return; - } - - // Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached - // in the Zone surface where selectedZone_ is set + the strip lives under its content area. - if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; - const Rect stripArea = zonesStripArea(zoneContentArea(w, h)); - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - if (drag_ == DragKind::kZoneLow) { - z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote); - } else if (drag_ == DragKind::kZoneHigh) { - z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote); - } else if (drag_ == DragKind::kZoneBody) { - // Move the whole span: apply the SAME delta to both edges so the span is preserved, - // clamping so neither edge escapes [0,127] (the span shifts, never shrinks). - const int newLow = resolveDragNote(sl, dragStartLow_, dx); - const int newHigh = resolveDragNote(sl, dragStartHigh_, dx); - const int span = dragStartHigh_ - dragStartLow_; - if (newLow < 0) { z.lowNote = 0; z.highNote = span; } - else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; } - else { z.lowNote = newLow; z.highNote = newHigh; } - } - invalidate(); -} - -void ReaSamplerEditor::onMouseUp(int x, int y) { - // Release a held preview note first (the preview button is a momentary key: note-off on up). - // This runs regardless of drag state — the preview press does not start a drag. - if (previewingNote_ >= 0) { - if (processor_) processor_->previewNoteOff(previewingNote_); - previewingNote_ = -1; - invalidate(); - } - if (drag_ == DragKind::kNone) return; - const DragKind kind = drag_; - const int paramId = dragParamId_; - const int curveIdx = curvePointIndex_; - const int curveZone = dragCurveZone_; - const Rect curveRect = dragCurveRect_; - drag_ = DragKind::kNone; - dragParamId_ = -1; - dragParamZone_ = -1; - curvePointIndex_ = -1; - dragCurveZone_ = -1; - // A scrollbar drag is transient UI (no map change), and the processor-side knobs (the - // preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that - // don't reload the instrument via the map path. Master gain is an atomic the audio thread - // reads directly. Voice count: the label/needle tracks live during the drag but the engine - // rebuild (setVoiceCount) fires ONCE here on release — not per integer step. - const bool deckTransient = - kind == DragKind::kDeckKnob && - (paramId == -2 || paramId == static_cast(ParamControl::kVoiceCount) || - paramId == static_cast(ParamControl::kMasterGain)); - if (kind == DragKind::kScrollThumb || deckTransient) { - // Commit the voice count now that the drag is complete (one rebuild per full drag). - if (deckTransient && processor_ && - paramId == static_cast(ParamControl::kVoiceCount)) - processor_->setVoiceCount(voiceCount_); - invalidate(); - return; - } - // S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the - // dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain - // move — its amp keeps the last clamped drag value). - if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 && - curveZone < static_cast(map_.zones.size())) { - const bool off = x < curveRect.left - kCurveDragOffMargin || - x > curveRect.right + kCurveDragOffMargin || - y < curveRect.top - kCurveDragOffMargin || - y > curveRect.bottom + kCurveDragOffMargin; - if (off) { - map_.zones[static_cast(curveZone)].velocityCurve.deletePoint( - static_cast(curveIdx)); - hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint - } - } - commitAndReload(); -} - -void ReaSamplerEditor::onMouseRDown(int x, int y) { - // r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete - // affordance; Alt-click and drag-off remain as landed alternates. Commits immediately - // through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint - // right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the - // Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) — - // and never during an in-flight left drag. - if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return; - if (drag_ != DragKind::kNone) return; - RECT rc{}; - GetClientRect(childHwnd_, &rc); - const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top); - if (!contains(pl.curveBox, x, y)) return; - // Hit-test first (read-only, via popupZone) so a right-click that lands between nodes - // does not materialize an uncommitted zone in map_. Materialize only on an actual hit. - const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox); - const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y); - if (idx < 0) return; - const int zi = popupZoneIndex(); - if (zi < 0) return; - PerformanceZone& z = map_.zones[static_cast(zi)]; - if (z.velocityCurve.deletePoint(static_cast(idx))) { - selectedZone_ = zi; - hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node - commitAndReload(); - } -} - -void ReaSamplerEditor::onMouseWheel(int delta) { - // Browser scroll (only in the Browse modal — the sole card grid). One wheel notch - // (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive - // delta (wheel up) scrolls toward the top (smaller offset). - if (view_ != View::kBrowse) return; - const int rows = delta / 120; - if (rows == 0) return; - scrollOffset_ -= rows * kBrowserCardHeight; - if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content - invalidate(); -} - -void ReaSamplerEditor::onSearchChar(unsigned int ch) { - // r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face - // or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search - // cannot hold focus under it). - if (curvePopupOpen_ && ch == 27) { - curvePopupOpen_ = false; - invalidate(); - return; - } - - // S12 numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes - // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when - // focused, owns the keystrokes). - if (view_ == View::kZone && entryField_ >= 0) { - if (ch == 13) { // Enter: parse + commit - if (auto note = parseNoteEntry(entryText_)) { - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote); - else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote); - else z.rootOverride = *note; - commitAndReload(); - } - } - entryField_ = -1; - entryText_.clear(); - invalidate(); - } else if (ch == 27) { // Escape cancels - entryField_ = -1; - entryText_.clear(); - invalidate(); - } else if (ch == 8) { // backspace - if (!entryText_.empty()) entryText_.pop_back(); - invalidate(); - } else if (ch >= 32 && ch < 127) { - entryText_.push_back(static_cast(ch)); - invalidate(); - } - return; - } - - // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace - // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search). - if (view_ != View::kBrowse || !searchFocused_) return; - if (ch == 8) { // backspace - if (!searchQuery_.empty()) searchQuery_.pop_back(); - } else if (ch == 27) { // escape clears + defocuses - searchQuery_.clear(); - searchFocused_ = false; - } else if (ch >= 32 && ch < 127) { - searchQuery_.push_back(static_cast(ch)); - } else { - return; // ignore other control chars - } - scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list - rebuildVisible(); - invalidate(); -} - -void ReaSamplerEditor::onFilesDropped(int droppedCount) { - // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact - // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff - // decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a - // timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing - // at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs - // each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0. - (void)droppedCount; // count is informational; the banner text is drop-count-agnostic - dropHintTicks_ = 6; -#ifdef _WIN32 - invalidate(); -#endif -} - -LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, - LPARAM lParam) { - auto* self = - reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); - switch (msg) { - case WM_PAINT: { - PAINTSTRUCT ps{}; - HDC hdc = BeginPaint(hwnd, &ps); - if (self) self->paint(hdc); - EndPaint(hwnd, &ps); - return 0; - } - case WM_LBUTTONDOWN: - if (self) { - SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child - SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) - self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - } - return 0; - case WM_MOUSEMOVE: - if (self) { - const int mx = GET_X_LPARAM(lParam); - const int my = GET_Y_LPARAM(lParam); - // Hover feedback (Phase L, L3): resolve the element under the pointer and - // repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover - // clears when the pointer leaves the child (TrackMouseEvent is one-shot). - if (!self->mouseTracking_) { - TRACKMOUSEEVENT tme{}; - tme.cbSize = sizeof(tme); - tme.dwFlags = TME_LEAVE; - tme.hwndTrack = hwnd; - TrackMouseEvent(&tme); - self->mouseTracking_ = true; - } - // While a drag is in flight the drag owns the surface; skip hover resolution - // (a hover repaint mid-drag would fight the live drag feedback). - if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my); - self->onMouseMove(mx, my); - } - return 0; - case WM_MOUSELEAVE: - if (self) { - self->mouseTracking_ = false; - if (self->hover_.kind != HoverKind::kNone) { - self->hover_ = HoverTarget{}; - self->invalidate(); - } - } - return 0; - case WM_MOUSEWHEEL: - // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. - if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); - return 0; - case WM_CHAR: - // S12 type-to-filter search keystrokes (only acted on when the search box is focused). - if (self) self->onSearchChar(static_cast(wParam)); - return 0; - case WM_GETDLGCODE: - // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). - return DLGC_WANTCHARS | DLGC_WANTARROWS; - case WM_LBUTTONUP: - if (self) { - self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - ReleaseCapture(); - } - return 0; - case WM_RBUTTONDOWN: - // r11: right-click — the curve popup's primary node-delete affordance (issue 3c). - // Routed explicitly (the child wndproc historically handled only left-button). - if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - case WM_RBUTTONUP: - return 0; // claimed so the pair never reaches DefWindowProc (no context menu) - case WM_CAPTURECHANGED: - // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its - // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset - // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. - // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. - if (self) { - // A held preview note must be released here too (peer of WM_LBUTTONUP) — capture - // loss otherwise leaves the momentary-key voice hung with no note-off. - if (self->previewingNote_ >= 0) { - if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_); - self->previewingNote_ = -1; - self->invalidate(); - } - if (self->drag_ != DragKind::kNone) { - // A scrollbar drag + the processor-side deck knobs (preview velocity -2 / - // voice count / master gain) are transient (no map mutation; dragStartMap_ - // not snapshotted) — reset drag state only, never touch map_. Every - // map-editing drag rolls its live mutation back to the snapshot. - const bool transient = self->drag_ == DragKind::kScrollThumb || - (self->drag_ == DragKind::kDeckKnob && - (self->dragParamId_ == -2 || - self->dragParamId_ == static_cast(ParamControl::kVoiceCount) || - self->dragParamId_ == static_cast(ParamControl::kMasterGain))); - if (!transient) self->map_ = self->dragStartMap_; - self->drag_ = DragKind::kNone; - self->dragParamId_ = -1; - self->dragParamZone_ = -1; - self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset) - self->dragCurveZone_ = -1; - self->invalidate(); - } - } - return 0; - case WM_DROPFILES: { - // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT - // read/ingest the paths (the instrument never ingests — the relay to the extension is - // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner. - HDROP drop = reinterpret_cast(wParam); - const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); - DragFinish(drop); - if (self) self->onFilesDropped(static_cast(count)); - return 0; - } - case WM_TIMER: - if (self && wParam == kSyncTimerId) self->onSyncTimer(); - return 0; - case WM_ERASEBKGND: - return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase - default: - return DefWindowProcW(hwnd, msg, wParam, lParam); - } -} - -#else // non-Windows: not a build target (D5), but keep the TU compilable. - -void ReaSamplerEditor::attachedToParent() {} -void ReaSamplerEditor::removedFromParent() {} -tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { - return CPluginView::onSize(newSize); -} - -#endif // _WIN32 - -} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp deleted file mode 100644 index 027527c..0000000 --- a/src/vst/reasampler_processor.cpp +++ /dev/null @@ -1,1164 +0,0 @@ -// reasampler_processor.cpp — see reasampler_processor.h. - -#include "reasampler_processor.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "pluginterfaces/base/ibstream.h" -#include "pluginterfaces/vst/ivstaudioprocessor.h" -#include "pluginterfaces/vst/ivstevents.h" -#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) -#include "pluginterfaces/vst/vstspeaker.h" - -#include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) -#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision -#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) -#include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) -#include "reasampler_editor.h" -#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) -#include "sample_map.h" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained) -#include "sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) -#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) - -using namespace Steinberg; -using namespace Steinberg::Vst; - -namespace reasampler::vst { - -namespace { - -// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is -// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather -// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice -// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the -// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays -// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. -constexpr std::size_t kPreserveVoiceCap = 8; - -// FB1 post-mixer gain ramp rate (per sample). gainCurrent_ converges to masterGain_ at this -// linear step; it ramps from 0 to unity (or vice versa) in ~20 ms at 48 kHz. The early-out -// (|current - target| < threshold) snaps to the target and avoids the ramp loop on idle blocks. -// kGainRampSnap is the threshold below which we snap to the target (avoids long sub-LSB creep). -constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms -constexpr float kGainRampSnap = kGainRampRate * 0.5f; - -// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy -// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the -// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic -// strength) is the requirement: two instances sharing a key is the copy-collision -// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision -// vanishingly unlikely. Off-thread only. -std::string mintUsageInstanceGuid() { - std::random_device rd; - std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); - std::uniform_int_distribution dist; - char buf[33] = {0}; - std::snprintf(buf, sizeof(buf), "%016llx%016llx", - static_cast(dist(gen)), - static_cast(dist(gen))); - return std::string(buf); -} - -// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on -// any failure — the caller treats an unreadable WAV as "nothing to play". -std::vector readFileBytes(const std::string& path) { - std::vector bytes; - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return bytes; - const std::streamoff size = f.tellg(); - if (size <= 0) return bytes; - f.seekg(0, std::ios::beg); - bytes.resize(static_cast(size)); - if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); - return bytes; -} - -// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file -// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode -// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for -// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path -// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no -// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by -// the zoned build and the single-capture path so both decode identically for the active mode. -std::optional decodeRelative(const std::string& projectDir, - const std::string& relativePath, - ChannelMode mode) { - const std::string abs = resolveBankFile(projectDir, relativePath); - if (abs.empty()) return std::nullopt; - const std::vector bytes = readFileBytes(abs); - const WavLayout layout = parseWavLayout(bytes); - if (!layout.valid) return std::nullopt; - std::vector interleaved = - extractFloatFrames(bytes, layout, 0, layout.frameCount()); - DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, - static_cast(layout.sampleRate)); - if (out.monoFrames.empty()) return std::nullopt; - return out; -} - -} // namespace - -FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { - // The host owns the returned reference. Cast up to the combined interface the SDK - // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. - return static_cast(new ReaSamplerProcessor()); -} - -// Out-of-line so unique_ptr sees the complete type here. -ReaSamplerProcessor::~ReaSamplerProcessor() = default; - -tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { - // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for - // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed - // shell. We own the shell (unique_ptr); the borrowed reference is valid because the - // processor outlives it. All other iids fall through to the SDK's queryInterface. - if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { - if (!embed_) embed_ = std::make_unique(this); - embed_->addRef(); - *obj = static_cast(embed_.get()); - return kResultOk; - } - return SingleComponentEffect::queryInterface(iid, obj); -} - -tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { - tresult result = SingleComponentEffect::initialize(context); - if (result != kResultOk) return result; - - // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the - // instrument still loads, it just has no live bank to play. - bridge_.connect(context); - - // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no - // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of - // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders - // dual-mono through the stereo bus (both channels equal, centered), which is audibly - // identical to a mono bus but never asks the host to re-map a live instance's pins. The - // prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every - // mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The - // in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer - // write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's - // re-routing of the live instance's pins across the arrangement change. A fixed arrangement - // is the maximally-standard VSTi shape and removes that whole negotiation surface. - addEventInput(STR16("MIDI In"), 16); - addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); - - return kResultOk; -} - -tresult PLUGIN_API ReaSamplerProcessor::terminate() { - // process() is not running at terminate: free the live + draining instruments and - // drain the graveyard. Take the pointers out of the atomics first so nothing else - // races them. - std::lock_guard lock(reloadMutex_); - delete live_.exchange(nullptr); - delete draining_.exchange(nullptr); - graveyard_.clear(); - return SingleComponentEffect::terminate(); -} - -tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { - // Activating: build the instrument from the currently-selected sample so the first - // block after activation can play. Deactivating: process is now GUARANTEED stopped by - // the host, so this is the safe point to reclaim the graveyard (the displaced engines - // no reload could free while active). The build/drain are off the audio thread — - // setActive is a main/UI-thread call. - if (state) { - // Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED - // sample refs — it needs no bank read, so it plays regardless of whether the - // extension's PROJEXTSTATE has parsed yet (or the extension exists at all). - // - // #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a - // pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic - // refreshRefsFromBank copies the refs in when the bank blob is readable by - // activation time, so an upgraded project plays on load without the instrument - // ever being opened (and the next save is self-contained). Residual load-order - // race, DAW-verifiable only: if the host activates this instance BEFORE the - // project's ext-state lines parse, the lift misses here and — with no editor open — - // nothing retries until the next activation or editor tick. MIGRATION NOTE: open a - // pre-v10 instrument once after upgrading if it restores silent. - reloadInstrument(); - } else { - std::lock_guard lock(reloadMutex_); - // process is guaranteed stopped: free EVERYTHING. The live instrument too — its - // voices are frozen mid-flight, and if it survived deactivation the reactivate - // reload would displace it into the DRAIN slot, resurrecting stale sustained - // voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above), - // so nothing is lost by clearing here. - delete live_.exchange(nullptr); - delete draining_.exchange(nullptr); - graveyard_.clear(); - } - return kResultOk; -} - -tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { - sampleRate_ = setup.sampleRate; - maxBlockSize_ = setup.maxSamplesPerBlock; - return SingleComponentEffect::setupProcessing(setup); -} - -tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { - if (!state) return kResultFalse; - // Read the whole component-state blob (the performance map, versioned). The blob is - // small; read in one shot into a growable buffer. - std::vector bytes; - std::uint8_t chunk[256]; - int32 got = 0; - while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { - bytes.insert(bytes.end(), chunk, chunk + got); - } - // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The - // selection and the zones are DISTINCT — the default face is one picked capture, zones - // are a demoted overlay — so both are restored explicitly (no more inferring a selection - // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only - // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so - // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 - // silent empty state (no first-sample fallback in reloadInstrument). - // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at - // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing - // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a - // pre-setup call would assert inside readZonesPayload (a programming error, not a field case). - const ComponentState cs = deserializeComponentState(bytes, sampleRate_); - setSelectedSampleId(cs.selectionId); - // Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a - // pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the - // saved selection under first-match resolve. Reconciling here restores "the sample the - // editor shows is the sample the engine plays" for already-affected projects; authored - // Zone-view maps (any narrow key range) pass through untouched. - PerformanceMap restored = cs.map; - reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load - setPerformanceMap(restored); - // S8: restore the last-consumed assignment generation so a re-open does not re-apply a - // stale assign_request (the user may have manually changed the selection after the assign). - { - std::lock_guard lock(assignMarkerMutex_); - lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; - } - // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see - // initialize) — the mode only governs how the reload below decodes, so no bus work here. - { - std::lock_guard lock(channelModeMutex_); - channelMode_ = cs.channelMode; - channelModeExplicit_ = cs.channelModeExplicit; - } - // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 - // the editor's velocity knob is a concurrent UI-thread writer. - { - std::lock_guard lock(previewMutex_); - previewVelocity_ = cs.previewVelocity; - } - // Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly, - // Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the - // reload below so the rebuilt engine is born with the saved polyphony/mode. - { - std::lock_guard lock(voiceParamsMutex_); - voiceCount_ = cs.voiceCount; - voiceMode_ = cs.voiceMode; - monoTrigger_ = cs.monoTrigger; - } - // FB1: restore the post-mixer master gain (v8; older blobs lift to unity in - // deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks - // it up at the next block start. - setMasterGainLinear(cs.masterGainLinear); - // pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the - // reload so it decodes straight from them — no bank read required to play. A pre-v10 - // blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob - // becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift), - // after which the next save is self-contained. - { - std::lock_guard lock(refsMutex_); - sampleRefs_ = cs.sampleRefs; - } - // pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty — - // minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME - // for the copy-collision analysis (the fresh nonce means this incarnation can never - // be mistaken for the previous one's writes — or for a copy-sibling's). - { - std::lock_guard lock(usageMutex_); - instanceGuid_ = cs.instanceGuid; - usageNonce_.clear(); - } - // A new blob is new facts: a staleness proof latched against the PREVIOUS state does - // not carry over (#A — the legacy lift gets one fresh run per restored state). - legacyLiftConcluded_.store(false, std::memory_order_relaxed); - // Rebuild from the restored state (off-thread — setState is a load-time call). - reloadInstrument(); - return kResultOk; -} - -tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { - if (!state) return kResultFalse; - // Persist the full instance state (v3, S10): the single-capture selection id AND the - // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" - // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} - // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing - // sample #1. - ComponentState state_out; - state_out.selectionId = selectedSampleId(); - state_out.map = performanceMap(); - { - // S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9). - std::lock_guard lock(channelModeMutex_); - state_out.channelMode = channelMode_; - state_out.channelModeExplicit = channelModeExplicit_; - } - { - std::lock_guard lock(assignMarkerMutex_); - state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker - } - state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity - { - // Phase S: persist the voice-system parameters (component state v7). - std::lock_guard lock(voiceParamsMutex_); - state_out.voiceCount = voiceCount_; - state_out.voiceMode = voiceMode_; - state_out.monoTrigger = monoTrigger_; - } - state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8) - // pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to - // decode + play with no extension present. Filtered (on the snapshot copy, the member is - // untouched) to exactly what the instance currently plays, so the table cannot grow with - // browsing history. - state_out.sampleRefs = sampleRefs(); - retainRefs(state_out.sampleRefs, - referencedSampleIds(state_out.selectionId, state_out.map)); - // pS-usage: persist the publish identity (v11) so the instance's usage key is - // stable across sessions (records do not proliferate per reopen). - { - std::lock_guard lock(usageMutex_); - state_out.instanceGuid = instanceGuid_; - } - const std::vector bytes = serializeComponentState(state_out); - if (!bytes.empty()) { - const tresult wr = state->write(const_cast(bytes.data()), - static_cast(bytes.size()), nullptr); - if (wr != kResultOk) return wr; - } - return kResultOk; -} - -std::string ReaSamplerProcessor::selectedSampleId() { - std::lock_guard lock(selectionMutex_); - return selectedSampleId_; -} - -void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { - std::lock_guard lock(selectionMutex_); - selectedSampleId_ = id; -} - -PerformanceMap ReaSamplerProcessor::performanceMap() { - std::lock_guard lock(performanceMutex_); - return performanceMap_; -} - -void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { - std::lock_guard lock(performanceMutex_); - performanceMap_ = map; -} - -SampleRefs ReaSamplerProcessor::sampleRefs() { - std::lock_guard lock(refsMutex_); - return sampleRefs_; -} - -ChannelMode ReaSamplerProcessor::channelMode() { - std::lock_guard lock(channelModeMutex_); - return channelMode_; -} - -std::uint8_t ReaSamplerProcessor::previewVelocity() { - std::lock_guard lock(previewMutex_); - return previewVelocity_; -} - -void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { - // Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview - // strike must sound). The editor's knob maps its 0..1 domain into this range before calling. - if (velocity < 1) velocity = 1; - if (velocity > 127) velocity = 127; - std::lock_guard lock(previewMutex_); - previewVelocity_ = velocity; -} - -int ReaSamplerProcessor::voiceCount() { - std::lock_guard lock(voiceParamsMutex_); - return voiceCount_; -} - -void ReaSamplerProcessor::setVoiceCount(int count) { - // Clamp to the shared pure-core range so the engine, the state bytes, and the editor's - // control can never disagree about the legal polyphony span. - if (count < kMinVoiceCount) count = kMinVoiceCount; - if (count > kMaxVoiceCount) count = kMaxVoiceCount; - { - std::lock_guard lock(voiceParamsMutex_); - if (voiceCount_ == count) return; // no-op: don't churn a rebuild - voiceCount_ = count; - } - // LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from - // the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change - // touches no audio data) and the displaced instrument keeps rendering its ringing tails, - // so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every - // zone from disk. Same contract for the mode/trigger setters below. - rebuildVoiceEngine(); -} - -VoiceMode ReaSamplerProcessor::voiceMode() { - std::lock_guard lock(voiceParamsMutex_); - return voiceMode_; -} - -void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { - { - std::lock_guard lock(voiceParamsMutex_); - if (voiceMode_ == mode) return; - voiceMode_ = mode; - } - rebuildVoiceEngine(); -} - -MonoTrigger ReaSamplerProcessor::monoTrigger() { - std::lock_guard lock(voiceParamsMutex_); - return monoTrigger_; -} - -void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { - { - std::lock_guard lock(voiceParamsMutex_); - if (monoTrigger_ == trigger) return; - monoTrigger_ = trigger; - } - rebuildVoiceEngine(); -} - -void ReaSamplerProcessor::setMasterGainLinear(double linear) { - // Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap = - // +24 dB). One relaxed atomic store — the audio thread reads it at the next block start; - // no rebuild, no lock (a post-sum output trim is not a keymap fact). - if (!(linear >= 0.0)) linear = 0.0; // also catches NaN - const double maxLin = masterGainMaxLinear(); - if (linear > maxLin) linear = maxLin; - masterGain_.store(static_cast(linear), std::memory_order_relaxed); -} - -void ReaSamplerProcessor::previewNoteOn(int note) { - if (note < 0) note = 0; - if (note > 127) note = 127; - const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request - // Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as - // long as we never land back on the exact value the audio thread last consumed in one step — - // 16 bits gives 65535 posts between collisions, unreachable at UI-click rates). - const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_; - const std::uint32_t packed = (static_cast(seq) << 16) | - (static_cast(vel) << 8) | - static_cast(note & 0xFF); - previewOnRequest_.store(packed, std::memory_order_release); -} - -void ReaSamplerProcessor::previewNoteOff(int note) { - if (note < 0) note = 0; - if (note > 127) note = 127; - const std::uint16_t seq = ++previewOffSeq_ == 0 ? ++previewOffSeq_ : previewOffSeq_; - const std::uint32_t packed = (static_cast(seq) << 16) | - static_cast(note & 0xFF); - previewOffRequest_.store(packed, std::memory_order_release); -} - -void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { - { - std::lock_guard lock(channelModeMutex_); - // The editor toggle is a DELIBERATE choice either way: latch explicit even on a - // same-mode click (the user confirmed the mode; the GA auto-default stops fighting it). - channelModeExplicit_ = true; - if (channelMode_ == mode) return; // no decode change: don't churn a reload - channelMode_ = mode; - } - // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no - // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread - // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering. - reloadInstrument(); -} - -tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( - SpeakerArrangement* inputs, int32 numIns, - SpeakerArrangement* outputs, int32 numOuts) { - // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a - // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected. - // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse) - // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a - // proposal keeps a valid arrangement of its own) — the host adapts its routing to us. - if (numIns < 0 || numOuts < 0) return kInvalidArgument; - if (numIns > 0) return kResultFalse; // no audio input bus to arrange - if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue; - return kResultFalse; -} - -std::string ReaSamplerProcessor::reloadInstrument() { - // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so - // the retired-slot free is single-writer. This mutex is NEVER taken on the audio - // thread — process() only touches the atomic. - std::lock_guard lock(reloadMutex_); - - // Mint this reload's generation number first so we can stamp the built instrument - // with it before publishing. Under reloadMutex_ no other reload races here. - const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - - // 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of - // truth for what to decode. The live bank blob, WHEN readable, is folded into the - // table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in - // mechanism and the S9 recapture sync in one — but its absence changes NOTHING - // below: a project restored before the extension's PROJEXTSTATE parses (or with - // the extension absent entirely) resolves + plays from the persisted refs. The - // project dir comes from REAPER itself (EnumProjects), not from the extension. - const std::string selId = selectedSampleId(); - const PerformanceMap map = performanceMap(); - const std::vector ids = referencedSampleIds(selId, map); - SampleRefs refs; - { - std::optional banksJson = - bridge_.readReasamplerExtState(kProjExtBanksKey); - std::lock_guard rl(refsMutex_); - if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); - // The LOAD path never prunes the owned table: dropping entries here on a transient - // bank miss could destroy the owned intrinsics of the previous selection — the ONE - // copy that survives with the extension absent. Entries for de-referenced ids stay - // in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary, - // where getState filters its snapshot via retainRefs to what the instance plays. - refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) - } - const std::string projectDir = bridge_.activeProjectDir(); - // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). - // Read once under its mutex, off the audio thread, before the decode loop. The single- - // capture branch below may auto-default it (GA) before its decode. - ChannelMode mode = channelMode(); - // Phase S: snapshot the voice-system parameters once — they are baked into the built - // engine's construction (the engine's config is immutable; a later change rebuilds). - int builtVoiceCount = kDefaultVoiceCount; - VoiceMode builtVoiceMode = VoiceMode::Poly; - MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; - { - std::lock_guard vp(voiceParamsMutex_); - builtVoiceCount = voiceCount_; - builtVoiceMode = voiceMode_; - builtMonoTrigger = monoTrigger_; - } - - std::string resolvedId; - std::unique_ptr built; - Keymap km; - bool haveKeymap = false; - - // 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its - // zones against the OWNED refs (an id with no ref drops cleanly), decode each - // zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays - // its sample repitched from its effective root note (override > ref intrinsic > - // C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped - // (not the whole map): the defined no-play, no crash, no retry loop. - if (!map.empty()) { - const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map); - if (!resolved.zones.empty()) { - std::vector decoded; - std::vector kept; - decoded.reserve(resolved.zones.size()); - kept.reserve(resolved.zones.size()); - for (const ResolvedZone& rz : resolved.zones) { - std::optional pcm = - decodeRelative(projectDir, rz.relativePath, mode); - if (!pcm) continue; // unreadable/missing WAV -> drop this zone - kept.push_back(rz); - decoded.push_back(std::move(*pcm)); - } - km = buildZonedKeymap(kept, decoded); - haveKeymap = !km.zones.empty(); - } - } - - // 3. Single-capture fast path (S10): an empty performance map plays the ONE - // deliberately-selected capture chromatically across the whole keyboard, resolved - // against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a - // selection with no ref) resolves to nothing, so an un-picked instrument stays - // SILENT (the editor shows its "pick a capture" empty state) rather than - // auto-playing sample #1 (S10 policy reversal of the S4 convenience default). - if (!haveKeymap) { - if (const SelectedSample* sel = findRef(refs, selId)) { - // GA auto-default: channelModeFor computes the mode from the loaded capture's - // REQUESTED channel count (always 2 for extension captures; mono only for - // ingest-imported mono files). An unknown count (0) or explicit user choice - // returns the current mode unchanged. Decode-only: the output bus is fixed - // stereo, so no bus work follows a flip. - { - std::lock_guard cm(channelModeMutex_); - channelMode_ = channelModeFor(sel->channelCount, channelMode_, - channelModeExplicit_); - mode = channelMode_; - } - std::optional pcm = - decodeRelative(projectDir, sel->relativePath, mode); - if (pcm) { - km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, - sel->rootNote, sel->loop, - std::move(pcm->framesR)); - haveKeymap = true; - resolvedId = selId; // the concrete pick that resolved - } - } - } - - if (haveKeymap) { - // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). - // Every voice's shifter is pre-sized to this off-thread here, so process()-time - // note-on never allocates. Floored at 2 so a valid window is always a real ring - // (which also covers a pathological host rate <= 0 — no rate literal needed). - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; - built = std::make_unique( - std::move(km), static_cast(builtVoiceCount), gen, - kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); - } - - // 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the - // DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices — - // a reload never cuts a sounding note; the next note-on plays the new state. The - // instrument evicted FROM the drain slot (two reloads old) goes to the graveyard - // (process may still be mid-block reading it). A null `built` (no ref / unreadable - // WAV) installs silence while the displaced tails still ring out via the drain. - // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted - // pointer is re-owned by the graveyard. - publishBuiltLocked(std::move(built)); - - // 5. pS-usage: publish this instance's held captures so the extension's prune can - // never reclaim them (see publishUsage). AFTER the instrument swap, still off the - // audio thread and under reloadMutex_. Publishes regardless of decode success: - // the holds are the refs the instance RETAINS (its play-set), not what decoded — - // a transiently unreadable WAV must stay protected. - publishUsage(refs, ids); - return resolvedId; -} - -void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, - const std::vector& ids) { - if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do - - UsageRecord mine; - mine.trackGuid = bridge_.currentTrackGuid(); - for (const std::string& id : ids) { - if (const SelectedSample* ref = findRef(refs, id)) { - if (!ref->relativePath.empty()) { - mine.holds.push_back(UsageHold{id, ref->relativePath}); - } - } - } - - std::lock_guard lock(usageMutex_); - // A never-published instance with nothing held writes nothing — no key litter for - // fresh/empty instances. Once an identity exists, empties DO publish (they release - // holds the prune would otherwise keep protecting). - if (instanceGuid_.empty() && mine.holds.empty()) return; - if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); - // The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so - // planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track - // sibling's byte-identical hold set can never pass as ours (its nonce differs), so - // siblings always union and never clean-replace over each other's held paths. - if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid(); - mine.ownerNonce = usageNonce_; - - const std::optional existing = - bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); - const UsagePublishPlan plan = planUsagePublish(existing, mine); - if (plan.remint) { - // This state was cloned onto another track (FX copy / track duplication): take a - // fresh identity and leave the original's record untouched. The abandoned old - // identity's record dies by the extension's liveness rule when its track no - // longer hosts an instance. getState persists the new guid on the next save. - instanceGuid_ = mintUsageInstanceGuid(); - } else if (plan.skipWrite) { - return; // idle tick, or a union that adds nothing — no ext-state churn - } - bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire); -} - -void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { - // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by - // reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance. - // - // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is - // the minimum installedAt process() published over the pointers it holds. Both - // slots are monotone in installedAt, so seen is monotone and any future process() - // load yields installedAt >= seen — an entry below seen is provably unreachable - // (see the header proof). Remaining entries drain at setActive(false) / terminate() - // when process is guaranteed stopped. - const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); - graveyard_.erase( - std::remove_if(graveyard_.begin(), graveyard_.end(), - [seen](const std::unique_ptr& e) { - return e->installedAt < seen; - }), - graveyard_.end()); - LoadedInstrument* prev = live_.exchange(built.release()); - LoadedInstrument* evicted = draining_.exchange(prev); - if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); -} - -void ReaSamplerProcessor::rebuildVoiceEngine() { - // OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract: - // a voice-param change touches NO audio data, so this rebuilds the engine - // around a COPY of the live instrument's already-decoded keymap — no bridge, no disk — - // and publishes through the same drain-slot swap, so ringing tails survive. - std::lock_guard lock(reloadMutex_); - LoadedInstrument* cur = live_.load(std::memory_order_acquire); - if (!cur) return; // nothing loaded: the new params bake into the next real reload. - - int builtVoiceCount = kDefaultVoiceCount; - VoiceMode builtVoiceMode = VoiceMode::Poly; - MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; - { - std::lock_guard vp(voiceParamsMutex_); - builtVoiceCount = voiceCount_; - builtVoiceMode = voiceMode_; - builtMonoTrigger = monoTrigger_; - } - - const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - // Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate). - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; - - // Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap - // is immutable after construction, and under reloadMutex_ nobody can free `cur`. - Keymap km = cur->keymap; - auto built = std::make_unique( - std::move(km), static_cast(builtVoiceCount), gen, - kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); - publishBuiltLocked(std::move(built)); -} - -void ReaSamplerProcessor::retireIdleDrain() { - // Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or - // it still sounds" — the common case costs one relaxed load and no mutex. - const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); - if (idleGen == 0) return; - std::lock_guard lock(reloadMutex_); - LoadedInstrument* drain = draining_.load(std::memory_order_acquire); - // Retire ONLY if the publication names the drain currently in the slot. A stale value - // (about an already-evicted, older drain) can never match the newer occupant's - // installedAt — the slot is monotone in generation — so a mid-swap race is closed by - // this identity check, not by timing. - if (!drain || drain->installedAt != idleGen) return; - draining_.store(nullptr, std::memory_order_release); - graveyard_.push_back(std::unique_ptr(drain)); - // Prune what is now provably unreachable — the same monotone-generation proof as the - // reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be - // held by process() now or ever again. The just-parked drain frees here immediately when - // process() has already published past it; otherwise on the next reload/retire/deactivate. - const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); - graveyard_.erase( - std::remove_if(graveyard_.begin(), graveyard_.end(), - [seen](const std::unique_ptr& e) { - return e->installedAt < seen; - }), - graveyard_.end()); -} - -bool ReaSamplerProcessor::legacyLiftShouldRun() { - // #A terminating guard for the pre-v10 legacy lift. The caller has already established - // refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before - // paying for a full reload. Once concluded, the steady state is this one relaxed load — - // no bank read, no parse, no reload churn. - if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; - const LegacyLiftDecision decision = legacyLiftDecision( - bridge_.readReasamplerExtState(kProjExtBanksKey), - referencedSampleIds(selectedSampleId(), performanceMap())); - if (decision == LegacyLiftDecision::Stale) { - // Provably stale (the bank parses and knows none of the referenced ids): give up - // PERMANENTLY. A later bank change that re-introduces an id bumps the generation, - // and the genChanged reload refreshes the refs without consulting this latch. - legacyLiftConcluded_.store(true, std::memory_order_relaxed); - return false; - } - return true; // Retry (blob not readable yet) or Lift (a ref can be copied in) -} - -ReaSamplerProcessor::BankSyncResult -ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { - // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call - // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER - // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. - BankSyncResult result; - - // Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer - // cadence that drives reloads — an edited-away instrument stops costing memory as soon - // as its tails die instead of squatting in the drain slot until the next reload. - retireIdleDrain(); - - // --- S8: assignment-request consume FIRST ------------------------------------- - // Decode the pending assignment request (nullopt when absent/malformed). Resolve its - // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when - // the sampleId names an existing sample (the reader requirement — an unresolvable pair is - // dropped). Then run the pure consume decision against this instance's persisted marker. - std::optional request; - if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { - request = decodeAssignmentRequest(*raw); - } - - bool resolves = false; - if (request) { - // Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request - // whose sample was rolled back by an extension undo resolves to nullopt -> dropped). - if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { - resolves = selectSample(*banksJson, request->sampleId).has_value(); - } - } - - // Read lastConsumed and conditionally write it back under a single lock scope so there - // is no interleave window between the read and the write (a concurrent getState could - // otherwise observe a stale marker between the two separate lock acquisitions). - std::int64_t lastConsumed = 0; - const AssignConsumeDecision decision = [&] { - std::lock_guard lock(assignMarkerMutex_); - lastConsumed = lastConsumedAssignGeneration_; - const AssignConsumeDecision d = - consumeDecision(request, lastConsumed, resolves, isFocusedTarget); - // Advance the persisted consumed marker whenever the decision consumed the request - // (applied OR dropped-as-seen). getState will persist it on the next project save so - // a re-open does not re-apply. A non-target instance leaves the marker (decision - // returns it unchanged) so it stays eligible if focus later lands here. - if (d.consumedGeneration != lastConsumed) { - lastConsumedAssignGeneration_ = d.consumedGeneration; - } - return d; - }(); - - if (decision.apply) { - // Apply the assignment as this instance's own selection (the same path a user card-pick - // takes) — the instrument updates its OWN state, never the bank. reloadInstrument below - // rebuilds against the new selection, so skip a redundant reload here. - setSelectedSampleId(decision.sampleId); - // Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone - // materialized for the previously loaded sample would shadow the assigned pick under - // first-match resolve. Authored maps (any narrow key range) are untouched. - PerformanceMap reconciled = performanceMap(); - if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) { - setPerformanceMap(reconciled); - } - result.applied = true; - } - - // --- S9: bank-generation change-detection ------------------------------------- - // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll - // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — - // setState already loaded the instrument from its OWNED refs (pS), so a redundant reload - // on open would only churn. A later - // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the - // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced). - std::int64_t currentGen = kBankGenerationAbsent; - if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { - currentGen = parseBankGeneration(*rawGen); - } - const bool firstPoll = (lastSeenBankGeneration_ < 0); - const bool genChanged = - !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); - lastSeenBankGeneration_ = currentGen; - - // LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones) - // but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had - // nothing to decode unless the bank happened to be readable already. Reload on this - // editor tick until the lift lands: reloadInstrument folds the bank blob into the refs - // when readable, after which the table is non-empty and this never fires again (the - // next save is then self-contained). A deliberately-empty instance has no intent and - // never churns; a bank that is not readable YET retries a cheap null publish on the - // editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob - // PARSES and no referenced id resolves in it, the ids are provably stale — there is - // nothing to lift, so the lift concludes permanently instead of churning a full bank - // read + reload every tick forever. This is a MIGRATION convenience for old projects, - // NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS). - bool legacyLift = false; - if (!genChanged && !result.applied && sampleRefs().empty()) { - const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty(); - legacyLift = hasIntent && legacyLiftShouldRun(); - } - - if (genChanged || result.applied || legacyLift) { - reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) - // Report the reload distinctly from an S8 apply so the editor re-snapshots its bank - // view. A legacy lift counts only when it actually landed an instrument (otherwise - // every retry tick would churn the editor's caches for nothing). - result.reloaded = - genChanged || - (legacyLift && live_.load(std::memory_order_acquire) != nullptr); - } - return result; -} - -tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { - // REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments - // once for the whole block (two atomic acquires), then publish the MINIMUM installedAt - // over the pointers held so the off-thread graveyard pruner knows exactly which - // generations this block is holding (see the header proof). - // - // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an - // ordering race: reading reloadGeneration_ after the slots could observe a generation - // newer than the pointers we actually hold, causing the pruner to free an instrument - // process is still reading. installedAt was set on the reload path before the atomic - // exchange that made the instrument visible. - // - // The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the - // last reload: its already-sounding voices keep rendering (and receive note-offs) so a - // curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons. - // A racing reload can briefly leave the same pointer in both slots (live_ was loaded - // before the swap, draining_ after); collapse that to live-only so one engine is never - // advanced twice per frame. - LoadedInstrument* inst = live_.load(std::memory_order_acquire); - LoadedInstrument* drain = draining_.load(std::memory_order_acquire); - if (drain == inst) drain = nullptr; - std::uint64_t heldGen = 0; - if (inst && drain) { - heldGen = inst->installedAt < drain->installedAt ? inst->installedAt - : drain->installedAt; - } else if (inst) { - heldGen = inst->installedAt; - } else if (drain) { - heldGen = drain->installedAt; - } - processGeneration_.store(heldGen, std::memory_order_release); - - // Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine - // voice silent) by naming its OWN installedAt (0 = no drain / still - // sounding). Evaluated at block START — idleness is monotone for a drain (it receives no - // note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply - // publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe. - drainIdleGeneration_.store( - (drain && drain->fullyIdle()) ? drain->installedAt : 0, - std::memory_order_relaxed); - - // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps - // events at block granularity (no per-event sample-offset split) — audible timing is - // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. - // Note-offs also route to the DRAIN engine so a note held across a reload releases - // its old-snapshot voice too (otherwise it would sustain until the next reload). - if (data.inputEvents) { - const int32 count = data.inputEvents->getEventCount(); - for (int32 i = 0; i < count; ++i) { - Event e; - if (data.inputEvents->getEvent(i, e) != kResultOk) continue; - if (e.type == Event::kNoteOnEvent) { - // A note-on with velocity 0 is a note-off by MIDI convention. - const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); - if (vel <= 0) { - if (inst) inst->engine.noteOff(e.noteOn.pitch); - if (drain) drain->engine.noteOff(e.noteOn.pitch); - } else if (inst) { - inst->engine.noteOn(e.noteOn.pitch, vel); - } - } else if (e.type == Event::kNoteOffEvent) { - if (inst) inst->engine.noteOff(e.noteOff.pitch); - if (drain) drain->engine.noteOff(e.noteOff.pitch); - } else if (e.type == Event::kLegacyMIDICCOutEvent) { - // PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a - // VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism - // — the type is nominally an output event; DAW-verify, see handoff). - // CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR - // release tail; Trigger one-shots play through their bounded play length. - // CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless - // of play mode, including Trigger one-shots that ignore CC 123. This is the - // true "panic" for a ringing one-shot (e.g. a full-length capture). - // Both clear the mono held stack. Both apply to live AND drain. A ringing - // preview note is a real engine voice since the PreviewCard retirement, so - // the panics cover it with no separate routing. allNotesOff / allSoundsOff - // are RT-safe (no allocation, bounded scans). - const auto cc = static_cast(e.midiCCOut.controlNumber); - if (cc == kCtrlAllSoundsOff) { - if (inst) inst->engine.allSoundsOff(); - if (drain) drain->engine.allSoundsOff(); - } else if (cc == kCtrlAllNotesOff) { - if (inst) inst->engine.allNotesOff(); - if (drain) drain->engine.allNotesOff(); - } - } - } - } - - // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed - // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last - // one we consumed; fire it once, then latch the sequence so the same request never re-fires. - // Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact - // noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real - // voice: it counts against the voice count, can steal / be stolen, and respects - // Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's - // isolation). The editor posts the root note, so it plays at unity. - // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument - // is loaded does not re-fire stale on the next instrument load. - { - const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire); - const std::uint16_t onSeq = static_cast(on >> 16); - if (onSeq != 0 && onSeq != previewOnConsumed_) { - previewOnConsumed_ = onSeq; - if (inst) { - const int vel = static_cast((on >> 8) & 0xFF); - const int note = static_cast(on & 0xFF); - if (vel > 0) inst->engine.noteOn(note, vel); - } - } - } - { - const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); - const std::uint16_t offSeq = static_cast(off >> 16); - if (offSeq != 0 && offSeq != previewOffConsumed_) { - // Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending - // while nothing was loaded would otherwise survive until a (heal) reload lands - // and release the NEXT preview press in the same block. - previewOffConsumed_ = offSeq; - // Route the preview note-off to BOTH engines (mirror of the host note-off): a - // preview held across a reload — e.g. a curve edit committed mid-press — must - // release the old-snapshot voice now draining, not just the (fresh) live one. - // NOTE: preview shares the host-MIDI note space — noteOff releases the newest - // voice at that pitch, so a preview release can release a host-held note at - // the same pitch (inherent to routing preview through the real note path). - if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); - if (drain) drain->engine.noteOff(static_cast(off & 0xFF)); - } - } - - if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { - embedPeak_.store(0.f, std::memory_order_relaxed); - return kResultOk; - } - AudioBusBuffers& out = data.outputs[0]; - const int32 frames = data.numSamples; - - // 64-bit host processing is not supported by the mono float core; emit silence - // rather than mis-render. REAPER runs 32-bit float by default. - if (data.symbolicSampleSize != kSample32) { - embedPeak_.store(0.f, std::memory_order_relaxed); - for (int32 ch = 0; ch < out.numChannels; ++ch) { - if (double* buf = out.channelBuffers64[ch]) { - for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; - } - } - out.silenceFlags = (out.numChannels >= 64) - ? ~0ULL - : ((1ULL << out.numChannels) - 1); - return kResultOk; - } - - // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked - // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here - // we simply match the buffers the host handed us: >=2 channels -> true stereo render into - // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the - // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. - float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; - float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; - if (ch0 && ch1) { - // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo - // path (both channels equal), so a mono capture in stereo mode is centered, not silent. - // The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer). - for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } - if (inst) inst->engine.render(ch0, ch1, static_cast(frames)); - if (drain) drain->engine.render(ch0, ch1, static_cast(frames)); - // FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so - // continuous knob drags produce no zipper noise and the true-zero bottom causes no click. - // Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the - // actual output. Branch-free inner loop; early-out when already at target. RT-safe. - { - const float gTarget = masterGain_.load(std::memory_order_relaxed); - const float diff = gTarget - gainCurrent_; - if (diff < -kGainRampSnap || diff > kGainRampSnap) { - // Ramp toward target: step per sample, then apply the per-sample gain. - for (int32 i = 0; i < frames; ++i) { - const float d = gTarget - gainCurrent_; - if (d > kGainRampRate) gainCurrent_ += kGainRampRate; - else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate; - else gainCurrent_ = gTarget; - ch0[i] *= gainCurrent_; - ch1[i] *= gainCurrent_; - } - } else { - gainCurrent_ = gTarget; - if (gTarget != 1.f) { - for (int32 i = 0; i < frames; ++i) { ch0[i] *= gTarget; ch1[i] *= gTarget; } - } - } - } - // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). - for (int32 ch = 2; ch < out.numChannels; ++ch) { - if (float* buf = out.channelBuffers32[ch]) { - for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; - } - } - // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. - float peak = 0.f; - for (int32 i = 0; i < frames; ++i) { - const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; - const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; - if (a0 > peak) peak = a0; - if (a1 > peak) peak = a1; - } - embedPeak_.store(peak, std::memory_order_relaxed); - } else if (ch0) { - // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; - // the replicate is defensive for a host that still hands >1 channel on a mono bus). - for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; - if (inst) inst->engine.render(ch0, static_cast(frames)); - if (drain) drain->engine.render(ch0, static_cast(frames)); - // FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch: - // post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe. - { - const float gTarget = masterGain_.load(std::memory_order_relaxed); - const float diff = gTarget - gainCurrent_; - if (diff < -kGainRampSnap || diff > kGainRampSnap) { - for (int32 i = 0; i < frames; ++i) { - const float d = gTarget - gainCurrent_; - if (d > kGainRampRate) gainCurrent_ += kGainRampRate; - else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate; - else gainCurrent_ = gTarget; - ch0[i] *= gainCurrent_; - } - } else { - gainCurrent_ = gTarget; - if (gTarget != 1.f) { - for (int32 i = 0; i < frames; ++i) ch0[i] *= gTarget; - } - } - } - float peak = 0.f; - for (int32 i = 0; i < frames; ++i) { - const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; - if (a > peak) peak = a; - } - embedPeak_.store(peak, std::memory_order_relaxed); - for (int32 ch = 1; ch < out.numChannels; ++ch) { - if (float* buf = out.channelBuffers32[ch]) { - for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; - } - } - } - - // Report silence only when nothing is loaded (lets the host optimize when idle). - // With an instrument loaded — or a drain snapshot still ringing out — we clear the - // flag so a ringing voice is not skipped. - out.silenceFlags = (inst || drain) ? 0 - : ((out.numChannels >= 64) - ? ~0ULL - : ((1ULL << out.numChannels) - 1)); - return kResultOk; -} - -IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { - if (name && FIDStringsEqual(name, ViewType::kEditor)) { - return new ReaSamplerEditor(this); - } - return nullptr; -} - -} // namespace reasampler::vst diff --git a/src/wav_trim.cpp b/src/wav_trim.cpp deleted file mode 100644 index e8265ae..0000000 --- a/src/wav_trim.cpp +++ /dev/null @@ -1,160 +0,0 @@ -// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor. - -#include "wav_trim.h" - -#include // std::memcpy, std::memcmp - -namespace reasampler { - -namespace { - -// Little-endian readers. Bounds are checked by the caller before each read; these -// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB. -std::uint16_t readU16LE(const std::vector& b, std::size_t off) { - return static_cast(b[off] | (b[off + 1] << 8)); -} -std::uint32_t readU32LE(const std::vector& b, std::size_t off) { - return static_cast(b[off]) | - (static_cast(b[off + 1]) << 8) | - (static_cast(b[off + 2]) << 16) | - (static_cast(b[off + 3]) << 24); -} - -bool tagEquals(const std::vector& b, std::size_t off, const char* tag) { - return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; -} - -// WAVE format tags we accept as 32-bit float (see wav_trim.h FORMAT ASSUMPTION). -constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003; -constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; - -} // namespace - -WavLayout parseWavLayout(const std::vector& bytes) { - WavLayout out; - - // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes. - if (bytes.size() < 12) return out; - if (!tagEquals(bytes, 0, "RIFF")) return out; - if (!tagEquals(bytes, 8, "WAVE")) return out; - - bool haveFmt = false; - std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0; - std::uint32_t sampleRate = 0; - std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible - - // Walk the sub-chunks after "WAVE" (offset 12). Each is: id(4) size(4) body(size), - // body padded to an even byte count (RIFF word alignment). Stop cleanly if a - // header would run past the buffer — a malformed/truncated file is "invalid", - // never an OOB read. - std::size_t pos = 12; - while (pos + 8 <= bytes.size()) { - const std::size_t bodyOffset = pos + 8; - const std::uint32_t bodySize = readU32LE(bytes, pos + 4); - - if (tagEquals(bytes, pos, "fmt ")) { - // fmt body: at least 16 bytes (PCM/float common fields). - if (bodyOffset + 16 > bytes.size() || bodySize < 16) return out; - fmtTag = readU16LE(bytes, bodyOffset + 0); - channels = readU16LE(bytes, bodyOffset + 2); - sampleRate = readU32LE(bytes, bodyOffset + 4); - bitsPerSample = readU16LE(bytes, bodyOffset + 14); - // For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading - // 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM - // integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to - // reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in - // the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected). - if (fmtTag == kWaveFormatExtensible) { - if (bodySize >= 40 && bodyOffset + 40 <= bytes.size()) { - extensibleSubFormatTag = readU16LE(bytes, bodyOffset + 24); - } - } - haveFmt = true; - } else if (tagEquals(bytes, pos, "data")) { - // The data chunk: PCM starts at bodyOffset, declared length bodySize. - // Reject if it runs past the buffer (truncated / lying header). - if (bodyOffset + bodySize > bytes.size()) return out; - if (!haveFmt) return out; // data before fmt — not a WAV we parse - - // Plain IEEE-float tag (0x0003): accept as-is. - // Extensible tag (0xFFFE): accept only when the SubFormat tag read from - // the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag - // 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT - // float and must be rejected to prevent mis-decoding as float. - const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || - (fmtTag == kWaveFormatExtensible && - extensibleSubFormatTag == kWaveFormatIeeeFloat); - if (!floatTag || bitsPerSample != 32 || channels == 0) return out; - - out.valid = true; - out.channelCount = channels; - out.sampleRate = sampleRate; - out.dataByteOffset = bodyOffset; - out.dataByteLength = bodySize; - out.riffSizeFieldOffset = 4; - out.dataSizeFieldOffset = pos + 4; // the `data` size field (LE uint32) - return out; - } - - // Advance past this chunk's body, honoring RIFF even-byte padding. Guard the - // additions against size_t overflow (a hostile bodySize near SIZE_MAX). - std::size_t advance = bodySize; - if (advance & 1u) ++advance; // pad byte - if (advance > bytes.size() - bodyOffset) break; // would overrun -> stop - pos = bodyOffset + advance; - } - - return out; // no data chunk found -> invalid -} - -std::vector extractFloatFrames(const std::vector& bytes, - const WavLayout& layout, - std::size_t startFrame, - std::size_t frameCount) { - std::vector out; - if (!layout.valid) return out; - - const std::size_t bytesPerFrame = - static_cast(layout.channelCount) * 4u; - const std::size_t totalFrames = layout.frameCount(); - if (startFrame >= totalFrames) return out; - - // Clamp the requested span to the frames that actually exist. - const std::size_t avail = totalFrames - startFrame; - const std::size_t frames = (frameCount < avail) ? frameCount : avail; - if (frames == 0) return out; - - const std::size_t firstByte = - layout.dataByteOffset + startFrame * bytesPerFrame; - out.resize(frames * layout.channelCount); - // memcpy each float (LE on target hosts — see header's byte-order note). - for (std::size_t i = 0; i < out.size(); ++i) { - float f = 0.0f; - std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u); - out[i] = f; - } - return out; -} - -WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) { - WavTruncatePlan plan; - if (!layout.valid) return plan; - - const std::size_t totalFrames = layout.frameCount(); - if (keptFrames > totalFrames) return plan; // never grow - - const std::size_t bytesPerFrame = - static_cast(layout.channelCount) * 4u; - const std::size_t keptDataBytes = keptFrames * bytesPerFrame; - - plan.valid = true; - plan.newFileByteLength = layout.dataByteOffset + keptDataBytes; - plan.dataSizeFieldOffset = layout.dataSizeFieldOffset; - plan.newDataSize = static_cast(keptDataBytes); - plan.riffSizeFieldOffset = layout.riffSizeFieldOffset; - // RIFF size counts everything after the 8-byte "RIFF"+size prefix. - plan.newRiffSize = static_cast(plan.newFileByteLength - 8); - return plan; -} - -} // namespace reasampler diff --git a/tests/test_action_bar.cpp b/tests/test_action_bar.cpp index cda6d88..7eba7b1 100644 --- a/tests/test_action_bar.cpp +++ b/tests/test_action_bar.cpp @@ -15,13 +15,14 @@ // degenerate/too-narrow bar handled without crash or overlap. // * Resize: no inventory item cut off or overlapping across a representative width range. -#include "../src/action_bar.h" +#include "../src/core/ui/action_bar.h" #include #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_app_version.cpp b/tests/test_app_version.cpp index 0fca97e..2f8c1e3 100644 --- a/tests/test_app_version.cpp +++ b/tests/test_app_version.cpp @@ -5,12 +5,13 @@ // back from ext state (PreVersioning / Unknown / Stamped). The ext-state I/O (persist) and // the show-version action (main) are DAW-verified shell. -#include "../src/app_version.h" +#include "../src/core/version/app_version.h" #include #include using namespace reasampler; +using namespace reasampler::version; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_app_version_padding.cpp b/tests/test_app_version_padding.cpp index 4e89dfd..295cc4d 100644 --- a/tests/test_app_version_padding.cpp +++ b/tests/test_app_version_padding.cpp @@ -25,12 +25,13 @@ // configure_file input in CMakeLists.txt. They are NOT the shipped version: never bump // them on release — their padded shape is the entire point. -#include "../src/app_version.h" +#include "../src/core/version/app_version.h" #include #include using namespace reasampler; +using namespace reasampler::version; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_assignment_request.cpp b/tests/test_assignment_request.cpp index 9dfe12c..45292e6 100644 --- a/tests/test_assignment_request.cpp +++ b/tests/test_assignment_request.cpp @@ -9,12 +9,13 @@ // trailing-garbage input -> nullopt (the reader's "no pending request" fallback hinges // on it). -#include "../src/assignment_request.h" +#include "../src/core/wire/assignment_request.h" #include #include using namespace reasampler; +using namespace reasampler::wire; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_bank_book.cpp b/tests/test_bank_book.cpp index 0e8674c..c25ddaf 100644 --- a/tests/test_bank_book.cpp +++ b/tests/test_bank_book.cpp @@ -10,13 +10,14 @@ // pool, set named, invalid id); JSON round-trip lossless (full book); legacy // bank_index → pool migration. -#include "../src/bank_book.h" +#include "../src/core/model/bank_book.h" #include #include #include using namespace reasampler; +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -41,6 +42,29 @@ static Sample sampleWith(const std::string& seed) { return sampleWith(seed, "has // --------------------------------------------------------------------------- +// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for a +// small fixture (a freshly-seeded book: pool only, one sample), not just +// self-consistent re-serialization — a format drift that both writer and +// reader agree on would slip past the round-trip tests but not this. The +// format is frozen as-shipped; the literal below is the captured current +// output. +static void testSerializeGoldenLiteral() { + BankBook book; + CHECK(book.pool().index.add(sampleWith("g1")) == AddResult::Added); + CHECK(book.serialize() == + "{\"version\":1,\"activeBank\":\"pool\",\"banks\":[{\"id\":\"pool\"," + "\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"version\":1," + "\"samples\":[{\"id\":\"id-g1\",\"displayName\":\"sample g1\"," + "\"relativePath\":\"bank/g1.wav\",\"sourceMode\":0,\"sourceRange\":{" + "\"startSeconds\":0,\"endSeconds\":0,\"startPpq\":0,\"endPpq\":0}," + "\"trackGuids\":[],\"wetDry\":1,\"channelCount\":2,\"sampleRate\":48000," + "\"lengthSeconds\":0,\"lengthBeats\":0,\"captureTempo\":0," + "\"captureTimeSigNum\":0,\"captureTimeSigDenom\":0,\"key\":null," + "\"rootNote\":null,\"loop\":null,\"levels\":{\"peakDb\":0,\"rmsDb\":0," + "\"lufs\":0},\"clipped\":false,\"tier\":0,\"contentHash\":\"hash-g1\"," + "\"provenance\":null,\"createdTimestamp\":1753080000}]},\"slots\":[]}]}"); +} + static void testPoolSeededAndDefaults() { BankBook book; // Pool present as bank-zero with fixed id + name + ordinal 0. @@ -339,9 +363,9 @@ static void testJsonEmptyBookRoundTrip() { } static void testLegacyMigration() { - // A bare legacy bank_index JSON (BankIndex::serialize output — has "samples", no + // A bare legacy bank_index JSON (BankModel::serialize output — has "samples", no // "banks") must promote into the pool: a book of { pool } with zero named banks. - BankIndex legacy; + BankModel legacy; CHECK(legacy.add(sampleWith("old1")) == AddResult::Added); CHECK(legacy.add(sampleWith("old2")) == AddResult::Added); std::string legacyJson = legacy.serialize(); @@ -361,7 +385,7 @@ static void testLegacyMigration() { } // An EMPTY legacy index ("{\"samples\":[]}" style via serialize) also migrates. - BankIndex emptyLegacy; + BankModel emptyLegacy; auto back2 = BankBook::deserialize(emptyLegacy.serialize()); CHECK(back2.has_value()); CHECK(back2 && back2->size() == 1 && back2->pool().index.empty()); @@ -408,7 +432,7 @@ static void testLoadPrefersBanksBlob() { CHECK(src.bank("drums")->index.add(sampleWith("new1")) == AddResult::Added); const std::string banksJson = src.serialize(); - BankIndex stale; + BankModel stale; CHECK(stale.add(sampleWith("stale-old")) == AddResult::Added); const std::string legacyJson = stale.serialize(); @@ -424,7 +448,7 @@ static void testLoadPrefersBanksBlob() { static void testLoadMigratesLegacyWhenNoBanks() { // No `banks` key, a legacy `bank_index` present: migrate into the pool, zero named. - BankIndex legacy; + BankModel legacy; CHECK(legacy.add(sampleWith("l1")) == AddResult::Added); CHECK(legacy.add(sampleWith("l2")) == AddResult::Added); const std::string legacyJson = legacy.serialize(); @@ -449,7 +473,7 @@ static void testLoadEmptyWhenNeither() { static void testLoadMalformedBanksDegradesWithoutLegacyFallback() { // A present-but-malformed `banks` blob must degrade to an empty book and must NOT // resurrect the stale legacy key (that would revive superseded single-bank state). - BankIndex stale; + BankModel stale; CHECK(stale.add(sampleWith("stale")) == AddResult::Added); const std::string legacyJson = stale.serialize(); @@ -789,123 +813,13 @@ static void testUpdateSampleInPlace() { // =========================================================================== // L7 — SlotMap (gap-preserving display positions) + BankBook ordering/reorder/replace // =========================================================================== - -// --- SlotMap unit behaviour -------------------------------------------------- - -static void testSlotMapDenseAppend() { - SlotMap m; - m.append("a"); - m.append("b"); - m.append("c"); - CHECK(m.slotOf("a") == 0); - CHECK(m.slotOf("b") == 1); - CHECK(m.slotOf("c") == 2); - CHECK(m.maxSlot() == 2); - CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); - CHECK(m.idAt(1) == "b"); - CHECK(m.slotOf("nope") == -1); -} - -static void testSlotMapRemoveLeavesGap() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - CHECK(m.remove("b")); // slot 1 now EMPTY (no re-pack) - CHECK(m.slotOf("a") == 0); - CHECK(m.slotOf("c") == 2); // c did NOT shift down - CHECK(m.idAt(1).empty()); // gap preserved - CHECK((m.orderedIds() == std::vector{"a", "c"})); - CHECK(!m.remove("b")); // already gone -} - -static void testSlotMapAppendAfterGapGoesToFrontier() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - m.remove("a"); // slot 0 empty - m.append("d"); // append goes AFTER last occupied (2) -> 3 - CHECK(m.slotOf("d") == 3); // did NOT fill the slot-0 gap - CHECK(m.idAt(0).empty()); -} - -static void testSlotMapReorderIntoEmpty() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - m.remove("b"); // slot 1 empty - CHECK(m.reorder("c", 1)); // c -> empty slot 1; its slot 2 empties - CHECK(m.slotOf("c") == 1); - CHECK(m.idAt(2).empty()); - CHECK(m.slotOf("a") == 0); // untouched -} - -static void testSlotMapReorderOntoOccupiedInsertsAndShifts() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); m.append("d"); // 0,1,2,3 - CHECK(m.reorder("d", 1)); // d onto occupied slot 1 -> insert-before, shift b,c up - CHECK(m.slotOf("a") == 0); // before the target: unchanged - CHECK(m.slotOf("d") == 1); // took the target slot - CHECK(m.slotOf("b") == 2); // shifted +1 - CHECK(m.slotOf("c") == 3); // shifted +1 - CHECK((m.orderedIds() == std::vector{"a", "d", "b", "c"})); -} - -static void testSlotMapReorderPreservesInteriorGapAboveTarget() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - m.remove("b"); // gap at 1: a@0, c@2 - m.append("d"); // d@3 - CHECK(m.reorder("d", 0)); // d onto occupied slot 0 -> a shifts to 1, c shifts to 3 - CHECK(m.slotOf("d") == 0); - CHECK(m.slotOf("a") == 1); // shifted from 0 -> 1 - CHECK(m.slotOf("c") == 3); // shifted from 2 -> 3 (gap at 2 preserved as a +1 of its own) - CHECK(m.idAt(2).empty()); // interior gap above the target survives -} - -static void testSlotMapReorderUnmappedIsNoOp() { - SlotMap m; - m.append("a"); - CHECK(!m.reorder("ghost", 0)); // not mapped -> false, no mutation - CHECK(m.slotOf("a") == 0); -} - -static void testSlotMapNegativeTargetClampsToZero() { - SlotMap m; - m.append("a"); m.append("b"); // 0,1 - CHECK(m.reorder("b", -3)); // clamp to 0 -> insert-before a - CHECK(m.slotOf("b") == 0); - CHECK(m.slotOf("a") == 1); -} - -static void testSlotMapResetDenseSkipsDupesAndEmpties() { - SlotMap m; - m.resetDense({"a", "", "b", "a", "c"}); // "" and the second "a" dropped - CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); - CHECK(m.slotOf("a") == 0); - CHECK(m.slotOf("c") == 2); -} - -static void testSlotMapReconcileDropsStaleAppendsNew() { - SlotMap m; - m.append("a"); m.append("b"); m.append("c"); // 0,1,2 - m.reconcile({"a", "c", "d"}); // b left the index (drop), d is new (append) - CHECK(m.slotOf("a") == 0); // kept at its slot - CHECK(m.slotOf("c") == 2); // kept at its slot (gap where b was) - CHECK(m.slotOf("b") == -1); // stale marker dropped - CHECK(m.slotOf("d") == 3); // appended after the frontier - CHECK(m.idAt(1).empty()); // b's slot stays empty -} - -static void testSlotMapEqualityAndFromEntries() { - SlotMap a; - a.append("x"); a.append("y"); - SlotMap b = SlotMap::fromEntries({{"x", 0}, {"y", 1}}); - CHECK(a == b); - // Defensive repair: duplicate id (first wins), slot conflict (later dropped), - // empty id / negative slot dropped. - SlotMap c = SlotMap::fromEntries({{"x", 0}, {"x", 5}, {"y", 0}, {"", 9}, {"z", -1}, {"w", 2}}); - CHECK(c.slotOf("x") == 0); // first x wins - CHECK(c.slotOf("y") == -1); // slot 0 already taken -> dropped - CHECK(c.slotOf("w") == 2); // valid - CHECK(c.slotOf("z") == -1); // negative slot dropped -} +// +// Pure SlotMap-only unit behaviour (add/remove/query, reorder gap-preservation, +// resetDense/reconcile, equality/fromEntries, serialize golden literal + round +// trip) now lives in test_slot_map.cpp (Q-W1 follow-up), extracted per the house +// every-pure-module-has-a-_tests rule. This file keeps the BankBook-level +// integration coverage below: reorderSample / reconcileSlots / JSON round-trip +// WITH a full book. // --- BankBook L7: JSON round-trip WITH positions ----------------------------- @@ -936,7 +850,7 @@ static void testBankBookSlotsRoundTrip() { static void testMigrationDefaultsToInsertionOrderDense() { // A pre-L7 legacy bank_index blob carries no slot data. On load -> reconcileSlots // seeds dense insertion order (no gaps), so it is visually identical. - BankIndex legacy; + BankModel legacy; CHECK(legacy.add(sampleWith("o1")) == AddResult::Added); CHECK(legacy.add(sampleWith("o2")) == AddResult::Added); CHECK(legacy.add(sampleWith("o3")) == AddResult::Added); @@ -1080,6 +994,7 @@ static void testReplaceSampleInPoolPassesGuard() { } int main() { + testSerializeGoldenLiteral(); testPoolSeededAndDefaults(); testPoolPrivileges(); testCreateRenameReorder(); @@ -1116,18 +1031,8 @@ int main() { testRemoveAllBanksLatentScope(); testUpdateSampleInPlace(); - // L7 — SlotMap + ordering/reorder/replace + slot round-trip/migration. - testSlotMapDenseAppend(); - testSlotMapRemoveLeavesGap(); - testSlotMapAppendAfterGapGoesToFrontier(); - testSlotMapReorderIntoEmpty(); - testSlotMapReorderOntoOccupiedInsertsAndShifts(); - testSlotMapReorderPreservesInteriorGapAboveTarget(); - testSlotMapReorderUnmappedIsNoOp(); - testSlotMapNegativeTargetClampsToZero(); - testSlotMapResetDenseSkipsDupesAndEmpties(); - testSlotMapReconcileDropsStaleAppendsNew(); - testSlotMapEqualityAndFromEntries(); + // L7 — BankBook ordering/reorder/replace + slot round-trip/migration. + // (Pure SlotMap-only unit behaviour lives in slot_map_tests.) testBankBookSlotsRoundTrip(); testMigrationDefaultsToInsertionOrderDense(); testOrderedSampleIdsReconcilesLazily(); diff --git a/tests/test_bank_grid.cpp b/tests/test_bank_grid.cpp index 9d178b8..2c0b07e 100644 --- a/tests/test_bank_grid.cpp +++ b/tests/test_bank_grid.cpp @@ -13,7 +13,7 @@ // keyboard nav (arrow clamp, row moves, shift-extend, partial-last-row clamp, // fresh-panel focus). -#include "../src/bank_grid.h" +#include "../src/core/ui/bank_grid.h" #include #include @@ -21,6 +21,7 @@ #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index 5de98db..299aef0 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -6,12 +6,13 @@ // present AND absent), dedup-by-hash collapse, tier filter + tier move, // relative-path invariant, empty-index round-trip, malformed/truncated JSON. -#include "../src/bank_model.h" +#include "../src/core/model/bank_model.h" #include #include using namespace reasampler; +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -65,12 +66,12 @@ static Sample minimalSample(const std::string& seed) { } static void testFullFieldRoundTrip() { - BankIndex idx; + BankModel idx; CHECK(idx.add(fullSample("a")) == AddResult::Added); CHECK(idx.add(minimalSample("b")) == AddResult::Added); std::string json = idx.serialize(); - auto back = BankIndex::deserialize(json); + auto back = BankModel::deserialize(json); CHECK(back.has_value()); CHECK(back && *back == idx); @@ -103,8 +104,32 @@ static void testFullFieldRoundTrip() { } } +// Golden byte-literal (Q-W1 T?-05 follow-up): pins the EXACT serialized bytes for +// a small fixture, not just self-consistent re-serialization — a format drift +// that round-trips losslessly (e.g. a renamed key both writer and reader agree +// on) would slip past testFullFieldRoundTrip but not this. The format is frozen +// as-shipped; the literal below is the captured current output. +static void testSerializeGoldenLiteral() { + BankModel idx; + Sample s; + s.id = "g1"; + s.relativePath = "bank/g1.wav"; + s.contentHash = "hash-g1"; + CHECK(idx.add(s) == AddResult::Added); + CHECK(idx.serialize() == + "{\"version\":1,\"samples\":[{\"id\":\"g1\",\"displayName\":\"\"," + "\"relativePath\":\"bank/g1.wav\",\"sourceMode\":0,\"sourceRange\":{" + "\"startSeconds\":0,\"endSeconds\":0,\"startPpq\":0,\"endPpq\":0}," + "\"trackGuids\":[],\"wetDry\":1,\"channelCount\":0,\"sampleRate\":0," + "\"lengthSeconds\":0,\"lengthBeats\":0,\"captureTempo\":0," + "\"captureTimeSigNum\":0,\"captureTimeSigDenom\":0,\"key\":null," + "\"rootNote\":null,\"loop\":null,\"levels\":{\"peakDb\":0,\"rmsDb\":0," + "\"lufs\":0},\"clipped\":false,\"tier\":0,\"contentHash\":\"hash-g1\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"); +} + static void testDedupByHash() { - BankIndex idx; + BankModel idx; Sample a = fullSample("x"); CHECK(idx.add(a) == AddResult::Added); @@ -128,7 +153,7 @@ static void testDedupByHash() { } static void testTierFilterAndMove() { - BankIndex idx; + BankModel idx; Sample scratch = minimalSample("s"); scratch.tier = Tier::Scratch; Sample archive = fullSample("a"); archive.tier = Tier::Archive; CHECK(idx.add(scratch) == AddResult::Added); @@ -153,7 +178,7 @@ static void testTierFilterAndMove() { } static void testRelativePathInvariant() { - BankIndex idx; + BankModel idx; // POSIX absolute, Windows drive, Windows backslash, UNC — all rejected. const char* absolutes[] = { @@ -184,7 +209,7 @@ static void testRelativePathInvariant() { // bypasses dedup (an in-place refresh is not a new insert). The relative-paths-only // invariant still guards the replacement. static void testUpdateInPlace() { - BankIndex idx; + BankModel idx; CHECK(idx.add(minimalSample("a")) == AddResult::Added); // id "min-a" CHECK(idx.add(minimalSample("b")) == AddResult::Added); // id "min-b" CHECK(idx.add(minimalSample("c")) == AddResult::Added); // id "min-c" @@ -221,10 +246,10 @@ static void testUpdateInPlace() { } static void testEmptyIndexRoundTrip() { - BankIndex idx; + BankModel idx; CHECK(idx.empty()); std::string json = idx.serialize(); - auto back = BankIndex::deserialize(json); + auto back = BankModel::deserialize(json); CHECK(back.has_value()); CHECK(back && back->empty()); CHECK(back && *back == idx); @@ -244,17 +269,17 @@ static void testMalformedJson() { "{\"samples\":[{\"createdTimestamp\":notanumber}]}", }; for (const char* j : bad) { - auto r = BankIndex::deserialize(j); + auto r = BankModel::deserialize(j); CHECK(!r.has_value()); // signaled as nullopt, no crash / UB } // A well-formed empty object deserializes to an empty index (lenient root). - auto ok = BankIndex::deserialize("{}"); + auto ok = BankModel::deserialize("{}"); CHECK(ok.has_value() && ok->empty()); } static void testRemoveAndQuery() { - BankIndex idx; + BankModel idx; CHECK(idx.add(fullSample("1")) == AddResult::Added); CHECK(idx.add(fullSample("2")) == AddResult::Added); CHECK(idx.query("id-1") != nullptr); @@ -267,7 +292,7 @@ static void testRemoveAndQuery() { // Fix 1: drive-relative and bare-drive forms must be rejected by add(). static void testAbsolutePathDriveRelative() { - BankIndex idx; + BankModel idx; // Drive-relative: resolves against the drive's CWD, not the project root. Sample dr = minimalSample("dr"); @@ -295,7 +320,7 @@ static void testAbsolutePathDriveRelative() { static void testUnicodeEscapeDecoding() { // é = U+00E9 → 2-byte UTF-8: 0xC3 0xA9 // JSON: "é" - auto r1 = BankIndex::deserialize( + auto r1 = BankModel::deserialize( "{\"samples\":[{\"id\":\"u1\",\"relativePath\":\"bank/u.wav\"," "\"displayName\":\"\\u00e9\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -319,7 +344,7 @@ static void testUnicodeEscapeDecoding() { // 中 = U+4E2D → 3-byte UTF-8: 0xE4 0xB8 0xAD // JSON: "中" - auto r2 = BankIndex::deserialize( + auto r2 = BankModel::deserialize( "{\"samples\":[{\"id\":\"u2\",\"relativePath\":\"bank/u.wav\"," "\"displayName\":\"\\u4e2d\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -343,7 +368,7 @@ static void testUnicodeEscapeDecoding() { } // 😀 = U+1F600 → surrogate pair 😀 → 4-byte UTF-8: 0xF0 0x9F 0x98 0x80 - auto r3 = BankIndex::deserialize( + auto r3 = BankModel::deserialize( "{\"samples\":[{\"id\":\"u3\",\"relativePath\":\"bank/u.wav\"," "\"displayName\":\"\\uD83D\\uDE00\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -368,7 +393,7 @@ static void testUnicodeEscapeDecoding() { } // Unpaired high surrogate (no following \uDCxx) → nullopt. - auto r4 = BankIndex::deserialize( + auto r4 = BankModel::deserialize( "{\"samples\":[{\"id\":\"u4\",\"relativePath\":\"bank/u.wav\"," "\"displayName\":\"\\uD83D\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -384,7 +409,7 @@ static void testUnicodeEscapeDecoding() { // Fix 3: strtoll overflow must reject the value, not clamp it silently. static void testIntegerOverflow() { // A timestamp value that overflows int64_t (> 9223372036854775807). - auto r = BankIndex::deserialize( + auto r = BankModel::deserialize( "{\"samples\":[{\"id\":\"ov1\",\"relativePath\":\"bank/ov.wav\"," "\"displayName\":\"\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -400,7 +425,7 @@ static void testIntegerOverflow() { // Fix 4: out-of-range enum values must reject the sample, not produce invalid enum. static void testEnumRangeValidation() { // tier: 99 is not a valid Tier enumerator. - auto r1 = BankIndex::deserialize( + auto r1 = BankModel::deserialize( "{\"samples\":[{\"id\":\"en1\",\"relativePath\":\"bank/en.wav\"," "\"displayName\":\"\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -413,7 +438,7 @@ static void testEnumRangeValidation() { CHECK(!r1.has_value()); // sourceMode: 99 is not a valid SourceMode enumerator. - auto r2 = BankIndex::deserialize( + auto r2 = BankModel::deserialize( "{\"samples\":[{\"id\":\"en2\",\"relativePath\":\"bank/en.wav\"," "\"displayName\":\"\"," "\"sourceMode\":99,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," @@ -442,7 +467,7 @@ static void testLegacyJsonDefaults() { "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-leg1\"," "\"provenance\":null,\"createdTimestamp\":0}]}"; - auto r = BankIndex::deserialize(legacy); + auto r = BankModel::deserialize(legacy); CHECK(r.has_value()); if (r) { const Sample* s = r->query("leg1"); @@ -453,7 +478,7 @@ static void testLegacyJsonDefaults() { // Re-serialize is lossless: parsing it again yields an equal index. This // proves the absent fields did not silently gain values on the way out. std::string out = r->serialize(); - auto again = BankIndex::deserialize(out); + auto again = BankModel::deserialize(out); CHECK(again.has_value()); CHECK(again && *again == *r); if (again) { @@ -471,7 +496,7 @@ static void testLegacyJsonDefaults() { // storing a bogus value. static void testSeamFieldBoundaries() { // rootNote at both MIDI edges + equal-and-end-anchored loop points round-trip. - BankIndex idx; + BankModel idx; Sample lo = minimalSample("lo"); lo.contentHash = "h-lo"; lo.rootNote = 0; lo.loop = LoopPoints{0, 0}; // zero-length marker at frame 0 @@ -484,7 +509,7 @@ static void testSeamFieldBoundaries() { CHECK(idx.add(hi) == AddResult::Added); CHECK(idx.add(end) == AddResult::Added); - auto back = BankIndex::deserialize(idx.serialize()); + auto back = BankModel::deserialize(idx.serialize()); CHECK(back.has_value()); CHECK(back && *back == idx); if (back) { @@ -514,21 +539,21 @@ static void testSeamFieldBoundaries() { "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-bad\"," "\"provenance\":null,\"createdTimestamp\":0}]}"; - CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value()); - CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value()); - CHECK(!BankIndex::deserialize( + CHECK(!BankModel::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value()); + CHECK(!BankModel::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value()); + CHECK(!BankModel::deserialize( std::string(head) + "\"loop\":{\"start\":10,\"end\":5}," + tail).has_value()); // start > end - CHECK(!BankIndex::deserialize( + CHECK(!BankModel::deserialize( std::string(head) + "\"loop\":{\"start\":-1,\"end\":5}," + tail).has_value()); // negative start } // S2 test case 3: the seam-field addition is purely additive — dedup-by-hash, tier -// moves/filtering, and BankIndex ordering are byte-for-byte unchanged by the +// moves/filtering, and BankModel ordering are byte-for-byte unchanged by the // presence (or absence) of rootNote/loop. Two samples differing ONLY in seam fields // but sharing a content hash still collapse; a seam-populated sample tiers exactly // like any other. static void testSeamFieldsAdditiveInvariant() { - BankIndex idx; + BankModel idx; Sample a = fullSample("z"); // has rootNote + loop populated CHECK(idx.add(a) == AddResult::Added); @@ -550,6 +575,7 @@ static void testSeamFieldsAdditiveInvariant() { int main() { testFullFieldRoundTrip(); + testSerializeGoldenLiteral(); testDedupByHash(); testTierFilterAndMove(); testRelativePathInvariant(); diff --git a/tests/test_bank_sync.cpp b/tests/test_bank_sync.cpp index cdcad2a..4195ecb 100644 --- a/tests/test_bank_sync.cpp +++ b/tests/test_bank_sync.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::bank_sync — no REAPER, no VST3, no framework. +// Standalone tests for reasampler::instrument::map::bank_sync — no REAPER, no VST3, no framework. // The S9 bank-generation change-detection + the S8 assignment-request consume DECISION // (the yes/no maths the instrument's off-audio-thread poll runs). The shell owns the // cadence + side effects; this proves the decision rules without a host. @@ -8,7 +8,7 @@ // (no request / not-newer / non-target / unresolvable-drop / apply), asserting both the // apply flag AND the advanced-marker value so a stale request is never re-evaluated. -#include "../src/vst/bank_sync.h" +#include "../src/core/instrument/map/bank_sync.h" #include #include @@ -16,7 +16,7 @@ #include using namespace reasampler; -using namespace reasampler::vst; +using namespace reasampler::instrument::map; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_batch_capture.cpp b/tests/test_batch_capture.cpp index 3cb8e93..f8dbd3b 100644 --- a/tests/test_batch_capture.cpp +++ b/tests/test_batch_capture.cpp @@ -9,13 +9,14 @@ // * batch result aggregation: all-success; partial-failure ORDERING (failed // ordinals reported in unit order); all-failed; single-unit noun singular. -#include "../src/batch_capture.h" +#include "../src/core/capture/batch_capture.h" #include #include #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp index 8c6abbb..e4d7b65 100644 --- a/tests/test_bridge_marshal.cpp +++ b/tests/test_bridge_marshal.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::bridge_marshal — no VST3, no REAPER, no test +// Standalone tests for reasampler::instrument::map::bridge_marshal — no VST3, no REAPER, no test // framework. Same fast assert loop as the sibling pure tests: assert the REAPER // bridge-read marshalling (GetProjExtState result decode) directly, so the DAW-facing // shell only has to invoke the API. @@ -6,13 +6,23 @@ // Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer // guard). The S1 spike's extractJsonStringField string-scan reader was retired in S4 // (the instrument now parses the bank through the shared bank_book JSON path), so its -// cases are gone with it. +// cases are gone with it. Q-W5 (rider T2-04) added readProjExtStateGrowing — the ONE +// grow-loop retry policy shared by persist / usage_scan / reaper_bridge, rehomed to +// core/wire/ext_state_read.h in Q-W6 and still pinned here alongside the decode — covered +// against a fake read: absent, small-fit, grow-then-fit, empty-complete (composed with +// the decode guard), and the 16 MB overflow give-up. The overflow case is +// prune-safety-adjacent (usage_scan folds it to abortPrune), so it is pinned here. -#include "../src/vst/bridge_marshal.h" +#include "../src/core/instrument/map/bridge_marshal.h" +#include "../src/core/wire/ext_state_read.h" // readProjExtStateGrowing (Q-W6 rehome) #include +#include +#include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::map; +using namespace reasampler::wire; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -45,12 +55,100 @@ static void testDecodeEmptyBuffer() { CHECK(!v.has_value()); } +// --- readProjExtStateGrowing (the T2-04 shared grow-loop policy) ------------- + +// A fake GetProjExtState: honors the buffer contract (writes at most cap-1 chars + +// NUL — REAPER clips to the caller's capacity) and returns the stored value's length. +static int fakeRead(const std::string& stored, char* buf, int cap) { + const std::size_t n = + stored.size() < static_cast(cap - 1) + ? stored.size() + : static_cast(cap - 1); + std::memcpy(buf, stored.data(), n); + buf[n] = '\0'; + return static_cast(stored.size()); +} + +static void testGrowingAbsent() { + // rv <= 0 on the first attempt: the key holds no value — Absent, no retry. + int calls = 0; + const auto r = readProjExtStateGrowing([&](char*, int) { + ++calls; + return 0; + }); + CHECK(r.status == GrowingExtStateRead::Status::Absent); + CHECK(r.apiReturn == 0); + CHECK(calls == 1); +} + +static void testGrowingSmallValueFitsFirstAttempt() { + const std::string stored = "hello"; + int calls = 0; + const auto r = readProjExtStateGrowing([&](char* buf, int cap) { + ++calls; + return fakeRead(stored, buf, cap); + }); + CHECK(r.status == GrowingExtStateRead::Status::Complete); + CHECK(r.value == stored); + CHECK(r.apiReturn == 5); + CHECK(calls == 1); +} + +static void testGrowingRetriesUntilStrictFit() { + // A value whose C string exactly fills the first buffer (size+1 == cap) is + // AMBIGUOUS — it may have been clipped — so the policy must retry at the next + // capacity, where it fits strictly and returns whole. + const std::string stored(static_cast((1 << 16) - 1), 'x'); + int calls = 0; + const auto r = readProjExtStateGrowing([&](char* buf, int cap) { + ++calls; + return fakeRead(stored, buf, cap); + }); + CHECK(r.status == GrowingExtStateRead::Status::Complete); + CHECK(r.value == stored); + CHECK(calls == 2); +} + +static void testGrowingEmptyCompleteComposesWithDecodeGuard() { + // rv > 0 but an empty buffer: the loop reports a Complete empty value, and the + // bridge path's decodeGetProjExtState(apiReturn, value) still rejects it — the + // stale/empty-buffer guard survives the T2-04 rewire unchanged. + const auto r = readProjExtStateGrowing([&](char* buf, int) { + buf[0] = '\0'; + return 3; + }); + CHECK(r.status == GrowingExtStateRead::Status::Complete); + CHECK(r.value.empty()); + CHECK(!decodeGetProjExtState(r.apiReturn, r.value).has_value()); +} + +static void testGrowingOverflowGivesUpAtCeiling() { + // Every attempt clips (the value never fits under 16 MB): Overflow — which is + // "unreadable WHOLE", never Absent. usage_scan folds this to the prune fail-safe + // abort, so the distinction is load-bearing. Caps run 2^16..2^24 step ×4 = 5 tries. + int calls = 0; + const auto r = readProjExtStateGrowing([&](char* buf, int cap) { + ++calls; + std::memset(buf, 'a', static_cast(cap - 1)); + buf[cap - 1] = '\0'; + return cap; // reports a length that never strictly fits + }); + CHECK(r.status == GrowingExtStateRead::Status::Overflow); + CHECK(calls == 5); +} + int main() { testDecodeHit(); testDecodeAbsentKey(); testDecodeNegativeReturn(); testDecodeEmptyBuffer(); + testGrowingAbsent(); + testGrowingSmallValueFitsFirstAttempt(); + testGrowingRetriesUntilStrictFit(); + testGrowingEmptyCompleteComposesWithDecodeGuard(); + testGrowingOverflowGivesUpAtCeiling(); + if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n"); return g_fail != 0; } diff --git a/tests/test_browser_scroll.cpp b/tests/test_browser_scroll.cpp index 9448f52..fdb1441 100644 --- a/tests/test_browser_scroll.cpp +++ b/tests/test_browser_scroll.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::browser_scroll — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::browser_scroll — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure editor tests: assert the S12 scroll-window + // scrollbar-thumb + type-to-filter-search geometry LAYERED over the S10 capture_browser. // @@ -11,13 +11,14 @@ // (case-insensitive substring, empty-query identity, no-match); filterNameIndices preserving order // and returning every index for an empty query. -#include "../src/vst/browser_scroll.h" +#include "../src/core/instrument/ui/browser_scroll.h" #include #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -45,7 +46,7 @@ static void testMaxOffsetFitsAndOverflows() { CHECK(scrollMaxOffset(L, L.columns) == 0); // Many rows overflow -> max = content - gridHeight. const int many = L.columns * 20; - const int expect = scrollContentHeight(L, many) - L.grid.height(); + const int expect = scrollContentHeight(L, many) - L.grid.height; CHECK(scrollMaxOffset(L, many) == expect); CHECK(expect > 0); } @@ -67,7 +68,7 @@ static void testVisibleRangeTop() { const VisibleRange vr = visibleCardRange(L, many, 0); CHECK(vr.first == 0); // At offset 0, the last visible row is the one containing (gridH-1). - const int expectedLastRow = (L.grid.height() - 1) / kBrowserCardHeight + 1; + const int expectedLastRow = (L.grid.height - 1) / kBrowserCardHeight + 1; CHECK(vr.last == expectedLastRow * L.columns); } @@ -89,30 +90,30 @@ static void testScrolledCellShiftsUp() { const BrowserLayout L = wideLayout(); const Rect base = cardCellRect(L, 3); const Rect shifted = scrolledCardCellRect(L, 3, 40); - CHECK(shifted.top == base.top - 40); - CHECK(shifted.bottom == base.bottom - 40); - CHECK(shifted.left == base.left); + CHECK(shifted.y == base.y - 40); + CHECK(shifted.bottom() == base.bottom() - 40); + CHECK(shifted.x == base.x); } // --- scrollbar thumb ---------------------------------------------------------- static void testThumbEmptyWhenFits() { const BrowserLayout L = wideLayout(); - CHECK(scrollThumbRect(L, L.columns, 0).height() == 0); // one row fits -> no thumb + CHECK(scrollThumbRect(L, L.columns, 0).height == 0); // one row fits -> no thumb } static void testThumbProportionalAndClamped() { const BrowserLayout L = wideLayout(); const int many = L.columns * 20; const Rect atTop = scrollThumbRect(L, many, 0); - CHECK(atTop.height() > 0); - CHECK(atTop.top == L.grid.top); // at offset 0 the thumb starts at the track top - CHECK(atTop.width() == kScrollbarWidth); - CHECK(atTop.right == L.grid.right); + CHECK(atTop.height > 0); + CHECK(atTop.y == L.grid.y); // at offset 0 the thumb starts at the track top + CHECK(atTop.width == kScrollbarWidth); + CHECK(atTop.right() == L.grid.right()); // At max offset, the thumb bottom reaches the grid bottom (pinned to the end). const int maxOff = scrollMaxOffset(L, many); const Rect atMax = scrollThumbRect(L, many, maxOff); - CHECK(atMax.bottom == L.grid.top + L.grid.height()); + CHECK(atMax.bottom() == L.grid.y + L.grid.height); } static void testThumbDragIsInverse() { @@ -126,7 +127,7 @@ static void testThumbDragIsInverse() { CHECK(thumbDragToOffset(L, many, maxOff, -100000) == 0); // Dragging the thumb by the whole track span from top reaches (near) max. const Rect thumb = scrollThumbRect(L, many, 0); - const int trackSpan = L.grid.height() - thumb.height(); + const int trackSpan = L.grid.height - thumb.height; const int off = thumbDragToOffset(L, many, 0, trackSpan); CHECK(off >= maxOff - 2 && off <= maxOff); } @@ -135,8 +136,8 @@ static void testThumbDragIsInverse() { static void testSearchBoxRect() { const Rect r = searchBoxRect(200); - CHECK(r.left == 0 && r.top == 0 && r.right == 200 && r.height() == kSearchBoxHeight); - CHECK(searchBoxRect(0).width() == 0); + CHECK(r.x == 0 && r.y == 0 && r.right() == 200 && r.height == kSearchBoxHeight); + CHECK(searchBoxRect(0).width == 0); } static void testNameMatch() { @@ -159,6 +160,26 @@ static void testFilterIndices() { CHECK(filterNameIndices(names, "zzz").empty()); } +// --- The Browse-modal regions (Q-W2v hoist, T2-06) --------------------------- + +// Title / search / content / footer partition the window top-to-bottom without gaps; +// Back right-anchors in the title band; Cancel/Load flank the footer. +static void testBrowseModalPartition() { + const BrowseModal m = computeBrowseModal(840, 620); + CHECK(m.title.y == 0 && m.title.width == 840 && m.title.height == kTitleHeight); + CHECK(m.back.right() == 840 - kPad && m.back.bottom() <= m.title.bottom()); + CHECK(m.search.y == m.title.bottom()); + CHECK(m.search.height == searchBoxRect(840).height); + CHECK(m.content.y == m.search.bottom()); + CHECK(m.content.bottom() == 620 - 30); // the footer band (kBrowseFooterH) + CHECK(m.cancel.x == kPad); + CHECK(m.confirm.right() == 840 - kPad); + CHECK(m.cancel.y == m.content.bottom() + 3 && m.cancel.y == m.confirm.y); + // Degenerate short window: the footer clamps below the search box (no inversion). + const BrowseModal s = computeBrowseModal(840, 40); + CHECK(s.content.bottom() >= s.content.y); +} + int main() { testContentHeight(); testMaxOffsetFitsAndOverflows(); @@ -173,6 +194,7 @@ int main() { testSearchBoxRect(); testNameMatch(); testFilterIndices(); + testBrowseModalPartition(); if (g_fail == 0) std::printf("browser_scroll: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_capture_browser.cpp b/tests/test_capture_browser.cpp index 9f8a7a0..9c0239e 100644 --- a/tests/test_capture_browser.cpp +++ b/tests/test_capture_browser.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::capture_browser — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::capture_browser — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests (embed_strip / editor_geometry): assert // the capture-first browser's card-grid + bank-filter-tab layout and hit-testing directly. // @@ -10,11 +10,12 @@ // the strip into equal segments with the last tab absorbing the remainder; filterTabHitTest // hitting each tab and missing off-strip. -#include "../src/vst/capture_browser.h" +#include "../src/core/instrument/ui/capture_browser.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -25,12 +26,12 @@ static int g_fail = 0; static void testLayoutNormalArea() { // Wide enough for several columns of the fixed-width card. const BrowserLayout L = layoutBrowser(560, 300); - CHECK(L.tabStrip.left == 0 && L.tabStrip.top == 0 && L.tabStrip.right == 560); - CHECK(L.tabStrip.height() == kBrowserTabHeight); + CHECK(L.tabStrip.x == 0 && L.tabStrip.y == 0 && L.tabStrip.right() == 560); + CHECK(L.tabStrip.height == kBrowserTabHeight); // The grid starts right below the tab strip and fills the rest, contiguous. - CHECK(L.grid.top == L.tabStrip.bottom); - CHECK(L.grid.bottom == 300 && L.grid.right == 560); - // columns = grid.width() / cardWidth (>= 1). + CHECK(L.grid.y == L.tabStrip.bottom()); + CHECK(L.grid.bottom() == 300 && L.grid.right() == 560); + // columns = grid.width / cardWidth (>= 1). CHECK(L.columns == 560 / kBrowserCardWidth); CHECK(L.columns >= 1); } @@ -39,22 +40,22 @@ static void testLayoutNarrowAreaSingleColumn() { // Narrower than one card: still a single column, no inversion. const BrowserLayout L = layoutBrowser(kBrowserCardWidth - 10, 200); CHECK(L.columns == 1); - CHECK(L.grid.width() >= 0); - CHECK(L.tabStrip.height() == kBrowserTabHeight); + CHECK(L.grid.width >= 0); + CHECK(L.tabStrip.height == kBrowserTabHeight); } static void testLayoutZeroArea() { const BrowserLayout L = layoutBrowser(0, 0); - CHECK(L.tabStrip.width() == 0 && L.tabStrip.height() == 0); - CHECK(L.grid.width() == 0); + CHECK(L.tabStrip.width == 0 && L.tabStrip.height == 0); + CHECK(L.grid.width == 0); CHECK(L.columns == 1); // never zero (avoids a divide-by-zero in card layout) } static void testLayoutTinyHeightClampsTabStrip() { // A height below the tab band: the tab strip clamps to the area, the grid is empty. const BrowserLayout L = layoutBrowser(560, kBrowserTabHeight - 6); - CHECK(L.tabStrip.height() == kBrowserTabHeight - 6); - CHECK(L.grid.height() <= 0); // no room left for cards + CHECK(L.tabStrip.height == kBrowserTabHeight - 6); + CHECK(L.grid.height <= 0); // no room left for cards } // --- card rects --------------------------------------------------------------- @@ -64,32 +65,32 @@ static void testCardCellsTileRowMajor() { const int cols = L.columns; // Card 0 is top-left of the grid. const Rect c0 = cardCellRect(L, 0); - CHECK(c0.left == L.grid.left && c0.top == L.grid.top); - CHECK(c0.width() == kBrowserCardWidth && c0.height() == kBrowserCardHeight); + CHECK(c0.x == L.grid.x && c0.y == L.grid.y); + CHECK(c0.width == kBrowserCardWidth && c0.height == kBrowserCardHeight); // Card 1 is one card-width to the right, same row. const Rect c1 = cardCellRect(L, 1); - CHECK(c1.left == L.grid.left + kBrowserCardWidth); - CHECK(c1.top == c0.top); + CHECK(c1.x == L.grid.x + kBrowserCardWidth); + CHECK(c1.y == c0.y); // The first card of the SECOND row wraps back to the left, one card-height down. const Rect wrap = cardCellRect(L, cols); - CHECK(wrap.left == L.grid.left); - CHECK(wrap.top == L.grid.top + kBrowserCardHeight); + CHECK(wrap.x == L.grid.x); + CHECK(wrap.y == L.grid.y + kBrowserCardHeight); } static void testCardCellNegativeIndex() { const BrowserLayout L = layoutBrowser(560, 300); const Rect r = cardCellRect(L, -1); - CHECK(r.left == 0 && r.top == 0 && r.right == 0 && r.bottom == 0); + CHECK(r.x == 0 && r.y == 0 && r.right() == 0 && r.bottom() == 0); } static void testCardContentInsetByGutter() { const BrowserLayout L = layoutBrowser(560, 300); const Rect cell = cardCellRect(L, 0); const Rect content = cardContentRect(L, 0); - CHECK(content.left == cell.left + kBrowserCardGutter); - CHECK(content.top == cell.top + kBrowserCardGutter); - CHECK(content.right == cell.right - kBrowserCardGutter); - CHECK(content.bottom == cell.bottom - kBrowserCardGutter); + CHECK(content.x == cell.x + kBrowserCardGutter); + CHECK(content.y == cell.y + kBrowserCardGutter); + CHECK(content.right() == cell.right() - kBrowserCardGutter); + CHECK(content.bottom() == cell.bottom() - kBrowserCardGutter); } static void testThumbnailAboveLabel() { @@ -98,12 +99,12 @@ static void testThumbnailAboveLabel() { const Rect thumb = cardThumbnailRect(L, 0); const Rect label = cardLabelRect(L, 0); // Thumbnail is the top band of the content; the label is the remainder below it, contiguous. - CHECK(thumb.left == content.left && thumb.right == content.right); - CHECK(thumb.top == content.top); - CHECK(thumb.height() == kBrowserThumbHeight); - CHECK(label.top == thumb.bottom); - CHECK(label.bottom == content.bottom); - CHECK(label.left == content.left && label.right == content.right); + CHECK(thumb.x == content.x && thumb.right() == content.right()); + CHECK(thumb.y == content.y); + CHECK(thumb.height == kBrowserThumbHeight); + CHECK(label.y == thumb.bottom()); + CHECK(label.bottom() == content.bottom()); + CHECK(label.x == content.x && label.right() == content.right()); } // --- cardHitTest -------------------------------------------------------------- @@ -111,8 +112,8 @@ static void testThumbnailAboveLabel() { static void testCardHitCenterOfCard() { const BrowserLayout L = layoutBrowser(560, 300); const Rect content = cardContentRect(L, 3); - const int cx = content.left + content.width() / 2; - const int cy = content.top + content.height() / 2; + const int cx = content.x + content.width / 2; + const int cy = content.y + content.height / 2; CHECK(cardHitTest(L, 12, cx, cy) == 3); } @@ -121,21 +122,21 @@ static void testCardHitMissesGutter() { // A point in the gutter between the content and the cell edge (top-left corner of cell 0) // is a miss — only the card CONTENT counts. const Rect cell = cardCellRect(L, 0); - CHECK(cardHitTest(L, 12, cell.left, cell.top) == -1); + CHECK(cardHitTest(L, 12, cell.x, cell.y) == -1); } static void testCardHitMissesPastLastCard() { const BrowserLayout L = layoutBrowser(560, 300); // Only 2 cards exist; a point on where card 5 WOULD be is a miss. const Rect content = cardContentRect(L, 5); - const int cx = content.left + content.width() / 2; - const int cy = content.top + content.height() / 2; + const int cx = content.x + content.width / 2; + const int cy = content.y + content.height / 2; CHECK(cardHitTest(L, 2, cx, cy) == -1); } static void testCardHitMissesTabStrip() { const BrowserLayout L = layoutBrowser(560, 300); - CHECK(cardHitTest(L, 12, 10, L.tabStrip.top + 2) == -1); + CHECK(cardHitTest(L, 12, 10, L.tabStrip.y + 2) == -1); } static void testCardHitZeroCards() { @@ -150,21 +151,21 @@ static void testFilterTabsTileStrip() { const int n = 4; // "All" + 3 banks const Rect t0 = filterTabRect(L, n, 0); const Rect tLast = filterTabRect(L, n, n - 1); - CHECK(t0.left == L.tabStrip.left); + CHECK(t0.x == L.tabStrip.x); // Adjacent tabs share an exact edge (no gap). - CHECK(filterTabRect(L, n, 0).right == filterTabRect(L, n, 1).left); - CHECK(filterTabRect(L, n, 1).right == filterTabRect(L, n, 2).left); + CHECK(filterTabRect(L, n, 0).right() == filterTabRect(L, n, 1).x); + CHECK(filterTabRect(L, n, 1).right() == filterTabRect(L, n, 2).x); // The last tab reaches the strip's right edge exactly (absorbs the remainder). - CHECK(tLast.right == L.tabStrip.right); + CHECK(tLast.right() == L.tabStrip.right()); // All tabs share the strip's height. - CHECK(t0.top == L.tabStrip.top && t0.bottom == L.tabStrip.bottom); + CHECK(t0.y == L.tabStrip.y && t0.bottom() == L.tabStrip.bottom()); } static void testFilterTabOutOfRange() { const BrowserLayout L = layoutBrowser(560, 300); - CHECK(filterTabRect(L, 3, -1).width() == 0); - CHECK(filterTabRect(L, 3, 3).width() == 0); - CHECK(filterTabRect(L, 0, 0).width() == 0); + CHECK(filterTabRect(L, 3, -1).width == 0); + CHECK(filterTabRect(L, 3, 3).width == 0); + CHECK(filterTabRect(L, 0, 0).width == 0); } static void testFilterTabHit() { @@ -172,12 +173,12 @@ static void testFilterTabHit() { const int n = 3; for (int i = 0; i < n; ++i) { const Rect t = filterTabRect(L, n, i); - const int cx = t.left + t.width() / 2; - const int cy = t.top + t.height() / 2; + const int cx = t.x + t.width / 2; + const int cy = t.y + t.height / 2; CHECK(filterTabHitTest(L, n, cx, cy) == i); } // Below the strip (in the grid) -> no tab. - CHECK(filterTabHitTest(L, n, 20, L.grid.top + 4) == -1); + CHECK(filterTabHitTest(L, n, 20, L.grid.y + 4) == -1); } int main() { diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index aff2b5e..3b1b95c 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -1,17 +1,15 @@ // Standalone tests for reasampler::capture_paths — no REAPER, no framework. // The capture shell is DAW-bound and only verifiable in REAPER; this covers the // one genuinely pure piece: the bank-folder / unique-name / project-relative -// path arithmetic that feeds BankIndex::add's relative-only invariant. +// path arithmetic that feeds BankModel::add's relative-only invariant. -#include "../src/capture_paths.h" +#include "../src/core/capture/capture_paths.h" -#include #include -#include // std::memcpy (for putF32cp in hashWavContent tests) #include -#include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -74,7 +72,7 @@ static void testDeriveRelativePathIsProjectRelative() { BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "master mix", "1753080000"); // Relative path is under the fixed bank subfolder, forward-slashed, .wav. CHECK(p.relativePath == "reasampler_bank/master_mix_1753080000.wav"); - // It must NOT be absolute by any of BankIndex::add's rejection rules: + // It must NOT be absolute by any of BankModel::add's rejection rules: // no leading '/', no drive letter, no backslash, no UNC prefix. CHECK(p.relativePath.find(':') == std::string::npos); CHECK(p.relativePath.find('\\') == std::string::npos); @@ -344,208 +342,9 @@ static void testTransitionInPlaceSaveIsNoOp() { == ProjectTransition::NoOp); } -// --- hashBytes (FNV-1a content hash) ---------------------------------------- -// -// The fix for the confirm-on-last-reference bug: hashBytes produces a 16-char hex -// string that capture.cpp and capture_realtime.cpp store on Sample::contentHash so -// BankBook::hashReferencedElsewhere can detect copies and suppress the confirm when -// another bank still holds the same file. - -static void testHashBytesOutputFormat() { - // Output is always 16 lowercase hex characters. - const std::uint8_t bytes[] = {0x01, 0x02, 0x03}; - const std::string h = hashBytes(bytes, 3); - CHECK(h.size() == 16); - for (char c : h) { - CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')); - } -} - -static void testHashBytesDeterministic() { - // Same input always produces the same output (bit-identical captures get - // the same hash, so hashReferencedElsewhere fires correctly for copies). - const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01}; - CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5)); -} - -static void testHashBytesDistinct() { - // Different inputs produce different hashes (no accidental dedup of distinct - // files). This covers the "one-bit-flip changes the hash" property. - std::uint8_t a[] = {0x00, 0x00}; - std::uint8_t b[] = {0x00, 0x01}; - CHECK(hashBytes(a, 2) != hashBytes(b, 2)); - - std::uint8_t c[] = {0xFF, 0xFF, 0xFF}; - std::uint8_t d[] = {0xFF, 0xFF, 0xFE}; - CHECK(hashBytes(c, 3) != hashBytes(d, 3)); -} - -static void testHashBytesEmptyBufferIsNonEmpty() { - // An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty - // sentinel) — capturing the contract that even empty inputs yield a 16-char hash. - const std::string h = hashBytes(nullptr, 0); - CHECK(h.size() == 16); -} - -static void testHashBytesLargerBufferDiffersFromSmaller() { - // Padding a buffer with a zero byte must change the hash (order + length - // sensitivity so two differently-sized WAV files don't accidentally collide). - const std::uint8_t short_buf[] = {0xAB, 0xCD}; - const std::uint8_t long_buf[] = {0xAB, 0xCD, 0x00}; - CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3)); -} - -// --- hashWavContent (WAV-aware dedup hash) ----------------------------------- -// -// Verifies that the WAV-content hash hashes only fmt+data (skipping metadata -// chunks like bext/LIST), falls back gracefully for non-WAV input, and that -// different audio data yields different hashes. - -// Minimal synthetic WAV builder (mirrors the one in test_wav_trim.cpp). -static void putU16cp(std::vector& b, std::uint16_t v) { - b.push_back(static_cast(v & 0xFF)); - b.push_back(static_cast((v >> 8) & 0xFF)); -} -static void putU32cp(std::vector& b, std::uint32_t v) { - b.push_back(static_cast(v & 0xFF)); - b.push_back(static_cast((v >> 8) & 0xFF)); - b.push_back(static_cast((v >> 16) & 0xFF)); - b.push_back(static_cast((v >> 24) & 0xFF)); -} -static void putTagcp(std::vector& b, const char* t) { - for (int i = 0; i < 4; ++i) b.push_back(static_cast(t[i])); -} -static void putF32cp(std::vector& b, float f) { - std::uint8_t tmp[4]; - std::memcpy(tmp, &f, 4); - for (int i = 0; i < 4; ++i) b.push_back(tmp[i]); -} - -// Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk -// inserted between "WAVE" and the fmt chunk. `metaChunkBody` and `metaTag` are -// used when `insertMeta` is true. This is the shape REAPER produces: a `bext` -// or `LIST` chunk before fmt with a render-time timestamp in the body. -static std::vector buildTestWav( - std::uint16_t channels, std::uint32_t sampleRate, - const std::vector& samples, - bool insertMeta = false, - const char* metaTag = "bext", - const std::vector& metaBody = {}) { - - std::vector chunks; - - if (insertMeta && !metaBody.empty()) { - putTagcp(chunks, metaTag); - putU32cp(chunks, static_cast(metaBody.size())); - chunks.insert(chunks.end(), metaBody.begin(), metaBody.end()); - if (metaBody.size() & 1u) chunks.push_back(0); // RIFF pad - } - - // fmt chunk (16-byte body, IEEE-float tag 3). - const std::uint32_t dataBytes = - static_cast(samples.size() * 4u); - putTagcp(chunks, "fmt "); - putU32cp(chunks, 16); - putU16cp(chunks, 3); // IEEE float - putU16cp(chunks, channels); - putU32cp(chunks, sampleRate); - putU32cp(chunks, sampleRate * channels * 4u); // byteRate - putU16cp(chunks, static_cast(channels * 4)); // blockAlign - putU16cp(chunks, 32); // bitsPerSample - - // data chunk. - putTagcp(chunks, "data"); - putU32cp(chunks, dataBytes); - for (float f : samples) putF32cp(chunks, f); - - std::vector wav; - putTagcp(wav, "RIFF"); - putU32cp(wav, static_cast(4 + chunks.size())); - putTagcp(wav, "WAVE"); - wav.insert(wav.end(), chunks.begin(), chunks.end()); - return wav; -} - -static void testHashWavContentIdenticalAudioSameHash() { - // Two WAVs with the same audio but different metadata body -> same hash. - // This is the core dedup regression: REAPER embeds a bext chunk with a - // render-time origination timestamp; without WAV-aware hashing, two renders - // of the same clip produce different file bytes -> no dedup collapse. - const std::vector audio = {0.1f, -0.2f, 0.3f, -0.4f}; - std::vector metaA(64, 0x00); // bext body, all zeros (e.g. epoch) - std::vector metaB(64, 0x00); - // Different origination timestamps: first 10 bytes of bext are ASCII date/time. - metaB[0] = '2'; metaB[1] = '0'; metaB[2] = '2'; metaB[3] = '6'; // year - - auto wavA = buildTestWav(1, 44100, audio, /*meta=*/true, "bext", metaA); - auto wavB = buildTestWav(1, 44100, audio, /*meta=*/true, "bext", metaB); - - // Files must differ (the bext body is different) to prove the test is valid. - CHECK(wavA != wavB); - - // But their content hashes must be equal: same fmt+data, different metadata. - CHECK(hashWavContent(wavA) == hashWavContent(wavB)); -} - -static void testHashWavContentDifferentAudioDifferentHash() { - // Different PCM data -> different content hashes (no false dedup). - const std::vector audioA = {0.5f, 0.5f}; - const std::vector audioB = {0.5f, 0.6f}; // last sample differs - - auto wavA = buildTestWav(1, 44100, audioA); - auto wavB = buildTestWav(1, 44100, audioB); - - CHECK(hashWavContent(wavA) != hashWavContent(wavB)); -} - -static void testHashWavContentDifferentFmtDifferentHash() { - // Different fmt fields (sample rate) -> different content hashes. - const std::vector audio = {0.1f, 0.2f}; - auto wav44 = buildTestWav(1, 44100, audio); - auto wav48 = buildTestWav(1, 48000, audio); - CHECK(hashWavContent(wav44) != hashWavContent(wav48)); -} - -static void testHashWavContentNonWavFallsBackToWholeFile() { - // Non-WAV bytes -> falls back to whole-file hashBytes; result is non-empty - // and equals hashBytes of the same bytes directly. - std::vector notWav = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02}; - const std::string h = hashWavContent(notWav); - CHECK(!h.empty()); - CHECK(h.size() == 16); - CHECK(h == hashBytes(notWav.data(), notWav.size())); -} - -static void testHashWavContentEmptyFallsBackToHashBytes() { - // Empty vector -> falls back to whole-file hashBytes (the FNV offset basis). - std::vector empty; - const std::string h = hashWavContent(empty); - CHECK(!h.empty()); - CHECK(h.size() == 16); - CHECK(h == hashBytes(nullptr, 0)); -} - -static void testHashWavContentListMetaSkipped() { - // A LIST/INFO chunk (another common metadata chunk) is likewise skipped. - const std::vector audio = {1.0f, -1.0f, 0.5f}; - std::vector listBody = {'I','N','F','O', 'x','x','x','x'}; - auto wavClean = buildTestWav(1, 48000, audio); - auto wavList = buildTestWav(1, 48000, audio, true, "LIST", listBody); - - // Content hashes must match: only the LIST chunk differs. - CHECK(hashWavContent(wavClean) == hashWavContent(wavList)); -} - -static void testHashWavContentDomainSeparationFromWholeFile() { - // The content hash ('W'-prefixed) must not accidentally equal the whole-file - // hash of the SAME bytes. This guards against the domain-separation prefix - // being dropped or zeroed out. - const std::vector audio = {0.0f}; - auto wav = buildTestWav(1, 44100, audio); - const std::string contentHash = hashWavContent(wav); - const std::string wholeHash = hashBytes(wav.data(), wav.size()); - CHECK(contentHash != wholeHash); -} +// NOTE (Q-W3, audit §4e): the hashBytes / hashWavContent tests moved to +// tests/test_wav_codec.cpp with the implementations — capture_paths is now path +// arithmetic only, with no content-hash / RIFF knowledge. // --- bankRelativeForName spelling consistency (Phase R, R2) ----------------- // @@ -596,18 +395,6 @@ int main() { testTransitionTwoUnsavedProjectsSwitchLoads(); testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps(); testTransitionInPlaceSaveIsNoOp(); - testHashBytesOutputFormat(); - testHashBytesDeterministic(); - testHashBytesDistinct(); - testHashBytesEmptyBufferIsNonEmpty(); - testHashBytesLargerBufferDiffersFromSmaller(); - testHashWavContentIdenticalAudioSameHash(); - testHashWavContentDifferentAudioDifferentHash(); - testHashWavContentDifferentFmtDifferentHash(); - testHashWavContentNonWavFallsBackToWholeFile(); - testHashWavContentEmptyFallsBackToHashBytes(); - testHashWavContentListMetaSkipped(); - testHashWavContentDomainSeparationFromWholeFile(); testBankRelativeForNameMatchesDerivePathSpelling(); testBankRelativeForNameConventionAndEdge(); diff --git a/tests/test_realtime_record.cpp b/tests/test_capture_realtime.cpp similarity index 97% rename from tests/test_realtime_record.cpp rename to tests/test_capture_realtime.cpp index c904ecc..ca5b2f6 100644 --- a/tests/test_realtime_record.cpp +++ b/tests/test_capture_realtime.cpp @@ -1,14 +1,16 @@ -// Standalone tests for reasampler::realtime_record — no REAPER, no framework. +// Standalone tests for reasampler::capture_realtime (renamed from realtime_record +// in Q-W3 — the Q-9 naming rider) — no REAPER, no framework. // Covers the two pure pieces behind the realtime-record backend (M8): the // record-mode/recipe bookkeeping (channel count + tap -> I_RECMODE / I_RECMODE_FLAGS) // and the wet/dry -> tap decision, plus the recorded-file -> Sample mapping. -#include "../src/realtime_record.h" +#include "../src/core/capture/capture_realtime.h" #include #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -327,7 +329,7 @@ int main() { testStopRequestedClassification(); testIsTerminalPhaseClassification(); - if (g_fail == 0) std::printf("realtime_record: all tests passed\n"); - else std::printf("realtime_record: %d CHECK(s) FAILED\n", g_fail); + if (g_fail == 0) std::printf("capture_realtime: all tests passed\n"); + else std::printf("capture_realtime: %d CHECK(s) FAILED\n", g_fail); return g_fail == 0 ? 0 : 1; } diff --git a/tests/test_card_drag.cpp b/tests/test_card_drag.cpp index a7018f4..473c6f1 100644 --- a/tests/test_card_drag.cpp +++ b/tests/test_card_drag.cpp @@ -7,11 +7,12 @@ // -> None); cursor-cue mapping (incl. Replace only for Replace); slot rects include empties // (gap layout), dense layout matches a plain grid, slot hit-test returns slot index + miss. -#include "../src/card_drag.h" +#include "../src/core/ui/card_drag.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_card_meta.cpp b/tests/test_card_meta.cpp index 37fcdbf..b94974b 100644 --- a/tests/test_card_meta.cpp +++ b/tests/test_card_meta.cpp @@ -6,12 +6,13 @@ // long capture, non-4/4 meters (3/4 and 6/8), unstamped meter -> blank, unknown tempo -> // blank (s.ms still derivable); s.ms zero / sub-second / multi-second / ms carry / negative. -#include "../src/card_meta.h" +#include "../src/core/ui/card_meta.h" #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_component_geometry.cpp b/tests/test_component_geometry.cpp index 3d97095..a793ac4 100644 --- a/tests/test_component_geometry.cpp +++ b/tests/test_component_geometry.cpp @@ -7,11 +7,12 @@ // row, hover hit-test returns the right row and "no hit" outside/past the last row; and the // shared half-open box hit-test agrees with layout (no double-claimed pixel). -#include "../src/component_geometry.h" +#include "../src/core/ui/component_geometry.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp new file mode 100644 index 0000000..0b4a618 --- /dev/null +++ b/tests/test_component_state_io.cpp @@ -0,0 +1,321 @@ +// component_state_io unit tests (Q-W2v). The HISTORICAL codec suite — the full +// envelope/payload version ladder, every legacy lift, the golden byte fixtures — +// lives in test_sample_map.cpp and runs unmodified against the split module; this +// target exists as the module's OWN executable (house rule: every pure module has +// one) and as the STRUCTURAL PROOF the codec links WITHOUT the voice engine +// (T2-07): it links component_state_io + velocity_curve + master_gain only — a +// sampler_core/pitch_shift symbol reaching this link is a regression. + +#include "../src/core/instrument/map/component_state_io.h" + +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::map; + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++failures; \ + } \ + } while (0) + +// A full round-trip through the CURRENT envelope (v11): every field survives. +static void testComponentStateRoundTrip() { + ComponentState in; + in.selectionId = "smp-1"; + in.channelMode = ChannelMode::Stereo; + in.channelModeExplicit = true; + in.lastConsumedAssignGeneration = 42; + in.previewVelocity = 99; + in.voiceCount = 7; + in.voiceMode = VoiceMode::Mono; + in.monoTrigger = MonoTrigger::Legato; + in.masterGainLinear = 0.5; + in.instanceGuid = "0123456789abcdef0123456789abcdef"; + SampleRefEntry e; + e.sampleId = "smp-1"; + e.ref.relativePath = "bank/smp-1.wav"; + e.ref.rootNote = 64; + e.ref.loop.hasLoop = true; + e.ref.loop.start = 100; + e.ref.loop.end = 2000; + e.ref.channelCount = 2; + e.displayName = "My Capture"; + in.sampleRefs.push_back(e); + PerformanceZone z; + z.sampleId = "smp-1"; + z.lowNote = 30; + z.highNote = 90; + z.rootOverride = 61; + z.startPoint = 5; + z.keyTrack = 1.5; + z.play.playMode = PlayMode::Trigger; + z.play.trigger.lengthFraction = 0.75; + z.play.trigger.fadeInFrames = 441; + z.play.trigger.fadeOutFrames = 882; + in.map.zones.push_back(z); + + const std::vector bytes = serializeComponentState(in); + const ComponentState out = deserializeComponentState(bytes, 48000.0); + + CHECK(out.selectionId == "smp-1"); + CHECK(out.channelMode == ChannelMode::Stereo); + CHECK(out.channelModeExplicit); + CHECK(out.lastConsumedAssignGeneration == 42); + CHECK(out.previewVelocity == 99); + CHECK(out.voiceCount == 7); + CHECK(out.voiceMode == VoiceMode::Mono); + CHECK(out.monoTrigger == MonoTrigger::Legato); + CHECK(out.masterGainLinear == 0.5); + CHECK(out.instanceGuid == "0123456789abcdef0123456789abcdef"); + CHECK(out.sampleRefs.size() == 1); + if (out.sampleRefs.size() == 1) { + CHECK(out.sampleRefs[0].sampleId == "smp-1"); + CHECK(out.sampleRefs[0].ref.relativePath == "bank/smp-1.wav"); + CHECK(out.sampleRefs[0].ref.rootNote == 64); + CHECK(out.sampleRefs[0].ref.loop.hasLoop); + CHECK(out.sampleRefs[0].ref.loop.start == 100); + CHECK(out.sampleRefs[0].ref.loop.end == 2000); + CHECK(out.sampleRefs[0].ref.channelCount == 2); + CHECK(out.sampleRefs[0].displayName == "My Capture"); + } + CHECK(out.map.zones.size() == 1); + if (out.map.zones.size() == 1) { + const PerformanceZone& oz = out.map.zones[0]; + CHECK(oz.sampleId == "smp-1"); + CHECK(oz.lowNote == 30); + CHECK(oz.highNote == 90); + CHECK(oz.rootOverride && *oz.rootOverride == 61); + CHECK(oz.startPoint && *oz.startPoint == 5); + CHECK(oz.keyTrack == 1.5); + CHECK(oz.play.playMode == PlayMode::Trigger); + CHECK(oz.play.trigger.lengthFraction == 0.75); + CHECK(oz.play.trigger.fadeInFrames == 441); + CHECK(oz.play.trigger.fadeOutFrames == 882); + } +} + +// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up, Q-W2v). testEnvelopePrefixBytesFrozen below +// only pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the +// mode byte (a field re-ordered or dropped inside the voice/gain/refs/guid/zone tail would +// still pass it). This test builds a canonical v11 ComponentState that exercises EVERY field +// family at once (two zones — one Trigger with every optional override set, one Gate with all +// optionals absent — a two-entry sample-refs table, non-default voice/gain/channel-mode +// fields, and a non-flat velocity curve) and asserts the encoded bytes equal an EXACT expected +// vector. The vector below is the current writer's PROVABLY-CORRECT output (proven by the +// round-trip test above) captured as the golden — so the byte layout itself becomes +// un-driftable, not just its first 5 bytes. +static void testGoldenFullBlobFixture() { + ComponentState in; + in.selectionId = "kick"; + in.channelMode = ChannelMode::Stereo; + in.channelModeExplicit = true; + in.lastConsumedAssignGeneration = 12345; + in.previewVelocity = 100; + in.voiceCount = 24; + in.voiceMode = VoiceMode::Mono; + in.monoTrigger = MonoTrigger::Legato; + in.masterGainLinear = 2.0; + in.instanceGuid = "guid-1234-5678-abcd"; + + SampleRefEntry kickRef; + kickRef.sampleId = "kick"; + kickRef.ref.relativePath = "bank/kick.wav"; + kickRef.ref.rootNote = 36; + kickRef.ref.loop.hasLoop = true; + kickRef.ref.loop.start = 1000; + kickRef.ref.loop.end = 5000; + kickRef.ref.channelCount = 2; + kickRef.displayName = "Kick Drum"; + in.sampleRefs.push_back(kickRef); + + SampleRefEntry snareRef; + snareRef.sampleId = "snare"; + snareRef.ref.relativePath = "bank/snare.wav"; + snareRef.ref.rootNote = 38; + snareRef.ref.loop.hasLoop = false; + snareRef.ref.loop.start = 0; + snareRef.ref.loop.end = 0; + snareRef.ref.channelCount = 1; + snareRef.displayName = "Snare"; + in.sampleRefs.push_back(snareRef); + + // Zone A: every optional field present, Trigger mode, non-flat velocity curve. + PerformanceZone zoneA; + zoneA.sampleId = "kick"; + zoneA.lowNote = 24; + zoneA.highNote = 60; + zoneA.rootOverride = 36; + SampleLoop loopA; + loopA.hasLoop = true; + loopA.start = 1000; + loopA.end = 5000; + zoneA.loopOverride = loopA; + zoneA.startPoint = 250; + zoneA.keyTrack = 0.5; + zoneA.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( + {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}}); + zoneA.play.playMode = PlayMode::Trigger; + zoneA.play.adsr.attackSeconds = 0.01; + zoneA.play.adsr.holdSeconds = 0.05; + zoneA.play.adsr.decaySeconds = 0.02; + zoneA.play.adsr.sustainLevel = 0.8; + zoneA.play.adsr.releaseSeconds = 0.15; + zoneA.play.trigger.lengthFraction = 0.75; + zoneA.play.trigger.fadeInFrames = 100; + zoneA.play.trigger.fadeOutFrames = 200; + zoneA.play.pitchEngine = PitchEngine::Preserve; + zoneA.play.pitchEnv.enabled = true; + zoneA.play.pitchEnv.attackSeconds = 0.02; + zoneA.play.pitchEnv.decaySeconds = 0.03; + zoneA.play.pitchEnv.peakSemitones = 5.0; + in.map.zones.push_back(zoneA); + + // Zone B: every optional field absent, Gate mode, default flat velocity curve. + PerformanceZone zoneB; + zoneB.sampleId = "snare"; + zoneB.lowNote = 61; + zoneB.highNote = 90; + zoneB.keyTrack = 2.0; + zoneB.play.playMode = PlayMode::Gate; + zoneB.play.adsr.attackSeconds = 0.005; + zoneB.play.adsr.holdSeconds = 0.0; + zoneB.play.adsr.decaySeconds = 0.1; + zoneB.play.adsr.sustainLevel = 0.5; + zoneB.play.adsr.releaseSeconds = 0.2; + zoneB.play.pitchEngine = PitchEngine::Varispeed; + in.map.zones.push_back(zoneB); + + const std::vector bytes = serializeComponentState(in); + // clang-format off + static const std::uint8_t kGolden[] = { + 0x0b,0x00,0x00,0x00,0x01,0x39,0x30,0x00,0x00,0x00,0x00,0x00,0x00,0x64,0x18,0x01, + 0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x01,0x02,0x00,0x00,0x00,0x04,0x00, + 0x00,0x00,0x6b,0x69,0x63,0x6b,0x0d,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x6b, + 0x69,0x63,0x6b,0x2e,0x77,0x61,0x76,0x24,0x00,0x00,0x00,0x01,0xe8,0x03,0x00,0x00, + 0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00, + 0x09,0x00,0x00,0x00,0x4b,0x69,0x63,0x6b,0x20,0x44,0x72,0x75,0x6d,0x05,0x00,0x00, + 0x00,0x73,0x6e,0x61,0x72,0x65,0x0e,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x73, + 0x6e,0x61,0x72,0x65,0x2e,0x77,0x61,0x76,0x26,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, + 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, + 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x07,0x00,0x00, + 0x00,0x02,0x00,0x00,0x00,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x18,0x00,0x00, + 0x00,0x3c,0x00,0x00,0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00, + 0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00, + 0x00,0x00,0x00,0x00,0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f, + 0xb8,0x1e,0x85,0xeb,0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40, + 0x7b,0x14,0xae,0x47,0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f, + 0x9a,0x99,0x99,0x99,0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f, + 0x00,0x00,0x00,0x00,0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00, + 0x00,0x00,0x50,0x40,0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00, + 0x00,0xc0,0x5f,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x05,0x00,0x00,0x00, + 0x73,0x6e,0x61,0x72,0x65,0x3d,0x00,0x00,0x00,0x5a,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xf0, + 0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7b,0x14,0xae,0x47,0xe1, + 0x7a,0x74,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xb9,0x3f,0x00,0x00,0x00,0x00,0x00, + 0x00,0xe0,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x40,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,0x00, + 0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, + }; + // clang-format on + CHECK(bytes.size() == sizeof(kGolden)); + if (bytes.size() == sizeof(kGolden)) { + bool same = true; + for (std::size_t i = 0; i < bytes.size(); ++i) { + if (bytes[i] != kGolden[i]) { same = false; break; } + } + CHECK(same); + } +} + +// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in +// either is a byte-format break the round-trip alone can't prove (both sides could +// drift together). Pins the writer's absolute bytes. +static void testEnvelopePrefixBytesFrozen() { + ComponentState in; // defaults: mono, implicit, no refs, no selection, no zones + const std::vector bytes = serializeComponentState(in); + CHECK(bytes.size() > 5); + if (bytes.size() > 5) { + CHECK(bytes[0] == 11 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0); + CHECK(bytes[4] == 0); // ChannelMode::Mono + } + CHECK(kComponentStateVersion == 11); + CHECK(kZonesPayloadVersion == 7); + CHECK(kZonesFormatMarker == 0xFFFFFF00u); +} + +// A v1 selection blob lifts to {id, one full-keyboard zone} — the oldest live lift. +static void testV1SelectionLift() { + const std::vector v1 = serializeSelection("old-pick"); + const ComponentState out = deserializeComponentState(v1, 48000.0); + CHECK(out.selectionId == "old-pick"); + CHECK(out.map.zones.size() == 1); + if (out.map.zones.size() == 1) { + CHECK(out.map.zones[0].sampleId == "old-pick"); + CHECK(out.map.zones[0].lowNote == 0); + CHECK(out.map.zones[0].highNote == 127); + } +} + +// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws. +static void testTruncationDegradesCleanly() { + ComponentState in; + in.selectionId = "smp-2"; + PerformanceZone z; + z.sampleId = "smp-2"; + in.map.zones.push_back(z); + const std::vector bytes = serializeComponentState(in); + for (std::size_t cut = 0; cut < bytes.size(); ++cut) { + const std::vector part(bytes.begin(), + bytes.begin() + static_cast(cut)); + const ComponentState out = deserializeComponentState(part, 48000.0); + (void)out; // reaching here without UB/throw is the contract under test + } + CHECK(true); +} + +// serializePerformance/deserializePerformance round-trip through the v2 envelope. +static void testPerformanceRoundTrip() { + PerformanceMap in; + PerformanceZone z; + z.sampleId = "zone-a"; + z.lowNote = 10; + z.highNote = 20; + in.zones.push_back(z); + const PerformanceMap out = deserializePerformance(serializePerformance(in), 48000.0); + CHECK(out.zones.size() == 1); + if (out.zones.size() == 1) { + CHECK(out.zones[0].sampleId == "zone-a"); + CHECK(out.zones[0].lowNote == 10); + CHECK(out.zones[0].highNote == 20); + } +} + +int main() { + testComponentStateRoundTrip(); + testGoldenFullBlobFixture(); + testEnvelopePrefixBytesFrozen(); + testV1SelectionLift(); + testTruncationDegradesCleanly(); + testPerformanceRoundTrip(); + if (failures == 0) { + std::printf("component_state_io_tests: all tests passed\n"); + return 0; + } + std::printf("component_state_io_tests: %d FAILURE(S)\n", failures); + return 1; +} diff --git a/tests/test_curve_popup.cpp b/tests/test_curve_popup.cpp index 9e6c5c2..262e560 100644 --- a/tests/test_curve_popup.cpp +++ b/tests/test_curve_popup.cpp @@ -1,14 +1,15 @@ -// Standalone tests for reasampler::vst::curve_popup — no VST3, no REAPER, no framework. Same +// Standalone tests for reasampler::instrument::ui::curve_popup — no VST3, no REAPER, no framework. Same // fast assert loop as the sibling pure tests. Assert the r11 popup-sheet geometry at the size // clamps (the spec's width clamp(60%, 360..520) / height clamp(55%, 260..380)), the centering, // the title-row/close-button placement, the curve-box remainder, and the outside-sheet // dismissal test. -#include "../src/vst/curve_popup.h" +#include "../src/core/instrument/ui/curve_popup.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -17,66 +18,66 @@ static int g_fail = 0; static void testDefaultWindowMidClamp() { // 840x620: 60% = 504 (inside 360..520), 55% = 341 (inside 260..380). const CurvePopupLayout pl = computeCurvePopup(840, 620); - CHECK(pl.sheet.width() == 504); - CHECK(pl.sheet.height() == 341); + CHECK(pl.sheet.width == 504); + CHECK(pl.sheet.height == 341); // Centered (within the integer-division pixel). - CHECK(pl.sheet.left == (840 - 504) / 2); - CHECK(pl.sheet.top == (620 - 341) / 2); + CHECK(pl.sheet.x == (840 - 504) / 2); + CHECK(pl.sheet.y == (620 - 341) / 2); } static void testMinClamp() { // The 560x460 constraint floor: 60% = 336 -> clamps UP to 360; 55% = 253 -> up to 260. const CurvePopupLayout pl = computeCurvePopup(560, 460); - CHECK(pl.sheet.width() == kCurvePopupMinW); - CHECK(pl.sheet.height() == kCurvePopupMinH); - CHECK(pl.sheet.left >= 0 && pl.sheet.right <= 560); - CHECK(pl.sheet.top >= 0 && pl.sheet.bottom <= 460); + CHECK(pl.sheet.width == kCurvePopupMinW); + CHECK(pl.sheet.height == kCurvePopupMinH); + CHECK(pl.sheet.x >= 0 && pl.sheet.right() <= 560); + CHECK(pl.sheet.y >= 0 && pl.sheet.bottom() <= 460); } static void testMaxClamp() { // A large window: 60% of 1600 = 960 -> clamps DOWN to 520; 55% of 900 = 495 -> down to 380. const CurvePopupLayout pl = computeCurvePopup(1600, 900); - CHECK(pl.sheet.width() == kCurvePopupMaxW); - CHECK(pl.sheet.height() == kCurvePopupMaxH); + CHECK(pl.sheet.width == kCurvePopupMaxW); + CHECK(pl.sheet.height == kCurvePopupMaxH); } static void testDegenerateWindowNeverOverhangs() { // A window smaller than the min clamp: the sheet caps at the window dimension (defensive — // below checkSizeConstraint, but geometry must stay sane). const CurvePopupLayout pl = computeCurvePopup(300, 200); - CHECK(pl.sheet.width() == 300); - CHECK(pl.sheet.height() == 200); - CHECK(pl.sheet.left == 0 && pl.sheet.top == 0); + CHECK(pl.sheet.width == 300); + CHECK(pl.sheet.height == 200); + CHECK(pl.sheet.x == 0 && pl.sheet.y == 0); } static void testTitleRowAndCurveBox() { const CurvePopupLayout pl = computeCurvePopup(840, 620); // Close: 18x18, right-anchored inside the title row. - CHECK(pl.close.width() == kCurvePopupCloseSize && pl.close.height() == kCurvePopupCloseSize); - CHECK(pl.close.right == pl.sheet.right - kCurvePopupPad); - CHECK(pl.close.top >= pl.sheet.top); - CHECK(pl.close.bottom <= pl.sheet.top + kCurvePopupTitleH); + CHECK(pl.close.width == kCurvePopupCloseSize && pl.close.height == kCurvePopupCloseSize); + CHECK(pl.close.right() == pl.sheet.right() - kCurvePopupPad); + CHECK(pl.close.y >= pl.sheet.y); + CHECK(pl.close.bottom() <= pl.sheet.y + kCurvePopupTitleH); // Title text: left of the close button, in the title row. - CHECK(pl.title.left == pl.sheet.left + kCurvePopupPad); - CHECK(pl.title.right <= pl.close.left); + CHECK(pl.title.x == pl.sheet.x + kCurvePopupPad); + CHECK(pl.title.right() <= pl.close.x); // Curve box: fills the remainder below the title row, inside the sheet margins. - CHECK(pl.curveBox.top >= pl.sheet.top + kCurvePopupTitleH); - CHECK(pl.curveBox.left == pl.sheet.left + kCurvePopupPad); - CHECK(pl.curveBox.right == pl.sheet.right - kCurvePopupPad); - CHECK(pl.curveBox.bottom == pl.sheet.bottom - kCurvePopupPad); - CHECK(pl.curveBox.width() > 0 && pl.curveBox.height() > 0); + CHECK(pl.curveBox.y >= pl.sheet.y + kCurvePopupTitleH); + CHECK(pl.curveBox.x == pl.sheet.x + kCurvePopupPad); + CHECK(pl.curveBox.right() == pl.sheet.right() - kCurvePopupPad); + CHECK(pl.curveBox.bottom() == pl.sheet.bottom() - kCurvePopupPad); + CHECK(pl.curveBox.width > 0 && pl.curveBox.height > 0); } static void testOutsideSheetDismissTest() { const CurvePopupLayout pl = computeCurvePopup(840, 620); // On the wash: outside. CHECK(popupOutsideSheet(pl, 0, 0)); - CHECK(popupOutsideSheet(pl, pl.sheet.left - 1, pl.sheet.top + 10)); - CHECK(popupOutsideSheet(pl, pl.sheet.right, pl.sheet.top + 10)); // half-open right edge + CHECK(popupOutsideSheet(pl, pl.sheet.x - 1, pl.sheet.y + 10)); + CHECK(popupOutsideSheet(pl, pl.sheet.right(), pl.sheet.y + 10)); // half-open right edge // On the sheet (title row, curve box, padding): inside. - CHECK(!popupOutsideSheet(pl, pl.sheet.left, pl.sheet.top)); - CHECK(!popupOutsideSheet(pl, pl.curveBox.left + 5, pl.curveBox.top + 5)); - CHECK(!popupOutsideSheet(pl, pl.sheet.right - 1, pl.sheet.bottom - 1)); + CHECK(!popupOutsideSheet(pl, pl.sheet.x, pl.sheet.y)); + CHECK(!popupOutsideSheet(pl, pl.curveBox.x + 5, pl.curveBox.y + 5)); + CHECK(!popupOutsideSheet(pl, pl.sheet.right() - 1, pl.sheet.bottom() - 1)); } int main() { diff --git a/tests/test_drag_out.cpp b/tests/test_drag_out.cpp index 8da3977..3511c44 100644 --- a/tests/test_drag_out.cpp +++ b/tests/test_drag_out.cpp @@ -9,13 +9,14 @@ // * Path-list assembly: single, multi, dedupe (cross-bank copy case), skip-missing, // skip-unresolved, empty selection, order preservation, mixed tallies. -#include "../src/drag_out.h" +#include "../src/core/ui/drag_out.h" #include #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index 2d363bf..8a4eef2 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::editor_geometry — no VST3, no REAPER, no test +// Standalone tests for reasampler::instrument::ui::editor_geometry — no VST3, no REAPER, no test // framework. Same fast assert loop as the sibling pure tests (mode_switch et al.): // assert the IPlugView LICE editor's layout math + hit-testing directly. // @@ -8,11 +8,12 @@ // the button, missing on the title/canvas, missing outside the surface, and boundary // pixels; layout<->hit-test agreement (a click on the drawn button rect hits it). -#include "../src/vst/editor_geometry.h" +#include "../src/core/instrument/ui/editor_geometry.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -21,7 +22,7 @@ static int g_fail = 0; // --- contains() --------------------------------------------------------------- static void testContainsHalfOpen() { - Rect r{10, 20, 50, 40}; // [10,50) x [20,40) + Rect r = Rect::ltrb(10, 20, 50, 40); // [10,50) x [20,40) CHECK(contains(r, 10, 20)); // top-left inclusive CHECK(contains(r, 49, 39)); // bottom-right exclusive edge, inside CHECK(!contains(r, 50, 30)); // right edge excluded @@ -31,9 +32,9 @@ static void testContainsHalfOpen() { } static void testContainsDegenerate() { - CHECK(!contains(Rect{10, 10, 10, 20}, 10, 15)); // zero width - CHECK(!contains(Rect{10, 10, 20, 10}, 15, 10)); // zero height - CHECK(!contains(Rect{20, 10, 10, 20}, 15, 15)); // inverted (right < left) + CHECK(!contains(Rect::ltrb(10, 10, 10, 20), 10, 15)); // zero width + CHECK(!contains(Rect::ltrb(10, 10, 20, 10), 15, 10)); // zero height + CHECK(!contains(Rect::ltrb(20, 10, 10, 20), 15, 15)); // inverted (right < left) } // --- layoutEditor: normal view ------------------------------------------------ @@ -43,20 +44,20 @@ static void testLayoutNormalView() { // rest; button sits inside the canvas, inset by the margin. const EditorLayout L = layoutEditor(400, 260); - CHECK(L.titleBar.left == 0 && L.titleBar.top == 0); - CHECK(L.titleBar.right == 400); - CHECK(L.titleBar.height() > 0 && L.titleBar.height() <= 260); + CHECK(L.titleBar.x == 0 && L.titleBar.y == 0); + CHECK(L.titleBar.right() == 400); + CHECK(L.titleBar.height > 0 && L.titleBar.height <= 260); // Canvas begins right below the title bar and reaches the bottom-right. - CHECK(L.canvas.top == L.titleBar.bottom); - CHECK(L.canvas.right == 400 && L.canvas.bottom == 260); + CHECK(L.canvas.y == L.titleBar.bottom()); + CHECK(L.canvas.right() == 400 && L.canvas.bottom() == 260); // Button is inside the canvas (does not overhang any edge). - CHECK(L.button.left >= L.canvas.left); - CHECK(L.button.top >= L.canvas.top); - CHECK(L.button.right <= L.canvas.right); - CHECK(L.button.bottom <= L.canvas.bottom); - CHECK(L.button.width() > 0 && L.button.height() > 0); + CHECK(L.button.x >= L.canvas.x); + CHECK(L.button.y >= L.canvas.y); + CHECK(L.button.right() <= L.canvas.right()); + CHECK(L.button.bottom() <= L.canvas.bottom()); + CHECK(L.button.width > 0 && L.button.height > 0); } // --- layoutEditor: tiny view (clamping) --------------------------------------- @@ -65,25 +66,25 @@ static void testLayoutTinyViewClampsButton() { // A view narrower/shorter than the button's natural size: the button must clamp to // the canvas and never produce an inverted or overhanging rect. const EditorLayout L = layoutEditor(40, 40); - CHECK(L.button.right <= L.canvas.right); - CHECK(L.button.bottom <= L.canvas.bottom); - CHECK(L.button.right >= L.button.left); // never inverted - CHECK(L.button.bottom >= L.button.top); + CHECK(L.button.right() <= L.canvas.right()); + CHECK(L.button.bottom() <= L.canvas.bottom()); + CHECK(L.button.right() >= L.button.x); // never inverted + CHECK(L.button.bottom() >= L.button.y); // Title bar clamps to the client height when the view is shorter than its height. - CHECK(L.titleBar.bottom <= 40); + CHECK(L.titleBar.bottom() <= 40); } // --- layoutEditor: zero view (all empty, no inversion) ------------------------ static void testLayoutZeroView() { const EditorLayout L = layoutEditor(0, 0); - CHECK(L.titleBar.width() <= 0 || L.titleBar.height() <= 0); - CHECK(L.canvas.width() <= 0 || L.canvas.height() <= 0); + CHECK(L.titleBar.width <= 0 || L.titleBar.height <= 0); + CHECK(L.canvas.width <= 0 || L.canvas.height <= 0); // No rect is inverted. - CHECK(L.button.right >= L.button.left); - CHECK(L.button.bottom >= L.button.top); - CHECK(L.canvas.right >= L.canvas.left); - CHECK(L.canvas.bottom >= L.canvas.top); + CHECK(L.button.right() >= L.button.x); + CHECK(L.button.bottom() >= L.button.y); + CHECK(L.canvas.right() >= L.canvas.x); + CHECK(L.canvas.bottom() >= L.canvas.y); // A click anywhere on an empty layout hits nothing. CHECK(hitTest(L, 0, 0) == HitTarget::kNone); CHECK(hitTest(L, 5, 5) == HitTarget::kNone); @@ -94,15 +95,15 @@ static void testLayoutZeroView() { static void testHitTestButton() { const EditorLayout L = layoutEditor(400, 260); // Center of the button hits it. - const int cx = (L.button.left + L.button.right) / 2; - const int cy = (L.button.top + L.button.bottom) / 2; + const int cx = (L.button.x + L.button.right()) / 2; + const int cy = (L.button.y + L.button.bottom()) / 2; CHECK(hitTest(L, cx, cy) == HitTarget::kButton); } static void testHitTestMissesNonButton() { const EditorLayout L = layoutEditor(400, 260); // Title bar is inert in the spike. - CHECK(hitTest(L, 200, L.titleBar.top + 1) == HitTarget::kNone); + CHECK(hitTest(L, 200, L.titleBar.y + 1) == HitTarget::kNone); // Empty canvas away from the button. CHECK(hitTest(L, 380, 240) == HitTarget::kNone); // Outside the surface entirely. @@ -113,9 +114,9 @@ static void testHitTestMissesNonButton() { static void testHitTestButtonBoundary() { const EditorLayout L = layoutEditor(400, 260); // Top-left corner of the button is inclusive; the right/bottom edges are excluded. - CHECK(hitTest(L, L.button.left, L.button.top) == HitTarget::kButton); - CHECK(hitTest(L, L.button.right, L.button.top) == HitTarget::kNone); - CHECK(hitTest(L, L.button.left, L.button.bottom) == HitTarget::kNone); + CHECK(hitTest(L, L.button.x, L.button.y) == HitTarget::kButton); + CHECK(hitTest(L, L.button.right(), L.button.y) == HitTarget::kNone); + CHECK(hitTest(L, L.button.x, L.button.bottom()) == HitTarget::kNone); } // --- layout<->hit-test agreement ---------------------------------------------- @@ -124,8 +125,8 @@ static void testHitTestButtonBoundary() { // load-bearing consistency invariant between what the shell draws and what it routes. static void testHitTestMatchesDrawnButton() { const EditorLayout L = layoutEditor(320, 200); - for (int y = L.button.top; y < L.button.bottom; ++y) { - for (int x = L.button.left; x < L.button.right; ++x) { + for (int y = L.button.y; y < L.button.bottom(); ++y) { + for (int x = L.button.x; x < L.button.right(); ++x) { CHECK(hitTest(L, x, y) == HitTarget::kButton); } } @@ -138,14 +139,14 @@ static void testSampleRowRectStacks() { const Rect r0 = sampleRowRect(L, 0); const Rect r1 = sampleRowRect(L, 1); // Row 0 starts at the canvas top and spans its full width. - CHECK(r0.top == L.canvas.top); - CHECK(r0.left == L.canvas.left && r0.right == L.canvas.right); - CHECK(r0.height() == kSampleRowHeight); + CHECK(r0.y == L.canvas.y); + CHECK(r0.x == L.canvas.x && r0.right() == L.canvas.right()); + CHECK(r0.height == kSampleRowHeight); // Row 1 sits directly below row 0 (no gap, no overlap). - CHECK(r1.top == r0.bottom); - CHECK(r1.height() == kSampleRowHeight); + CHECK(r1.y == r0.bottom()); + CHECK(r1.height == kSampleRowHeight); // A negative index is an empty rect. - CHECK(sampleRowRect(L, -1).width() == 0 && sampleRowRect(L, -1).height() == 0); + CHECK(sampleRowRect(L, -1).width == 0 && sampleRowRect(L, -1).height == 0); } static void testSampleRowHitTestMapsClickToRow() { @@ -153,35 +154,35 @@ static void testSampleRowHitTestMapsClickToRow() { const int rows = 5; // A click in the vertical middle of row 2 resolves to index 2. const Rect r2 = sampleRowRect(L, 2); - const int midY = (r2.top + r2.bottom) / 2; + const int midY = (r2.y + r2.bottom()) / 2; CHECK(sampleRowHitTest(L, rows, 200, midY) == 2); // Row 0's top-left corner hits row 0. const Rect r0 = sampleRowRect(L, 0); - CHECK(sampleRowHitTest(L, rows, r0.left, r0.top) == 0); + CHECK(sampleRowHitTest(L, rows, r0.x, r0.y) == 0); } static void testSampleRowHitTestMisses() { const EditorLayout L = layoutEditor(400, 260); const int rows = 3; // Above the first row (in the title bar) -> no row. - CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.top) == -1); + CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.y) == -1); // Below the last row -> no row. const Rect last = sampleRowRect(L, rows - 1); - CHECK(sampleRowHitTest(L, rows, 200, last.bottom + 1) == -1); + CHECK(sampleRowHitTest(L, rows, 200, last.bottom() + 1) == -1); // Left of the canvas -> no row. - CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1); + CHECK(sampleRowHitTest(L, rows, L.canvas.x - 1, last.y) == -1); // Zero rows -> always -1. - CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1); - // At or below canvas.bottom -> always -1, even if rowCount would cover that y. + CHECK(sampleRowHitTest(L, 0, 200, L.canvas.y + 1) == -1); + // At or below canvas.bottom() -> always -1, even if rowCount would cover that y. // This guards paint<->hit-test agreement: sampleRowRect does not clamp to canvas, - // so without this clip a row that extends past canvas.bottom would hit-test but + // so without this clip a row that extends past canvas.bottom() would hit-test but // never be drawn (or vice versa). - CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom) == -1); + CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom()) == -1); // Use a large rowCount so index arithmetic would return a valid row without the - // canvas.bottom guard — proving the guard fires independently of rowCount. + // canvas.bottom() guard — proving the guard fires independently of rowCount. const int bigRows = 1000; - CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom) == -1); - CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom + 5) == -1); + CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom()) == -1); + CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom() + 5) == -1); } // The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to @@ -191,10 +192,10 @@ static void testSampleRowHitTestMatchesDrawnRows() { const int rows = 4; for (int i = 0; i < rows; ++i) { const Rect r = sampleRowRect(L, i); - if (r.top >= L.canvas.bottom) break; // clipped rows aren't clickable targets - const int y = (r.top + r.bottom) / 2; - if (y >= L.canvas.bottom) continue; - CHECK(sampleRowHitTest(L, rows, r.left + 1, y) == i); + if (r.y >= L.canvas.bottom()) break; // clipped rows aren't clickable targets + const int y = (r.y + r.bottom()) / 2; + if (y >= L.canvas.bottom()) continue; + CHECK(sampleRowHitTest(L, rows, r.x + 1, y) == i); } } @@ -204,30 +205,30 @@ static void testKeymapLayoutSplitsCanvas() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); // The left sample list and right zone panel partition the canvas with no overlap and // no gap: the list's right edge is the panel's left edge. - CHECK(L.sampleList.left == L.base.canvas.left); - CHECK(L.sampleList.right == L.zonePanel.left); - CHECK(L.zonePanel.right == L.base.canvas.right); - CHECK(L.sampleList.top == L.base.canvas.top); - CHECK(L.zonePanel.top == L.base.canvas.top); - CHECK(L.sampleList.bottom == L.base.canvas.bottom); - CHECK(L.zonePanel.bottom == L.base.canvas.bottom); - CHECK(L.sampleList.width() > 0 && L.zonePanel.width() > 0); + CHECK(L.sampleList.x == L.base.canvas.x); + CHECK(L.sampleList.right() == L.zonePanel.x); + CHECK(L.zonePanel.right() == L.base.canvas.right()); + CHECK(L.sampleList.y == L.base.canvas.y); + CHECK(L.zonePanel.y == L.base.canvas.y); + CHECK(L.sampleList.bottom() == L.base.canvas.bottom()); + CHECK(L.zonePanel.bottom() == L.base.canvas.bottom()); + CHECK(L.sampleList.width > 0 && L.zonePanel.width > 0); // Add-Zone button caps the panel; zone rows stack below it. - CHECK(L.addZoneButton.top == L.zonePanel.top); - CHECK(L.addZoneButton.left == L.zonePanel.left && L.addZoneButton.right == L.zonePanel.right); - CHECK(L.zoneRowArea.top == L.addZoneButton.bottom); - CHECK(L.zoneRowArea.bottom == L.zonePanel.bottom); + CHECK(L.addZoneButton.y == L.zonePanel.y); + CHECK(L.addZoneButton.x == L.zonePanel.x && L.addZoneButton.right() == L.zonePanel.right()); + CHECK(L.zoneRowArea.y == L.addZoneButton.bottom()); + CHECK(L.zoneRowArea.bottom() == L.zonePanel.bottom()); } static void checkNoInversion(const KeymapEditorLayout& L) { - CHECK(L.sampleList.right >= L.sampleList.left); - CHECK(L.zonePanel.right >= L.zonePanel.left); - CHECK(L.addZoneButton.right >= L.addZoneButton.left); - CHECK(L.addZoneButton.bottom >= L.addZoneButton.top); - CHECK(L.zoneRowArea.right >= L.zoneRowArea.left); - CHECK(L.zoneRowArea.bottom >= L.zoneRowArea.top); + CHECK(L.sampleList.right() >= L.sampleList.x); + CHECK(L.zonePanel.right() >= L.zonePanel.x); + CHECK(L.addZoneButton.right() >= L.addZoneButton.x); + CHECK(L.addZoneButton.bottom() >= L.addZoneButton.y); + CHECK(L.zoneRowArea.right() >= L.zoneRowArea.x); + CHECK(L.zoneRowArea.bottom() >= L.zoneRowArea.y); // Regions stay within the client area. - CHECK(L.zonePanel.right <= L.base.canvas.right); + CHECK(L.zonePanel.right() <= L.base.canvas.right()); } static void testKeymapLayoutTinyAndZeroNoInversion() { @@ -243,36 +244,36 @@ static void testKeymapSampleRowInLeftColumn() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); const Rect r0 = keymapSampleRowRect(L, 0); // Rows live in the LEFT column (not the full canvas width). - CHECK(r0.left == L.sampleList.left && r0.right == L.sampleList.right); - CHECK(r0.right < L.base.canvas.right); // strictly left of the zone panel - CHECK(r0.top == L.sampleList.top && r0.height() == kSampleRowHeight); + CHECK(r0.x == L.sampleList.x && r0.right() == L.sampleList.right()); + CHECK(r0.right() < L.base.canvas.right()); // strictly left of the zone panel + CHECK(r0.y == L.sampleList.y && r0.height == kSampleRowHeight); // Hit-test maps a left-column click to the row and rejects a click in the zone panel. - const int midY = (r0.top + r0.bottom) / 2; - CHECK(keymapSampleRowHitTest(L, 3, r0.left + 2, midY) == 0); - CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.left + 2, midY) == -1); + const int midY = (r0.y + r0.bottom()) / 2; + CHECK(keymapSampleRowHitTest(L, 3, r0.x + 2, midY) == 0); + CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.x + 2, midY) == -1); } static void testAddZoneHitTest() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); - const int cx = (L.addZoneButton.left + L.addZoneButton.right) / 2; - const int cy = (L.addZoneButton.top + L.addZoneButton.bottom) / 2; + const int cx = (L.addZoneButton.x + L.addZoneButton.right()) / 2; + const int cy = (L.addZoneButton.y + L.addZoneButton.bottom()) / 2; CHECK(addZoneHitTest(L, cx, cy)); // A click in the zone-row area below the button is NOT the Add button. - CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.top + 2)); + CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.y + 2)); // A click in the left list is NOT the Add button. - CHECK(!addZoneHitTest(L, L.sampleList.left + 2, L.sampleList.top + 2)); + CHECK(!addZoneHitTest(L, L.sampleList.x + 2, L.sampleList.y + 2)); } static void testZoneRowStacksAndSelects() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); const Rect z0 = zoneRowRect(L, 0); const Rect z1 = zoneRowRect(L, 1); - CHECK(z0.top == L.zoneRowArea.top && z0.height() == kZoneRowHeight); - CHECK(z1.top == z0.bottom); // stacked, no gap - CHECK(z0.left == L.zoneRowArea.left && z0.right == L.zoneRowArea.right); + CHECK(z0.y == L.zoneRowArea.y && z0.height == kZoneRowHeight); + CHECK(z1.y == z0.bottom()); // stacked, no gap + CHECK(z0.x == L.zoneRowArea.x && z0.right() == L.zoneRowArea.right()); // A click on the LABEL area (left part of a zone row) selects the zone with no field. - const int labelX = z0.left + 2; // far left = label, not a control - const int midY = (z0.top + z0.bottom) / 2; + const int labelX = z0.x + 2; // far left = label, not a control + const int midY = (z0.y + z0.bottom()) / 2; const ZoneHit h = zoneHitTest(L, 2, labelX, midY); CHECK(h.zoneIndex == 0 && h.field == ZoneField::kZoneNone); } @@ -280,10 +281,10 @@ static void testZoneRowStacksAndSelects() { static void testZoneRowControlsMapToFields() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); const Rect row = zoneRowRect(L, 0); - const int midY = (row.top + row.bottom) / 2; + const int midY = (row.y + row.bottom()) / 2; // The seven controls occupy the rightmost 7*kZoneCtrlWidth px, left-to-right: // low-, low+, high-, high+, root-, root+, delete. - const int block = row.right - 7 * kZoneCtrlWidth; + const int block = row.right() - 7 * kZoneCtrlWidth; const ZoneField expected[7] = { ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, @@ -300,14 +301,86 @@ static void testZoneRowControlsMapToFields() { static void testZoneHitTestMisses() { const KeymapEditorLayout L = layoutKeymapEditor(600, 300); const Rect row = zoneRowRect(L, 0); - const int midY = (row.top + row.bottom) / 2; + const int midY = (row.y + row.bottom()) / 2; // Zero zones -> always miss. - CHECK(zoneHitTest(L, 0, row.left + 2, midY).zoneIndex == -1); + CHECK(zoneHitTest(L, 0, row.x + 2, midY).zoneIndex == -1); // Below the last zone row -> miss. const Rect last = zoneRowRect(L, 2); - CHECK(zoneHitTest(L, 3, row.left + 2, last.bottom + 1).zoneIndex == -1); + CHECK(zoneHitTest(L, 3, row.x + 2, last.bottom() + 1).zoneIndex == -1); // Left of the zone panel (in the sample list) -> miss. - CHECK(zoneHitTest(L, 3, L.sampleList.left + 2, midY).zoneIndex == -1); + CHECK(zoneHitTest(L, 3, L.sampleList.x + 2, midY).zoneIndex == -1); +} + +// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- + +// The band stack at the default 840x620 with a 120px deck: title / hero / cluster / +// deck in order, hero elastic (absorbs the slack), deck bottom-anchored at kPad. +static void testSampleBandsStackAndElasticHero() { + const SampleBands b = computeSampleBands(840, 620, 120); + CHECK(b.title.y == 0 && b.title.height == kTitleHeight && b.title.width == 840); + CHECK(b.hero.y == b.title.bottom()); + CHECK(b.hero.height >= 150); // above the hero floor + CHECK(b.cluster.y > b.hero.bottom()); // cluster below the hero (+gap) + CHECK(b.deck.bottom() == 620 - kPad); // deck bottom-anchored + CHECK(b.deck.height == 120); + // Nav buttons right-anchored inside the title band, Browse left of Zone. + CHECK(b.navZone.right() == 840 - kPad); + CHECK(b.navBrowse.right() < b.navZone.x); + CHECK(b.navZone.bottom() <= b.title.bottom()); + // A too-short window: the hero keeps its floor; the lower bands clip below. + const SampleBands s = computeSampleBands(840, 200, 120); + CHECK(s.hero.height == 150); + CHECK(s.deck.bottom() > 200); // clips past the window bottom (defensive case) +} + +// The cluster's right-anchored run tiles left of the channel toggle without overlap: +// rootStrip | preview | velCell(velKnob+velLabel) | curveBtn | (toggle). +static void testClusterRectsRunAndKnobCentering() { + const Rect cluster = Rect::ltrb(0, 500, 840, 552); + const ChannelToggleRects chan = channelToggleRects(cluster); + CHECK(chan.stereo.right() == 840 - kPad); + CHECK(chan.mono.right() == chan.stereo.x); + const ClusterRects cr = clusterRects(cluster, chan.mono, 28); + CHECK(cr.curveBtn.right() == chan.mono.x - kPad); + CHECK(cr.velCell.right() == cr.curveBtn.x - kPad); + CHECK(cr.preview.right() == cr.velCell.x - kPad); + CHECK(cr.rootStrip.x == cluster.x + kPad); + CHECK(cr.rootStrip.right() == cr.preview.x - kPad); + // The knob square centers in the cell and the label band sits beneath it. + CHECK(cr.velKnob.width == 28); + CHECK(cr.velKnob.x - cr.velCell.x == cr.velCell.right() - cr.velKnob.right()); + CHECK(cr.velLabel.y == cr.velKnob.bottom()); + CHECK(cr.velLabel.bottom() == cr.velCell.bottom()); +} + +// The Zone surface: content below the title; strip below the add/delete row; the note +// entry fields tile in three ordered segments; deck + curve button split the panel. +static void testZoneSurfaceLayoutAnchors() { + const Rect content = zoneContentArea(840, 620); + CHECK(content.y == kTitleHeight && content.bottom() == 620); + const Rect back = zoneBackRect(840, 620); + CHECK(back.right() == 840 - kPad && back.bottom() <= kTitleHeight); + const Rect addR = zoneAddRect(content); + const Rect delR = zoneDeleteRect(addR); + CHECK(addR.y == content.y + 4); + CHECK(delR.x == addR.right() + 8 && delR.y == addR.y); + const Rect strip = zonesStripArea(content); + CHECK(strip.y == addR.bottom() + 12); + CHECK(strip.x == content.x + kPad && strip.right() == content.right() - kPad); + const Rect fields = noteEntryFieldsArea(content); + CHECK(fields.y == strip.bottom() + 8); + const Rect f0 = noteEntryFieldRect(fields, 0); + const Rect f1 = noteEntryFieldRect(fields, 1); + const Rect f2 = noteEntryFieldRect(fields, 2); + CHECK(f0.x < f1.x && f1.x < f2.x); + CHECK(f2.right() == fields.right()); + CHECK(noteEntryFieldRect(fields, 3).width == 0); // out-of-range -> empty + const Rect panel = zonesControlPanel(content); + const Rect deck = zonesDeckArea(content); + const Rect curve = zonesCurveButton(content); + CHECK(panel.y == strip.bottom() + 8 + 18 + 8); + CHECK(deck.y == panel.y && deck.right() < curve.x); // curve column reserved + CHECK(curve.right() == panel.right() && curve.y == panel.y); } int main() { @@ -331,6 +404,9 @@ int main() { testZoneRowStacksAndSelects(); testZoneRowControlsMapToFields(); testZoneHitTestMisses(); + testSampleBandsStackAndElasticHero(); + testClusterRectsRunAndKnobCentering(); + testZoneSurfaceLayoutAnchors(); if (g_fail == 0) std::printf("editor_geometry: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_embed_strip.cpp b/tests/test_embed_strip.cpp index 4ce3c22..9d3ea93 100644 --- a/tests/test_embed_strip.cpp +++ b/tests/test_embed_strip.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::embed_strip — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::embed_strip — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests (editor_geometry et al.): assert the // embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly. // @@ -9,11 +9,12 @@ // on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list; // levelFillRect clamping 0..1 and its endpoints. -#include "../src/vst/embed_strip.h" +#include "../src/core/instrument/ui/embed_strip.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -24,34 +25,34 @@ static int g_fail = 0; static void testLayoutNormalArea() { // A comfortable inline strip: keymap band on top, thin level band pinned to the bottom. const EmbedLayout L = layoutEmbed(300, 40); - CHECK(L.keymap.left == 0 && L.keymap.top == 0 && L.keymap.right == 300); - CHECK(L.levelBand.left == 0 && L.levelBand.right == 300); + CHECK(L.keymap.x == 0 && L.keymap.y == 0 && L.keymap.right() == 300); + CHECK(L.levelBand.x == 0 && L.levelBand.right() == 300); // Level band is the fixed height at the very bottom; keymap fills the rest, contiguous. - CHECK(L.levelBand.height() == kEmbedLevelBandHeight); - CHECK(L.levelBand.bottom == 40); - CHECK(L.keymap.bottom == L.levelBand.top); - CHECK(L.keymap.height() == 40 - kEmbedLevelBandHeight); + CHECK(L.levelBand.height == kEmbedLevelBandHeight); + CHECK(L.levelBand.bottom() == 40); + CHECK(L.keymap.bottom() == L.levelBand.y); + CHECK(L.keymap.height == 40 - kEmbedLevelBandHeight); } static void testLayoutTinyAreaKeepsKeymap() { // A very short area: the level band must yield so the keymap keeps its minimum, and no // rect inverts. const EmbedLayout L = layoutEmbed(300, 8); - CHECK(L.keymap.height() >= 0); - CHECK(L.levelBand.height() >= 0); - CHECK(L.keymap.bottom == L.levelBand.top); - CHECK(L.levelBand.bottom == 8); + CHECK(L.keymap.height >= 0); + CHECK(L.levelBand.height >= 0); + CHECK(L.keymap.bottom() == L.levelBand.y); + CHECK(L.levelBand.bottom() == 8); // The keymap is not starved below its floor when the area allows it. - CHECK(L.keymap.height() >= kEmbedKeymapMinHeight || 8 < kEmbedKeymapMinHeight); + CHECK(L.keymap.height >= kEmbedKeymapMinHeight || 8 < kEmbedKeymapMinHeight); } static void testLayoutZeroArea() { const EmbedLayout L = layoutEmbed(0, 0); - CHECK(L.keymap.width() <= 0 && L.keymap.height() <= 0); - CHECK(L.levelBand.width() <= 0 && L.levelBand.height() <= 0); + CHECK(L.keymap.width <= 0 && L.keymap.height <= 0); + CHECK(L.levelBand.width <= 0 && L.levelBand.height <= 0); // Negative dimensions clamp to a zero-area, non-inverted rect. const EmbedLayout N = layoutEmbed(-50, -50); - CHECK(N.keymap.right >= N.keymap.left && N.keymap.bottom >= N.keymap.top); + CHECK(N.keymap.right() >= N.keymap.x && N.keymap.bottom() >= N.keymap.y); } // --- zoneSegmentRect ---------------------------------------------------------- @@ -60,9 +61,9 @@ static void testZoneSegmentFullSpan() { // A zone covering the whole keyboard spans the entire keymap band width. const EmbedLayout L = layoutEmbed(256, 40); const Rect r = zoneSegmentRect(L, 0, 127); - CHECK(r.left == L.keymap.left); - CHECK(r.right == L.keymap.right); - CHECK(r.top == L.keymap.top && r.bottom == L.keymap.bottom); + CHECK(r.x == L.keymap.x); + CHECK(r.right() == L.keymap.right()); + CHECK(r.y == L.keymap.y && r.bottom() == L.keymap.bottom()); } static void testAdjacentZonesTileSeamlessly() { @@ -71,10 +72,10 @@ static void testAdjacentZonesTileSeamlessly() { const EmbedLayout L = layoutEmbed(256, 40); const Rect lo = zoneSegmentRect(L, 0, 59); const Rect hi = zoneSegmentRect(L, 60, 127); - CHECK(lo.left == L.keymap.left); - CHECK(hi.right == L.keymap.right); - CHECK(lo.right == hi.left); // seamless tile — the load-bearing assertion - CHECK(lo.right == L.keymap.left + 60 * 2); // 60 keys * 2px + CHECK(lo.x == L.keymap.x); + CHECK(hi.right() == L.keymap.right()); + CHECK(lo.right() == hi.x); // seamless tile — the load-bearing assertion + CHECK(lo.right() == L.keymap.x + 60 * 2); // 60 keys * 2px } static void testZoneSegmentClampsBadNotes() { @@ -82,9 +83,9 @@ static void testZoneSegmentClampsBadNotes() { // Out-of-range notes clamp into the band; an inverted zone (low > high) collapses to a // zero-or-positive-width rect, never inverts. const Rect over = zoneSegmentRect(L, -10, 200); - CHECK(over.left == L.keymap.left && over.right == L.keymap.right); + CHECK(over.x == L.keymap.x && over.right() == L.keymap.right()); const Rect inv = zoneSegmentRect(L, 100, 20); - CHECK(inv.right >= inv.left); + CHECK(inv.right() >= inv.x); } // --- zoneAtPoint -------------------------------------------------------------- @@ -95,31 +96,31 @@ static void testZoneAtPointHits() { // A point inside the low zone's segment resolves to zone 0; inside the high zone, 1. const Rect lo = zoneSegmentRect(L, 0, 59); const Rect hi = zoneSegmentRect(L, 60, 127); - const int yMid = (L.keymap.top + L.keymap.bottom) / 2; - CHECK(zoneAtPoint(L, zones, 2, lo.left + 1, yMid) == 0); - CHECK(zoneAtPoint(L, zones, 2, hi.right - 1, yMid) == 1); + const int yMid = (L.keymap.y + L.keymap.bottom()) / 2; + CHECK(zoneAtPoint(L, zones, 2, lo.x + 1, yMid) == 0); + CHECK(zoneAtPoint(L, zones, 2, hi.right() - 1, yMid) == 1); } static void testZoneAtPointFirstMatchOnOverlap() { const EmbedLayout L = layoutEmbed(256, 40); // Two overlapping zones; the FIRST in order must win the contested keys. const EmbedZone zones[2] = {{0, 127}, {40, 80}}; - const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + const int yMid = (L.keymap.y + L.keymap.bottom()) / 2; const Rect contested = zoneSegmentRect(L, 40, 80); - CHECK(zoneAtPoint(L, zones, 2, contested.left + 1, yMid) == 0); // zone 0 wins + CHECK(zoneAtPoint(L, zones, 2, contested.x + 1, yMid) == 0); // zone 0 wins } static void testZoneAtPointMisses() { const EmbedLayout L = layoutEmbed(256, 40); const EmbedZone zones[1] = {{60, 72}}; // a narrow zone; most keys uncovered - const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + const int yMid = (L.keymap.y + L.keymap.bottom()) / 2; // A key left of the zone is uncovered -> -1. - CHECK(zoneAtPoint(L, zones, 1, L.keymap.left + 1, yMid) == -1); + CHECK(zoneAtPoint(L, zones, 1, L.keymap.x + 1, yMid) == -1); // A point in the level band (below the keymap) is off the keymap -> -1. - CHECK(zoneAtPoint(L, zones, 1, L.levelBand.left + 4, L.levelBand.top) == -1); + CHECK(zoneAtPoint(L, zones, 1, L.levelBand.x + 4, L.levelBand.y) == -1); // Empty / null list -> -1. - CHECK(zoneAtPoint(L, zones, 0, L.keymap.left + 1, yMid) == -1); - CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.left + 1, yMid) == -1); + CHECK(zoneAtPoint(L, zones, 0, L.keymap.x + 1, yMid) == -1); + CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.x + 1, yMid) == -1); } // --- levelFillRect ------------------------------------------------------------ @@ -127,16 +128,16 @@ static void testZoneAtPointMisses() { static void testLevelFillClamps() { const EmbedLayout L = layoutEmbed(200, 40); // Zero / negative -> empty. - CHECK(levelFillRect(L, 0.0).width() <= 0); - CHECK(levelFillRect(L, -1.0).width() <= 0); + CHECK(levelFillRect(L, 0.0).width <= 0); + CHECK(levelFillRect(L, -1.0).width <= 0); // Full / over-full -> the whole band width. - CHECK(levelFillRect(L, 1.0).width() == L.levelBand.width()); - CHECK(levelFillRect(L, 5.0).width() == L.levelBand.width()); + CHECK(levelFillRect(L, 1.0).width == L.levelBand.width); + CHECK(levelFillRect(L, 5.0).width == L.levelBand.width); // Half -> ~half the band, pinned to the band's left and vertical extent. const Rect half = levelFillRect(L, 0.5); - CHECK(half.left == L.levelBand.left); - CHECK(half.top == L.levelBand.top && half.bottom == L.levelBand.bottom); - CHECK(half.width() == L.levelBand.width() / 2); + CHECK(half.x == L.levelBand.x); + CHECK(half.y == L.levelBand.y && half.bottom() == L.levelBand.bottom()); + CHECK(half.width == L.levelBand.width / 2); } int main() { diff --git a/tests/test_envelope_edit.cpp b/tests/test_envelope_edit.cpp index 6b40cac..37f92a3 100644 --- a/tests/test_envelope_edit.cpp +++ b/tests/test_envelope_edit.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::envelope_edit — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::envelope_edit — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. Assert the S-VIEW-3 draggable-node INVERSE // map: node hit-test + pixel-delta -> clamped/monotonic param set, HARD at the clamp + monotonic // boundaries (the load-bearing "a drag can never produce a param a slider couldn't" invariant). @@ -14,14 +14,15 @@ // pixel delta; zero-fade-out node grabbable at the right edge and draggable inward — FA2); // degenerate area/duration + non-draggable node + cross-mode node -> no motion. -#include "../src/vst/envelope_edit.h" +#include "../src/core/instrument/ui/envelope_edit.h" #include #include #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -41,7 +42,7 @@ static bool findNode(const std::vector& poly, EnvNode node, EnvVertex // scale (FA2 param-domain schematic — sample-length-free): (850-1-32)px over the 8.0s schematic // domain => 102.125 px/s, each segment prefixed by the 8px separation base; the gateEnv() nodes // draw at A x@28, H x@47, D x@85, RS x@235, RE x@284. -static Rect wideArea() { return Rect{20, 10, 1020, 110}; } +static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } static constexpr double kTotal = 2.0; static const double kGateSecPerPx = 1.0 / gatePxPerSecond(wideArea()); @@ -71,10 +72,10 @@ static void testHitGrabsDrawnHandle() { const AmpEnvelope e = gateEnv(); const Rect a = wideArea(); // AttackEnd draws at x = left+28 (8px base + 0.2s * 102.125 px/s), y = top (level 1). - NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 28, a.top); + NodeHit h = nodeAtPoint(e, a, kTotal, a.x + 28, a.y); CHECK(h.hit && h.node == EnvNode::AttackEnd); // The sustain node (DecayEnd) at left+85, level 0.5 -> ~top+50. - NodeHit s = nodeAtPoint(e, a, kTotal, a.left + 85, a.top + 50); + NodeHit s = nodeAtPoint(e, a, kTotal, a.x + 85, a.y + 50); CHECK(s.hit && s.node == EnvNode::DecayEnd); } @@ -82,7 +83,7 @@ static void testHitMissesOffEveryNode() { const AmpEnvelope e = gateEnv(); const Rect a = wideArea(); // A point far from any drawn handle (right of the release ramp, well away from a node). - NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 700, a.top + 5); + NodeHit h = nodeAtPoint(e, a, kTotal, a.x + 700, a.y + 5); CHECK(!h.hit); } @@ -90,11 +91,11 @@ static void testHitSkipsNonDraggableAnchors() { const AmpEnvelope e = gateEnv(); const Rect a = wideArea(); // Origin draws at (left, bottom-1). Even a pixel-perfect grab there is NOT a draggable node. - NodeHit o = nodeAtPoint(e, a, kTotal, a.left, a.bottom - 1); + NodeHit o = nodeAtPoint(e, a, kTotal, a.x, a.bottom() - 1); CHECK(!o.hit); // ReleaseStart draws at (left+235, sustain level ~top+50) — the fixed plateau end. It is // drawing-only -> not grabbable; no other node is within the radius, so this grab misses. - NodeHit rs = nodeAtPoint(e, a, kTotal, a.left + 235, a.top + 50); + NodeHit rs = nodeAtPoint(e, a, kTotal, a.x + 235, a.y + 50); CHECK(!rs.hit); } @@ -106,7 +107,7 @@ static void testHitNearestNodeWinsOverDrawOrder() { AmpEnvelope e = gateEnv(); e.holdSeconds = 0.01; const Rect a = wideArea(); - NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 33, a.top); + NodeHit h = nodeAtPoint(e, a, kTotal, a.x + 33, a.y); CHECK(h.hit && h.node == EnvNode::HoldEnd); } @@ -199,11 +200,11 @@ static void testGateTimeOnlyNodeIgnoresY() { static void testGateReleaseEndGrabAndDrag() { // The FA2 fix: ReleaseEnd is a drawn, IN-BOUNDS, grabbable handle (pre-FA2 it mapped past - // area.right and could never be grabbed). gateEnv() draws it at x@284, level 0 (bottom row). + // area.right() and could never be grabbed). gateEnv() draws it at x@284, level 0 (bottom row). const AmpEnvelope e = gateEnv(); const Rect a = wideArea(); EnvClampBounds b; - NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 284, a.bottom - 1); + NodeHit h = nodeAtPoint(e, a, kTotal, a.x + 284, a.bottom() - 1); CHECK(h.hit && h.node == EnvNode::ReleaseEnd); // Dragging it RIGHT lengthens the release at the gate timed scale; only release changes. AmpEnvelope out = resolveNodeDrag(e, EnvNode::ReleaseEnd, a, kTotal, b, 85, 0); @@ -289,14 +290,14 @@ static void testTriggerZeroFadeOutGrabbableAtRightEdge() { e.fadeOutFraction = 0.0; const Rect a = wideArea(); EnvClampBounds b; - NodeHit h = nodeAtPoint(e, a, kTotal, a.right - 1, a.top); + NodeHit h = nodeAtPoint(e, a, kTotal, a.right() - 1, a.y); CHECK(h.hit && h.node == EnvNode::FadeOutStart); // -100px = -0.2s on the 2.0s played span, applied OPPOSITE -> fadeOut 0.0 -> 0.1. AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeOutStart, a, kTotal, b, -100, 0); CHECK(near(out.fadeOutFraction, 0.1)); CHECK(near(out.lengthFraction, e.lengthFraction)); // length untouched // LengthEnd sits at the same x but level 0 (bottom row) — grabbable at ITS drawn point. - NodeHit le = nodeAtPoint(e, a, kTotal, a.right - 1, a.bottom - 1); + NodeHit le = nodeAtPoint(e, a, kTotal, a.right() - 1, a.bottom() - 1); CHECK(le.hit && le.node == EnvNode::LengthEnd); } @@ -327,7 +328,7 @@ static void testNonDraggableNodeNoMotion() { static void testDegenerateAreaNoMotion() { const AmpEnvelope e = gateEnv(); EnvClampBounds b; - const Rect zeroW = Rect{0, 0, 0, 100}; + const Rect zeroW = Rect::ltrb(0, 0, 0, 100); AmpEnvelope o1 = resolveNodeDrag(e, EnvNode::AttackEnd, zeroW, kTotal, b, 500, 0); CHECK(near(o1.attackSeconds, e.attackSeconds)); AmpEnvelope o2 = resolveNodeDrag(e, EnvNode::AttackEnd, wideArea(), 0.0, b, 500, 0); // no time @@ -347,7 +348,7 @@ static void testCrossModeNodeNoMotion() { out = resolveNodeDrag(g, EnvNode::FadeInEnd, wideArea(), kTotal, b, 50, 0); CHECK(near(out.fadeInFraction, g.fadeInFraction)); // And the zero-height baseline's ReleaseEnd is not even reported grabbable in Trigger mode. - const Rect flat = Rect{0, 0, 100, 0}; + const Rect flat = Rect::ltrb(0, 0, 100, 0); const NodeHit h = nodeAtPoint(t, flat, kTotal, 99, 0); CHECK(!h.hit); } diff --git a/tests/test_envelope_overlay.cpp b/tests/test_envelope_overlay.cpp index bd09245..6839bec 100644 --- a/tests/test_envelope_overlay.cpp +++ b/tests/test_envelope_overlay.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::envelope_overlay — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::envelope_overlay — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. Assert the S-VIEW-3/FA2 amp-envelope -> // polyline FORWARD map: the Gate BOUNDED-SCHEMATIC AHDSR shape (attack ramp / hold plateau / // decay-to-sustain / fixed-width sustain plateau / in-bounds release) and the Trigger @@ -14,12 +14,13 @@ // the played span, overlap clamp, full-length/zero-fade-out nodes in-bounds at right-1); // degenerate flat baseline. -#include "../src/vst/envelope_overlay.h" +#include "../src/core/instrument/ui/envelope_overlay.h" #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -27,7 +28,7 @@ static int g_fail = 0; // A comfortable overlay area: 1000px wide, 100px tall, offset so left/top != 0 (catches origin // bugs). Under levelToY the level span is height-1 = 99 rows. -static Rect wideArea() { return Rect{20, 10, 1020, 110}; } // width 1000, height 100 +static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } // width 1000, height 100 // Find the first vertex with a given node in a polyline; asserts presence via the returned bool. static bool findNode(const std::vector& poly, EnvNode node, EnvVertex& out) { @@ -41,32 +42,32 @@ static bool findNode(const std::vector& poly, EnvNode node, EnvVertex static void testTimeToXEndpoints() { const Rect a = wideArea(); - CHECK(timeToX(a, 2.0, 0.0) == a.left); // t=0 -> left - CHECK(timeToX(a, 2.0, 2.0) == a.right - 1); // t=total -> last in-bounds column - CHECK(timeToX(a, 2.0, 1.0) == a.left + 500); // midpoint + CHECK(timeToX(a, 2.0, 0.0) == a.x); // t=0 -> left + CHECK(timeToX(a, 2.0, 2.0) == a.right() - 1); // t=total -> last in-bounds column + CHECK(timeToX(a, 2.0, 1.0) == a.x + 500); // midpoint } static void testTimeToXNegativePinsLeft() { const Rect a = wideArea(); - CHECK(timeToX(a, 2.0, -0.5) == a.left); // t<0 pins left + CHECK(timeToX(a, 2.0, -0.5) == a.x); // t<0 pins left } static void testTimeToXPastEndClamps() { // FA2 bounds invariant: t past total pins to the last in-bounds column, never past right. const Rect a = wideArea(); - CHECK(timeToX(a, 2.0, 3.0) == a.right - 1); - CHECK(timeToX(a, 2.0, 1000.0) == a.right - 1); + CHECK(timeToX(a, 2.0, 3.0) == a.right() - 1); + CHECK(timeToX(a, 2.0, 1000.0) == a.right() - 1); // A HUGE t must clamp in double space, not overflow the integer cast (32-bit long on // Windows would wrap to LONG_MIN and pin to the WRONG edge). - CHECK(timeToX(a, 2.0, 1e15) == a.right - 1); + CHECK(timeToX(a, 2.0, 1e15) == a.right() - 1); } static void testGateTimedWidth() { // 15% of the 1000px canvas is reserved for the sustain plateau -> 850px timed region. CHECK(gateTimedWidth(wideArea()) == 850); // Zero-width area -> 0; a tiny area still yields >= 1 so the px<->s scale never degenerates. - CHECK(gateTimedWidth(Rect{5, 5, 5, 45}) == 0); - CHECK(gateTimedWidth(Rect{0, 0, 1, 10}) == 1); + CHECK(gateTimedWidth(Rect::ltrb(5, 5, 5, 45)) == 0); + CHECK(gateTimedWidth(Rect::ltrb(0, 0, 1, 10)) == 1); } static void testGatePxPerSecond() { @@ -74,30 +75,30 @@ static void testGatePxPerSecond() { // 1000px canvas: (850 - 1 - 32) / 8.0s = 817/8 px/s. Independent of any sample duration. const double expected = 817.0 / (4.0 * kGateStageMaxSeconds); CHECK(gatePxPerSecond(wideArea()) == expected); - CHECK(gatePxPerSecond(Rect{5, 5, 5, 45}) == 0.0); // zero-width area -> 0 - CHECK(gatePxPerSecond(Rect{0, 0, 10, 10}) > 0.0); // tiny area: usable floors at 1px, > 0 + CHECK(gatePxPerSecond(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0 + CHECK(gatePxPerSecond(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0 } static void testTimeToXDegenerate() { const Rect a = wideArea(); - CHECK(timeToX(a, 0.0, 1.0) == a.left); // no duration -> left - const Rect z = Rect{5, 5, 5, 45}; // zero width - CHECK(timeToX(z, 2.0, 1.0) == z.left); + CHECK(timeToX(a, 0.0, 1.0) == a.x); // no duration -> left + const Rect z = Rect::ltrb(5, 5, 5, 45); // zero width + CHECK(timeToX(z, 2.0, 1.0) == z.x); } static void testLevelToYEndpoints() { const Rect a = wideArea(); - CHECK(levelToY(a, 1.0) == a.top); // level 1 -> top row - CHECK(levelToY(a, 0.0) == a.bottom - 1); // level 0 -> bottom row - CHECK(levelToY(a, 0.5) == a.top + 50); // mid: round((1-0.5)*99)=round(49.5)=50 + CHECK(levelToY(a, 1.0) == a.y); // level 1 -> top row + CHECK(levelToY(a, 0.0) == a.bottom() - 1); // level 0 -> bottom row + CHECK(levelToY(a, 0.5) == a.y + 50); // mid: round((1-0.5)*99)=round(49.5)=50 } static void testLevelToYClamps() { const Rect a = wideArea(); - CHECK(levelToY(a, 2.0) == a.top); // >1 clamps to top - CHECK(levelToY(a, -1.0) == a.bottom - 1); // <0 clamps to bottom - const Rect z = Rect{5, 5, 45, 5}; // zero height - CHECK(levelToY(z, 0.5) == z.top); + CHECK(levelToY(a, 2.0) == a.y); // >1 clamps to top + CHECK(levelToY(a, -1.0) == a.bottom() - 1); // <0 clamps to bottom + const Rect z = Rect::ltrb(5, 5, 45, 5); // zero height + CHECK(levelToY(z, 0.5) == z.y); } // --- Gate polyline ------------------------------------------------------------ @@ -149,11 +150,11 @@ static void testGateSchematicPlacement() { const std::vector poly = buildEnvelopePolyline(env, a, 2.0); EnvVertex v; - CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.left + 28); - CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.left + 47); - CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.left + 85); - CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.left + 235); - CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.left + 284); + CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 28); + CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 47); + CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 85); + CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 235); + CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 284); } static void testGateLayoutIndependentOfSampleDuration() { @@ -195,7 +196,7 @@ static void testGateSustainPlateauFixedWidth() { env.sustainLevel = 0.6; env.releaseSeconds = 0.3; const Rect a = wideArea(); - const int plateauPx = a.width() - gateTimedWidth(a); // 150 + const int plateauPx = a.width - gateTimedWidth(a); // 150 const std::vector poly = buildEnvelopePolyline(env, a, 2.0); EnvVertex decay, plateauEnd; @@ -207,7 +208,7 @@ static void testGateSustainPlateauFixedWidth() { static void testGateReleaseVisibleInBounds() { // The FA2 fix: Release is a VISIBLE, in-bounds segment — ReleaseEnd sits strictly right of - // the plateau end and strictly inside the canvas (pre-FA2 it mapped past area.right and the + // the plateau end and strictly inside the canvas (pre-FA2 it mapped past area.right() and the // shell clipped its handle away). AmpEnvelope env; env.mode = EnvMode::Gate; @@ -223,7 +224,7 @@ static void testGateReleaseVisibleInBounds() { CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd)); CHECK(findNode(poly, EnvNode::ReleaseEnd, rel)); CHECK(rel.x > plateauEnd.x); // a visible ramp, not a collapsed point - CHECK(rel.x < a.right); // strictly in-bounds + CHECK(rel.x < a.right()); // strictly in-bounds CHECK(rel.level == 0.0); } @@ -231,7 +232,7 @@ static void testGateOverrunCompressesFromRight() { // Stages BEYOND the schematic domain (4.0s each > kGateStageMaxSeconds): the layout // compresses from the right preserving the minimum gaps — ReleaseEnd pins to the last // in-bounds column, but the trailing nodes stay strictly increasing and individually - // separated (>= kGateNodeSepPx), NOT piled on one pixel. NOTHING maps past area.right. + // separated (>= kGateNodeSepPx), NOT piled on one pixel. NOTHING maps past area.right(). AmpEnvelope env; env.mode = EnvMode::Gate; env.attackSeconds = 4.0; @@ -246,12 +247,12 @@ static void testGateOverrunCompressesFromRight() { EnvVertex plateauEnd, rel; CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd)); CHECK(findNode(poly, EnvNode::ReleaseEnd, rel)); - CHECK(rel.x == a.right - 1); // pinned to the last in-bounds column + CHECK(rel.x == a.right() - 1); // pinned to the last in-bounds column CHECK(plateauEnd.level == 0.7); // still at sustain for (size_t i = 1; i < poly.size(); ++i) { CHECK(poly[i].x > poly[i - 1].x); // strictly monotonic CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx - 1); // min gaps survive compression - CHECK(poly[i].x >= a.left && poly[i].x < a.right); // in-bounds + CHECK(poly[i].x >= a.x && poly[i].x < a.right()); // in-bounds } } @@ -279,8 +280,8 @@ static void testGateAllVerticesInBounds() { for (const AmpEnvelope& env : {base, big, zero, trig, huge}) { for (const EnvVertex& v : buildEnvelopePolyline(env, a, 2.0)) { - CHECK(v.x >= a.left && v.x < a.right); - CHECK(v.y >= a.top && v.y < a.bottom); + CHECK(v.x >= a.x && v.x < a.right()); + CHECK(v.y >= a.y && v.y < a.bottom()); } } } @@ -305,9 +306,9 @@ static void testTriggerShape() { CHECK(poly[3].node == EnvNode::LengthEnd); EnvVertex v; - CHECK(findNode(poly, EnvNode::FadeInEnd, v) && v.x == a.left + 100 && v.level == 1.0); - CHECK(findNode(poly, EnvNode::FadeOutStart, v) && v.x == a.left + 350 && v.level == 1.0); - CHECK(findNode(poly, EnvNode::LengthEnd, v) && v.x == a.left + 500 && v.level == 0.0); + CHECK(findNode(poly, EnvNode::FadeInEnd, v) && v.x == a.x + 100 && v.level == 1.0); + CHECK(findNode(poly, EnvNode::FadeOutStart, v) && v.x == a.x + 350 && v.level == 1.0); + CHECK(findNode(poly, EnvNode::LengthEnd, v) && v.x == a.x + 500 && v.level == 0.0); } static void testTriggerFadeOverlapClamp() { @@ -324,7 +325,7 @@ static void testTriggerFadeOverlapClamp() { CHECK(findNode(poly, EnvNode::FadeInEnd, fin)); CHECK(findNode(poly, EnvNode::FadeOutStart, fout)); CHECK(fin.x == fout.x); // fades meet exactly, never cross - CHECK(fin.x == a.left + 800); + CHECK(fin.x == a.x + 800); } static void testTriggerFullLengthZeroFadeOutInBounds() { @@ -342,8 +343,8 @@ static void testTriggerFullLengthZeroFadeOutInBounds() { EnvVertex fout, lend; CHECK(findNode(poly, EnvNode::FadeOutStart, fout)); CHECK(findNode(poly, EnvNode::LengthEnd, lend)); - CHECK(fout.x == a.right - 1); // present + in-bounds at zero fade-out - CHECK(lend.x == a.right - 1); + CHECK(fout.x == a.right() - 1); // present + in-bounds at zero fade-out + CHECK(lend.x == a.right() - 1); CHECK(fout.level == 1.0 && lend.level == 0.0); } @@ -351,7 +352,7 @@ static void testTriggerFullLengthZeroFadeOutInBounds() { static void testDegenerateFlatBaseline() { AmpEnvelope env; // any params - const Rect zeroW = Rect{0, 0, 0, 100}; + const Rect zeroW = Rect::ltrb(0, 0, 0, 100); const std::vector p1 = buildEnvelopePolyline(env, zeroW, 2.0); CHECK(p1.size() == 2); // always a drawable line CHECK(p1.front().level == 0.0 && p1.back().level == 0.0); @@ -360,7 +361,7 @@ static void testDegenerateFlatBaseline() { const std::vector p2 = buildEnvelopePolyline(env, ok, 0.0); // no duration CHECK(p2.size() == 2); CHECK(p2.front().level == 0.0 && p2.back().level == 0.0); - CHECK(p2.front().x == ok.left && p2.back().x == ok.right - 1); // spans the area, in-bounds + CHECK(p2.front().x == ok.x && p2.back().x == ok.right() - 1); // spans the area, in-bounds } int main() { diff --git a/tests/test_file_bytes.cpp b/tests/test_file_bytes.cpp new file mode 100644 index 0000000..ba6789a --- /dev/null +++ b/tests/test_file_bytes.cpp @@ -0,0 +1,52 @@ +// Standalone tests for reasampler::readFileBytes — no REAPER, no framework. +// The ONE whole-file loader (Q-W1, T2-03) shared by both artifacts. Exercises +// the three-way contract: exact bytes back, empty on a missing file, empty on +// an empty file. Uses a scratch file in the test's working directory. + +#include "../src/core/util/file_bytes.h" + +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::util; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static const char* kScratch = "file_bytes_scratch.bin"; + +static void testReadsExactBytesBack() { + // Binary content incl. NUL and 0xFF — the loader must be byte-transparent. + const std::vector payload = {0x00, 0x01, 0xFF, 0x7E, 0x00, 0x0A}; + { + std::ofstream f(kScratch, std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(payload.data()), + static_cast(payload.size())); + } + CHECK(readFileBytes(kScratch) == payload); + std::remove(kScratch); +} + +static void testMissingFileIsEmpty() { + CHECK(readFileBytes("no_such_file_anywhere.bin").empty()); +} + +static void testEmptyFileIsEmpty() { + { std::ofstream f(kScratch, std::ios::binary | std::ios::trunc); } + CHECK(readFileBytes(kScratch).empty()); + std::remove(kScratch); +} + +int main() { + testReadsExactBytesBack(); + testMissingFileIsEmpty(); + testEmptyFileIsEmpty(); + + if (g_fail == 0) std::printf("file_bytes: all tests passed\n"); + else std::printf("file_bytes: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_footer_bar.cpp b/tests/test_footer_bar.cpp index ba8cb4d..73a2462 100644 --- a/tests/test_footer_bar.cpp +++ b/tests/test_footer_bar.cpp @@ -13,11 +13,12 @@ // * Hit-test: Toggle / Tail returned for in-bounds points, None outside AND on the count // label (a passive readout, never a control); half-open bounds; suppressed box claims none. -#include "../src/footer_bar.h" +#include "../src/core/ui/footer_bar.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_guid_diff.cpp b/tests/test_guid_diff.cpp index 08febad..b888ab2 100644 --- a/tests/test_guid_diff.cpp +++ b/tests/test_guid_diff.cpp @@ -10,7 +10,7 @@ // 5. reset() (project switch) re-arms the first-poll guard: the next observe() // re-baselines and reports nothing new — never diffs across projects. -#include "../src/guid_diff.h" +#include "../src/core/view/guid_diff.h" #include #include @@ -18,6 +18,7 @@ #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_insert_plan.cpp b/tests/test_insert_plan.cpp index 1339ddb..c118288 100644 --- a/tests/test_insert_plan.cpp +++ b/tests/test_insert_plan.cpp @@ -5,12 +5,13 @@ // proof that the forbidden stretch bit is never set and that native-length insert // carries no tempo bits. -#include "../src/insert_plan.h" +#include "../src/core/capture/insert_plan.h" #include #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_instrument_drop.cpp b/tests/test_instrument_drop.cpp index 08ffaa6..2a6a934 100644 --- a/tests/test_instrument_drop.cpp +++ b/tests/test_instrument_drop.cpp @@ -6,8 +6,8 @@ // container -> the instrument's OWN reader -> assert the capture selected) IS the // cross-artifact contract guard — the same pattern assignment_request_tests uses. -#include "../src/instrument_drop.h" -#include "../src/vst/sample_map.h" // deserializeComponentState — the instrument's OWN reader +#include "../src/core/wire/instrument_drop.h" +#include "../src/core/instrument/map/component_state_io.h" // deserializeComponentState — the instrument's OWN reader #include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — pins the per-channel class ID @@ -17,6 +17,8 @@ #include using namespace reasampler; +using namespace reasampler::wire; +using namespace reasampler::instrument::map; // ComponentState + the codec (Q-W2v re-namespace) static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_json.cpp b/tests/test_json.cpp new file mode 100644 index 0000000..0c71cbe --- /dev/null +++ b/tests/test_json.cpp @@ -0,0 +1,297 @@ +// Standalone tests for reasampler::json — no REAPER, no framework. The ONE +// lexical JSON layer (Q-W1) behind bank_model / bank_book / view_mode_model / +// owned_manifest / tail_control. The consumers' own suites prove the domain +// grammars; this suite pins the LEXICAL contract — the escape set, the number +// renderings (byte-exact), the parse tolerances, and the reject paths — so a +// change here is caught before it silently shifts five persisted-blob formats. +// +// NOTE: json::Reader BORROWS its input string, so every test binds a named +// std::string first — never a temporary. + +#include "../src/core/json/json.h" + +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::json; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// Convenience: parse helpers over a named buffer per call site. +static bool intFrom(const std::string& s, int& v) { json::Reader r(s); return r.parseInt(v); } +static bool int64From(const std::string& s, std::int64_t& v) { json::Reader r(s); return r.parseInt64(v); } +static bool doubleFrom(const std::string& s, double& v) { json::Reader r(s); return r.parseDouble(v); } +static bool boolFrom(const std::string& s, bool& v) { json::Reader r(s); return r.parseBool(v); } +static bool stringFrom(const std::string& s, std::string& v) { json::Reader r(s); return r.parseString(v); } + +// --- emit: writeEscaped ------------------------------------------------------- + +static void testEscapeExactBytes() { + // The seven short escapes + \u00XX for remaining control chars, verbatim + // pass-through otherwise. Byte-exact: this is the persisted-blob format. + std::string out; + json::writeEscaped(out, "a\"b\\c\n\t\x01z"); + CHECK(out == "\"a\\\"b\\\\c\\n\\t\\u0001z\""); +} + +static void testEscapeUtf8PassesThrough() { + // Multi-byte UTF-8 passes through verbatim; only C0 controls are \u-escaped. + std::string out; + json::writeEscaped(out, "gr\xC3\xBC n"); // "grü n" + CHECK(out == "\"gr\xC3\xBC n\""); +} + +// --- emit: numToStr ----------------------------------------------------------- + +static void testNumToStrIntForms() { + CHECK(json::numToStr(0) == "0"); + CHECK(json::numToStr(-7) == "-7"); + CHECK(json::numToStr(INT_MAX) == "2147483647"); + CHECK(json::numToStr(static_cast(1) << 40) == "1099511627776"); + CHECK(json::numToStr(2000.0) == "2000"); // %.17g drops the trailing .0 + CHECK(json::numToStr(0.5) == "0.5"); +} + +static void testDoubleRoundTripsBitForBit() { + // %.17g is the shortest form that round-trips every IEEE-754 double. + const double v = 3141.592653589793; + double back = 0.0; + CHECK(doubleFrom(json::numToStr(v), back)); + CHECK(back == v); +} + +// --- emit: Writer object grammar ---------------------------------------------- + +static void testWriterEmitsExactObjectBytes() { + std::string out; + { + json::Writer w(out); + w.keyRaw("a", json::numToStr(1)); + w.keyStr("b", "x\"y"); + w.keyBegin("c"); + { + json::Writer nested(out); + nested.keyRaw("d", json::numToStr(2.5)); + } + w.keyBegin("e"); + json::writeStringArray(out, {"p", "q"}); + w.keyBegin("f"); + json::writeIntArray(out, {1, 2}); + } + CHECK(out == "{\"a\":1,\"b\":\"x\\\"y\",\"c\":{\"d\":2.5}," + "\"e\":[\"p\",\"q\"],\"f\":[1,2]}"); +} + +static void testEmptyArraysEmitBrackets() { + std::string s, i; + json::writeStringArray(s, {}); + json::writeIntArray(i, {}); + CHECK(s == "[]"); + CHECK(i == "[]"); +} + +// --- Reader: strings ---------------------------------------------------------- + +static void testParseStringEscapes() { + std::string out; + CHECK(stringFrom(" \"a\\\"b\\\\c\\n\\u0041\"", out)); + CHECK(out == "a\"b\\c\nA"); +} + +static void testParseStringSurrogatePairToUtf8() { + // \uD83D\uDE00 (grinning face) -> F0 9F 98 80. + std::string out; + CHECK(stringFrom("\"\\ud83d\\ude00\"", out)); + CHECK(out == "\xF0\x9F\x98\x80"); +} + +static void testParseStringRejectsMalformed() { + std::string out; + CHECK(!stringFrom("\"unterminated", out)); + CHECK(!stringFrom("\"bad\\qescape\"", out)); + CHECK(!stringFrom("\"\\ud800 alone\"", out)); // unpaired high surrogate + CHECK(!stringFrom("\"\\udc00\"", out)); // unpaired low surrogate + CHECK(!stringFrom("noquote", out)); +} + +// --- Reader: numbers ---------------------------------------------------------- + +static void testParseIntAcceptsAndRejects() { + int v = 0; + CHECK(intFrom("42,", v)); CHECK(v == 42); + CHECK(intFrom("-7}", v)); CHECK(v == -7); + CHECK(intFrom("2147483647]", v)); CHECK(v == INT_MAX); + // Out of int range is REJECTED (the unified guard every consumer now shares). + CHECK(!intFrom("2147483648,", v)); + CHECK(!intFrom("1.5,", v)); + CHECK(!intFrom("x,", v)); + CHECK(!intFrom("", v)); +} + +static void testParseInt64RangeAndReject() { + std::int64_t v = 0; + CHECK(int64From("9223372036854775807,", v)); + CHECK(v == 9223372036854775807LL); + CHECK(!int64From("9223372036854775808,", v)); // ERANGE -> reject +} + +static void testParseDoubleRejectsRangeAndGarbage() { + double v = 0; + CHECK(!doubleFrom("1e999,", v)); // ERANGE + CHECK(!doubleFrom("1.5abc,", v)); // trailing bytes + CHECK(doubleFrom("2.5}", v)); CHECK(v == 2.5); +} + +static void testParseBool() { + bool v = false; + CHECK(boolFrom("true,", v)); CHECK(v); + CHECK(boolFrom("false]", v)); CHECK(!v); + CHECK(!boolFrom("TRUE,", v)); +} + +// --- Reader: structure -------------------------------------------------------- + +static void testExpectNullOr() { + { + const std::string s = "null,"; + json::Reader r(s); + bool wasNull = false; + CHECK(r.expectNullOr(wasNull)); CHECK(wasNull); CHECK(r.consume(',')); + } + { + const std::string s = "\"x\""; + json::Reader r(s); + bool wasNull = true; + std::string v; + CHECK(r.expectNullOr(wasNull)); CHECK(!wasNull); + CHECK(r.parseString(v)); CHECK(v == "x"); + } + { + const std::string s; + json::Reader r(s); + bool wasNull = false; + CHECK(!r.expectNullOr(wasNull)); + } +} + +static void testParseKeyConsumesColon() { + const std::string s = " \"k\" : 1"; + json::Reader r(s); + std::string k; + int v = 0; + CHECK(r.parseKey(k)); + CHECK(k == "k"); + CHECK(r.parseInt(v)); + CHECK(v == 1); +} + +static void testParseArraysAppend() { + { + const std::string s = "[\"a\",\"b\"]"; + json::Reader r(s); + std::vector v; + CHECK(r.parseStringArray(v)); + CHECK(v.size() == 2 && v[0] == "a" && v[1] == "b"); + } + { + const std::string s = "[]"; + json::Reader r(s); + std::vector v; + CHECK(r.parseStringArray(v)); CHECK(v.empty()); + } + { + const std::string s = "[1,2]"; // non-string element + json::Reader r(s); + std::vector v; + CHECK(!r.parseStringArray(v)); + } + { + const std::string s = "[1,2,3]"; + json::Reader r(s); + std::vector v; + CHECK(r.parseIntArray(v)); + CHECK(v.size() == 3 && v[2] == 3); + } + { + const std::string s = "[1,"; // truncated + json::Reader r(s); + std::vector v; + CHECK(!r.parseIntArray(v)); + } +} + +static void testSkipValueOverNestedShapes() { + // Skips a nested object whose strings contain structural chars, then the + // cursor sits exactly on the next separator. + const std::string s = "{\"deep\":[\"}\",{\"x\":\"]\"}]},7"; + json::Reader r(s); + CHECK(r.skipValue()); + CHECK(r.consume(',')); + int v = 0; + CHECK(r.parseInt(v)); + CHECK(v == 7); +} + +static void testCaptureValueVerbatim() { + const std::string s = " {\"a\":[1,\"{\"]} ,tail"; + json::Reader r(s); + std::string raw; + CHECK(r.captureValue(raw)); + CHECK(raw == "{\"a\":[1,\"{\"]}"); + CHECK(r.consume(',')); +} + +static void testWriterOutputParsesBack() { + // The emitted object is consumable by the Reader — the seam the five + // consumers rely on (writer and reader agree on one dialect). + std::string out; + { + json::Writer w(out); + w.keyStr("name", "tab\there"); + w.keyRaw("n", json::numToStr(-3)); + } + json::Reader r(out); + CHECK(r.consume('{')); + std::string k1, v1; + CHECK(r.parseKey(k1) && k1 == "name"); + CHECK(r.parseString(v1) && v1 == "tab\there"); + CHECK(r.consume(',')); + std::string k2; + int v2 = 0; + CHECK(r.parseKey(k2) && k2 == "n"); + CHECK(r.parseInt(v2) && v2 == -3); + CHECK(r.consume('}')); + r.skipWs(); + CHECK(r.eof()); +} + +int main() { + testEscapeExactBytes(); + testEscapeUtf8PassesThrough(); + testNumToStrIntForms(); + testDoubleRoundTripsBitForBit(); + testWriterEmitsExactObjectBytes(); + testEmptyArraysEmitBrackets(); + testParseStringEscapes(); + testParseStringSurrogatePairToUtf8(); + testParseStringRejectsMalformed(); + testParseIntAcceptsAndRejects(); + testParseInt64RangeAndReject(); + testParseDoubleRejectsRangeAndGarbage(); + testParseBool(); + testExpectNullOr(); + testParseKeyConsumesColon(); + testParseArraysAppend(); + testSkipValueOverNestedShapes(); + testCaptureValueVerbatim(); + testWriterOutputParsesBack(); + + if (g_fail == 0) std::printf("json: all tests passed\n"); + else std::printf("json: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp index 57596cf..61bbc4b 100644 --- a/tests/test_keyboard_strip.cpp +++ b/tests/test_keyboard_strip.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::keyboard_strip — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. Assert the capture-first editor's // keyboard-strip layout, root marker, key mapping, zone-bar hit regions, and the drag-delta // note resolver directly — the geometry that backs the single-capture root-set and the opt-in @@ -14,11 +14,12 @@ // no-ops; isNaturalKey across a full octave (C4..B4), at boundary notes 0 and 127, and with // out-of-range inputs that clamp to [0,127]. -#include "../src/vst/keyboard_strip.h" +#include "../src/core/instrument/ui/keyboard_strip.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -32,13 +33,13 @@ static StripLayout wideStrip() { return layoutStrip(1280, 40); } static void testLayoutNormalArea() { const StripLayout L = layoutStrip(640, 40); - CHECK(L.keys.left == 0 && L.keys.top == 0); - CHECK(L.keys.right == 640 && L.keys.bottom == 40); + CHECK(L.keys.x == 0 && L.keys.y == 0); + CHECK(L.keys.right() == 640 && L.keys.bottom() == 40); } static void testLayoutZeroArea() { const StripLayout L = layoutStrip(0, 0); - CHECK(L.keys.width() == 0 && L.keys.height() == 0); + CHECK(L.keys.width == 0 && L.keys.height == 0); } // --- keyLeftX / keyRect / rootMarkerRect -------------------------------------- @@ -46,8 +47,8 @@ static void testLayoutZeroArea() { static void testKeyLeftMonotonicAndBounds() { const StripLayout L = wideStrip(); // Key 0's left edge is the band left; the 128 boundary is the band right. - CHECK(keyLeftX(L, 0) == L.keys.left); - CHECK(keyLeftX(L, 128) == L.keys.right); + CHECK(keyLeftX(L, 0) == L.keys.x); + CHECK(keyLeftX(L, 128) == L.keys.right()); // Strictly non-decreasing across the span. int prev = keyLeftX(L, 0); for (int n = 1; n <= 128; ++n) { @@ -62,17 +63,17 @@ static void testKeyLeftMonotonicAndBounds() { static void testKeyRectHalfOpen() { const StripLayout L = wideStrip(); const Rect k = keyRect(L, 60); - CHECK(k.left == keyLeftX(L, 60)); - CHECK(k.right == keyLeftX(L, 61)); - CHECK(k.top == L.keys.top && k.bottom == L.keys.bottom); - CHECK(k.width() == 10); // 10px/key + CHECK(k.x == keyLeftX(L, 60)); + CHECK(k.right() == keyLeftX(L, 61)); + CHECK(k.y == L.keys.y && k.bottom() == L.keys.bottom()); + CHECK(k.width == 10); // 10px/key } static void testRootMarkerEqualsKeyRect() { const StripLayout L = wideStrip(); const Rect m = rootMarkerRect(L, 64); const Rect k = keyRect(L, 64); - CHECK(m.left == k.left && m.right == k.right && m.top == k.top && m.bottom == k.bottom); + CHECK(m.x == k.x && m.right() == k.right() && m.y == k.y && m.bottom() == k.bottom()); } // --- keyAtPoint --------------------------------------------------------------- @@ -81,17 +82,17 @@ static void testKeyAtPointInverts() { const StripLayout L = wideStrip(); // A point in the middle of key 60's cell resolves to 60. const Rect k = keyRect(L, 60); - CHECK(keyAtPoint(L, k.left + 5, k.top + 2) == 60); + CHECK(keyAtPoint(L, k.x + 5, k.y + 2) == 60); // The very left of the band is key 0; just inside the right edge is key 127. - CHECK(keyAtPoint(L, L.keys.left, 2) == 0); - CHECK(keyAtPoint(L, L.keys.right - 1, 2) == 127); + CHECK(keyAtPoint(L, L.keys.x, 2) == 0); + CHECK(keyAtPoint(L, L.keys.right() - 1, 2) == 127); } static void testKeyAtPointOffBand() { const StripLayout L = wideStrip(); CHECK(keyAtPoint(L, -5, 2) == -1); // left of band - CHECK(keyAtPoint(L, L.keys.right + 5, 2) == -1); // right of band - CHECK(keyAtPoint(L, 100, L.keys.bottom + 5) == -1); // below band + CHECK(keyAtPoint(L, L.keys.right() + 5, 2) == -1); // right of band + CHECK(keyAtPoint(L, 100, L.keys.bottom() + 5) == -1); // below band } // --- zoneBarRect -------------------------------------------------------------- @@ -99,17 +100,17 @@ static void testKeyAtPointOffBand() { static void testZoneBarSpansInclusive() { const StripLayout L = wideStrip(); const Rect bar = zoneBarRect(L, 12, 23); // C1..B1 inclusive - CHECK(bar.left == keyLeftX(L, 12)); - CHECK(bar.right == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully - CHECK(bar.width() == 120); // 12 keys * 10px + CHECK(bar.x == keyLeftX(L, 12)); + CHECK(bar.right() == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully + CHECK(bar.width == 120); // 12 keys * 10px } static void testZoneBarMalformedCollapses() { const StripLayout L = wideStrip(); // low > high must collapse, never invert. const Rect bar = zoneBarRect(L, 80, 40); - CHECK(bar.width() >= 0); - CHECK(bar.right >= bar.left); + CHECK(bar.width >= 0); + CHECK(bar.right() >= bar.x); } // --- zoneGrabAt --------------------------------------------------------------- @@ -117,23 +118,23 @@ static void testZoneBarMalformedCollapses() { static void testZoneGrabEdgesAndBody() { const StripLayout L = wideStrip(); const Rect bar = zoneBarRect(L, 20, 60); // wide bar with a clear body - const int y = L.keys.top + 2; + const int y = L.keys.y + 2; // Near the left edge -> low; near the right edge -> high; the middle -> body. - CHECK(zoneGrabAt(L, 20, 60, bar.left + 1, y) == ZoneGrab::kLowEdge); - CHECK(zoneGrabAt(L, 20, 60, bar.right - 1, y) == ZoneGrab::kHighEdge); - CHECK(zoneGrabAt(L, 20, 60, bar.left + bar.width() / 2, y) == ZoneGrab::kBody); + CHECK(zoneGrabAt(L, 20, 60, bar.x + 1, y) == ZoneGrab::kLowEdge); + CHECK(zoneGrabAt(L, 20, 60, bar.right() - 1, y) == ZoneGrab::kHighEdge); + CHECK(zoneGrabAt(L, 20, 60, bar.x + bar.width / 2, y) == ZoneGrab::kBody); // Off the bar entirely -> none. - CHECK(zoneGrabAt(L, 20, 60, bar.right + 20, y) == ZoneGrab::kNone); + CHECK(zoneGrabAt(L, 20, 60, bar.right() + 20, y) == ZoneGrab::kNone); } static void testZoneGrabNarrowBarSplitsAtMidpointLowWins() { const StripLayout L = wideStrip(); // A 1-key bar is narrower than 2*edge: no body; the low edge wins the exact midpoint. const Rect bar = zoneBarRect(L, 50, 50); - const int y = L.keys.top + 2; - const int mid = bar.left + bar.width() / 2; + const int y = L.keys.y + 2; + const int mid = bar.x + bar.width / 2; CHECK(zoneGrabAt(L, 50, 50, mid, y) == ZoneGrab::kLowEdge); // tie -> low - CHECK(zoneGrabAt(L, 50, 50, bar.right - 1, y) == ZoneGrab::kHighEdge); + CHECK(zoneGrabAt(L, 50, 50, bar.right() - 1, y) == ZoneGrab::kHighEdge); } // --- zoneBarAtPoint ----------------------------------------------------------- @@ -143,8 +144,8 @@ static void testZoneBarAtPointFirstMatch() { const int lows[2] = {20, 30}; // zone 0 and zone 1 overlap on [30,50] const int highs[2] = {50, 70}; const Rect overlap = zoneBarRect(L, 30, 50); - const int y = L.keys.top + 2; - const int cx = overlap.left + overlap.width() / 2; + const int y = L.keys.y + 2; + const int cx = overlap.x + overlap.width / 2; // A point in the overlap resolves to the FIRST covering zone (draw order). const ZoneBarHit hit = zoneBarAtPoint(L, lows, highs, 2, cx, y); CHECK(hit.zoneIndex == 0); @@ -219,7 +220,7 @@ static void testResolveDragProportionalNonDivisibleWidth() { // a drag from note 0 by (width-1) pixels must land at keyAtPoint(width-1), which is 127. const int width = 544; const StripLayout L = layoutStrip(width, 40); - CHECK(keyAtPoint(L, width - 1, L.keys.top + 1) == 127); + CHECK(keyAtPoint(L, width - 1, L.keys.y + 1) == 127); CHECK(resolveDragNote(L, 0, width - 1) == 127); // Also verify mid-strip coherence: for each key N, a drag from 0 by N's left-edge @@ -227,12 +228,12 @@ static void testResolveDragProportionalNonDivisibleWidth() { // rounding may round down). The critical direction is that it must NOT over-shoot by // more than 0 (it must reach at least the right key). for (int n = 1; n < kStripKeyCount; ++n) { - const int leftPx = keyRect(L, n).left; + const int leftPx = keyRect(L, n).x; const int resolved = resolveDragNote(L, 0, leftPx); // The left edge of key N is the first pixel "in" that key, so we expect resolved == N. // Allow resolved == N-1 only when the pixel is at the exact boundary (keyEdgeToX may // produce the same x for adjacent keys when keys share a pixel). Disallow over-shoot. - const int expected = keyAtPoint(L, leftPx, L.keys.top + 1); + const int expected = keyAtPoint(L, leftPx, L.keys.y + 1); CHECK(resolved >= expected - 1 && resolved <= expected + 1); } } diff --git a/tests/test_knob_deck.cpp b/tests/test_knob_deck.cpp index 695c6fa..4852617 100644 --- a/tests/test_knob_deck.cpp +++ b/tests/test_knob_deck.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::knob_deck — no VST3, no REAPER, no framework. Same fast +// Standalone tests for reasampler::instrument::ui::knob_deck — no VST3, no REAPER, no framework. Same fast // assert loop as the sibling pure tests. Assert the r11 deck layout HARD: // // * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths. @@ -10,12 +10,13 @@ // * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, blank (-1) cells // and fence padding miss, outside-deck miss. -#include "../src/vst/knob_deck.h" +#include "../src/core/instrument/ui/knob_deck.h" #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -75,20 +76,20 @@ static void testWrapAtNarrowWidthIsDeterministic() { CHECK(dl.groups.size() == 5); // Row membership: groups on row 1 share the first top; the wrapped groups sit one row // pitch lower and restart at the left margin. - const int row0Top = dl.groups[0].box.top; + const int row0Top = dl.groups[0].box.y; const int row1Top = row0Top + kDeckGroupH + kDeckRowGap; - CHECK(dl.groups[0].box.top == row0Top); - CHECK(dl.groups[1].box.top == row0Top); + CHECK(dl.groups[0].box.y == row0Top); + CHECK(dl.groups[1].box.y == row0Top); bool sawWrap = false; for (std::size_t i = 1; i < dl.groups.size(); ++i) { - if (dl.groups[i].box.top == row1Top && dl.groups[i - 1].box.top == row0Top) { - CHECK(dl.groups[i].box.left == 8); // wrapped row restarts at the left edge + if (dl.groups[i].box.y == row1Top && dl.groups[i - 1].box.y == row0Top) { + CHECK(dl.groups[i].box.x == 8); // wrapped row restarts at the left edge sawWrap = true; } } CHECK(sawWrap); // Every box stays within the available width (no group straddles the right edge). - for (const auto& g : dl.groups) CHECK(g.box.right <= 8 + 544); + for (const auto& g : dl.groups) CHECK(g.box.right() <= 8 + 544); } static void testFirstGroupAlwaysPlaces() { @@ -103,33 +104,33 @@ static void testGroupInnerGeometry() { const DeckLayout dl = layoutDeck(deck, 8, 50, 824); const DeckGroupLayout& amp = dl.groups[0]; // Caption row at the top padding; caption toggle right-anchored inside the box. - CHECK(amp.caption.top == amp.box.top + kDeckGroupPadY); + CHECK(amp.caption.y == amp.box.y + kDeckGroupPadY); CHECK(amp.captionToggle.id == 100); - CHECK(amp.captionToggle.seg1.right == amp.box.right - kDeckGroupPadX); - CHECK(amp.captionToggle.seg0.right == amp.captionToggle.seg1.left); - CHECK(amp.captionToggle.seg0.width() == 44 && amp.captionToggle.seg1.width() == 44); - CHECK(amp.captionToggle.seg0.height() == kDeckToggleH); + CHECK(amp.captionToggle.seg1.right() == amp.box.right() - kDeckGroupPadX); + CHECK(amp.captionToggle.seg0.right() == amp.captionToggle.seg1.x); + CHECK(amp.captionToggle.seg0.width == 44 && amp.captionToggle.seg1.width == 44); + CHECK(amp.captionToggle.seg0.height == kDeckToggleH); // The caption text rect stops before the toggle. - CHECK(amp.caption.right <= amp.captionToggle.seg0.left); + CHECK(amp.caption.right() <= amp.captionToggle.seg0.x); // Cells: five, fixed size, abutting, inside the box, below the caption row. CHECK(static_cast(amp.cells.size()) == 5); for (std::size_t i = 0; i < amp.cells.size(); ++i) { const DeckCellLayout& c = amp.cells[i]; - CHECK(c.cell.width() == kDeckCellW && c.cell.height() == kDeckCellH); - CHECK(c.cell.top == amp.box.top + kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap); - if (i > 0) CHECK(c.cell.left == amp.cells[i - 1].cell.right); + CHECK(c.cell.width == kDeckCellW && c.cell.height == kDeckCellH); + CHECK(c.cell.y == amp.box.y + kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap); + if (i > 0) CHECK(c.cell.x == amp.cells[i - 1].cell.right()); // Knob square centered horizontally, label band beneath it, both inside the cell. - CHECK(c.knob.width() == kDeckKnobSize && c.knob.height() == kDeckKnobSize); - CHECK(c.knob.left - c.cell.left == c.cell.right - c.knob.right); - CHECK(c.label.top >= c.knob.bottom); - CHECK(c.label.bottom <= c.cell.bottom); + CHECK(c.knob.width == kDeckKnobSize && c.knob.height == kDeckKnobSize); + CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right()); + CHECK(c.label.y >= c.knob.bottom()); + CHECK(c.label.bottom() <= c.cell.bottom()); } // VOICE group's row toggle sits after its cell, vertically centered in the cell row. const DeckGroupLayout& voice = dl.groups[3]; CHECK(voice.rowToggle.id == 104); - CHECK(voice.rowToggle.seg0.left == voice.cells[0].cell.right + kDeckToggleGap); - CHECK(voice.rowToggle.seg0.height() == kDeckToggleH); - CHECK(voice.rowToggle.seg0.top > voice.cells[0].cell.top); + CHECK(voice.rowToggle.seg0.x == voice.cells[0].cell.right() + kDeckToggleGap); + CHECK(voice.rowToggle.seg0.height == kDeckToggleH); + CHECK(voice.rowToggle.seg0.y > voice.cells[0].cell.y); // MASTER has no toggles. CHECK(dl.groups[4].captionToggle.id == -1); CHECK(dl.groups[4].rowToggle.id == -1); @@ -142,20 +143,20 @@ static void testHitTest() { // Knob hit: anywhere in the cell (including the label band) resolves to the cell id. const DeckCellLayout& c0 = amp.cells[0]; - DeckHit h = hitTestDeck(dl, c0.cell.left + 1, c0.cell.top + 1); + DeckHit h = hitTestDeck(dl, c0.cell.x + 1, c0.cell.y + 1); CHECK(h.kind == DeckHitKind::Knob && h.id == 1 && h.segment == -1); - h = hitTestDeck(dl, c0.label.left + 2, c0.label.top + 2); + h = hitTestDeck(dl, c0.label.x + 2, c0.label.y + 2); CHECK(h.kind == DeckHitKind::Knob && h.id == 1); // Caption toggle segments 0/1 at their boundary: last px of seg0, first px of seg1. - h = hitTestDeck(dl, amp.captionToggle.seg0.right - 1, amp.captionToggle.seg0.top + 1); + h = hitTestDeck(dl, amp.captionToggle.seg0.right() - 1, amp.captionToggle.seg0.y + 1); CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 0); - h = hitTestDeck(dl, amp.captionToggle.seg1.left, amp.captionToggle.seg1.top + 1); + h = hitTestDeck(dl, amp.captionToggle.seg1.x, amp.captionToggle.seg1.y + 1); CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 1); // Row toggle. const DeckGroupLayout& voice = dl.groups[3]; - h = hitTestDeck(dl, voice.rowToggle.seg1.left + 1, voice.rowToggle.seg1.top + 1); + h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1); CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1); // A blank cell (id -1) misses even though its rect exists. @@ -164,11 +165,11 @@ static void testHitTest() { const DeckLayout tl = layoutDeck(trig, 0, 0, 824); const DeckCellLayout& blank = tl.groups[0].cells[4]; CHECK(blank.id == -1); - h = hitTestDeck(tl, blank.cell.left + 5, blank.cell.top + 5); + h = hitTestDeck(tl, blank.cell.x + 5, blank.cell.y + 5); CHECK(h.kind == DeckHitKind::None); // The fence padding inside the box misses; outside the deck misses. - h = hitTestDeck(dl, amp.box.left + 1, amp.box.bottom - 1); + h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1); CHECK(h.kind == DeckHitKind::None); h = hitTestDeck(dl, -50, -50); CHECK(h.kind == DeckHitKind::None); diff --git a/tests/test_lane_keys.cpp b/tests/test_lane_keys.cpp index 82a280b..7cfdd0b 100644 --- a/tests/test_lane_keys.cpp +++ b/tests/test_lane_keys.cpp @@ -9,12 +9,13 @@ // 3. Round-trip: managedLaneKey(laneNameForMode(m)) == "reasampler:" + m, so the // Wave-3 minting path and the read path cannot drift. -#include "../src/lane_keys.h" +#include "../src/core/view/lane_keys.h" #include #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_master_gain.cpp b/tests/test_master_gain.cpp index 16be668..50b098e 100644 --- a/tests/test_master_gain.cpp +++ b/tests/test_master_gain.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::master_gain — no VST3, no REAPER, no framework. Same +// Standalone tests for reasampler::instrument::engine::master_gain — no VST3, no REAPER, no framework. Same // fast assert loop as the sibling pure tests. Assert the FB1 post-mixer gain taper HARD: // // * -inf bottom — norm 0 maps to -infinity dB and TRUE ZERO linear (silence, not an epsilon); @@ -9,13 +9,14 @@ // * monotonicity — more norm never means less gain. // * label — "-inf" at the bottom, signed one-decimal dB elsewhere. -#include "../src/vst/master_gain.h" +#include "../src/core/instrument/engine/master_gain.h" #include #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::engine; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_mode_enable.cpp b/tests/test_mode_enable.cpp index 2e8effc..d8fe990 100644 --- a/tests/test_mode_enable.cpp +++ b/tests/test_mode_enable.cpp @@ -7,13 +7,14 @@ // buttons are live and "…: Design" buttons are dead; when Arrange is active, the reverse. An // unrecognized active id fails OPEN (every button live) so a future mode never dead-locks the bar. -#include "../src/mode_enable.h" -#include "../src/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ids the rule keys off +#include "../src/core/ui/mode_enable.h" +#include "../src/core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ids the rule keys off #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_mode_switch.cpp b/tests/test_mode_switch.cpp index 3110165..42a84c3 100644 --- a/tests/test_mode_switch.cpp +++ b/tests/test_mode_switch.cpp @@ -8,13 +8,14 @@ // evenly); hit-test hits per segment and misses (outside the band above/below/ // left/right, boundary pixels); degenerate widths and counts. -#include "../src/mode_switch.h" +#include "../src/core/view/mode_switch.h" #include #include #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_note_entry.cpp b/tests/test_note_entry.cpp index 4990001..1107f36 100644 --- a/tests/test_note_entry.cpp +++ b/tests/test_note_entry.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::note_entry — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::map::note_entry — no VST3, no REAPER, no framework. // Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note. // // Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the @@ -6,11 +6,12 @@ // [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt; // the integer path taking precedence over the note-name path for a leading digit. -#include "../src/vst/note_entry.h" +#include "../src/core/instrument/map/note_entry.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::map; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_overflow_menu.cpp b/tests/test_overflow_menu.cpp index 0a064bb..c6912c3 100644 --- a/tests/test_overflow_menu.cpp +++ b/tests/test_overflow_menu.cpp @@ -8,11 +8,12 @@ // a degenerate band reserves nothing; hit-test in/out/edge (half-open bounds); an empty button // claims no point; draw and hit-test agree over the whole rect. -#include "../src/overflow_menu.h" +#include "../src/core/ui/overflow_menu.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_owned_manifest.cpp b/tests/test_owned_manifest.cpp index 77ecf17..d850004 100644 --- a/tests/test_owned_manifest.cpp +++ b/tests/test_owned_manifest.cpp @@ -7,12 +7,13 @@ // semantics, insertion-order preservation, malformed-parse -> nullopt (the persist // shell's warn+fallback hinges on it), and round-trip of paths with JSON metacharacters. -#include "../src/owned_manifest.h" +#include "../src/core/model/owned_manifest.h" #include #include using namespace reasampler; +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -35,6 +36,18 @@ static void testEmptyManifest() { CHECK(back->empty()); } +// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for a +// small fixture (two paths), not just self-consistent re-serialization — a +// format drift that both writer and reader agree on would slip past the +// round-trip tests but not this. The format is frozen as-shipped; the literal +// below is the captured current output. +static void testSerializeGoldenLiteral() { + OwnedFileManifest m; + m.add("reasampler_bank/a.wav"); + m.add("reasampler_bank/b.wav"); + CHECK(m.serialize() == "{\"owned\":[\"reasampler_bank/a.wav\",\"reasampler_bank/b.wav\"]}"); +} + // --- add / contains / order -------------------------------------------------- static void testAddAndContains() { @@ -158,6 +171,7 @@ static void testMalformedParse() { } int main() { + testSerializeGoldenLiteral(); testEmptyManifest(); testAddAndContains(); testDedupRepeatedAdds(); diff --git a/tests/test_param_slider.cpp b/tests/test_param_slider.cpp index 35005a4..48dfaed 100644 --- a/tests/test_param_slider.cpp +++ b/tests/test_param_slider.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::param_slider — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::param_slider — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure editor tests (capture_browser / keyboard_strip): // assert the S12/S15/S16 control-surface layout, toggle-segment split + hit-test, slider // value<->pixel mapping (round-trip + clamping + endpoints), and point->control routing. @@ -16,13 +16,14 @@ // o'clock), wrap-boundary + un-normalized arc inputs, the needle endpoint on the circle, and // the vertical-drag delta->value map (up = increase) with clamping at 0/1. -#include "../src/vst/param_slider.h" +#include "../src/core/instrument/ui/param_slider.h" #include #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -33,7 +34,7 @@ static bool approx(double a, double b) { return (a - b) < 1e-9 && (b - a) < 1e-9 // --- layoutControls ----------------------------------------------------------- static void testLayoutStacksRows() { - const Rect panel{0, 100, 300, 400}; + const Rect panel = Rect::ltrb(0, 100, 300, 400); std::vector ctl{ {1, ControlKind::Toggle}, {2, ControlKind::Slider}, @@ -42,58 +43,58 @@ static void testLayoutStacksRows() { const std::vector rows = layoutControls(panel, ctl); CHECK(rows.size() == 3); // Row 0 sits at the panel top; each subsequent row is one row-height + gap below. - CHECK(rows[0].row.top == 100); - CHECK(rows[0].row.bottom == 100 + kControlRowHeight); - CHECK(rows[1].row.top == rows[0].row.bottom + kControlRowGap); - CHECK(rows[2].row.top == rows[1].row.bottom + kControlRowGap); + CHECK(rows[0].row.y == 100); + CHECK(rows[0].row.bottom() == 100 + kControlRowHeight); + CHECK(rows[1].row.y == rows[0].row.bottom() + kControlRowGap); + CHECK(rows[2].row.y == rows[1].row.bottom() + kControlRowGap); // Ids + kinds carried through in order. CHECK(rows[0].id == 1 && rows[0].kind == ControlKind::Toggle); CHECK(rows[1].id == 2 && rows[1].kind == ControlKind::Slider); // Label column then control column, contiguous, spanning the panel width. - CHECK(rows[0].label.left == panel.left); - CHECK(rows[0].control.left == rows[0].label.right); - CHECK(rows[0].control.right == panel.right); - CHECK(rows[0].label.width() == kControlLabelWidth); + CHECK(rows[0].label.x == panel.x); + CHECK(rows[0].control.x == rows[0].label.right()); + CHECK(rows[0].control.right() == panel.right()); + CHECK(rows[0].label.width == kControlLabelWidth); } static void testLayoutEmptyAndDegenerate() { - CHECK(layoutControls(Rect{0, 0, 300, 300}, {}).empty()); + CHECK(layoutControls(Rect::ltrb(0, 0, 300, 300), {}).empty()); std::vector ctl{{1, ControlKind::Slider}}; - CHECK(layoutControls(Rect{0, 0, 0, 0}, ctl).empty()); - CHECK(layoutControls(Rect{0, 0, 300, 0}, ctl).empty()); + CHECK(layoutControls(Rect::ltrb(0, 0, 0, 0), ctl).empty()); + CHECK(layoutControls(Rect::ltrb(0, 0, 300, 0), ctl).empty()); } static void testLayoutNarrowPanelClampsLabel() { // A panel narrower than 2*labelWidth clamps the label column to half so a control column // survives. - const Rect panel{0, 0, 100, 200}; + const Rect panel = Rect::ltrb(0, 0, 100, 200); const std::vector rows = layoutControls(panel, {{1, ControlKind::Slider}}); CHECK(rows.size() == 1); - CHECK(rows[0].label.width() <= panel.width() / 2 + 1); - CHECK(rows[0].control.width() > 0); + CHECK(rows[0].label.width <= panel.width / 2 + 1); + CHECK(rows[0].control.width > 0); } // --- toggle ------------------------------------------------------------------- static void testToggleSegmentsTile() { - const Rect control{100, 0, 300, 22}; // width 200 + const Rect control = Rect::ltrb(100, 0, 300, 22); // width 200 const Rect s0 = toggleSegmentRect(control, 0); const Rect s1 = toggleSegmentRect(control, 1); - CHECK(s0.left == 100 && s0.right == 200); - CHECK(s1.left == 200 && s1.right == 300); // last absorbs remainder -> reaches control.right + CHECK(s0.x == 100 && s0.right() == 200); + CHECK(s1.x == 200 && s1.right() == 300); // last absorbs remainder -> reaches control.right() // Out of range. - CHECK(toggleSegmentRect(control, 2).width() == 0); - CHECK(toggleSegmentRect(control, -1).width() == 0); + CHECK(toggleSegmentRect(control, 2).width == 0); + CHECK(toggleSegmentRect(control, -1).width == 0); } static void testToggleSegmentRemainderInLast() { - const Rect control{0, 0, 201, 22}; // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder) - CHECK(toggleSegmentRect(control, 0).width() == 100); - CHECK(toggleSegmentRect(control, 1).right == 201); + const Rect control = Rect::ltrb(0, 0, 201, 22); // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder) + CHECK(toggleSegmentRect(control, 0).width == 100); + CHECK(toggleSegmentRect(control, 1).right() == 201); } static void testToggleHitTest() { - const Rect control{100, 0, 300, 22}; + const Rect control = Rect::ltrb(100, 0, 300, 22); CHECK(toggleSegmentHitTest(control, 150, 10) == 0); CHECK(toggleSegmentHitTest(control, 250, 10) == 1); CHECK(toggleSegmentHitTest(control, 50, 10) == -1); // left of control @@ -103,59 +104,59 @@ static void testToggleHitTest() { // --- slider ------------------------------------------------------------------- static void testSliderTrackInsetsHalfHandle() { - const Rect control{100, 0, 300, 22}; + const Rect control = Rect::ltrb(100, 0, 300, 22); const Rect track = sliderTrackRect(control); - CHECK(track.left == control.left + kSliderHandleWidth / 2); - CHECK(track.right == control.right - kSliderHandleWidth / 2); + CHECK(track.x == control.x + kSliderHandleWidth / 2); + CHECK(track.right() == control.right() - kSliderHandleWidth / 2); // A control too narrow for a handle yields an empty track. - CHECK(sliderTrackRect(Rect{0, 0, kSliderHandleWidth - 1, 22}).width() == 0); + CHECK(sliderTrackRect(Rect::ltrb(0, 0, kSliderHandleWidth - 1, 22)).width == 0); } static void testSliderHandleAtEndpointsAndMid() { - const Rect control{100, 0, 300, 22}; + const Rect control = Rect::ltrb(100, 0, 300, 22); const Rect track = sliderTrackRect(control); const int half = kSliderHandleWidth / 2; - // Value 0 -> handle centered at track.left. + // Value 0 -> handle centered at track.x. const Rect h0 = sliderHandleRect(control, 0.0); - CHECK(h0.left + half == track.left); - // Value 1 -> handle centered at track.right. + CHECK(h0.x + half == track.x); + // Value 1 -> handle centered at track.right(). const Rect h1 = sliderHandleRect(control, 1.0); - CHECK(h1.left + half == track.right); + CHECK(h1.x + half == track.right()); // Value 0.5 -> centered at the track middle. const Rect hm = sliderHandleRect(control, 0.5); - CHECK(hm.left + half == track.left + track.width() / 2); + CHECK(hm.x + half == track.x + track.width / 2); } static void testSliderHandleClampsOutOfRange() { - const Rect control{0, 0, 200, 22}; - CHECK(sliderHandleRect(control, -0.5).left == sliderHandleRect(control, 0.0).left); - CHECK(sliderHandleRect(control, 5.0).left == sliderHandleRect(control, 1.0).left); + const Rect control = Rect::ltrb(0, 0, 200, 22); + CHECK(sliderHandleRect(control, -0.5).x == sliderHandleRect(control, 0.0).x); + CHECK(sliderHandleRect(control, 5.0).x == sliderHandleRect(control, 1.0).x); } static void testValueAtPointEndpointsSaturate() { - const Rect control{100, 0, 300, 22}; + const Rect control = Rect::ltrb(100, 0, 300, 22); const Rect track = sliderTrackRect(control); - CHECK(approx(valueAtPoint(control, track.left - 20), 0.0)); - CHECK(approx(valueAtPoint(control, track.left), 0.0)); - CHECK(approx(valueAtPoint(control, track.right + 20), 1.0)); - CHECK(approx(valueAtPoint(control, track.right), 1.0)); + CHECK(approx(valueAtPoint(control, track.x - 20), 0.0)); + CHECK(approx(valueAtPoint(control, track.x), 0.0)); + CHECK(approx(valueAtPoint(control, track.right() + 20), 1.0)); + CHECK(approx(valueAtPoint(control, track.right()), 1.0)); } static void testValueAtPointIsHandleInverse() { // Round-trip: a value -> handle center -> valueAtPoint recovers (within one pixel quantum). - const Rect control{50, 0, 450, 22}; // wide track for pixel resolution + const Rect control = Rect::ltrb(50, 0, 450, 22); // wide track for pixel resolution const Rect track = sliderTrackRect(control); for (double v : {0.1, 0.25, 0.5, 0.75, 0.9}) { const Rect h = sliderHandleRect(control, v); - const int centerX = h.left + kSliderHandleWidth / 2; + const int centerX = h.x + kSliderHandleWidth / 2; const double back = valueAtPoint(control, centerX); CHECK(back >= v - 0.01 && back <= v + 0.01); - CHECK(centerX >= track.left && centerX <= track.right); + CHECK(centerX >= track.x && centerX <= track.right()); } } static void testValueAtPointDegenerateTrack() { - CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0)); + CHECK(approx(valueAtPoint(Rect::ltrb(0, 0, kSliderHandleWidth - 1, 22), 5), 0.0)); } // --- knob (FA4) ----------------------------------------------------------------- @@ -164,21 +165,21 @@ static bool nearWithin(double a, double b, double tol) { return (a - b) < tol && static void testKnobGeometryInscribesCell() { // A 44x44 cell at (100,0): center (122,22), radius 22. - const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); + const KnobGeometry g = computeKnob(Rect::ltrb(100, 0, 144, 44)); CHECK(approx(g.centerX, 122.0)); CHECK(approx(g.centerY, 22.0)); CHECK(approx(g.radius, 22.0)); // A wide cell inscribes on the smaller (vertical) dimension. - const KnobGeometry w = computeKnob(Rect{0, 0, 200, 22}); + const KnobGeometry w = computeKnob(Rect::ltrb(0, 0, 200, 22)); CHECK(approx(w.radius, 11.0)); CHECK(approx(w.centerX, 100.0)); // Degenerate cells yield radius 0. - CHECK(computeKnob(Rect{0, 0, 0, 22}).radius == 0.0); - CHECK(computeKnob(Rect{0, 0, 22, 0}).radius == 0.0); + CHECK(computeKnob(Rect::ltrb(0, 0, 0, 22)).radius == 0.0); + CHECK(computeKnob(Rect::ltrb(0, 0, 22, 0)).radius == 0.0); } static void testKnobHitTestCircle() { - const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22 + const KnobGeometry g = computeKnob(Rect::ltrb(100, 0, 144, 44)); // center (122,22), r 22 CHECK(knobHitTest(g, 122, 22)); // center — always hits CHECK(!knobHitTest(g, 122 + 22, 22)); // exactly on the boundary — boundary exclusive CHECK(!knobHitTest(g, 122 + 22, 44)); // cell corner: inside the rect, outside the circle @@ -223,7 +224,7 @@ static void testKnobArcWrapBoundary() { } static void testKnobNeedlePointOnCircle() { - const KnobGeometry g = computeKnob(Rect{100, 0, 144, 44}); // center (122,22), r 22 + const KnobGeometry g = computeKnob(Rect::ltrb(100, 0, 144, 44)); // center (122,22), r 22 // Default arc, value 0 -> 7 o'clock -> needle points down-left from center. const KnobPoint p7 = knobNeedlePoint(g, KnobArc{}, 0.0); // 210° clockwise from 12: sin(210°)=-0.5, cos(210°)=-√3/2 -> x = cx - r/2, y = cy + r*√3/2 @@ -265,7 +266,7 @@ static void testKnobDragClamps() { // --- controlAtPoint routing --------------------------------------------------- static void testControlAtPointRoutes() { - const Rect panel{0, 0, 300, 400}; + const Rect panel = Rect::ltrb(0, 0, 300, 400); std::vector ctl{ {10, ControlKind::Toggle}, {20, ControlKind::Slider}, @@ -274,26 +275,26 @@ static void testControlAtPointRoutes() { const std::vector rows = layoutControls(panel, ctl); // A point in the toggle's control area routes to the toggle id. const Rect tctl = rows[0].control; - CHECK(controlAtPoint(rows, (tctl.left + tctl.right) / 2, (tctl.top + tctl.bottom) / 2) == 10); + CHECK(controlAtPoint(rows, (tctl.x + tctl.right()) / 2, (tctl.y + tctl.bottom()) / 2) == 10); // A point on the slider's track routes to the slider id. const Rect strack = sliderTrackRect(rows[1].control); - CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2, - (strack.top + strack.bottom) / 2) == 20); + CHECK(controlAtPoint(rows, (strack.x + strack.right()) / 2, + (strack.y + strack.bottom()) / 2) == 20); // A point at the knob's center routes to the knob id; the control-rect corner (outside // the circle) is a miss. const KnobGeometry kg = computeKnob(rows[2].control); CHECK(controlAtPoint(rows, static_cast(kg.centerX), static_cast(kg.centerY)) == 30); - CHECK(controlAtPoint(rows, rows[2].control.left + 1, rows[2].control.top + 1) == -1); + CHECK(controlAtPoint(rows, rows[2].control.x + 1, rows[2].control.y + 1) == -1); } static void testControlAtPointMisses() { - const Rect panel{0, 0, 300, 400}; + const Rect panel = Rect::ltrb(0, 0, 300, 400); const std::vector rows = layoutControls(panel, {{10, ControlKind::Toggle}, {20, ControlKind::Slider}}); // The label column is not interactive. - CHECK(controlAtPoint(rows, rows[0].label.left + 2, rows[0].label.top + 4) == -1); + CHECK(controlAtPoint(rows, rows[0].label.x + 2, rows[0].label.y + 4) == -1); // The gap between rows is a miss. - const int gapY = rows[0].row.bottom + kControlRowGap / 2; + const int gapY = rows[0].row.bottom() + kControlRowGap / 2; CHECK(controlAtPoint(rows, 200, gapY) == -1); // Off-panel below. CHECK(controlAtPoint(rows, 200, 5000) == -1); diff --git a/tests/test_peaks.cpp b/tests/test_peaks.cpp index 713f70b..4ac91b4 100644 --- a/tests/test_peaks.cpp +++ b/tests/test_peaks.cpp @@ -11,13 +11,14 @@ // merge, steep disjoint-span merge, no-bin-dropped spike sweep, no-column-empty // coverage, col clamp, degenerate inputs. -#include "../src/peaks.h" +#include "../src/core/audio/peaks.h" #include #include #include using namespace reasampler; +using namespace reasampler::audio; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index b913993..768e45e 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -23,14 +23,18 @@ // 6. unity + latency contract — asserted bit-exactly: a warm()ed shifter at ratio 1.0 IS a // clean window delay; a prime()d one has ZERO added latency (out[i] == src[i] to the // bit) — the GA2 immediate-onset claim. +// 8. stereo linked lag (Q-W0 T1-01) — a follower channel driven via processLinked() mirrors +// the master's splice decision (jump/lag/frac/fadeLen AND firing frame) exactly, on +// decorrelated stereo content where an independent per-channel search provably diverges. -#include "../src/vst/pitch_shift.h" +#include "../src/core/instrument/engine/pitch_shift.h" #include #include #include using namespace reasampler; +using namespace reasampler::instrument::engine; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -491,6 +495,92 @@ static void testFreezeTailContinuousTone() { } } +// --- 8. Stereo linked lag (Q-W0 T1-01): a follower channel driven via processLinked() +// applies EXACTLY the master's splice decision — same firing frame, same jump, same +// lag, same sub-sample frac, same fade length — so a stereo pair shares ONE splice +// schedule (no inter-channel offset re-drawn per splice: the pre-fix image-wander / +// mono-sum-combing mechanism). The divergence witness: an INDEPENDENT shifter fed the +// follower's content picks a different lag on the same schedule, proving the mirror +// assertion is not vacuous (the two channels' contents genuinely disagree on the best +// alignment). A third shifter (`mirror`), primed with the SAME content as the master +// and driven via processLinked() with the master's own decisions, must reproduce the +// master's output BIT-IDENTICALLY every frame — this is the review-rider strengthening: +// the `ef == em` mirror check above only proves lastSplice_ was copied verbatim (which +// applySplice() always does), not that applySplice() actually reproduces splice()'s +// effect on posA_/fadeLen_/audio output; a same-content bit-identical check catches a +// real divergence there (e.g. an asymmetry between applySplice()'s unconditional +// `max(1, ev.fadeLen)` and splice()'s own fadeLen_ assignment). This driven-every-frame +// setup keeps both master and follower in lockstep the whole run (posA_/writePos_ stay +// identical since jumps are geometric, not content-dependent), so it exercises +// applySplice() on every splice — never the Q-W0 remediation self-healing fallback +// (own-search splice on a stale follower), which only fires when a follower has been +// skipped a block relative to the master (mono-render-block starvation). --- +static void testStereoLinkedLagSharedSchedule() { + const std::int64_t w = 2205; // the product window + const std::size_t n = 40000; // ~17 splice cycles at ratio 2 + // Decorrelated "stereo" content: two different non-integer-period tones, so each + // channel's own correlation optimum lands on a different lag. + const double fL = 1.0 / 196.37; + const double fR = 1.0 / 123.13; + std::vector srcL(n + static_cast(w)); + std::vector srcR(n + static_cast(w)); + for (std::size_t i = 0; i < srcL.size(); ++i) { + srcL[i] = static_cast(std::sin(2.0 * kPi * fL * static_cast(i))); + srcR[i] = static_cast(std::sin(2.0 * kPi * fR * static_cast(i))); + } + PitchShifter master, follower, independent, mirror; + master.configure(w); + follower.configure(w); + independent.configure(w); + mirror.configure(w); + master.prime(srcL.data(), w); + follower.prime(srcR.data(), w); // linked: R content, master's decisions + independent.prime(srcR.data(), w); // control: R content, OWN search (pre-fix behavior) + mirror.prime(srcL.data(), w); // SAME content as master: bit-identical witness + master.setShiftRatio(2.0); + follower.setShiftRatio(2.0); + independent.setShiftRatio(2.0); + mirror.setShiftRatio(2.0); + + int spliceCount = 0; + bool followerDiverged = false; + bool independentDiverged = false; + bool mirrorDiverged = false; + for (std::size_t i = 0; i < n; ++i) { + const std::size_t si = i + static_cast(w); + const AudioSample oM = master.process(srcL[si]); + const SpliceEvent& em = master.lastSplice(); + const AudioSample oR = follower.processLinked(srcR[si], em); + CHECK(std::isfinite(oR)); + // The follower mirrors the master's decision EXACTLY, every frame (fired == false + // frames included). In this driven-every-frame lockstep run the follower never falls + // behind, so it never reaches the Q-W0 self-healing fallback — every splice here goes + // through applySplice(), same as the mirror check below. + const SpliceEvent& ef = follower.lastSplice(); + if (ef.fired != em.fired || ef.jump != em.jump || ef.lag != em.lag || + ef.frac != em.frac || ef.fadeLen != em.fadeLen) { + followerDiverged = true; + } + if (em.fired) ++spliceCount; + // The control: same content as the follower, own search. Its decision differing + // from the master's proves the mirror assertion above is load-bearing. + (void)independent.process(srcR[si]); + const SpliceEvent& ei = independent.lastSplice(); + if (ei.fired != em.fired || ei.lag != em.lag || ei.frac != em.frac) { + independentDiverged = true; + } + // The bit-identical witness: same content as the master, master's decisions applied + // via applySplice() instead of computed via splice() — the two code paths must produce + // the exact same sample stream. + const AudioSample oMirror = mirror.processLinked(srcL[si], em); + if (oMirror != oM) mirrorDiverged = true; + } + CHECK(spliceCount >= 3); // the run actually exercised several splices + CHECK(!followerDiverged); // linked lag: one decision, one schedule, both channels + CHECK(independentDiverged); // non-tautology witness: unlinked channels DO disagree + CHECK(!mirrorDiverged); // applySplice() reproduces splice() bit-identically +} + int main() { testDurationInvariance(); testUnityRoughlyReproduces(); @@ -499,6 +589,7 @@ int main() { testRepitchSpectralPurityAndOnset(); testUnityBitExactAndLatency(); testFreezeTailContinuousTone(); + testStereoLinkedLagSharedSchedule(); if (g_fail == 0) { std::printf("all pitch_shift tests passed\n"); diff --git a/tests/test_provenance.cpp b/tests/test_provenance.cpp index 3671f93..5c77186 100644 --- a/tests/test_provenance.cpp +++ b/tests/test_provenance.cpp @@ -7,21 +7,24 @@ // track GUIDs, FX-chain identity) -> a MISMATCH (different string / recipe). // * fxChainIdentity fold: order-sensitive, field-injection-proof, empty-stable. // * parse of malformed / wrong-version / truncated input -> nullopt (graceful). +// * hardened wire cursor (Q-W0 T2-01a): hostile digit-run lengths, wrap-magnitude +// lengths, and huge GUID counts -> nullopt with no overflow and no over-allocation. // * parent-detection decision: positive, negative, ambiguous, empty, and the // edge where a source file is not in the bank (missing-from-bank). // // The Sample-JSON round-trip of the fingerprint (leveraging M1's existing provenance // round-trip) is exercised in test_bank_model.cpp — see the fingerprint case there. -#include "../src/provenance.h" +#include "../src/core/model/provenance.h" -#include "../src/bank_model.h" // recipe-through-Sample-JSON round-trip (M1 seam) +#include "../src/core/model/bank_model.h" // recipe-through-Sample-JSON round-trip (M1 seam) #include #include #include using namespace reasampler; +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -190,9 +193,61 @@ static void testMalformedFingerprint() { "1:0" "0:").has_value()); } +// --- Q-W0 T2-01a: hardened wire cursor (backported from assignment_request / +// sample_usage) — corrupt or crafted persisted fingerprints must fail the parse +// cleanly (nullopt), never wrap an integer, never throw, never over-allocate. ---- + +// Mirrors buildFingerprint's field order with benign values, except the GUID-count +// field carries caller-supplied raw text — the attack surface under test. +static void putF(std::string& out, const std::string& f) { + out += std::to_string(f.size()); + out += ':'; + out += f; +} +static std::string forgedFingerprint(const std::string& guidCountText) { + std::string out = "rsprov1"; + putF(out, "0"); // scope = Item + putF(out, "0"); // sourceMode + putF(out, "0"); // startSeconds + putF(out, "1"); // endSeconds + putF(out, "0"); // tailMode + putF(out, "0"); // tailMs + putF(out, "48000"); // sampleRate + putF(out, "2"); // channelCount + putF(out, guidCountText); // GUID count (no GUID fields follow) + putF(out, ""); // fxChainIdentity (empty) + return out; +} + +static void testHardenedCursorRejectsHostileLengths() { + // A 200-digit length run: pre-hardening the accumulate wrapped std::size_t silently + // (the digit cap + overflow guard now reject it outright). + CHECK(!parseFingerprint("rsprov1" + std::string(200, '9') + ":x").has_value()); + // A SIZE_MAX-magnitude length: the additive bounds check `start + len > size` could + // itself wrap and pass; the subtraction-first form rejects. + CHECK(!parseFingerprint("rsprov118446744073709551615:x").has_value()); + // One past SIZE_MAX: the per-digit overflow guard fires during the accumulate. + CHECK(!parseFingerprint("rsprov118446744073709551616:x").has_value()); +} + +static void testHugeGuidCountRejectedBeforeReserve() { + // A GUID count astronomically larger than the wire could hold must return nullopt + // WITHOUT reaching trackGuids.reserve(count) — pre-fix this drove reserve(10^16) + // into std::length_error / bad_alloc thrown through the shell. + CHECK(!parseFingerprint(forgedFingerprint("9999999999999999")).has_value()); + // A count merely past the wire-size sanity bound (each GUID field needs >= 2 wire + // bytes) is provably bogus and rejected before the field loop. + CHECK(!parseFingerprint(forgedFingerprint("1000")).has_value()); + // A digit run past 20 fails the count parser's cap. + CHECK(!parseFingerprint(forgedFingerprint(std::string(25, '9'))).has_value()); + // Sanity (non-vacuous forgery): the honest zero-count version of the same forged + // shape parses fine — the rejections above are the count's doing, not the shape's. + CHECK(parseFingerprint(forgedFingerprint("0")).has_value()); +} + // --- recorded-recipe model round-trips through the Sample JSON ---------------- // The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's -// BankIndex JSON already round-trips. Prove a real recipe survives that path intact. +// BankModel JSON already round-trips. Prove a real recipe survives that path intact. static void testRecipeThroughSampleJson() { const CaptureRecipe r = baseRecipe(); @@ -206,10 +261,10 @@ static void testRecipeThroughSampleJson() { prov.fxChainSnapshot = buildFingerprint(r); s.provenance = prov; - BankIndex idx; + BankModel idx; CHECK(idx.add(s) == AddResult::Added); - auto back = BankIndex::deserialize(idx.serialize()); + auto back = BankModel::deserialize(idx.serialize()); CHECK(back.has_value()); const Sample* child = back ? back->query("child-1") : nullptr; CHECK(child != nullptr); @@ -296,6 +351,8 @@ int main() { testFxChainIdentityInjectionProof(); testCombineChainIdentities(); testMalformedFingerprint(); + testHardenedCursorRejectsHostileLengths(); + testHugeGuidCountRejectedBeforeReserve(); testRecipeThroughSampleJson(); testDetectParentPositive(); testDetectParentMultipleSameParent(); diff --git a/tests/test_prune_button.cpp b/tests/test_prune_button.cpp index 19ff0aa..2803d64 100644 --- a/tests/test_prune_button.cpp +++ b/tests/test_prune_button.cpp @@ -7,11 +7,12 @@ // tail-label inset or is degenerate; hit-test in/out/edge (half-open bounds); a // suppressed/empty button claims no point; draw and hit-test agree over the whole rect. -#include "../src/prune_button.h" +#include "../src/core/ui/prune_button.h" #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_prune_reconcile.cpp b/tests/test_prune_reconcile.cpp index 8a283d1..2fabda3 100644 --- a/tests/test_prune_reconcile.cpp +++ b/tests/test_prune_reconcile.cpp @@ -15,8 +15,8 @@ // Plus: determinism (present-order output), duplicate-`present` de-dup, exact-string // (non-normalizing) match, and the referencedPaths() union query directly. -#include "../src/bank_book.h" -#include "../src/prune_reconcile.h" +#include "../src/core/model/bank_book.h" +#include "../src/core/reclaim/prune_reconcile.h" #include #include @@ -26,6 +26,8 @@ #include using namespace reasampler; +using namespace reasampler::model; +using namespace reasampler::reclaim; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index 62001c1..fc30425 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -6,7 +6,7 @@ // FX-bypass plan (corrects the "items captured through parent FX" defect), and the // capture-action taxonomy table (stable ids, scope x tail-variant matrix). -#include "../src/render_settings.h" +#include "../src/core/capture/render_settings.h" #include #include @@ -14,6 +14,7 @@ #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 427cbcb..d0f68c2 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -22,7 +22,8 @@ // stride contract across the seam (that the byte stride wav_trim reports matches the // channel-count stride downmixToMono divides by). -#include "../src/vst/sample_map.h" +#include "../src/core/instrument/map/sample_map.h" +#include "../src/core/instrument/map/component_state_io.h" // the Q-W2v codec split (formats FROZEN; suite unchanged) #include #include @@ -30,18 +31,22 @@ #include #include -#include "../src/bank_book.h" -#include "../src/bank_model.h" -#include "../src/vst/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap) +#include "../src/core/model/bank_book.h" +#include "../src/core/model/bank_model.h" +#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap) using namespace reasampler; +using namespace reasampler::instrument::engine; +using namespace reasampler::instrument::map; // sample_map + component_state_io (Q-W2v re-namespace) +using namespace reasampler::capture; // wav_trim (WavLayout) +using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // Build a Sample with the fields sample_map reads. Relative path is required by -// BankIndex::add (relative-only invariant); a content hash is set so dedup does not +// BankModel::add (relative-only invariant); a content hash is set so dedup does not // collapse distinct entries. static Sample makeSample(const std::string& id, const std::string& name, const std::string& rel, std::optional root) { @@ -62,7 +67,7 @@ static std::string bookJson(const std::vector& poolSamples, for (const Sample& s : poolSamples) book.pool().index.add(s); if (!drumSamples.empty()) { book.createBank("drums-id", "Drums"); - BankIndex* di = book.index("drums-id"); + BankModel* di = book.index("drums-id"); for (const Sample& s : drumSamples) di->add(s); } return book.serialize(); @@ -1305,7 +1310,7 @@ static void testComponentStateMasterGainWriterClamps() { hi.masterGainLinear = 1000.0; CHECK(std::fabs(deserializeComponentState(serializeComponentState(hi), 44100.0) .masterGainLinear - - vst::masterGainMaxLinear()) < 1e-9); + instrument::engine::masterGainMaxLinear()) < 1e-9); ComponentState lo; lo.masterGainLinear = -5.0; CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).masterGainLinear == @@ -1686,7 +1691,7 @@ static void testVelocityCurveRoundTrip() { // A second zone left at the flat default proves the field is per-record and defaults to flat y=1. PerformanceMap m; PerformanceZone z = zone("lead", 20, 100); - z.velocityCurve = vst::VelocityCurve::linear(); + z.velocityCurve = VelocityCurve::linear(); z.velocityCurve.addPoint(60.0, 0.3); // an interior knot to exercise multi-point round-trip m.zones.push_back(z); m.zones.push_back(zone("pad", 0, 19)); // default flat curve @@ -1694,7 +1699,7 @@ static void testVelocityCurveRoundTrip() { CHECK(back.zones.size() == 2); if (back.zones.size() != 2) return; CHECK(back.zones[0].velocityCurve.equals(z.velocityCurve)); // exact point round-trip - CHECK(back.zones[1].velocityCurve.equals(vst::VelocityCurve::flat())); // default preserved + CHECK(back.zones[1].velocityCurve.equals(VelocityCurve::flat())); // default preserved // And the flat default really is unity everywhere (R10-F1 Option A), not the old linear ramp. CHECK(back.zones[1].velocityCurve.eval(1.0) == 1.0); CHECK(back.zones[1].velocityCurve.eval(64.0) == 1.0); @@ -1706,12 +1711,12 @@ static void testVelocityCurveThroughComponentEnvelope() { ComponentState s; s.selectionId = "pick"; PerformanceZone z = zone("pick", 0, 127); - z.velocityCurve = vst::VelocityCurve::linear(); + z.velocityCurve = VelocityCurve::linear(); s.map.zones.push_back(z); const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.map.zones.size() == 1); if (back.map.zones.size() != 1) return; - CHECK(back.map.zones[0].velocityCurve.equals(vst::VelocityCurve::linear())); + CHECK(back.map.zones[0].velocityCurve.equals(VelocityCurve::linear())); } static void testVelocityCurveResolvesToZone() { @@ -1720,12 +1725,12 @@ static void testVelocityCurveResolvesToZone() { const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); PerformanceMap m; PerformanceZone z = zone("a", 0, 127); - z.velocityCurve = vst::VelocityCurve::linear(); + z.velocityCurve = VelocityCurve::linear(); m.zones.push_back(z); const ResolvedPerformance r = resolvePerformance(json, m); CHECK(r.zones.size() == 1); if (r.zones.size() != 1) return; - CHECK(r.zones[0].velocityCurve.equals(vst::VelocityCurve::linear())); + CHECK(r.zones[0].velocityCurve.equals(VelocityCurve::linear())); } // FA1 bug 3a — the COMPOSED end-to-end regression, mirroring the processor's reload composition @@ -1740,7 +1745,7 @@ static void testVelocityCurveEndToEndThroughReloadComposition() { ComponentState s; s.selectionId = "a"; PerformanceZone z = zone("a", 0, 127); - z.velocityCurve = vst::VelocityCurve::linear(); + z.velocityCurve = VelocityCurve::linear(); s.map.zones.push_back(z); const ComponentState back = deserializeComponentState(serializeComponentState(s), 48000.0); CHECK(back.map.zones.size() == 1); @@ -1817,7 +1822,7 @@ static void testVelocityCurveV6BackCompatLiftsToFlat() { CHECK(back.zones[0].sampleId == "v6saved"); CHECK(back.zones[0].keyTrack == 0.5); // the v6 field still read correctly // No curve tail -> flat y=1 default (the deliberate behavior change). - CHECK(back.zones[0].velocityCurve.equals(vst::VelocityCurve::flat())); + CHECK(back.zones[0].velocityCurve.equals(VelocityCurve::flat())); CHECK(back.zones[0].velocityCurve.eval(20.0) == 1.0); // a soft hit now plays at unity } diff --git a/tests/test_sample_usage.cpp b/tests/test_sample_usage.cpp index c2d84b0..8c48da5 100644 --- a/tests/test_sample_usage.cpp +++ b/tests/test_sample_usage.cpp @@ -22,7 +22,7 @@ // the pure identity matcher (UID hex / module filename base / display name, beta // over-protect), and the composed pruneOrphans exclusion proof. -#include "../src/sample_usage.h" +#include "../src/core/wire/sample_usage.h" #include #include @@ -30,9 +30,11 @@ #include #include -#include "../src/prune_reconcile.h" // mergeReferenced + pruneOrphans (composed proof) +#include "../src/core/reclaim/prune_reconcile.h" // mergeReferenced + pruneOrphans (composed proof) using namespace reasampler; +using namespace reasampler::reclaim; +using namespace reasampler::wire; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index e494d30..ec968c9 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -17,7 +17,7 @@ // by the CMake target linking neither SDK — this file includes only sampler_core.h + // the standard library, which is itself the compile-time proof. -#include "../src/vst/sampler_core.h" +#include "../src/core/instrument/engine/sampler_core.h" #include #include @@ -25,6 +25,7 @@ #include using namespace reasampler; +using namespace reasampler::instrument::engine; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -426,7 +427,7 @@ static void testNoteOffReleasesNewestSameNote() { // A LINEAR velocity curve keeps the two velocities distinguishable (velocity/127). The default // flat y=1 curve (S-VIEW-9 R10-F1) would render both at unity, collapsing the distinction this // note-off-selection test relies on — so we opt this zone back to the linear response. - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); + km.zones[0].velocityCurve = VelocityCurve::linear(); VoiceEngine eng(8, km); std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain @@ -751,7 +752,7 @@ static void testVelocityDefaultCurveIsFlatUnity() { // (not a hardcoded map) drives the gain, and that eval is applied at note-on. static void testVelocityLinearCurveReproducesRamp() { Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); + km.zones[0].velocityCurve = VelocityCurve::linear(); { VoiceEngine eng(1, km); eng.noteOn(60, 127); @@ -776,7 +777,7 @@ static void testVelocityLinearCurveReproducesRamp() { // curve's shaped value, not the linear one. Proves the whole curve, not just the endpoints, applies. static void testVelocityShapedCurveDrivesGain() { Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 - vst::VelocityCurve curve = vst::VelocityCurve::linear(); + VelocityCurve curve = VelocityCurve::linear(); curve.addPoint(64.0, 0.9); // pull the mid-velocity response UP to 0.9 km.zones[0].velocityCurve = curve; VoiceEngine eng(1, km); @@ -1429,7 +1430,7 @@ static void testVelocityCurveAppliesUnderPreserve() { SampleData s = dcSample(4000, 60); s.play.pitchEngine = PitchEngine::Preserve; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); + km.zones[0].velocityCurve = VelocityCurve::linear(); VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256); eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion) std::vector out; @@ -1578,7 +1579,7 @@ static void testMonoRepressHeldNoteMovesToTop() { static void testMonoRetriggerFallbackUsesOriginalVelocity() { SampleData s = dcLevelSample(200000, 1.0f, 60); Keymap km = Keymap::singleSampleChromatic(std::move(s)); - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); // gain = velocity/127 + km.zones[0].velocityCurve = VelocityCurve::linear(); // gain = velocity/127 VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); eng.noteOn(60, 32); // soft first note CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4)); @@ -1645,7 +1646,7 @@ static void testMonoLegatoRetunesWithoutReadRestart() { s.rootNote = 60; s.play.adsr = flatAdsr(); Keymap km = Keymap::singleSampleChromatic(std::move(s)); - km.zones[0].velocityCurve = vst::VelocityCurve::linear(); + km.zones[0].velocityCurve = VelocityCurve::linear(); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato); eng.noteOn(60, 127); // unity: read advances 1/frame, full gain std::vector out; @@ -2474,6 +2475,68 @@ static void testPreserveTriggerTailGapFree() { } } +// --- Q-W0 T1-03: the Preserve prime is bounded by the PLAYABLE span. A Trigger zone whose +// play length is shorter than the OLA window must never carry source PAST the user's +// chosen stop into the ring — pre-fix, the prime pulled a full window bounded only by +// frameCount, and an up-shifted tap PLAYED the cut content (transposed) before the voice +// freed. The sample poisons everything past playEnd with amplitude 8: if any of it +// reaches the output, the peak bound fails. --- +static void testPreservePrimeStopsAtTriggerPlayEnd() { + const std::size_t frames = 8000; + const std::size_t w = 2048; // OLA window >> playable span + const std::size_t playLen = 500; // playEnd = round(8000 * 0.0625) = 500 + SampleData s; + s.frames.resize(frames); + const double f0 = 1.0 / 50.0; // 10 cycles inside the playable span + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = i < playLen + ? static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))) + : 8.0f; // POISON: cut content past the play end + } + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Preserve; + s.play.trigger.lengthFraction = 0.0625; // exactly 500 / 8000 + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast(w)); + eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into + // the deepest primed history the ring holds + std::vector out; + eng.render(out, playLen + 64); // through the voice's own end (readPos >= playEnd) + double peak = 0.0; + for (const AudioSample v : out) { + const double a = std::fabs(static_cast(v)); + if (a > peak) peak = a; + } + CHECK(peak < 1.5); // the 8.0 poison never sounds: nothing past playEnd entered the ring + CHECK(peak > 0.4); // ...and the real span genuinely played (the bound is not vacuous) +} + +// --- Q-W0 T1-03 (companion): a whole sample SHORTER than the window (Gate, no loop) must not +// get zero padding declared as valid ring history — pre-fix, the prime zero-filled the +// window remainder with filled_ = window, so splices/tap travel landed in silence: +// hundreds-of-frames dead runs inside a sub-window one-shot (the pre-GA2 burst/gap +// artifact re-entering for short material). Post-fix the prime stops at the sample end +// and freezes the tail immediately, so the ring recycles ONLY real content. --- +static void testPreserveSubWindowSampleNoZeroPadInRing() { + const std::size_t frames = 1200; // sample < one window + const std::size_t w = 2048; + const double f0 = 1.0 / 96.0; // period 96: zero crossings dwell ~2 frames + SampleData s = tailSine(frames, f0, 60); + s.play.pitchEngine = PitchEngine::Preserve; + s.play.adsr = flatAdsr(); + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast(w)); + eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring) + std::vector out; + eng.render(out, frames); // voice runs to its natural end (no loop) + // Pre-fix: the tap crossed the declared-valid zero pad repeatedly — quiet runs of 150+ + // frames. Post-fix every relocation stays inside the real filled span; only sine zero + // crossings dip below the threshold. + CHECK(worstQuietRun(out, 0, frames, 0.05) < 30); + CHECK(blockPeak(out, 0, frames) > 0.5); // and it genuinely played at full level +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -2584,6 +2647,11 @@ int main() { testPreserveTailReleaseContinuous(); testPreserveTriggerTailGapFree(); + // Q-W0 T1-03 — the prime is bounded by the playable span (Trigger playEnd / sample end), + // with an immediate tail freeze on sub-window spans. + testPreservePrimeStopsAtTriggerPlayEnd(); + testPreserveSubWindowSampleNoZeroPadInRing(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0; diff --git a/tests/test_slot_map.cpp b/tests/test_slot_map.cpp new file mode 100644 index 0000000..72fce65 --- /dev/null +++ b/tests/test_slot_map.cpp @@ -0,0 +1,228 @@ +// Standalone tests for reasampler::model::SlotMap — no REAPER, no test framework. +// SlotMap is the L7 gap-preserving display-position carrier for one bank, extracted +// from bank_book (Q-W1, T4-05). These are the pure SlotMap-only assertions that +// previously lived inline in test_bank_book.cpp (the L7 "SlotMap unit behaviour" +// block); test_bank_book.cpp keeps its BankBook-level integration coverage +// (reorderSample / reconcileSlots / JSON round-trip WITH a full book), this file +// owns the module's own contract: add/remove/query, reorder gap-preservation, +// resetDense/reconcile, equality/fromEntries, and the serialize wire shape. + +#include "../src/core/model/slot_map.h" + +#include +#include +#include +#include + +#include "../src/core/json/json.h" + +using namespace reasampler::model; +namespace json = reasampler::json; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- SlotMap unit behaviour -------------------------------------------------- + +static void testSlotMapDenseAppend() { + SlotMap m; + m.append("a"); + m.append("b"); + m.append("c"); + CHECK(m.slotOf("a") == 0); + CHECK(m.slotOf("b") == 1); + CHECK(m.slotOf("c") == 2); + CHECK(m.maxSlot() == 2); + CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); + CHECK(m.idAt(1) == "b"); + CHECK(m.slotOf("nope") == -1); +} + +static void testSlotMapRemoveLeavesGap() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + CHECK(m.remove("b")); // slot 1 now EMPTY (no re-pack) + CHECK(m.slotOf("a") == 0); + CHECK(m.slotOf("c") == 2); // c did NOT shift down + CHECK(m.idAt(1).empty()); // gap preserved + CHECK((m.orderedIds() == std::vector{"a", "c"})); + CHECK(!m.remove("b")); // already gone +} + +static void testSlotMapAppendAfterGapGoesToFrontier() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.remove("a"); // slot 0 empty + m.append("d"); // append goes AFTER last occupied (2) -> 3 + CHECK(m.slotOf("d") == 3); // did NOT fill the slot-0 gap + CHECK(m.idAt(0).empty()); +} + +static void testSlotMapReorderIntoEmpty() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.remove("b"); // slot 1 empty + CHECK(m.reorder("c", 1)); // c -> empty slot 1; its slot 2 empties + CHECK(m.slotOf("c") == 1); + CHECK(m.idAt(2).empty()); + CHECK(m.slotOf("a") == 0); // untouched +} + +static void testSlotMapReorderOntoOccupiedInsertsAndShifts() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); m.append("d"); // 0,1,2,3 + CHECK(m.reorder("d", 1)); // d onto occupied slot 1 -> insert-before, shift b,c up + CHECK(m.slotOf("a") == 0); // before the target: unchanged + CHECK(m.slotOf("d") == 1); // took the target slot + CHECK(m.slotOf("b") == 2); // shifted +1 + CHECK(m.slotOf("c") == 3); // shifted +1 + CHECK((m.orderedIds() == std::vector{"a", "d", "b", "c"})); +} + +static void testSlotMapReorderPreservesInteriorGapAboveTarget() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.remove("b"); // gap at 1: a@0, c@2 + m.append("d"); // d@3 + CHECK(m.reorder("d", 0)); // d onto occupied slot 0 -> a shifts to 1, c shifts to 3 + CHECK(m.slotOf("d") == 0); + CHECK(m.slotOf("a") == 1); // shifted from 0 -> 1 + CHECK(m.slotOf("c") == 3); // shifted from 2 -> 3 (gap at 2 preserved as a +1 of its own) + CHECK(m.idAt(2).empty()); // interior gap above the target survives +} + +static void testSlotMapReorderUnmappedIsNoOp() { + SlotMap m; + m.append("a"); + CHECK(!m.reorder("ghost", 0)); // not mapped -> false, no mutation + CHECK(m.slotOf("a") == 0); +} + +static void testSlotMapNegativeTargetClampsToZero() { + SlotMap m; + m.append("a"); m.append("b"); // 0,1 + CHECK(m.reorder("b", -3)); // clamp to 0 -> insert-before a + CHECK(m.slotOf("b") == 0); + CHECK(m.slotOf("a") == 1); +} + +static void testSlotMapResetDenseSkipsDupesAndEmpties() { + SlotMap m; + m.resetDense({"a", "", "b", "a", "c"}); // "" and the second "a" dropped + CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); + CHECK(m.slotOf("a") == 0); + CHECK(m.slotOf("c") == 2); +} + +static void testSlotMapReconcileDropsStaleAppendsNew() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.reconcile({"a", "c", "d"}); // b left the index (drop), d is new (append) + CHECK(m.slotOf("a") == 0); // kept at its slot + CHECK(m.slotOf("c") == 2); // kept at its slot (gap where b was) + CHECK(m.slotOf("b") == -1); // stale marker dropped + CHECK(m.slotOf("d") == 3); // appended after the frontier + CHECK(m.idAt(1).empty()); // b's slot stays empty +} + +static void testSlotMapEqualityAndFromEntries() { + SlotMap a; + a.append("x"); a.append("y"); + SlotMap b = SlotMap::fromEntries({{"x", 0}, {"y", 1}}); + CHECK(a == b); + // Defensive repair: duplicate id (first wins), slot conflict (later dropped), + // empty id / negative slot dropped. + SlotMap c = SlotMap::fromEntries({{"x", 0}, {"x", 5}, {"y", 0}, {"", 9}, {"z", -1}, {"w", 2}}); + CHECK(c.slotOf("x") == 0); // first x wins + CHECK(c.slotOf("y") == -1); // slot 0 already taken -> dropped + CHECK(c.slotOf("w") == 2); // valid + CHECK(c.slotOf("z") == -1); // negative slot dropped +} + +// --- serialize: golden byte-literal + round-trip ----------------------------- + +// Pins the exact wire shape (an array of {"id":..,"slot":..} objects, ascending +// slot, no whitespace) so a future format drift is caught here rather than only +// as a downstream bank_book diff. Mirrors the pre-extraction bank_book writer +// byte-for-byte (core/json emit helpers are shared, not reimplemented). +static void testSlotMapSerializeGoldenLiteral() { + SlotMap empty; + CHECK(empty.serialize() == "[]"); + + SlotMap m; + m.append("a"); + m.append("b"); + CHECK(m.serialize() == "[{\"id\":\"a\",\"slot\":0},{\"id\":\"b\",\"slot\":1}]"); +} + +// A local mirror of bank_book's private parseSlots (the "slots" array grammar): +// [{id, slot}, ...]. slot_map.cpp itself only emits — JSON parsing is a consumer +// concern (see slot_map.h) — so the round-trip proof below parses the emitted +// text back into pairs the same way bank_book does, then rebuilds via +// SlotMap::fromEntries and checks equality against the original. +static bool parseSlotsArray(json::Reader& r, std::vector>& out) { + out.clear(); + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; // empty array + do { + if (!r.consume('{')) return false; + std::string id; + int slot = 0; + bool haveId = false, haveSlot = false; + do { + std::string k; + if (!r.parseKey(k)) return false; + if (k == "id") { if (!r.parseString(id)) return false; haveId = true; } + else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; } + else { if (!r.skipValue()) return false; } + } while (r.consume(',')); + if (!r.consume('}')) return false; + if (!haveId || !haveSlot) return false; + out.emplace_back(std::move(id), slot); + } while (r.consume(',')); + return r.consume(']'); +} + +static void testSlotMapSerializeRoundTrip() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); + m.remove("b"); // leave a gap: a@0, c@2 + m.append("d"); // d@3 + + const std::string blob = m.serialize(); + json::Reader r(blob); + std::vector> pairs; + CHECK(parseSlotsArray(r, pairs)); + + SlotMap round = SlotMap::fromEntries(pairs); + CHECK(round == m); + CHECK(round.slotOf("a") == 0); + CHECK(round.idAt(1).empty()); // gap survives the round trip + CHECK(round.slotOf("c") == 2); + CHECK(round.slotOf("d") == 3); +} + +int main() { + testSlotMapDenseAppend(); + testSlotMapRemoveLeavesGap(); + testSlotMapAppendAfterGapGoesToFrontier(); + testSlotMapReorderIntoEmpty(); + testSlotMapReorderOntoOccupiedInsertsAndShifts(); + testSlotMapReorderPreservesInteriorGapAboveTarget(); + testSlotMapReorderUnmappedIsNoOp(); + testSlotMapNegativeTargetClampsToZero(); + testSlotMapResetDenseSkipsDupesAndEmpties(); + testSlotMapReconcileDropsStaleAppendsNew(); + testSlotMapEqualityAndFromEntries(); + testSlotMapSerializeGoldenLiteral(); + testSlotMapSerializeRoundTrip(); + + if (g_fail == 0) { + std::printf("slot_map_tests: all passed\n"); + return 0; + } + std::printf("slot_map_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_tab_strip.cpp b/tests/test_tab_strip.cpp index ab32613..833cfb5 100644 --- a/tests/test_tab_strip.cpp +++ b/tests/test_tab_strip.cpp @@ -10,13 +10,14 @@ // left/right chevron precedence at the ends, dead space between visible tabs, // outside the band above/below/left/right, half-open boundary pixels). -#include "../src/tab_strip.h" +#include "../src/core/ui/tab_strip.h" #include #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_tail_control.cpp b/tests/test_tail_control.cpp index 09d20b0..20e6e27 100644 --- a/tests/test_tail_control.cpp +++ b/tests/test_tail_control.cpp @@ -3,12 +3,13 @@ // (None -> Auto -> Manual -> None), the manual-length clamp to the 8 s cap, and the // toggle label text. The drawing / click hit-testing is DAW-verified in bank_panel. -#include "../src/tail_control.h" +#include "../src/core/capture/tail_control.h" #include #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -135,6 +136,20 @@ static void testRoundTripAuto() { CHECK(back && settingsEqual(*back, s)); } +static void testSerializeByteIdentity() { + // Q-W1 structural-dedupe guard: the core/json-backed writer must emit the + // EXACT bytes the former snprintf writer produced ({"mode":%d,"manualMs":%.17g}) + // and re-serializing a round-tripped setting must be byte-identical — the blob + // lives in the .rpp, so a byte shift would dirty every saved project. + TailSetting s; // None + 2000.0 default + CHECK(serializeTailSetting(s) == "{\"mode\":0,\"manualMs\":2000}"); + TailSetting man; man.mode = TailMode::Manual; man.manualMs = 3141.592653589793; + const std::string json = serializeTailSetting(man); + auto back = deserializeTailSetting(json); + CHECK(back.has_value()); + CHECK(back && serializeTailSetting(*back) == json); // stable second round-trip +} + static void testDeserializeEmptyIsDefault() { // An absent/empty stored value (older project) -> nullopt, so the caller falls // back to the default. This is the graceful-old-project path the brief requires. @@ -164,6 +179,7 @@ int main() { testRoundTripNoneDefault(); testRoundTripManualArbitraryMs(); testRoundTripAuto(); + testSerializeByteIdentity(); testDeserializeEmptyIsDefault(); testDeserializeMalformedIsDefault(); diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index 3885752..545d91c 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -8,7 +8,7 @@ // interaction-state transform behaves, the WCAG math is correct against known anchors, and // the Direction C spectral ramp interpolates its endpoints. -#include "../src/theme.h" +#include "../src/core/ui/theme.h" #include #include @@ -16,6 +16,7 @@ #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_tooltip.cpp b/tests/test_tooltip.cpp index 2d7cf11..fef6765 100644 --- a/tests/test_tooltip.cpp +++ b/tests/test_tooltip.cpp @@ -5,12 +5,13 @@ // match); placement BELOW the anchor centred; horizontal clamp at both client edges; the // bottom-edge flip to ABOVE; the both-clip clamp for a tall tooltip; degenerate inputs -> empty. -#include "../src/tooltip.h" +#include "../src/core/ui/tooltip.h" #include #include using namespace reasampler; +using namespace reasampler::ui; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_trigger_seam.cpp b/tests/test_trigger_seam.cpp index 58cbcb5..702c44d 100644 --- a/tests/test_trigger_seam.cpp +++ b/tests/test_trigger_seam.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::trigger_seam — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::map::trigger_seam — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. // // Covers: triggerPlayLength (zero play length, startFrame set, startFrame past frameCount, @@ -6,11 +6,12 @@ // (zero play length, rounding); round-trip fidelity; the Finding 1 regression (start-point // set — the case that was broken before this module existed). -#include "../src/vst/trigger_seam.h" +#include "../src/core/instrument/map/trigger_seam.h" #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::map; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_velocity_curve.cpp b/tests/test_velocity_curve.cpp index 9baeaab..7e642cc 100644 --- a/tests/test_velocity_curve.cpp +++ b/tests/test_velocity_curve.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::velocity_curve — no VST3, no REAPER, no framework. Same fast +// Standalone tests for reasampler::instrument::engine::velocity_curve — no VST3, no REAPER, no framework. Same fast // assert loop as the sibling pure tests. Assert the S-VIEW-9 velocity->amp transfer curve HARD: // // * eval — flat y=1 default (R10-F1 Option A: EVERY velocity -> 1.0), linear ramp, curved shape @@ -11,12 +11,13 @@ // * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls // back to flat() for a sub-2-point list. -#include "../src/vst/velocity_curve.h" +#include "../src/core/instrument/engine/velocity_curve.h" #include #include -using namespace reasampler::vst; +using namespace reasampler; +using namespace reasampler::instrument::engine; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 94ce056..bb57611 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -15,14 +15,15 @@ // (park-while-parked) so untagged leaves return to visible after toggling back; // guards the in-DAW "all leaves hidden after toggling twice" regression. -#include "../src/view_mode_model.h" -#include "../src/lane_keys.h" // laneNameForMode — assert the minting plan's durable keys +#include "../src/core/view/view_mode_model.h" +#include "../src/core/view/lane_keys.h" // laneNameForMode — assert the minting plan's durable keys #include #include #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -55,6 +56,20 @@ static int flagValue(const TrackPlan& p, Flag f) { return -999; // sentinel: flag absent } +// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for the +// default-seeded model (Arrange + Design, no membership), not just self- +// consistent re-serialization — a format drift that both writer and reader +// agree on would slip past the round-trip tests but not this. The format is +// frozen as-shipped; the literal below is the captured current output. +static void testSerializeGoldenLiteral() { + ViewModeModel vm; + CHECK(vm.serialize() == + "{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\"," + "\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\"," + "\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[]," + "\"snapshots\":[],\"lanes\":[]}"); +} + // -- 1. N-mode proven -------------------------------------------------------- static void testNModeRegistryAndMembership() { @@ -1786,6 +1801,7 @@ static void testLaneMalformedJson() { } int main() { + testSerializeGoldenLiteral(); testNModeRegistryAndMembership(); testParentDerivationMultiMode(); testParentOwnMembershipVisibility(); diff --git a/tests/test_view_tree.cpp b/tests/test_view_tree.cpp index f52c131..9a7e06e 100644 --- a/tests/test_view_tree.cpp +++ b/tests/test_view_tree.cpp @@ -3,12 +3,13 @@ // one genuinely pure piece: the I_FOLDERDEPTH walk that turns REAPER's linear // track stream into the parent<->child FolderTree the pure model consumes. -#include "../src/view_tree.h" +#include "../src/core/view/view_tree.h" #include #include using namespace reasampler; +using namespace reasampler::view; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_wav_trim.cpp b/tests/test_wav_codec.cpp similarity index 53% rename from tests/test_wav_trim.cpp rename to tests/test_wav_codec.cpp index a615be2..b768e71 100644 --- a/tests/test_wav_trim.cpp +++ b/tests/test_wav_codec.cpp @@ -1,13 +1,19 @@ -// Standalone tests for reasampler::wav_trim — no REAPER, no test framework. -// Builds synthetic 32-bit-float WAV byte buffers, asserts the parse geometry, the -// float extraction, and the truncate-plan arithmetic (the header size-field patch). +// Standalone tests for reasampler::wav_codec — no REAPER, no test framework. +// The ONE pure WAV/RIFF owner (Q-W3, audit §4e): builds synthetic 32-bit-float WAV +// byte buffers, asserts the parse geometry, the float extraction, the truncate-plan +// arithmetic + size-field patch, the float32 build round-trip, and the WAV-aware +// content hashes (moved here from capture_paths with the hash implementations). // // Covers: canonical stereo/mono 32-bit-float parse; a leading unknown chunk skipped; // format rejection (16-bit PCM, non-WAV, data-before-fmt, truncated data); frame // extraction (whole / tail window / clamp / out-of-range); truncate plan (kept #include @@ -15,6 +21,7 @@ #include using namespace reasampler; +using namespace reasampler::capture; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -262,15 +269,10 @@ static void testTruncatePlanKeepFewer() { // Applying the plan yields a buffer that re-parses to exactly 4 frames. std::vector trimmed(wav.begin(), wav.begin() + p.newFileByteLength); - // Patch the two size fields (what the shell does before truncating on disk). - auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { - b[off + 0] = static_cast(v & 0xFF); - b[off + 1] = static_cast((v >> 8) & 0xFF); - b[off + 2] = static_cast((v >> 16) & 0xFF); - b[off + 3] = static_cast((v >> 24) & 0xFF); - }; - writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize); - writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize); + // Patch the two size fields with the module's own patch primitive (what the + // shell does before truncating on disk). + patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize); + patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize); WavLayout L2 = parseWavLayout(trimmed); CHECK(L2.valid); @@ -324,14 +326,8 @@ static void testExtensibleFloatAccepted() { CHECK(p.valid); CHECK(p.newDataSize == 3 * 2 * 4u); std::vector trimmed(wav.begin(), wav.begin() + p.newFileByteLength); - auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { - b[off + 0] = static_cast(v & 0xFF); - b[off + 1] = static_cast((v >> 8) & 0xFF); - b[off + 2] = static_cast((v >> 16) & 0xFF); - b[off + 3] = static_cast((v >> 24) & 0xFF); - }; - writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize); - writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize); + patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize); + patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize); WavLayout L2 = parseWavLayout(trimmed); CHECK(L2.valid); CHECK(L2.frameCount() == 3); @@ -355,6 +351,248 @@ static void testTruncatePlanKeepZeroAndGrowRejected() { CHECK(!planWavTruncate(bad, 0).valid); } +// --- patchU32LE (the size-field patch primitive) ------------------------------ + +static void testPatchU32LEWritesLittleEndian() { + std::vector buf(8, 0xEE); + patchU32LE(buf, 2, 0x0A0B0C0Du); + CHECK(buf[0] == 0xEE && buf[1] == 0xEE); // bytes outside the field untouched + CHECK(buf[2] == 0x0D && buf[3] == 0x0C && buf[4] == 0x0B && buf[5] == 0x0A); + CHECK(buf[6] == 0xEE && buf[7] == 0xEE); +} + +// --- buildFloat32Wav (the one WAV writer, absorbed from ingest — T4-10) ------- + +static void testBuildFloat32WavGoldenHeaderAndRoundTrip() { + // 2 channels, 3 frames of known interleaved values. + const std::vector pcm = {0.0, 0.5, -0.25, 1.0, -1.0, 0.125}; + auto wav = buildFloat32Wav(2, 48000, 3, pcm); + + // Golden container shape: 44-byte header + 6 samples * 4 bytes. + CHECK(wav.size() == 44u + 6u * 4u); + CHECK(std::memcmp(wav.data(), "RIFF", 4) == 0); + CHECK(std::memcmp(wav.data() + 8, "WAVE", 4) == 0); + CHECK(std::memcmp(wav.data() + 12, "fmt ", 4) == 0); + CHECK(std::memcmp(wav.data() + 36, "data", 4) == 0); + CHECK(wav[20] == 0x03 && wav[21] == 0x00); // WAVE_FORMAT_IEEE_FLOAT + CHECK(wav[34] == 32 && wav[35] == 0); // bitsPerSample = 32 + + // The build round-trips through the module's own parse + extraction, with the + // documented double->float narrowing. + WavLayout L = parseWavLayout(wav); + CHECK(L.valid); + CHECK(L.channelCount == 2); + CHECK(L.sampleRate == 48000); + CHECK(L.frameCount() == 3); + auto back = extractFloatFrames(wav, L, 0, 3); + CHECK(back.size() == 6); + for (std::size_t i = 0; i < back.size(); ++i) + CHECK(back[i] == static_cast(pcm[i])); +} + +static void testBuildFloat32WavShortInputRejectedByParse() { + // Fewer interleaved samples than frameCount*nch declares: the data chunk still + // declares the full length; the missing tail simply is not written. The build + // caller (ingest) always passes a full buffer; this locks the clamp-no-OOB shape. + const std::vector pcm = {1.0}; // 1 sample for a 2-frame mono request + auto wav = buildFloat32Wav(1, 44100, 2, pcm); + // Declared data size covers 2 frames; actual bytes stop after 1 sample, so the + // declared length overruns the buffer -> parse rejects (the honest verdict for + // a short-fed build; ingest never produces this). + CHECK(wav.size() == 44u + 4u); + CHECK(!parseWavLayout(wav).valid); +} + +// --- hashBytes (FNV-1a content hash — moved with the impl from capture_paths) -- + +static void testHashBytesOutputFormat() { + // Output is always 16 lowercase hex characters. + const std::uint8_t bytes[] = {0x01, 0x02, 0x03}; + const std::string h = hashBytes(bytes, 3); + CHECK(h.size() == 16); + for (char c : h) { + CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')); + } +} + +static void testHashBytesDeterministicAndDistinct() { + // Same input always produces the same output; different inputs differ (no + // accidental dedup of distinct files), including a one-bit flip and a + // trailing-zero-byte length change. + const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01}; + CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5)); + + std::uint8_t a[] = {0x00, 0x00}; + std::uint8_t b[] = {0x00, 0x01}; + CHECK(hashBytes(a, 2) != hashBytes(b, 2)); + + const std::uint8_t short_buf[] = {0xAB, 0xCD}; + const std::uint8_t long_buf[] = {0xAB, 0xCD, 0x00}; + CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3)); +} + +static void testHashBytesEmptyBufferIsNonEmpty() { + // An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty + // sentinel) — capturing the contract that even empty inputs yield a 16-char hash. + const std::string h = hashBytes(nullptr, 0); + CHECK(h.size() == 16); +} + +// --- hashWavContent (WAV-aware dedup hash) ----------------------------------- +// +// Verifies that the WAV-content hash hashes only fmt+data (skipping metadata +// chunks like bext/LIST), falls back gracefully for non-WAV input, and that +// different audio data yields different hashes. + +// Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk +// inserted between "WAVE" and the fmt chunk. This is the shape REAPER produces: a +// `bext` or `LIST` chunk before fmt with a render-time timestamp in the body. +static std::vector buildMetaWav( + std::uint16_t channels, std::uint32_t sampleRate, + const std::vector& samples, + bool insertMeta = false, + const char* metaTag = "bext", + const std::vector& metaBody = {}) { + + std::vector chunks; + + if (insertMeta && !metaBody.empty()) { + putTag(chunks, metaTag); + putU32(chunks, static_cast(metaBody.size())); + chunks.insert(chunks.end(), metaBody.begin(), metaBody.end()); + if (metaBody.size() & 1u) chunks.push_back(0); // RIFF pad + } + + // fmt chunk (16-byte body, IEEE-float tag 3). + const std::uint32_t dataBytes = + static_cast(samples.size() * 4u); + putTag(chunks, "fmt "); + putU32(chunks, 16); + putU16(chunks, 3); // IEEE float + putU16(chunks, channels); + putU32(chunks, sampleRate); + putU32(chunks, sampleRate * channels * 4u); // byteRate + putU16(chunks, static_cast(channels * 4)); // blockAlign + putU16(chunks, 32); // bitsPerSample + + // data chunk. + putTag(chunks, "data"); + putU32(chunks, dataBytes); + for (float f : samples) putFloat(chunks, f); + + std::vector wav; + putTag(wav, "RIFF"); + putU32(wav, static_cast(4 + chunks.size())); + putTag(wav, "WAVE"); + wav.insert(wav.end(), chunks.begin(), chunks.end()); + return wav; +} + +static void testHashWavContentIdenticalAudioSameHash() { + // Two WAVs with the same audio but different metadata body -> same hash. + // This is the core dedup regression: REAPER embeds a bext chunk with a + // render-time origination timestamp; without WAV-aware hashing, two renders + // of the same clip produce different file bytes -> no dedup collapse. + const std::vector audio = {0.1f, -0.2f, 0.3f, -0.4f}; + std::vector metaA(64, 0x00); // bext body, all zeros (e.g. epoch) + std::vector metaB(64, 0x00); + // Different origination timestamps: first 10 bytes of bext are ASCII date/time. + metaB[0] = '2'; metaB[1] = '0'; metaB[2] = '2'; metaB[3] = '6'; // year + + auto wavA = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaA); + auto wavB = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaB); + + // Files must differ (the bext body is different) to prove the test is valid. + CHECK(wavA != wavB); + + // But their content hashes must be equal: same fmt+data, different metadata. + CHECK(hashWavContent(wavA) == hashWavContent(wavB)); +} + +static void testHashWavContentDifferentAudioDifferentHash() { + // Different PCM data -> different content hashes (no false dedup). + const std::vector audioA = {0.5f, 0.5f}; + const std::vector audioB = {0.5f, 0.6f}; // last sample differs + + auto wavA = buildMetaWav(1, 44100, audioA); + auto wavB = buildMetaWav(1, 44100, audioB); + + CHECK(hashWavContent(wavA) != hashWavContent(wavB)); +} + +static void testHashWavContentDifferentFmtDifferentHash() { + // Different fmt fields (sample rate) -> different content hashes. + const std::vector audio = {0.1f, 0.2f}; + auto wav44 = buildMetaWav(1, 44100, audio); + auto wav48 = buildMetaWav(1, 48000, audio); + CHECK(hashWavContent(wav44) != hashWavContent(wav48)); +} + +static void testHashWavContentNonWavFallsBackToWholeFile() { + // Non-WAV bytes -> falls back to whole-file hashBytes; result is non-empty + // and equals hashBytes of the same bytes directly. Empty input likewise. + std::vector notWav = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02}; + const std::string h = hashWavContent(notWav); + CHECK(!h.empty()); + CHECK(h.size() == 16); + CHECK(h == hashBytes(notWav.data(), notWav.size())); + + std::vector empty; + const std::string he = hashWavContent(empty); + CHECK(he.size() == 16); + CHECK(he == hashBytes(nullptr, 0)); +} + +static void testHashWavContentListMetaSkipped() { + // A LIST/INFO chunk (another common metadata chunk) is likewise skipped. + const std::vector audio = {1.0f, -1.0f, 0.5f}; + std::vector listBody = {'I','N','F','O', 'x','x','x','x'}; + auto wavClean = buildMetaWav(1, 48000, audio); + auto wavList = buildMetaWav(1, 48000, audio, true, "LIST", listBody); + + // Content hashes must match: only the LIST chunk differs. + CHECK(hashWavContent(wavClean) == hashWavContent(wavList)); +} + +static void testHashWavContentDomainSeparationFromWholeFile() { + // The content hash ('W'-prefixed) must not accidentally equal the whole-file + // hash of the SAME bytes. This guards against the domain-separation prefix + // being dropped or zeroed out. + const std::vector audio = {0.0f}; + auto wav = buildMetaWav(1, 44100, audio); + const std::string contentHash = hashWavContent(wav); + const std::string wholeHash = hashBytes(wav.data(), wav.size()); + CHECK(contentHash != wholeHash); +} + +static void testHashMatchesBuildOutput() { + // The consolidation guarantee end-to-end: a WAV produced by the module's own + // builder hashes as WAV content (not the whole-file fallback), so an imported + // conversion and a captured render of identical audio can dedup-collapse. + const std::vector pcm = {0.25, -0.25}; + auto wav = buildFloat32Wav(1, 44100, 2, pcm); + CHECK(hashWavContent(wav) != hashBytes(wav.data(), wav.size())); // chunk-aware path taken + // And a metadata-bearing copy of the same audio content hashes identically. + auto withMeta = buildMetaWav(1, 44100, {0.25f, -0.25f}, true, "bext", + std::vector(16, 0x7A)); + CHECK(hashWavContent(wav) == hashWavContent(withMeta)); +} + +// Golden hash literal (review follow-up, Q-W3): the tests above prove format, +// determinism, and relational properties (equal/different) of hashBytes and +// hashWavContent, but none of them pins the actual output bytes — a feed-sequence +// change (e.g. dropping the 'W' domain-separation prefix, or reordering the +// fmt/data feed) would pass every test above while silently invalidating every +// contentHash already persisted in existing projects' BankIndex JSON. This test +// locks a fixed buildFloat32Wav input's hashes against exact hex literals +// captured from the current implementation, so any such drift fails loudly here. +static void testGoldenHashLiterals() { + const std::vector pcm = {0.0, 0.5, -0.25, 1.0, -1.0, 0.125}; + auto wav = buildFloat32Wav(2, 48000, 3, pcm); + CHECK(hashWavContent(wav) == "7ccf298c166a670a"); + CHECK(hashBytes(wav.data(), wav.size()) == "68d8a193c958fd44"); +} + int main() { testParseCanonicalStereo(); testParseMonoAndLeadingChunk(); @@ -366,7 +604,22 @@ int main() { testTruncatePlanKeepZeroAndGrowRejected(); testExtensiblePcmIntegerRejected(); testExtensibleFloatAccepted(); + testPatchU32LEWritesLittleEndian(); + testBuildFloat32WavGoldenHeaderAndRoundTrip(); + testBuildFloat32WavShortInputRejectedByParse(); + testHashBytesOutputFormat(); + testHashBytesDeterministicAndDistinct(); + testHashBytesEmptyBufferIsNonEmpty(); + testHashWavContentIdenticalAudioSameHash(); + testHashWavContentDifferentAudioDifferentHash(); + testHashWavContentDifferentFmtDifferentHash(); + testHashWavContentNonWavFallsBackToWholeFile(); + testHashWavContentListMetaSkipped(); + testHashWavContentDomainSeparationFromWholeFile(); + testHashMatchesBuildOutput(); + testGoldenHashLiterals(); - if (g_fail == 0) std::printf("All tests passed.\n"); + if (g_fail == 0) std::printf("wav_codec: all tests passed\n"); + else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail); return g_fail ? 1 : 0; } diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp index 2969cd9..d5cfd60 100644 --- a/tests/test_waveform_view.cpp +++ b/tests/test_waveform_view.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::vst::waveform_view — no VST3, no REAPER, no framework. +// Standalone tests for reasampler::instrument::ui::waveform_view — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling pure tests. Assert the S11 waveform surface's // frame<->pixel mapping, marker grab regions, drag-delta frame resolver (with clamps), and // the zero-crossing snap — the geometry + snap that back the draggable start/loop markers. @@ -9,61 +9,62 @@ // no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower, // no-crossing keeps target, target clamp, degenerate buffers). -#include "../src/vst/waveform_view.h" +#include "../src/core/instrument/ui/waveform_view.h" #include #include -using namespace reasampler::vst; -using reasampler::AudioSample; +using namespace reasampler; +using namespace reasampler::instrument::ui; +using reasampler::audio::AudioSample; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs). -static Rect wideArea() { return Rect{20, 10, 1020, 90}; } // width 1000 +static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 90); } // width 1000 // --- frameToX / xToFrame ------------------------------------------------------ static void testFrameToXEndpoints() { const Rect a = wideArea(); - CHECK(frameToX(a, 1000, 0) == a.left); // frame 0 -> left edge - CHECK(frameToX(a, 1000, 1000) == a.right); // frameCount -> right edge - CHECK(frameToX(a, 1000, 500) == a.left + 500); // midpoint (1:1 here) + CHECK(frameToX(a, 1000, 0) == a.x); // frame 0 -> left edge + CHECK(frameToX(a, 1000, 1000) == a.right()); // frameCount -> right edge + CHECK(frameToX(a, 1000, 500) == a.x + 500); // midpoint (1:1 here) } static void testFrameToXClampsOutOfRange() { const Rect a = wideArea(); - CHECK(frameToX(a, 1000, -50) == a.left); // below 0 pins left - CHECK(frameToX(a, 1000, 5000) == a.right); // above count pins right + CHECK(frameToX(a, 1000, -50) == a.x); // below 0 pins left + CHECK(frameToX(a, 1000, 5000) == a.right()); // above count pins right } static void testFrameToXDegenerate() { const Rect a = wideArea(); - CHECK(frameToX(a, 0, 100) == a.left); // no frames -> left - const Rect z = Rect{5, 5, 5, 45}; // zero width - CHECK(frameToX(z, 1000, 500) == z.left); + CHECK(frameToX(a, 0, 100) == a.x); // no frames -> left + const Rect z = Rect::ltrb(5, 5, 5, 45); // zero width + CHECK(frameToX(z, 1000, 500) == z.x); } static void testXToFrameInverse() { const Rect a = wideArea(); - CHECK(xToFrame(a, 1000, a.left) == 0); - CHECK(xToFrame(a, 1000, a.right) == 1000); - CHECK(xToFrame(a, 1000, a.left + 250) == 250); // 1:1 map here + CHECK(xToFrame(a, 1000, a.x) == 0); + CHECK(xToFrame(a, 1000, a.right()) == 1000); + CHECK(xToFrame(a, 1000, a.x + 250) == 250); // 1:1 map here } static void testXToFrameClampsOutside() { const Rect a = wideArea(); - CHECK(xToFrame(a, 1000, a.left - 100) == 0); // left of area -> 0 - CHECK(xToFrame(a, 1000, a.right + 100) == 1000); // right of area -> frameCount - CHECK(xToFrame(a, 0, a.left + 10) == 0); // no frames -> 0 + CHECK(xToFrame(a, 1000, a.x - 100) == 0); // left of area -> 0 + CHECK(xToFrame(a, 1000, a.right() + 100) == 1000); // right of area -> frameCount + CHECK(xToFrame(a, 0, a.x + 10) == 0); // no frames -> 0 } static void testFrameToXRoundTrip() { // Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then // xToFrame should land within a couple frames (rounding both directions). - const Rect a = Rect{0, 0, 800, 60}; + const Rect a = Rect::ltrb(0, 0, 800, 60); for (std::int64_t f = 0; f <= 2000; f += 137) { const int x = frameToX(a, 2000, f); const std::int64_t back = xToFrame(a, 2000, x); @@ -77,39 +78,39 @@ static void testMarkerAtPointGrabsWithinBand() { const Rect a = wideArea(); // Markers at frames 100, 500, 900 -> x = left+100, left+500, left+900. const std::int64_t frames[3] = {100, 500, 900}; - const int midY = a.top + a.height() / 2; - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 100, midY) == 0); - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, midY) == 1); - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 900, midY) == 2); + const int midY = a.y + a.height / 2; + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 100, midY) == 0); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 500, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 900, midY) == 2); // Within the grab band on either side of the line. - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 + kMarkerGrabWidth, midY) == 1); - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 - kMarkerGrabWidth, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 500 + kMarkerGrabWidth, midY) == 1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 500 - kMarkerGrabWidth, midY) == 1); } static void testMarkerAtPointMissesBetween() { const Rect a = wideArea(); const std::int64_t frames[3] = {100, 500, 900}; - const int midY = a.top + a.height() / 2; + const int midY = a.y + a.height / 2; // Well away from any marker line. - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 300, midY) == -1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 300, midY) == -1); // Off the area vertically. - CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, a.top - 5) == -1); + CHECK(markerAtPoint(a, 1000, frames, 3, a.x + 500, a.y - 5) == -1); } static void testMarkerAtPointFirstMatchOnOverlap() { const Rect a = wideArea(); // Two markers at the same frame -> first in order wins. const std::int64_t frames[2] = {400, 400}; - const int midY = a.top + a.height() / 2; - CHECK(markerAtPoint(a, 1000, frames, 2, a.left + 400, midY) == 0); + const int midY = a.y + a.height / 2; + CHECK(markerAtPoint(a, 1000, frames, 2, a.x + 400, midY) == 0); } static void testMarkerAtPointRejectsNullEmpty() { const Rect a = wideArea(); - const int midY = a.top + a.height() / 2; - CHECK(markerAtPoint(a, 1000, nullptr, 3, a.left + 100, midY) == -1); + const int midY = a.y + a.height / 2; + CHECK(markerAtPoint(a, 1000, nullptr, 3, a.x + 100, midY) == -1); const std::int64_t frames[1] = {100}; - CHECK(markerAtPoint(a, 1000, frames, 0, a.left + 100, midY) == -1); + CHECK(markerAtPoint(a, 1000, frames, 0, a.x + 100, midY) == -1); } // --- resolveDragFrame --------------------------------------------------------- @@ -130,7 +131,7 @@ static void testResolveDragFrameClamps() { static void testResolveDragFrameRounds() { // 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is // at the frame centre. Use a scale where a fractional result appears. - const Rect a = Rect{0, 0, 300, 60}; // 1000 frames / 300px = 3.33 frames/px + const Rect a = Rect::ltrb(0, 0, 300, 60); // 1000 frames / 300px = 3.33 frames/px // +3px -> 3*1000/300 = 10.0 -> 10 frames. CHECK(resolveDragFrame(a, 1000, 100, 3) == 110); // +1px -> 1000/300 = 3.33 -> rounds to 3. @@ -138,7 +139,7 @@ static void testResolveDragFrameRounds() { } static void testResolveDragFrameDegenerate() { - const Rect z = Rect{0, 0, 0, 60}; // zero width + const Rect z = Rect::ltrb(0, 0, 0, 60); // zero width CHECK(resolveDragFrame(z, 1000, 300, 100) == 300); // pinned to start const Rect a = wideArea(); CHECK(resolveDragFrame(a, 0, 300, 100) == 0); // no frames -> clamp(start)=0 diff --git a/tests/test_wire.cpp b/tests/test_wire.cpp new file mode 100644 index 0000000..edb0354 --- /dev/null +++ b/tests/test_wire.cpp @@ -0,0 +1,243 @@ +// Standalone tests for reasampler::wire — no REAPER, no framework. The ONE +// length-prefixed ext-state wire codec (Q-W1, T2-01b) behind provenance / +// assignment_request / sample_usage / bank_sync. The consumers' own suites +// prove their record grammars round-trip; this suite pins the CODEC contract — +// byte-exact encode, the full hardening (length caps, overflow guards, +// subtraction-first bounds), and the fixed fieldInt range rejection. +// +// NOTE: wire::Cursor BORROWS its input string, so every helper takes a named / +// reference-bound std::string — the Cursor never outlives its buffer. + +#include "../src/core/wire/wire.h" +#include "../src/core/wire/bytes.h" // putLE / ByteReader — the ONE LE byte codec (Q-W2v, T4-20) + +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::wire; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// One length-prefixed field around `v` — the writer-side convention. +static std::string enc(const std::string& v) { + std::string out; + wire::putField(out, v); + return out; +} + +// Single-field decode helpers (Cursor + buffer share the call's lifetime). +static bool fieldFrom(const std::string& s, std::string& out) { + wire::Cursor c(s); + return c.field(out); +} +static bool i64From(const std::string& s, std::int64_t& out) { + wire::Cursor c(s); + return c.fieldInt64(out); +} +static bool intFrom(const std::string& s, int& out) { + wire::Cursor c(s); + return c.fieldInt(out); +} +static bool sizeFrom(const std::string& s, std::size_t& out) { + wire::Cursor c(s); + return c.fieldSizeT(out); +} +static bool dblFrom(const std::string& s, double& out) { + wire::Cursor c(s); + return c.fieldDouble(out); +} + +// --- putField: byte-exact encode ---------------------------------------------- + +static void testPutFieldExactBytes() { + std::string out; + wire::putField(out, "abc"); + CHECK(out == "3:abc"); + wire::putField(out, ""); // empty field is legal: "0:" + CHECK(out == "3:abc0:"); + wire::putField(out, "a:b"); // ':' inside a value cannot shift the parse + CHECK(out == "3:abc0:3:a:b"); +} + +// --- Cursor: round-trip + literal --------------------------------------------- + +static void testFieldRoundTripIncludingSeparators() { + std::string out = "magic"; + wire::putField(out, "12:34"); // digits + colons in the value + wire::putField(out, ""); + wire::putField(out, "tail"); + wire::Cursor c(out); + std::string a, b, t; + CHECK(c.literal("magic")); + CHECK(c.field(a) && a == "12:34"); + CHECK(c.field(b) && b.empty()); + CHECK(c.field(t) && t == "tail"); + CHECK(c.ok() && c.atEnd()); +} + +static void testLiteralMismatchFails() { + const std::string good = "rsprov1x"; + const std::string wrong = "rsprov0x"; + const std::string truncated = "rspro"; + { wire::Cursor c(good); CHECK(c.literal("rsprov1")); } + { wire::Cursor c(wrong); CHECK(!c.literal("rsprov1")); CHECK(!c.ok()); } + { wire::Cursor c(truncated); CHECK(!c.literal("rsprov1")); } +} + +// --- Cursor: field hardening --------------------------------------------------- + +static void testFieldRejectsMalformedLengths() { + std::string f; + CHECK(!fieldFrom("abc", f)); // no colon + CHECK(!fieldFrom(":x", f)); // empty length + CHECK(!fieldFrom("2x:ab", f)); // non-digit length + CHECK(!fieldFrom("9:ab", f)); // runs past end + // A 200-digit length cannot accumulate past SIZE_MAX (digit-run cap). + CHECK(!fieldFrom(std::string(200, '9') + ":x", f)); + // Exactly-20-digit values: SIZE_MAX itself passes the accumulate but fails the + // bounds check; one past SIZE_MAX trips the overflow guard. + CHECK(!fieldFrom("18446744073709551615:x", f)); + CHECK(!fieldFrom("18446744073709551616:x", f)); +} + +static void testFailureLatchesOk() { + // After one failed read every subsequent read fails too — the caller may + // check ok() once at the end (the "never a partial value" discipline). + const std::string s = "3:abc"; + wire::Cursor c(s); + std::string f; + CHECK(!c.literal("nope")); + CHECK(!c.field(f)); + CHECK(!c.ok()); +} + +// --- Cursor: fieldInt64 / fieldInt -------------------------------------------- + +static void testFieldInt64AcceptsAndRejects() { + std::int64_t v = 0; + CHECK(i64From(enc("12345"), v)); CHECK(v == 12345); + CHECK(i64From(enc("-42"), v)); CHECK(v == -42); + CHECK(i64From(enc("9223372036854775807"), v)); // INT64_MAX + CHECK(v == 9223372036854775807LL); + CHECK(!i64From(enc("9223372036854775808"), v)); // overflow + CHECK(!i64From(enc("12345678901234567890"), v)); // 20-digit cap + CHECK(!i64From(enc("-"), v)); // bare sign + CHECK(!i64From(enc("1a"), v)); // non-digit + CHECK(!i64From(enc(""), v)); // empty +} + +static void testFieldIntRejectsOutOfIntRange() { + // The fixed form of the old provenance strtol TODO: an out-of-int-range field + // FAILS the parse instead of silently narrowing. + int v = 0; + CHECK(intFrom(enc("2147483647"), v)); CHECK(v == 2147483647); + CHECK(intFrom(enc("-2147483648"), v)); CHECK(v == -2147483647 - 1); + CHECK(!intFrom(enc("2147483648"), v)); + CHECK(!intFrom(enc("3000000000"), v)); +} + +// --- Cursor: fieldSizeT / fieldDouble ------------------------------------------ + +static void testFieldSizeT() { + std::size_t v = 1; + CHECK(sizeFrom(enc("0"), v)); CHECK(v == 0); + CHECK(sizeFrom(enc("4096"), v)); CHECK(v == 4096); + CHECK(!sizeFrom(enc("-1"), v)); // sign = non-digit + CHECK(!sizeFrom(enc(std::string(21, '9')), v)); // 21-digit cap + CHECK(!sizeFrom(enc("18446744073709551616"), v)); // overflow guard +} + +static void testFieldDoubleRoundTrip() { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", 3141.592653589793); + double v = 0; + CHECK(dblFrom(enc(buf), v)); + CHECK(v == 3141.592653589793); + CHECK(!dblFrom(enc("1.5x"), v)); // trailing bytes +} + +// --- parseUnsignedDecimal (the bank_sync generation core) ----------------------- + +static void testParseUnsignedDecimal() { + std::int64_t v = 0; + CHECK(wire::parseUnsignedDecimal("0", v) && v == 0); + CHECK(wire::parseUnsignedDecimal("1721947293", v) && v == 1721947293); + CHECK(wire::parseUnsignedDecimal("9223372036854775807", v) && v == 9223372036854775807LL); + CHECK(!wire::parseUnsignedDecimal("", v)); + CHECK(!wire::parseUnsignedDecimal("+5", v)); // sign rejected (non-digit) + CHECK(!wire::parseUnsignedDecimal("-5", v)); + CHECK(!wire::parseUnsignedDecimal("12a", v)); + CHECK(!wire::parseUnsignedDecimal("9223372036854775808", v)); // overflow + CHECK(!wire::parseUnsignedDecimal(std::string(40, '9'), v)); // long run cannot wrap +} + +// --- core/wire/bytes.h — the LE byte codec (Q-W2v, T4-20) -------------------- + +// putLE emits exactly the bytes the retired hand-rolled putU32le/putU64le emitted +// (LSB first, fixed width) — the FROZEN wire byte order. +static void testPutLEExactBytes() { + std::vector out; + wire::putLE(out, static_cast(0x0403'0201u)); + CHECK(out.size() == 4); + CHECK(out[0] == 0x01 && out[1] == 0x02 && out[2] == 0x03 && out[3] == 0x04); + out.clear(); + wire::putLE(out, static_cast(0x0807'0605'0403'0201ull)); + CHECK(out.size() == 8); + CHECK(out[0] == 0x01 && out[7] == 0x08); +} + +// ByteReader round-trips putLE output, and the double bit-cast is lossless. +static void testByteReaderRoundTrip() { + std::vector out; + wire::putLE(out, static_cast(7)); + wire::putLE(out, static_cast(wire::doubleToBits(-2.5))); + wire::putLE(out, static_cast(0xAB)); + wire::putLE(out, + static_cast(static_cast(-42))); // i64 image + wire::ByteReader r(out); + CHECK(r.peekU32() == 7); + CHECK(r.u32() == 7); + CHECK(wire::bitsToDouble(r.u64()) == -2.5); + CHECK(r.u8() == 0xAB); + CHECK(r.i64() == -42); + CHECK(r.ok); +} + +// Truncation latches ok=false and every subsequent read yields zero/empty — +// the partial-parse contract every codec consumer leans on. +static void testByteReaderLatchesOnTruncation() { + std::vector out; + wire::putLE(out, static_cast(9)); + out.pop_back(); // truncate mid-u32 + wire::ByteReader r(out); + CHECK(r.u32() == 0); + CHECK(!r.ok); + CHECK(r.u8() == 0); // latched: even an in-bounds width now fails + CHECK(r.str(1).empty()); + CHECK(r.peekU32() == 0); +} + +int main() { + testPutLEExactBytes(); + testByteReaderRoundTrip(); + testByteReaderLatchesOnTruncation(); + testPutFieldExactBytes(); + testFieldRoundTripIncludingSeparators(); + testLiteralMismatchFails(); + testFieldRejectsMalformedLengths(); + testFailureLatchesOk(); + testFieldInt64AcceptsAndRejects(); + testFieldIntRejectsOutOfIntRange(); + testFieldSizeT(); + testFieldDoubleRoundTrip(); + testParseUnsignedDecimal(); + + if (g_fail == 0) std::printf("wire: all tests passed\n"); + else std::printf("wire: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}