Compare commits

..

71 Commits

Author SHA1 Message Date
daniel 56e62c826f 1.7.7 2026-10-19 09:58:01 -04:00
daniel a6d7440b2f Docs: record Phase Upsilon W1; carry forward W2/W3 constraints; SEPT_VERIFY section 7 2026-09-25 15:06:44 -04:00
daniel 17711adc3b Merge instrument own-project resolution: ReaSampler 9000 reads samples from its own project, not the focused tab 2026-09-25 15:03:54 -04:00
daniel b0ac961fd9 Merge the bank-root seam: every extension bank-directory lookup goes through bankRootOf 2026-09-25 15:03:42 -04:00
daniel cb6b63cc88 Fall back to focused tab when getReaperParent(3) is null
REAPER may not expose the parent project pointer during plugin creation/setState, so the instrument would silently resolve no sample roots. Only that null-parent case now falls back to EnumProjects(-1); a resolved-but-unlisted or unsaved project still resolves nothing.
2026-09-25 14:59:07 -04:00
daniel 0976ea13a8 Resolve ReaSampler 9000 samples against the instance's own project, not the focused tab
sampleRoots matches getReaperParent(3) among EnumProjects' open projects; every instrument sample read goes through an ordered root list (one root today) so a later temp-bank root slots in after it.
2026-09-25 14:55:50 -04:00
daniel 43994e0d9d Route every extension bank-directory lookup through one bankRootOf seam
The pure bankRootFor(rppPath, unsavedRoot) and the shell bankRootOf(ReaProject*) replace eleven hand-rolled .rpp-parent derivations. No unsaved root is supplied yet, so behaviour and refusals are unchanged. Save-As relocation now reads the stored lastBankRoot_.
2026-09-25 14:53:48 -04:00
daniel 21c21dd8c6 Docs: add Phase Upsilon (temp bank for never-saved projects) to PLAN.md 2026-09-25 14:38:51 -04:00
daniel 8c5a9473f3 Docs: add verify-unsaved-bank.md, the five REAPER experiments gating the temp-bank design 2026-09-25 14:37:49 -04:00
daniel c3e7556261 Docs: record cross-rate playback; finish SEPT_VERIFY sample-rate checks 2026-09-25 14:31:27 -04:00
daniel 783ad7546e Merge cross-rate playback: ReaSampler 9000 plays any file rate at its own pitch and length 2026-09-25 14:29:55 -04:00
daniel 30bc5a9e77 Tidy reasampler_processor.h ceiling, restamp contour publish, tighten pitch-shift test bound
Splits BuiltCapture/sourceRateRefusalText into processor_snapshot.h; fixes stale lock-nesting notes; restamp now also publishes liveContours_ like reload; tightens a slack test bound and one drifted comment.
2026-09-25 14:29:43 -04:00
daniel 648fa515ad Merge dev into ux-w1-t1-sample-rate: take the spline live-drag and plugin-drop work 2026-09-25 14:17:20 -04:00
daniel 64f314a6a4 Fix cross-rate Preserve splices, re-stamp on host-rate change, name the rate refusal
The splice crossfade now counts output frames and stops before the ring's far end (this also fixes a lap at matched rate). The search strides by the rate ratio. The editor takes its time base from its own decode. Same-rate output is pinned to hashes from before the change.
2026-09-25 14:17:11 -04:00
daniel d22def7c13 Docs: record plugin-editor drop; update SEPT_VERIFY plugin-drop checks 2026-09-25 14:16:59 -04:00
daniel 9347c861a7 Merge plugin-editor drop: a card dragged onto a third-party plugin's editor is handed to the OS as a file drop 2026-09-25 14:15:47 -04:00
daniel 7e523b0505 Clean up stale editor-hInstance comments, add isOwnModulePath edge tests
Drops a false claim about ReaSampler's own editor's hInstance, corrects the real reason for registering the wndproc class against our own module, and covers isOwnModulePath's separator edge cases.
2026-09-25 14:15:36 -04:00
daniel faa513cafb Fix SWELL drag guard, channel-fork the VST editor window class, dedupe own-module match
Panel drag no longer misfires on SWELL's always-zero wParam; stable/beta VST editors register distinct window classes against their own module instead of colliding on REAPER's; own-module basename matching moves to a tested pure helper.
2026-09-25 14:11:22 -04:00
daniel 599d525e06 Docs: record live spline-envelope drags; file two follow-ups in TODO.md 2026-09-25 14:04:35 -04:00
daniel 3ecbdb26e2 Merge live spline-envelope drags: held and new notes follow a dragged knot 2026-09-25 14:02:23 -04:00
daniel 4567744b8f Fix spline-drag rollback, bake contour leak, held-note live miss, amp clamp
Editor close mid-drag now rolls back published contours like capture-loss does; bake detaches liveContours so a printed capture can't observe a live drag; a held note now receives a live publish that lands in the same block as a note-on; amp spline read clamped to [0,1].
2026-09-25 13:59:37 -04:00
daniel fee941a83d Exempt ReaSampler 9000's own editor from the plugin OS drag hand-off
A dragged bank card over our own instrument's editor was OLE-dropping like a third-party plugin. Classify our channel's own .vst3 module as Instrument, not Plugin, so it keeps the normal instrument-drop outcome.
2026-09-25 13:57:11 -04:00
daniel 0484599408 Fix stuck drag after OS hand-off withdrawal, split panel_drag_os, doc fixes
Re-checks the physical button before resuming an internal drag and treats a button-up move mid-drag as a release, closing a stale-payload/silent-drop bug; splits the OS hand-off seam into its own TU for the line ceiling; corrects two docs and skips a redundant module-path lookup.
2026-09-25 13:52:28 -04:00
daniel 550a0a86e4 Docs: record Phase Omega's landed work in COMPLETED.md, trim it from PLAN.md 2026-09-25 13:46:10 -04:00
daniel 5151cb41dc Docs: add SEPT_VERIFY.md, the DAW checklist for Aug-Sept work 2026-09-25 13:45:33 -04:00
daniel ec60cf78c6 Play cross-rate captures at their own pitch and length
The voice now converts the file's rate to the host's at note-on instead of assuming they match. Stage times resolve at the host rate, the filter solves at it, and the bake follows. Same-rate renders are unchanged bit for bit.
2026-09-25 13:44:39 -04:00
daniel f7505d0e53 Make spline EG drags audible live: held and new notes follow a dragged knot
Contours ride their own seqlock block into a double-buffered engine copy, and the swap step is smoothed. Each drag move publishes contours without a model write; release still commits and reloads.
2026-09-25 13:42:25 -04:00
daniel fe1aec5925 Hand a card dragged onto a plugin's own editor to the OS as a file drop; withdraw the OLE drag when the pointer returns to REAPER
A code-ownership proof (window class procedure's module, via the pure window_origin) plus REAPER's FX-surface token routes a plugin editor to OLE; FX chrome keeps the instrument drop. The OLE loop re-asks the law each turn, so passing a plugin editor stays reversible.
2026-09-25 13:39:39 -04:00
daniel 0a77b0071d Docs: list PLAN.md and VERIFICATION.md in CLAUDE.md, correct TODO-1.0.md's role 2026-09-25 13:37:06 -04:00
daniel 9ff34b0a9e Docs: record the FX-drain indicator in COMPLETED.md 2026-08-21 16:03:15 -04:00
daniel 67dfa540ee Merge the indicator polish pass: sign-off note lifted, overclaim corrected, duplicated rationale trimmed 2026-08-21 16:01:03 -04:00
daniel 3fa4d10b91 Lift the sign-off DRAFT note, fix footer/cursor CLAUDE.md overclaim, honest-en the readout-width test, trim duplicated rationale
Removed DRAFT marker on approved readout strings; corrected "paint and cursor cannot disagree" to note drag-cue suppression; renamed the width test to state it only bounds character count, not pixels; trimmed restated rationale to its CLAUDE.md/header home.
2026-08-21 15:59:32 -04:00
daniel ae305b6788 Merge the FX-drain busy indicator: a busy cursor and a direction-naming footer readout over fxReclaimActivity 2026-08-21 15:45:19 -04:00
daniel bbdeba9dda Guard the FX-drain busy indicator against silent enum drift and cursor edge cases
Promote drainStateNow's switch to error-on-missing-case locally; set DWLP_MSGRESULT on WM_SETCURSOR; add an HTCLIENT guard; skip refreshBusyCursor during a live drag; mark the docked-dialog WM_SETCURSOR routing premise [verify - DAW].
2026-08-21 15:14:55 -04:00
daniel fbb87316bf Docs: correct two stale line-count figures in the 600-line-ceiling TODO entry 2026-08-21 14:52:48 -04:00
daniel 4acde051b1 Docs: record the Design View mode-switch redesign in COMPLETED.md 2026-08-21 14:50:22 -04:00
daniel 052fbb0d52 Make the background FX drain visible: a busy cursor over the panel and a footer readout that names its direction, driven off fxReclaimActivity 2026-08-21 14:45:25 -04:00
daniel da8401239a Merge the Design View redesign: pure-layer park split, shell restructure, the FX reclaimer, and one undo point per mode switch 2026-08-21 14:14:56 -04:00
daniel 580a41b5ba Merge the undo-span work: one Ctrl-Z per Design View mode switch, and a single undo-block opener for the tree 2026-08-09 14:08:22 -04:00
daniel 258b1b6b85 Remove the temporary undo-span tracing now that the mid-drain Ctrl-Z fix has landed
Deletes the ring-buffer tracer and its diagnostic action; reverts the span
entry points to their pre-tracing no-parameter signatures.
2026-08-09 13:53:56 -04:00
daniel f6f09d983c Close the FX reclaim span when REAPER's own Undo/Redo is dispatched, so the switch's point mints first
Both command hooks intercept Edit: Undo (40029) and Edit: Redo (40030) and consume
nothing. Undo routes that never dispatch an action remain uncovered.
2026-08-09 13:40:28 -04:00
daniel e423bf6502 Buffer undo trace to a ring, add dump action, stop per-tick loadSaveProj spam
Trace lines fill a 4096-line ring instead of hitting the console per line;
DUMP_UNDO_TRACE drains it in one ShowConsoleMsg call. Overflow is marked, not silent.
2026-08-09 13:16:05 -04:00
daniel b782fb2731 Add temporary undo-span tracing for the mid-drain Ctrl-Z bug
One switch (REASAMPLER_UNDO_TRACE) gates every line. Traces span open/close
reasons, FX writes, UndoBlock depth, timer standoffs, and load-state hooks.
2026-08-09 12:28:50 -04:00
daniel d5ff476a19 Make Design View membership edits undoable: the view_state write moves inside the block
All four actions share one body; the point turns on a model diff, so a no-op
leaves history and the Undo button alone. The Save-As prompt stays outside.
2026-08-07 19:51:37 -04:00
daniel 20fef2a3bf Keep the extension's tick out of REAPER's own project load, and re-earn the park holds across one load 2026-08-07 18:03:58 -04:00
daniel 3d95eb715c Undo-block review fixes: exception-safe depth, corrected docs, real test teeth
Fixes ctor exception safety, a ValidatePtr2 guard, false SDK-citation and
enforcement claims, the span-depth invariant, and vacuous fx_reclaim tests.
2026-08-07 17:07:56 -04:00
daniel b7b93a7ac3 One undo-block opener for the whole tree: fourteen sites route through it, and the reclaim span can no longer swallow a user's act
The drain refuses a tick inside a foreign block; applyMode wakes the reclaimer
before it opens the span.
2026-08-07 16:38:11 -04:00
daniel b2f6b3f1d0 Design View: no undo block may open over the reclaim span, and a reapply closes none
Every opener ends the span first, so a tag fired mid-drain no longer hijacks the
switch's point. One stated criterion governs both lane sites; no-op actions discard.
2026-08-07 15:13:52 -04:00
daniel 39529a5ac4 Design View: drop the undo blocks around writes that mint nothing; the membership actions own theirs
Flag writes are silent from a timer, so a block around a reapply or the reconcile
repair could only manufacture a point. The real switch's span is unchanged.
2026-08-07 14:41:17 -04:00
daniel 67d9ec3da9 Put the FX reclaimer to sleep on a project-state load instead of waking it
abandon() clears the wakeup where invalidate() raises it, so File > Revert
cannot converge FX or spend park records against a model nothing re-read.
2026-08-07 14:23:56 -04:00
daniel 39b995e824 Terminate the FX reclaimer's undo span on EVERY project-state load, not just undo
BeginLoadProjectState now routes unbranched through fxReclaimAbandonForStateLoad;
only the session's deferred reload stays undo-only.
2026-08-07 13:57:06 -04:00
daniel 9b6f725747 Close the FX reclaimer's undo span at the state-load hook, and expose the drain's direction
A Ctrl-Z mid-span used to close the pre-undo block over post-undo state; it now
terminates at BeginLoadProjectState and stops the drain where it stands.
fxReclaimActivity reports Online/Offline/Idle; fxReclaimPending is unchanged.
2026-08-07 13:45:58 -04:00
daniel 86aa8dabba Collapse a Design View mode switch and its background FX parking to ONE undo point
applyMode now returns with its undo block open; the reclaimer closes it when the drain
settles. A transport deferral or a tab switch ends it early -- two points, never a leak.
2026-08-07 12:53:42 -04:00
daniel 2173263bb9 Docs: reflect the Design View redesign — EXPOSE replaces the park refusal, FX offlining moved to the reclaimer, undo repair is ours 2026-08-06 23:56:50 -04:00
daniel c9c957a3da Merge Wave 3: the FX reclaimer (per-track atomic drain, generation-owned queue, transport-gated offline, save-time release flush) 2026-08-06 23:35:27 -04:00
daniel 4f6cf60740 Narrow the FX reclaimer's transport gate to defer offline work only; let online drain through, and always run the park clock's observe 2026-08-06 23:04:02 -04:00
daniel c95203718a fx_reclaim: use GetCurrentProjectInLoadSave for the save hook, stop disarming ReleaseFlush on one write, validate project before cached-track lookup 2026-08-06 22:52:57 -04:00
daniel 02790f1270 Gate the FX reclaimer on the transport, both directions; move the release's persist off the drain and onto the project save
The drain no longer dirties a project on its own initiative — a spent park record rides the user's next save via OnSaveExtensionConfig.
2026-08-06 22:31:47 -04:00
daniel 224dcf84df Fix the reclaimer stranding a tab-switched-into project: lift the pin and wakeup into FxReclaimQueue, retarget instead of going idle
Also validate the cached track in its project, and stop a drop-free tick resetting the report memo.
2026-08-06 22:16:41 -04:00
daniel 35fdfca805 Harden fx_reclaim against project closure mid-drain; cache resolved track per item; correct two stale budget comments 2026-08-06 21:36:08 -04:00
daniel 7687ea76fc Persist the reclaimer's FX release so a reload cannot re-drive a spent record, and report the FX-restore drops it was swallowing 2026-08-06 21:26:55 -04:00
daniel 5dcfd3a53f Reclaim drain owns its work: invalidation only bumps a generation, and a generation that moved aborts the tick's commit and its release. 2026-08-06 21:08:34 -04:00
daniel f04c704353 Make an FX reclaim step one whole track's chain: drop the resume cursor, yield only between tracks 2026-08-06 20:48:17 -04:00
daniel 5a35b71dc0 Add the FX reclaimer: a debounced, time-boxed convergence loop that offlines parked chains and restores returning ones off the switch path 2026-08-06 19:07:17 -04:00
daniel a9d2397586 Merge Wave 2: Design View shell restructure (FX off the switch, park/lanes/reconcile/tracks split, record-scoped repair, expose-on-lost-record) 2026-08-06 18:38:40 -04:00
daniel 0f6df5fd6d Design View: expose a park-shaped no-record track visible instead of adopting or refusing it
Adopting silently wipes hand repairs or hides untouched tracks forever; refusing was rejected too. EXPOSE hands the track back and self-heals on the next apply.
2026-08-06 18:35:24 -04:00
daniel 024c619884 Fix Wave-2 review findings: FX union on re-park, per-project reconcile memo, shared undo mask, decayed test oracle 2026-08-06 17:50:49 -04:00
daniel c4038a9e9c Strip FX writes from the Design View mode switch; split view.cpp into park/lanes/reconcile/tracks and add the record-scoped undo repair pass 2026-08-06 17:19:43 -04:00
daniel d455b9a2fa Merge Wave 1: Design View pure-layer split (flag-only park plans, derived FX work list, two-half record, applied-state fingerprint) 2026-08-06 16:54:43 -04:00
daniel b064081a3c Fix five Wave-1 pure-layer FX/park review findings: resume-point stall, stale doc comments, implicit mode read, keying reset 2026-08-06 16:50:43 -04:00
daniel 2423225f0c Split FX out of the park plan: flag-only plans, derived online-first FX work list, two-half record release, applied-state fingerprint 2026-08-06 16:31:59 -04:00
155 changed files with 10962 additions and 3081 deletions
+7 -4
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**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: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
Per-module detail — what each file owns, its invariants — lives in the twenty-seven per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
Per-module detail — what each file owns, its invariants — lives in the twenty-eight per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/PLAN.md`, `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
## Settled decisions
@@ -84,7 +84,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
## Architecture: the load-bearing split
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-seven directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-eight directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
| Directory | Scope |
|---|---|
@@ -114,6 +114,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
| `src/shell/package/` | package filesystem I/O (streaming atomic read/write, exclusive-create landing), the rollback journal, and the REAPER file pickers |
| `src/shell/panel/` | the docked bank-panel shell + the shared LICE draw kit |
| `src/shell/persist/` | project ext-state persistence, prune filesystem I/O, usage scan |
| `src/shell/undo/` | the one REAPER undo-block opener every extension block goes through |
| `src/shell/view/` | Design View mode application shell |
## Directory and namespace layout
@@ -187,7 +188,7 @@ Comments carry *why*, and context where non-obvious — never *what* the code al
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.
4. On unload (`rec == nullptr`), mirror-unregister everything with the same strings prefixed by `'-'`.
- **Non-main sections use a different mechanism.** `gaccel_register_t` carries no section field — `command_id` + `gaccel` can only ever produce a Main-section action. To publish into another section (Media Explorer = 32063, MIDI editor = 32060, MIDI event list = 32061, MIDI inline = 32062), register a `custom_action_register_t{uniqueSectionId, idStr, name, extra}` under `"custom_action"`; it returns the command id, or **0 on failure** (e.g. a duplicate `idStr`) — which the caller must tolerate rather than half-register. `idStr` must be unique **across all sections**, so an action published into both Main and a non-main section needs a SECOND id string; the FOREVER-STABLE contract binds it identically from the moment it ships. `custom_action_register_t` has no `ACCEL`, so a non-main entry ships no default keybinding. Dispatch for these ids arrives through `"hookcommand2"` (`bool(KbdSectionInfo*, int command, int val, int val2, int relmode, HWND)`) — `"hookcommand"` runs for the main section only. The two hooks must partition the ids between them; what happens when a command is claimed by both is unspecified by the SDK (`hookcommand2`'s doc says a `true` return prevents further hooks/actions from running, which is in tension with a clean double-fire either way), so nothing may rely on either outcome. On unload, mirror with `"-custom_action"` `[verify — DAW]` (the header confirms the `-` prefix for "most" registration types and spells out only `-pcmsrc` by name; `custom_action` itself is unconfirmed) and `"-hookcommand2"`.
- **Non-main sections use a different mechanism.** `gaccel_register_t` carries no section field — `command_id` + `gaccel` can only ever produce a Main-section action. To publish into another section (Media Explorer = 32063, MIDI editor = 32060, MIDI event list = 32061, MIDI inline = 32062), register a `custom_action_register_t{uniqueSectionId, idStr, name, extra}` under `"custom_action"`; it returns the command id, or **0 on failure** (e.g. a duplicate `idStr`) — which the caller must tolerate rather than half-register. `idStr` must be unique **across all sections**, so an action published into both Main and a non-main section needs a SECOND id string; the FOREVER-STABLE contract binds it identically from the moment it ships. `custom_action_register_t` has no `ACCEL`, so a non-main entry ships no default keybinding. Dispatch for these ids arrives through `"hookcommand2"` (`bool(KbdSectionInfo*, int command, int val, int val2, int relmode, HWND)`) — `"hookcommand"` runs for the main section only. `hookcommand2` is NOT the mirror-image "every other section" hook, and reading it that way would miss half the traffic: `reaper_plugin.h:214` says it runs prior to every action triggered by a KEY or MIDI event, in ANY section, main included — so a main-section keystroke can reach both hooks, in an order the SDK does not document. The two hooks must partition the ids between them; what happens when a command is claimed by both is unspecified by the SDK (`hookcommand2`'s doc says a `true` return prevents further hooks/actions from running, which is in tension with a clean double-fire either way), so nothing may rely on either outcome. On unload, mirror with `"-custom_action"` `[verify — DAW]` (the header confirms the `-` prefix for "most" registration types and spells out only `-pcmsrc` by name; `custom_action` itself is unconfirmed) and `"-hookcommand2"`.
## Product design docs
@@ -200,8 +201,10 @@ Files: `bank-package.md`, `capture-tail.md`, `code-organization.md`, `code-quali
Plan-style docs live under `docs/`:
- `docs/ARCHIVE.md` — pre-1.0 history, rarely read, not a source of context for current work.
- `docs/COMPLETED.md` — 1.x landed items.
- `docs/PLAN.md` — the active post-1.0 roadmap: phases, waves, tracks, and rulings that implementation specialists are dispatched against directly.
- `docs/TODO.md` — deferred follow-ups with recorded rationale.
- `docs/TODO-1.0.md` — the raw 1.x work list, not yet structured into a plan.
- `docs/TODO-1.0.md` — retained as PLAN.md's verbatim-provenance appendix: Daniel's raw asks and every answer round, unedited, with a traceability table mapping each item onto the PLAN.md track that owns it.
- `docs/VERIFICATION.md` — the in-REAPER hand-test checklist for post-1.0 work no unit test can close, plus per-feature companion scripts (`docs/verify-*.md`, e.g. `verify-track-scope-multitrack.md`, `verify-package-transfer.md`).
## The load-bearing principle
+2 -2
View File
@@ -21,9 +21,9 @@ cmake_minimum_required(VERSION 3.19)
# invariant (reconstruct-from-components inside app_version.cpp) via a permanent synthetic
# "0.9.01" fixture there that must NEVER be bumped on release. It cannot see this line
# becoming a CMake derivation — that is this comment's job.
set(REASAMPLER_VERSION "1.7.5")
set(REASAMPLER_VERSION "1.7.7")
project(reaper_reasampler VERSION 1.7.5 LANGUAGES CXX)
project(reaper_reasampler VERSION 1.7.7 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
+189
View File
@@ -1660,3 +1660,192 @@ v2 blob keeps the behaviour it had rather than losing every FX state.
**Left open, filed to `docs/TODO.md`:** FX-GUID stability itself — whether
`TrackFX_GetFXGUID` survives a chain reorder while parked — is unverified in the DAW
(SWS issue #802 names a specific way it might not hold).
### Phase Ω — Responsiveness, and the control surface's second pass
Ω-W1's five tracks landed together, merged to `dev` as `1159d36` (2026-08-03):
- **Ω-W1-T1 `mode-switch-responsiveness`.** Narrowed the mode-switch undo mask off blanket
`UNDO_STATE_ALL`, bracketed the flag-write phase in a `PreventUIRefresh` RAII pair
(`UiRefreshHold`), and replaced the linear GUID scan with a hash map (`TrackByGuid`, an
`unordered_map`). Also shipped a deferred FX-park queue splitting the synchronous mode-switch
apply from per-FX offline/online. A follow-on split of that drain by kind, **Ω-W2-T4**
(`70bd29d`), was **reverted** (`2f96dd3`): FX-park state was left stranded across save/reopen,
so forced synchronous FX-park drains returned — and with them the mode-switch hitch — until a
re-snapshot-after-discard fix lands.
- **Ω-W1-T2 `mode-switch-persist`.** A mode switch now writes only the `view_state` ext-state
key (`saveViewStateOnly`) instead of the full multi-key session save on every switch.
- **Ω-W1-T3 `meter-rate`.** A second timer (`kMeterTimerIntervalMs = 16` ms, ~60 FPS) drains the
master meter independently of the 500 ms bank/bake poll (`kSyncTimerIntervalMs`).
- **Ω-W1-T4 `deck-reflow-focus-toggles`.** Moved Filter Mod onto the Filter Envelope deck,
centred Trigger's reserved knob-cell runs, converted the two-segment toggles (the
"Envelope"/"Filter"/"Limiter" enables and chrome "Loop") to single buttons, and made clicking
anywhere on an envelope deck focus its overlay.
- **Ω-W1-T5 `overlay-mapping`.** Unified the marker/overlay coordinate mapping with the
waveform draw chain around one `frameToX`/`OverlayArea`; Trigger now hides the loop marks and
caption outright, and Gate-with-loop-off draws them grey-disabled and still grabbable.
Two Ω-W2 tracks landed the same day. **Ω-W2-T2 `content-detect-throttle`** (`84afd1b`)
throttled the panel's new-content enumeration to `kDetectIntervalMs = 500` ms, down from the
~30 Hz timer tick it rode before. **Ω-W2-T5 `snap-radius`** (`aa87d81`) bounded
`nearestZeroCrossing`'s fan-out to `kMarkerGrabWidth` (5 px) and added Ctrl-defeats-snap on a
marker drag; `docs/product/instrument-control-surface.md` §4.1 now documents the gesture.
**Also landed alongside Ω (2026-08-03), outside the plan:**
- `4b19556` **Insert as FX** — a panel button + bindable action loading ReaSampler 9000 onto
the selected track with the focused capture.
- `098727e` **Panel polish** — a crosshair arrange-drop cue, and Re-capture / Insert Conform
moved to the "⋯" overflow menu.
- `a5febbd` **Drag-out hand-off fix** — the card drag stays internal while the pointer is over
REAPER.
**Still open, in `docs/PLAN.md`:** Ω-W2-T1 `view-topology-cache` (measurement-gated on
Ω-W1-T1's landed result; never dispatched) and Ω-W2-T3 `omega-verification-sweep` (the DAW
verification record for Ω-W1; not run).
**Superseded history.** Ω-W1-T1's deferred FX-park queue, a follow-up snapshot guard
(`bd86ede`, 2026-08-05, "refuse to park when the pre-park truth is gone"), and an undo-collapse
fix (`03f7fb5`, 2026-08-06) all landed against the synchronous FX-park approach the Aug 3
revert left in place. **All of it was superseded on 2026-08-21** by the Design View
mode-switch redesign below, which moved per-FX offline/online off the switch path a second
time — onto a background reclaimer — and collapsed the undo footprint through the new
`src/shell/undo/` module.
### Design View mode-switch redesign — FX off the switch path, one undo point
Four waves, merged to `dev` as `da84012`, that took the per-FX offline/online work
off the Design View mode switch's synchronous path and collapsed the switch's undo
footprint to a single point.
The first wave split the pure layer: park plans became flag-only, the FX work list
is now derived separately (new `src/core/view/fx_state.{h,cpp}` — `desiredFxState`,
`fxRestoreTargets`, `resolveFxWork`), the park record's two halves now release
independently (the flag half at restore, the FX half only once the reclaimer has
actually driven the chain back online), and a new `applied_state.{h,cpp}`
fingerprints what the last apply actually drove, for a reconcile pass to diff
against a reloaded model.
The second wave restructured the shell to match: FX writes were stripped out of the
mode switch itself, `src/shell/view/view.cpp` split into `view_park`, `view_lanes`,
`view_reconcile`, and `view_tracks`, and `view_fx_park.{h,cpp}` was deleted
outright. A track that reads park-shaped (flags only) but carries no park record is
now handed back visible with a named console report ("EXPOSE") rather than
silently adopted as a park or refused.
The third wave added the reclaimer: new `src/shell/view/fx_reclaim.{h,cpp}`, a
debounced (`kParkHoldMs`, 3s) and time-boxed (`kOnlineBudgetMs`/`kOfflineBudgetMs`
per tick) background convergence loop that offlines parked chains and restores
returning ones off the switch path. Each track moves atomically per step, the
derived work queue is owned by a generation-counted `FxReclaimQueue` so a mid-drain
invalidation is never applied over stale state, the offline direction (not online)
is gated off the transport, and a completed record's release is held in memory and
only made durable on the project's next save — never on the drain's own
initiative.
The fourth wave collapsed the undo footprint: a new `src/shell/undo/` module holds
the one REAPER undo-block opener, `UndoBlock`, which every extension-owned block in
the tree — thirteen call sites across `shell/actions`, `shell/capture`,
`shell/bank_ops`, and `shell/view` — now routes through, plus `undo_intercept`'s
interception of REAPER's own Undo/Redo dispatch (ids `40029`/`40030`), which ends
the FX reclaim span before REAPER runs the command so the switch's own undo point
mints first rather than being caught mid-drain.
**Left open:** `fxReclaimActivity()` — added in the fourth wave so a UI indicator
could paint the drain's direction — shipped with no caller; the caller landed in
the FX-drain indicator entry below. `src/shell/view/CLAUDE.md` still carries a
`[verify — DAW]` note on whether a redo can re-strand a track (rebuilding the
pre-switch `view_state`, which carries no snapshot for a track that reads parked)
— unresolved.
### FX-drain indicator — the busy cursor and footer readout that give `fxReclaimActivity()` its caller
Merged to `dev` as `ae305b6`, then `3fa4d10`. The background FX-reclaim drain the
mode-switch redesign above left silent now paints: a busy cursor over the bank
panel plus a footer readout that pre-empts the per-mode track count while the
drain is moving, both driven off `fxReclaimActivity()`. `src/core/ui/` gained its
own three-state `FxDrainState { Idle, Online, Offline }` enum, since `core/` may
not include a shell header; the one map between it and the shell's
`FxReclaimActivity` lives in `panel_input`'s `drainStateNow`. Online reads
"restoring FX", Offline reads "parking FX" — both signed off by Daniel.
Three things are unverified, as the tree marks them: whether REAPER's docker
routes `WM_SETCURSOR` to the docked child dialog at all is `[verify — DAW]` in
the code — the premise the whole Windows half of the indicator rests on.
`IDC_APPSTARTING` is Windows-only; SWELL ships no equivalent, so on mac/Linux the
footer readout is the whole indicator. And the readout's fit in the footer's
count box is bounded only by a character count, not a real pixel width, since a
pure test can't measure the proportional font without pulling shell drawing into
`core/ui`; overflow end-ellipsizes rather than breaking layout.
### Live spline-envelope drags: held and new notes follow a dragged knot
Dragging a knot on a Spline-mode amp/pitch/filter envelope in ReaSampler 9000's editor
is now audible during the drag, for both held and new notes — releasing still commits
exactly as before, in one undo point. The three contours travel to the audio thread on
their own seqlock-published, double-buffered block (`core/instrument/engine/live_contours.h`,
`seqlock.h`), separate from `LiveValues` so a knob publish, an automation merge or the
live-block compare never carry the ~9 KB contour payload. Landed in the same merge:
closing the editor mid-drag rolls the published contours back to the pre-grab snapshot
(`removedFromParent`); the resample bake detaches live contours before its own render
(`bake_render.cpp`); a pre-existing bug is fixed where a live-parameter publish arriving
in the same block as a note-on failed to reach the already-held, sounding voices —
`startVoice`'s `refreshLive()` consumed the new generation without applying it to active
voices (`VoiceEngine::startVoice` now calls `applyLiveToActive()`); and the amp spline's read is
clamped to `[0,1]` to absorb a swap-step overshoot from the smoother. Not DAW-verified —
checks are tracked in `docs/SEPT_VERIFY.md` §5.
### Plugin-editor drop hand-off
Dragging a bank card onto a third-party plugin's own editor window (e.g. Serum 2's
oscillator) now hands the file to Windows as an ordinary OS file drop, so the plugin
loads it — the same outcome as dragging the file in from Explorer. REAPER's other
surfaces keep their existing outcomes: dropping on the FX chain list or FX window
chrome still loads ReaSampler 9000, and TCP/MCP/arrange are unchanged. ReaSampler
9000's own editor is exempted from the hand-off (`WindowFacts::ownModule`, a basename
match against the channel's own `.vst3`) — a drop on it still loads the instrument.
The OS hand-off is reversible mid-drag: moving back over REAPER (including after
leaving REAPER for Explorer) hands the drag back to the panel's own drop behavior.
Stock REAPER plugins (ReaEQ etc.) now count as plugin editors for this purpose. Landed
in the same merge: stable and beta ReaSampler 9000 editors previously shared one
Windows window class, so side-by-side installs could run the wrong channel's code —
each channel now registers its own class. Mechanism detail: `src/core/ui/CLAUDE.md`
(`window_origin`) and `src/shell/actions/drag_out_win.h`. Not DAW-verified — checks
are tracked in `docs/SEPT_VERIFY.md` §4.
### Cross-rate playback: ReaSampler 9000 plays any file rate at its own pitch and length
ReaSampler 9000 now plays a sample whose file rate differs from the project rate at its
correct pitch and wall-clock length — previously, e.g., a 44.1 kHz file in a 48 kHz project
played about 1.47 semitones sharp and short. The fix folds a file-rate/host-rate factor
into the read once per note rather than resampling at load; envelope stage times, the
filter, the loop crossfade, the Preserve (time-stretch) engine, and the bake all follow the
same factor (`SampleData::sourceRateRatio`, `play_params.h`; mechanism detail lives in
`src/core/instrument/CLAUDE.md`'s "Two frame domains" section, not restated here).
Same-rate playback at Rate 100% is unchanged, pinned by hashes of pre-change renders.
Landed in the same merge: a Preserve splice-crossfade fix that also changes output at
matched rate for high Rate settings (~200%+) — a down-splice previously read audio a prior
splice had already overwritten; Daniel approved the change. A file more than 32x the
project rate (`kMaxSourceRateRatio`, `play_params.h`) is refused: the ReaSampler 9000
editor title shows "... -- <refusal text>, not played" (clearing once another sample loads
or the project rate makes it supported), and a bake refuses with "cannot render: ...". A
project sample-rate change while the plugin is inactive re-stamps in place rather than
reloading from disk.
Not DAW-verified — checks are tracked in `docs/SEPT_VERIFY.md` §6.
### Υ-W1 — The bank-root seam, and the instrument's own project
**T1 `bank-root-seam`:** a pure `capture::bankRootFor(rppPath, unsavedRoot)` and one shell
resolver, `bankRootOf(ReaProject*)` (`src/shell/persist/bank_root`), now carry every
extension-side bank-directory lookup; `session` stores `lastBankRoot_` for Save-As
relocation. No behaviour change — the only visible delta is that the refused-render console
path prints lowercase on Windows. Detail: `src/shell/persist/CLAUDE.md`.
**T2 `instrument-own-project`:** ReaSampler 9000 now resolves bank-relative sample paths
against its OWN project (`getReaperParent(3)`, matched among the open projects) rather than
the focused tab — fixing an instance in a background tab reading the wrong project's folder.
It falls back to the focused tab only when REAPER reports no parent project. The ordered
candidate-root list (`src/core/instrument/map/sample_roots`) is ready for Υ-W2's temp-bank
root. Detail: `src/shell/instrument/CLAUDE.md`. Not DAW-verified — checks are tracked in
`docs/SEPT_VERIFY.md` §7.
+198 -517
View File
@@ -18,7 +18,9 @@ seventeen: it came from a direct list of seven defects and refinements (Daniel,
2026-08-03), and like Ψ it has **no backing product doc of its own** — the seven are
carried verbatim in its phase header below as Ω.1…Ω.7. It does amend two clauses of
`docs/product/instrument-control-surface.md`, which are edited there rather than merely
contradicted here.
contradicted here. **Phase Υ**, a temporary bank for never-saved projects, is also a direct
request (Daniel, 2026-09-25). Like Ω it has no backing product doc; its settled design is
recorded in its phase section.
## What this doc is, and how it relates to the others
@@ -40,7 +42,7 @@ contradicted here.
**Worktree slug convention:** `p<phase>-w<wave>-t<track>-<slug>`. Greek phase letters
transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**, **Ψ → `psi`**, **Ε → `e`**, **Ρ → `r`**,
**Λ → `l`**, **Ω → `om`**. So Θ-W1-T1 dispatches into `pth-w1-t1-zone-retirement`, Γ-W1-T1 into
**Λ → `l`**, **Ω → `om`**, **Υ → `u`**. So Θ-W1-T1 dispatches into `pth-w1-t1-zone-retirement`, Γ-W1-T1 into
`pg-w1-t1-knob-interaction-law`, Ψ-W1-T1 into `ppsi-w1-t1-capture-range-exactness`,
Λ-W2-T1 into `pl-w2-t1-linux-compile-blockers` (Phase Λ's two audit tracks already ran
under `pl-w1-t1-build-toolchain-audit` and `pl-w1-t2-source-runtime-audit`), and Ω-W1-T1
@@ -160,6 +162,11 @@ phase in this plan with unanswered [Daniel]-class questions; the enumeration in
predates Ω, so read *"scoped to Θ, Ξ, Γ, Ψ, Ε and Ρ"* as **Θ, Ξ, Γ, Ψ, Ε, Ρ and Ω** — seven
phases, all of them fully ruled, and Λ alone excepted.
**Phase Υ (added 2026-09-25) has eight rulings, Υ-D1…Υ-D8, and all eight are settled**
(Daniel, 2026-09-25, every recommendation accepted). It has zero open forks. Its W0
experiments are `[verify — DAW]` gates, not [Daniel] questions, so they don't change the Λ
carve-out above.
**Ruling 3 (Daniel, 2026-08-01) — real units at the host boundary.** *"The parameter values
exposed to the VST host should be in real units, such that the host automation lanes report
usable values."* Satisfied through VST3's **plain-value layer**, not its wire format (which is
@@ -329,6 +336,146 @@ touches them. The instrument adds a sixth surface that binds every Θ track:
---
## Phase Υ — A temporary bank for never-saved projects
**Ships:** Capture, ingest, import, export and the resample bake all work in a project that
has never been saved. Until first save the bank lives in a per-project temp root under
REAPER's default recording path. On first save it is copied into
`<.rpp dir>/reasampler_bank/`, and the index doesn't change, because index paths were
already relative to the root.
**Consolidates: none of the seventeen.** Daniel's direct request, 2026-09-25, verbatim:
*"capture in a never saved project fails now. instead, can we put a temp bank in the default
reaper media folder, which upon initial save (with the copy option checked) will move into
the saved directory bank location?"* There is no backing product doc. The design came from a
staff-engineer design pass (2026-09-25), and its settled form is recorded here in full.
**Zero open forks.** The W0 experiments are `[verify — DAW]` gates, not decisions.
**The design, in five statements:**
- **Bank root.** For a saved project, the root is the `.rpp`'s directory. For an unsaved one
it is `<GetProjectPathEx(proj)>/ReaSampler unsaved banks/<genGuid>/`. Index paths stay
`reasampler_bank/<file>` relative to the root, so the index, the tracking ledger, instrument
sample refs and usage records **never change at first save**. The relative-paths-only
invariant holds for the index unchanged.
- **Lazy temp root.** The root is created by the first act that needs to write (capture,
ingest, import, bake landing). Its absolute path goes in ONE new ext-state key,
`unsaved_bank_root`, and never in the index, the ledger or any ref. The key is read only
when the `.rpp` path is empty, or as a migration breadcrumb. It is cleared, without
dirtying the project, after migration.
- **Ext-state writes on unsaved projects are allowed.** Today they are refused by the
project's own rule (`ext_state_io.cpp`'s `activeSavedProject` / `ensureProjectGuid`), not
by an SDK fact. **This is gated on experiment E1.**
- **First-save migration**, `migrateUnsavedBank(root, projectDir)`, is idempotent and has
two triggers. (1) The existing timer poll sees the first-save transition: path `""` → X on
the same project object and GUID. The GUID is minted without dirtying the project.
(2) At project load, a saved `.rpp` still carries `unsaved_bank_root`. That covers
save-at-close/quit, a crash, and a later session. The migration **copies**, following the
existing relocation precedent. It skips an existing file of the same size, refuses to
overwrite one that differs and reports it, and counts items that still point into temp.
- **The instrument resolves its own project**, not the active tab. It tries candidate roots in
order: the project dir, then the breadcrumb root.
### Rulings — Daniel's, 2026-09-25. Eight, settled (every recommendation accepted).
**Do not re-litigate these.** There is no product doc, so this table is their only home.
| Ruling | What it settled | Bound into |
|---|---|---|
| **Υ-D1** | **Always move the bank on first save, whatever the copy-media checkbox says.** An extension can't observe that checkbox | Υ-W2-T2 |
| **Υ-D2** | **Copy, and keep the temp originals.** No second deletion authority beside prune | Υ-W2-T2 |
| **Υ-D3** | **Arrange items still pointing at temp files after first save get a console notice only.** No automatic re-point | Υ-W2-T2 |
| **Υ-D4** | **The temp root goes under the default recording path** (`GetProjectPathEx` on the unsaved project) | Υ-W2-T1 |
| **Υ-D5** | **The breadcrumb stores the absolute temp path** | Υ-W2-T1 |
| **Υ-D6** | **Leftover temp roots from abandoned or crashed projects are left alone in v1.** A confirmed, dry-run-first "reclaim unsaved-bank leftovers" action in the prune family comes later | Υ-W3-T2 |
| **Υ-D7** | **Drop the Save-As prompts** from offline capture, realtime capture and Design View | Υ-W3-T1 |
| **Υ-D8** | **Allow package import and export on unsaved projects** | Υ-W3-T1 |
### Phase-Υ acceptance criteria
These bind every track in this phase, on top of the plan-wide set above.
- **A saved project's behaviour doesn't change.** For a saved project, the root resolves to
exactly the directory it resolves to today. Υ-W1 is a pure no-behaviour-change refactor and
says so as a criterion.
- **The temp path never enters the index, the ledger, instrument refs or usage records.**
`unsaved_bank_root` is the only place it is stored.
- **Prune is still the only thing that deletes files** (Υ-D2). Migration copies and nothing
else. The temp originals survive until Υ-W3-T2's reclaim action, which is itself part of
the prune family.
- **Migration is idempotent and never overwrites silently.** A second run is a no-op. A file
of the same size is skipped. A file that differs is refused and reported, never
overwritten. Note that this is stricter than today's `relocateBankFolder` (see A1 below).
- **Capture and placement stay separate acts.** Nothing here places an item or re-points
one (Υ-D3).
- **`bankRootFor` is pure and has its own test target**, the same as every pure module.
---
### Υ-W0 — Daniel's REAPER experiments
The script is `docs/verify-unsaved-bank.md`. **E1 gates Υ-W2**, and E2 shapes how it is
built.
| Exp | Question |
|---|---|
| **E1** | Does ext state survive this sequence: unsaved → switch tabs → first save? |
| **E2** | Does `SetProjExtState` on its own dirty the project? |
| **E3** | Is an unsaved project's `GetProjectPathEx` the same across tabs? |
| **E4** | Where do items pointing at temp files end up after save, with copy ticked and with it unticked? |
| **E5** | Does save-at-close give the extension no tick? (If so, the load-time trigger is the only one that catches that case) |
### Υ-W1 — The bank-root seam *(complete)*
Both tracks landed 2026-09-25 — **T1 `bank-root-seam`** (`bankRootFor` / `bankRootOf`) and
**T2 `instrument-own-project`** (the instrument resolves its own project; ordered candidate
roots in `sample_roots`). See `docs/COMPLETED.md`. Nothing to dispatch.
### Υ-W2 — Unsaved ext state, and the migration *(gated on E1; E2 shapes it)*
| Track | Owns |
|---|---|
| **T1** `unsaved-ext-state` | Lift the ext-state refusal for unsaved projects; create the temp root lazily; add the `unsaved_bank_root` key; update the ext-state contract text (Υ-D4, Υ-D5) |
| **T2** `first-save-migration` | `migrateUnsavedBank` and both of its triggers (timer-poll transition, load-time breadcrumb); the console notice for items still pointing into temp (Υ-D1, Υ-D2, Υ-D3) |
Carry-forward constraints from Υ-W1:
- **T1 must not make `bankRootOf` return the temp root before T2's `migrateUnsavedBank` owns
the first-save path.** Today's `session.cpp` Save-As branch classifies first save as
`SaveAsRelocate` and would call `relocateBankFolder`, which copies with `overwrite_existing`
(A1) — violating "never overwrites silently". Either land T1 and T2 together, or guard
`SaveAsRelocate` when the old root came from the unsaved branch.
- **T1: keep the unsaved branch cheap.** `bankRootOf(nullptr)` runs on every panel paint and
audition start; once it reads `unsaved_bank_root` ext state, keep that lookup cheap or cache
it on the session, invalidated on the project-change transition.
- **Instrument side:** `readFromFirstRoot` falls through on a zero-byte read, not only a
missing file — decide deliberately whether an empty project-folder file should fall back to a
same-named temp-root file.
### Υ-W3 — Lift the refusals, then the leftovers
| Track | Owns |
|---|---|
| **T1** `lift-unsaved-refusals` | Every refusal on an unsaved-project surface (Υ-D7, Υ-D8). Closes the `docs/TODO.md` entries "The capture path ignores `saveToActiveProject`'s return at four sites" and "`RunCaptureItemAssign`'s undo point does not follow the pattern its comment claims" |
| **T2** `reclaim-unsaved-leftovers` *(later)* | Υ-D6's confirmed, dry-run-first reclaim action in the prune family |
Carry-forward constraint from Υ-W1:
- **T1: prune's own saved-project check (`prune_fs.cpp`) is not named by any track.** Decide
explicitly whether prune refuses on an unsaved project or re-keys on `bankRootOf` — an open
question for Daniel.
### Pre-existing issues the design pass found — for awareness, NOT in scope
| # | Issue |
|---|---|
| **A1** | `relocateBankFolder` uses `overwrite_existing`, so a Save-As into a folder that holds another project's bank can overwrite files with the same name |
| **A3** | Prune doesn't protect bank files that arrange items reference. The usage scan only looks at instrument FX, so a sample that was placed and then removed can be pruned, which takes the item offline |
| **A4** | Save-As, including first save, marks the project dirty immediately (GUID re-mint plus `MarkProjectDirty` in `session.cpp`) |
| **A5** | REAPER's "Clean current project directory" may offer to delete `reasampler_bank` files `[unverified]` |
---
## Phase Ω — Responsiveness, and the control surface's second pass
**Ships:** A mode switch that draws its result at once instead of two seconds later; a master
@@ -392,11 +539,19 @@ cross-reference.
| **Ω-F5** | **The mode-switch symptom is both entry surfaces**, and it is *"nothing for 2 seconds, then everything switches"* — nothing renders until the whole synchronous body completes | Ω-W1-T1, Ω-W1-T2 |
| **Ω-F6** | **ACCEPTED, and it is a spec amendment: separate the FX parking's UI half from its processing half.** Visibility / routing / FX-enable apply **synchronously**; per-FX offline/online **defers to a later idle tick** — *"the switch doesn't happen during playback anyway… the UI is the priority when toggling modes"* | Ω-W1-T1 |
**SUPERSEDED — Ω-F6 and every deferred-park instruction under Ω-W1-T1 below (the new park-queue TU, its `OnTimer` drain, its `target_sources` line).** The deferral cost a SECOND undo point and could not be made to cost one: REAPER's undo surface has no append-to-point and no block held across a return to the message loop, so per-FX offline/online now runs inline in `applyMode`'s own block and the queue is deleted. The hitch is back on the switch's synchronous path, deliberately. The rest of Ω-W1-T1 (the honest undo mask, compare-before-write, `PreventUIRefresh`, the `resolve` hash map) stands as written. Two of that track's acceptance criteria below are superseded with it: "paints its new state before the park work runs" assumed the split that no longer exists — a switch now parks synchronously before anything paints — and "two rapid switches leave every track in the state the SECOND switch specifies" assumed a queue that could apply both — the synchronous latch REFUSES a switch fired while another is already applying, so a rapid second switch is dropped, not applied.
**Verified by Daniel, not a fork:** the deck knob-spacing defect is in the **Trigger** face.
**Gate is correct and must stay pixel-identical** — an acceptance criterion of Ω-W1-T4, not a
hope.
**Doubly superseded — read this as history, not as current behaviour.** Ω-F6's deferred
FX-park queue (the park-queue TU, its `OnTimer` drain, its `target_sources` line) shipped
with Ω-W1-T1 on 2026-08-03. A follow-on split of that drain by kind, Ω-W2-T4 (`70bd29d`),
was reverted (`2f96dd3`): FX-park state was left stranded across save/reopen, so forced
synchronous FX-park drains returned — per-FX offline/online back to running inline in
`applyMode`'s own block — and with them the mode-switch hitch, until a
re-snapshot-after-discard fix lands. A snapshot guard (`bd86ede`, 2026-08-05) and an
undo-collapse fix (`03f7fb5`, 2026-08-06) then landed
against that synchronous approach. **All of it was superseded again on 2026-08-21** by the
Design View mode-switch redesign (`docs/COMPLETED.md`), which moved per-FX offline/online
off the switch path a second time — this time onto a background reclaimer — and collapsed
the switch's undo footprint through the new `src/shell/undo/` module. Nothing in this
paragraph describes current behaviour; see `docs/COMPLETED.md` for what actually ships.
### Phase-Ω acceptance criteria
@@ -433,458 +588,11 @@ These bind every track in this phase, in addition to the plan-wide set above.
### Ω-W1 — The five defects, in parallel
**Depends on:** nothing. **Five tracks, disjoint at the file level.** Items 2, 3 and 4 are ONE
track on purpose: they land in the same deck modules and the same pinned-geometry test files,
so splitting them means re-deriving `test_deck_groups_measured.cpp` three times. Items 5 and 6
are one track because they overlap inside `paintWaveform`'s mark-draw loop — one deletes and
gates it, the other changes the `mx` it computes.
| Track | Owns |
|---|---|
| **T1** `mode-switch-responsiveness` | `shell/view/view.{h,cpp}` + one new deferred-park TU + its drain in `app/main.cpp` |
| **T2** `mode-switch-persist` | `shell/actions/design_view_actions.cpp` + `shell/persist/ext_state_io.cpp` — dirty-key gating |
| **T3** `meter-rate` | the editor's timer/paint path + the hero-envelope cache |
| **T4** `deck-reflow-focus-toggles` | Ω.2 + Ω.3 + Ω.4 — the deck's composition, layout, paint, input and pinned tests |
| **T5** `overlay-mapping` | Ω.5 + Ω.6 — the waveform overlay's marks and its one coordinate mapping |
**Shared files in the wave, named rather than discovered at merge.** All textual adjacency
unless marked otherwise.
| File | Tracks | Nature |
|---|---|---|
| `src/shell/instrument/reasampler_editor.h` | T3 (meter clock + timer members), T4 (deck focus state), T5 (nothing new expected) | **The one to watch.** Additive member declarations in different sections of a header with ~36 lines of ceiling margin between them. Whichever lands second rebases AND re-checks the line count |
| `src/shell/instrument/editor_paint_waveform.cpp` | T3 (the envelope-cache call site at `paintWaveform`'s bin/`computeEnvelope` block), T5 (the mark, caption and mapping regions below it) | Disjoint regions of one function's body; whichever lands second rebases |
| `src/shell/instrument/editor_controls.cpp` | T4 (`faceLayout`'s `deckDescs` build), T5 (`waveMarksFor` / `grabbableMarks`) | Different functions, no shared state |
| `src/app/CMakeLists.txt` | T1 (one `target_sources` line for the new park-queue TU) | Ω's only CMake edit. **Cross-phase adjacency:** Λ-W2-T2, Λ-W2-T3, Λ-W4-T3 and Λ-W5-T1 all own regions of this file. Ω lands first; Λ rebases |
| `src/app/main.cpp` | T1 (the drain call inside `OnTimer`) | **Cross-phase adjacency:** Λ-W2-T1 owns this file's `REAPERAPI_LoadAPI` failure branch — a different region |
#### Ω-W1-T1 — `mode-switch-responsiveness`
**Goal.** A mode switch redraws immediately. The expensive half of the switch still happens; it
just stops being the thing the user waits for.
**Spec:** Ω.7 and rulings Ω-F5, Ω-F6 above; no product doc.
**Surface boundary — owns:** `src/shell/view/view.cpp` (the `applyMode` body — the undo block,
the park/restore loops, the parent-visibility loop, `resolve`), `src/shell/view/view.h`, ONE
new TU pair beside them for the deferred queue, one `target_sources` line in
`src/app/CMakeLists.txt`, the drain call in `src/app/main.cpp`'s `OnTimer`, and
`src/shell/view/CLAUDE.md`'s FX-parking caveat. **Does not own:** `core/view` (that is
Ω-W2-T1's), `design_view_actions.cpp` or `ext_state_io.cpp` (T2's), `panel_input.cpp`
(Ω-W2-T2's), or `view_solo.cpp`.
**The solo surface index is EXONERATED.** It costs ~2T extra API calls, sub-millisecond. Say so
in the brief so it is not re-litigated: the magnitude is two older O(project) costs that are
invisible on a scratch project and quadratic-feeling on a real one.
**Behavior.**
- **The undo mask (`view.cpp:563`).** `Undo_EndBlock2(proj, undoLabel.c_str(), -1)` — `-1` is
`UNDO_STATE_ALL` (`reaper_plugin.h:1540`), which includes `UNDO_STATE_FX` (`:1542`). REAPER
marshals **every plugin's state chunk in the project** into the undo record, on every switch
**and** every tag/untag reapply. The honest mask is
`UNDO_STATE_TRACKCFG|UNDO_STATE_ITEMS|UNDO_STATE_MISCCFG`, with `UNDO_STATE_FX` OR'd in only
when the park/restore loop actually toggled an offline state this apply. The second
`-1` at `:607` (`mintManagedLanes`) is a separate call site — assess it, do not assume it.
- **Unconditional `TrackFX_SetOffline`.** `parkFxOffline` (`:190-195`) calls it on **every FX of
every parked track**, including tracks already parked and already offline — unloading and
re-instantiating plugins for no state change. `restoreFxOffline` (`:200-204`) writes verbatim
from the snapshot the same way. Both compare before writing.
- **`PreventUIRefresh` is used NOWHERE in `src/`** (verified: zero matches across the tree)
though the SDK exports it (`reaper_plugin_functions.h:5583`). ~10T flag writes land with UI
refresh live. Bracket the write phase, and keep the existing
`TrackList_AdjustWindows(false)` + `UpdateArrange()` as the one deliberate rebuild after the
bracket closes. **Balanced pairing is mandatory** — an early return between the two calls
leaves REAPER's UI frozen.
- **Compare before write.** `applyFlags` (`:182-186`) and the parent-visibility loop
(`:521-531`) write unconditionally, so a reapply that changed nothing still pays every write.
- **`resolve` is a linear scan** (`:122-128`) called once per parked / restored / parent / lane
track — O(T²). `readFolderEntries` (`:103-120`) already builds `handleByGuid` and can hand
back a hash map instead of a vector of pairs.
- **Ω-F6, and it is the headline.** Split the synchronous apply from the deferred park:
visibility, routing and FX-enable apply **inside** `applyMode`; per-FX offline/online is
enqueued and drained on a later idle tick. **The spec must state what the queue does when a
second switch arrives before the first's park has run** — the answer is that a queue entry is
keyed by track GUID and the LATEST intent wins (an enqueued park superseded by a restore
cancels, not stacks), because the two are inverses and replaying both is both slower and
observably wrong. A project close, a track deletion, or a project switch drains the queue
without applying it.
- **Where the queue lives.** `src/shell/view/view.cpp` measures **642 lines** today and is
already over the ~600 ceiling with its seam blocked (`docs/TODO.md`). The queue is therefore
a **new TU beside it**, not more of `view.cpp` — `src/shell/view/` has no `CMakeLists.txt` of
its own, so this costs exactly one `target_sources` line in `src/app/CMakeLists.txt` (where
`view.cpp` and `view_solo.cpp` are already listed). This is the one structural decision in
the track and it moves `view.cpp` toward the ceiling rather than further past it.
- **The invariant amendment.** `src/shell/view/CLAUDE.md`'s "Documented caveat" bullet accepts a
load hitch on the return to the active mode. After this track the hitch is still real but no
longer sits on the switch's synchronous path — the caveat must say so, and must say what the
deferral does NOT change (the plugin still re-instantiates; un-persisted internal state is
still lost).
**Acceptance criteria.**
- On a project with enough tracks and FX to reproduce it, the mode switch **paints its new
state before the park work runs**, and the wall-clock to first paint is recorded before and
after. `[verify — DAW]`, discharged by Ω-W2-T3.
- A tag/untag reapply on an unchanged project performs **zero** `TrackFX_SetOffline` calls and
**zero** redundant flag writes.
- The undo record for one mode switch no longer carries FX state when no offline state changed;
**one switch is still one Ctrl-Z**, and Ctrl-Z still restores what it restored before.
- The park/restore round trip — the phase's trust anchor — still returns every driven flag to
its captured value, including when the deferral means the park lands on a later tick.
- Two switches in rapid succession leave every track in the state the SECOND switch specifies,
with no track left offline that should be online.
- `PreventUIRefresh` is balanced on every exit path from `applyMode`, including the two early
returns already there.
- `view.cpp` is no longer than it is today.
**Prerequisites.** None; concurrent with T2–T5. **Discharges:** Ω.7 (the structural half),
Ω-F5, Ω-F6.
#### Ω-W1-T2 — `mode-switch-persist`
**Goal.** A mode switch stops rewriting the whole session to record that one key changed.
**Spec:** Ω.7's lower-priority half; ruling Ω-F5 (both entry surfaces).
**Surface boundary — owns:** `src/shell/actions/design_view_actions.cpp` (`persistViewState` and
the two callers `doActivateMode` / `doToggleMode`) and `src/shell/persist/ext_state_io.cpp`
(`saveToActiveProject`). **Does not own:** `view.cpp`, `session.cpp`, or any panel TU.
**Behavior.**
- The panel-click path routes through `Main_OnCommand` → `doActivateMode`
(`panel_input.cpp:330-334`), which calls `persistViewState()` →
`saveToActiveProject()` — a **seven-key** ext-state write (`ext_state_io.cpp:140-190`: banks,
the legacy-key clear, view, tail, owned_files, version stamp, bank generation) plus a
`MarkProjectDirty`, every switch, when only `view_state` changed. Serializing the bank book
and the tracking ledger is the expensive part and neither moved.
- **Gate by dirty key, not by call site.** A per-key dirty flag on the session, or a
`saveViewStateOnly` entry point beside the full save — the track picks, and states why. The
full save must remain the default for every other caller.
- **`saveToActiveProject`'s return contract must not change.** `shell/persist/CLAUDE.md` is
explicit: `false` means *no active/saved project and NOTHING was written*, and it may never
grow an observational third failure mode. A narrowed write returns on the same rule.
- The degraded-ledger suppression (`tracking::ledgerDegraded`) and the version stamp both stay
exactly where they are on the full path.
**Acceptance criteria.**
- A mode switch on a saved project writes `view_state` and nothing else; a capture, a bank op
and a project save each still write everything they wrote before.
- `MarkProjectDirty` still fires on the narrowed path — the state DID change.
- Both entry surfaces (the footer segment and the bindable action) take the same path.
- The write-count difference is measured and recorded.
**Prerequisites.** None; concurrent with T1. **Discharges:** Ω.7 (the persist half).
#### Ω-W1-T3 — `meter-rate`
**Goal.** The master meter moves at 60 FPS, and a paint costs what one frame should.
**Spec:** Ω.1; no product doc. `src/shell/instrument/CLAUDE.md` already records that "a
meter-rate timer remains a separate change and is not in" — this is that change.
**Surface boundary — owns:** `src/shell/instrument/editor_platform.cpp` (the timer set-up and
the `WM_PAINT`/`invalidate` path), `editor_session.cpp` (the meter block currently inside
`onSyncTimer`), `editor_paint.cpp` (the paint entry's bitmap and dirty-rect handling), the
hero-envelope cache's accessor in `editor_paint_waveform.cpp`, and `reasampler_editor.h`'s
meter/timer/cache members. **Does not own:** `meter_ballistics.h`, `master_meter`, the
processor, any deck or overlay module.
**Ballistics need no rework.** Every constant in `core/instrument/engine/meter_ballistics.h` is
per-second, and elapsed time is measured rather than assumed — the rate is a UI-cadence
problem, not a ballistics one. **Nothing changes on the audio thread.**
**Behavior.**
- **The cause.** There is exactly ONE timer in the tree: `kSyncTimerIntervalMs = 500`
(`editor_platform.cpp:33`), the bank/bake change-detection poll. The meter's advance +
`invalidate` sit inside `onSyncTimer` (`editor_session.cpp:113-134`), so 2 FPS is the
ceiling, and `WM_TIMER` coalescing under host load takes it below that.
- **The recommended shape: a SECOND timer id on the same child window**, running only the meter
drain, advance and invalidate. **The 500 ms poll body is untouched** — `pollBankSync`,
`bakeAvailable`, `resolveBakeHoldNeeded` and the bake run must not speed up (a bridge read
plus a bank parse each), and the tick-counted UI decays (`kBakeMessageTicks`,
`dropHintTicks_`) keep their meaning for free. **The rejected alternative** is raising the
shared interval and converting those decays to elapsed time: it is one fewer timer and it
silently shortens two user-visible banners by ~15× if the conversion is missed anywhere.
- **`masterBusMeter()` is a CONSUMING drain** (exchange-to-identity) and must keep **exactly
ONE caller**, or the two clocks steal windows from each other. Moving it to the meter timer
keeps that; leaving a copy behind in `onSyncTimer` breaks it.
- **The meter block must stay AHEAD of the in-flight-drag guard.** On its own timer this is
structural rather than positional — state it, because the reason (a drag suppresses the
reload poll but the bus keeps sounding) survives the move.
- **The clock resolution is a real hazard at 16 ms.** Today the elapsed measure is
`GetTickCount64` (`editor_session.cpp:114`), whose granularity is ~15.6 ms — at 60 FPS the
measured delta quantizes to 0 or 16 and a zero-delta advance does nothing but burn a frame.
Move the meter clock to a high-resolution source off the audio thread, and skip the advance
on a zero delta rather than passing it in.
- **Three paint costs, all of which the higher rate exposes:**
1. `invalidate()` (`editor_platform.cpp:65-66`) dirties the **whole client area**
(`InvalidateRect(hwnd, nullptr, FALSE)`) and `paint` never reads `ps.rcPaint`
(`:149-155`). The meter needs its own rect invalidated and the paint needs to honour the
dirty rect.
2. `paint` allocates a **full-size `LICE_SysBitmap` per paint** (`editor_paint.cpp:28`).
Hoist it to a member resized on `WM_SIZE`, where `thumbCache_.clear()` already lives.
3. `paintWaveform` recomputes `computeEnvelope` over the **entire decoded PCM every paint**
(`editor_paint_waveform.cpp:204-212`). The decode is cached (`channelPcmFor` /
`monoPcmFor`); the hero envelope is not. Cache it keyed on sample id + bin count + lane
count, busted by `refreshFromBank` and by a resize — the same discipline `marksCache_` and
`thumbCache_` already use.
- **Target 60 FPS.** 30 is the fallback **only** if the paint-cost work cannot get there, and
choosing it requires the measurement that justifies it.
**Acceptance criteria.**
- The meter visibly tracks transients at the shipped rate; the measured frame interval is
recorded, with the rate chosen and why. `[verify — DAW]`, discharged by Ω-W2-T3.
- A meter-only frame does **not** repaint the deck, chrome or waveform bands.
- No allocation in the steady-state paint path.
- With no editor open, nothing changed: the meter timer is bound to the child window exactly as
the sync timer is.
- The bake affordance, the Hold applicability, the bake message and the drop-hint banner all
behave exactly as before — same cadence, same durations.
- `masterBusMeter()` has exactly one call site.
- The first meter read is still DISCARDED (session history, not a window) and the clip latch is
still NOT discarded with it.
**Prerequisites.** None; concurrent with T1, T2, T4, T5. **Discharges:** Ω.1.
#### Ω-W1-T4 — `deck-reflow-focus-toggles`
**Goal.** Filter Mod sits with the envelope it modulates, the Trigger face's knobs sit at their
natural pitch, a two-state toggle is one button, and clicking an envelope deck anywhere selects
its overlay.
**Spec:** Ω.2, Ω.3, Ω.4 and rulings Ω-F3, Ω-F4. Deck composition law:
`src/core/instrument/ui/CLAUDE.md`'s `knob_deck` and `deck_groups` bullets.
**Surface boundary — owns:** `src/core/instrument/ui/deck_groups.{h,cpp}`,
`knob_deck.{h,cpp}`, `deck_values.cpp` (the toggle commit seam only),
`src/core/instrument/ui/sample_chrome.{h,cpp}`, `src/shell/instrument/editor_paint_deck.cpp`,
`editor_input_deck.cpp`, `editor_paint_chrome.cpp`, `editor_input_chrome.cpp`,
`editor_input.cpp` (the focus-clear arm of the mouse-down dispatch only), `editor_controls.cpp`
(`faceLayout`'s `deckDescs` build only), and `tests/test_deck_groups.cpp`,
`tests/test_deck_groups_measured.cpp`, `tests/test_knob_deck.cpp`. **Does not own:**
`waveform_view`, `envelope_overlay`, `spline_edit`, or any `editor_*_waveform` TU (T5's), and
**no `param/` file whatsoever**.
**Behavior — Ω.2, the move.**
- `sampleDeckGroups` (`deck_groups.cpp`) is the sole deck-composition authority. Move
`kFilterModAmt` out of FILTER's `cellIds` (`:62-68`) into FILTER ENV's (`:80-89`), **in both
faces**, landing **last in the run** — the `kPitchEnvDepth` precedent (`:50-53`).
- **NO VST3 parameter-id change.** `kFilterModAmt` keeps its frozen id (`kParamFilterModAmount`
= 1240) and its unit; ids are keyed on `DeckParam`, never on deck group. `param_id.h`'s
header comment says the blocks run "per deck group in SIGNAL-FLOW order" (`:34`) — that
becomes untrue for exactly one row. **Annotate it; never renumber.** The frozen-table rule is
the whole reason the annotation is the correct fix.
- **This move costs the filter tie-line, and that cost is stated rather than discovered.**
Today `test_deck_groups_measured.cpp` pins both rows' filter groups to one right edge (x=640
block-relative) — the only reason the row block was widened 1020→1028. After the move the
Sound row loses 60 px and the Contour row gains 60, and with the two filter groups then equal
in width the tie is **unreachable at any block width** under the space-between law. The
alignment goes; the ruling stands. Re-derive that fixture from the new contract, record the
loss in it, and **do not change `kDeckRowBlockW` or the editor floor to chase it** — revisiting
the floor is a separate proposal, not this track's to make.
**Behavior — Ω.2, the spacing.**
- The defect is a **layout-algorithm artifact, not a constant**. A group's cell run is a
reserved width, and a `-1` reserve donates its slot to the surviving cells
(`knob_deck.cpp:123-138`), so Trigger's FILTER ENV gets ~100 px cells and AMP ENV ~75 against
the standard 60. Gate is uniform at 60 because it has no reserves.
- **The law: cells stay at `kDeckCellW`; the run is CENTRED in the reserve.** It is the only
candidate satisfying both of Daniel's constraints — group width stays stable across a mode
flip (which is why the reserves exist) and knobs keep their natural pitch. Gate is then
pixel-identical by construction, because with no reserves the centred run and the divided run
are the same run.
- **The two rejected laws, recorded so they are not re-proposed:** *(a) drop the reserves and
let the group narrow in Trigger* — reflows every neighbour on a mode flip, which is the thing
the reserves were introduced to prevent; *(b) keep the division but cap the cell width* —
leaves an asymmetric residue at one end and re-introduces the dead-slot look the division law
replaced.
- A consequence worth stating: once the run is centred, **where a `-1` sits in `cellIds` stops
mattering** — only the count does. The reserves become pure width accounting.
**Behavior — Ω.3, single buttons.**
- Today every deck toggle is structurally two segments (`DeckToggleDesc` `knob_deck.h:69-72`,
`placeToggle` `knob_deck.cpp:82-94`, `hitTestDeck`'s seg0/seg1 `:259-268`, `drawToggle`
`editor_paint_deck.cpp:117-137`). **Two variants replace it:**
- **Enable** — one button, label names what it controls, **`AccentPrimary` when active and
dim gray when off** (Ω-F3, Ω-F4). Applies to: the envelope enable (`kPitchEnvEnable`,
today `Off|On`) → **"Envelope"**; `kFilterEnable` → **"Filter"**; `kLimiterEnable` →
**"Limiter"** (it currently names nothing it controls); and the chrome row's
`Loop Off|Loop On` → **"Loop"**.
- **Mode selector** — one button whose **label reads the current mode**, with **no dim-gray
state** (Ω-F3). Applies to: the three `Stg|Spl` env-mode toggles → **"Stage"/"Spline"**,
and the chrome row's `Mono|Stereo`.
- **A third visual state survives and must not be collapsed into "off":** a control refused
by the mode (the chrome Loop enable in Trigger, `kFilterLaw` while the filter is off) draws
**Disabled and inert**. Off is live and clickable; Disabled is not.
- **Explicitly NOT converted, and that is a boundary rather than an oversight:** `kPlayMode`
(`Gate|Trigger`), `kPitchEngine` (`Varisp|Presrv`), `kVoiceMode` (`Poly|Mono`), `kFilterLaw`
(`Band|Notch`) and `kMonoTrigger` (`Retrig|Legato`) keep their two-segment form. Daniel named
a set; this track ships that set. The mode-selector variant generalizes to them the day he
asks.
- **Three load-bearing risks the implementation must answer:**
1. **A single button carries no `segment`**, so the commit must derive the NEXT state from the
current one. `setDeckParam`'s `segment == 1` contract (`deck_values.cpp:135-162`) is the
seam; `nextOverlaySelection` (`deck_groups.cpp:298-302`) is the existing precedent for
computing a next discrete state purely.
2. **`kLimiterEnable`'s split commit must survive VERBATIM** (`editor_input_deck.cpp:67-79`):
it commits the parameter set and calls `setLimiterEnabled`, which only ARMS the host's
`restartComponent(kLatencyChanged)` for the sync tick to deliver. Nothing on that path may
start calling into the host from inside a mouse handler.
3. **MASTER has ZERO caption slack** — its caption row (46 + toggle + radio) and its knob row
(`kDeckCellW` + `kDeckColumnGap` + `kMeterColumnW`) both measure 130, which is exactly
`kDeckSpanningW` − 2·`kDeckGroupPadX`. **So the "Limiter" button must be ≤ 64 px** (what
the two 32 px segments occupy today). Wider and `kDeckSpanningW` grows, which pushes
`kEditorMinWidth` into the 82 px budget against `kEditorCeilingWidth`. **Every other group
absorbs a wider button free** — verified: each is knob-row-bound with 50+ px of caption
headroom.
- **`hitTestKnobFace` runs no toggle-precedence pass** (`knob_deck.h:217-224`) and is only
correct while no toggle rect overlaps a knob circle. Any button that grows must be re-checked
against that, or given the same precedence order.
**Behavior — Ω.4, the deck as the overlay target.**
- Remove the three overlay radios from the env groups' descriptors. **`DeckRadioDesc` itself
must SURVIVE** — MASTER uses a passive one for the GR lamp (`deck_groups.cpp:140`), and the
`passive` skip in `hitTestDeck` (`:255-258`) is what keeps a readout from growing a gesture.
- Clicking anywhere in an envelope deck — panel background, knob, or button — focuses that
envelope's overlay. The focused deck takes an **`AccentPrimary`** border (Ω-F4). Focus clears
when the user clicks a control surface outside it; **the overlay itself is excluded from
stealing or clearing focus**, so editing the thing you selected does not deselect it.
- **Five design constraints, each of which breaks something if missed:**
1. The focus write must land **before** the `hit.kind` switch in `mouseDownDeck`
(`editor_input_deck.cpp:31-37`) and must **not consume the click**, or every knob grab and
every toggle commit on a focused deck dies.
2. **`DeckHit` carries no group id** (`knob_deck.h:190-195`). Either the shell scans
`dl.groups` for containment or `DeckHit` gains one — the latter is the smaller lie.
3. **`hitTestDeck` returns a miss for a click on group padding** (`knob_deck.cpp:252-278`),
which is exactly the "deck panel background" Daniel wants to hit. Containment against
`g.box` is the test, not the hit kind.
4. **A deck click SETS focus idempotently; it does not toggle to none.** Otherwise every knob
tweak on the focused deck unfocuses it. This retires `nextOverlaySelection`'s
re-click-clears branch (`deck_groups.cpp:301`) and replaces the radio-id map with a
group-id one.
5. **Refuse focus changes while a drag is in flight**, and keep `overlayEnv_` transient view
state with **no persistence path** — it is what is drawn, not what is played.
**Acceptance criteria.**
- **The Gate deck face is pixel-identical to today**, group boxes and cell rects both, asserted
in `test_deck_groups_measured.cpp` — except for FILTER and FILTER ENV, whose widths move by
exactly one cell in opposite directions.
- Trigger's FILTER ENV and AMP ENV cells measure `kDeckCellW`, centred in their reserves, and
each group's box width is **identical in both modes**.
- `kFilterModAmt`'s `ParamId` is unchanged; `exposedParams()` returns the same list in the same
order; no `param/` file is edited.
- Every converted control's commit produces the same audible result as the segment it replaced,
including the limiter's latency-restart arm.
- `kEditorMinWidth` and `kEditorCeilingWidth` are unchanged, and the 82 px budget is unspent.
- Clicking a knob, a button and the bare panel of an envelope deck all focus that overlay; the
first two still perform their own action; clicking the overlay does not change focus.
- The deck's pure fixtures cover the new composition, the centred run and the focus map — the
shell layer is not where any of the three is asserted.
**Prerequisites.** None; concurrent with T1, T2, T3, T5. **Discharges:** Ω.2, Ω.3, Ω.4, Ω-F3,
Ω-F4.
#### Ω-W1-T5 — `overlay-mapping`
**Goal.** The loop marks say what the mode means, and every overlay that rides the waveform
lands on the frame it names.
**Spec:** Ω.5, Ω.6 and rulings Ω-F1, Ω-F2; `docs/product/instrument-control-surface.md`
§6.3(d) and §6.4 **as amended by Ω-F2**.
**Surface boundary — owns:** `src/core/instrument/ui/waveform_view.{h,cpp}`,
`src/shell/instrument/editor_paint_waveform.cpp` (the mark, caption and overlay regions),
`editor_input_waveform.cpp`, `editor_controls.cpp` (`waveMarksFor` / `grabbableMarks` only),
`src/core/instrument/ui/spline_edit.h`'s `splineOverlayBox` and `envelope_overlay`, and
`tests/test_waveform_view.cpp` + `tests/test_component_geometry.cpp`. **Does not own:** any
deck module or `editor_*_deck` TU (T4's), the envelope-cache region of
`editor_paint_waveform.cpp` (T3's), or — **without an explicit proposal** —
`src/shell/panel/draw_kit.cpp` and `src/core/ui/component_geometry.cpp`.
**Behavior — Ω.5, the marks.**
- **Trigger ⇒ the loop pair and the crossfade mark are HIDDEN entirely.** Not Disabled, not
dim: absent. **Gate with loop off ⇒ they are still drawn and still grabbable**, styled **grey
disabled** rather than today's dim teal at `kMarkAlphaDisabled`
(`editor_paint_waveform.cpp:51-53, 285-305`). The drag-to-set-loop gesture survives, which is
the whole reason they stay grabbable.
- **The `"LOOP \xe2\x80\x94 GATE ONLY"` caption (`editor_paint_waveform.cpp:254`) is removed
unconditionally** — in Trigger there is no span left to centre it in.
- **The two sibling captions SURVIVE** (`:255`): `DRAG TO SET LOOP` when the pair is parked and
`LOOP OFF` when a span is retained. Their box derives from the marks' span, which still
exists in Gate, and they carry the one thing the grey styling cannot — *which* off state you
are in, and that dragging will fix it. **This is a [propose]-class call made here rather than
left open**; reversing it means deleting information, not styling.
- **The seams.** `waveMarksFor`'s `present[]` (`editor_controls.cpp:145`) is the correct place
to suppress — it also skips label layout. `grabbableMarks` (`:162`) is the separate
grabbability fold and must NOT follow it into hiding for the Gate-off case.
- **Warning to the track:** `resolveWaveformClaim`'s smallest-area arbitration and `capAtPoint`'s
reverse-order resolve (`editor_input_waveform.cpp:65, 89, 111, 212`) are both sensitive to
marks entering and leaving the set — `capAtPoint`'s reverse order exists precisely so a
coincident pair stays separable and the crossfade (the one mark with no column) can never be
shadowed (`core/instrument/CLAUDE.md`, `waveform_view`). Changing which marks are in the set
changes what those two resolve, which is the regression risk in this half of the track.
**Behavior — Ω.6, the mapping. This is a CLASS defect, not a START-marker instance.**
Two chains that do not agree, verified this pass:
| | Marker / overlay chain | Waveform draw chain |
|---|---|---|
| Area | `waveformOverlayArea` returns the **FULL band, no inset** (`waveform_view.cpp:23-25`) | insets **2 px each side**; `waveformColumnCount` = `width − 4` (`component_geometry.cpp:95-98`), columns start at `box.x + 2` (`draw_kit.cpp:292, 311`) |
| Domain | `[0, N]` **closed** (`clampFrame`, `waveform_view.cpp:15-19`) | `[0, N)` **half-open** (`columnMinMax`, `peaks.cpp:69-71`) |
| Rounding | **round-half-up** (`frameToX`, `:51-58`) | **truncation** |
| Phase | a 2 px marker line centred on an integer (`editor_paint_waveform.cpp:298, :301`) | a 1 px column centred at +0.5 |
Four independent discrepancies. The 2 px inset dominates — frame 0's marker sits 2 px left of
the first column and the error reverses sign toward the end — and the closed domain puts the
last frame one pixel outside the band. **Everything riding the overlay inherits all four:** the
loop span fill, the crossfade wedges, the staged envelope polyline, and the spline contour —
whose `spline_edit.h` guarantee that it uses "the FULL area, no inset, so the drawn contour
stays 1:1 with the sample's time axis" **is currently false by 2 px.**
- **Mandate ONE mapping both chains read**, so the drift cannot recur. Two routes:
- **Inset the overlay** (recommended) — instrument-only, and the overlay is already the
distinct `OverlayArea` type, so the change has one construction site.
- **De-inset the waveform** — `[propose]`, **requires justification before it is taken**: it
touches the shared `draw_kit`, which also serves the docked bank panel and the browse-card
thumbnails, so its blast radius is three surfaces rather than one.
- **The zero-crossing snap must be preserved EXACTLY *by this track*.** `nearestZeroCrossing`
(`waveform_view.cpp:181-210`) fans out symmetrically, probing `t−d` before `t+d` so an
equidistant tie resolves to the lower frame; it runs on the mono cache at drag time and is
exempt for the crossfade handle. **Behaviour-identical after the change** is an acceptance
criterion, not an assumption.
> **Amended, ruling R3 (Daniel, 2026-08-03) — read this as scope, not as a verdict on the
> snap.** This clause bound T5 to change the coordinate mapping WITHOUT perturbing the snap,
> and **T5 met it: the fan-out it received is the fan-out it returned, and its tie-rule
> fixtures pass unmodified.** The snap's behaviour was subsequently changed on purpose by a
> DIFFERENT track — **Ω-W2-T5 `snap-radius`**, which bounds the fan-out to
> `kMarkerGrabWidth` — so a later reader finding a bounded snap in the tree is looking at
> Ω-W2-T5's work, not at a T5 violation. **Bounding which frames are REACHABLE does not
> change how a reachable tie RESOLVES**, which is why T5's fixtures survive it untouched.
- **One question the track must ANSWER, not dodge:** what "sample-accurate" means when
`frames < columns` and one frame spans many pixel columns. A marker must land somewhere
inside its own frame's column span; which end, and whether the inverse `xToFrame`
(`:60-70`) still round-trips, are the track's to specify.
- **The two pinned fixtures are re-DERIVED from the new contract, never re-baselined.**
`test_waveform_view.cpp` and `test_component_geometry.cpp` pin the current mapping by
construction; a test updated to match whatever the code now prints has stopped being a test.
**Acceptance criteria.**
- One mapping function serves both chains; a second one anywhere in the tree fails review.
- Every mark, the loop span fill, both crossfade wedges, the staged polyline and the spline
contour land on the same pixel as the waveform column for the frame they name — at frame 0,
at the last frame, and at an interior frame, asserted in the pure fixtures.
- `frames < columns` and `frames > columns` are both covered.
- The zero-crossing snap returns the identical frame for the identical input, ties included.
**(Scope criterion on THIS track, discharged. Superseded phase-wide by R3 — the snap's
radius bound is Ω-W2-T5's deliberate change, and this track's tie-rule fixtures pass
unmodified under it.)**
- Trigger shows no loop marks and no loop caption; Gate-with-loop-off shows grey marks that
still drag, and dragging one still turns the enable on.
- `docs/product/instrument-control-surface.md` §6.3(d) and §6.4 agree with the shipped
behaviour.
**Prerequisites.** None; concurrent with T1–T4. **Discharges:** Ω.5, Ω.6, Ω-F1, Ω-F2.
**All five tracks have landed** — T1 `mode-switch-responsiveness`, T2 `mode-switch-persist`,
T3 `meter-rate`, T4 `deck-reflow-focus-toggles`, T5 `overlay-mapping` — merged to `dev` as
`1159d36` (2026-08-03). See `docs/COMPLETED.md` for the full narrative. (T1's deferred
FX-park queue was reverted the same day and its downstream mechanics were superseded again
on 2026-08-21 — see the rulings note above.)
---
@@ -894,32 +602,19 @@ stays 1:1 with the sample's time axis" **is currently false by 2 px.**
not to be worth doing, and that is a legitimate outcome. T3's deliverable is a **RECORD, not a
fix** (the Λ-W3 precedent).
| Track | Owns |
|---|---|
| **T1** `view-topology-cache` | `src/core/view/` — tree / `parentOf` / handle-map memoization |
| **T2** `content-detect-throttle` | `src/shell/panel/panel_input.cpp` — the 30 Hz detector's cadence |
| **T3** `omega-verification-sweep` | `docs/VERIFICATION.md` — the DAW record for all of Ω-W1 |
| **T5** `snap-radius` | `src/core/instrument/ui/waveform_view.{h,cpp}` + the marker-drag input path — **spec'd nowhere; see below** |
| Track | Owns | Status |
|---|---|---|
| **T1** `view-topology-cache` | `src/core/view/` — tree / `parentOf` / handle-map memoization | Open — measurement-gated |
| **T2** `content-detect-throttle` | `src/shell/panel/panel_input.cpp` — the 30 Hz detector's cadence | **Landed**, `84afd1b` (2026-08-03) |
| **T3** `omega-verification-sweep` | `docs/VERIFICATION.md` — the DAW record for all of Ω-W1 | Open — not run |
| **T5** `snap-radius` | `src/core/instrument/ui/waveform_view.{h,cpp}` + the marker-drag input path | **Landed**, `aa87d81` (2026-08-03) |
**Shared files in the wave:** none. T1–T3 touch three disjoint directories, and T5 touches a
fourth (Ω-W1-T5's surface, which lands first).
**Ω-W2-T5 has no spec section here, on purpose.** It arrived from ruling R3 — Daniel,
2026-08-03, recorded in `docs/product/single-cycle-and-wavetable.md` §7.1 and summarized at
"Flagged for awareness" item 4 above — after this phase was written, and is **committed on
branch `omega-w2-t5-snap-radius` (`b7b7e88`)**: it
bounds `nearestZeroCrossing`'s fan-out to `kMarkerGrabWidth` (5 px) and adds
**Ctrl-defeats-snap** on a marker drag. **Numbered T5 rather than T4 because it inherits
Ω-W1-T5's file surface; there is no Ω-W2-T4.** Two things it needs from other owners:
- **Flagged for doc-keeper:** its record belongs in `docs/COMPLETED.md`. This plan names it
only so the phase's own superseded criteria can point at what superseded them.
- **Flagged for doc-keeper — a documentation gap, not a defect.** *Ctrl-defeats-snap on a
marker drag* is a new **user-visible gesture** with no home in
`docs/product/instrument-control-surface.md`, and §4.1's exclusion block there — which
excludes waveform markers from the modifier law because *"overloading Shift there would
collide with a shipped gesture"* — now reads as a claim that markers carry no modifier at
all. That block needs amending and the gesture needs documenting.
**T2 and T5 have landed** — see `docs/COMPLETED.md` for the full narrative. T5 arrived from
ruling R3 (Daniel, 2026-08-03, recorded in `docs/product/single-cycle-and-wavetable.md` §7.1)
after this phase was written and inherited Ω-W1-T5's file surface rather than getting its own
spec section here; there is no Ω-W2-T4. Its documentation gap (Ctrl-defeats-snap on a marker
drag, previously unhomed in `docs/product/instrument-control-surface.md`) has since been
closed — that doc's §4.1 now carries the gesture.
#### Ω-W2-T1 — `view-topology-cache` *(measurement-gated on Ω-W1-T1's result)*
@@ -949,39 +644,6 @@ not take that seam here, and do not grow that file.
**Prerequisites.** Ω-W1-T1 landed and measured. **Discharges:** Ω.7's residual, or records it
as not worth taking.
#### Ω-W2-T2 — `content-detect-throttle`
**Goal.** The auto-tag detector stops spending main-thread time 30 times a second on exactly
the projects where the switch is slowest.
**Spec:** Ω.7's third cost.
**Surface boundary — owns:** `src/shell/panel/panel_input.cpp` (`detectNewContent` /
`enumerateLiveGuids` and their cadence inside `bankPanelRefresh`). **Does not own:** any other
panel TU, `view.cpp`, or the auto-tag RULES — only how often they are asked.
**Behavior.** `detectNewContent` (`panel_input.cpp:147`) runs `enumerateLiveGuids` (`:106-135`)
every tick of REAPER's `"timer"` registration (`main.cpp:354`, ~30/sec), regardless of panel
state — O(T + I) REAPER string calls plus roughly 5 allocations per track and 6 per item per
tick (a per-element-type count, not the earlier `~4(T+I)` derivation, which undercounted; the
ratio the throttle buys is unaffected by the correction). Throttle the ENUMERATION, not the
detection semantics: the same GUIDs must still be detected, the first-poll and `reloadPending`
re-baseline guards must still fire before any diff, and the lane-minting pass must still run
only on a tick that tagged. A slower cadence is a latency change to an invisible background
tag, and the track states the chosen interval and why.
**Acceptance criteria.**
- New tracks and items are still auto-tagged into the active mode, and a project reload still
re-baselines rather than mass-tagging.
- The Ρ-W1-T1 rule survives: an added GUID that already carries a membership record is still
dropped, so a render-in-place result is not re-tagged to Design.
- The per-tick allocation count is derived, not measured: it needs live REAPER-side track/item
counts and a runtime allocation trap neither CTest nor this repo's tooling provides.
`[verify — DAW]`.
- The panel's own repaint cadence is unaffected.
**Prerequisites.** None beyond Ω-W1. **Discharges:** Ω.7's polling cost.
#### Ω-W2-T3 — `omega-verification-sweep`
**Goal.** The DAW-observable half of this phase is recorded rather than assumed.
@@ -2300,4 +1962,23 @@ Phase Lambda — ReaSampler on Linux (none of the seventeen; a direct reque
gating a dispatch, W2-T3), L-F3 (copy-only invariant wording — gates nothing),
L-F4 (declared support floor — gates W5, not a dispatch).
Lambda registers NO VST3 parameter, ever — that table is Gamma-W4-T1's and is frozen.
Phase Upsilon — Temporary bank for never-saved projects (none of the seventeen; 2026-09-25)
W0 Daniel's REAPER experiments (docs/verify-unsaved-bank.md) [E1 GATES W2]
W1 The bank-root seam [IN PROGRESS, dispatched 2026-09-25]
T1 bank-root-seam .............. pure bankRootFor + ONE shell bankRootOf(ReaProject*)
replaces ~12 copies; no behaviour change
T2 instrument-own-project ...... own project, not the active tab (a bug fix);
ordered candidate roots
W2 Unsaved ext state, and the migration [GATED: E1; E2 shapes it]
T1 unsaved-ext-state ........... lazy temp root + unsaved_bank_root breadcrumb
T2 first-save-migration ........ idempotent COPY; timer-poll + load-time triggers
W3 Lift the refusals, then the leftovers
T1 lift-unsaved-refusals ....... D7 + D8; closes two TODO.md capture-family entries
T2 reclaim-unsaved-leftovers ... D6, later; prune family, dry-run first
Index paths stay relative to the bank root, so nothing in the index/ledger/refs changes
at first save. D1-D8 SETTLED 2026-09-25; ZERO open forks. Awareness only, not in scope:
A1 relocate overwrites, A3 prune ignores arrange refs, A4 Save-As dirties at once,
A5 Clean-project-dir may offer the bank.
```
+126
View File
@@ -0,0 +1,126 @@
# September DAW verification — new work not yet hand-tested
Checks for work landed or in progress since the last `VERIFICATION.md` pass that no unit test can
close. Build **Release**, install into `UserPlugins/`, restart REAPER. Panel tail toggle = **None**,
project rate 48000, unless a check says otherwise.
## 1. Instrument control-surface pass (landed Aug 3, 2026)
- [ ] Play notes and watch the ReaSampler 9000 editor's master meter — it redraws smoothly (~60 fps)
- [ ] Load a heavy project (several other plugins, high track count) and watch the meter again under
that load — still smooth, no visible stutter
- [ ] Click the single-button "Envelope" toggle on a deck — cycles on/off cleanly; confirm its
disabled-state look when the deck it governs isn't applicable
- [ ] Same check for the "Filter" toggle
- [ ] Same check for the "Limiter" toggle
- [ ] Same check for the "Stage"/"Spline" toggle (the envelope mode selector — one button reading
either "Stage" or "Spline")
- [ ] Click anywhere on an envelope deck's control section (not just a knob) — that envelope becomes
selected for editing on the waveform overlay
- [ ] Switch the instrument to Trigger mode — the loop markers and their caption disappear from the
waveform entirely
- [ ] Switch to Gate mode with looping off — the loop markers still show, greyed out, and are still
grabbable
- [ ] Zoom in on a loop marker and drag it near a zero crossing — it snaps only within a small pixel
radius of the crossing, not from anywhere on the waveform
- [ ] Hold Ctrl while dragging a marker near a zero crossing — snapping is disabled for that drag
## 2. Bank panel additions (Aug 3)
- [ ] With a track selected and a bank card focused, click "Insert as FX" — ReaSampler 9000 loads on
that track with the focused sample already loaded
- [ ] Open the panel's top-toolbar "⋯" overflow menu — Re-capture and Insert Conform are both there
and both work
- [ ] Drag a bank card over the arrange view — the cursor shows a crosshair cue while over a valid
arrange-insert target
## 3. Design View FX parking (Aug 21)
- [ ] Trigger a mode switch that parks/restores FX on a heavy chain, and watch the docked bank
panel while the reclaimer drains: the cursor goes busy over the panel and the footer reads
"parking FX" / "restoring FX" appropriately. If the cursor never changes, note it — REAPER may not
route `WM_SETCURSOR` to the docked panel at all (`src/shell/panel/CLAUDE.md` Gotchas)
- [ ] Native drag-reorder a parked track's FX chain, switch modes, and confirm each plugin's state
restores to the SAME plugin it came from (not swapped by slot)
- [ ] Do the same reorder via an SWS move command
- [ ] Save the project after a reorder, reopen, then switch modes — restore still keys to the right
plugin
- [ ] Reorder a chain holding two copies of the same plugin — each copy's state restores to itself,
not swapped with its twin
(`docs/TODO.md` §"FX-GUID stability for the park's restore is unverified in the DAW")
- [ ] For mode-switching itself (solo-set restore, transport refusal, persistence, undo point count,
etc.) — see `docs/VERIFICATION.md` §"Mode switching"; not repeated here
## 4. Drag a card into a plugin's editor (landed Sept 2026)
- [ ] Drop a bank card on Serum 2's oscillator in its floating FX editor window — Serum loads the
sample
- [ ] Drop a bank card on Serum 2's oscillator from the FX chain window — same, Serum loads the
sample
- [ ] Drop a bank card on the FX chain list or window chrome (not the oscillator) — ReaSampler 9000
loads as before, unchanged
- [ ] Drop a bank card on the TCP, MCP, or arrange — unchanged from current behavior
- [ ] Start a drag over a plugin editor, then move back over the arrange mid-drag — the drag reverts
to the panel's own drop behavior (may only visibly update on the next key press or mouse-button
change)
- [ ] Drag a card out to Windows Explorer and back into REAPER — control hands back to the panel's
own drag correctly
- [ ] Drop a card onto an already-open ReaSampler 9000 editor — it loads ReaSampler 9000 onto the
track (no banner, no OS drop)
- [ ] Drop a card onto ReaEQ's editor — shows a no-drop cursor, nothing loads
- [ ] Drop a card onto a plugin editor that doesn't accept files (e.g. a compressor) — note whether
it is refused (no-drop cursor, nothing loads) or lands in REAPER's own file import; either outcome
is accepted, but record which one actually happens
- [ ] Run stable and beta ReaSampler 9000 side by side and open both editors — each works normally,
no crash
## 5. Live spline-envelope drags (landed Sept 2026)
- [ ] Set the amp envelope to Spline, hold a note, and drag a knot — the sound audibly follows the
shape change during the drag itself
- [ ] Same check for the pitch envelope
- [ ] Same check for the filter envelope
- [ ] After dragging a knot, trigger a new note — it uses the dragged shape
- [ ] Listen closely during a fast drag for any audible glitch beyond what an ordinary knob move
would cause (clicks, zippering)
- [ ] Drag a knot on a held note and listen for an audible pitch/amp/filter glide as it moves, not a
stepped jump
- [ ] Release a dragged knot — commits exactly as before: one undo point, and reopening the project
keeps the dragged shape
- [ ] With a note held, add a new point to the spline — audible immediately
- [ ] With a note held, delete a point from the spline — audible immediately
## 6. Sample-rate playback (landed Sept 2026)
- [ ] In a 48 kHz project, import a 44.1 kHz file via the Media Explorer into ReaSampler 9000; play
its root note — same pitch and length as the file auditioned in the Media Explorer. Repeat in
Preserve mode.
- [ ] Same with a 96 kHz file in a 44.1 kHz project
- [ ] Envelope attack times, loop (a single-cycle loop holds its pitch; loop crossfade length) and
filter cutoff sound the same as on a same-rate copy of the file
- [ ] Bake a mismatched-rate sample — the baked result has the same pitch and length as what you
heard
- [ ] Change the project sample rate with ReaSampler 9000 loaded (and with it inactive/bypassed,
then re-enabled) — the sample still plays at correct pitch afterwards
- [ ] Existing projects that used mismatched-rate samples WILL sound different now (correct pitch)
— reopen one and confirm it's the expected change
- [ ] Same-rate samples sound exactly as before
- [ ] In Preserve mode at matched rate, set Rate to ~200% — sounds clean (this changed with the
splice-crossfade fix; confirm it's an improvement, not a regression)
- [ ] Load a file more than 32x the project rate (e.g. a 384 kHz file in an 11.025 kHz project, if
one is available) — the editor title shows the refusal message, the sample plays silent, the
message clears when another sample is loaded, and a bake shows "cannot render"
- [ ] The refusal text is not truncated at the default editor width
## 7. ReaSampler 9000 finds samples in its own project (landed Sept 2026)
- [ ] Open two projects in tabs, each with a ReaSampler 9000 on a different bank sample; switch
tabs — each instance plays its own sample, including the one in the background tab
- [ ] Open a saved project containing ReaSampler 9000 into a NEW tab — the instance plays (not
silent)
- [ ] Same, via File → Open into the CURRENT tab
- [ ] Render the project via the render queue — the instrument is audible in the render
- [ ] Render a project that is used as a subproject — the instrument is audible
For the last four: if any comes up silent until the sample is re-selected, report it (a retry is
the planned fix).
+50 -19
View File
@@ -2,20 +2,6 @@
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
## The per-voice filter is solved against the WAV's sample rate, not the render rate
**Context (what shipped — Θ-W2-T1, the filter in the voice path).** `Voice::start` sets `filterRate_ = sample.sampleRate` — the rate read off the **decoded WAV header** — and hands it to `VoiceFilter::prepare()` and every later `setCutoffNorm()`. But the voice emits exactly one frame per **host** frame, so the rate the corner should be solved against is the project/render rate the processor already latches in `setupProcessing` (`ReaSamplerProcessor::sampleRate_`), not the file's. The rate enters the DSP only through `g = tan(pi*fc/sr)` (`engine/filter/CLAUDE.md`), so a wrong `sr` scales the realized corner by exactly the ratio of the two rates.
**The wart.** When capture rate ≠ project rate, the corner lands at the wrong frequency, by that ratio. A 44.1 kHz capture in a 48 kHz project puts the corner roughly **1.5 semitones sharp** (48000/44100 ≈ 1.088×); the same capture in a 96 kHz project is roughly **13.5 semitones off**. The Nyquist clamp (`kFilterNyquistFraction`) measures against the wrong Nyquist for the same reason. This falsifies the guarantee `filter_params.h` states in its own words — that the persisted value is a normalized knob position precisely so one preset does not sound different at 44.1k and 96k. The control law honors that; the solve defeats it.
**Intended fix.** Thread the host render rate onto `SampleData` and set `filterRate_` from it. The processor already holds `sampleRate_` from `setupProcessing` and already guards on it being non-zero before building, so the value is available at exactly the point `SampleData` is constructed — this is a plumbing change, not a new mechanism.
**The constraint the fix MUST handle.** The engine **already conflates the two rates everywhere** — `sample_map` resolves the AHDSR's stored seconds at the WAV's own rate, and nothing resamples the source — so a cross-rate capture already plays back sharp *and* short by the same ratio. This is an inherited assumption, not a defect introduced by the filter; the filter is simply the first module where it lands as an audible **frequency** error rather than a timing one. A fix that corrects only the filter leaves the filter rate-correct while envelope timing stays rate-wrong. That is strictly less wrong and defensible, but it splits one assumption into two, and the split must be a deliberate choice rather than a side effect of fixing the loudest symptom. Second constraint: `filterRate_ <= 0` must keep meaning **bypass** — the filter module forbids a reference, calibration, or fallback rate anywhere in itself, and a plumbing fix must not smuggle one in as a default.
**Priority / risk.** Deferred by ruling — Daniel, 2026-07-30: *"record this and proceed."* Inaudible whenever capture rate == project rate, which is the common case for captures this tool made in the project they belong to. Audible and large on an imported or cross-rate capture, and worse the further the two rates diverge.
**Done looks like.** The realized filter corner matches `filterCutoffHzFromNorm(pos)` within measurement tolerance at every combination of capture rate and project rate; the Nyquist clamp measures against the render rate; and the decision about whether envelope timing follows the same correction is recorded rather than left implicit.
## Filter ring-out is truncated on the source-exhaustion path
**Context (what shipped — Θ-W2-T1).** The per-voice filter runs between the pitch stage and the amp multiply. When `readPos_` runs past the end of the sample with no usable loop, `Voice::advanceFrame` latches `active_ = false` and returns 0 — the voice stops feeding, and whatever energy remains in the filter's two integrators is discarded rather than rung out.
@@ -852,7 +838,7 @@ in the `.rpp`", and does not gain a second home for the distinction.
**Context (surfaced by the FX-GUID keying track).** Root `CLAUDE.md`'s structural
heuristics put an ~600-line ceiling on any one file, with a documented responsibility
seam as the required method for splitting it, not an arbitrary bisection.
`src/core/view/view_mode_model.cpp` measures **815 lines** (verified this pass),
`src/core/view/view_mode_model.cpp` measures **857 lines** (re-measured 2026-08-21),
up from 715 before the FX-GUID keying track's v2 schema addition made it worse.
**The named seam.** The JSON codec — `serialize()`/`deserialize()` — wants its own
@@ -867,9 +853,9 @@ public accessor surface. Doing that in the same commit that changed the byte for
the golden test literals pin would roll a format change and a codec extraction
together, which is the riskier order.
**`src/shell/view/view.cpp` is no longer over the bar.** It measures **594 lines**
**`src/shell/view/view.cpp` is no longer over the bar.** It measures **248 lines**
(`wc -l`, re-measured after the undo collapse moved one track's whole park/restore —
snapshot, flags and per-FX offline — into `view_fx_park`'s `parkTrack`/
snapshot, flags and per-FX offline — into `view_park`'s `parkTrack`/
`restoreTrack`). Recorded because the entry above used to name it as a second
over-ceiling file: if it grows again, note that a further seam there is blocked
not by a private-state/friend question but by a build file another team owns —
@@ -885,7 +871,7 @@ sidestepped), dropping the file under the ~600-line ceiling.
## FX-GUID stability for the park's restore is unverified in the DAW
**Context.** The Design View park/restore FX keying (`restoreTrack`,
`src/shell/view/view_fx_park.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that
`src/shell/view/view_park.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that
survives a chain reorder while a track is parked. SWS issue #802 reports that after
`SNM_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin
(`SNM_PreObjectState()` → `RemoveAllIds()`) — if that still holds, an SWS-driven
@@ -896,7 +882,7 @@ operation this keying targets.
instances of the same plugin.
**Already flagged in code — this entry is the tracked home, not a restatement.**
There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view_fx_park.cpp` and
There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view_park.cpp` and
a note in `src/shell/view/CLAUDE.md`'s Gotchas; point at them rather than restating
them in full.
@@ -906,3 +892,48 @@ them in full.
two-instance-of-the-same-plugin case are each observed in a live REAPER session, and
either the identity is confirmed to survive all four, or a degradation is found and
the keying is amended.
## Spline-drag abort exits republish the editor's snapshot, not the processor's model
**Context (what shipped — the live spline-envelope drags track).** Both spline-drag abort
exits — `ReaSamplerEditor::removedFromParent` and the `WM_CAPTURECHANGED` handler in
`src/shell/instrument/editor_platform.cpp` — roll a drag back by restoring
`params_ = dragStartParams_`, the editor's own pre-grab snapshot, then republishing
contours from it.
**The wart.** `dragStartParams_` is a snapshot taken by the editor at mouse-down. If
`setState` or a bake reload lands mid-drag — either of which can rewrite the processor's
own model out from under the editor — the rollback re-couriers the STALE snapshot over
the model rather than the model's own current value, silently reverting whatever landed
during the drag.
**Intended fix.** Have the processor republish from its own params on these two abort
paths, rather than the editor courier-ing its own stale pre-grab copy.
**Priority / risk.** Not yet triaged.
**Done looks like.** A `setState` or bake reload landing mid-drag survives a subsequent
capture-loss or editor-close abort intact — the abort rolls the drag back without
clobbering the intervening model change.
## `reasampler_processor.h`'s preview mailbox is a candidate seam for the ceiling exception
**Context (what shipped — the live spline-envelope drags track).** `reasampler_processor.h`
is a documented ~600-line-ceiling exception (`src/shell/instrument/CLAUDE.md` Gotchas):
after `processor_snapshot.h` and `automation_channel.h` were split out, what remains is
the class declaration itself, plus a handful of lines over the ceiling since the
live-contour block landed beside `liveParams_`.
**The wart.** No further seam was found that isn't behaviour — except one candidate not
yet taken: the preview mailbox (the packed-sequence post/consume pair) looks like it could
move into its own header-inline `PreviewMailbox` aggregate without adding indirection to
`process()`'s access.
**Intended fix.** Revisit once the pending sample-rate branch lands — it is expected to
add roughly 10 more lines to the file, which may tip the decision.
**Priority / risk.** Not yet triaged.
**Done looks like.** Either the preview mailbox is split into its own header-inline
aggregate with `process()`'s access left zero-cost, or the file is re-affirmed as a
documented exception once the sample-rate branch's size impact is known.
+8 -5
View File
@@ -47,15 +47,18 @@ Checks for Θ, Ξ, Ψ, Ε, Ρ, Γ, and Ω work that no unit test can close. Buil
- [ ] Solo tracks in Arrange, switch to Design, solo different tracks, switch back — each mode restores its own solo set verbatim (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] Attempt a mode switch while the transport is playing, then while recording — both refuse, visibly (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] Click the footer mode segment, save, reopen the project — the mode persisted (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] `[verify — DAW]` Reproduce the strand: project with FX in both modes, switch to Design, save, close, reopen, toggle to Arrange. Toggle back and forth several more times — the arrangement's FX must NOT converge on permanently offline/hidden. A track the tool refuses to park is NAMED in the console, and the same refusal is not reprinted on a later reapply unless the refused set changed (`src/shell/view/view_fx_park.h`'s `decidePark` / `reportRefusedParks`)
- [ ] `[verify — DAW]` **ONE switch, ONE point.** In a project with several FX across several tracks, switch modes once and count the undo points REAPER shows (Ctrl-Z tooltip / undo history): expect exactly ONE, `ReaSampler: activate <mode> view` — never two, and never one per FX. Then press Ctrl-Z once: the flags unpark and EVERY FX comes back online, in that single step. Verify the EFFECT, not merely that one entry exists — a mask that failed to pick up `UNDO_STATE_FX` would still show one correct-looking entry while leaving every plugin offline, which reads as a pass and isn't. Then Ctrl-Y: the redo re-parks all of it, also in one step (`src/shell/view/CLAUDE.md` §Invariants — "ONE mode switch is ONE undo point")
- [ ] `[verify — DAW]` Reproduce the strand: a track with no park record whose live chain already reads park-shaped (hand-offline a plugin, or drop the tool's record via undo/redo), then switch modes — the track is driven visible, in-mix and FX-enabled (not parked), and NAMED in the console once. Switch again with nothing else changed and confirm the same message does NOT reprint (`src/shell/view/view_park.h`'s `parkTrack` / `reportExposedTracks` / `shouldReport`)
- [ ] `[verify — DAW]` **ONE switch, ONE point.** In a project with several FX across several tracks, switch modes once and count the undo points REAPER shows (Ctrl-Z tooltip / undo history): expect exactly ONE, `ReaSampler: activate <mode> view` — never two, and never one per FX. Then press Ctrl-Z once: the flags and lane state unpark in that single step — FX are the reclaimer's and are not part of this undo point (see the item below on undo and FX). Then Ctrl-Y: the redo re-parks the flags and lane state, also in one step (`src/shell/view/CLAUDE.md` §Invariants — "ONE mode switch is ONE undo point")
- [x] **VERIFIED — the discard-block mint-suppression premise.** Ten unblocked `TrackFX_SetOffline` calls minted ten undo points; the same ten calls wrapped in `Undo_BeginBlock2(p)` … `Undo_EndBlock2(p, "", 0)` minted zero. This is what the reclaimer's per-write discard block (`fx_reclaim.cpp`'s `writeOffline`) and the off-switch FX design both rest on — no longer an unobserved inference (`src/shell/view/CLAUDE.md` §Invariants — "ONE mode switch is ONE undo point")
- [ ] `[verify — DAW]` Whether a `SetProjExtState` issued from inside `SaveExtensionConfig` reaches the `<EXTSTATE>` block of the file being written now, or lands one save later (`src/shell/view/fx_reclaim.h`'s `ReleaseFlush`, `fx_reclaim.cpp`'s `fxReclaimFlushRelease`)
- [ ] `[verify — DAW]` Whether an undo (Ctrl-Z) of a mode switch now restores visibility correctly, now that FX offline/online moved off `applyMode`'s block onto the reclaimer — a Ctrl-Z rolls back only the flags and lane state, not whatever the reclaimer had (or had not) converged (`src/shell/view/CLAUDE.md` §Gotchas — "An UNDO of a mode switch no longer rolls back FX at all")
- [ ] `[verify — DAW]` **Where the `view_state` lands on undo AND redo, and whether the redo strands a track.** `persistViewState` writes the ext state AFTER `applyMode`'s block closes, so the point never saw the new `view_state` — the undo direction is expected to come out right by position, the redo direction is the open one. Use a project with FX on the tracks that park. After the Ctrl-Z above, read the footer segment: it must show the mode the user came FROM, and a further switch must bank solos under that mode id. Then Ctrl-Y and read it again: if the tracks re-park while the footer still shows the pre-switch mode, the redo restored the stale ext state and model-vs-project is out of step. **Then switch modes ONCE MORE and read the console** — the hypothesised consequence is a `left unparked` refusal line naming those tracks, because the redo-restored model carries no snapshot for a chain that reads parked. A clean switch with no refusal line kills the hypothesis; the line appearing confirms a NEW refusal route and the persist position has to be reopened. Report which, and the footer state at each step. Do NOT fix by moving `persistViewState` inside the block — it can open a modal Save-As (`src/shell/view/CLAUDE.md` §Gotchas)
- [ ] `[verify — DAW]` **No point on open.** Open a project saved in Design mode with every inactive leaf already fully parked (flags AND FX), and check the undo history: the load-tick reapply writes nothing, so it must add NO undo point at all — the `("", 0)` discard form. This only holds when the saved state really was fully parked; a first open after tagging a new leaf, an FX added to a parked track, or a plugin hand-onlined since the last save all have real state to write and legitimately mint one correct point. An entry appearing on a project that WAS fully parked means the discard is not working — and since `mintManagedLanes`' no-op path rests on the same idiom, check that alongside it (`src/shell/view/view.h`'s `applyMintsUndoPoint`)
- [ ] `[verify — DAW]` **The hitch now sits on the switch.** On a project with heavy plugins (convolution, a loaded sampler) across several parked tracks, time the mode toggle: by design the action does not return until every plugin has unloaded/reloaded. Report roughly how long, and whether the UI recovers cleanly afterwards — the whole write phase runs under one `PreventUIRefresh(1)` hold (`src/shell/view/CLAUDE.md` §Invariants — the documented caveat)
- [ ] `[verify — DAW]` **Nested blocks around the FX writes.** Two directions, both undocumented in the SDK header. (a) `render_in_place` calls `applyMode` inside its OWN undo block, so its single `ReaSampler: render selected track to a new track` point should now absorb the reapply's FX moves — run that verb from Design mode over a track with FX and confirm ONE point, not two, and that Ctrl-Z reverses all of it. (b) The inverse: fire a mode-switch action WHILE a switch's plugin loads are in flight (a heavy chain widens the window) — `applyMode` is latched, so the expected outcome is that the second switch is DROPPED entirely and silently; the history must show one point, and the footer must still read the first switch's target. Re-firing it afterwards must work normally. (c) The same window, but let `OnTimer` -> `bankPanelRefresh` -> `mintManagedLanes` run into it: that path is NOT latched and opens a block of its own, so check the history for a split or misordered point (`src/shell/view/CLAUDE.md` §Gotchas)
- [ ] `[verify — DAW]` **The switch itself returns fast; the hitch moved to the reclaimer.** On a project with heavy plugins (convolution, a loaded sampler) across several parked tracks, time the mode toggle action itself — it no longer waits on any plugin load/unload, so it should return near-instantly. Then watch the background reclaim: once a track has read continuously parked for `kParkHoldMs` (3s), the reclaimer moves it in budgeted ticks (`kOnlineBudgetMs`/`kOfflineBudgetMs`) — report whether those ticks are noticeable/disruptive to the UI while they run (`src/shell/view/CLAUDE.md` §Invariants — the documented caveat, `fx_reclaim.h`'s `kOnlineBudgetMs`/`kOfflineBudgetMs`)
- [ ] `[verify — DAW]` **Nested blocks around the flag/lane writes.** Two directions, both undocumented in the SDK header (`applyMode` no longer writes FX at all — that moved to the reclaimer, off this block entirely). (a) `render_in_place` calls `applyMode` inside its OWN undo block, so its single `ReaSampler: render selected track to a new track` point should absorb the reapply's flag/lane writes — run that verb from Design mode over a track with FX and confirm ONE point, not two, and that Ctrl-Z reverses all of it. (b) The inverse: fire a mode-switch action WHILE a switch's plugin loads are in flight (a heavy chain widens the window) — `applyMode` is latched, so the expected outcome is that the second switch is DROPPED entirely and silently; the history must show one point, and the footer must still read the first switch's target. Re-firing it afterwards must work normally. (c) The same window, but let `OnTimer` -> `bankPanelRefresh` -> `mintManagedLanes` run into it: that path is NOT latched and opens a block of its own, so check the history for a split or misordered point (`src/shell/view/CLAUDE.md` §Gotchas)
- [ ] `[verify — DAW]` **The load reapply is never lost to the latch.** Open a project saved in Design mode with heavy plugins on parked tracks (so an apply is long) while another apply can overlap — e.g. trigger a project open from an action fired during a switch. The saved mode must still be applied: the load glue tests `modeApplyInProgress` before spending its one-shot signal and retries on the next timer tick, so a delayed-by-a-tick reapply is a pass and a never-applied one is a failure (`src/app/main.cpp`'s load glue, `src/shell/view/view.h`'s `modeApplyInProgress`)
- [ ] `[verify — DAW]` **A tab switch mid-apply cannot split the block.** `applyMode` pins the project once at entry and threads it through `Undo_BeginBlock2`/`Undo_EndBlock2`/`ValidatePtr2`. With a long switch in flight, switch project tabs (the pump premise makes this reachable) and confirm the undo point lands on the project the switch started in, with nothing written into the other tab (`src/shell/view/view.cpp`'s `applyMode`)
- [ ] `[verify — DAW]` **A→B→A costs two full cycles.** Toggle Arrange→Design→Arrange quickly on a project with heavy plugins: every plugin unloads and reloads twice, where the retired intent queue made a fast double-toggle free. Confirm the END STATE is still correct (every FX online, every driven flag back at its captured value) and report whether the doubled cost is tolerable in practice (`src/shell/view/CLAUDE.md` §Invariants — the A→B→A bullet)
- [ ] `[verify — DAW]` **A fast A→B→A moves zero plugins; a held one costs a cycle each way.** Toggle Arrange→Design→Arrange quickly (well inside `kParkHoldMs`, 3s) on a project with heavy plugins — the debounce should mean nothing ever unloads: confirm the END STATE is correct (every FX online, every driven flag back at its captured value) and that the reclaimer reports no writes. Then repeat holding each side past 3s before flipping back and confirm the reclaimer now does converge each leg (offline while parked, online again on return), reporting whether that cost is tolerable in practice (`src/shell/view/CLAUDE.md` §Invariants — "A rapid A→B→A flip moves zero plugins", `fx_reclaim.h`'s `kParkHoldMs`)
- [ ] `[verify — DAW]` For a track the console names as left unparked, follow the message's recovery IN FULL: re-enable its TCP/mixer visibility, main send and FX enable, **and set every FX in its chain online** (FX enable is the chain bypass — it does not bring an individually offlined FX back). Then switch modes twice: it parks and restores normally, with no further console line. Do the flag half ONLY on a second such track and confirm it is still refused — the FX half of the trust test, and the reason the message spells the step out (unit-tested only as a decision, never against a live chain)
## Actions and drops
+103
View File
@@ -0,0 +1,103 @@
# DAW verification — REAPER behaviour behind a bank for never-saved projects
What a hand-run REAPER pass must establish before the temporary bank for never-saved
projects is designed. The plan is that the extension keeps a never-saved project's bank
index in project ext state, then moves the bank's files into the project folder on the
project's first save. That only works if REAPER keeps ext state on a project that has no
`.rpp` yet and carries it into the first save. **No ReaSampler code exists for this yet** —
every experiment below tests REAPER itself. **If E1 fails, the design needs rework**;
E2–E5 only refine it.
**Build to use.** None needed. The snippets run in REAPER's ReaScript console: *Actions →
ReaScript: run ReaScript (EEL2 or lua)...*, or save the snippet as a `.lua` file and load
it through *Actions → New action → Load ReaScript...*. Every API name cited below is in
`vendor/reaper-sdk/sdk/reaper_plugin_functions.h`. The Lua return shapes are
`[verify — DAW]`: ReaScript returns out-buffers as extra return values rather than taking
a buffer argument, so adjust a snippet if it errors.
**Projects to use.** Fresh never-saved tabs (*File → New project tab*). Close any saved
project first so a stray `0` (current project) cannot resolve to it.
---
## E1. Ext state on a never-saved project survives tab switches and the first save (gates the design)
1. In a new, never-saved tab, run:
```lua
reaper.SetProjExtState(0, "rs_test", "k", "v")
```
2. Switch to another project tab, then back.
3. Run:
```lua
local n, v = reaper.GetProjExtState(0, "rs_test", "k") -- [verify — DAW] return shape
reaper.ShowConsoleMsg(("n=%s v=%s\n"):format(tostring(n), tostring(v)))
```
4. Save the project for the first time. Close the tab, reopen the `.rpp`, and run step 3
again. Optionally open the `.rpp` in a text editor and look for `rs_test`.
Read off: is `v` still `v` after the tab switch? After save and reopen? Is `rs_test` in
the `.rpp`? The SDK header says `GetProjExtState` returns the value "the last time the
project was saved", so a blank read at step 3 would matter even if step 4 passes.
Result:
## E2. Does `SetProjExtState` alone mark a never-saved project dirty?
In a fresh never-saved tab with no other edits, run the `SetProjExtState` line from E1,
then:
```lua
reaper.ShowConsoleMsg("dirty=" .. reaper.IsProjectDirty(0) .. "\n")
```
Read off: the `dirty` value, whether the title bar shows an asterisk, and whether closing
the tab prompts to save.
Result:
## E3. What project path does a never-saved tab report?
In two different never-saved tabs, run in each:
```lua
local p = reaper.GetProjectPathEx(0, "") -- [verify — DAW] return shape
reaper.ShowConsoleMsg("ex=" .. tostring(p) .. " plain=" .. tostring(reaper.GetProjectPath("")) .. "\n")
```
Then change one tab's recording path (*File → Project settings → Media → Path to save
media files*) and run it again in that tab.
Read off: is it the default recording path? Is it the same in both tabs? Does the per-tab
setting change it?
Result:
## E4. Save As with and without "Copy all media into project directory"
In a never-saved tab, insert a media item whose file lives outside any project folder
(e.g. a `.wav` on the desktop).
1. *File → Save project as...* with **Copy all media into project directory** ticked.
Check where the item's source now points (item *Source properties*, or the take's
source via `GetMediaSourceFileName`).
2. Repeat in a new never-saved tab with the option **unticked**.
Read off: ticked — does the item now point at a copy in the project directory? Unticked —
does it still point at the original path?
Result:
## E5. Save from the "save changes?" prompt on close and on quit
Observation only. In a never-saved tab, make any change, then close the tab and choose
**Save** in REAPER's "save changes?" prompt. Repeat by quitting REAPER with a never-saved,
changed tab open.
Read off: did REAPER save and then close in one step, with nothing in between? This
confirms that an extension timer never gets to run between the save and the close.
Result:
+24 -10
View File
@@ -21,12 +21,14 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
${REASAMPLER_SRC_DIR}/shell/persist/session.cpp
${REASAMPLER_SRC_DIR}/shell/persist/bank_root.cpp
${REASAMPLER_SRC_DIR}/shell/persist/ext_state_io.cpp
${REASAMPLER_SRC_DIR}/shell/persist/prune_fs.cpp
${REASAMPLER_SRC_DIR}/shell/bank_ops/bank_ops.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag_os.cpp
${REASAMPLER_SRC_DIR}/shell/panel/footer_input.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp
@@ -38,9 +40,15 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/panel/draw_kit.cpp
${LICE_SRC}
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
${REASAMPLER_SRC_DIR}/shell/undo/undo_block.cpp
${REASAMPLER_SRC_DIR}/shell/undo/undo_intercept.cpp
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_fx_park.cpp
${REASAMPLER_SRC_DIR}/shell/view/fx_reclaim.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_lanes.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_park.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_reconcile.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_tracks.cpp
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
${REASAMPLER_SRC_DIR}/shell/capture/item_read.cpp
@@ -56,20 +64,26 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree fx_state applied_state guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out window_origin instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The
# instrument renders its own bake in its own process, which is what keeps the extension's
# link graph free of the voice engine — a link edge to it here means the design drifted.
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# The park surface's decisions are pure (header-inline, no REAPER types), so they are
# CTest-covered like a core/ module: the snapshot trust test, the refusal message and
# its once-per-changed-set gate, and the apply's undo-point fold. Declared here rather
# than in a src/shell/view/CMakeLists.txt because that directory deliberately has none
# — its TUs are compiled into this target directly. view_mode_model is linked for
# makeParkPlan, the flag set the trust test folds over; pinning that against hand-built
# ops would not catch a change to the planner.
reasampler_test(view_fx_park LINK fx_offline view_mode_model)
# The view shell's decisions are pure (header-inline, no REAPER types), so they are
# CTest-covered like a core/ module: the Flag -> parameter mapping, the apply's
# undo-point fold, and the reconcile's scope rule and drift trigger. Declared here
# rather than in a src/shell/view/CMakeLists.txt because that directory deliberately
# has none — its TUs are compiled into this target directly.
reasampler_test(view_park LINK view_mode_model)
reasampler_test(view_reconcile LINK applied_state view_mode_model)
# The reclaimer's drain loop is templated on its host for exactly this reason: the
# ordering, budget, debounce and resume bookkeeping run against a fake chain.
reasampler_test(fx_reclaim LINK fx_state)
# Which dispatched action id ends the span. Header-only and SDK-free, so it links nothing;
# src/shell/undo has no CMakeLists of its own for the same reason src/shell/view has none.
add_executable(undo_intercept_tests ${REASAMPLER_TESTS_DIR}/test_undo_intercept.cpp)
add_test(NAME undo_intercept_tests COMMAND undo_intercept_tests)
# Bank-package import: the promptless verb plus its action skin. Kept as its own
# appended block rather than merged into the lists above, so the two package
+79 -19
View File
@@ -38,7 +38,11 @@
#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
#include "shell/undo/undo_intercept.h" // REAPER's own Undo/Redo, caught at dispatch
#include "shell/view/fx_reclaim.h" // the FX reclaimer's tick
#include "shell/view/view.h" // applyMode
#include "shell/view/view_lanes.h" // reconcileManagedLanes
#include "shell/view/view_reconcile.h" // reconcileAppliedFlags
namespace capture = reasampler::capture;
@@ -104,7 +108,6 @@ static void RunShowVersion(int) {
// the console window).
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str());
}
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix, Actions-list
// phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload
// mirror-unregister all iterate this data. The capture scope rows come first,
@@ -180,13 +183,27 @@ static void OnTimer()
// the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
// A project load or save is IN FLIGHT, so this tick is running inside its own message
// pump — a restore brings plugins online and a plugin load pumps. Everything below
// would then act on a half-restored project: poll() reads the pre-load <EXTSTATE> that
// OnBeginLoadProjectState defers past precisely because it is wrong, and the reclaim
// tick writes FX into a project REAPER is still rewriting. The extension's other
// re-entrancy guards cover OUR pumps; this one covers REAPER's. Consumes nothing, so
// the first tick after the load runs as if this one had never been due. Self-lowering
// (reaper_plugin_functions.h:1584-1587), and placed after the realtime drive so that
// fast path keeps its single-pointer-test shape. See src/shell/view/CLAUDE.md.
if (GetCurrentProjectInLoadSave()) return;
// poll()'s undo/redo-reload and project-switch Load branches call
// loadFromProject, replacing g_session.view() wholesale — deferred here while
// an applyMode is on the stack (reachable if a pumped message loop re-enters
// this timer mid-apply), same retry-next-tick shape as the consume guard below.
// Deferred WHOLE, not just the reload branches: poll()'s identity tracking is
// cheap to skip for one tick and resumes correctly, unlatched, on the next.
if (!reasampler::modeApplyInProgress()) g_session.poll();
// The reclaim drain is latched for the same reason and is the same window: it
// holds that view model for the length of a plugin unload, which pumps.
if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress())
g_session.poll();
// persist stays MODEL-ONLY (loads the saved view model but does not apply
// visibility, to avoid coupling persist to the view shell); poll() raises a
@@ -195,19 +212,34 @@ static void OnTimer()
// signal re-arms the bank panel's new-content detector — notified BEFORE the
// reapply so re-arm and model restore ride the one load event (otherwise
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
// modeApplyInProgress is tested BEFORE the consume, not after the applyMode call:
// the load signal is one-shot, a re-entered applyMode fails closed, and a reapply
// dropped here is the saved mode never applied at all. Holding the whole block
// back one tick also keeps the panel re-arm ahead of the reapply.
if (!reasampler::modeApplyInProgress() && g_session.consumeLoadSignal()) {
// Both latches are tested BEFORE the consume, not after the applyMode call: the load
// signal is one-shot, a re-entered applyMode fails closed, and a reapply dropped here
// is the saved mode never applied at all. Holding the whole block back one tick also
// keeps the panel re-arm ahead of the reapply.
if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress() &&
g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded();
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
// the cross-session source of truth) BEFORE reapplying visibility. Never
// re-mints, never mass-tags.
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
// An undo/redo reload also arrives here, and REAPER's undo does not restore
// the visibility flags a park drove (UNDO_STATE_TRACKCFG does not carry them).
// Repair the tracks the reloaded model no longer records BEFORE the reapply,
// which only ever drives tracks the model still records.
reasampler::reconcileAppliedFlags(g_session.view(), nullptr);
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
}
// Converge the FX chains toward what the view state says they should be — the
// 1897ms of TrackFX_SetOffline a mode switch no longer pays synchronously. Same
// idle discipline as the realtime fast path above: ONE bool read when there is
// nothing to converge. Ordered AFTER the load block deliberately — a reload replaces
// the view model wholesale, and the applyMode above is what re-derives the queue
// from it, so draining first would spend a tick against the outgoing model.
if (reasampler::fxReclaimPending())
reasampler::fxReclaimTick(g_session, nullptr);
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
}
@@ -220,27 +252,40 @@ static void OnTimer()
// TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading
// GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot
// reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has
// finished restoring the <EXTSTATE> block. A normal project open also fires this
// (isUndo=false); ignored here so a normal open flows solely through the timer's
// identity-transition Load path (no double load).
// finished restoring the <EXTSTATE> block. Only the RELOAD REQUEST is undo-only: a
// normal open's reload belongs to the timer's identity-transition Load path, whose
// forked-sibling re-GUID branch requesting a reload here would bypass.
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
{
// UNBRANCHED, and the reason the terminator is a hook at all: this is REAPER's one
// entry for a state load — "project load/undo", plus a new project
// (reaper_plugin.h:1203-1205) — and the last moment before the restore. Why that
// matters, and why isUndo does not narrow it: fxReclaimAbandonForStateLoad.
reasampler::fxReclaimAbandonForStateLoad();
if (isUndo)
g_session.requestReload();
}
// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this
// extension's own project lines. The struct is registered ONLY for the
// BeginLoadProjectState undo/redo notification.
// Intentional no-op: ReaSampler stores state via project EXT STATE, not this
// extension's own project lines.
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*/,
// We write no lines into `ctx` — this is the FX reclaimer's one durability point. Its
// spent park records ride the user's save rather than dirtying the project on the
// reclaimer's own initiative (fx_reclaim.h's ReleaseFlush).
//
// A save only: an isUndo pass is REAPER capturing an undo point, not writing the file,
// and clearing the pending flush on one would leave the record unspent on disk for good.
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool isUndo,
project_config_extension_t* /*reg*/)
{
if (!isUndo)
reasampler::fxReclaimFlushRelease(g_session);
}
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
@@ -257,6 +302,10 @@ static project_config_extension_t g_projectConfig{
static bool OnHookCommand(int command, int /*flag*/)
{
if (command == 0) return false;
// FIRST, and ahead of every claim below: this hook runs "prior to every action in the
// main section" (reaper_plugin.h:202), so it is the last moment at which the mode
// switch's point can still be minted before REAPER's own undo runs.
reasampler::endReclaimSpanForUndoCommand(command, 0);
if (reasampler::actionTableHandleCommand(command)) return true;
if (reasampler::designViewHandleCommand(command)) return true;
if (reasampler::bankHandleCommand(command)) return true;
@@ -264,13 +313,20 @@ static bool OnHookCommand(int command, int /*flag*/)
return false;
}
// "hookcommand" covers the main section only, so actions we published into another
// section arrive here instead. Partitioning contract: root `CLAUDE.md` §"REAPER
// extension contract".
static bool OnHookCommand2(KbdSectionInfo* /*sec*/, int command, int /*val*/, int /*val2*/,
// Where the actions we published into a non-main section arrive — "hookcommand" runs for
// the main section only. Partitioning contract: root `CLAUDE.md` §"REAPER extension
// contract".
static bool OnHookCommand2(KbdSectionInfo* sec, int command, int /*val*/, int /*val2*/,
int /*relmode*/, HWND /*hwnd*/)
{
if (command == 0) return false;
// The undo intercept runs from BOTH hooks, because this one is not the non-main
// sections' hook: it runs "prior to every action triggered by a key/MIDI event"
// (reaper_plugin.h:214) in ANY section, main included — and a Ctrl-Z is exactly a
// key event. Which of the two hooks REAPER calls first is undocumented and does not
// matter: the close is idempotent. A null section is left alone rather than assumed to
// be main; OnHookCommand covers the main section unconditionally either way.
reasampler::endReclaimSpanForUndoCommand(command, sec ? sec->uniqueID : -1);
return reasampler::ingestHandleSectionCommand(command);
}
@@ -296,6 +352,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
// altered transport/cursor in the user's project on unload.
capture::AbortRealtimeCaptureForUnload(g_session);
// Same reason, for the reclaimer's undo span: a block left open here has no
// owner left to close it and would swallow every edit the user makes after.
reasampler::fxReclaimEndUndoSpan();
g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
+1 -1
View File
@@ -48,7 +48,7 @@ Detail specific to these pure modules:
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + the lossless mono collapse + 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`).
- `batch_capture` — pure batch-capture planner: maps source ranges to capture units and aggregates results.
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the bank-root rule (`bankRootFor(rppPath, unsavedRoot)` — the `.rpp`'s directory, else the unsaved root, else empty, both branches in `normalizeSlashes` form; reached from the extension only through `shell/persist`'s `bankRootOf` seam, while the VST3 instrument still calls `projectDirOfRpp` directly), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates.
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the one bounds mode a capture hands its window over on (`kRenderBoundsTimeSelection`) and the tail bit paired with it (`kTailFlagTimeSelection`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal).
+6
View File
@@ -106,6 +106,12 @@ std::string projectDirOfRpp(const std::string& rppPath) {
return normalizeSlashes(dir);
}
std::string bankRootFor(const std::string& rppPath, const std::string& unsavedRoot) {
const std::string projectDir = projectDirOfRpp(rppPath);
if (!projectDir.empty()) return projectDir;
return normalizeSlashes(unsavedRoot);
}
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir) {
BankRelocation r;
+8 -2
View File
@@ -1,7 +1,7 @@
#pragma once
// capture_paths — the REAPER-free path arithmetic behind offline capture. The
// capture shell resolves the current project directory via REAPER APIs, then
// hands the raw strings here. Forward-slash form throughout, no filesystem
// shell reads the .rpp path via REAPER APIs and resolves the bank root through
// bankRootFor, then hands the raw strings here. Forward-slash form throughout, no filesystem
// access; the bank subfolder name is a fixed constant.
#include <cstddef>
@@ -87,6 +87,12 @@ std::string resolveBankFile(const std::string& projectDir,
// same way persist does.
std::string projectDirOfRpp(const std::string& rppPath);
// The directory a project's bank lives under: the .rpp's directory when it has one,
// else `unsavedRoot` (where a never-saved project banks), else empty — never a
// default-location fallback. Either branch comes back in normalizeSlashes form, so a
// root compares the same whichever branch produced it.
std::string bankRootFor(const std::string& rppPath, const std::string& unsavedRoot);
// A relocation plan for the physical bank folder on Save-As to a new project
// location. The index's relative paths do NOT change (they are relative to the
// project dir, which moved with the .rpp), so relocation is purely a folder
+44 -6
View File
@@ -45,7 +45,8 @@ subdirectories:
- **File seam (audio, permanent).** The sample **audio** is the on-disk 32-bit-float
WAVs — project-relative, travelling with the `.rpp`. The instrument resolves those
paths the same way `persist` does (a shared convention, not a re-implementation).
paths the same way `persist` does (a shared convention, not a re-implementation),
against the project the instance lives in (`map/sample_roots`).
There is no live PCM stream across the bridge, by design.
- **Live-state seam (the mapping, via the bridge).** For everything that is not raw
audio — the bank index, the mapping, which project is active — the instrument reads
@@ -170,6 +171,33 @@ pitch envelope/curve (AD?) which is off by default."*
- **S15/S16 are Tier 0–1 engine features, not Tier 2/3** — do not let the held Tier-2
feature list (velocity layers / round-robin / filter work) drive their build shape.
### Two frame domains — a capture plays at its own rate in any host
A capture's file rate and the host's are independent, and the engine never resamples: the
voice reads SOURCE frames and emits OUTPUT frames, and `SampleData::sourceRateRatio`
(`play_params.h`) is the one conversion between them, folded into the read, the Preserve feed and
the shifter's tap at note-on.
- **Source frames:** the PCM, the loop and its crossfade, the start point, the Trigger span, the
spline phase, the detected period, and the Preserve ring (`preserveWindowFrames`) with every
splice distance in it.
- **Output frames (the host's):** every stage time in `SampleData::play` (resolved at
`SampleData::renderRate`), the filter solve, the live ramps, the declick, and the shifter's
splice crossfade (`PitchShifter::setSourceRateRatio`, which also strides the splice search to
a matched rate's cost). A source-offset envelope converts through `stageFitRate`, which
carries the ratio.
- **Rejected alternative: resample at load.** It would round every loop point into the host
domain (detuning a single-cycle loop), force the editor and the bank intrinsics through a
conversion at every edge, and need a band-limited resampler in the core. The cost of the
chosen shape is that a mismatched capture's ROOT note is interpolated like any other repitched
note already is.
- **That cost is asymmetric.** Nothing band-limits the read, so a file ABOVE the host rate
folds whatever it holds over the host's Nyquist into the audible band even at its root — the
aliasing a matched capture only meets when transposed up. A file BELOW the host rate is read
slower than one frame per frame and only images, the milder direction. Offline captures
render at the project rate (`shell/capture` pins `RENDER_SRATE`), so the exposed case is an
ingested high-rate file, or a host running below the rate a capture was made at.
### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30)
Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other envelopes. We
@@ -203,6 +231,14 @@ automatable parameters."* It rejects the precedent, not one instance of it.
- **Two genuine level steps are smoothed, not ruled away**: a sustain level moved while the
voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by
the envelope's own bounded offset smoother.
- **A drawn contour is live too, through a second block.** `LiveContours` (all three contours
as fixed-capacity arrays, ~9 KB) is published beside `LiveValues` rather than inside it, so a
knob publish, the automation merge and the block compare never carry it. Each `VoiceEngine`
double-buffers it, and `applyContoursToActive` (`voice_engine.h`) owns the rebind invariant
that makes the double buffer safe. A cursor `start()` left cleared (a Staged EG) stays
cleared: Staged|Spline is a note-on fact. A contour is positional, like the Trigger AHD,
so a swap is a level step at the read position, absorbed by a per-EG `StepSmoother` — one more
predicted branch per sample on a drawn EG, none on a Staged one.
### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md)
@@ -255,8 +291,9 @@ consequences, each with one home:
- **Normalized is what makes a contour length-independent.** There are no stored seconds to
rescale, so a different-length capture replays the same shape proportionally.
- **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope
structs.** Those are copied whole into the live block, which must stay trivially copyable
(`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it
structs.** Those are copied whole into the live block, which must stay small and trivially
copyable (`live_params.h`). A contour still reaches the note already sounding — through its
OWN block (`live_contours.h`), see "Live parameter delivery". Only the Staged|Spline mode
travels by reload.
**Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:**
@@ -294,7 +331,7 @@ anything for a trigger shape.
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead.
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns. The same library carries `live_contours.h`/`.cpp` (the drawn-contour block beside it, and `foldContours`, templated over both parameter representations like `splineActive`) and the header-only `seqlock.h`, the ONE seqlock both blocks publish through.
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. 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.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
@@ -325,12 +362,13 @@ anything for a trigger shape.
### `map/`
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **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). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the HOST rate the voice emits at (never the WAV's own — see "Two frame domains" above) — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v16), 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 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. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable, v16 the playback rate + the baseline pitch offset. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `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.
- `sample_roots` — where a bank-relative sample path resolves: `ownProjectDir` picks the instance's own project out of the shell's enumeration BY IDENTITY (no fallback to any other project), `sampleRootCandidates` orders the roots to try, and `readFromFirstRoot` reads the first that yields bytes. The shell half and its rationale are `shell/instrument`'s.
- `trigger_seam` — the shared Trigger play-SPAN formula: how a %-length becomes the source-frame span the overlay draws over and the bake's window holds, threading `startFrame` correctly and clamping the fraction the same way `Voice::start` does (the engine evaluates the same formula inline rather than depending on `map/`). The spline fold every consumer must apply first — `effectiveLengthFraction` — is `play_params.h`'s, beside the rest of that rule family. (Its fade frames↔fraction converters retired with the fade pair itself.)
### `ui/`
@@ -396,7 +434,7 @@ anything for a trigger shape.
envelope that was never running.
- **A live edit leaves the snapshot's own `sample.play` stale, on purpose.** The block, not the
snapshot, is the audio thread's source; a new voice latches the stale copy and is corrected by
`snapLive` before its first frame.
`snapLive` (and a drawn EG by `applyContours`' snap) before its first frame.
- **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep
covers client-pixel widths (including multiples standing in for larger client areas);
nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI
+8 -3
View File
@@ -97,9 +97,14 @@ decision about what the render made obsolete.
- **`defaultBakeProgram`'s read-rate bound is an upper bound, not a model.** Anything that
slows the read makes the head take longer to cross its span, so the window is scaled by the
slowest read the voice can reach — a shallower excursion leaves trailing silence in the file.
Rate is a term of it under BOTH engines and the deepest downward pitch offset under Varispeed
alone (`playbackStretch` argues each); both the Trigger span and the Gate exhaustion length
take the product, and the Gate-with-loop branch takes neither.
Rate and the source/host rate conversion are terms of it under BOTH engines and the deepest
downward pitch offset under Varispeed alone (`playbackStretch` argues each); both the Trigger
span and the Gate exhaustion length take the product, and the Gate-with-loop branch takes
neither.
- **The snapshot and the plan must share one rate.** The snapshot's frame counts are resolved
for its `renderRate`; the shell passes the plan's rate into `bakeSnapshot` so they agree, and
`renderBake` refuses a pair that does not rather than printing every stage time off by the
ratio.
- **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves
are live, so the velocity is a property of the sound being printed and not a detail of the
render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window).
+10 -5
View File
@@ -30,19 +30,23 @@ bool toFrames(double seconds, int rate, std::int64_t& out) {
}
// OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the
// factor a source span is scaled by to bound how long it takes to play out. Two terms:
// factor a source span is scaled by to bound how long it takes to play out. Three terms:
//
// Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve
// feeds the stretcher at it, so either way the source is consumed at that many frames per
// output frame. Taken through the engine's clamp, because that is the value Voice::start
// actually plays.
//
// The source/host rate conversion divides too, under both engines and for the same reason:
// it is a factor of the read and of the feed alike (SampleData::sourceRateRatio).
//
// The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head
// advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read
// rate alone, which is the only sense in which the two are decoupled there.
//
// A Gate release is ticked per output frame, so neither term touches it.
double playbackStretch(const PlayParams& play, int velocity) {
// A Gate release is ticked per output frame, so no term touches it.
double playbackStretch(const SampleData& dialed, int velocity) {
const PlayParams& play = dialed.play;
double down = 0.0;
if (play.pitchEngine == PitchEngine::Varispeed) {
down = (std::min)(0.0, kVelocityPitchRangeSemitones *
@@ -58,7 +62,8 @@ double playbackStretch(const PlayParams& play, int velocity) {
: (std::min)(0.0, play.pitchEnv.peakSemitones);
}
}
return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate);
return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate) /
dialed.sourceRateRatio();
}
// Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top)
@@ -93,7 +98,7 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
const double rate = static_cast<double>(renderSampleRate);
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
const std::int64_t start = effectiveStart(dialed);
const double stretch = playbackStretch(dialed.play, p.velocity.value());
const double stretch = playbackStretch(dialed, p.velocity.value());
const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
double endOffsetSeconds = 0.0;
+5 -3
View File
@@ -29,11 +29,13 @@ bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t cro
bool bakeWindowNeedsHold(const SampleData& dialed);
// The bake's programmed note, DERIVED from the dialed sound at `renderSampleRate` (the rate
// the bake renders at, which is what the engine's frame counts are consumed against):
// the bake renders at, which is what the engine's frame counts are consumed against — and must
// be the rate `dialed` was resolved at, its renderRate):
//
// Trigger — the note IS the play span (note-off is ignored anyway), stretched by the
// slowest read the dialed voice can reach: Rate under BOTH engines, plus the
// deepest downward pitch offset under Varispeed.
// slowest read the dialed voice can reach: Rate and the source/host rate
// conversion under BOTH engines, plus the deepest downward pitch offset under
// Varispeed.
// Gate, loop — `hold` is the note length; the end offset is the release.
// Gate, no loop— the read head runs off the source and frees the voice whatever the gate is
// doing, so the note is the whole post-start span, stretched the same way.
+7 -5
View File
@@ -23,6 +23,9 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
bool limiterEnabled) {
BakeAudio out;
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
// The snapshot's frame counts were resolved at ITS output rate; rendered at another, every
// stage time and the filter corner would print off by the ratio.
if (sample.outputRate() > 0 && sample.outputRate() != plan.sampleRate) return out;
// Each field bounded BEFORE the sum: renderFrames() adds them, and a hand-built plan
// (planBake already bounds both — bake_plan.cpp) could otherwise carry leadInFrames
// near INT64_MAX and signed-overflow inside the guard meant to catch exactly that.
@@ -36,6 +39,7 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
// depend on what the user happened to be dragging. The dialed values are already in
// this SampleData's own play params, which is what the bake is meant to print.
sample.live = nullptr;
sample.liveContours = nullptr;
const int channels = sample.channelCount();
// The limiter delays its output by its lookahead, so the buffers carry that many extra
@@ -51,11 +55,9 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
// Pre-size the Preserve shifters here, off any audio thread, exactly as the processor
// does for its live engine — a cold shifter would smear the onset.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(plan.sampleRate) / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow,
VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false);
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0,
preserveWindowFrames(sample), VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/false);
for (std::int64_t pos = 0; pos < plan.renderFrames();) {
if (pos == plan.noteOnFrame) engine.noteOn(plan.note, plan.velocity);
+2 -1
View File
@@ -33,7 +33,8 @@ struct BakeAudio {
// runs after the engine: `masterGainLinear`, then the limiter when `limiterEnabled` — see
// bake_render.cpp for why both print here rather than in the processor. `limiterEnabled`
// false yields the pre-limiter render. The result is the plan's captured window: the
// lead-in frames are rendered and dropped. An unplayable sample yields an empty result.
// lead-in frames are rendered and dropped. An unplayable sample, or one resolved for an
// output rate other than the plan's, yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear,
bool limiterEnabled);
+12 -3
View File
@@ -26,10 +26,11 @@ add_subdirectory(filter)
# marker layer. After filter: it links play_params' dependency set, which includes it.
add_subdirectory(loop)
# The live-parameter block: the value layer plus its publication, deliberately linking no
# engine — the block is a plain value the voice observes, not a thing the engine owns.
# The live-parameter block and the drawn-contour block beside it: value layers plus their
# publication, deliberately linking no engine — a block is a plain value the voice observes, not
# a thing the engine owns.
reasampler_pure_library(live_params
SOURCES live_params.cpp
SOURCES live_params.cpp live_contours.cpp
LINK PUBLIC peaks velocity_curve filter curve_law)
reasampler_test(live_params LINK live_params)
@@ -52,10 +53,18 @@ reasampler_test(sampler_filter LINK sampler_core)
# does to a voice that is already sounding, and what it must leave alone.
reasampler_test(live_delivery LINK sampler_core)
# The drawn-contour half of live delivery. Its own executable because it replaces the global
# allocator to prove the audio-side swap allocates nothing, which must not leak into other tests.
reasampler_test(live_contours LINK sampler_core)
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
# both mode shapes share, and the Trigger tail's terminal behaviour.
reasampler_test(staged_envelopes LINK sampler_core)
# A capture whose file rate differs from the host's, across the same engine: pitch, duration,
# envelope, loop and filter quantities asserted in wall-clock terms.
reasampler_test(source_rate LINK sampler_core)
# Measurement harness for Preserve on low-frequency material: how the splice search's
# reachable relocation interval interacts with a long source period. Written longhand and
# deliberately NOT add_test()'d — it sweeps frequencies, windows and spectra and takes ~2m40s
@@ -0,0 +1,16 @@
// live_contours.cpp — the VelocityCurve -> fixed-capacity copy the contour block carries.
#include "core/instrument/engine/live_contours.h"
namespace reasampler::instrument::engine {
ContourPoints contourPoints(const VelocityCurve& curve) {
ContourPoints out{};
const std::vector<VelocityPoint>& pts = curve.points();
const std::size_t n = pts.size() < kMaxCurvePoints ? pts.size() : kMaxCurvePoints;
for (std::size_t i = 0; i < n; ++i) out.points[i] = pts[i];
out.count = static_cast<std::uint32_t>(n);
return out;
}
} // namespace reasampler::instrument::engine
@@ -0,0 +1,50 @@
#pragma once
// live_contours.h — the three drawn EG contours as one fixed-capacity plain value, and the block
// that publishes them to the audio thread so a sounding voice follows a contour edit. Separate
// from LiveValues on purpose: at kMaxCurvePoints x 3 this is ~9 KB, and riding the live block
// would put it on every knob publish, automation merge and field compare.
#include <cstdint>
#include <type_traits>
#include "core/instrument/engine/seqlock.h"
#include "core/instrument/engine/velocity_curve.h"
namespace reasampler::instrument::engine {
// One contour's points, copied out of its VelocityCurve so the audio thread can hold it without
// a vector. `count` never exceeds kMaxCurvePoints — the curve's own ceiling — and the points
// past it are ignored.
struct ContourPoints {
std::uint32_t count = 0;
VelocityPoint points[kMaxCurvePoints];
};
// All three are carried whatever each envelope's mode: Staged|Spline is a note-on fact that
// travels by reload, so the voice decides which cursors to rebind and the block stays one shape.
struct LiveContours {
ContourPoints amp;
ContourPoints pitch;
ContourPoints filter;
};
static_assert(std::is_trivially_copyable_v<LiveContours>,
"the contour block is copied under a seqlock — it must stay a plain value");
ContourPoints contourPoints(const VelocityCurve& curve);
// Templated over the frames and seconds parameter representations for the same reason
// splineActive is (play_params.h): both spell the three fields identically. Off the audio thread.
template <class Play>
LiveContours foldContours(const Play& p) {
LiveContours c{};
c.amp = contourPoints(p.ampSpline.contour);
c.pitch = contourPoints(p.pitchSpline.contour);
c.filter = contourPoints(p.filterSpline.contour);
return c;
}
// A class rather than an alias so play_params.h can forward-declare it.
class LiveContourBlock : public Seqlock<LiveContours> {};
} // namespace reasampler::instrument::engine
+6 -61
View File
@@ -1,15 +1,15 @@
#pragma once
// live_params.h — the live playback-parameter block: the plain value bundle the audio thread
// observes once per BLOCK, the single-writer seqlock that publishes it without a lock or a
// torn read, the ONE fold from PlayParams that keeps the two representations in step, and the
// observes once per BLOCK, its publication (the seqlock.h protocol) without a lock or a torn
// read, the ONE fold from PlayParams that keeps the two representations in step, and the
// per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every
// instrument snapshot (see SampleData::live).
#include <atomic>
#include <cstdint>
#include <type_traits>
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/seqlock.h"
namespace reasampler::instrument::engine {
@@ -96,64 +96,9 @@ inline bool operator!=(const LiveValues& a, const LiveValues& b) { return !(a ==
// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
LiveValues foldLive(const PlayParams& params, double keyTrack);
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
// and an even generation; the reader copies the block and re-checks the generation, retrying
// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the
// reader: after the retry budget it reports "nothing new" and the caller keeps its last good
// snapshot rather than spinning on the audio thread.
//
// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the
// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the
// block, and a reader then accepts a torn block as coherent. Every publisher must serialize.
//
// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock:
// the fences give correct ordering, but the concurrent read of a non-atomic object is a data
// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not
// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain
// value the audio thread can copy in one shot.
//
// The writer interface deliberately assumes NO particular thread beyond single-writer, so a
// host's own parameter-change queue (delivered on the audio thread with sample offsets) can
// drive it later without a redesign.
class LiveParams {
public:
// A generation of 0 means "never published"; the first publish lands on 2.
void publish(const LiveValues& values) {
const std::uint32_t gen = seq_.load(std::memory_order_relaxed);
seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress
std::atomic_thread_fence(std::memory_order_release);
values_ = values;
std::atomic_thread_fence(std::memory_order_release);
// Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and
// stall every reader until the NEXT publish — a silent mode, unlike a loud one.
const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2;
seq_.store(next, std::memory_order_release); // even: complete and coherent
}
// The last generation published, without copying the block — one relaxed load, so a reader
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
// Copies the block into `out` and returns the generation actually observed, or 0 when
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
// a torn copy and MUST be discarded — compare the return against 0 before using it).
std::uint32_t read(LiveValues& out, int maxAttempts = 4) const {
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
const std::uint32_t before = seq_.load(std::memory_order_acquire);
if (before == 0) return 0; // never published
if ((before & 1u) != 0u) continue; // writer mid-update
std::atomic_thread_fence(std::memory_order_acquire);
out = values_;
std::atomic_thread_fence(std::memory_order_acquire);
if (seq_.load(std::memory_order_relaxed) == before) return before;
}
return 0;
}
private:
std::atomic<std::uint32_t> seq_{0};
LiveValues values_{};
};
// The live-parameter block's publication — seqlock.h owns the protocol and its single-writer
// contract. A class rather than an alias so play_params.h can forward-declare it.
class LiveParams : public Seqlock<LiveValues> {};
// Linear per-frame glide with EXACT termination: once the target is within one step the value
// becomes the target itself. An asymptotic smoother would leave the value forever a hair off,
+75 -30
View File
@@ -60,6 +60,8 @@ void PitchShifter::configure(std::int64_t windowFrames) {
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
sourceRatio_ = 1.0;
stride_ = 1;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
return;
@@ -67,26 +69,43 @@ void PitchShifter::configure(std::int64_t windowFrames) {
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
ringLen_ = 2 * window_;
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
// Geometry (all quarters of the window):
// - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach
// the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
// past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
// Geometry (all quarters of the window, in SOURCE frames):
// - maxLag_: alignment search half-range — one window/4 covers a full period of any tone
// down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
// - dLow_/dHigh_: safe delay band; unity parks the tap mid-band (window/2 delay).
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
// the splice burst.
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
// fadeFrames_ and corrFrames_ also scale with the source ratio — applySourceRatio.
maxLag_ = window_ / 4;
dLow_ = window_ / 4;
dHigh_ = ringLen_ - window_ / 4;
corrFrames_ = std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512));
// The delay band is (dHigh_ - dLow_) wide and the search can add up to maxLag_ on either
// side; one frame more than that and a jump could land exactly ON a trigger boundary.
jumpMax_ = std::max<std::int64_t>(1, dHigh_ - dLow_ - maxLag_ - 1);
fadeLen_ = 0;
reset();
reset(); // applies the unity source ratio
}
void PitchShifter::applySourceRatio() {
if (window_ <= 1) return;
// (setSourceRateRatio owns why each of these moves with the ratio.)
// - fadeFrames_: nominal splice crossfade, in OUTPUT frames. Only safe while the outgoing
// tap can reach neither the writer nor the ring's far end before the fade ends; splice()
// shortens the live fade whenever the drift says it would. Rounded, so 1.0 lands on
// window/4 exactly.
// - stride_: unstrided, the search's cost would grow with the ratio — a burst on one audio
// frame, stacked across every voice that splices on it.
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 SAMPLED
// frames bounds the splice burst and keeps the reference's wall-clock span.
// Both clamped in double before the int64 cast: the setter accepts any positive ratio.
const double fade = static_cast<double>(window_ / 4) / sourceRatio_ + 0.5;
fadeFrames_ = std::max<std::int64_t>(
static_cast<std::int64_t>(
std::min(fade, static_cast<double>(std::numeric_limits<std::int32_t>::max()))),
1);
const double stride = std::min(sourceRatio_ + 0.5, static_cast<double>(maxLag_));
stride_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(stride));
corrFrames_ =
std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512 * stride_));
}
void PitchShifter::reset() {
@@ -111,6 +130,8 @@ void PitchShifter::reset() {
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
sourceRatio_ = 1.0;
applySourceRatio();
period_ = 0.0;
jump_ = window_ > 1 ? window_ : 0;
tailFrozen_ = false;
@@ -195,6 +216,12 @@ void PitchShifter::setFeedRate(double rate) {
if (rate > 0.0) feedRate_ = rate;
}
void PitchShifter::setSourceRateRatio(double ratio) {
if (!(ratio > 0.0)) return;
sourceRatio_ = ratio;
applySourceRatio();
}
double PitchShifter::readTap(double pos) const {
// Fractional linear interpolation with ring wrap.
double p = pos;
@@ -214,9 +241,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
// content, -window_ = toward the writer), refined by a correlation search so the relocated
// read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
// (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
// sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
// per splice.
// (step 4*stride_ over +/- maxLag_) then fine (+/- 3 strides around the coarse best, +/-
// stride_-1 single frames around that, then a parabolic sub-sample peak), each score
// sampling every stride_-th frame: a bounded burst of ~ (maxLag_/(2*stride_) + 2*stride_ +
// 7) * corrFrames_/stride_ multiply-adds, once per splice.
const std::int64_t d = static_cast<std::int64_t>(delay);
// An up-jump may only relocate into valid history. The deepest slot the search (plus the
@@ -247,13 +275,13 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
std::int64_t ia = iA;
std::int64_t ic = ((iA - jump + lag) % ringLen_ + ringLen_) % ringLen_;
double s = 0.0, ec = 0.0;
for (std::int64_t k = 0; k < corr; ++k) {
for (std::int64_t k = 0; k < corr; k += stride_) {
const double a = static_cast<double>(ring_[static_cast<std::size_t>(ia)]);
const double c = static_cast<double>(ring_[static_cast<std::size_t>(ic)]);
s += a * c;
ec += c * c;
if (++ia >= ringLen_) ia = 0;
if (++ic >= ringLen_) ic = 0;
if ((ia += stride_) >= ringLen_) ia -= ringLen_;
if ((ic += stride_) >= ringLen_) ic -= ringLen_;
}
// Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
// so on a decaying tail every up-splice would prefer the loudest candidate over the
@@ -264,7 +292,7 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
std::int64_t bestLag = 0;
double bestScore = -std::numeric_limits<double>::infinity();
for (std::int64_t lag = -maxLag_; lag <= maxLag_; lag += 4) {
for (std::int64_t lag = -maxLag_; lag <= maxLag_; lag += 4 * stride_) {
const double s = scoreAt(lag);
if (s > bestScore) {
bestScore = s;
@@ -272,7 +300,7 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
}
}
const std::int64_t coarse = bestLag;
for (std::int64_t lag = coarse - 3; lag <= coarse + 3; ++lag) {
for (std::int64_t lag = coarse - 3 * stride_; lag <= coarse + 3 * stride_; lag += stride_) {
if (lag == coarse || lag < -maxLag_ || lag > maxLag_) continue;
const double s = scoreAt(lag);
if (s > bestScore) {
@@ -280,6 +308,16 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
bestLag = lag;
}
}
// Empty at a stride of 1, which is what keeps the matched-rate search unchanged.
const std::int64_t strided = bestLag;
for (std::int64_t lag = strided - (stride_ - 1); lag <= strided + (stride_ - 1); ++lag) {
if (lag == strided || lag < -maxLag_ || lag > maxLag_) continue;
const double s = scoreAt(lag);
if (s > bestScore) {
bestScore = s;
bestLag = lag;
}
}
// Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
// sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
@@ -305,26 +343,33 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
while (p < 0.0) p += len;
while (p >= len) p -= len;
posA_ = p;
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
// writer at (ratio - feedRate) per frame; the nominal window/4 fade only keeps it behind
// the writer while that rate stays under ~1 — beyond that (e.g. +24 st = ratio 4, or a
// half-speed feed under any up-shift) it would cross mid-fade and play stale read-ahead
// data. Cap the live fade at the drain headroom actually available, minus 2 (trigger
// undershoot + interpolator read-ahead margin). A drain rate at or below zero (down-shifts,
// and up-shifts the feed outruns) can't reach the ring end within window/4 frames, so those
// always keep the full fade.
// Drift-scaled fade length. The outgoing tap's delay behind the writer moves by
// (feedRate - ratio) source frames per output frame, and the fade lasts fadeFrames_ OUTPUT
// frames, so it moves the tap drift x fadeFrames_ — a quarter window at |drift| == 1 at a
// matched rate. Past that it runs out of ring in whichever direction it drifts:
// - drain (ratio > feedRate; up-shifts, a slow feed): toward the writer, where it would
// read stale read-ahead data (e.g. +24 st = ratio 4, or a half-speed feed under any
// up-shift);
// - recede (ratio < feedRate; down-shifts, a fast feed): toward the ring's far end, where
// it would read audio the writer has already replaced (e.g. -24 st at Rate 200 %).
// Cap the live fade at the headroom actually left in that direction — a quarter window
// either side of the band — minus the one frame's trigger overshoot and 2 (the integer
// feed's jitter + interpolator read-ahead margin).
//
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
// either shift direction, so the drain rate is ratio_ regardless of feed and the cap applies
// at every ratio (including unity, since delay now drains at unity too).
fadeLen_ = fadeFrames_;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - feedRate_);
if (drainRate > 0.0) {
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
if (drainRate != 0.0) {
const double rate = std::fabs(drainRate);
const double room = drainRate > 0.0 ? static_cast<double>(dLow_)
: static_cast<double>(ringLen_ - dHigh_);
const double headroom = room - rate - 2.0;
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
// at very high sample rates (where headroom/drainRate could overflow int64).
// at very high sample rates (where headroom/rate could overflow int64).
const double safeDbl = headroom > 0.0
? std::min(headroom / drainRate, static_cast<double>(fadeFrames_))
? std::min(headroom / rate, static_cast<double>(fadeFrames_))
: 1.0;
fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl));
}
+29 -11
View File
@@ -60,7 +60,7 @@ struct SpliceEvent {
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
std::int64_t fadeLen = 0; // live crossfade length chosen (drift-scaled, either direction)
};
// The nominal splice jump for a source whose period is known: the multiple of `periodFrames`
@@ -118,6 +118,15 @@ public:
// Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
void setFeedRate(double rate);
// Source frames per output frame at unity (SampleData::sourceRateRatio). The ring and every
// splice distance are SOURCE frames, but a crossfade is counted per OUTPUT frame, so the
// fade is window/4 source frames' worth of output time — the same wall-clock length and the
// same share of the splice cadence at any ratio. It also strides the correlation search by
// the nearest whole ratio, so a splice's cost and its wall-clock reach are a matched rate's
// at any file rate. Values <= 0 are ignored; exactly 1.0 is the matched geometry bit for
// bit. Reset to 1.0 by configure()/reset(), like the feed rate.
void setSourceRateRatio(double ratio);
// The period of the source being fed, in SOURCE frames, making every splice jump a whole
// number of it — <= 0 means "unknown" (see periodAlignedJump for the exact fallback); the
// default, so a caller that never calls this sees no change.
@@ -195,6 +204,9 @@ private:
// `write` false is the starved stretch frame: read/splice/advance the taps, but consume no
// input and hold the write head (the same shape tail-freezing already takes).
AudioSample processImpl(AudioSample in, const SpliceEvent* linked, bool write);
// Re-derives the three quantities sourceRatio_ scales (fadeFrames_, stride_, corrFrames_)
// from the window's fixed geometry.
void applySourceRatio();
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
@@ -204,11 +216,13 @@ private:
double posB_ = 0.0; // outgoing tap during a splice crossfade
bool fading_ = false; // a splice crossfade is in flight
std::int64_t fadePos_ = 0; // crossfade progress, [0, fadeLen_)
std::int64_t fadeFrames_ = 0; // NOMINAL crossfade length (window_/4)
std::int64_t fadeFrames_ = 0; // NOMINAL crossfade length in OUTPUT frames: window_/4
// source frames at sourceRatio_ (window_/4 at 1.0)
std::int64_t fadeLen_ = 0; // LIVE crossfade length for the in-flight splice —
// ratio-scaled at splice time so an up-shift's outgoing
// tap can never drain into the writer mid-fade
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
// drift-scaled at splice time so the outgoing tap can
// never reach the writer, or lap the ring's far end,
// mid-fade
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4 source frames)
double period_ = 0.0; // source period in frames, 0 = unknown (fixed-window)
std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ retunes it
std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY
@@ -216,15 +230,19 @@ private:
// period-sized jump can never land back on a trigger and
// thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_). At
// jump_==jumpMax_ a DOWN-splice's correlation read comes
// within ~41 frames of the write head (measured: the
// exact ring/lag/corrFrames_ geometry at the product
// window, worst case over every lag the search reaches) —
// within dLow_+2-corrFrames_ frames of the write head at
// the worst lag the search reaches (41 at a 44.1k 50 ms
// window; never under 3, since corrFrames_ <= dLow_-1) —
// real margin, not zero, but tight enough that widening
// maxLag_, corrFrames_ or jumpMax_ without re-deriving
// this bound risks reading unwritten ring content.
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind
// the writer by construction at an up-splice)
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512
// SAMPLED frames — 512*stride_ — so the reference read
// forward from the tap stays behind the writer by
// construction at an up-splice)
std::int64_t stride_ = 1; // correlation sample + coarse-lag stride: the nearest whole
// sourceRatio_, floored at 1
double sourceRatio_ = 1.0; // source frames per output frame at unity
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
std::int64_t filled_ = 0; // frames of valid history behind the writer; splice()
+57 -9
View File
@@ -15,7 +15,8 @@
namespace reasampler {
namespace instrument::engine { class LiveParams; } // live_params.h; SampleData holds a pointer
// SampleData holds a pointer to each (live_params.h, live_contours.h).
namespace instrument::engine { class LiveParams; class LiveContourBlock; }
using audio::AudioSample;
using instrument::engine::VelocityCurve;
@@ -103,9 +104,9 @@ enum class PitchEngine { Varispeed, Preserve };
// still halves duration with no params set).
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
// on big transpositions. Onset latency is zero — start() primes the ring with the first window
// of real source, so output frame 0 is source frame 0 regardless of window size.
// OLA window for the Preserve PitchShifter, in ms of SOURCE (preserveWindowFrames); larger =
// smoother on big transpositions. Onset latency is zero — start() primes the ring with the first
// window of real source, so output frame 0 is source frame 0 regardless of window size.
inline constexpr double kPreserveWindowMs = 50.0;
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 0 ->
@@ -181,8 +182,8 @@ struct PlayParams {
// The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to
// filter.env/trigEnv respectively. They sit HERE rather than inside the three envelope
// structs because those are copied whole into the live block, which must stay trivially
// copyable (live_params.h) — and a contour is not a live control anyway: like the velocity
// curves it travels by reload.
// copyable (live_params.h). A contour reaches a sounding voice through its own block
// instead (live_contours.h); only its Staged|Spline mode travels by reload.
SplineEnv ampSpline;
SplineEnv pitchSpline;
SplineEnv filterSpline;
@@ -253,9 +254,13 @@ struct SampleLoop {
struct SampleData {
std::vector<AudioSample> frames;
std::vector<AudioSample> framesR; // empty for a mono sample
int sampleRate = 0; // ratio math is note-relative, so rate cancels for
// repitch; still, 0 is invalid — every consumer must
// receive a real rate before use.
// The SOURCE's own rate: the PCM, the loop, the start, the crossfade and the source period
// are all frames at this rate.
int sampleRate = 0;
// The rate the voice EMITS frames at (the host's), and so the rate every frame count in
// `play` was resolved against. 0 = not supplied, which renders the source at its own rate —
// a hand-built SampleData, or a load before the host reported a rate.
int renderRate = 0;
int rootNote = 60;
SampleLoop loop;
@@ -295,6 +300,9 @@ struct SampleData {
// same knob as a live one. That is the desired behaviour — it is the note the user is
// hearing. Do not "fix" it by moving ownership into the snapshot.
const instrument::engine::LiveParams* live = nullptr;
// The drawn-contour peer of `live`, under the same ownership and null contract: null plays
// the contours in `play` for the note's whole life.
const instrument::engine::LiveContourBlock* liveContours = nullptr;
// A framesR of a different length than frames is treated as absent — a malformed pair
// never half-plays.
@@ -305,6 +313,46 @@ struct SampleData {
// Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a
// voice on an empty read span.
bool playable() const { return !frames.empty(); }
int outputRate() const { return renderRate > 0 ? renderRate : sampleRate; }
// Source frames consumed per output frame at unity pitch — THE conversion between the two
// frame domains, folded into the read and the Preserve feed at note-on. Exactly 1.0 when the
// rates agree or either is unknown, which is what keeps a same-rate render bit-identical to
// the engine before the factor existed.
double sourceRateRatio() const {
const int out = outputRate();
return (sampleRate > 0 && out > 0 && sampleRate != out)
? static_cast<double>(sampleRate) / static_cast<double>(out)
: 1.0;
}
};
// The largest sourceRateRatio a load accepts. It is the one per-frame cost the ratio scales: a
// Preserve voice writes up to ceil(kStretchRateMax x ratio) source frames into its ring per
// output frame (StretchCursor::due). The splice search does NOT scale with it — pitch_shift
// strides it to a matched rate's cost. A header claiming more than this is treated as corrupt
// (buildSampleData).
inline constexpr double kMaxSourceRateRatio = 32.0;
// THE refusal predicate for that bound: whether a file at `sourceRate` may play in a host
// emitting `renderRate` (<= 0: not reported, so the file plays at its own rate). Named apart
// from the build so a shell can tell the user WHY a load is silent — the file decoded fine.
inline bool sourceRateSupported(int sourceRate, int renderRate) {
if (sourceRate <= 0 || renderRate <= 0) return true;
return static_cast<double>(sourceRate) / static_cast<double>(renderRate) <=
kMaxSourceRateRatio;
}
// The Preserve ring's window in SOURCE frames. The ring holds source and its feed and tap both
// carry sourceRateRatio, so a window sized at the source rate keeps every splice interval,
// search reach and alignable period in time_stretch.h fixed in Hz at any host rate; the
// shifter's crossfade and search stride are what it converts to output frames
// (PitchShifter::setSourceRateRatio). Floored at 2 so a valid window is always a real ring.
inline std::int64_t preserveWindowFrames(const SampleData& s) {
const std::int64_t w = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(s.sampleRate) / 1000.0 + 0.5);
return w < 2 ? 2 : w;
}
} // namespace reasampler
+76
View File
@@ -0,0 +1,76 @@
#pragma once
// seqlock.h — the lock-free publication every live block rides from its off-thread writer to
// the audio thread: a single-writer seqlock over one plain value. Generic over that value so the
// live-parameter block and the drawn-contour block share one implementation of the protocol.
#include <atomic>
#include <cstdint>
#include <type_traits>
namespace reasampler::instrument::engine {
// Single-writer seqlock. The writer publishes a whole block between an odd and an even
// generation; a reader copies the block and re-checks the generation, retrying a bounded number
// of times, so it can never act on a half-applied edit. Wait-free for the reader: after the
// retry budget it reports "nothing new" and the caller keeps its last good snapshot rather than
// spinning on the audio thread. Reading does not mutate, so any number of readers may share one
// block (the live and the draining engine both do).
//
// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the
// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the
// block, and a reader then accepts a torn block as coherent. Every publisher must serialize.
//
// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock:
// the fences give correct ordering, but the concurrent read of a non-atomic object is a data
// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not
// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain
// value the audio thread can copy in one shot.
//
// The writer interface deliberately assumes NO particular thread beyond single-writer, so a
// host's own parameter-change queue (delivered on the audio thread with sample offsets) can
// drive it later without a redesign.
template <class T>
class Seqlock {
static_assert(std::is_trivially_copyable_v<T>,
"the block is copied under a seqlock — it must stay a plain value");
public:
// A generation of 0 means "never published"; the first publish lands on 2.
void publish(const T& values) {
const std::uint32_t gen = seq_.load(std::memory_order_relaxed);
seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress
std::atomic_thread_fence(std::memory_order_release);
values_ = values;
std::atomic_thread_fence(std::memory_order_release);
// Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and
// stall every reader until the NEXT publish — a silent mode, unlike a loud one.
const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2;
seq_.store(next, std::memory_order_release); // even: complete and coherent
}
// The last generation published, without copying the block — one relaxed load, so a reader
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
// Copies the block into `out` and returns the generation actually observed, or 0 when
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
// a torn copy and MUST be discarded — compare the return against 0 before using it).
std::uint32_t read(T& out, int maxAttempts = 4) const {
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
const std::uint32_t before = seq_.load(std::memory_order_acquire);
if (before == 0) return 0; // never published
if ((before & 1u) != 0u) continue; // writer mid-update
std::atomic_thread_fence(std::memory_order_acquire);
out = values_;
std::atomic_thread_fence(std::memory_order_acquire);
if (seq_.load(std::memory_order_relaxed) == before) return before;
}
return 0;
}
private:
std::atomic<std::uint32_t> seq_{0};
T values_{};
};
} // namespace reasampler::instrument::engine
+27 -15
View File
@@ -11,8 +11,9 @@
namespace reasampler::instrument::engine {
// The playback rates the Preserve DSP is measured over, and therefore the only ones it
// accepts. The ceiling also bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame
// source frames) — the RT-safety argument for feeding a variable count at all.
// accepts. The ceiling, times the load-bounded source/host ratio, also bounds a voice's
// per-output-frame feed loop (StretchCursor::due) — the RT-safety argument for feeding a
// variable count at all.
//
// This range NARROWS the splice-cadence failure onto the source fundamental; it does not
// eliminate it. A splice recurs every `pitch_shift.h`'s spliceJump() / |rate - shift| output
@@ -23,14 +24,16 @@ namespace reasampler::instrument::engine {
// 0.25 (-24 st), fixed-window jump (2205): interval 2205/3.75 ~= 588 vs period ~4*P ~= 785
// frames (P ~= 196) — matches the originally observed 539-vs-785 failure. This range's ceiling
// (2.0, not 4.0) raises the safe floor, it does not remove it: at rate 2.0, shift 0.25, interval
// = 2205/1.75 = 1260 still produces measurable splice debris for any source period P > 315
// frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -24 st is reachable from the
// Pitch knob alone. pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter)
// asserts this corner directly at P=500/600/700: energy outside the fundamental runs 7-21% there
// against ~0% on an aligned control at the same rate/shift — zero-crossing period is NOT what it
// checks, since splice debris fools that estimator into reading the wrong period on a render
// whose fundamental is provably correct. (The pre-stretch rate-1.0 engine's floor by the same
// inequality is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.)
// = 2205/1.75 = 1260 puts any source period P > 315 frames (~140 Hz at 44.1k) inside the
// inequality — bass/low-vocal material, and -24 st is reachable from the Pitch knob alone.
// pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) measures this
// corner directly at P=500/600/700 — by energy outside the fundamental, NOT zero-crossing
// period, since splice debris fools that estimator into reading the wrong period on a render
// whose fundamental is provably correct. Its raw 7-21% readings are the metric's own floor, and
// the 0.2-0.5% excess once measured over that floor was not the cadence at all: it was this
// corner's down-shift crossfade lapping the ring's far end, which splice()'s fade cap closed —
// the excess now reads 0.00%. (The pre-stretch rate-1.0 engine's floor by the same inequality
// is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.)
//
// The above derives the floor with jump == window(), which is only the FIXED-WINDOW half of
// the story. Once a source period is known, spliceJump() is periodAlignedJump's answer instead
@@ -77,9 +80,12 @@ namespace reasampler::instrument::engine {
// quantity from the raw percentages here. What survives: a period longer than the reachable
// jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no
// single period falls back to it by design (periodAlignedJump, pitch_shift.h).
//
// These bound the MUSICAL rate. The feed is that rate times the source/host rate ratio
// (StretchCursor::start), and the ratio is deliberately NOT clamped here: the geometry above
// holds in wall-clock terms at any ratio (preserveWindowFrames, play_params.h, owns why).
inline constexpr double kStretchRateMin = 0.5;
inline constexpr double kStretchRateMax = 2.0;
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax)
// Non-positive and NaN fold to unity rather than to the minimum: an unusable rate should leave
// playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to
@@ -97,16 +103,21 @@ inline double clampStretchRate(double rate) {
class StretchCursor {
public:
// `frame` is where the ring prime stopped; the per-frame feed continues there.
void start(std::int64_t frame) {
// `sourceRateRatio` is source frames per output frame at rate 1.0 (SampleData's own) —
// exactly 1.0 at a matched rate, where the multiply below is the identity. Required, so no
// caller can drop the source/host conversion from the feed by omission.
void start(std::int64_t frame, double sourceRateRatio) {
frame_ = frame;
debt_ = 0.0;
sourceRatio_ = sourceRateRatio > 0.0 ? sourceRateRatio : 1.0;
}
// Adds one output frame's worth of source at `rate` and returns how many whole source
// frames are now due, in [0, kMaxFeedPerFrame]. Take each of them with next(). The clamp
// lives here rather than at the caller because this return value is the loop bound.
// frames are now due, in [0, ceil(kStretchRateMax * sourceRateRatio)]. Take each of them
// with next(). The clamp lives here rather than at the caller because this return value is
// the loop bound; the load's kMaxSourceRateRatio (play_params.h) is what bounds the ratio.
std::int64_t due(double rate) {
debt_ += clampStretchRate(rate);
debt_ += clampStretchRate(rate) * sourceRatio_;
const std::int64_t whole = static_cast<std::int64_t>(debt_); // debt_ >= 0: trunc = floor
debt_ -= static_cast<double>(whole);
return whole;
@@ -127,6 +138,7 @@ public:
private:
std::int64_t frame_ = 0;
double debt_ = 0.0; // fractional source frames carried into the next output frame
double sourceRatio_ = 1.0;
};
} // namespace reasampler::instrument::engine
+11 -7
View File
@@ -224,17 +224,21 @@ private:
// sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the
// 128-point ceiling.
//
// Holds a RAW POINTER into the bound curve's point array: the caller guarantees the curve
// outlives the cursor. The voice binds against its SampleData, which has exactly that lifetime.
// Holds a RAW POINTER into the bound point array: the caller guarantees the array outlives the
// cursor, or rebinds it first. The voice binds against its SampleData (that lifetime), or
// against its engine's live contour copy — the double-buffer contract VoiceEngine::contours_
// owns (voice_engine.h) is what makes that second binding safe.
class SplineCursor {
public:
// Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so
// the caller's `if (active())` skips the whole spline path.
void bind(const VelocityCurve& c) {
const std::vector<VelocityPoint>& pts = c.points();
if (pts.size() < 2) { clear(); return; }
pts_ = pts.data();
n_ = pts.size();
void bind(const VelocityCurve& c) { bind(c.points().data(), c.points().size()); }
// The same over a raw X-ordered array (live_contours.h's fixed-capacity copy). Rebinding a
// cursor mid-read is legal: it restarts the segment cache, and the next eval re-locates.
void bind(const VelocityPoint* pts, std::size_t n) {
if (pts == nullptr || n < 2) { clear(); return; }
pts_ = pts;
n_ = n;
select(0);
}
void clear() { pts_ = nullptr; n_ = 0; }
+39 -7
View File
@@ -65,6 +65,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
// debt accumulate the SAME value: they must stay exactly one window apart for the note's
// whole life.
stretchRate_ = instrument::engine::clampStretchRate(stretchRate);
sourceRatio_ = sample.sourceRateRatio();
feedRate_ = stretchRate_ * sourceRatio_;
// Keyed on the read path this note will ACTUALLY take, which is not the same question as
// the stored engine: advanceFrame runs the Preserve branch only while the shifters are
// configured, and a Preserve voice whose shifters were never sized falls back to the
@@ -109,6 +111,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
} else {
filterSplineCur_.clear();
}
ampSplineSmooth_.clear();
pitchSplineSmooth_.clear();
filterSplineSmooth_.clear();
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
@@ -142,10 +147,10 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
pitchEnv_.noteOn();
// A restart lands every live glide back on the new note's own values, at a step derived
// from this sample's rate rather than any assumed one.
// from the rate this voice emits at rather than any assumed one.
filterRamping_ = false;
const double rampStep = instrument::engine::liveRampStep(
static_cast<double>(sample.sampleRate));
static_cast<double>(sample.outputRate()));
rBaseCutoff_.step = rampStep;
rModAmount_.step = rampStep;
rResonance_.step = rampStep;
@@ -163,7 +168,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
filterKeyTrack_ = p.filter.keyTrack;
filterVelCurve_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
filterVelOffset_ = p.filter.velAmount * filterVelCurve_;
filterRate_ = static_cast<double>(sample.sampleRate);
// The emitted rate, never the source's: the corner is a frequency of what the voice
// outputs, and one output frame is one host frame.
filterRate_ = static_cast<double>(sample.outputRate());
rModAmount_.set(p.filter.modAmount);
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
@@ -241,9 +248,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
}
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
// playable span).
stretch_.start(p);
shiftL_.setFeedRate(stretchRate_);
shiftR_.setFeedRate(stretchRate_);
stretch_.start(p, sourceRatio_);
shiftL_.setFeedRate(feedRate_);
shiftR_.setFeedRate(feedRate_);
shiftL_.setSourceRateRatio(sourceRatio_);
shiftR_.setSourceRateRatio(sourceRatio_);
// Pitch-synchronous splices: the period was detected once at load (period_detect,
// which this library deliberately does not link — the loader hands the answer down on
// SampleData). 0 restores the fixed-window geometry, so a capture with no single
@@ -269,7 +278,7 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
//
// live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class,
// delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The
// latched stretchRate_ is what stageFitRate carries into every conversion below, so a
// latched feedRate_ is what stageFitRate carries into every conversion below, so a
// stage-time move mid-note lands in this note's own rate domain rather than resetting it.
const bool gate = (playMode_ == PlayMode::Gate);
// The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_
@@ -330,6 +339,29 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
rMorph_.moving() || rDrive_.moving();
}
namespace {
// One EG's half of Voice::applyContours. `before` is read through the OLD binding — still
// intact, by the double-buffer contract VoiceEngine::contours_ owns (voice_engine.h).
void rebindContour(SplineCursor& cursor, StepSmoother& smooth,
const instrument::engine::ContourPoints& pts, double phase, bool snap) {
if (!cursor.active()) return;
const double before = snap ? 0.0 : cursor.eval(phase);
cursor.bind(pts.points, pts.count);
if (snap || !cursor.active()) return;
const double after = cursor.eval(phase);
if (after != before) smooth.absorb(before - after);
}
} // namespace
void Voice::applyContours(const instrument::engine::LiveContours& contours, bool snap) {
const double phase = splinePhase();
rebindContour(ampSplineCur_, ampSplineSmooth_, contours.amp, phase, snap);
rebindContour(pitchSplineCur_, pitchSplineSmooth_, contours.pitch, phase, snap);
rebindContour(filterSplineCur_, filterSplineSmooth_, contours.filter, phase, snap);
}
void Voice::retune(int note) {
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
+64 -26
View File
@@ -15,6 +15,7 @@
#include "core/instrument/engine/envelopes.h"
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/live_contours.h"
#include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h"
@@ -179,6 +180,14 @@ public:
// pitch ratio, and the decoded PCM stay latched at note-on.
void applyLive(const instrument::engine::LiveValues& live, bool snap);
// Re-points every drawn EG this note is ALREADY reading at `contours` — a cursor start()
// left cleared stays cleared, because Staged|Spline is a note-on fact. Block-boundary only,
// like applyLive. Without `snap` the level step at the current read position is absorbed
// by that EG's StepSmoother, the same way a live reshape of a positional AHD is; with it
// (a voice that has rendered nothing) the new contour is taken outright. `contours` must
// outlive the binding or be rebound first — VoiceEngine's double buffer owns that.
void applyContours(const instrument::engine::LiveContours& contours, bool snap);
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
@@ -207,24 +216,27 @@ private:
// THE fold of every pitch factor that is constant for the note into one number, so
// advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has
// always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset,
// and the Rate ratio — which start() zeroes out of this product when the note is running the
// the Rate ratio — which start() zeroes out of this product when the note is running the
// Preserve read, since Rate feeds stretch_ (duration) there and must never reach the
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
// shifter's transpose — and the source/host rate conversion, which belongs in BOTH reads:
// under Preserve the shifter's ring holds source frames, so its tap needs the same factor
// to land the root on the root. Cold: note-on, legato retune, and a live block.
void recomputeBaseRatio() {
if (sample_ == nullptr) return;
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_ * sourceRatio_;
}
// The rate the read head consumes SOURCE at, counting only the factors whose stage-time
// coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset
// coupling is compensated. Under Preserve that is the feed rate alone — the Pitch offset
// transposes inside the shifter and never touches the read. Under Varispeed both Rate and
// Pitch are factors of the read increment and both are compensated: they are two views of one
// multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the
// velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped
// sounds, and compensating them would move every note off the root.
// multiply, so the "30 ms is 30 ms" rule binds them identically. The source/host conversion
// (inside feedRate_) is compensated under both, because it is a change of units, not a
// sound. Key-tracking and the velocity->pitch transpose are deliberately LEFT OUT — those
// predate Rate, are shipped sounds, and compensating them would move every note off the root.
double stageFitRate() const {
return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_;
return preserveRead_ ? feedRate_ : feedRate_ * pitchOffsetRatio_;
}
// A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the
@@ -258,7 +270,7 @@ private:
const double postStart = static_cast<double>(
static_cast<std::int64_t>(sample_->frames.size()) - startFrame_);
const double readRate =
preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
preserveRead_ ? feedRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
const double span = (readRate > 0.0) ? postStart / readRate : postStart;
return static_cast<std::int64_t>(span + 0.5);
}
@@ -268,6 +280,14 @@ private:
// its opening value.
double splinePhase() const { return readPos_ * splineScale_; }
// One drawn EG's value this frame: the contour at the read position plus whatever step a
// live contour swap left to absorb (applyContours). The smoother is inactive at rest, so a
// contour nobody is editing costs one predicted branch over the bare cursor read.
double readSpline(SplineCursor& cursor, StepSmoother& smooth) {
const double v = cursor.eval(splinePhase());
return smooth.active() ? v + smooth.advance() : v;
}
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
@@ -284,11 +304,17 @@ private:
if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) {
// Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's
// finished()) — onFinalSegment()/segmentEndValue()'s own doc comments own the why.
amp = ampSplineCur_.eval(splinePhase());
amp = readSpline(ampSplineCur_, ampSplineSmooth_);
// A swap-step absorbed by ampSplineSmooth_ can briefly push the sum outside [0,1]
// (a contour's own points are drawn in-range, but the smoother's addend isn't); the
// filter clamps its own read for the same reason (below). The finished-check reads
// the PRE-clamp value so a genuine zero-terminus isn't masked by the clamp itself.
if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
ampSplineCur_.segmentEndValue() == 0.0) {
amplitudeDone_ = true;
}
if (amp < 0.0) amp = 0.0;
else if (amp > 1.0) amp = 1.0;
} else if (playMode_ == PlayMode::Gate) {
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
@@ -321,7 +347,7 @@ private:
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
// branch is perfectly predicted.
const double envOut = filterSplineCur_.active()
? filterSplineCur_.eval(splinePhase())
? readSpline(filterSplineCur_, filterSplineSmooth_)
: ((playMode_ == PlayMode::Gate)
? filterEnv_.tick()
: filterAhd_.amplitudeAt(sourceOffset()));
@@ -514,8 +540,9 @@ private:
seedTerminalDeclick();
}
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchSplineCur_.active()
? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase())
const double pitchEnvSemis =
pitchSplineCur_.active()
? pitchSplineDepth_ * readSpline(pitchSplineCur_, pitchSplineSmooth_)
: pitchEnv_.tick();
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
@@ -529,8 +556,8 @@ private:
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// The two rates the shifter takes (pitch_shift.h owns why they are independent):
// the source is FED at stretchRate_, and the tap is SHIFTED by
// 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount,
// the source is FED at feedRate_, and the tap is SHIFTED by baseRatio_ x
// 2^(pitchEnvSemis/12) — the pitch envelope adds to the shift amount,
// never to the read rate. The feed runs one window ahead of readPos_ (the rings were
// primed with that window at start()), under the same sustain-loop wrap rule,
// reading integer source frames into the ring — no RATE-DEPENDENT interpolation
@@ -553,9 +580,10 @@ private:
shiftL_.setShiftRatio(shift);
if (stereoOut) shiftR_.setShiftRatio(shift);
// 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are
// written without producing output; the last rides the ordinary 1-in-1-out
// process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged.
// 0..ceil(kStretchRateMax x sourceRatio_) source frames fall due this output frame
// (StretchCursor::due). All but the LAST are written without producing output; the
// last rides the ordinary 1-in-1-out process(), so a feed of exactly 1.0 walks the
// pre-stretch code path unchanged.
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const std::int64_t due = stretch_.due(stretchRate_);
@@ -607,13 +635,13 @@ private:
outRlocal = shiftedL;
}
}
// Preserve advances the read head at the STRETCH rate — the one duration control.
// Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
// therefore stays a source-frame fact and scales by construction.
// Preserve advances the read head at the FEED rate — the one duration control, in
// source frames. Everything downstream of it (the loop wrap, the Trigger span, the
// spline phase) therefore stays a source-frame fact and scales by construction.
//
// The two sustain-less envelopes are evaluated at sourceOffset(), which advances at
// this rate — rateFittedAhd is what keeps their stage times wall-clock anyway.
ratio_ = stretchRate_;
ratio_ = feedRate_;
} else {
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
@@ -748,13 +776,18 @@ private:
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// The three drawn contours, bound at note-on to the loaded capture's own point arrays (the
// SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor
// inactive, so a purely staged instrument's per-sample path gains three predicted branches
// and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour
// shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
// SampleData outlives the voice — same contract as sample_) and, when the engine observes a
// contour block, re-pointed at its engine's live copy (applyContours). A Staged EG leaves its
// cursor inactive, so a purely staged instrument's per-sample path gains three predicted
// branches and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every
// contour shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
// Each smoother holds the step a live contour swap left, in the contour's own y domain.
SplineCursor ampSplineCur_;
SplineCursor pitchSplineCur_;
SplineCursor filterSplineCur_;
StepSmoother ampSplineSmooth_;
StepSmoother pitchSplineSmooth_;
StepSmoother filterSplineSmooth_;
double splineScale_ = 0.0;
double pitchSplineDepth_ = 0.0;
@@ -816,6 +849,11 @@ private:
PitchShifter shiftR_;
instrument::engine::StretchCursor stretch_;
double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on
// SampleData::sourceRateRatio, latched at note-on: source frames per output frame at unity.
double sourceRatio_ = 1.0;
// stretchRate_ in source frames per output frame (x sourceRatio_): the Preserve read head's
// and feed's exact rate, and the Rate factor every stage-time fit converts by.
double feedRate_ = 1.0;
std::vector<AudioSample> primeBuf_;
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
+27 -1
View File
@@ -52,8 +52,30 @@ void VoiceEngine::applyLiveToActive() {
}
}
void VoiceEngine::applyContoursToActive() {
const instrument::engine::LiveContourBlock* block = sample_.liveContours;
if (block == nullptr) return; // bare engine: every voice keeps its snapshot's contours
const std::uint32_t published = block->generation();
if (published == 0 || published == contourGeneration_) return;
const std::size_t back = contourFront_ ^ 1u;
const std::uint32_t observed = block->read(contours_[back]);
if (observed == 0) return; // torn: the back buffer is discarded, retried next boundary
contourFront_ = back;
contourGeneration_ = observed;
haveContours_ = true;
for (Voice& voice : voices_) {
if (voice.active()) voice.applyContours(contours_[contourFront_], /*snap=*/false);
}
}
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
refreshLive();
// FIRST, so no sounding voice is left bound to a buffer the next read overwrites. A held
// note sharing this generation with the note-on must see it too — refreshLive() alone
// would consume the generation without applying it, and since applyLiveToActive() only
// acts on a NEW generation, the held voice would then never see this publish at all
// (not merely late a block), only catching up whenever the next one arrives.
applyContoursToActive();
applyLiveToActive();
// THE read of the note-on-latched commit class, and the only one: a published block outranks
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
// touches none of these three — so a move reaches the next note and no sounding one.
@@ -65,6 +87,8 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
lengthFraction);
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
// The snapshot's contours are stale after a live edit, exactly as its play params are.
if (haveContours_) voice.applyContours(contours_[contourFront_], /*snap=*/true);
voice.setStartOrder(nextStartOrder_++);
}
@@ -258,6 +282,7 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
// audio thread never touches the heap.
if (out == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop
applyContoursToActive();
for (Voice& voice : voices_) {
if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) {
@@ -274,6 +299,7 @@ void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t fram
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
if (left == nullptr || right == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop
applyContoursToActive();
for (Voice& voice : voices_) {
if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) {
+22
View File
@@ -10,6 +10,7 @@
#include <vector>
#include "core/audio/peaks.h"
#include "core/instrument/engine/live_contours.h"
#include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/voice.h"
@@ -57,6 +58,11 @@ public:
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
bool takeoverDeclick = false);
// Voices may hold raw pointers into this engine's own contour buffers, so a copy would
// leave them reading the original's.
VoiceEngine(const VoiceEngine&) = delete;
VoiceEngine& operator=(const VoiceEngine&) = delete;
// MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the
// index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an
// out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error.
@@ -124,6 +130,22 @@ private:
std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet
bool haveLive_ = false;
// --- Drawn-contour observation (live_contours.h) ---
// Reads a new contour generation, when one landed, into the buffer no voice is bound to and
// rebinds EVERY active voice to it before returning — which is what keeps the other buffer
// free for the next read. That invariant is why this, and never a bare read, is what both
// the block boundary and startVoice call.
void applyContoursToActive();
// Double-buffered because a voice's SplineCursor points INTO the buffer: reading over it
// would change a contour under a sounding voice without the rebind that absorbs the step,
// and a torn read (which the seqlock only reports after the fact) must land somewhere no
// voice reads. ~9 KB each, in the engine, so nothing on the audio thread allocates.
std::array<instrument::engine::LiveContours, 2> contours_{};
std::size_t contourFront_ = 0;
std::uint32_t contourGeneration_ = 0; // last generation observed; 0 = none yet
bool haveContours_ = false;
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const;
+3
View File
@@ -1,6 +1,9 @@
reasampler_pure_library(bridge_marshal SOURCES bridge_marshal.cpp)
reasampler_test(bridge_marshal LINK bridge_marshal)
reasampler_pure_library(sample_roots SOURCES sample_roots.cpp LINK PRIVATE capture_paths)
reasampler_test(sample_roots LINK sample_roots)
reasampler_pure_library(trigger_seam SOURCES trigger_seam.cpp)
# Plain frame arithmetic over doubles: no engine, no value layer, no editor geometry. The
# %-length fold it used to host lives with its siblings in play_params.h.
+11 -5
View File
@@ -309,28 +309,34 @@ std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
return resolveCapture(*ref, params);
}
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) {
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded,
int renderRate) {
SampleData data;
if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
assert(decoded.sampleRate > 0 &&
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
// A header rate this far above the host's is corrupt, not a capture — and playing it would
// put that ratio's worth of feed work on the audio thread per frame (kMaxSourceRateRatio).
if (!sourceRateSupported(decoded.sampleRate, renderRate)) return data;
data.sampleRate = decoded.sampleRate;
data.renderRate = renderRate > 0 ? renderRate : 0;
data.frames = std::move(decoded.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.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
data.framesR = std::move(decoded.framesR);
}
data.sampleRate = decoded.sampleRate;
data.rootNote = resolved.rootNote;
data.loop = resolved.loop;
data.loopCrossfadeFrames = resolved.loopCrossfadeFrames;
data.startFrame = resolved.startFrame;
data.keyTrack = resolved.keyTrack;
data.velocityCurve = resolved.velocityCurve;
// 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(resolved.play, data.sampleRate);
// Resolve the stored wall-clock SECONDS to OUTPUT frames — every stage the engine counts is
// counted per emitted frame, or converted into source frames at note-on. Source-timeline
// params (trigger %-length, start, loop, crossfade) carry through in the WAV's own frames.
data.play = resolvePlay(resolved.play, data.outputRate());
// The one place Preserve's source period is computed: the load, off the audio thread.
// Channel 0 only — a stereo pair's two channels share a fundamental, and the splice
// schedule is linked across them anyway. The span is the sustain loop where one is long
+10 -7
View File
@@ -145,9 +145,9 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
// --- Seconds -> frames --------------------------------------------------------
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
// unchanged. `sampleRate` must be > 0 (the caller guards this).
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against the rate the
// voice EMITS at — the host's (frames = round(seconds * rate)). Source-timeline fields carry
// through unchanged. `sampleRate` must be > 0 (the caller guards this).
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate);
// --- The instrument's ONE parameter set (its OWN state) -----------------------
@@ -263,10 +263,13 @@ DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
// Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine
// plays across the whole keyboard, repitched from the effective root. A second channel is
// carried only when it length-matches channel 0 (SampleData::channelCount() enforces the
// same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to
// frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an
// unplayable SampleData (silence, never a crash).
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded);
// same rule, so a bad pair never half-plays). `renderRate` is the host's rate: the stored
// wall-clock SECONDS resolve to frames against it, and the voice converts the decode's own
// rate to it (SampleData::sourceRateRatio). A non-positive renderRate (the host has not
// reported one yet) renders at the decode's rate. Empty PCM, a non-positive decode rate, or a
// decode rate more than kMaxSourceRateRatio times the host's yields an unplayable SampleData
// (silence, never a crash).
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded, int renderRate);
// The ComponentState envelope + params-payload binary codec lives in component_state_io.h:
// it grows on every envelope bump and is consumed by the extension's preset-blob path too,
+36
View File
@@ -0,0 +1,36 @@
// sample_roots.cpp — see sample_roots.h.
#include "core/instrument/map/sample_roots.h"
#include "core/capture/capture_paths.h" // projectDirOfRpp / resolveBankFile (persist's convention)
namespace reasampler::instrument::map {
std::string ownProjectDir(const void* own, const std::vector<ProjectTab>& open,
const void* activeFallback) {
const void* target = own ? own : activeFallback;
if (!target) return {};
for (const ProjectTab& tab : open) {
if (tab.project == target) return capture::projectDirOfRpp(tab.rppPath);
}
return {};
}
std::vector<std::string> sampleRootCandidates(const std::string& projectDir) {
if (projectDir.empty()) return {};
return {projectDir};
}
std::vector<std::uint8_t> readFromFirstRoot(const std::vector<std::string>& roots,
const std::string& relativePath,
const FileReader& read) {
for (const std::string& root : roots) {
const std::string abs = capture::resolveBankFile(root, relativePath);
if (abs.empty()) continue;
std::vector<std::uint8_t> bytes = read(abs);
if (!bytes.empty()) return bytes;
}
return {};
}
} // namespace reasampler::instrument::map
+47
View File
@@ -0,0 +1,47 @@
// sample_roots — where the instrument looks for a bank-relative sample: the directory of
// the project it LIVES IN, found by identity among the open projects, never the focused
// tab's. Pure: the shell enumerates REAPER's projects and reads files; this decides which
// project, which roots, and in what order.
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
namespace reasampler::instrument::map {
// One enumerated project: its identity (an opaque ReaProject*, compared, never
// dereferenced) and its .rpp path, empty for a never-saved project.
struct ProjectTab {
const void* project = nullptr;
std::string rppPath;
};
// The directory of the project whose identity is `own`; empty when `own` is not among
// `open` or was never saved. Deliberately no fallback to any other project for a
// NON-NULL `own`: another project's bank folder can hold a same-named file, and playing
// it is the wrong-project bug this exists to close.
//
// `activeFallback` is used ONLY when `own` is null (REAPER may not expose
// getReaperParent(3) yet during plugin creation/setState) — then it stands in for `own`
// unchanged, so a still-null fallback (or one absent from `open`) resolves nothing exactly
// as a null `own` always did.
std::string ownProjectDir(const void* own, const std::vector<ProjectTab>& open,
const void* activeFallback = nullptr);
// The roots a bank-relative path is tried against, in order. Only the project directory
// today — the ordered list is the seam a later root (tried after it) extends. Empty when
// there is no project directory, which every caller treats as nothing to play.
std::vector<std::string> sampleRootCandidates(const std::string& projectDir);
using FileReader = std::function<std::vector<std::uint8_t>(const std::string& path)>;
// The bytes of `relativePath` resolved under the first root whose read yields any (a read
// that comes back empty falls through to the next root); empty when none does.
std::vector<std::uint8_t> readFromFirstRoot(const std::vector<std::string>& roots,
const std::string& relativePath,
const FileReader& read);
} // namespace reasampler::instrument::map
+27 -12
View File
@@ -86,16 +86,30 @@ L7 sub-pass, 2026-07-27):
- **The drag-out law is per-move and stateless.** The class is resolved from the
current pointer on every move and again at the release point; nothing is latched
between evaluations. That is what makes every transition reversible and what
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. Leaving
REAPER entirely is the one irreversible transition, because the OS hand-off goes
modal — so it must be PROVEN (a window-ownership test in the shell), never inferred
from a hit-test token REAPER could not name; an unrecognised surface *inside* REAPER
refuses. That proof is necessary but NOT sufficient: a plugin bridged into
`reaper_host*.exe` fails the process test while still being REAPER's own UI, so the
hand-off additionally requires REAPER's hit-test to have named nothing — a recognised
token can only ever veto the hand-off, never produce one. A first-move class lock and
a drag-lifetime "cannot hand off" latch both existed here and were removed — do not
reintroduce either.
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. The OS
hand-off goes modal, so it must be PROVEN by a window test in the shell, never
inferred from a hit-test token REAPER could not name; an unrecognised surface
*inside* REAPER refuses. There are exactly two proofs, each necessary but NOT
sufficient, each paired with a hit-test condition:
- **Leaving REAPER** (process ownership). A plugin bridged into `reaper_host*.exe`
fails the process test while still being inside REAPER, so this route also requires
REAPER's hit-test to have named nothing — here a recognised token can only VETO.
- **A plugin's own editor** (code ownership, `window_origin`). The proof alone would
hand off over any extension window docked in REAPER, so this route also REQUIRES the
FX-surface token; within an FX window the proof is what separates the plugin's editor
(OS hand-off — the plugin takes the files as it would from Explorer) from REAPER's
chrome (instrument drop, unchanged). Our own ReaSampler 9000 editor is exempted from
this proof (`WindowCode::Instrument`) — it is FX chrome we own, so a drop on it is the
instrument-drop outcome like any other REAPER chrome, never the OS hand-off.
Once handed off, the law keeps running: on Windows the OS drag loop re-asks it every
turn (`decideOsDragTurn`) and WITHDRAWS the moment it stops answering `OsHandoff`,
giving the gesture back to the panel — or, at a release, committing REAPER's own
outcome at that point. So neither a pass over a plugin's editor nor a return from
outside REAPER costs a REAPER-internal outcome. SWELL's file drag exposes no such hook,
so off Windows the hand-off stays one-way (and there is no plugin-editor route there).
A first-move class lock and a drag-lifetime "cannot hand off" latch both existed here
and were removed — do not reintroduce either.
## Modules
@@ -103,11 +117,12 @@ L7 sub-pass, 2026-07-27):
- `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.
- `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button.
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it; there is deliberately no off-REAPER member, since no token can carry that fact), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. `OsHandoff` is reachable ONLY through `DropContext::pointerOffHost` — a positive window-ownership proof the shell owes the law (`drag_out_win::pointerOverHostWindow`) — AND only where REAPER's own hit-test named nothing (`Other`, no track), gated ahead of the surface switch, so neither evidence source can give a live drag away to OLE on its own. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it; there is deliberately no off-REAPER member, since no token can carry that fact), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. `OsHandoff` is reachable ONLY through the two proof members of `DropContext` — `pointerOffHost` (`drag_out_win::pointerOverHostWindow`) where REAPER's hit-test named nothing (`Other`, no track), and `pointerOverPluginEditor` (`drag_out_win::pointerOverPluginEditor`) where it named `FxSurface` — both gated ahead of the surface switch, so no evidence source can give a live drag away to OLE on its own (the invariant above states why each pairing). Also owns `decideOsDragTurn`, the OS drag loop's per-turn Continue / Drop / Cancel / Withdraw / WithdrawAtRelease verdict. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
- `window_origin` — whose code implements a window (Host / System / Plugin / Instrument / Unknown, from the owning process and the module holding the window CLASS's procedure) and `isPluginEditorChain`, the rule behind `pointerOverPluginEditor`: any Plugin window from the hit window up to its root. Unknown is never proof, and without the OS directory every non-host module degrades to Unknown — both keep the failure direction toward REAPER's own outcome, never toward the modal hand-off. `Instrument` is the one deliberate exemption: our own ReaSampler 9000 module (`WindowFacts::ownModule`, a basename match against this channel's `app_version::vstOutputName() + ".vst3"` — stable and beta each recognise only their own binary) is FX chrome we own, not a hand-off-worthy plugin editor, so it is never `Plugin` and never counts as `isPluginEditorChain` proof — a drag over our own editor stays the instrument-drop outcome. Every other plugin's module is unaffected. The shell reads the facts; the known misses are listed at `drag_out_win.h`'s `pointerOverPluginEditor`.
- `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure.
- `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers.
- `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching).
- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement, plus `modeSegmentEnabled` — the mode segment's live/dead predicate under the playback gate AND under whether the shell resolved a routable command id for it (both bools passed IN, so this stays REAPER-free).
- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement, plus `modeSegmentEnabled` — the mode segment's live/dead predicate under the playback gate AND under whether the shell resolved a routable command id for it (both bools passed IN, so this stays REAPER-free) — plus `footerReadout`, which decides what the footer's ONE passive readout box says: a MOVING background FX drain pre-empts the per-mode member count, since the drain is transient and the count is not. Its `FxDrainState` mirrors the shell's `FxReclaimActivity` name-for-name (a shell header cannot be included here); the shell owns the decision, this owns only the wording, and the two directions word differently because parking and restoring are different acts.
- `overflow_menu` — pure overflow-menu-button geometry/reserve/hit-test for the top-toolbar More (⋯) button.
- `mode_enable` — pure opposite-mode enablement predicate: given the active mode, computes per-button live/disabled state for the four Item/Track × Arrange/Design tag buttons.
- `insert_fx_enable` — pure refusal fold behind the Insert-as-FX verb (one instance holds one capture, so it needs exactly one focused capture AND a destination track), plus the button's live/dead bit derived from that same fold and one sentence per refusal. The payload/destination axes are ordered deliberately because they are knowable at different times — the header states which half the button may speak for.
+3
View File
@@ -10,6 +10,9 @@ reasampler_test(prune_button LINK prune_button)
reasampler_pure_library(drag_out SOURCES drag_out.cpp)
reasampler_test(drag_out LINK drag_out)
reasampler_pure_library(window_origin SOURCES window_origin.cpp)
reasampler_test(window_origin LINK window_origin)
reasampler_pure_library(theme SOURCES theme.cpp)
reasampler_test(theme LINK theme)
+21 -8
View File
@@ -24,17 +24,24 @@ DropClass decideDropClass(int px, int py, const PanelClientRect& client,
if (!ctx.drag.dragging || !ctx.drag.hasArmedSamples) return DropClass::None;
if (insideClient(px, py, client)) return DropClass::Internal;
// The ONE route to OsHandoff, ahead of the surface switch, and it needs BOTH halves. The
// shell's ownership proof alone is not sufficient: REAPER runs a bridged plugin's UI in a
// separate process, so a window failing that test can still be REAPER's own UI. A recognised
// token therefore VETOES the hand-off instead of producing one — a veto can only move the
// answer toward staying inside REAPER, so pointerOffHost remains the only member whose true
// value can reach OLE. An off-host point REAPER DID name falls through to that surface's own
// outcome below: over a bridged FX window that is the instrument drop, as it is for a native
// one.
// The two routes to OsHandoff, both ahead of the surface switch, each needing BOTH halves.
//
// Leaving REAPER. The process proof alone is not sufficient: REAPER runs a bridged plugin's UI
// in a separate process, so a window failing that test can still be inside REAPER. A
// recognised token therefore VETOES this route instead of producing it — a veto can only move
// the answer toward staying inside REAPER. An off-host point REAPER DID name falls through to
// the second route, then to that surface's own outcome below.
if (ctx.pointerOffHost && ctx.surface == ReaperSurface::Other && !ctx.haveTrack)
return DropClass::OsHandoff;
// A plugin's own editor, with the reverse pairing: REAPER's naming is REQUIRED here, not a
// veto. The code proof alone would hand off over any extension window docked in REAPER; the
// FX-surface token confines it to FX windows, and the proof picks the plugin's editor out of
// that window's REAPER chrome, which keeps its instrument outcome below. A bridged editor
// lands here too — the token that vetoed the first route admits this one.
if (ctx.pointerOverPluginEditor && ctx.surface == ReaperSurface::FxSurface)
return DropClass::OsHandoff;
switch (ctx.surface) {
case ReaperSurface::TrackPanel:
case ReaperSurface::FxSurface:
@@ -100,4 +107,10 @@ OsHandoff decideOsHandoff(const std::vector<std::string>& paths) {
return out;
}
OsDragTurn decideOsDragTurn(bool escapePressed, bool buttonDown, bool lawHandsOff) {
if (escapePressed) return OsDragTurn::Cancel;
if (buttonDown) return lawHandsOff ? OsDragTurn::Continue : OsDragTurn::Withdraw;
return lawHandsOff ? OsDragTurn::Drop : OsDragTurn::WithdrawAtRelease;
}
} // namespace reasampler::ui
+38 -12
View File
@@ -4,9 +4,10 @@
// hit-testing, cursor setting, and outcome execution stay in the shell (panel_drag.cpp +
// instrument_drop_win / arrange_drop_win / drag_out_win).
//
// THE LAW: the class is resolved from what is under the cursor on EVERY move; every transition
// is reversible until release or until the pointer leaves REAPER entirely; the OS hand-off is
// reserved for leaving REAPER (DropContext::pointerOffHost owns what proves that) and every
// THE LAW: the class is resolved from what is under the cursor on EVERY move — and on every turn
// of the OS drag loop once handed off (decideOsDragTurn), so every transition is reversible until
// release wherever the OS loop can be re-asked; the OS hand-off is reserved for targets that are
// not REAPER's own UI (DropContext's two proof members own what establishes that) and every
// REAPER-internal target executes natively on release. Do not reintroduce a first-move class
// lock or a drag-lifetime "blocked" latch.
//
@@ -55,7 +56,7 @@ enum class DropClass {
InstrumentDrop, // a track's panel or FX surface — add ReaSampler 9000 preloaded
ArrangeInsert, // the timeline — place items at the pointer's track and time
Refuse, // a REAPER surface with no defined outcome for this payload — cue it
OsHandoff, // the pointer left REAPER — hand the file list to the OS
OsHandoff, // not REAPER's own UI (off-host, or a plugin's editor) — hand the files to the OS
};
// Everything the law reads. Nothing here is remembered between evaluations: two evaluations at
@@ -65,15 +66,22 @@ struct DropContext {
bool singlePayload = false; // payload holds EXACTLY one capture (arms InstrumentDrop)
ReaperSurface surface = ReaperSurface::Other; // only read outside the client rect
bool haveTrack = false; // GetThingFromPoint returned a non-null MediaTrack*
// THE ONE HOME for the hand-off's evidence rule. This is the only member whose true value
// can reach the irreversible OS hand-off, and it is a POSITIVE proof the shell owes the law
// — a window-ownership test (drag_out_win::pointerOverHostWindow), never "the hit-test named
// nothing I recognise", which is equally true of the transport, the toolbar and the docker
// chrome; reading an unnamed token as an exit is what handed live drags to OLE mid-gesture.
// Defaults false so an unset context stays inside REAPER: the failure mode of forgetting to
// resolve it must be a refusal, never a drag given away to OLE. NECESSARY but not sufficient
// — decideDropClass's gate carries the second half and why it needs one.
// THE ONE HOME for the hand-off's evidence rule. This member and pointerOverPluginEditor are
// the only two whose true value can reach the modal OS hand-off, and each is a POSITIVE proof
// the shell owes the law — a window test, never "the hit-test named nothing I recognise",
// which is equally true of the transport, the toolbar and the docker chrome; reading an
// unnamed token as an exit is what handed live drags to OLE mid-gesture. Both default false
// so an unset context stays inside REAPER: forgetting to resolve one must be a refusal, never
// a drag given away to OLE. Each is NECESSARY but not sufficient — decideDropClass pairs each
// with a hit-test condition, and says why.
//
// Process ownership (drag_out_win::pointerOverHostWindow): the window under the pointer is not
// the host process's.
bool pointerOffHost = false;
// Code ownership (drag_out_win::pointerOverPluginEditor, deciding via ui::window_origin): the
// window under the pointer, or one of its ancestors, is implemented by plugin code — a plugin's
// own editor, which takes files the way it takes them from Explorer.
bool pointerOverPluginEditor = false;
};
// Resolves the class for a drag at pointer (px, py) over `client`. Position-and-context only,
@@ -131,4 +139,22 @@ struct OsHandoff {
// means the internal drag stays live rather than dying half-torn-down.
OsHandoff decideOsHandoff(const std::vector<std::string>& paths);
// --- The OS drag loop, once handed off ---------------------------------------------
// One turn of the OS drag loop. The hand-off does NOT end the law: the loop re-asks it at the
// current pointer every turn, and the moment it stops answering OsHandoff the OS drag is
// WITHDRAWN and the gesture given back to the panel — which is what keeps a pass over a plugin's
// editor, or a return from outside REAPER, from costing the user every REAPER-internal outcome.
enum class OsDragTurn {
Continue, // still over an OS target, button held
Drop, // released over an OS target: the OS delivers the files
Cancel, // Escape: the whole drag ends, nothing happens (unchanged)
Withdraw, // button held over a REAPER surface: resume the internal drag
WithdrawAtRelease, // released over a REAPER surface: resume, then commit it at that point
};
// Escape outranks everything, so a cancel can never be read as a release anywhere.
// `lawHandsOff` is decideDropClass(...) == OsHandoff at the current pointer; ignored on Escape.
OsDragTurn decideOsDragTurn(bool escapePressed, bool buttonDown, bool lawHandsOff);
} // namespace reasampler::ui
+11
View File
@@ -83,4 +83,15 @@ bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routab
return isActiveSegment || (!transportRunning && routable);
}
std::string footerReadout(FxDrainState drain, int activeModeMembers) {
// Enumerated rather than defaulted so a fourth state has to be answered here instead
// of inheriting the count by accident.
switch (drain) {
case FxDrainState::Online: return kFxRestoringReadout;
case FxDrainState::Offline: return kFxParkingReadout;
case FxDrainState::Idle: break;
}
return std::to_string(activeModeMembers) + (activeModeMembers == 1 ? " track" : " tracks");
}
} // namespace reasampler::ui
+22
View File
@@ -13,6 +13,8 @@
// (computeSegmentRects / hitTestSegment) for per-segment tiling — mode_switch stays the one
// owner of segment geometry.
#include <string>
#include "core/ui/prune_button.h" // FooterRect — the shared footer strip input type
namespace reasampler::ui {
@@ -79,4 +81,24 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout);
// `core/ui` — stays REAPER-free, exactly as `transportRunning` is.
bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable);
// Which way the background FX drain is moving, as the readout needs to hear it. Declared
// here rather than taken from the shell's FxReclaimActivity because `core/ui` may not
// include a shell header; the member names are deliberately the shell's, so the one map
// between them (panel_input) is a name-for-name switch and a drift shows up as one.
// Idle is "nothing moving", never "no work queued" — the shell's enum owns that reasoning.
enum class FxDrainState { Idle, Online, Offline };
// Short on purpose: these share the count box, whose default 64px holds roughly a
// dozen glyphs at the panel's smallest font before the shell's kit end-ellipsizes it.
// The two directions read differently because they ARE different acts — Offline
// takes the inactive modes' chains out, Online brings the entering mode's back.
inline constexpr const char* kFxParkingReadout = "parking FX";
inline constexpr const char* kFxRestoringReadout = "restoring FX";
// The footer's ONE passive readout. A moving FX drain pre-empts the per-mode member count
// — the drain is transient and the count is not, so it borrows the slot the user already
// reads rather than earning a second box that this footer's greedy layout would be the
// first to drop on a narrow panel.
std::string footerReadout(FxDrainState drain, int activeModeMembers);
} // namespace reasampler::ui
+62
View File
@@ -0,0 +1,62 @@
// window_origin — pure implementation. See window_origin.h.
#include "core/ui/window_origin.h"
#include <algorithm>
namespace reasampler::ui {
namespace {
// Case-folded (ASCII only — Windows' own directory names are ASCII), separator-unified, with the
// "\\?\" long-path prefix GetModuleFileName may report dropped, so the same directory compares
// equal however each API chose to spell it.
std::string normalizePath(const std::string& p) {
std::string s = p.rfind("\\\\?\\", 0) == 0 ? p.substr(4) : p;
for (char& c : s) {
if (c == '/') c = '\\';
else if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
}
while (!s.empty() && s.back() == '\\') s.pop_back();
return s;
}
// Directory containment, not string prefix: C:\Windows must not claim C:\WindowsApps.
bool isBeneath(const std::string& path, const std::string& dir) {
const std::string p = normalizePath(path);
const std::string d = normalizePath(dir);
return !d.empty() && p.size() > d.size() && p.compare(0, d.size(), d) == 0 &&
p[d.size()] == '\\';
}
} // namespace
WindowCode classifyWindowCode(const WindowFacts& facts, const std::string& systemRoot) {
// Another process's procedure address means nothing in ours; the process boundary is itself
// the proof. Inside an FX window that is the bridged plugin's editor.
if (facts.otherProcess) return WindowCode::Plugin;
if (!facts.procResolved) return WindowCode::Unknown;
if (facts.hostModule) return WindowCode::Host;
if (facts.ownModule) return WindowCode::Instrument;
if (normalizePath(systemRoot).empty()) return WindowCode::Unknown;
return isBeneath(facts.modulePath, systemRoot) ? WindowCode::System : WindowCode::Plugin;
}
bool isPluginEditorChain(const std::vector<WindowCode>& chain) {
return std::find(chain.begin(), chain.end(), WindowCode::Plugin) != chain.end();
}
bool isOwnModulePath(const std::string& path, const std::string& ownName) {
const std::size_t pos = path.find_last_of("\\/");
const std::string base = pos == std::string::npos ? path : path.substr(pos + 1);
if (base.size() != ownName.size()) return false;
for (std::size_t i = 0; i < base.size(); ++i) {
char a = base[i], b = ownName[i];
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a - 'A' + 'a');
if (b >= 'A' && b <= 'Z') b = static_cast<char>(b - 'A' + 'a');
if (a != b) return false;
}
return true;
}
} // namespace reasampler::ui
+49
View File
@@ -0,0 +1,49 @@
#pragma once
// window_origin — whose code implements a window, and whether the window chain under the pointer
// is a plugin's own editor rather than REAPER's chrome. The shell reads the facts (owning process,
// the module holding the window CLASS's procedure); this module decides, so the rule is provable
// without a DAW. Evidence feeding ui::DropContext::pointerOverPluginEditor.
#include <string>
#include <vector>
namespace reasampler::ui {
enum class WindowCode {
Host, // the host executable itself — REAPER's own chrome and containers
System, // an OS module (standard controls, dialog class): says nothing about who placed it
Plugin, // any other code — a plugin binary, or a window of another process (bridged plugin)
Instrument, // our own ReaSampler 9000 module: FX chrome, not a hand-off-worthy plugin editor
Unknown, // the procedure resolved to no loaded module — no evidence either way
};
// What the shell observed about ONE window. The CLASS procedure, not the instance one, because an
// extension subclassing a REAPER window swaps only the instance procedure — reading that would
// make REAPER's own chrome look like foreign code.
struct WindowFacts {
bool otherProcess = false; // the window belongs to a process other than the host's
bool procResolved = false; // the class procedure lies inside a loaded module
bool hostModule = false; // ...and that module is the host executable
// ...and that module is THIS channel's ReaSampler 9000 .vst3. Rationale: core/ui/CLAUDE.md's
// window_origin entry (the ownModule/Instrument exemption).
bool ownModule = false;
std::string modulePath; // that module's file path, UTF-8
};
// `systemRoot` is the OS directory (GetSystemWindowsDirectory); a module beneath it is System.
// An empty root makes every non-host module Unknown rather than Plugin: without it a standard
// button in REAPER's chrome would be indistinguishable from plugin code.
WindowCode classifyWindowCode(const WindowFacts& facts, const std::string& systemRoot);
// `chain` runs from the window under the pointer up to and including its top-level root. True iff
// any window in it is Plugin code: a plugin's editor sits beneath REAPER's container, and a
// standard control INSIDE that editor is still under the plugin's own window. Unknown is never
// proof — the hand-off this gates is modal, so it needs positive evidence.
bool isPluginEditorChain(const std::vector<WindowCode>& chain);
// True iff `path`'s file name (the part after the last separator, forward or back) matches
// `ownName` case-insensitively — see WindowFacts::ownModule for why this decides Instrument vs
// Plugin. `path` may be a bundle path (only the final component is compared).
bool isOwnModulePath(const std::string& path, const std::string& ownName);
} // namespace reasampler::ui
+7
View File
@@ -45,6 +45,11 @@ settled 2026-07-23):
`ViewModeModel::reconcile(liveGuids)`). The same rule binds one level down: a
parked track's per-FX offline state keys on the FX's own identity
(`TrackFX_GetFXGUID`), never its slot — see `fx_offline`.
- **A park record's two halves release independently.** The flag half is spent
the moment a restore runs (`releaseSnapshotFlags`); the FX half survives until
the reclaimer has actually driven that chain back online (`releaseSnapshotFx`)
— releasing both at restore would destroy the only record of which plugins the
user had offline before the park, and a later online would blanket-enable them.
- **Relative/portable state only** in the persisted view section (GUID strings,
mode ids — no absolute paths, no index positions).
- **Show-both semantics.** A per-track "pin visible across modes" flag that
@@ -103,6 +108,8 @@ settled 2026-07-23):
- `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch.
- `guid_diff` — the pure, REAPER-free core of the D2 Wave-2 new-content detection: `newGuids(previous, current)` computes the GUIDs present in `current` but absent from `previous` (empty GUIDs ignored); `GuidBaseline` tracks the live GUID set across polls for one project, implementing the first-poll-after-open guard (the first `observe()` after construction/`reset()` records a baseline and reports nothing new, so pre-existing content is never mass-tagged) and re-arms via `reset()` on a detected project switch so detection never diffs across two unrelated projects.
- `lane_keys` — the pure, REAPER-free convention mapping a fixed lane's durable REAPER name (`P_LANENAME:n`) to the opaque lane-key `view_mode_model` keys by, plus the managed/manual heuristic both the toggle-apply path and the new-content/auto-tag exemption path share: `kManagedLanePrefix` ("reasampler:") stamps every lane the tool mints; `isManagedLaneName`/`managedLaneKey`/`laneNameForMode`/`modeIdFromLaneName` round-trip a lane name ↔ its owning mode id; `isOnManualLane` is the single predicate governing which lanes a toggle may drive and which items are exempt from auto-tag.
- `fx_state` — what the FX chains SHOULD be, derived from view state alone: `desiredFxState` (the whole project's Online/Offline work, Online sorted first), `fxRestoreTargets` (one track's Online targets from its park record), and `resolveFxWork` (one item's move resolved by identity against a live chain). Derived and never persisted; the park record's captured FX half is the only durable half.
- `applied_state` — a comparable fingerprint (`AppliedState`/`appliedState`) of what the tool's last apply actually drove, so a reconcile can ask "did our output drift from what a restore would drive it back to" rather than "did an undo happen" — membership, lane ownership and the solo cache are deliberately excluded.
## Gotchas
+6
View File
@@ -19,6 +19,12 @@ reasampler_test(view_mode_model LINK view_mode_model)
reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model)
reasampler_test(view_tree LINK view_tree)
reasampler_pure_library(fx_state SOURCES fx_state.cpp LINK PUBLIC view_mode_model)
reasampler_test(fx_state LINK fx_state)
reasampler_pure_library(applied_state SOURCES applied_state.cpp LINK PUBLIC view_mode_model)
reasampler_test(applied_state LINK applied_state)
reasampler_pure_library(guid_diff SOURCES guid_diff.cpp)
reasampler_test(guid_diff LINK guid_diff)
+16
View File
@@ -0,0 +1,16 @@
#include "core/view/applied_state.h"
namespace reasampler::view {
AppliedState appliedState(const ViewModeModel& model) {
AppliedState out;
out.activeModeId = model.activeModeId();
for (const auto& [guid, snap] : model.snapshots()) { // std::map ⇒ GUID order
if (!snap.flagsHeld) continue;
out.parked.push_back(
AppliedTrack{guid, snap.showInTcp, snap.showInMixer, snap.mainSend, snap.fxEnable});
}
return out;
}
} // namespace reasampler::view
+55
View File
@@ -0,0 +1,55 @@
#pragma once
// A comparable fingerprint of what an apply put into the project, so a reconcile
// can ask "did our applied output drift from what a restore would drive it back
// to?" rather than "did an undo happen?". The second question is not the same
// one: an undo that touched nothing we drive still answers yes, and repairing on
// it re-enables state the user disabled by hand. Narrower still than "did the
// model change?": membership, lane ownership and the solo cache are excluded by
// design (see AppliedState below), so an edit to any of those changes what a
// future apply must drive while leaving this fingerprint bit-identical. Not a
// sole reapply trigger for anything membership can affect.
#include <string>
#include <vector>
#include "core/view/view_mode_model.h"
namespace reasampler::view {
// One track the tool currently holds parked, and the flag values a restore would
// drive it back to. Per-FX offline is deliberately absent: it is derived, never
// persisted and never in an undo record, so a difference there is evidence of
// nothing about the model.
struct AppliedTrack {
std::string guid;
int showInTcp = 0;
int showInMixer = 0;
int mainSend = 0;
int fxEnable = 0;
bool operator==(const AppliedTrack& o) const {
return guid == o.guid && showInTcp == o.showInTcp &&
showInMixer == o.showInMixer && mainSend == o.mainSend &&
fxEnable == o.fxEnable;
}
bool operator!=(const AppliedTrack& o) const { return !(*this == o); }
};
// Membership edits, lane ownership and the solo cache are all deliberately out:
// they change constantly without changing what the apply drove, and folding them
// in would put a reconcile back to firing on every unrelated edit.
struct AppliedState {
std::string activeModeId;
std::vector<AppliedTrack> parked; // GUID order
bool operator==(const AppliedState& o) const {
return activeModeId == o.activeModeId && parked == o.parked;
}
bool operator!=(const AppliedState& o) const { return !(*this == o); }
};
// A track appears iff its park record still HOLDS its flag half — that is
// precisely the set the tool is currently driving away from the user's values.
AppliedState appliedState(const ViewModeModel& model);
} // namespace reasampler::view
+71
View File
@@ -0,0 +1,71 @@
#include "core/view/fx_state.h"
#include <cstddef>
#include <utility>
namespace reasampler::view {
namespace {
// Slot keying, deliberately: the live chain IS the target set, so its positions
// are current by construction. Identity keying would drop an FX REAPER reports no
// GUID for, and reclaim has to reach those too.
std::vector<FxOfflineOp> offlineTargets(const std::string& trackGuid,
const std::vector<std::string>& liveFxGuids) {
std::vector<FxOfflineOp> ops;
ops.reserve(liveFxGuids.size());
for (std::size_t i = 0; i < liveFxGuids.size(); ++i)
ops.push_back(FxOfflineOp{trackGuid, FxKeying::Slot, liveFxGuids[i],
static_cast<int>(i), true});
return ops;
}
} // namespace
std::vector<FxOfflineOp> fxRestoreTargets(const ViewModeModel& model,
const std::string& trackGuid) {
std::vector<FxOfflineOp> ops;
if (!model.holdsSnapshotFx(trackGuid)) return ops;
const TrackSnapshot* snap = model.snapshot(trackGuid);
ops.reserve(snap->fxOffline.size());
for (std::size_t i = 0; i < snap->fxOffline.size(); ++i) {
const FxOfflineState& fx = snap->fxOffline[i];
ops.push_back(FxOfflineOp{trackGuid, snap->fxKeying, fx.fxGuid,
static_cast<int>(i), fx.offline != 0});
}
return ops;
}
std::vector<FxTrackWork> desiredFxState(const ViewModeModel& model,
const std::string& targetMode,
const std::set<std::string>& liveTrackGuids) {
std::vector<FxTrackWork> online;
std::vector<FxTrackWork> offline;
for (const auto& [guid, snap] : model.snapshots()) { // std::map ⇒ GUID order
if (liveTrackGuids.count(guid) == 0) continue; // dead track — nothing to write
if (!model.leafBelongsToMode(guid, targetMode)) {
offline.push_back(FxTrackWork{guid, FxWorkKind::Offline, {}});
continue;
}
// Active with the FX half already released: the chain is back online and
// the record is only waiting on its flags. Nothing owed either way.
if (!snap.fxHeld) continue;
online.push_back(FxTrackWork{guid, FxWorkKind::Online, fxRestoreTargets(model, guid)});
}
online.reserve(online.size() + offline.size());
for (FxTrackWork& w : offline) online.push_back(std::move(w));
return online;
}
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
const std::vector<std::string>& liveFxGuids) {
if (work.kind == FxWorkKind::Offline)
return resolveFxRestore(offlineTargets(work.trackGuid, liveFxGuids), liveFxGuids);
return resolveFxRestore(work.targets, liveFxGuids);
}
} // namespace reasampler::view
+74
View File
@@ -0,0 +1,74 @@
#pragma once
// What the FX chains SHOULD be, derived from view state: an ordered per-track
// work list plus its resolution against a live chain. Derived, never persisted
// and never in an undo record — the park record's captured FX half is the only
// persisted half. Pure: FX identities are opaque strings the shell reads from
// REAPER (TrackFX_GetFXGUID) and hands in.
#include <set>
#include <string>
#include <vector>
#include "core/view/fx_offline.h"
#include "core/view/view_mode_model.h"
namespace reasampler::view {
enum class FxWorkKind {
Online, // back to the park record's captured per-FX state, verbatim
Offline, // reclaim: every live FX offline
};
// One track's whole FX move, and the ATOMIC unit of work: a drain may stop
// between items, never inside one, so an item is either untouched or complete
// and no partial state survives a tick boundary. Do not reintroduce a
// within-track resume cursor — carrying one across ticks meant reconciling it
// against a chain the user may have edited in between, and the case an FX with
// no reported identity forced (a cursor that reads as "nothing done yet" and
// re-walks the item forever) was a live non-termination hazard.
struct FxTrackWork {
std::string trackGuid;
FxWorkKind kind = FxWorkKind::Offline;
// Online: the captured targets, verbatim — never a blanket "online", which is
// exactly what would lose the plugins the USER had offline before the park.
// Offline: empty. The live chain is the target set, and only the shell can
// read it, so resolveFxWork synthesises the targets from what it is handed.
std::vector<FxOfflineOp> targets;
bool operator==(const FxTrackWork& o) const {
return trackGuid == o.trackGuid && kind == o.kind && targets == o.targets;
}
};
// The whole project's FX work, from view state alone — no folder tree, because a
// park record is only ever written for a track the planner already decided to
// park (parents and show-both leaves never get one).
//
// ONLINE WORK SORTS FIRST: onlining restores state the user owns, offlining is
// opportunistic reclaim that may be deferred indefinitely without losing
// anything. Within a kind, GUID order — deterministic, so a drain's position in
// the list survives a re-derivation.
//
// `targetMode` is explicit, mirroring planToggle, rather than read from
// `model.activeModeId()`: applyMode sets the active mode LAST, after its park
// and restore loops, so a caller reading it from inside or before that block
// would classify every track against the OUTGOING mode instead.
std::vector<FxTrackWork> desiredFxState(const ViewModeModel& model,
const std::string& targetMode,
const std::set<std::string>& liveTrackGuids);
// One track's Online targets from its park record's FX half; empty when the
// record holds none. Each target carries the record's keying, so a snapshot
// lifted from a pre-identity project still restores by slot.
std::vector<FxOfflineOp> fxRestoreTargets(const ViewModeModel& model,
const std::string& trackGuid);
// Resolves one work item against the chain as it stands, where `liveFxGuids[i]`
// is the identity of the FX at slot `i` right now (empty when REAPER reported
// none) — the item's WHOLE move, matched by identity: an identity that is not
// live is DROPPED, never re-pointed at a slot.
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
const std::vector<std::string>& liveFxGuids);
} // namespace reasampler::view
+62 -20
View File
@@ -188,7 +188,7 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
return plan;
}
TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
TrackPlan makeParkPlan(const std::string& guid) {
TrackPlan p;
p.flags = {
{guid, Flag::ShowInTcp, 0},
@@ -196,13 +196,6 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
{guid, Flag::MainSend, 0},
{guid, Flag::FxEnable, 0},
};
// Park has no identity question to answer: it offlines every slot that is
// live right now, so slot keying IS the addressing. In production fxCount
// is always 0 here — planToggle calls this with fxCount=0 and the D2 shell
// expands the real writes itself via TrackFX_GetCount (parkFxOffline in
// shell/view/view.cpp); a nonzero fxCount only exercises this loop in tests.
for (int i = 0; i < fxCount; ++i)
p.fxOffline.push_back(FxOfflineOp{guid, FxKeying::Slot, {}, i, true});
return p;
}
@@ -214,11 +207,6 @@ TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
{guid, Flag::MainSend, snap.mainSend},
{guid, Flag::FxEnable, snap.fxEnable},
};
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i) {
const FxOfflineState& fx = snap.fxOffline[i];
p.fxOffline.push_back(FxOfflineOp{guid, snap.fxKeying, fx.fxGuid,
static_cast<int>(i), fx.offline != 0});
}
return p;
}
@@ -253,6 +241,49 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
return it == snapshots_.end() ? nullptr : &it->second;
}
bool ViewModeModel::holdsSnapshotFlags(const std::string& guid) const {
const TrackSnapshot* s = snapshot(guid);
return s && s->flagsHeld;
}
bool ViewModeModel::holdsSnapshotFx(const std::string& guid) const {
const TrackSnapshot* s = snapshot(guid);
return s && s->fxHeld;
}
namespace {
// A record with neither half left records nothing — keeping it would make
// snapshot() answer yes to a track the model no longer describes.
void eraseIfSpent(std::map<std::string, TrackSnapshot>& snapshots,
std::map<std::string, TrackSnapshot>::iterator it) {
if (!it->second.flagsHeld && !it->second.fxHeld) snapshots.erase(it);
}
} // namespace
void ViewModeModel::releaseSnapshotFlags(const std::string& guid) {
auto it = snapshots_.find(guid);
if (it == snapshots_.end()) return;
it->second.flagsHeld = false;
it->second.showInTcp = 0;
it->second.showInMixer = 0;
it->second.mainSend = 0;
it->second.fxEnable = 0;
eraseIfSpent(snapshots_, it);
}
void ViewModeModel::releaseSnapshotFx(const std::string& guid) {
auto it = snapshots_.find(guid);
if (it == snapshots_.end()) return;
it->second.fxHeld = false;
it->second.fxOffline.clear();
// fxKeying is left as captured: the record's own keying, not a claim about
// one it never had. Harmless today (fxOffline is empty right above), but
// whoever reads fxKeying after this point should see the truth.
eraseIfSpent(snapshots_, it);
}
std::size_t ViewModeModel::reconcile(const std::set<std::string>& liveGuids) {
// See header: snapshots are pruned, membership is not (undo-delete rationale).
soloCache_.reconcile(liveGuids);
@@ -322,11 +353,12 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string&
const bool active = leafBelongsToMode(guid, targetMode);
if (active) {
if (const TrackSnapshot* snap = snapshot(guid))
plan.restore.push_back(makeRestorePlan(guid, *snap));
// holdsSnapshotFlags, not snapshot(): a record whose FX half is still
// draining carries zeroed flags, and restoring THOSE would re-park it.
if (holdsSnapshotFlags(guid))
plan.restore.push_back(makeRestorePlan(guid, *snapshot(guid)));
} else {
// fxOffline is empty here; the D2 shell expands it via TrackFX_GetCount.
plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0));
plan.park.push_back(makeParkPlan(guid));
}
}
@@ -370,11 +402,15 @@ std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;
// Version ladder for the stored blob, under the FOREVER-STABLE "view_state" key.
// Only the per-FX snapshot shape has ever moved:
//
// v1 "snapshots":[{...,"fxOffline":[0,1,0]}] — offline state by SLOT
// v2 "snapshots":[{...,"fxOffline":[0,1,0], — the v1 array, still written
// "fx":[{"guid":"{..}","offline":0},...]}] — by FX IDENTITY
// v3 adds "flagsHeld"/"fxHeld", each written ONLY when false. A record's two
// halves are released independently, and absent-means-held keeps every v1/v2
// blob (and every fully-held v3 record) byte-identical to what v2 wrote. A v2
// reader sees a half-released record as fully held, which restores flags it
// should not — the same leniency direction the version field itself takes.
//
// v2 writes BOTH: "fx" is what this build reads, and the v1 array is what a build
// that predates identity keying reads — a downgrade keeps exactly the behavior it
@@ -387,7 +423,7 @@ using ObjWriter = json::Writer;
// view_state falls back to a default model, which loses every membership tag, so
// leniency is the safe direction here — the opposite call from origin_ledger,
// where a misread blob would put prune's deletion authority on bad data.
constexpr int kViewStateVersion = 2;
constexpr int kViewStateVersion = 3;
} // namespace
@@ -439,7 +475,7 @@ std::string ViewModeModel::serialize() const {
out += ']';
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable,
// fxOffline[], fx[] } — see the version ladder above.
// fxOffline[], flagsHeld?, fxHeld?, fx[] } — see the version ladder above.
root.keyBegin("snapshots");
out += '[';
{
@@ -460,6 +496,10 @@ std::string ViewModeModel::serialize() const {
e.keyBegin("fxOffline");
writeIntArray(out, bySlot);
// Absent means held — see the version ladder above.
if (!snap.flagsHeld) e.keyRaw("flagsHeld", "false");
if (!snap.fxHeld) e.keyRaw("fxHeld", "false");
// A Slot-keyed snapshot has no identities to write — emitting an
// "fx" array for it would invent the very keys it lacks.
if (snap.fxKeying == FxKeying::Identity) {
@@ -634,6 +674,8 @@ bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps
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(bySlot)) return false; haveSlot = true; }
else if (k == "flagsHeld") { if (!r.parseBool(snap.flagsHeld)) return false; }
else if (k == "fxHeld") { if (!r.parseBool(snap.fxHeld)) return false; }
else if (k == "fx") {
if (!parseFxStates(r, byIdentity)) return false;
haveIdentity = true;
+37 -17
View File
@@ -196,6 +196,11 @@ struct FolderTree {
// parking — restore's source of truth. Ints, not bools, so a snapshot
// faithfully round-trips whatever REAPER reported (defensive against
// non-0/1 values).
//
// The two halves are released INDEPENDENTLY. The flag half is spent the moment a
// restore writes it back; the FX half has to outlive that, because it is the only
// record of which plugins the USER had offline before the park — release it with
// the flag half and a later online blanket-enables them.
struct TrackSnapshot {
int showInTcp = 0; // B_SHOWINTCP prior value
int showInMixer = 0; // B_SHOWINMIXER prior value
@@ -208,15 +213,19 @@ struct TrackSnapshot {
std::vector<FxOfflineState> fxOffline;
FxKeying fxKeying = FxKeying::Identity;
bool flagsHeld = true; // false ⇒ a restore already wrote the flags back
bool fxHeld = true; // false ⇒ the chain is back online; nothing left to restore
bool operator==(const TrackSnapshot& o) const {
return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
mainSend == o.mainSend && fxEnable == o.fxEnable &&
fxOffline == o.fxOffline && fxKeying == o.fxKeying;
fxOffline == o.fxOffline && fxKeying == o.fxKeying &&
flagsHeld == o.flagsHeld && fxHeld == o.fxHeld;
}
};
// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it
// is per-slot, variable length) — see TrackParkPlan::fxOffline.
// Which scalar flag a TrackFlagOp drives. FX-offline is not one of these and is
// not planned beside them — it is derived from view state (core/view/fx_state).
enum class Flag {
ShowInTcp, // B_SHOWINTCP
ShowInMixer, // B_SHOWINMIXER
@@ -249,14 +258,12 @@ struct LanePlayOp {
}
};
// The complete set of operations to park one inactive leaf, or restore one
// leaf. Park uses fixed zeros; restore uses a snapshot's values. Park's
// fxOffline ops are slot-keyed (every live slot goes offline); restore's carry
// the snapshot's keying and are resolved against the live chain by
// resolveFxRestore before any write.
// One leaf's FLAG-DOMAIN plan: park writes fixed zeros, restore writes the
// snapshot's captured values. Per-FX offline is deliberately absent — it is a
// derived function of view state (core/view/fx_state), not a peer of the flags,
// and carrying it here only ever produced an always-empty park-side vector.
struct TrackPlan {
std::vector<TrackFlagOp> flags;
std::vector<FxOfflineOp> fxOffline;
};
// The plan for a toggle to a target mode. Parents and show-both leaves never
@@ -295,11 +302,27 @@ public:
bool setActiveMode(const std::string& modeId);
// The shell calls store before it parks a track, so restore survives a save.
// Both halves are held from here; each is released by the act that spends it.
void storeSnapshot(const std::string& guid, const TrackSnapshot& snap);
void clearSnapshot(const std::string& guid);
void clearSnapshot(const std::string& guid); // both halves at once
const TrackSnapshot* snapshot(const std::string& guid) const;
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
// A held half is one nothing has spent yet. `snapshot()` returning non-null is
// NOT that question: a record outlives its flag half, so a caller that reads
// the flag values off a record without asking here will drive stale ones.
bool holdsSnapshotFlags(const std::string& guid) const;
bool holdsSnapshotFx(const std::string& guid) const;
// Spent by the restore that writes the captured flags back. Zeroes them, so a
// record that no longer claims them cannot be read for them either.
void releaseSnapshotFlags(const std::string& guid);
// Spent only once the chain is ACTUALLY back online — not by the flag restore.
// Releasing both together destroys the only record of which plugins the user
// had offline before the park, and a later online then blanket-enables them.
void releaseSnapshotFx(const std::string& guid);
// Drops every snapshot whose GUID is NOT in `liveGuids`, and prunes the solo
// cache the same way (see SoloCache::reconcile). Returns the count of
// SNAPSHOTS removed — the solo cache's own count is available from it directly.
@@ -330,10 +353,7 @@ public:
// restored from it. Parents and show-both leaves are never parked.
// Untagged leaves are Arrange members and park/restore accordingly. Tree
// membership is the enumeration source, so stale membership GUIDs absent
// from the tree are ignored.
//
// Park plans here carry an empty fxOffline vector — the D2 shell expands
// per-FX offline writes via TrackFX_GetCount (not available to the pure model).
// from the tree are ignored. Flag domain only; FX comes from desiredFxState.
TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const;
// Managed lanes only, from the ownership index — the set a toggle may
@@ -357,11 +377,11 @@ private:
view::SoloCache soloCache_; // modeId -> guid -> raw I_SOLO
};
// Fixed-zero park plan for one leaf, offlining `fxCount` slots.
TrackPlan makeParkPlan(const std::string& guid, int fxCount);
// Fixed-zero park plan for one leaf.
TrackPlan makeParkPlan(const std::string& guid);
// Restore plan for one leaf from its snapshot — every flag to its captured
// value, never a default.
// value, never a default. Callers must have checked holdsSnapshotFlags.
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap);
// Auto-tag decision: new content takes the active mode at creation; the
+4 -3
View File
@@ -1,8 +1,9 @@
#pragma once
// instrument_drop — pure payload-construction core of drop-and-load: dropping a
// bank capture onto a track's panel or FX surface instantiates ReaSampler 9000 on
// that track already playing that capture. Also the classifier that names those
// surfaces. No REAPER/SWELL/VST3 SDK/vendor includes
// bank capture onto a track's panel or an FX window's REAPER chrome instantiates
// ReaSampler 9000 on that track already playing that capture (a plugin's own editor
// inside an FX window takes the files instead — ui::decideDropClass). Also the
// classifier that names those surfaces. No REAPER/SWELL/VST3 SDK/vendor includes
// (+ the pure sample_map it reuses and the SDK-free UID macros in
// reasampler_uid.h); unit-tested outside the DAW.
//
+22 -6
View File
@@ -29,9 +29,25 @@ is owned by other directories and only skinned here.
separation is load-bearing here same as everywhere else. Only the arrange-capture
surface writes the `assignment_request` wire; Media-Explorer import and file-drop
onto the bank panel do not, and neither affects a live instance's selection.
- **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.
- **Every undo block in this directory is opened through `shell/undo`'s
`UndoBlock`**, never `Undo_BeginBlock2` directly — the ingest surfaces, the two
drop shells and the Design View membership actions alike. That is what keeps a
user's act its own correctly-labelled Ctrl-Z while a Design View mode switch's FX
reclaim span is open; the mechanism and its compile-time enforcement are
`src/shell/undo/CLAUDE.md`'s. Bank index verbs additionally stay one batched
`UNDO_STATE_MISCCFG` point apiece, so one bank operation is one Ctrl-Z.
- **A Design View membership edit's undo point holds its `view_state` write — so the
persist runs INSIDE the block, and the point turns on a MODEL DIFF, not on "REAPER
was written to".** All four membership actions (tag, untag / tag→Arrange, show-both,
the item moves) share one body, `design_view_actions`' `runMembershipEdit`.
Everything the reapply they drive writes is the four driven track flags, which mint
no undo state at all (`src/shell/view/CLAUDE.md`), so a point closed BEFORE the
ext-state write covered no change: the edit was not undoable and rode whatever point
the user's next act minted. Two consequences bind. The persist's modal Save-As half
is split off and runs before the block opens — a modal may not hold a block open.
And a no-op edit records no label, because a recorded label re-titles the Undo button
whether or not REAPER appends an entry. Same shape as `shell/bank_ops`'
`persistBankOp`, for the same reason.
- **The prune action is the ONLY file-deletion action in the system**; it opens no
undo point (file deletion is not REAPER-undoable). It halts on
`blockedByTracking` and prints whichever blockers fired — the malformed ledger,
@@ -39,10 +55,10 @@ is owned by other directories and only skinned here.
## Modules
- `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). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery 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; `src/shell/bank_ops/CLAUDE.md` owns the one-op-one-Ctrl-Z rule those verbs enforce. 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). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
- `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body.
- `drag_out_win` — the host/OS boundary of drag-out: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. Also owns `pointerOverHostWindow`, the window-ownership test behind `ui::DropContext::pointerOffHost` (`core/ui/CLAUDE.md` owns the hand-off rule it feeds) — process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only. Its shipped exception: a plugin bridged into `reaper_host*.exe` reports off-host though the pointer never left REAPER.
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns) — it answers only what REAPER named, never whether the pointer is still in REAPER — and the drop half 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.**
- `drag_out_win` — the host/OS boundary of drag-out: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. Owns the two window tests behind `ui::DropContext`'s hand-off proofs (`core/ui/CLAUDE.md` owns the rule they feed). `pointerOverHostWindow` is process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only; its shipped exception: a plugin bridged into `reaper_host*.exe` reports off-host though the pointer never left REAPER. `pointerOverPluginEditor` walks from the hit window to its root reading each window's owning process and the module of its CLASS procedure (`GetClassLongPtr(GCLP_WNDPROC)` → `GetModuleHandleEx(FROM_ADDRESS)`), and lets `ui::window_origin` decide; its failure modes are listed at the declaration, and it is always false under SWELL. It also sets `WindowFacts::ownModule` — a basename match of the resolved module against this channel's own `reasampler_9000[_beta].vst3` (`app_version::vstOutputName() + ".vst3"`) — so our own instrument's editor classifies as `Instrument`, not `Plugin`, and a drag over it stays the instrument-drop outcome instead of handing off to the OS; every other plugin's editor is unaffected. The OLE drop source re-asks the gesture law on every `QueryContinueDrag` (a function pointer the panel supplies) and withdraws the drag per `ui::decideOsDragTurn`, reporting it as `OsDragEnd::Withdrawn*` so the panel resumes its own gesture. A drop into a plugin means the extension writes nothing to the bank and inserts no timeline item — the plugin receives a copy-only path list, exactly as from Explorer (the plugin's own reaction to that path list is outside the extension's control).
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns) — it answers only what REAPER named, never whether the pointer is still in REAPER or over a plugin's own editor (so an FX surface reaches the instrument drop only as its chrome; a plugin's editor inside it hands off to the OS) — and the drop half 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.**
- `insert_fx_action` — the "Insert as FX" verb: a second ENTRY POINT to `instrument_drop_win`'s drop body (the bank panel's Placement button and a bindable action), loading the panel's focused capture onto the first selected track. Owns no insert logic of its own — it resolves the selection, folds `core/ui/insert_fx_enable`, and calls `performInstrumentDrop` (which keeps the one undo block). Refuses visibly on every failing precondition; the panel button additionally paints dead for the two panel-local ones.
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
- `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Builds and shows every message the import produces, but the ledger-refusal body itself is `core/package::ledgerRefusalMessage` — a pure fold this TU only supplies the channel-correct namespace to — so the wording is assertable without a DAW. `doImportBankPackage`/`doImportBankPackageFile` return the minted bank id on a landed import (empty otherwise) so a caller can focus it; the verb itself is promptless.
+3 -7
View File
@@ -29,6 +29,7 @@
#include <vector>
#include "core/capture/insert_plan.h" // computeInsertMode — the ONE InsertMedia bitfield owner
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "reaper_plugin.h"
@@ -42,8 +43,6 @@
#define REAPERAPI_WANT_SetOnlyTrackSelected
#define REAPERAPI_WANT_SetTrackSelected
#define REAPERAPI_WANT_SnapToGrid
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -98,7 +97,7 @@ int performArrangeDrop(MediaTrack* track, double time,
// One undo block around the whole drop (every item plus the selection/cursor restore) so a
// single Ctrl-Z returns the project to exactly its pre-drop state.
Undo_BeginBlock2(nullptr);
UndoBlock undo(nullptr);
SetOnlyTrackSelected(track);
SetEditCurPos(SnapToGrid(nullptr, time), /*moveview=*/false, /*seekplay=*/false);
@@ -111,9 +110,6 @@ int performArrangeDrop(MediaTrack* track, double time,
if (InsertMedia(path.c_str(), mode) != 0) ++inserted;
}
// A fixed stack buffer, not std::string concatenation: the prior shape built the label with
// std::to_string + `+` between the restore below and Undo_EndBlock2, so a bad_alloc there
// would leave an unbalanced undo block open. snprintf here removes the allocation outright.
char label[64];
std::snprintf(label, sizeof(label), "ReaSampler: drop %d %s onto arrange", inserted,
inserted == 1 ? "capture" : "captures");
@@ -122,7 +118,7 @@ int performArrangeDrop(MediaTrack* track, double time,
SetEditCurPos(priorCursor, /*moveview=*/false, /*seekplay=*/false);
// extraflags -1 = UNDO_STATE_ALL, matching the insert action's own block.
Undo_EndBlock2(nullptr, label, -1);
undo.recordPoint(label, -1);
return inserted;
}
+129 -52
View File
@@ -25,7 +25,10 @@
#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)
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "shell/view/view.h" // applyMode (D2 shell)
#include "shell/view/view_lanes.h" // mintManagedLanes
#include "shell/view/view_park.h" // kViewUndoMask
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks
@@ -38,8 +41,6 @@
#define REAPERAPI_WANT_Main_SaveProject
#define REAPERAPI_WANT_Help_Set
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -103,7 +104,11 @@ std::vector<std::string> selectedTrackGuids() {
}
// Reapplies the CURRENT active mode so a membership mutation takes visible effect
// immediately (park/unpark/re-derive parents).
// immediately (park/unpark/re-derive parents). `applied` is deliberately discarded: a
// refused reapply leaves the membership edit standing and unapplied, and the user's
// next edit or mode switch reapplies it. What must NOT be dropped with it is the
// reclaimer's queue, which is why applyMode invalidates on its refusal path too
// (view.cpp).
void reapplyActiveMode() {
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
}
@@ -139,33 +144,45 @@ std::vector<RetagItem> selectedRetagItems() {
// serialization. Every other action keeps the full save.
enum class PersistScope { Full, ViewOnly };
// Persists the bank + Design-View model after every state-changing action so the
// view model is not lost across save/close/reopen. If membership is non-empty and
// the project is unsaved, prompts Save-As first (mirrors the flow capture uses) —
// DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed.
void persistViewState(PersistScope scope) {
if (!g_session->view().membership().empty()) {
// The persist's MODAL half, split from the write below so a caller can run the two on
// opposite sides of an undo block. If membership is non-empty and the project is
// unsaved, prompts Save-As (mirrors the flow capture uses) — DAW-ONLY:
// Main_SaveProject(proj, true) blocks until the dialog is dismissed. False means the
// user declined and nothing may be written.
bool ensurePersistTarget() {
if (g_session->view().membership().empty()) return true;
ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (proj) {
if (!proj) return true; // no active project — the write below no-ops on its own
auto readRppPath = [&]() -> std::string {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return std::string(buf.data());
};
if (readRppPath().empty()) {
if (!readRppPath().empty()) return true;
Main_SaveProject(proj, true);
if (readRppPath().empty()) { // still empty -> user cancelled
ShowConsoleMsg(
"ReaSampler: Design View state will not persist until "
if (!readRppPath().empty()) return true; // still empty -> user cancelled
ShowConsoleMsg("ReaSampler: Design View state will not persist until "
"the project is saved.\n");
return;
return false;
}
// The persist's WRITE half: the bank + Design-View model, so neither is lost across
// save/close/reopen. Returns whether the write was ISSUED — false means no active
// project or an unsaved one, with NOTHING written (session.h owns that contract), which
// is the only reading a caller may turn into a discarded undo point.
bool writePersistedState(PersistScope scope) {
return scope == PersistScope::ViewOnly ? g_session->saveViewStateOnly()
: g_session->saveToActiveProject();
}
}
}
if (scope == PersistScope::ViewOnly) g_session->saveViewStateOnly();
else g_session->saveToActiveProject();
// The two halves as a caller holding no undo block runs them.
void persistViewState(PersistScope scope) {
if (ensurePersistTarget()) writePersistedState(scope);
}
// The footer segment already reads disabled while the transport runs, but an action can
@@ -184,77 +201,137 @@ 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)
if (!applyMode(g_session->view(), target, nullptr)) { reportModeSwitchRefused(); return; }
if (!applyMode(g_session->view(), target, nullptr).applied) {
reportModeSwitchRefused();
return;
}
persistViewState(PersistScope::ViewOnly);
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
}
// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if
// Direct jump to a named mode. applyMode is a no-op (`applied` false, no mutation) if
// the id is unregistered or the transport is running, so both fail safe.
void doActivateMode(const std::string& modeId) {
if (!applyMode(g_session->view(), modeId, nullptr)) { reportModeSwitchRefused(); return; }
if (!applyMode(g_session->view(), modeId, nullptr).applied) {
reportModeSwitchRefused();
return;
}
persistViewState(PersistScope::ViewOnly);
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
}
// The drive half of a membership edit: what runs inside the block, answering whether
// it made a project change of its own that a point must hold even with nothing
// persisted. The reapply alone never does — everything it writes is the four driven
// flags, which mint no undo state (src/shell/view/CLAUDE.md).
bool driveReapply() {
reapplyActiveMode();
return false;
}
// The item moves' drive: re-drive the same minting/apply path auto-tag uses — mint or
// split lanes for any track whose items now span modes, then reassert active-mode lane
// visibility. A mint moves items between lanes, which is a real project change whether
// or not the persist below lands.
bool driveMintLanesAndReapply() {
const bool minted = mintManagedLanes(g_session->view(), nullptr);
reapplyActiveMode();
return minted;
}
// The ONE body behind all four membership edits (this directory's CLAUDE.md owns the
// rule; what follows is why the ORDER is what it is). `mutate` is a pure model edit —
// nothing in it reaches REAPER.
//
// The prompt sits between the edit and the block because it is pinned on both sides:
// its condition reads the POST-edit membership (a first-ever tag on an unsaved project
// must still prompt), and a modal may not stand inside an open block. The persist then
// runs INSIDE, being the only thing here a point can hold; a re-tag of tracks already
// in that mode re-writes byte-identical ext state, which is why the label turns on the
// model diff and not on the write.
template <class Mutate>
void runMembershipEdit(Mutate&& mutate, bool (*drive)(), const std::string& label,
int mask) {
const MembershipIndex before = g_session->view().membership();
mutate();
const bool changed = !(g_session->view().membership() == before);
const bool persistable = ensurePersistTarget();
UndoBlock undo(nullptr);
const bool structural = drive();
const bool persisted = persistable && writePersistedState(PersistScope::Full);
if (structural || (changed && persisted))
undo.recordPoint(label, mask);
// Otherwise the block closes on its ("", 0) discard form — real from a hand-fired
// action (DAW-measured, src/shell/view/CLAUDE.md) — leaving history and the Undo
// button both untouched.
}
// tag() replaces any prior single-mode membership (a leaf lives in one mode; the
// cross-mode case is show-both).
void doTag(const std::string& modeId) {
for (const std::string& g : selectedTrackGuids())
g_session->view().membership().tag(g, modeId);
reapplyActiveMode();
persistViewState(PersistScope::Full);
const std::vector<std::string> guids = selectedTrackGuids();
if (guids.empty()) return; // nothing selected — no act, no undo point (cf. doMoveItems)
runMembershipEdit(
[&] {
for (const std::string& g : guids) g_session->view().membership().tag(g, modeId);
},
&driveReapply, "ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask);
}
// Shared body behind "Untag selected" and "Tag -> Arrange" — Arrange is the absence
// of a tag, so the two actions are the same act.
void doUntag() {
for (const std::string& g : selectedTrackGuids())
g_session->view().membership().untag(g);
reapplyActiveMode();
persistViewState(PersistScope::Full);
const std::vector<std::string> guids = selectedTrackGuids();
if (guids.empty()) return;
runMembershipEdit(
[&] {
for (const std::string& g : guids) g_session->view().membership().untag(g);
},
&driveReapply, "ReaSampler: untag selected tracks", kViewUndoMask);
}
// Flips each track's pin independently — the honest semantics of a toggle on a
// multi-selection (a mixed selection converges toward uniform only if it already was).
void doShowBoth() {
const std::vector<std::string> guids = selectedTrackGuids();
if (guids.empty()) return;
runMembershipEdit(
[&] {
MembershipIndex& m = g_session->view().membership();
for (const std::string& g : selectedTrackGuids())
m.setShowBoth(g, !m.isShowBoth(g));
reapplyActiveMode();
persistViewState(PersistScope::Full);
for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g));
},
&driveReapply, "ReaSampler: toggle show-both on selected tracks", kViewUndoMask);
}
// Retag the current ITEM selection to `targetMode` (empty => untag -> Arrange
// default). planItemRetag decides which items to retag (manual-lane items are
// EXEMPT), upholding the managed-lanes-only invariant. Wrapped in ONE Undo block.
// EXEMPT), upholding the managed-lanes-only invariant.
//
// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog, which must NOT sit
// inside the Undo block, so we close the block first, then persist.
// The mask stays UNDO_STATE_ALL rather than the view mask the three track actions
// close on: this one can mint lanes, and the fixed-lane properties are only ASSUMED to
// ride UNDO_STATE_ITEMS (see kViewUndoMask).
void doMoveItems(const std::string& targetMode) {
const std::vector<RetagItem> selected = selectedRetagItems();
const std::vector<ItemRetagOp> ops = planItemRetag(selected, targetMode);
if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty
runMembershipEdit(
[&] {
MembershipIndex& membership = g_session->view().membership();
Undo_BeginBlock2(nullptr);
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, then reassert active-mode lane visibility.
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(PersistScope::Full);
},
&driveMintLanesAndReapply,
targetMode.empty() ? std::string("ReaSampler: untag selected items")
: "ReaSampler: move selected items -> " + targetMode,
-1);
}
} // namespace
+151 -15
View File
@@ -12,6 +12,10 @@
#include <shlobj.h> // DROPFILES, CF_HDROP
#include <cstring>
#include "core/ui/drag_out.h" // decideOsDragTurn — the OS loop's per-turn verdict
#include "core/ui/window_origin.h" // classifyWindowCode / isPluginEditorChain
#include "core/version/app_version.h" // vstOutputName — this channel's own .vst3 basename
namespace reasampler {
namespace {
@@ -86,10 +90,16 @@ bool ensureOleForThisThread() {
return state > 0;
}
// Minimal IDropSource: continue until the (left) button releases or Escape cancels; always
// request the copy cursor. This is the standard textbook drop source — no custom feedback.
// IDropSource: the textbook button/Escape source, plus the law re-asked every turn so the drag can
// be withdrawn back to the panel (ui::decideOsDragTurn). Always requests the default cursors.
// [verify — DAW] QueryContinueDrag's cadence: MSDN words it as called on a keyboard or
// mouse-BUTTON state change, so on plain moves it may not run; if so a mid-drag withdrawal waits
// for the next state change, but the release — a button change — is still decided here.
class DropSource final : public IDropSource {
public:
explicit DropSource(bool (*lawHandsOff)()) : lawHandsOff_(lawHandsOff) {}
ui::OsDragTurn withdrawal() const { return withdrawal_; }
// IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override {
if (riid == IID_IUnknown || riid == IID_IDropSource) {
@@ -108,15 +118,33 @@ public:
}
// IDropSource
HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escapePressed, DWORD keyState) override {
if (escapePressed) return DRAGDROP_S_CANCEL;
if (!(keyState & MK_LBUTTON)) return DRAGDROP_S_DROP; // released -> drop
return S_OK; // keep dragging
const bool escape = escapePressed != FALSE;
// Escape ignores the law, so it is not probed then; no callback is the textbook source.
const bool lawHandsOff = escape || !lawHandsOff_ || lawHandsOff_();
const ui::OsDragTurn turn =
ui::decideOsDragTurn(escape, (keyState & MK_LBUTTON) != 0, lawHandsOff);
switch (turn) {
case ui::OsDragTurn::Continue:
return S_OK;
case ui::OsDragTurn::Drop:
return DRAGDROP_S_DROP;
case ui::OsDragTurn::Cancel:
return DRAGDROP_S_CANCEL;
case ui::OsDragTurn::Withdraw:
case ui::OsDragTurn::WithdrawAtRelease:
withdrawal_ = turn; // the target sees a cancel (DragLeave), never a drop
return DRAGDROP_S_CANCEL;
}
return DRAGDROP_S_CANCEL;
}
HRESULT STDMETHODCALLTYPE GiveFeedback(DWORD /*effect*/) override {
return DRAGDROP_S_USEDEFAULTCURSORS; // let OLE draw the standard copy cursor
}
private:
volatile LONG refs_ = 1;
bool (*lawHandsOff_)() = nullptr;
// Continue doubles as "not withdrawn": it is never the turn that ends the loop.
ui::OsDragTurn withdrawal_ = ui::OsDragTurn::Continue;
};
// Minimal IDataObject exposing exactly one format (CF_HDROP / TYMED_HGLOBAL). The HDROP is
@@ -214,25 +242,34 @@ private:
} // namespace
bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths) {
if (absolutePaths.empty()) return false;
if (!ensureOleForThisThread()) return false;
OsDragEnd initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths,
bool (*lawHandsOff)()) {
if (absolutePaths.empty()) return OsDragEnd::Concluded;
if (!ensureOleForThisThread()) return OsDragEnd::Concluded;
HGLOBAL hdrop = buildHDrop(absolutePaths);
if (!hdrop) return false;
if (!hdrop) return OsDragEnd::Concluded;
auto* data = new HDropDataObject(hdrop); // takes ownership of hdrop
auto* source = new DropSource();
auto* source = new DropSource(lawHandsOff);
DWORD effect = 0;
// COPY-ONLY: the allowed-effects mask is DROPEFFECT_COPY alone. MOVE is never
// offered, so no drop target can relocate (delete) the bank file.
const HRESULT hr = DoDragDrop(data, source, DROPEFFECT_COPY, &effect);
DoDragDrop(data, source, DROPEFFECT_COPY, &effect);
const ui::OsDragTurn withdrawal = source->withdrawal();
source->Release();
data->Release(); // frees the source HGLOBAL via HDropDataObject's dtor
return hr == DRAGDROP_S_DROP && effect == DROPEFFECT_COPY;
switch (withdrawal) {
case ui::OsDragTurn::Withdraw: return OsDragEnd::Withdrawn;
case ui::OsDragTurn::WithdrawAtRelease: return OsDragEnd::WithdrawnAtRelease;
case ui::OsDragTurn::Continue:
case ui::OsDragTurn::Drop:
case ui::OsDragTurn::Cancel: break;
}
return OsDragEnd::Concluded;
}
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
@@ -265,6 +302,98 @@ bool pointerOverHostWindow(int screenX, int screenY) {
return pid == GetCurrentProcessId();
}
namespace {
std::string toUtf8(const std::wstring& w) {
if (w.empty()) return {};
const int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast<int>(w.size()), nullptr,
0, nullptr, nullptr);
if (n <= 0) return {};
std::string s(static_cast<std::size_t>(n), '\0');
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast<int>(w.size()), &s[0], n, nullptr,
nullptr);
return s;
}
std::string moduleFileName(HMODULE mod) {
std::wstring buf(MAX_PATH, L'\0');
for (;;) {
const DWORD n = GetModuleFileNameW(mod, &buf[0], static_cast<DWORD>(buf.size()));
if (n == 0) return {};
if (n < buf.size()) { buf.resize(n); return toUtf8(buf); }
if (buf.size() >= 32768) return {}; // truncated at the long-path limit: no evidence
buf.resize(buf.size() * 2);
}
}
// This channel's own ReaSampler 9000 module file name (e.g. "reasampler_9000.vst3", or the
// "_beta" variant) — the same name CMake gives the built .vst3 (OUTPUT_NAME + SUFFIX). Matching
// logic lives in core/ui/window_origin's isOwnModulePath; see there.
const std::string& ownVstFileName() {
static const std::string name = version::vstOutputName() + ".vst3";
return name;
}
// Empty on failure, which window_origin reads as "cannot tell OS code from plugin code".
const std::string& systemRoot() {
static const std::string root = [] {
wchar_t dir[MAX_PATH] = {0};
const UINT n = GetSystemWindowsDirectoryW(dir, MAX_PATH);
return (n > 0 && n < MAX_PATH) ? toUtf8(std::wstring(dir, n)) : std::string{};
}();
return root;
}
ui::WindowFacts windowFacts(HWND hwnd) {
ui::WindowFacts f;
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
if (pid != GetCurrentProcessId()) {
f.otherProcess = true;
return f;
}
// The charset-matched read returns the procedure's address; a mismatched one can return a
// handle that lies in no module, which is left as unresolved rather than guessed at.
const ULONG_PTR proc = IsWindowUnicode(hwnd) ? GetClassLongPtrW(hwnd, GCLP_WNDPROC)
: GetClassLongPtrA(hwnd, GCLP_WNDPROC);
HMODULE mod = nullptr;
if (!proc || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(proc), &mod) ||
!mod) {
return f;
}
f.procResolved = true;
f.hostModule = mod == GetModuleHandleW(nullptr);
// classifyWindowCode returns Host on hostModule before ever reading modulePath — skip the
// (string-building) lookup in that case.
if (!f.hostModule) {
f.modulePath = moduleFileName(mod);
f.ownModule = ui::isOwnModulePath(f.modulePath, ownVstFileName());
}
return f;
}
} // namespace
bool pointerOverPluginEditor(int screenX, int screenY) {
// The CLASS procedure's module rather than the window's hInstance: a bare-SDK plugin may
// create its editor with the hInstance of the parent REAPER hands it, so hInstance would
// read that plugin as REAPER.
const POINT pt{screenX, screenY};
const HWND hit = WindowFromPoint(pt);
if (!hit) return false;
const HWND root = GetAncestor(hit, GA_ROOT);
constexpr std::size_t kMaxDepth = 64; // a guard against a malformed tree, not a real bound
std::vector<ui::WindowCode> chain;
for (HWND h = hit; h && chain.size() < kMaxDepth; h = GetAncestor(h, GA_PARENT)) {
chain.push_back(ui::classifyWindowCode(windowFacts(h), systemRoot()));
if (h == root) break;
}
return ui::isPluginEditorChain(chain);
}
} // namespace reasampler
#else // ---- macOS / Linux (SWELL) -----------------------------------------------------
@@ -278,8 +407,9 @@ namespace reasampler {
// SWELL_InitiateDragDropOfFileList initiates a copy-style file drag from the given
// window. Unlike OLE it exposes no per-source effect mask, so the copy-only
// guarantee here rests on SWELL's copy semantics rather than an explicit mask.
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths) {
if (absolutePaths.empty() || !panelHwnd) return false;
OsDragEnd initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths,
bool (* /*lawHandsOff*/)()) {
if (absolutePaths.empty() || !panelHwnd) return OsDragEnd::Concluded;
std::vector<const char*> ptrs;
ptrs.reserve(absolutePaths.size());
@@ -288,7 +418,8 @@ bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolute
// srcrect null: SWELL positions the drag image at the current event. No custom icon.
SWELL_InitiateDragDropOfFileList(reinterpret_cast<HWND>(panelHwnd), nullptr,
ptrs.data(), static_cast<int>(ptrs.size()), nullptr);
return true; // fire-and-forget; SWELL owns the drag from here (no accept/cancel return)
// Fire-and-forget; SWELL owns the drag from here, with no per-turn hook to re-ask the law.
return OsDragEnd::Concluded;
}
// SWELL exposes no readiness probe ahead of SWELL_InitiateDragDropOfFileList (which itself
@@ -307,6 +438,11 @@ bool pointerOverHostWindow(int screenX, int screenY) {
return WindowFromPoint(pt) != nullptr;
}
// No plugin-editor hand-off off Windows: every FX window keeps its instrument outcome, as before.
bool pointerOverPluginEditor(int /*screenX*/, int /*screenY*/) {
return false;
}
} // namespace reasampler
#endif
+48 -4
View File
@@ -1,6 +1,7 @@
#pragma once
// drag_out_win — the host/OS boundary of native OS drag-out: whether the pointer has
// crossed it, whether the OS is ready to take the drag, and the hand-off itself, which
// crossed it (left the host process, or reached a plugin's own editor), whether the OS is
// ready to take the drag, and the hand-off itself, which
// gives a resolved existing-file path list to the OS's drag-drop machinery so the user can
// drop bank samples into Explorer / another app / another DAW. The pure gesture-boundary
// decision + path-list assembly live in drag_out.*.
@@ -21,11 +22,22 @@ struct HWND__; // avoid dragging windows.h into every includer; the shell casts
namespace reasampler {
// How the OS drag ended, as far as the caller must act on it. Whether a Concluded drag was
// accepted is not reported — nothing acts on it.
enum class OsDragEnd {
Concluded, // dropped, cancelled, or failed to start: the gesture is over
Withdrawn, // ui::OsDragTurn::Withdraw — the button is still down
WithdrawnAtRelease, // ui::OsDragTurn::WithdrawAtRelease — the button came up over REAPER
};
// `absolutePaths` must already be resolved/existing/de-duped (drag_out::assemblePathList
// output). No-op when empty. BLOCKING on Windows: OLE DoDragDrop runs its own modal
// message loop until drop/cancel. Returns true iff the drop was accepted
// (DROPEFFECT_COPY); the return is advisory — a failed drag surfaces no error.
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths);
// message loop, re-asking `lawHandsOff` (the gesture law at the current pointer) on every
// IDropSource::QueryContinueDrag and withdrawing per ui::decideOsDragTurn. macOS/Linux: SWELL's
// file drag is fire-and-forget with no such hook, so it always reports Concluded — the hand-off
// stays irreversible there.
OsDragEnd initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths,
bool (*lawHandsOff)());
// Side-effect-free readiness probe for initiateDragOut. Call BEFORE tearing down internal drag
// state — an OS that isn't ready (OLE unavailable, HDROP build failure) must not consume the
@@ -44,4 +56,36 @@ bool canInitiateDragOut(const std::vector<std::string>& absolutePaths);
// Side-effect-free; safe on every mouse-move.
bool pointerOverHostWindow(int screenX, int screenY);
// True iff plugin code implements the window under the screen point or one of its ancestors —
// the POSITIVE proof behind ui::DropContext::pointerOverPluginEditor. Windows reads, per window
// from the hit window up to its root, the owning process and the module holding the window
// CLASS's procedure, and lets ui::window_origin decide. The failure modes, all toward REAPER's
// own outcome (the instrument drop) rather than toward OLE:
// - a plugin that draws straight into the parent REAPER hands it, creating no window of its
// own, leaves nothing of its code in the chain;
// - a plugin binary installed beneath the Windows directory reads as an OS module;
// - a class procedure that resolves to no module (a charset-mismatch handle) is no evidence;
// - an all-#32770 dialog-based editor (the generic Windows dialog class) resolves to no
// recognizable plugin module — falls to REAPER's outcome;
// - a .NET/WPF editor's class procedure is a CLR-thunked address with no resolvable module —
// also falls to REAPER's outcome; `[verify]`;
// - our own ReaSampler 9000 module (this channel's reasampler_9000[_beta].vst3, matched by
// basename) also falls to REAPER's outcome, deliberately — see ui::WindowFacts::ownModule;
// - ReaSampler 9000 itself run bridged/in a separate process reads as Plugin, same as any other
// bridged plugin — banner (the OS hand-off fires over our own editor), harmless;
// - a renamed ReaSampler 9000 binary (the basename no longer matches ownVstFileName()) misses
// the exemption and reads as Plugin — banner, harmless.
// The opposite direction is unguarded: a third-party hook that replaces REAPER's own chrome
// class procedure via SetClassLongPtr(GCLP_WNDPROC) would make REAPER's own window read as
// Plugin — toward OLE instead of away from it.
// [verify — DAW] that REAPER's GetThingFromPoint names an fx_* token over the plugin's own child
// window — the law requires it, so an unnamed editor would refuse rather than hand off.
// [verify — DAW] that OLE never walks up to an ancestor drop target (e.g. REAPER's own file
// import) when the plugin editor's window registers no IDropTarget of its own.
// [verify — DAW] that GetThingFromPoint cannot name a REAPER region (e.g. an fx_* token) when a
// foreign window overlaps it at the same screen point — this fn trusts WindowFromPoint's hit
// over whatever GetThingFromPoint separately reports.
// SWELL: always false (no plugin-editor hand-off off Windows). Side-effect-free.
bool pointerOverPluginEditor(int screenX, int screenY);
} // namespace reasampler
+23 -30
View File
@@ -17,18 +17,19 @@
#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/capture/capture_paths.h" // deriveBankPaths
#include "core/util/file_bytes.h" // shared whole-file loader
#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
#include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h" // ReaSamplerSession
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#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)
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_MediaExplorerGetLastPlayedFileInfo
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
@@ -36,8 +37,6 @@
#define REAPERAPI_WANT_GetMediaSourceNumChannels
#define REAPERAPI_WANT_GetMediaSourceSampleRate
#define REAPERAPI_WANT_GetMediaSourceLength
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_GetSelectedTrack
#include "reaper_plugin_functions.h"
@@ -48,7 +47,6 @@ using capture::buildFloat32Wav;
using capture::deriveBankPaths;
using capture::hashWavContent;
using capture::parseWavLayout;
using capture::projectDirOfRpp;
using capture::WavLayout;
using util::readFileBytes;
using version::channelActionName;
@@ -80,14 +78,6 @@ std::string g_idImportStr;
std::string g_idImportMxStr;
std::string g_labelImportStr;
// Forward-slashed, no trailing slash. Empty for an unsaved/no-active project, which
// makes the import refuse to place a file (relative-paths invariant, no fallback).
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return projectDirOfRpp(std::string(buf.data()));
}
// Writes a byte buffer to a file. Returns true on success. The caller is responsible for
// ensuring the directory exists before calling.
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
@@ -171,7 +161,8 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
return out;
}
const std::string projectDir = currentProjectDir();
// Empty refuses to place a file: relative-paths invariant, no fallback.
const std::string projectDir = bankRootOf(nullptr);
if (projectDir.empty()) {
out.message = "no saved project, so the bank has no location -- save the "
"project first";
@@ -362,14 +353,14 @@ void doImportFromMediaExplorer() {
// so any open VST3 browser instances refresh to show the new sound.
bool persisted = true; // true when nothing needed persisting (dedup)
if (r.added) {
Undo_BeginBlock2(nullptr);
{
UndoBlock undo(nullptr);
g_session->bumpBankGeneration();
persisted = g_session->saveToActiveProject();
if (persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import Media Explorer file into bank",
undo.recordPoint("ReaSampler: import Media Explorer file into bank",
UNDO_STATE_MISCCFG);
else
Undo_EndBlock2(nullptr, "", 0);
}
bankPanelRefresh();
}
ShowConsoleMsg(("ReaSampler ingest: " + r.message +
@@ -386,20 +377,21 @@ void doImportFromMediaExplorer() {
// Persist happens INSIDE the block and BEFORE the FX add so the new instance's
// setState -> reloadInstrument sees the just-persisted sample.
Undo_BeginBlock2(nullptr);
bool persisted = true; // true when nothing needed persisting (dedup)
bool placed = false;
{
UndoBlock undo(nullptr);
if (r.added) {
g_session->bumpBankGeneration();
persisted = g_session->saveToActiveProject();
}
const bool placed = loadInstrumentOntoTrack(target, preset);
placed = loadInstrumentOntoTrack(target, preset);
// Anything else — the FX add/inject failed (already rolled back, no orphan), or
// the project was unsaved (persist no-op) — records no point at all.
if (placed && persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer into selected track",
undo.recordPoint("ReaSampler: import from Media Explorer into selected track",
UNDO_STATE_MISCCFG);
else
// Either the FX add/inject failed (already rolled back, no orphan) or the
// project was unsaved (persist no-op): discard so no empty point is recorded.
Undo_EndBlock2(nullptr, "", 0);
}
bankPanelRefresh();
if (placed)
@@ -450,13 +442,14 @@ void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
// One undo point for the whole drop, opened only if a NEW index entry was created.
if (importedNew > 0) {
Undo_BeginBlock2(nullptr);
bool persisted = false;
{
UndoBlock undo(nullptr);
g_session->bumpBankGeneration();
const bool persisted = g_session->saveToActiveProject();
persisted = g_session->saveToActiveProject();
if (persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import dropped file(s)", UNDO_STATE_MISCCFG);
else
Undo_EndBlock2(nullptr, "", 0);
undo.recordPoint("ReaSampler: import dropped file(s)", UNDO_STATE_MISCCFG);
}
bankPanelRefresh();
const std::string msg =
"ReaSampler ingest: imported " + std::to_string(importedTotal) +
+3 -4
View File
@@ -13,6 +13,7 @@
#include "core/version/app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing)
#include "core/wire/instrument_drop.h" // classifyReaperSurface — the PURE, unit-tested classifier
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "reaper_plugin.h"
@@ -22,8 +23,6 @@
#define REAPERAPI_WANT_TrackFX_Delete
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_SetPreset
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -138,9 +137,9 @@ bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>&
bool performInstrumentDrop(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes) {
if (!track || presetBytes.empty()) return false;
Undo_BeginBlock2(nullptr);
UndoBlock undo(nullptr);
const bool ok = loadInstrumentOntoTrack(track, presetBytes);
Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1);
undo.recordPoint("ReaSampler: drop capture onto FX chain", -1);
return ok;
}
+3 -9
View File
@@ -9,13 +9,13 @@
#include <string>
#include <vector>
#include "core/capture/capture_paths.h" // projectDirOfRpp, sanitizeStem
#include "core/capture/capture_paths.h" // sanitizeStem
#include "shell/package/export_bank.h"
#include "shell/package/package_pickers.h"
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_ShowMessageBox
#include "reaper_plugin_functions.h"
@@ -28,12 +28,6 @@ constexpr const char* kUnsavedProjectMsg =
"ReaSampler export: save the project first -- an unsaved project has no bank folder "
"to read from.\n";
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return capture::projectDirOfRpp(std::string(buf.data()));
}
// 6 == YES; anything else cancels (SDK ~6544).
bool confirmed(const std::string& msg, const char* title) {
return ShowMessageBox(msg.c_str(), title, 4) == 6;
@@ -118,7 +112,7 @@ void reportOutcome(const ExportOutcome& out, const std::string& destPath) {
} // namespace
void doBankPackageExport(const ReaSamplerSession& session, const std::string& bankId) {
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
if (projectDir.empty()) {
ShowConsoleMsg(kUnsavedProjectMsg);
return;
+15 -4
View File
@@ -26,13 +26,24 @@ calls into that model, it does not reimplement its rules.
- **Movement is index-only.** Moving/copying a sample between banks removes/adds
the index entry only; the underlying file never moves on disk. No bank operation
writes, moves, or deletes a file.
- **One bank operation is one Ctrl-Z.** Every bank index verb wraps its mutation in
a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`)
via `persistBankOp` — this is enforced in this directory, not left to callers.
- **One bank operation is one Ctrl-Z, whatever else is in flight.** Every bank index
verb wraps its mutation in a batched REAPER undo point (`UNDO_STATE_MISCCFG`) via
`persistBankOp` — enforced in this directory, not left to callers. The block is
opened through `shell/undo`'s `UndoBlock`, which is what makes the claim hold while
a Design View mode switch's FX reclaim span is open: without it the verb's own
close landed at depth 2->1, minted nothing, and the rename or delete was absorbed
into the mode switch, un-undoable on its own. Reason and mechanism:
`src/shell/undo/CLAUDE.md`. ACCEPTED, same cost `view_lanes`' `mintManagedLanes`
already carries (`src/shell/view/view_lanes.cpp`'s note at its own `UndoBlock`):
opening `persistBankOp`'s block ends any pending reclaim span unconditionally,
including on the unsaved-project path above, which then records no point of its
own — the switch's point still mints early, and its remaining parking mints again
under `kReclaimUndoLabel`. Bounded at one extra undo point; the alternative
(nesting over the span) destroys the switch's point outright.
## Modules
- `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.
- `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 `UndoBlock`-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.
## Gotchas
+7 -11
View File
@@ -14,12 +14,11 @@
#include "core/model/bank_book.h" // BankBook / TransferResult / RemoveScope
#include "shell/persist/session.h" // ReaSamplerSession — the session the verbs mutate
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#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 {
@@ -45,18 +44,15 @@ std::string mintBankId() {
// ext-state is the block's "after" image.
//
// UNSAVED-PROJECT GUARDRAIL: on an unsaved/no-active project saveToActiveProject()
// no-ops; we still CLOSE the block, but with an empty label + zero flag so REAPER
// discards the point instead of recording a no-effect undo entry. Quiet persist by
// design (mirrors capture, not Design-View) — deliberately no Save-As prompt.
// no-ops, so recordPoint below is skipped — the block closes on its own default
// discard form (empty label, zero flag) instead of recording a no-effect undo entry.
// Quiet persist by design (mirrors capture, not Design-View) — deliberately no
// Save-As prompt.
void persistBankOp(ReaSamplerSession& session, const char* label,
bool bumpGeneration) {
Undo_BeginBlock2(nullptr);
UndoBlock undo(nullptr);
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
if (session.saveToActiveProject()) undo.recordPoint(label, UNDO_STATE_MISCCFG);
}
// --- Promptless inner bank verbs (one home) ------------------------------------
+4
View File
@@ -20,6 +20,10 @@ relative-paths-only) and the load-bearing capture/placement separation — see r
`CLAUDE.md` §Precision invariants and §The load-bearing principle. Shell-specific
detail not covered there:
- **Every undo block here is opened through `shell/undo`'s `UndoBlock`**, never
`Undo_BeginBlock2` directly — `insert`, `capture_orchestrator`, `capture_batch`,
`bake_land` and `render_in_place` alike. Rule and enforcement:
`src/shell/undo/CLAUDE.md`.
- **The item scope's render source is window-dependent.** `ResolveScopeSource` is
where that is decided — it measures the selected items' extent against the
resolved range (`core/capture/render_window`) and hands the answer to
+9 -31
View File
@@ -1,7 +1,8 @@
// bake_land.cpp — see bake_land.h. main.cpp owns the API pointers; this TU gets them
// extern. REAPER symbols used here (EnumProjects, EnumProjExtState, GetProjExtState,
// SetProjExtState, ShowConsoleMsg, Undo_BeginBlock2/EndBlock2) are verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h. Landing one request is bake_landing's.
// SetProjExtState, ShowConsoleMsg) are verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h. Landing one request is
// bake_landing's; the undo block is shell/undo's.
#include "shell/capture/bake_land.h"
@@ -12,13 +13,14 @@
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // projectDirOfRpp
#include "core/wire/bake_wire.h"
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop)
#include "ext_keys.h"
#include "shell/capture/bake_landing.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h"
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
@@ -28,8 +30,6 @@
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_SetProjExtState
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler::capture {
@@ -77,19 +77,17 @@ KeyRead readKey(ReaProject* proj, const std::string& key) {
struct OpenProject {
ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project
std::string dir; // the project's bank root; empty when it has no bank location
};
// Every open project tab. The request key lives in the INSTANCE's project, which is not
// necessarily the focused one, so the scan cannot assume the current tab holds it.
std::vector<OpenProject> openProjects() {
std::vector<OpenProject> out;
std::vector<char> buf(4096, '\0');
for (int idx = 0;; ++idx) {
buf[0] = '\0';
ReaProject* proj = EnumProjects(idx, buf.data(), static_cast<int>(buf.size()));
ReaProject* proj = EnumProjects(idx, nullptr, 0);
if (!proj) break;
out.push_back(OpenProject{proj, projectDirOfRpp(std::string(buf.data()))});
out.push_back(OpenProject{proj, bankRootOf(proj)});
}
return out;
}
@@ -107,26 +105,6 @@ struct ScannedKey {
wire::BakeKeyOutcome report;
};
// Undo_BeginBlock2/EndBlock2 as stack RAII, the FxBypassGuard discipline: a landing reads a
// whole WAV and hashes it, so a bad_alloc between the two calls would otherwise leave an
// open undo block in the user's project. Closing with an empty description records no undo
// point, which is what every path that landed nothing wants anyway.
class UndoBlock {
public:
UndoBlock() { Undo_BeginBlock2(nullptr); }
~UndoBlock() { Undo_EndBlock2(nullptr, description_.c_str(), flags_); }
void recordPoint(std::string description, int flags) {
description_ = std::move(description);
flags_ = flags;
}
UndoBlock(const UndoBlock&) = delete;
UndoBlock& operator=(const UndoBlock&) = delete;
private:
std::string description_;
int flags_ = 0;
};
// One entry's write-back, and the proof of whether it took. Guarded whole: it encodes into
// a fresh string and readKey grows a buffer toward 16 MB, and a throw escaping here would
// skip the ShowConsoleMsg for EVERY key — the silent console this action exists to make
@@ -206,7 +184,7 @@ void RunResampleBake(ReaSamplerSession& session) {
// the answers already buffered — the exact no-answer-with-a-silent-console this action
// exists to make impossible. UndoBlock is inside the try, so unwinding still closes it.
try {
UndoBlock undo;
UndoBlock undo(nullptr);
for (const OpenProject& open : openProjects()) {
++tally.tabsScanned;
const bool activeTab = static_cast<const void*>(open.proj) == active;
+9 -28
View File
@@ -36,6 +36,7 @@
#include "core/util/file_bytes.h"
#include "core/capture/render_settings.h"
#include "shell/capture/render_bounds_gate.h" // the exact-bounds verdict on the landed render
#include "shell/persist/bank_root.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -391,43 +392,23 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
return result;
}
// Unsaved-project detection via EnumProjects(-1, buf, bufsz): the .rpp path
// out-param is empty for a project that has never been saved — a reliable
// unsaved sentinel. NOT GetProjectPathEx: that returns the recording path, not
// the .rpp location, and is never empty even when unsaved (the original bug —
// captures landed in REAPER's default media location instead of by the .rpp).
//
// Flow: read .rpp path; if empty, Main_SaveProject(proj, true) — true =
// No bank root (a never-saved project): Main_SaveProject(proj, true) — true =
// forceSaveAsIn — prompts Save-As and (per SDK header) blocks until dismissed,
// though that blocking behaviour cannot be verified from the header itself;
// re-read; if still empty (cancelled), refuse with NoProject and write nothing.
auto readRppPath = [&]() -> std::string {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return std::string(buf.data());
};
std::string rppPath = readRppPath();
if (rppPath.empty()) {
// re-resolve; if still empty (cancelled), refuse with NoProject and write nothing.
// Never GetProjectPathEx: that is the recording path, never empty even when
// unsaved (the original bug — captures landed in REAPER's default media location).
std::string projectDir = bankRootOf(proj);
if (projectDir.empty()) {
Main_SaveProject(proj, true);
rppPath = readRppPath();
projectDir = bankRootOf(proj);
}
if (rppPath.empty()) {
if (projectDir.empty()) {
result.status = CaptureStatus::NoProject;
result.message = "Project must be saved before capture — nothing captured.";
return result;
}
// Project dir = parent of the .rpp; forward-slash-normalized so the rest of
// the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path.
const std::string projectDir = [&]() -> std::string {
namespace fs = std::filesystem;
std::string dir = fs::path(rppPath).parent_path().string();
for (char& c : dir) { if (c == '\\') c = '/'; }
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
return dir;
}();
// Compute the tag ONCE — calling makeUniqueTag() twice would let the file
// stem and Sample.id diverge (the counter advances per call).
const std::string uniqueTag = makeUniqueTag("");
+20 -24
View File
@@ -16,21 +16,21 @@
#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/capture/capture_paths.h" // projectDirOfRpp
#include "core/model/bank_book.h" // BankBook / Bank
#include "core/model/provenance.h" // recipe parse/build, fingerprint
#include "shell/persist/bank_root.h"
#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/render_bounds_gate.h" // refusedRenderFolder
#include "shell/capture/scope_resolve.h" // ResolvedSource
#include "shell/capture/track_guid.h" // guidString
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedMediaItems
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetSelectedMediaItem
#define REAPERAPI_WANT_GetMediaItem_Track
#define REAPERAPI_WANT_GetMediaItemInfo_Value
@@ -46,8 +46,6 @@
#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 {
@@ -105,10 +103,8 @@ private:
// at all -- the batch summary reports ordinals only.
std::string withQuarantineNote(std::string line, int quarantinedCount) {
if (quarantinedCount <= 0) return line;
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
const std::string dir = projectDirOfRpp(std::string(buf.data()));
if (dir.empty()) return line; // unreachable: a quarantine implies a saved project
const std::string dir = bankRootOf(nullptr);
if (dir.empty()) return line; // unreachable: a quarantine implies a bank root
line += " " + std::to_string(quarantinedCount) + " refused render" +
(quarantinedCount == 1 ? " was" : "s were") + " retained for diagnosis, "
"normally at " + refusedRenderFolder(dir) + " (delete when done) -- one "
@@ -493,26 +489,26 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
updated.provenance = prov;
// One batched undo point around the in-place mutation; index-only ext-state,
// nothing placed on the timeline.
Undo_BeginBlock2(nullptr);
// Before the index outcome is even known: the render above already wrote the file,
// and makeUniqueTag guarantees a NEW path, so this is a second record carrying the
// same sampleId as the original. The superseded file becomes an orphan for prune.
// nothing placed on the timeline. A plan that mutated nothing records no point at
// all — the block's default discard form.
{
UndoBlock undo(nullptr);
// Before the index outcome is even known: the render above already wrote the
// file, and makeUniqueTag guarantees a NEW path, so this is a second record
// carrying the same sampleId as the original. The superseded file becomes an
// orphan for prune.
session.recordCreated(updated, tracking::OriginKind::Recapture);
const bool changed = session.book().updateSampleInPlace(sampleId, updated);
if (changed)
if (session.book().updateSampleInPlace(sampleId, updated))
{
// Regenerating the same id's audio is exactly why instances need the generation
// bump — they'd otherwise keep playing stale audio until reload. Bumped inside
// the undo block so undo rolls back the generation with the rest of the blob.
// Regenerating the same id's audio is exactly why instances need the
// generation bump — they'd otherwise keep playing stale audio until reload.
// Bumped inside the undo block so undo rolls back the generation with the
// rest of the blob.
session.bumpBankGeneration();
const bool persisted = session.saveToActiveProject(); // book + ledger + generation + MarkProjectDirty
Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
persisted ? UNDO_STATE_MISCCFG : 0);
// book + ledger + generation + MarkProjectDirty
if (session.saveToActiveProject())
undo.recordPoint("ReaSampler: re-capture from source", UNDO_STATE_MISCCFG);
}
else
{
Undo_EndBlock2(nullptr, "", 0); // nothing mutated -> discard the empty point
}
bankPanelRefresh(); // reflect the regenerated file in the docked grid
+7 -6
View File
@@ -18,6 +18,7 @@
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
#include "shell/capture/render_selection.h" // RenderTrackSelection
#include "shell/capture/render_isolation.h" // UpstreamIsolation
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
@@ -28,8 +29,6 @@
#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 {
@@ -369,20 +368,22 @@ 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);
{
UndoBlock undo(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);
// Capture failed or no-op'd — RunCapture already reported. The block closes
// on its default discard form, so the empty point is never recorded.
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.recordPoint("ReaSampler: capture + assign to active instance",
UNDO_STATE_MISCCFG);
}
bankPanelRefresh();
ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active "
+7 -22
View File
@@ -59,6 +59,7 @@
#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
#include "shell/persist/bank_root.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -84,20 +85,6 @@ 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;
}
// Reads the ACTIVE project's .rpp path (empty if unsaved). Only needed at begin()
// time, when the record's project IS the active project.
std::string readRppPath() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return std::string(buf.data());
}
// -1 if unresolved yet. Used by the flush wait to detect stability (size
// unchanged across a tick) before moving the file — a take REAPER is still
// flushing grows tick over tick.
@@ -290,20 +277,18 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
return nullptr;
}
// Saved-project gate (same as offline): the bank folder resolves against the
// .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved.
std::string rppPath = readRppPath();
if (rppPath.empty()) {
// Bank-root gate (same as offline): prompt Save-As once when there is no bank
// root; refuse if there still is none.
std::string projectDir = bankRootOf(proj);
if (projectDir.empty()) {
Main_SaveProject(proj, true);
rppPath = readRppPath();
projectDir = bankRootOf(proj);
}
if (rppPath.empty()) {
if (projectDir.empty()) {
outFailure.status = CaptureStatus::NoProject;
outFailure.message = "Project must be saved before capture — nothing captured.";
return nullptr;
}
const std::string projectDir =
normSlashes(std::filesystem::path(rppPath).parent_path().string());
RealtimeCaptureHandle st(new RealtimeCaptureState());
st->proj_ = proj;
+9 -25
View File
@@ -32,11 +32,12 @@
#include "core/model/bank_model.h"
#include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId
#include "core/capture/capture_paths.h"
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h"
#include "shell/undo/undo_block.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetCursorPosition
#define REAPERAPI_WANT_GetSelectedTrack
#define REAPERAPI_WANT_InsertMedia
@@ -44,14 +45,11 @@
#define REAPERAPI_WANT_SetOnlyTrackSelected
#define REAPERAPI_WANT_SetTrackSelected
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
using capture::computeInsertMode;
using capture::normalizeSlashes;
using capture::resolveBankFile;
using capture::TempoConform;
@@ -59,18 +57,6 @@ namespace {
namespace fs = std::filesystem;
// Mirrors bank_panel/capture/persist's own derivation; the bank index stores
// relative paths, so resolving a file needs the current .rpp dir. A shared "current
// project dir" helper would be a clean small refactor now that a fourth consumer
// exists (also noted in panel_bank_ops.cpp) — out of scope here.
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
std::string rpp(buf.data());
if (rpp.empty()) return {}; // unsaved project: no resolvable bank
return normalizeSlashes(fs::path(rpp).parent_path().string());
}
// Snapshot of the currently-selected track set (master is skipped, matching
// CountSelectedTracks/GetSelectedTrack), in selection order, for restore later.
std::vector<MediaTrack*> snapshotSelectedTracks() {
@@ -112,9 +98,9 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; }
const std::string& id = ids.front(); // focused / first selected — single sample
// WHERE: an unsaved project has no resolvable bank dir; no-op rather than
// WHERE: no bank root means no resolvable bank file; no-op rather than
// resolving against CWD.
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
// Resolve against the bank the selection came from — it may be the pool or a
@@ -139,9 +125,8 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
const double cursorPos = GetCursorPosition();
// One undo block around the whole placement (all tracks + selection/cursor
// restore) so a single undo removes every item and restores prior state. Always
// balanced — opened before the first insert, closed after the restore.
Undo_BeginBlock2(nullptr);
// restore) so a single undo removes every item and restores prior state.
UndoBlock undo(nullptr);
// Insert onto each selected track at the same cursor position (assumption B): per
// track, isolate it as the only selection (A + E), reset the cursor (C is
@@ -159,11 +144,10 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
// Label reflects count + conform choice for a clear undo history. extraflags -1 =
// UNDO_STATE_ALL (tracks, items, envelope points, project state).
const std::string label =
"ReaSampler: insert on " + std::to_string(result.inserted) +
undo.recordPoint("ReaSampler: insert on " + std::to_string(result.inserted) +
(result.inserted == 1 ? " track" : " tracks") +
(request.options.conform == TempoConform::None ? "" : " (conform)");
Undo_EndBlock2(nullptr, label.c_str(), -1);
(request.options.conform == TempoConform::None ? "" : " (conform)"),
-1);
result.status = InsertStatus::Ok;
return result;
+1 -1
View File
@@ -44,7 +44,7 @@ struct InsertResult {
// currently-selected track set, then inserts the sample onto EACH selected track
// at the SAME edit-cursor position. Snapshot/restore ensures the user's track
// selection and cursor position are unchanged after the action. The whole operation
// is wrapped in a single Undo_BeginBlock2 / Undo_EndBlock2.
// is wrapped in a single UndoBlock.
//
// No-op cases (with console messages):
// - No track selected: prints "select a track first."
+15 -9
View File
@@ -21,7 +21,9 @@
#include "shell/capture/track_guid.h" // guidString
#include "shell/panel/panel_input.h" // bankPanelTailSetting
#include "shell/persist/session.h"
#include "shell/view/view.h" // applyMode / mintManagedLanes
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "shell/view/view.h" // applyMode
#include "shell/view/view_lanes.h" // mintManagedLanes
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTrackMediaItems
@@ -41,8 +43,6 @@
#define REAPERAPI_WANT_SetOnlyTrackSelected
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_TrackList_AdjustWindows
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler::capture {
@@ -132,7 +132,13 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) {
// visible — never to choose a tag. Both tags below are absolute.
const std::string activeMode = session.view().activeModeId();
Undo_BeginBlock2(nullptr);
// Both read after the block closes, so both outlive the scope below.
bool placed = false;
std::string freshName;
// Scoped so the persist below lands OUTSIDE the block — see the note at the close.
{
UndoBlock undo(nullptr);
// flags = 0, never 1: flags&1 adds default envelopes/FX, and a default chain
// would process a render that already carries the source's FX a second time.
@@ -142,8 +148,7 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) {
// InsertTrackInProject already mutated the project by this point, so the
// "no ext-state write -> discard" idiom does not apply here — a discard would
// leave the orphaned track un-undoable.
Undo_EndBlock2(nullptr, "ReaSampler: render in place (failed to create result track)",
-1);
undo.recordPoint("ReaSampler: render in place (failed to create result track)", -1);
refuse("could not create the result track; the render landed at " +
res.absolutePath + " but was not placed.");
return;
@@ -164,7 +169,7 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) {
// and the absence of one, with no branch.
SetMediaTrackInfo_Value(fresh, "I_CUSTOMCOLOR",
static_cast<double>(GetTrackColor(source)));
const std::string freshName = captureTrackName(trackName(source));
freshName = captureTrackName(trackName(source));
setTrackName(fresh, freshName);
// After every attribute write, per the SDK header's manual-panel-update caveat.
@@ -179,7 +184,7 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) {
// InsertMedia's int return isn't SDK-documented; treated conservatively as
// 0 = failure, matching performArrangeDrop — an empty result track would
// otherwise be a silent no-op, which is exactly what this verb must not produce.
const bool placed =
placed =
InsertMedia(res.absolutePath.c_str(), computeInsertMode(InsertOptions{})) != 0;
SetEditCurPos(cursorPos, false, false);
// The new track is left selected, alone — in the headline case the source is being
@@ -206,7 +211,8 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) {
mintManagedLanes(session.view(), nullptr);
applyMode(session.view(), activeMode, nullptr); // a reapply, never a switch
Undo_EndBlock2(nullptr, "ReaSampler: render selected track to a new track", -1);
undo.recordPoint("ReaSampler: render selected track to a new track", -1);
}
// Persist outside the block. The offline render's own save gate already forced a
// saved project, so the Save-As-guarded persist the Design View actions need
+4 -18
View File
@@ -8,12 +8,12 @@
#include "shell/capture/scope_resolve.h"
#include <cstring> // strnlen — bounded read of GetTrackName's buffer
#include <filesystem> // project-dir derivation for provenance parent resolution
#include <utility>
#include "core/capture/render_window.h" // itemExtentPrintsWindow
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / *SourceFiles / bankFileRefs
#include "shell/capture/track_guid.h" // guidString
#include "shell/persist/bank_root.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_GetSet_LoopTimeRange
@@ -195,28 +195,14 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh
return true;
}
// Empty for an unsaved project (EnumProjects writes an empty .rpp path), which
// makes every bank file resolve empty -> no false parentage.
std::string currentProjectDir()
{
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(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;
}
// Detection rule: the capture's source item media file(s) must all resolve, by
// exact normalized absolute path, to one bank sample's file.
// exact normalized absolute path, to one bank sample's file. No bank root makes every
// bank file resolve empty -> no false parentage.
std::optional<model::Provenance> buildCaptureProvenance(
const BankBook& book, const CaptureRequest& req,
CaptureScope scope, const ResolvedSource& src)
{
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
const std::vector<model::BankFileRef> bankFiles = bankFileRefs(book, projectDir);
// What "the source audio" means depends on scope: item scope uses the selected
-4
View File
@@ -72,10 +72,6 @@ std::string trackName(MediaTrack* tr);
// 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 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
+49 -13
View File
@@ -84,6 +84,17 @@ The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still
parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is
declared ahead of the instrument slots at that member in `reasampler_processor.h`.
**The drawn-contour drag is the one gesture that publishes WITHOUT committing.** Each move
couriers the editor's contours to `liveContours_` (`publishLiveContours`) and writes nothing
else — no parameter set, no generation, no host callback — and its release is the
`commitAndReload` it always was. That is sound because no contour is a host parameter, so no
host-facing reader needs the model to carry it mid-gesture; it is what keeps a motion event off
the host entirely (an unlatched `setInstrumentParams` opens a group edit even when nothing
exposed moved). `reloadInstrument` republishes the model's contours, so a release, a click-edit
(add/delete/hard) and a restore all reach the voices through the reload; capture-lost and the
editor closing mid-drag (`removedFromParent`) are the two exits that commit nothing — both
re-courier the restored `params_`.
**The VST3 parameter surface is a THIRD surface onto the one model, never a second copy.** The
pure half — the frozen id table, the exposed set, the plain-value layer, the formatter — is
`core/instrument/param` and is documented there; this directory only adapts it.
@@ -109,7 +120,8 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
the audio thread and may allocate on the way; `process()` merges that block with the host's
automation points into `automationLive_`, which is what `SampleData::live` points at. Two
blocks rather than one because the seqlock's single-writer contract is load-bearing and the two
writers genuinely differ in thread.
writers genuinely differ in thread. The drawn-contour block `liveContours_` has no such pair:
no host automates a contour, so the engines read it straight from its off-thread writers.
### THE AUTHORITY MODEL — who may write a parameter's value, and until when
@@ -123,6 +135,7 @@ the single authority.** Everything else that holds these values is a cache or a
| Writer | Authority begins | Authority ends |
|---|---|---|
| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model |
| Contour drag's courier (`publishLiveContours`) | mouse-down | never writes the model — the release's `commitAndReload` does |
| Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) |
| State restore (`setState`) | the call | the call returns |
| Bake reset (`adoptBakedCapture`) | the call | the call returns |
@@ -130,9 +143,9 @@ the single authority.** Everything else that holds these values is a cache or a
| Reload seed (`reloadInstrument`) | never — it does not write `params_` | it only republishes a live block folded from whatever the model already holds |
| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** |
Every writer above except the last two writes the model directly, so for those "authority ends"
is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never
touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193`
Every writer above except the contour courier and the last two writes the model directly, so for
those "authority ends" is just "the write happened". Reload seed is not itself a model write —
`reloadInstrument` never touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193`
— which is why its row states no authority window of its own. The automation lane is the only one
that cannot write directly: the SDK delivers it on the audio thread, where the model path
allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the
@@ -213,8 +226,8 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
## Modules
- `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. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `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. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **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 `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **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_<instanceGuid>` 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. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `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. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. And it owns `sampleRoots`, the ONE answer to where a bank-relative sample path resolves — see the Gotcha below.
- `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. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **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 `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. A host-rate change while inactive does NOT re-decode: every stage time in the park (or in a publish that landed while inactive) and in the live block is in that rate's frames, so the resume re-stamps those in place (`restampHostRateLocked`) and falls back to the full reload only when the new rate refuses the file's (`sourceRateSupported`). Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **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_<instanceGuid>` 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. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. 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 to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
@@ -229,13 +242,20 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
## Gotchas
- **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a
false premise.** It was described as ONE class declaration; it also carried two namespace-scope
aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are
now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds
`editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is
behaviour. What remains is under the ceiling. Its bulk is the drain-slot proof and the
RT-discipline constraints, which the comment conventions name as keep-worthy.
- **`reasampler_processor.h` carries a small, documented ceiling exception.** It was described as
ONE class declaration; it also carried two namespace-scope aggregates (`MasterBusMeter`,
`LoadedInstrument`) and the automation lane's own state. All three are split out —
`processor_snapshot.h` and `automation_channel.h`, on the same grounds `editor_interaction.h`
was split out of `reasampler_editor.h` in this directory: neither is behaviour. What remains is
the class declaration itself, 630 lines (measured post-split) — over the ceiling since the
live-contour block (`liveContours_` + `publishLiveContours`) landed beside `liveParams_` under
the same ordering
constraint. No further seam exists that isn't behaviour: every remaining aggregate-shaped chunk
(the preview mailbox, the drain-slot triple) is read and written inline by `process()` on the
audio thread, so splitting it into its own header would either duplicate the RT-discipline
comments at a second site or turn a plain member access into a cross-header one for no gain.
Its bulk is the drain-slot proof and the RT-discipline constraints, which the comment
conventions name as keep-worthy.
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
@@ -293,6 +313,22 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
meter's fixed right-anchored slot, at any resizable window size — so the fast path stays live
under it; `paintMeterField` reapplies the popup's own 0.50-alpha wash to the field alone so a
meter-only frame doesn't flash through it at full brightness.
- **A sample resolves against the instance's OWN project, never the focused tab.**
`ReaperBridge::sampleRoots` matches `getReaperParent(3)` by identity among the projects
`EnumProjects(idx)` walks (plus the SDK's currently-rendering slot, `0x40000000`) and hands
the match's `.rpp` directory to the pure `core/instrument/map/sample_roots`, which returns an
ordered root list; every instrument-side sample read (`reloadInstrument`, `bakeSnapshot`,
the editor's `channelPcmFor`/`monoPcmFor`) goes through `readFromFirstRoot` over it. The old
`EnumProjects(-1)` lookup resolved a background tab's instance against the FOCUSED project's
folder — do not reintroduce it as the PRIMARY resolve. It survives ONE sanctioned use: when
`getReaperParent(3)` itself comes back null (REAPER may not expose the parent during plugin
creation/setState), `ownProjectDir` falls back to the focused tab — because at that point there
is no "own project" identity to prefer a wrong answer over. A NON-NULL parent that is an
unlisted project or an unsaved one still yields no roots, which plays nothing, exactly as an
unsaved project always did. The ordered list is one element today; a later root is added after
the project directory, in `sampleRootCandidates`. `[verify — DAW]`: that `getReaperParent(3)` is
non-null and enumerable during `setState` on project load, and that a background-tab instance
and two open projects each with an instance play their own project's sample.
- The bake's availability probe runs on the SAME tick that paints the button, so the
control can never be enabled on one tick and refuse on the next. The bake Hold control's
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
+1 -1
View File
@@ -85,7 +85,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# generated version header, so the instrument reads the SAME channel-derived ext-state
# namespace the extension writes.
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sampler_core sample_map component_state_io capture_paths embed_strip app_version
sampler_core sample_map sample_roots component_state_io capture_paths embed_strip app_version
capture_browser keyboard_strip sample_bands sample_chrome
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
+3 -2
View File
@@ -100,8 +100,9 @@ void ReaSamplerEditor::resetParamControl(int id) {
resetDeckParam(static_cast<instrument::ui::DeckParam>(id), params_.play);
}
double ReaSamplerEditor::liveSampleRate() const {
return processor_ ? processor_->sampleRate() : 0.0;
double ReaSamplerEditor::selectedSourceRate() const {
const auto it = pcmCache_.find(selectedId_);
return it != pcmCache_.end() ? static_cast<double>(it->second.sampleRate) : 0.0;
}
double ReaSamplerEditor::previewVelocity01() const {
@@ -42,7 +42,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
// (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode),
// so at most one of the two hit-tests below is ever live for the same click — both feed the
// SAME arbitration slot below rather than either one getting its own check-order return.
const double rate = liveSampleRate();
const double rate = selectedSourceRate();
const bool nodesLive =
overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates);
NodeHit envNodeHit;
@@ -178,6 +178,7 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
// At the ceiling: refused, contour untouched, nothing to roll back. Swallow the click
// rather than letting it fall through to a marker grab under the cursor.
if (index < 0) return true;
publishContoursLive(); // the new point is heard before the first move, not after it
}
drag_ = DragKind::kSplineNode;
curvePointIndex_ = index;
@@ -188,7 +189,7 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
dragCurveRect_ = waveArea.rect;
dragStartX_ = x;
dragStartY_ = y;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
invalidate(); // the model commit lands on WM_LBUTTONUP
return true;
}
@@ -235,7 +236,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag(
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
splineOverlayBox(overlay), dx, y - dragStartY_);
invalidate(); // live feedback; commit on release
publishContoursLive(); // heard now; the model write stays the release's commit
invalidate();
return;
}
@@ -245,7 +247,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
// set. The StageEnvelope was snapshotted at grab (dragStartEnv_) so the delta is
// absolute.
const std::int64_t frames = dragSampleFrames_;
const double rate = liveSampleRate();
const double rate = selectedSourceRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const DragModifiers mods = dragModifiers();
+1 -1
View File
@@ -63,7 +63,7 @@ StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds
std::int64_t frames,
std::int64_t startFrame) const {
StageEnvelope env;
const double rate = liveSampleRate();
const double rate = selectedSourceRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
+8 -2
View File
@@ -107,8 +107,14 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
// The instance's own loaded state outranks bank availability (the bank is a browser
// source, not the instrument's identity) — a self-contained instance names its sound
// (refs displayName fallback) even when the bank snapshot is empty.
if (!selectedId_.empty())
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
if (!selectedId_.empty()) {
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_);
// The waveform still draws (the editor decodes on its own), so without this a
// refused capture would look loaded and simply never sound.
if (processor_->sourceRateRefused())
title += " -- " + sourceRateRefusalText() + ", not played";
title += "]";
}
else if (samples_.empty()) title += " [bank empty]";
else title += " [pick a capture]";
} else {
@@ -388,7 +388,7 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
paintSplineOverlay(bmp, waveArea);
return;
}
const double rate = liveSampleRate();
const double rate = selectedSourceRate();
if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const std::int64_t startFrame = params_.startPoint.value_or(0);
+45 -5
View File
@@ -13,6 +13,7 @@
#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only)
#include "shell/instrument/reasampler_processor.h"
#include "core/version/app_version.h" // vstOutputName — channel-forked window class name
using namespace Steinberg;
@@ -20,7 +21,32 @@ namespace reasampler::vst {
#ifdef _WIN32
namespace {
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// Channel-forked so stable and beta never fight over one class registration (a second
// RegisterClassW against an already-taken name silently fails, leaving that channel's editors
// running the FIRST-registered channel's wndProc). vstOutputName() is ASCII (CMake OUTPUT_NAME),
// so a plain widen is safe.
const std::wstring& childClassName() {
static const std::wstring name = [] {
const std::string& base = version::vstOutputName();
return std::wstring(base.begin(), base.end()) + L"VstEditor";
}();
return name;
}
// The module holding a given code address — used to resolve THIS wndProc's own module rather
// than the parent's hInstance REAPER hands us. Registering against the parent would put the class
// in REAPER's own module while the wndproc still points into our DLL — a dangling procedure
// pointer once our DLL unloads but REAPER's class registration outlives it. Takes the address
// rather than naming wndProc directly so a free function in this anonymous namespace doesn't need
// access to the private member.
HMODULE moduleHandleFor(void* codeAddress) {
HMODULE mod = nullptr;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(codeAddress), &mod);
return mod;
}
// The change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
// timer: responsive enough that a recapture/ingest/assign refreshes within a bounded
@@ -105,8 +131,9 @@ void ReaSamplerEditor::attachedToParent() {
HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return;
HINSTANCE hInst =
reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE));
// Own module, not the parent's hInstance — see moduleHandleFor() above.
HINSTANCE hInst = reinterpret_cast<HINSTANCE>(
moduleHandleFor(reinterpret_cast<void*>(&ReaSamplerEditor::wndProc)));
if (!hInst) hInst = GetModuleHandle(nullptr);
static bool classRegistered = false;
@@ -114,7 +141,7 @@ void ReaSamplerEditor::attachedToParent() {
WNDCLASSW wc{};
wc.lpfnWndProc = &ReaSamplerEditor::wndProc;
wc.hInstance = hInst;
wc.lpszClassName = kChildClassName;
wc.lpszClassName = childClassName().c_str();
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
// CS_DBLCLKS is what makes WM_LBUTTONDBLCLK arrive at all. It also REPLACES the second
// WM_LBUTTONDOWN of a double-click, so every surface that counted two downs (Browse's
@@ -134,7 +161,7 @@ void ReaSamplerEditor::attachedToParent() {
refreshFromBank();
const ViewRect& r = getRect();
childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0,
childHwnd_ = CreateWindowExW(0, childClassName().c_str(), L"", WS_CHILD | WS_VISIBLE, 0, 0,
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
if (childHwnd_) {
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
@@ -162,6 +189,15 @@ void ReaSamplerEditor::removedFromParent() {
// an edit forever and every later internal write to that parameter would emit a bare
// performEdit. The capture-lost path normally closes it; this does not rely on Windows
// delivering WM_CAPTURECHANGED before the window goes away.
if (drag_ == DragKind::kSplineNode) {
// A contour drag reaches the voices without the model (see publishContoursLive) — the
// window closing mid-drag is a second exit alongside WM_CAPTURECHANGED that must roll
// the voices back the same way, or an abandoned drag stays audible after the editor
// that started it is gone.
params_ = dragStartParams_;
publishContoursLive();
drag_ = DragKind::kNone;
}
if (processor_) processor_->endParamGesture();
if (childHwnd_) {
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
@@ -312,6 +348,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
// drag used.
if (self->dragCommitsLive(self->drag_, self->dragParamId_)) {
self->commitLive();
} else if (self->drag_ == DragKind::kSplineNode) {
// The contour drag reached the voices without the model; restore
// the voices to it the same way (see publishContoursLive).
self->publishContoursLive();
}
}
// Peer of onMouseUp's bracket close, and after the rollback for the same
+15 -13
View File
@@ -13,8 +13,8 @@
#include <vector>
#include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail)
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames
#include "core/instrument/map/sample_roots.h" // readFromFirstRoot (own-project resolution)
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "ext_keys.h"
@@ -33,7 +33,6 @@ 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;
@@ -251,6 +250,10 @@ void ReaSamplerEditor::commitLive() {
processor_->publishLiveParams();
}
void ReaSamplerEditor::publishContoursLive() {
if (processor_) processor_->publishLiveContours(params_.play);
}
bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) {
return paramId == -2 ||
paramId == static_cast<int>(ParamControl::kVoiceCount) ||
@@ -421,9 +424,8 @@ const ReaSamplerEditor::ChannelPcm& ReaSamplerEditor::channelPcmFor(
const std::string relativePath = samplePathFor(sampleId);
if (relativePath.empty()) return channelPcm_;
const std::string projectDir = processor_->bridge().activeProjectDir();
const std::vector<std::uint8_t> bytes =
readFileBytes(resolveBankFile(projectDir, relativePath)); // empty on any failure
const std::vector<std::uint8_t> bytes = readFromFirstRoot(
processor_->bridge().sampleRoots(), relativePath, readFileBytes); // empty on any failure
const WavLayout layout = parseWavLayout(bytes);
if (layout.valid) {
channelPcm_.interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount());
@@ -434,26 +436,26 @@ const ReaSamplerEditor::ChannelPcm& ReaSamplerEditor::channelPcmFor(
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
auto it = pcmCache_.find(sampleId);
if (it != pcmCache_.end()) return it->second;
if (it != pcmCache_.end()) return it->second.frames;
// Every failure path caches an empty vector so a broken/missing file is not re-decoded on
// Every failure path caches an empty entry 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::vector<AudioSample> mono;
MonoPcm mono;
const std::string relativePath = samplePathFor(sampleId);
if (!relativePath.empty()) {
const std::string projectDir = processor_->bridge().activeProjectDir();
const std::string abs = resolveBankFile(projectDir, relativePath);
const std::vector<std::uint8_t> bytes = readFileBytes(abs); // empty on any failure
const std::vector<std::uint8_t> bytes = readFromFirstRoot(
processor_->bridge().sampleRoots(), relativePath, readFileBytes); // empty on any failure
const WavLayout layout = parseWavLayout(bytes);
if (layout.valid) {
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
mono = downmixToMono(interleaved, layout.channelCount);
mono.frames = downmixToMono(interleaved, layout.channelCount);
mono.sampleRate = static_cast<int>(layout.sampleRate);
}
}
auto ins = pcmCache_.emplace(sampleId, std::move(mono));
return ins.first->second;
return ins.first->second.frames;
}
const Envelope& ReaSamplerEditor::heroEnvelope(std::size_t bins, int laneCount) {
+7 -5
View File
@@ -114,7 +114,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
if (!sourceEntry) return fail("the loaded capture has no resolvable file");
const SelectedSample* source = &sourceEntry->ref;
const int sampleRate = static_cast<int>(processor.sampleRate());
const int sampleRate = processor.hostRenderRate();
if (sampleRate <= 0) return fail("the host has not reported a sample rate yet");
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
@@ -128,14 +128,16 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
// The snapshot comes first: the default program's window is derived from the sound it
// carries (a Gate release, a Trigger play span), not from a constant.
std::optional<SampleData> snapshot = processor.bakeSnapshot();
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
BuiltCapture built = processor.bakeSnapshot(sampleRate);
if (built.rateRefused) return fail("cannot render: " + sourceRateRefusalText());
if (!built.sample) return fail("the loaded capture could not be decoded for the render");
SampleData& snapshot = *built.sample;
// The note fires at the velocity the user has been auditioning at: three velocity curves
// are live, so a sound dialed at 127 does not bake as one dialed at 40.
const Velocity velocity = Velocity::of(processor.previewVelocity());
const instrument::bake::PlannedBake planned = planBake(
resolveNote(defaultBakeProgram(*snapshot, sampleRate, dialed.bakeHold, velocity),
resolveNote(defaultBakeProgram(snapshot, sampleRate, dialed.bakeHold, velocity),
*tempo),
sampleRate, rootNote);
if (!planned.plan) {
@@ -146,7 +148,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
const instrument::bake::BakePlan& plan = *planned.plan;
const instrument::bake::BakeAudio audio =
renderBake(std::move(*snapshot), plan, processor.masterGainLinear(),
renderBake(std::move(snapshot), plan, processor.masterGainLinear(),
dialed.limiterEnabled);
if (audio.empty()) return fail("the offline pass produced no audio");
+81 -38
View File
@@ -13,13 +13,14 @@
#include <mutex>
#include <optional>
#include <random>
#include <string>
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
#include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision
#include "core/instrument/map/sample_map.h" // refs resolve, buildSampleData (self-contained)
#include "core/instrument/map/sample_roots.h" // readFromFirstRoot (own-project resolution)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse)
#include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam)
@@ -31,7 +32,6 @@ using namespace instrument::map; // resolution + bank-sync vocabulary this TU
using namespace reasampler::wire; // assignment_request + sample_usage wire records
using capture::extractFloatFrames;
using capture::parseWavLayout;
using capture::resolveBankFile;
using capture::WavLayout;
using util::readFileBytes;
@@ -57,16 +57,15 @@ std::string mintUsageInstanceGuid() {
return std::string(buf);
}
// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only),
// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono
// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any
// resolve/read/decode failure — the caller plays silence.
std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
// Resolves a project-relative WAV path against the instance's sample roots, reads + decodes
// it (file I/O, off-thread only), and applies the cross-mode channel policy for `mode` (mono
// downmix; stereo -> dual-mono for a mono source, L/R for a stereo source — see
// decodeChannels). Returns nullopt on any resolve/read/decode failure — the caller plays
// silence.
std::optional<DecodedPcm> decodeRelative(const std::vector<std::string>& roots,
const std::string& relativePath,
ChannelMode mode) {
const std::string abs = resolveBankFile(projectDir, relativePath);
if (abs.empty()) return std::nullopt;
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
const std::vector<std::uint8_t> bytes = readFromFirstRoot(roots, relativePath, readFileBytes);
const WavLayout layout = parseWavLayout(bytes);
if (!layout.valid) return std::nullopt;
std::vector<AudioSample> interleaved =
@@ -79,18 +78,26 @@ std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
// The ONE decode + build the reload and the bake snapshot share, so a baked render can
// never be built from a different fold than the one the user is hearing.
std::optional<SampleData> buildFromRef(const SelectedSample& sel,
BuiltCapture buildFromRef(const SelectedSample& sel,
const InstrumentParams& params,
const std::string& projectDir, ChannelMode mode) {
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode);
if (!pcm) return std::nullopt;
SampleData sample = buildSampleData(resolveCapture(sel, params), std::move(*pcm));
if (!sample.playable()) return std::nullopt;
return sample;
const std::vector<std::string>& roots,
ChannelMode mode, int renderRate) {
std::optional<DecodedPcm> pcm = decodeRelative(roots, sel.relativePath, mode);
if (!pcm) return {};
if (!sourceRateSupported(pcm->sampleRate, renderRate)) return {std::nullopt, true};
SampleData sample =
buildSampleData(resolveCapture(sel, params), std::move(*pcm), renderRate);
if (!sample.playable()) return {};
return {std::move(sample), false};
}
} // namespace
std::string sourceRateRefusalText() {
return "sample rate over " + std::to_string(static_cast<int>(kMaxSourceRateRatio)) +
"x the host's";
}
std::string ReaSamplerProcessor::reloadInstrument() {
// OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the
// retired-slot free is single-writer; never taken on the audio thread.
@@ -116,7 +123,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// (getState filters via retainRefs to what the instance plays).
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
}
const std::string projectDir = bridge_.activeProjectDir();
const std::vector<std::string> roots = bridge_.sampleRoots();
// Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
// capture's own channel count below, before the decode.
ChannelMode mode = channelMode();
@@ -125,6 +132,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
std::unique_ptr<LoadedInstrument> built;
SampleData sample;
bool havePlayable = false;
bool rateRefused = false;
// 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard
// repitched from its effective root. No first-sample fallback: an empty selection
@@ -142,9 +150,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
channelModeExplicit_);
mode = channelMode_;
}
if (std::optional<SampleData> decoded =
buildFromRef(*sel, params, projectDir, mode)) {
sample = std::move(*decoded);
BuiltCapture decoded = buildFromRef(*sel, params, roots, mode, hostRenderRate());
rateRefused = decoded.rateRefused;
if (decoded.sample) {
sample = std::move(*decoded.sample);
havePlayable = true;
// Point the built snapshot at the instance's ONE engine-facing block and seed the
// MODEL block from the very PlayParams the voices latch, so an untouched knob folds
@@ -153,22 +162,29 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// top of the first block after this, which is where the swap below becomes visible
// too — so the new snapshot's first note reads it.
sample.live = &automationLive_;
sample.liveContours = &liveContours_;
// BEFORE the publish: the publish is what makes the audio thread re-merge, and the
// merge resolves every held automation stage time against this rate. Stored after,
// a merge in the window between them would resolve them against the previous
// capture's rate — or, on the first-ever build, against 0, where secondsToFrames
// build's rate — or, on the first-ever build, against 0, where secondsToFrames
// collapses every automated envelope stage to zero frames.
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed);
{
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here, the
// same nesting restampHostRateLocked takes under its own reloadMutex_-held
// callers; publishLiveParams never holds reloadMutex_.
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
// The model's contours, so a reload-tier contour edit (a point added, deleted or
// made hard, a drag's release) reaches the drain's sounding voices too, and a
// courier publish from an abandoned gesture cannot outlive the reload.
liveContours_.publish(instrument::engine::foldContours(sample.play));
}
resolvedId = selId; // the concrete pick that resolved
}
}
sourceRateRefused_.store(rateRefused, std::memory_order_relaxed);
if (havePlayable) built = buildInstrumentLocked(std::move(sample));
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
@@ -189,9 +205,9 @@ std::string ReaSamplerProcessor::usageInstanceGuid() {
return instanceGuid_;
}
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
BuiltCapture ReaSamplerProcessor::bakeSnapshot(int renderRate) {
const std::string selId = selectedSampleId();
if (selId.empty()) return std::nullopt;
if (selId.empty()) return {};
const InstrumentParams params = instrumentParams();
SampleRefs refs;
{
@@ -199,10 +215,10 @@ std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
refs = sampleRefs_;
}
const SelectedSample* sel = findRef(refs, selId);
if (!sel) return std::nullopt;
if (!sel) return {};
// No live block is attached: the bake renders the stored parameter set, which is what
// every knob has already written, rather than whatever the audio thread is observing.
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode());
return buildFromRef(*sel, params, bridge_.sampleRoots(), channelMode(), renderRate);
}
void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
@@ -328,13 +344,8 @@ ReaSamplerProcessor::buildInstrumentLocked(SampleData sample) {
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs), pre-sized
// here so process()-time note-on never allocates. Floored at 2 so a valid window is always
// a real ring, covering a pathological host rate <= 0 too. Re-derived per build, so a
// reactivation after the host changed its rate gets a ring sized for the new one.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
// Pre-sized here so process()-time note-on never allocates.
const std::int64_t preserveWindow = preserveWindowFrames(sample);
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
return std::make_unique<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
@@ -358,10 +369,24 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
// Off the audio thread (setActive only). The reactivation half of the lifetime split: the
// voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is.
std::lock_guard<std::mutex> lock(reloadMutex_);
// Every frame count in a built sample — and in the live block published beside it — was
// resolved for the host rate of its build, and setupProcessing may have changed that rate
// while inactive (or a setState load ran before the host reported one). Only the resolve
// depends on it, never the decode, so both paths below re-stamp rather than re-read a file
// that may since have gone missing.
const int renderRate = hostRenderRate();
// A publish that landed while inactive (setState's reload, a bake's adopt) IS the
// activation state — its voices have never rendered, and it has already superseded the
// park. Rebuilding here would displace a correct instrument into the drain slot.
if (live_.load(std::memory_order_acquire)) return true;
// park. Rebuilding here at an unchanged rate would displace a correct instrument into the
// drain slot; at a changed one the rebuild is the re-stamp, and there is nothing sounding
// to displace.
if (const LoadedInstrument* cur = live_.load(std::memory_order_acquire)) {
if (cur->sample.renderRate == renderRate) return true;
SampleData restamped = cur->sample;
if (!restampHostRateLocked(restamped, renderRate)) return false;
publishBuiltLocked(buildInstrumentLocked(std::move(restamped)));
return true;
}
if (!dormantSample_) return false;
// The activation is still where a bank change made with NO EDITOR OPEN is picked up:
@@ -391,6 +416,7 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
// and refreshing the refs without re-decoding would leave the table naming one file while
// the voices play another. Hand back to the full reload, which decodes the new one.
if (sourceMoved) return false;
if (!restampHostRateLocked(*dormantSample_, renderRate)) return false;
// MOVED, not copied: the park exists for this one handoff, and keeping it would hold a
// second copy of the PCM for the whole active lifetime. Disengaged BEFORE the build so a
@@ -405,6 +431,23 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
return true;
}
bool ReaSamplerProcessor::restampHostRateLocked(SampleData& sample, int renderRate) {
if (sample.renderRate == renderRate) return true;
if (!sourceRateSupported(sample.sampleRate, renderRate)) return false;
sample.renderRate = renderRate;
// The model's stored seconds, which is exactly what reloadInstrument resolves (via
// resolveCapture) — so the re-stamp and a disk reload land on the same frames.
sample.play = resolvePlay(instrumentParams().play, sample.outputRate());
// BEFORE the caller's publish, for the reason reloadInstrument gives at the same store.
builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed);
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
// Symmetric with reloadInstrument's own publish pair: a re-stamp is a rebuild too, so the
// drain's sounding voices need the model's contours refreshed beside the live params.
liveContours_.publish(instrument::engine::foldContours(sample.play));
return true;
}
void ReaSamplerProcessor::retireIdleDrain() {
// Cheap early-out before the lock: 0 means "no drain, or it still sounds".
const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
+16
View File
@@ -7,11 +7,27 @@
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::vst {
// What the editor and the bake print for a refused file rate (sourceRateSupported), so the
// limit is spelled in one place.
std::string sourceRateRefusalText();
// The dialed sound as plain data for the offline bake (ReaSamplerProcessor::bakeSnapshot):
// empty when nothing is loaded or the WAV is unreadable; `rateRefused` distinguishes a
// decoded file whose rate is refused against the host's from that plain no-play.
struct BuiltCapture {
std::optional<SampleData> sample;
// The file decoded, but sourceRateSupported (play_params.h) refuses its rate against
// the host's — a distinct failure the user is told about, not an unreadable file.
bool rateRefused = false;
};
// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw
// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics),
// because a hold timer or a log on the audio thread would be per-block work that buys nothing.
+8
View File
@@ -293,6 +293,14 @@ AutomationChannel::ReleaseProof ReaSamplerProcessor::publishLiveParams() {
return AutomationChannel::ReleaseProof::fromPublish(before, liveParams_.generation());
}
void ReaSamplerProcessor::publishLiveContours(const PlaySeconds& play) {
// Folded outside the lock, as publishLiveParams' block is. A contour is normalized time, so
// unlike that block it needs no rate and publishes even before anything is built.
const instrument::engine::LiveContours contours = instrument::engine::foldContours(play);
std::lock_guard<std::mutex> lock(livePublishMutex_);
liveContours_.publish(contours);
}
SampleRefs ReaSamplerProcessor::sampleRefs() {
std::lock_guard<std::mutex> lock(refsMutex_);
return sampleRefs_;
+34 -12
View File
@@ -8,7 +8,7 @@
#include "core/instrument/map/bridge_marshal.h"
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (grow-loop policy)
#include "core/capture/capture_paths.h" // projectDirOfRpp (shared project-dir derivation)
#include "core/instrument/map/sample_roots.h" // ownProjectDir / sampleRootCandidates
#include "ext_keys.h" // kProjExtNamespace (shared wire contract)
// VST3 base types must be included before REAPER's VST3 interface header, which uses
@@ -34,8 +34,10 @@ DEF_CLASS_IID(Steinberg::IReaperHostApplication)
namespace reasampler::vst {
using capture::projectDirOfRpp;
using instrument::map::decodeGetProjExtState;
using instrument::map::ownProjectDir;
using instrument::map::ProjectTab;
using instrument::map::sampleRootCandidates;
bool ReaperBridge::connect(Steinberg::FUnknown* context) {
getProjExtState_ = nullptr;
@@ -63,8 +65,7 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
reaper->getReaperApi("GetProjExtState"));
enumProjExtState_ = reinterpret_cast<EnumProjExtStateFn>(
reaper->getReaperApi("EnumProjExtState"));
// EnumProjects(-1, ...) yields the active project + its .rpp path — same convention
// the persist shell uses, so the instrument derives the project directory identically.
// Walked by sampleRoots to find this instance's own project among the open ones.
enumProjects_ = reinterpret_cast<EnumProjectsFn>(
reaper->getReaperApi("EnumProjects"));
// The (prefix-guarded) usage publish write + the track-identity pair it stamps. All
@@ -93,8 +94,8 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::string& key) {
if (!getProjExtState_ || !hostApp_) return std::nullopt;
// getReaperParent(3) reads the live "reasampler" ext-state against the active project
// the instrument was instantiated in, so it follows project switches for free. A null
// getReaperParent(3) reads the live "reasampler" ext-state against the project the
// instrument was instantiated in, so it follows project switches for free. A null
// project is legitimate (REAPER treats it as the current project) — pass it through
// rather than bailing; a fruitless read still yields nullopt to the caller.
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
@@ -199,13 +200,34 @@ std::string ReaperBridge::currentTrackGuid() {
return std::string(buf);
}
std::string ReaperBridge::activeProjectDir() {
if (!enumProjects_) return {};
// idx=-1 is the current project tab; the out-buffer is empty for a never-saved
// project. projectDirOfRpp keeps that empty (no default-location fallback).
std::vector<std::string> ReaperBridge::sampleRoots() {
if (!enumProjects_ || !hostApp_) return {};
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
const void* own = reaper->getReaperParent(3);
std::vector<ProjectTab> open;
std::vector<char> buf(4096, '\0');
enumProjects_(-1, buf.data(), static_cast<int>(buf.size()));
return projectDirOfRpp(std::string(buf.data()));
const auto enumerate = [&](int idx) -> bool {
buf[0] = '\0'; // so one project's path can never read as the next one's
void* proj = enumProjects_(idx, buf.data(), static_cast<int>(buf.size()));
if (proj) open.push_back(ProjectTab{proj, std::string(buf.data())});
return proj != nullptr;
};
for (int idx = 0; enumerate(idx); ++idx) {}
// The SDK's "currently rendering project" slot, appended after the tabs in case a
// render runs a project the tab walk does not list ([verify — DAW] whether one can).
// A duplicate of a listed tab is harmless: the first identity match wins.
constexpr int kRenderingProject = 0x40000000;
enumerate(kRenderingProject);
// getReaperParent(3) can come back null while REAPER has not yet exposed the parent
// (plugin creation/setState) — the focused tab is the one sanctioned fallback, used
// ONLY then; see ownProjectDir. A non-null own that isn't in `open`, or is unsaved,
// still resolves nothing.
const void* activeFallback =
own ? nullptr
: enumProjects_(-1, buf.data(), static_cast<int>(buf.size()));
return sampleRootCandidates(ownProjectDir(own, open, activeFallback));
}
} // namespace reasampler::vst
+16 -8
View File
@@ -11,6 +11,7 @@
#include <optional>
#include <string>
#include <vector>
#include "pluginterfaces/base/funknown.h"
@@ -31,18 +32,23 @@ public:
bool isConnected() const { return getProjExtState_ != nullptr; }
// Reads a "reasampler" ext-state value by key from the host's active project.
// Returns nullopt when unconnected, unresolvable, or the key is absent.
// Reads a "reasampler" ext-state value by key from this instance's own project
// (getReaperParent(3)). Returns nullopt when unconnected, unresolvable, or the key is
// absent.
//
// NOT REAL-TIME SAFE (allocates + calls into REAPER): audio-thread callers MUST NOT
// invoke this. The instrument reads on the main/UI thread and hands a snapshot to
// the process path.
std::optional<std::string> readReasamplerExtState(const std::string& key);
// The active project's directory (forward-slashed, no trailing slash) — the same
// convention persist uses to place the bank alongside the .rpp. Empty for an unsaved
// project or when unconnected. Not RT-safe.
std::string activeProjectDir();
// The ordered roots this instance's bank-relative sample paths resolve against — its OWN
// project's directory (getReaperParent(3) matched among EnumProjects' open projects),
// never the focused tab's; see core/instrument/map/sample_roots. When getReaperParent(3)
// is null (REAPER may not expose it yet during plugin creation/setState), falls back to
// the focused tab (EnumProjects(-1)) — the one sanctioned use of it. Empty when
// unconnected, when neither resolves to an open project, or when it was never saved.
// Not RT-safe.
std::vector<std::string> sampleRoots();
// The instrument's TWO sanctioned ext-state write surfaces, each accepting exactly one
// key prefix and refusing every other key. That structural refusal is what keeps the
@@ -107,8 +113,10 @@ private:
using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx,
char* keyOut, int keyOut_sz, char* valOut,
int valOut_sz);
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path. idx=-1 (current
// tab) follows the active project, same convention as the persist shell.
// EnumProjects(idx, projfnOut, sz) -> the idx'th open project + its .rpp path, null past
// the last. Walked by index for the open-tabs list; idx=-1 (the focused tab) was the
// wrong-project lookup sampleRoots replaced as the PRIMARY resolve — it survives only as
// the fallback used when getReaperParent(3) is null (see sampleRoots).
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
// Used ONLY by the two prefix-guarded writers — see the read-only-bank note there.
using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
+20 -4
View File
@@ -267,6 +267,12 @@ private:
// the knob. Does not repaint; callers already do. UI thread only.
void commitLive();
// The drawn-contour drag's per-move publish: params_' three contours straight to the voices,
// with NO parameter-set write — why is this directory's CLAUDE.md (the contour-drag note
// under the commit tiers). Every path that ends such a drag without its release's
// commitAndReload — capture-lost — must call this again after restoring params_. UI thread.
void publishContoursLive();
// Whether an in-flight drag commits live rather than through a reload. A deck knob is
// live per deckParamCommit; an envelope-node drag is live in EITHER mode — see
// liveCommitFor (deck_groups.h) for why.
@@ -409,8 +415,13 @@ private:
// The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
int effectiveRoot() const;
// The live sample rate from the bridge, or 0 when unavailable (caller guards).
double liveSampleRate() const;
// The selected capture's OWN rate — the time base that maps its waveform frames to the
// seconds a staged envelope stores — or 0 when nothing is decoded (caller guards). Not the
// host's: the voice plays a source frame at the source's rate whatever the host runs at.
// Read from the same decode the frames came from (monoPcmFor, which every caller has already
// run for its frame count), so the two cannot disagree — the processor's own build can
// fail while the waveform still draws.
double selectedSourceRate() const;
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
double previewVelocity01() const;
// The bake Hold division as a 0..1 knob position, via the pure bake_hold map.
@@ -589,8 +600,13 @@ private:
std::unordered_map<std::string, Envelope> thumbCache_;
// Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope
// binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh.
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;
// binning + zero-crossing snap, and carries the file's own rate beside the frames it is the
// time base of (selectedSourceRate). Cleared alongside thumbCache_ on refresh.
struct MonoPcm {
std::vector<AudioSample> frames;
int sampleRate = 0; // 0 = nothing decoded
};
std::unordered_map<std::string, MonoPcm> pcmCache_;
// The single-slot interleaved-PCM cache behind channelPcmFor (see its note on why this
// is not keyed into pcmCache_). Cleared alongside pcmCache_ on refresh.
+56 -25
View File
@@ -23,6 +23,7 @@
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set)
#include "core/instrument/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter
#include "core/instrument/engine/live_contours.h" // LiveContourBlock (the drawn-contour block)
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
#include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume
#include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding
@@ -31,6 +32,7 @@ namespace reasampler::vst {
using instrument::map::ComponentState;
using instrument::map::InstrumentParams;
using instrument::map::PlaySeconds;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault;
@@ -146,11 +148,14 @@ public:
std::string reloadInstrument();
// The dialed sound as plain data for the offline bake: the SAME refs resolve + decode +
// build reloadInstrument runs, with no live block attached. Rebuilt rather than copied
// off the live snapshot because a tier-3 live edit leaves that snapshot's own play
// params stale on purpose — copying it would bake the pre-drag values. nullopt when
// nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O).
std::optional<SampleData> bakeSnapshot();
// build reloadInstrument runs, with no live block attached, resolved for `renderRate` —
// the rate the bake's plan renders at, passed in so the two cannot disagree. Rebuilt
// rather than copied off the live snapshot because a tier-3 live edit leaves that
// snapshot's own play params stale on purpose — copying it would bake the pre-drag values.
// Empty when nothing is loaded, the WAV is unreadable, or its rate is refused (then
// `rateRefused` says so). Off the audio thread (file I/O). See BuiltCapture
// (processor_snapshot.h).
BuiltCapture bakeSnapshot(int renderRate);
// Adopt a landed bake in ONE act: the new capture becomes this instance's ref and
// selection, the parameter set and master gain go neutral, and a single reload
@@ -178,9 +183,17 @@ public:
// editor borrows it (outlives the editor).
ReaperBridge& bridge() { return bridge_; }
// The live host sample rate latched from setupProcessing; the editor's envelope overlay
// shares this time base. 0.0 before setupProcessing runs.
// The live host sample rate latched from setupProcessing. 0.0 before setupProcessing runs.
double sampleRate() const { return sampleRate_; }
// The same rate as the whole-Hz int every SampleData build and bake plan takes, so the
// reload and the bake round it one way. 0 before setupProcessing runs.
int hostRenderRate() const {
return sampleRate_ > 0.0 ? static_cast<int>(sampleRate_ + 0.5) : 0;
}
// True while the selected capture is silent because the last reload refused its file's
// rate (BuiltCapture::rateRefused). The editor's title band names it
// (sourceRateRefusalText).
bool sourceRateRefused() const { return sourceRateRefused_.load(std::memory_order_relaxed); }
// The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
// Empty id -> silence, no first-sample fallback.
std::string selectedSampleId();
@@ -206,6 +219,11 @@ public:
// bakes and publishes). UI thread; serialized against reloadInstrument's own publish. Returns drainAutomationToModel's release proof (automation_channel.h); other callers ignore it.
AutomationChannel::ReleaseProof publishLiveParams();
// Publishes `play`'s three drawn contours to every sounding voice WITHOUT writing the
// model — the contour drag's courier (this directory's CLAUDE.md, the authority table).
// UI thread; serialized against reloadInstrument's own contour publish.
void publishLiveContours(const PlaySeconds& play);
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
// on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
// fixed stereo, so a mode change never renegotiates host I/O.
@@ -329,12 +347,20 @@ private:
// The reactivation half of the activation/decode lifetime split (see dormantSample_):
// folds the live bank blob into the refs, rebuilds the voice state around the parked
// sample, publishes it through the same drain-slot swap, and republishes usage. True also
// when a publish landed while inactive, which needs no rebuild. False when there is nothing
// to activate from, or when that fold moved what the park was decoded from — the caller
// then falls back to a full reload, which decodes the new file and is also where the pre-v10
// legacy lift lives. Off the audio thread only.
// when a publish landed while inactive, which needs no rebuild. A park or publish resolved
// for a host rate that has since changed is re-stamped in place (restampHostRateLocked)
// rather than re-decoded. False when there is nothing to activate from, when the new host
// rate refuses the file's, or when that fold moved what the park was decoded from — the
// caller then falls back to a full reload, which decodes the new file and is also where the
// pre-v10 legacy lift lives. Off the audio thread only.
bool resumeDormantInstrument();
// Re-resolves `sample` for a new host rate without touching its PCM: the render rate, the
// stage times (from the model's stored seconds, exactly as a reload resolves them) and the
// live seed published beside it. False, leaving `sample` untouched, when the new rate
// refuses the file's. Requires reloadMutex_ held.
bool restampHostRateLocked(SampleData& sample, int renderRate);
// Builds a playable snapshot around `sample` at the current voice-system parameters and
// host rate, stamped with a fresh generation. Requires reloadMutex_ held; the ONE
// construction site shared by the reload, the voice-param rebuild and the reactivation.
@@ -387,6 +413,9 @@ private:
// Both live_ and draining_ observe this same block — a block owned by a snapshot would
// leave the drain's still-sounding voices deaf to the knob under them.
instrument::engine::LiveParams liveParams_;
// SampleData::liveContours' one target, under liveParams_' ordering constraint above and for
// its reason (this directory's CLAUDE.md owns why it needs no automation merge).
instrument::engine::LiveContourBlock liveContours_;
// The block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by
// the audio thread, which merges liveParams_ with the host's automation points once per block
// and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane
@@ -404,22 +433,23 @@ private:
// The host lane's state and its release protocol; automation_channel.h owns the mechanism and
// this directory's CLAUDE.md the authority model it enforces.
AutomationChannel automation_;
// Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams)
// only — separate from reloadMutex_ so a knob drag's publish never blocks behind a
// reload's WAV decode. The audio thread never takes this; process() only reads via
// LiveParams::read's lock-free seqlock retry.
// Serializes the writer sites of liveParams_ (reloadInstrument, publishLiveParams,
// restampHostRateLocked) and of liveContours_ (reloadInstrument, publishLiveContours,
// restampHostRateLocked) only — separate from reloadMutex_ so a drag's publish never blocks
// behind a reload's WAV decode. The audio thread never takes
// this; it only reads via the seqlock's lock-free retry.
std::mutex livePublishMutex_;
// The rate the loaded capture was decoded/built at, so a live republish resolves the
// stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing
// built yet.
// The output rate the loaded capture's play params were resolved at (SampleData::
// outputRate — the host's), so a live republish resolves the stored wall-clock seconds to
// exactly the frames the built SampleData carries. 0 = nothing built yet.
//
// ONE BLOCK, ONE RATE: this is stamped by whichever capture built last, and a reload
// publishes the new block before installing the new instrument. Swapping to a capture at a
// different rate therefore hands drain voices still ringing from the old-rate capture
// envelope frame counts resolved at the NEW rate (~8.8% timing shift on a 48k->44.1k swap).
// Unavoidable while one block sits above every snapshot, and it touches a release tail
// only.
// ONE BLOCK, ONE RATE: one block sits above every snapshot, so a drain voice reads frame
// counts resolved at whatever this last became — harmless while it is the HOST rate for
// every capture. The one residue is a build made before the host reported a rate, which
// resolves at the capture's own and is re-stamped by the activation.
std::atomic<int> builtSampleRate_{0};
// See sourceRateRefused(). Written by reloadInstrument only.
std::atomic<bool> sourceRateRefused_{false};
// --- The audio-thread handoff (drain slot) ---
// process() atomically loads live_ + draining_ at block start (two acquires, no lock).
@@ -553,7 +583,8 @@ private:
std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired
std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired
// Latched from setupProcessing; 0.0 is explicitly invalid (reloadInstrument guards on it).
// Latched from setupProcessing; 0.0 = not reported yet (a build then renders at the
// capture's own rate until resumeDormantInstrument re-stamps it for the reported one).
double sampleRate_ = 0.0;
Steinberg::int32 maxBlockSize_ = 4096;
+3 -10
View File
@@ -1,5 +1,5 @@
// import_bank.cpp — see import_bank.h for the contract. The REAPER-facing half of the
// import: the project directory, the minted bank id, the birth records, and the one
// import: the bank root, the minted bank id, the birth records, and the one
// undo-batched persist. Every decision it makes is in import_landing / import_plan.
//
// main.cpp owns the API pointers; this TU gets them extern. DAW-verified, not unit tested.
@@ -11,12 +11,11 @@
#include <string>
#include <vector>
#include "core/capture/capture_paths.h" // projectDirOfRpp
#include "shell/bank_ops/bank_ops.h" // persistBankOp — one bank op is one Ctrl-Z
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h"
@@ -25,12 +24,6 @@ namespace reasampler {
namespace {
std::string activeProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return capture::projectDirOfRpp(std::string(buf.data()));
}
// The model mints no ids (it stays pure and deterministic), so the shell does — the
// same GUID pair bankOpCreate uses.
std::string mintBankId() {
@@ -62,7 +55,7 @@ ImportBankResult importBankPackage(ReaSamplerSession& session,
std::to_string(static_cast<std::int64_t>(std::time(nullptr)));
LandedFileJournal journal;
const ImportLanding landing = landPackage(packageAbsPath, activeProjectDir(),
const ImportLanding landing = landPackage(packageAbsPath, bankRootOf(nullptr),
session.book(), uniqueTag, journal);
out.outcome = landing.outcome;
out.header = landing.header;
+20 -3
View File
@@ -2,9 +2,9 @@
## Scope
The REAPER-facing shell for the docked bank panel: the eight `bank_panel` split TUs
The REAPER-facing shell for the docked bank panel: the nine `bank_panel` split TUs
(`panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` /
`panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via
`panel_drag_os` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via
`panel_state.h`), plus `draw_kit`, the shared LICE draw shell also consumed by the
VST3 editor (`shell/instrument/`).
@@ -47,12 +47,13 @@ live in `shell/bank_ops`, a sibling directory, not here.
## Modules
- `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 → Placement, frequent acts only, via `action_bar`, short labels; Maintenance and the rarer Placement/Capture variants sit in the 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_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_drag_os` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into nine TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Placement, frequent acts only, via `action_bar`, short labels; Maintenance and the rarer Placement/Capture variants sit in the 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_drag_os` the OS hand-off/resume seam split out of `panel_drag`; `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.
- `panel_window` — SWELL dialog lifecycle + dialog proc + drop-target opt-in. The drop splits by extension: a `.rsbank` is a whole bank and routes to the package-import action (one NEW bank each), everything else keeps the audio-ingest route.
- `panel_layout` — 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_drag_os` — the OS hand-off/resume seam: the live re-ask the OS drag loop uses, the hand-off itself, and resuming the internal drag when the OS loop gives the gesture back.
- `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.
@@ -62,6 +63,22 @@ live in `shell/bank_ops`, a sibling directory, not here.
- **No external UI framework.** iPlug2 / JUCE / VSTGUI are rejected (DS-1). LICE +
reused WDL pieces are the toolkit; reject any path that pulls in a new framework.
- **The FX-drain indicator asks `fxReclaimActivity()` and decides nothing itself.**
`panel_input` polls it once per tick into `g_panel.fxDrain`; the footer readout and the
`WM_SETCURSOR` busy cursor both read that cached state, so the two never disagree about
the drain's own direction. The cursor additionally suppresses on `dragArmed`/`dragging` (a
live card drag outranks it), so mid-drag the footer can still read "parking FX"/"restoring
FX" while the cursor shows the drag cue — that is the one case where paint and cursor
diverge, and it is deliberate. The transport gate, the park debounce and the open-undo-block
stand-off are all already folded into that answer, against the reclaim queue's own pinned
project — do not re-evaluate any of them here, and never drive this off `fxReclaimPending`
(true throughout the debounce, when only the opposite direction is eligible).
The indicator reaches only our own window: over REAPER's arrange or mixer the cursor is
REAPER's, and the SDK exposes nothing host-wide. A global `SetSystemCursor` or a message
hook would reach further and is rejected — do not reintroduce either. `IDC_APPSTARTING`
is Windows-only (SWELL ships no equivalent), so on mac/Linux the readout is the whole
indicator. `[verify — DAW]` whether REAPER's docker even routes `WM_SETCURSOR` to the
docked child dialog at all — the whole Windows half of this rests on that.
- **No hit-test geometry in untestable shell code.** Even when reusing a WDL piece
(e.g. a `vwnd` control for a long scroll list), layout/hit-test math stays in pure
CTest-covered modules — do not import a WDL control's retained-mode object model
+2 -1
View File
@@ -7,6 +7,7 @@
#include <string>
#include "shell/panel/panel_state.h"
#include "shell/persist/bank_root.h"
// PlayPreview/StopPreview (stock, not SWS-only) drive a caller-owned preview_register_t.
#define REAPERAPI_MINIMAL
@@ -75,7 +76,7 @@ void startAudition(int idx) {
const Sample* s = index->query(disp.orderedIds[static_cast<std::size_t>(idx)]);
if (!s) return;
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
const std::string abs = resolveBankFile(projectDir, s->relativePath);
if (abs.empty()) return;
+2 -11
View File
@@ -19,10 +19,10 @@
#include "shell/actions/package_export_action.h" // doBankPackageExport — the export skin
#include "shell/actions/package_import_action.h" // doImportBankPackage — the menu's import row
#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs
#include "shell/persist/bank_root.h"
#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
@@ -32,15 +32,6 @@ namespace reasampler::panel {
namespace fs = std::filesystem;
// Mirrors the persist shell's derivation (ext_state_io.cpp).
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
std::string rpp(buf.data());
if (rpp.empty()) return {};
return normalizeSlashes(fs::path(rpp).parent_path().string());
}
BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; }
// The BankModel a region currently displays. Pool region -> the pool; banks region ->
@@ -206,7 +197,7 @@ std::vector<std::string> resolveDragPathsForOs() {
const BankModel* idx = b->index(g_panel.dragSourceBankId);
if (!idx) return {};
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
resolved.reserve(g_panel.dragSampleIds.size());
for (const std::string& sid : g_panel.dragSampleIds) {
const Sample* s = idx->query(sid);
+27 -47
View File
@@ -148,11 +148,13 @@ void applyDragCursor(CardGesture g) {
SetCursor(LoadCursor(nullptr, idc));
}
} // namespace
// The out-of-client half of the same thin lookup: pure class -> pure cue -> stock cursor. The
// cue is set on EVERY move, so "will this work" is visible before the button comes up, and a
// refusal is a cursor the user can see rather than a release that does nothing. Cursor-only by
// design — the docked panel is usually not under the pointer during an out-of-client drag, so
// panel status text would be invisible exactly when it is needed.
// panel status text would be invisible exactly when it is needed. Shared with panel_drag_os.cpp.
void applyDropCue(DropCue cue) {
const char* idc = nullptr;
switch (cue) {
@@ -169,15 +171,7 @@ void applyDropCue(DropCue cue) {
SetCursor(LoadCursor(nullptr, idc));
}
// One independent evaluation of the drag's target: the resolved class plus the live facts its
// outcome needs. Nothing is remembered between calls (core/ui/CLAUDE.md: "the drag-out law is
// per-move and stateless").
struct LiveDrop {
DropClass cls = DropClass::None;
MediaTrack* track = nullptr;
int screenX = 0; // the evaluated point in screen coords; meaningful only outside the client
};
// Shared with panel_drag_os.cpp (lawHandsOffAtCursor).
LiveDrop resolveLiveDrop(int x, int y) {
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
@@ -199,10 +193,11 @@ LiveDrop resolveLiveDrop(int x, int y) {
const DropProbe probe = probeDropTarget(sp.x, sp.y);
ctx.surface = probe.surface;
ctx.haveTrack = probe.track != nullptr;
// Two probes because they answer two independent questions: what REAPER named, and
// whether we are still inside REAPER at all. The law's hand-off gate reads both — see
// ui::DropContext::pointerOffHost.
// Separate probes because they answer independent questions: what REAPER named, whether
// we are still inside REAPER at all, and whether plugin code owns the window. The law's
// hand-off gates pair them — see ui::DropContext::pointerOffHost.
ctx.pointerOffHost = !pointerOverHostWindow(sp.x, sp.y);
ctx.pointerOverPluginEditor = pointerOverPluginEditor(sp.x, sp.y);
out.track = probe.track;
out.screenX = sp.x;
}
@@ -219,39 +214,7 @@ LiveDrop resolveLiveDrop(int x, int y) {
return out;
}
// The law's one irreversible transition: DoDragDrop takes mouse capture and runs its own modal
// loop, so the internal drag must be fully wound down first, and only once the payload is known
// to be hand-off-able. This runs on every qualifying move — do not memoize a failed attempt.
//
// Residual (accepted): once the pointer has genuinely left every REAPER window, dragging back IN
// mid-modal-loop delivers a CF_HDROP to REAPER's own file-import drop target rather than to our
// gesture law. NOT confirmed by experiment — inferred from REAPER's handling of external file
// drops, and the inferred outcome (an item at the drop point) coincides with what our own
// arrange path would have done. This is now the ONLY way to see that import ghost mid-drag;
// reaching it from a surface still inside REAPER was the defect pointerOffHost closed.
void handOffToOs() {
// Resolve BEFORE tearing anything down (the resolver reads the live drag payload), then let
// the pure rule couple the two side effects: an unresolvable payload leaves the internal
// drag intact rather than winding it down for a hand-off that never runs — a half-torn-down
// drag reads as "the drag did nothing, try again". canInitiateDragOut extends the same
// coupling to the OS-readiness failures the pure decision cannot see.
const std::vector<std::string> paths = resolveDragPathsForOs();
if (!ui::decideOsHandoff(paths).handOffToOs || !canInitiateDragOut(paths)) {
applyDropCue(DropCue::Refuse);
invalidatePanel();
return;
}
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
resetDragState();
invalidatePanel();
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
// The drag state was reset above, so the WM_LBUTTONUP that ends the modal loop takes
// onLBtnUp's not-dragging branch and never restores the arrow — do it here instead.
SetCursor(LoadCursor(nullptr, IDC_ARROW));
}
namespace {
// 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
@@ -339,7 +302,24 @@ void maybeShowTooltip() {
}
}
void onMouseMove(int x, int y) {
void onMouseMove(int x, int y, WPARAM wParam) {
// A move with the button already up while still dragging means the release was missed (the
// OS hand-off's own modal loop can eat a WM_LBUTTONUP, and coalescing can lose one too) —
// treat it as the release now rather than leaving dragging stuck for the next plain click.
#ifdef _WIN32
if (g_panel.dragging && !(wParam & MK_LBUTTON)) {
onLBtnUp(x, y);
return;
}
#else
// SWELL never sets wParam on WM_MOUSEMOVE (swell-types.h ~1315), so the button-up test must
// read the physical key state instead of the message's own (always-zero) wParam.
if (g_panel.dragging && !(GetAsyncKeyState(VK_LBUTTON) & 0x8000)) {
onLBtnUp(x, y);
return;
}
#endif
// Hover feedback: resolve + repaint-on-change, but NOT during a drag (the drag owns
// the visual feedback then — a drop-target highlight, not a hover).
if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y);
+113
View File
@@ -0,0 +1,113 @@
// panel_drag_os.cpp — the OS hand-off / resume seam split out of panel_drag.cpp: the live
// re-ask the OS drag loop uses to decide whether to keep the drag, the hand-off itself, and
// resuming the internal drag when the OS loop gives the gesture back.
//
// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called directly here;
// REAPER SDK types arrive via panel_state.h.
#include <string>
#include <vector>
#include "shell/panel/panel_state.h"
#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam
namespace reasampler::panel {
namespace {
// The physical left button, honouring a swapped primary button (SM_SWAPBUTTON) — distinct from
// dragging/dragArmed, which only say what OUR state machine last believed. Read fresh rather than
// trusted, because DoDragDrop's own modal loop can consume the WM_LBUTTONUP that would otherwise
// have told us the button came up.
bool leftButtonPhysicallyDown() {
const int vk = GetSystemMetrics(SM_SWAPBUTTON) ? VK_RBUTTON : VK_LBUTTON;
return (GetAsyncKeyState(vk) & 0x8000) != 0;
}
// Re-asked by the OS drag loop on every turn. The payload fields the law reads survive
// resetDragState, so it still sees the drag the hand-off wound down. With the panel gone
// mid-loop there is no gesture left to give back, so the OS keeps the drag.
bool lawHandsOffAtCursor() {
if (!g_panel.hwnd) return true;
POINT p{};
GetCursorPos(&p);
ScreenToClient(g_panel.hwnd, &p);
return resolveLiveDrop(p.x, p.y).cls == DropClass::OsHandoff;
}
// The OS loop gave the gesture back (ui::OsDragTurn::Withdraw*): restore exactly the fields
// resetDragState cleared that the drag still needs, then either keep dragging under our own
// capture or — the release was consumed by the OS loop, so no WM_LBUTTONUP will follow — commit
// at the release point now. No move is replayed here: the next WM_MOUSEMOVE re-resolves the cue,
// and replaying one could re-enter handOffToOs from inside itself.
void resumeInternalDrag(const std::string& primaryId, bool released) {
if (!g_panel.hwnd) return;
g_panel.dragArmed = true;
g_panel.dragging = true;
g_panel.dragPrimaryId = primaryId;
POINT p{};
GetCursorPos(&p);
ScreenToClient(g_panel.hwnd, &p);
if (released) {
onLBtnUp(p.x, p.y);
return;
}
// [verify — DAW] that SetCapture re-takes the mouse mid-press after DoDragDrop released it.
SetCapture(g_panel.hwnd);
// The button can have come up between DoDragDrop's own release and this re-capture (the
// Withdrawn turn only promises the button was down AT the last OS-loop turn, not at this
// instant) — without this check dragging stays armed with a stale payload, and the very next
// plain click commits a reorder/replace nobody asked for.
if (!leftButtonPhysicallyDown()) {
onLBtnUp(p.x, p.y);
return;
}
invalidatePanel();
}
} // namespace
// The hand-off: DoDragDrop takes mouse capture and runs its own modal loop, so the internal drag
// must be fully wound down first, and only once the payload is known to be hand-off-able. This
// runs on every qualifying move — do not memoize a failed attempt. The OS loop may hand the
// gesture back (ui::OsDragTurn owns why); a side effect worth keeping is that, on Windows, a
// return trip can no longer drop a CF_HDROP on REAPER's file import or on this panel's own
// WM_DROPFILES ingest — the release there is withdrawn, not dropped.
void handOffToOs() {
// Resolve BEFORE tearing anything down (the resolver reads the live drag payload), then let
// the pure rule couple the two side effects: an unresolvable payload leaves the internal
// drag intact rather than winding it down for a hand-off that never runs — a half-torn-down
// drag reads as "the drag did nothing, try again". canInitiateDragOut extends the same
// coupling to the OS-readiness failures the pure decision cannot see.
const std::vector<std::string> paths = resolveDragPathsForOs();
if (!ui::decideOsHandoff(paths).handOffToOs || !canInitiateDragOut(paths)) {
applyDropCue(DropCue::Refuse);
invalidatePanel();
return;
}
const std::string primaryId = g_panel.dragPrimaryId; // resetDragState clears it
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
resetDragState();
invalidatePanel();
// COPY-ONLY; blocking on Windows.
switch (initiateDragOut(g_panel.hwnd, paths, &lawHandsOffAtCursor)) {
case OsDragEnd::Withdrawn:
resumeInternalDrag(primaryId, /*released=*/false);
return;
case OsDragEnd::WithdrawnAtRelease:
resumeInternalDrag(primaryId, /*released=*/true);
return;
case OsDragEnd::Concluded:
break;
}
// The drag state was reset above, so the WM_LBUTTONUP that ends the modal loop takes
// onLBtnUp's not-dragging branch and never restores the arrow — do it here instead.
SetCursor(LoadCursor(nullptr, IDC_ARROW));
}
} // namespace reasampler::panel
+41 -1
View File
@@ -20,7 +20,9 @@
#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag
#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam
#include "shell/capture/track_guid.h" // guidString — canonical track GUID key
#include "shell/view/view.h" // mintManagedLanes / transportBlocksModeSwitch
#include "shell/view/fx_reclaim.h" // fxReclaimActivity — the drain-direction read
#include "shell/view/view.h" // transportBlocksModeSwitch
#include "shell/view/view_lanes.h" // mintManagedLanes
// New-content detection: enumerate live tracks + items and read fixed-lane state to
// classify an item's lane as managed vs manual.
@@ -475,6 +477,35 @@ int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) {
accelerator_register_t g_accel{translateAccel, true, nullptr};
bool g_accelRegistered = false;
// The ONE map from the reclaimer's answer to the pure readout's vocabulary. Name-for-name
// so a divergence reads as one; the decision itself is entirely fxReclaimActivity's.
// This TU compiles straight into the reaper_reasampler MODULE, which carries none of
// reasampler_pure_library's -Werror=switch/we4062 promotion (footer_bar's mirror switch
// over FxDrainState has that; this one didn't) — a fourth FxReclaimActivity enumerator
// would fall through to Idle here with no diagnostic at all. Promoted locally instead of
// widening the target-wide flag, which would need auditing every switch already compiled
// into this MODULE for an intentionally-partial case list.
#if defined(_MSC_VER)
#pragma warning(push)
#pragma warning(error : 4062)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic push
#pragma GCC diagnostic error "-Wswitch"
#endif
FxDrainState drainStateNow() {
switch (fxReclaimActivity()) {
case FxReclaimActivity::Online: return FxDrainState::Online;
case FxReclaimActivity::Offline: return FxDrainState::Offline;
case FxReclaimActivity::Idle: break;
}
return FxDrainState::Idle;
}
#if defined(_MSC_VER)
#pragma warning(pop)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic pop
#endif
} // namespace
void registerAccel() {
@@ -529,6 +560,15 @@ void bankPanelRefresh() {
InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE);
}
// The drain finishes without a callback, so its direction is polled here alongside the
// transport and repainted only on an edge.
const panel::FxDrainState drain = panel::drainStateNow();
if (drain != panel::g_panel.fxDrain) {
panel::g_panel.fxDrain = drain;
panel::refreshBusyCursor();
InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE);
}
// The custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if
// a toolbar button has rested under the pointer past the delay, latch + repaint it.
panel::maybeShowTooltip();

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