Merge dev into ux-w1-t1-sample-rate: take the spline live-drag and plugin-drop work

This commit is contained in:
2026-09-25 14:17:20 -04:00
47 changed files with 2150 additions and 757 deletions
+4 -2
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. **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-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/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 ## Settled decisions
@@ -201,8 +201,10 @@ Files: `bank-package.md`, `capture-tail.md`, `code-organization.md`, `code-quali
Plan-style docs live under `docs/`: Plan-style docs live under `docs/`:
- `docs/ARCHIVE.md` — pre-1.0 history, rarely read, not a source of context for current work. - `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/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.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 ## The load-bearing principle
+85
View File
@@ -1661,6 +1661,56 @@ v2 blob keeps the behaviour it had rather than losing every FX state.
`TrackFX_GetFXGUID` survives a chain reorder while parked — is unverified in the DAW `TrackFX_GetFXGUID` survives a chain reorder while parked — is unverified in the DAW
(SWS issue #802 names a specific way it might not hold). (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 ### 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 Four waves, merged to `dev` as `da84012`, that took the per-FX offline/online work
@@ -1726,3 +1776,38 @@ 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 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 pure test can't measure the proportional font without pulling shell drawing into
`core/ui`; overflow end-ellipsizes rather than breaking layout. `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.
+30 -515
View File
@@ -392,11 +392,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 | | **Ω-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 | | **Ω-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. **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
**Verified by Daniel, not a fork:** the deck knob-spacing defect is in the **Trigger** face. with Ω-W1-T1 on 2026-08-03. A follow-on split of that drain by kind, Ω-W2-T4 (`70bd29d`),
**Gate is correct and must stay pixel-identical** — an acceptance criterion of Ω-W1-T4, not a was reverted (`2f96dd3`): FX-park state was left stranded across save/reopen, so forced
hope. 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 ### Phase-Ω acceptance criteria
@@ -433,458 +441,11 @@ These bind every track in this phase, in addition to the plan-wide set above.
### Ω-W1 — The five defects, in parallel ### Ω-W1 — The five defects, in parallel
**Depends on:** nothing. **Five tracks, disjoint at the file level.** Items 2, 3 and 4 are ONE **All five tracks have landed** — T1 `mode-switch-responsiveness`, T2 `mode-switch-persist`,
track on purpose: they land in the same deck modules and the same pinned-geometry test files, T3 `meter-rate`, T4 `deck-reflow-focus-toggles`, T5 `overlay-mapping` — merged to `dev` as
so splitting them means re-deriving `test_deck_groups_measured.cpp` three times. Items 5 and 6 `1159d36` (2026-08-03). See `docs/COMPLETED.md` for the full narrative. (T1's deferred
are one track because they overlap inside `paintWaveform`'s mark-draw loop — one deletes and FX-park queue was reverted the same day and its downstream mechanics were superseded again
gates it, the other changes the `mx` it computes. on 2026-08-21 — see the rulings note above.)
| 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.
--- ---
@@ -894,32 +455,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 not to be worth doing, and that is a legitimate outcome. T3's deliverable is a **RECORD, not a
fix** (the Λ-W3 precedent). fix** (the Λ-W3 precedent).
| Track | Owns | | Track | Owns | Status |
|---|---| |---|---|---|
| **T1** `view-topology-cache` | `src/core/view/` — tree / `parentOf` / handle-map memoization | | **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 | | **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 | | **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 — **spec'd nowhere; see below** | | **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 **T2 and T5 have landed** — see `docs/COMPLETED.md` for the full narrative. T5 arrived from
fourth (Ω-W1-T5's surface, which lands first). 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
**Ω-W2-T5 has no spec section here, on purpose.** It arrived from ruling R3 — Daniel, spec section here; there is no Ω-W2-T4. Its documentation gap (Ctrl-defeats-snap on a marker
2026-08-03, recorded in `docs/product/single-cycle-and-wavetable.md` §7.1 and summarized at drag, previously unhomed in `docs/product/instrument-control-surface.md`) has since been
"Flagged for awareness" item 4 above — after this phase was written, and is **committed on closed — that doc's §4.1 now carries the gesture.
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.
#### Ω-W2-T1 — `view-topology-cache` *(measurement-gated on Ω-W1-T1's result)* #### Ω-W2-T1 — `view-topology-cache` *(measurement-gated on Ω-W1-T1's result)*
@@ -949,39 +497,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 **Prerequisites.** Ω-W1-T1 landed and measured. **Discharges:** Ω.7's residual, or records it
as not worth taking. 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` #### Ω-W2-T3 — `omega-verification-sweep`
**Goal.** The DAW-observable half of this phase is recorded rather than assumed. **Goal.** The DAW-observable half of this phase is recorded rather than assumed.
+107
View File
@@ -0,0 +1,107 @@
# 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 (in progress, 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 (in progress, 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
+45
View File
@@ -892,3 +892,48 @@ them in full.
two-instance-of-the-same-plugin case are each observed in a live REAPER session, and 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 either the identity is confirmed to survive all four, or a degradation is found and
the keying is amended. 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.
+2 -1
View File
@@ -27,6 +27,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp ${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.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.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag_os.cpp
${REASAMPLER_SRC_DIR}/shell/panel/footer_input.cpp ${REASAMPLER_SRC_DIR}/shell/panel/footer_input.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp ${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp ${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp
@@ -62,7 +63,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp ${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.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 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 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 # 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 # 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. # link graph free of the voice engine — a link edge to it here means the design drifted.
+13 -4
View File
@@ -230,6 +230,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 - **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 voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by
the envelope's own bounded offset smoother. 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) ### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md)
@@ -282,8 +290,9 @@ consequences, each with one home:
- **Normalized is what makes a contour length-independent.** There are no stored seconds to - **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. rescale, so a different-length capture replays the same shape proportionally.
- **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope - **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 structs.** Those are copied whole into the live block, which must stay small and trivially
(`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it 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. travels by reload.
**Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:** **Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:**
@@ -321,7 +330,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: - 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. - `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. - `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.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. - `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. - `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.
@@ -423,7 +432,7 @@ anything for a trigger shape.
envelope that was never running. envelope that was never running.
- **A live edit leaves the snapshot's own `sample.play` stale, on purpose.** The block, not the - **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 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 - **`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); covers client-pixel widths (including multiples standing in for larger client areas);
nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI
+1
View File
@@ -39,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 // 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. // this SampleData's own play params, which is what the bake is meant to print.
sample.live = nullptr; sample.live = nullptr;
sample.liveContours = nullptr;
const int channels = sample.channelCount(); const int channels = sample.channelCount();
// The limiter delays its output by its lookahead, so the buffers carry that many extra // The limiter delays its output by its lookahead, so the buffers carry that many extra
+8 -3
View File
@@ -26,10 +26,11 @@ add_subdirectory(filter)
# marker layer. After filter: it links play_params' dependency set, which includes it. # marker layer. After filter: it links play_params' dependency set, which includes it.
add_subdirectory(loop) add_subdirectory(loop)
# The live-parameter block: the value layer plus its publication, deliberately linking no # The live-parameter block and the drawn-contour block beside it: value layers plus their
# engine — the block is a plain value the voice observes, not a thing the engine owns. # 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 reasampler_pure_library(live_params
SOURCES live_params.cpp SOURCES live_params.cpp live_contours.cpp
LINK PUBLIC peaks velocity_curve filter curve_law) LINK PUBLIC peaks velocity_curve filter curve_law)
reasampler_test(live_params LINK live_params) reasampler_test(live_params LINK live_params)
@@ -52,6 +53,10 @@ reasampler_test(sampler_filter LINK sampler_core)
# does to a voice that is already sounding, and what it must leave alone. # does to a voice that is already sounding, and what it must leave alone.
reasampler_test(live_delivery LINK sampler_core) 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 # 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. # both mode shapes share, and the Trigger tail's terminal behaviour.
reasampler_test(staged_envelopes LINK sampler_core) reasampler_test(staged_envelopes LINK sampler_core)
@@ -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 #pragma once
// live_params.h — the live playback-parameter block: the plain value bundle the audio thread // 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 // observes once per BLOCK, its publication (the seqlock.h protocol) without a lock or a torn
// torn read, the ONE fold from PlayParams that keeps the two representations in step, and the // 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 // per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every
// instrument snapshot (see SampleData::live). // instrument snapshot (see SampleData::live).
#include <atomic>
#include <cstdint> #include <cstdint>
#include <type_traits> #include <type_traits>
#include "core/instrument/engine/play_params.h" #include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/seqlock.h"
namespace reasampler::instrument::engine { 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. // PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
LiveValues foldLive(const PlayParams& params, double keyTrack); LiveValues foldLive(const PlayParams& params, double keyTrack);
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd // The live-parameter block's publication — seqlock.h owns the protocol and its single-writer
// and an even generation; the reader copies the block and re-checks the generation, retrying // contract. A class rather than an alias so play_params.h can forward-declare it.
// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the class LiveParams : public Seqlock<LiveValues> {};
// 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_{};
};
// Linear per-frame glide with EXACT termination: once the target is within one step the value // 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, // becomes the target itself. An asymptotic smoother would leave the value forever a hair off,
+7 -3
View File
@@ -15,7 +15,8 @@
namespace reasampler { 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 audio::AudioSample;
using instrument::engine::VelocityCurve; using instrument::engine::VelocityCurve;
@@ -181,8 +182,8 @@ struct PlayParams {
// The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to // 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 // 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 // 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 // copyable (live_params.h). A contour reaches a sounding voice through its own block
// curves it travels by reload. // instead (live_contours.h); only its Staged|Spline mode travels by reload.
SplineEnv ampSpline; SplineEnv ampSpline;
SplineEnv pitchSpline; SplineEnv pitchSpline;
SplineEnv filterSpline; SplineEnv filterSpline;
@@ -299,6 +300,9 @@ struct SampleData {
// same knob as a live one. That is the desired behaviour — it is the note the user is // 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. // hearing. Do not "fix" it by moving ownership into the snapshot.
const instrument::engine::LiveParams* live = nullptr; 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 // A framesR of a different length than frames is treated as absent — a malformed pair
// never half-plays. // never half-plays.
+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
+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 // sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the
// 128-point ceiling. // 128-point ceiling.
// //
// Holds a RAW POINTER into the bound curve's point array: the caller guarantees the curve // Holds a RAW POINTER into the bound point array: the caller guarantees the array outlives the
// outlives the cursor. The voice binds against its SampleData, which has exactly that lifetime. // 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 { class SplineCursor {
public: public:
// Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so // 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. // the caller's `if (active())` skips the whole spline path.
void bind(const VelocityCurve& c) { void bind(const VelocityCurve& c) { bind(c.points().data(), c.points().size()); }
const std::vector<VelocityPoint>& pts = c.points(); // The same over a raw X-ordered array (live_contours.h's fixed-capacity copy). Rebinding a
if (pts.size() < 2) { clear(); return; } // cursor mid-read is legal: it restarts the segment cache, and the next eval re-locates.
pts_ = pts.data(); void bind(const VelocityPoint* pts, std::size_t n) {
n_ = pts.size(); if (pts == nullptr || n < 2) { clear(); return; }
pts_ = pts;
n_ = n;
select(0); select(0);
} }
void clear() { pts_ = nullptr; n_ = 0; } void clear() { pts_ = nullptr; n_ = 0; }
+26
View File
@@ -111,6 +111,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
} else { } else {
filterSplineCur_.clear(); filterSplineCur_.clear();
} }
ampSplineSmooth_.clear();
pitchSplineSmooth_.clear();
filterSplineSmooth_.clear();
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to // 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. // frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
@@ -336,6 +339,29 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
rMorph_.moving() || rDrive_.moving(); 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) { void Voice::retune(int note) {
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps // 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 // running (no re-attack), the read head keeps its position, the shifter keeps its ring
+37 -8
View File
@@ -15,6 +15,7 @@
#include "core/instrument/engine/envelopes.h" #include "core/instrument/engine/envelopes.h"
#include "core/instrument/engine/filter/filter_params.h" #include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.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/live_params.h"
#include "core/instrument/engine/loop/loop_span.h" #include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h" #include "core/instrument/engine/pitch_shift.h"
@@ -179,6 +180,14 @@ public:
// pitch ratio, and the decoded PCM stay latched at note-on. // pitch ratio, and the decoded PCM stay latched at note-on.
void applyLive(const instrument::engine::LiveValues& live, bool snap); 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 // 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 // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through. // runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
@@ -271,6 +280,14 @@ private:
// its opening value. // its opening value.
double splinePhase() const { return readPos_ * splineScale_; } 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 // 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 // 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 // per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
@@ -287,11 +304,17 @@ private:
if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) { if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) {
// Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's // 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. // 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() && if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
ampSplineCur_.segmentEndValue() == 0.0) { ampSplineCur_.segmentEndValue() == 0.0) {
amplitudeDone_ = true; amplitudeDone_ = true;
} }
if (amp < 0.0) amp = 0.0;
else if (amp > 1.0) amp = 1.0;
} else if (playMode_ == PlayMode::Gate) { } else if (playMode_ == PlayMode::Gate) {
amp = env_.tick(); amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true; if (env_.finished()) amplitudeDone_ = true;
@@ -324,7 +347,7 @@ private:
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the // the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
// branch is perfectly predicted. // branch is perfectly predicted.
const double envOut = filterSplineCur_.active() const double envOut = filterSplineCur_.active()
? filterSplineCur_.eval(splinePhase()) ? readSpline(filterSplineCur_, filterSplineSmooth_)
: ((playMode_ == PlayMode::Gate) : ((playMode_ == PlayMode::Gate)
? filterEnv_.tick() ? filterEnv_.tick()
: filterAhd_.amplitudeAt(sourceOffset())); : filterAhd_.amplitudeAt(sourceOffset()));
@@ -517,8 +540,9 @@ private:
seedTerminalDeclick(); seedTerminalDeclick();
} }
const double gain = amp * velocityGain_; const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchSplineCur_.active() const double pitchEnvSemis =
? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase()) pitchSplineCur_.active()
? pitchSplineDepth_ * readSpline(pitchSplineCur_, pitchSplineSmooth_)
: pitchEnv_.tick(); : pitchEnv_.tick();
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the // 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
@@ -752,13 +776,18 @@ private:
bool amplitudeDone_ = false; // set when the active amplitude envelope finished 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 // 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 // SampleData outlives the voice — same contract as sample_) and, when the engine observes a
// inactive, so a purely staged instrument's per-sample path gains three predicted branches // contour block, re-pointed at its engine's live copy (applyContours). A Staged EG leaves its
// and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour // cursor inactive, so a purely staged instrument's per-sample path gains three predicted
// shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled. // 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 ampSplineCur_;
SplineCursor pitchSplineCur_; SplineCursor pitchSplineCur_;
SplineCursor filterSplineCur_; SplineCursor filterSplineCur_;
StepSmoother ampSplineSmooth_;
StepSmoother pitchSplineSmooth_;
StepSmoother filterSplineSmooth_;
double splineScale_ = 0.0; double splineScale_ = 0.0;
double pitchSplineDepth_ = 0.0; double pitchSplineDepth_ = 0.0;
+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) { 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 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 // 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. // 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, voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
lengthFraction); lengthFraction);
if (haveLive_) voice.applyLive(live_, /*snap=*/true); 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_++); voice.setStartOrder(nextStartOrder_++);
} }
@@ -258,6 +282,7 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
// audio thread never touches the heap. // audio thread never touches the heap.
if (out == nullptr || frameCount == 0) return; if (out == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop applyLiveToActive(); // block boundary, once — never inside the frame loop
applyContoursToActive();
for (Voice& voice : voices_) { for (Voice& voice : voices_) {
if (!voice.active()) continue; if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) { 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). // discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
if (left == nullptr || right == nullptr || frameCount == 0) return; if (left == nullptr || right == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop applyLiveToActive(); // block boundary, once — never inside the frame loop
applyContoursToActive();
for (Voice& voice : voices_) { for (Voice& voice : voices_) {
if (!voice.active()) continue; if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) { for (std::size_t f = 0; f < frameCount; ++f) {
+22
View File
@@ -10,6 +10,7 @@
#include <vector> #include <vector>
#include "core/audio/peaks.h" #include "core/audio/peaks.h"
#include "core/instrument/engine/live_contours.h"
#include "core/instrument/engine/live_params.h" #include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/play_params.h" #include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/voice.h" #include "core/instrument/engine/voice.h"
@@ -57,6 +58,11 @@ public:
MonoTrigger monoTrigger = MonoTrigger::Retrigger, MonoTrigger monoTrigger = MonoTrigger::Retrigger,
bool takeoverDeclick = false); 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 // 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 // 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. // 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 std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet
bool haveLive_ = false; 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 // Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
// (cheap: bounded by maxVoices) rather than maintained as a running tally. // (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const; std::size_t activePreserveVoices() const;
+26 -11
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 - **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 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 between evaluations. That is what makes every transition reversible and what
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. Leaving makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. The OS
REAPER entirely is the one irreversible transition, because the OS hand-off goes hand-off goes modal, so it must be PROVEN by a window test in the shell, never
modal — so it must be PROVEN (a window-ownership test in the shell), never inferred inferred from a hit-test token REAPER could not name; an unrecognised surface
from a hit-test token REAPER could not name; an unrecognised surface *inside* REAPER *inside* REAPER refuses. There are exactly two proofs, each necessary but NOT
refuses. That proof is necessary but NOT sufficient: a plugin bridged into sufficient, each paired with a hit-test condition:
`reaper_host*.exe` fails the process test while still being REAPER's own UI, so the - **Leaving REAPER** (process ownership). A plugin bridged into `reaper_host*.exe`
hand-off additionally requires REAPER's hit-test to have named nothing — a recognised fails the process test while still being inside REAPER, so this route also requires
token can only ever veto the hand-off, never produce one. A first-move class lock and REAPER's hit-test to have named nothing — here a recognised token can only VETO.
a drag-lifetime "cannot hand off" latch both existed here and were removed — do not - **A plugin's own editor** (code ownership, `window_origin`). The proof alone would
reintroduce either. 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 ## Modules
@@ -103,7 +117,8 @@ 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. - `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. - `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. - `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. - `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. - `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). - `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching).
+3
View File
@@ -10,6 +10,9 @@ reasampler_test(prune_button LINK prune_button)
reasampler_pure_library(drag_out SOURCES drag_out.cpp) reasampler_pure_library(drag_out SOURCES drag_out.cpp)
reasampler_test(drag_out LINK drag_out) 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_pure_library(theme SOURCES theme.cpp)
reasampler_test(theme LINK theme) 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 (!ctx.drag.dragging || !ctx.drag.hasArmedSamples) return DropClass::None;
if (insideClient(px, py, client)) return DropClass::Internal; if (insideClient(px, py, client)) return DropClass::Internal;
// The ONE route to OsHandoff, ahead of the surface switch, and it needs BOTH halves. The // The two routes to OsHandoff, both ahead of the surface switch, each needing BOTH halves.
// 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 // Leaving REAPER. The process proof alone is not sufficient: REAPER runs a bridged plugin's UI
// token therefore VETOES the hand-off instead of producing one — a veto can only move the // in a separate process, so a window failing that test can still be inside REAPER. A
// answer toward staying inside REAPER, so pointerOffHost remains the only member whose true // recognised token therefore VETOES this route instead of producing it — a veto can only move
// value can reach OLE. An off-host point REAPER DID name falls through to that surface's own // the answer toward staying inside REAPER. An off-host point REAPER DID name falls through to
// outcome below: over a bridged FX window that is the instrument drop, as it is for a native // the second route, then to that surface's own outcome below.
// one.
if (ctx.pointerOffHost && ctx.surface == ReaperSurface::Other && !ctx.haveTrack) if (ctx.pointerOffHost && ctx.surface == ReaperSurface::Other && !ctx.haveTrack)
return DropClass::OsHandoff; 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) { switch (ctx.surface) {
case ReaperSurface::TrackPanel: case ReaperSurface::TrackPanel:
case ReaperSurface::FxSurface: case ReaperSurface::FxSurface:
@@ -100,4 +107,10 @@ OsHandoff decideOsHandoff(const std::vector<std::string>& paths) {
return out; 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 } // 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 + // hit-testing, cursor setting, and outcome execution stay in the shell (panel_drag.cpp +
// instrument_drop_win / arrange_drop_win / drag_out_win). // 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 // THE LAW: the class is resolved from what is under the cursor on EVERY move — and on every turn
// is reversible until release or until the pointer leaves REAPER entirely; the OS hand-off is // of the OS drag loop once handed off (decideOsDragTurn), so every transition is reversible until
// reserved for leaving REAPER (DropContext::pointerOffHost owns what proves that) and every // 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 // REAPER-internal target executes natively on release. Do not reintroduce a first-move class
// lock or a drag-lifetime "blocked" latch. // 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 InstrumentDrop, // a track's panel or FX surface — add ReaSampler 9000 preloaded
ArrangeInsert, // the timeline — place items at the pointer's track and time 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 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 // 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) bool singlePayload = false; // payload holds EXACTLY one capture (arms InstrumentDrop)
ReaperSurface surface = ReaperSurface::Other; // only read outside the client rect ReaperSurface surface = ReaperSurface::Other; // only read outside the client rect
bool haveTrack = false; // GetThingFromPoint returned a non-null MediaTrack* 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 // THE ONE HOME for the hand-off's evidence rule. This member and pointerOverPluginEditor are
// can reach the irreversible OS hand-off, and it is a POSITIVE proof the shell owes the law // the only two whose true value can reach the modal OS hand-off, and each is a POSITIVE proof
// — a window-ownership test (drag_out_win::pointerOverHostWindow), never "the hit-test named // the shell owes the law — a window test, never "the hit-test named nothing I recognise",
// nothing I recognise", which is equally true of the transport, the toolbar and the docker // which is equally true of the transport, the toolbar and the docker chrome; reading an
// chrome; reading an unnamed token as an exit is what handed live drags to OLE mid-gesture. // unnamed token as an exit is what handed live drags to OLE mid-gesture. Both default false
// Defaults false so an unset context stays inside REAPER: the failure mode of forgetting to // so an unset context stays inside REAPER: forgetting to resolve one must be a refusal, never
// resolve it must be a refusal, never a drag given away to OLE. NECESSARY but not sufficient // a drag given away to OLE. Each is NECESSARY but not sufficient — decideDropClass pairs each
// — decideDropClass's gate carries the second half and why it needs one. // 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; 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, // 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. // means the internal drag stays live rather than dying half-torn-down.
OsHandoff decideOsHandoff(const std::vector<std::string>& paths); 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 } // 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
+4 -3
View File
@@ -1,8 +1,9 @@
#pragma once #pragma once
// instrument_drop — pure payload-construction core of drop-and-load: dropping a // 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 // bank capture onto a track's panel or an FX window's REAPER chrome instantiates
// that track already playing that capture. Also the classifier that names those // ReaSampler 9000 on that track already playing that capture (a plugin's own editor
// surfaces. No REAPER/SWELL/VST3 SDK/vendor includes // 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 // (+ the pure sample_map it reuses and the SDK-free UID macros in
// reasampler_uid.h); unit-tested outside the DAW. // reasampler_uid.h); unit-tested outside the DAW.
// //
+2 -2
View File
@@ -57,8 +57,8 @@ is owned by other directories and only skinned here.
- `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. - `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. - `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. - `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 — 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.** - `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. - `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. - `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. - `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.
+151 -15
View File
@@ -12,6 +12,10 @@
#include <shlobj.h> // DROPFILES, CF_HDROP #include <shlobj.h> // DROPFILES, CF_HDROP
#include <cstring> #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 reasampler {
namespace { namespace {
@@ -86,10 +90,16 @@ bool ensureOleForThisThread() {
return state > 0; return state > 0;
} }
// Minimal IDropSource: continue until the (left) button releases or Escape cancels; always // IDropSource: the textbook button/Escape source, plus the law re-asked every turn so the drag can
// request the copy cursor. This is the standard textbook drop source — no custom feedback. // 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 { class DropSource final : public IDropSource {
public: public:
explicit DropSource(bool (*lawHandsOff)()) : lawHandsOff_(lawHandsOff) {}
ui::OsDragTurn withdrawal() const { return withdrawal_; }
// IUnknown // IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override {
if (riid == IID_IUnknown || riid == IID_IDropSource) { if (riid == IID_IUnknown || riid == IID_IDropSource) {
@@ -108,15 +118,33 @@ public:
} }
// IDropSource // IDropSource
HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escapePressed, DWORD keyState) override { HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escapePressed, DWORD keyState) override {
if (escapePressed) return DRAGDROP_S_CANCEL; const bool escape = escapePressed != FALSE;
if (!(keyState & MK_LBUTTON)) return DRAGDROP_S_DROP; // released -> drop // Escape ignores the law, so it is not probed then; no callback is the textbook source.
return S_OK; // keep dragging 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 { HRESULT STDMETHODCALLTYPE GiveFeedback(DWORD /*effect*/) override {
return DRAGDROP_S_USEDEFAULTCURSORS; // let OLE draw the standard copy cursor return DRAGDROP_S_USEDEFAULTCURSORS; // let OLE draw the standard copy cursor
} }
private: private:
volatile LONG refs_ = 1; 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 // Minimal IDataObject exposing exactly one format (CF_HDROP / TYMED_HGLOBAL). The HDROP is
@@ -214,25 +242,34 @@ private:
} // namespace } // namespace
bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths) { OsDragEnd initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths,
if (absolutePaths.empty()) return false; bool (*lawHandsOff)()) {
if (!ensureOleForThisThread()) return false; if (absolutePaths.empty()) return OsDragEnd::Concluded;
if (!ensureOleForThisThread()) return OsDragEnd::Concluded;
HGLOBAL hdrop = buildHDrop(absolutePaths); HGLOBAL hdrop = buildHDrop(absolutePaths);
if (!hdrop) return false; if (!hdrop) return OsDragEnd::Concluded;
auto* data = new HDropDataObject(hdrop); // takes ownership of hdrop auto* data = new HDropDataObject(hdrop); // takes ownership of hdrop
auto* source = new DropSource(); auto* source = new DropSource(lawHandsOff);
DWORD effect = 0; DWORD effect = 0;
// COPY-ONLY: the allowed-effects mask is DROPEFFECT_COPY alone. MOVE is never // COPY-ONLY: the allowed-effects mask is DROPEFFECT_COPY alone. MOVE is never
// offered, so no drop target can relocate (delete) the bank file. // 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(); source->Release();
data->Release(); // frees the source HGLOBAL via HDropDataObject's dtor 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) { bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
@@ -265,6 +302,98 @@ bool pointerOverHostWindow(int screenX, int screenY) {
return pid == GetCurrentProcessId(); 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 } // namespace reasampler
#else // ---- macOS / Linux (SWELL) ----------------------------------------------------- #else // ---- macOS / Linux (SWELL) -----------------------------------------------------
@@ -278,8 +407,9 @@ namespace reasampler {
// SWELL_InitiateDragDropOfFileList initiates a copy-style file drag from the given // 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 // 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. // guarantee here rests on SWELL's copy semantics rather than an explicit mask.
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths) { OsDragEnd initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths,
if (absolutePaths.empty() || !panelHwnd) return false; bool (* /*lawHandsOff*/)()) {
if (absolutePaths.empty() || !panelHwnd) return OsDragEnd::Concluded;
std::vector<const char*> ptrs; std::vector<const char*> ptrs;
ptrs.reserve(absolutePaths.size()); 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. // srcrect null: SWELL positions the drag image at the current event. No custom icon.
SWELL_InitiateDragDropOfFileList(reinterpret_cast<HWND>(panelHwnd), nullptr, SWELL_InitiateDragDropOfFileList(reinterpret_cast<HWND>(panelHwnd), nullptr,
ptrs.data(), static_cast<int>(ptrs.size()), 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 // 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; 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 } // namespace reasampler
#endif #endif
+48 -4
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
// drag_out_win — the host/OS boundary of native OS drag-out: whether the pointer has // 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 // 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 // drop bank samples into Explorer / another app / another DAW. The pure gesture-boundary
// decision + path-list assembly live in drag_out.*. // 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 { 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 // `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 // 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 // message loop, re-asking `lawHandsOff` (the gesture law at the current pointer) on every
// (DROPEFFECT_COPY); the return is advisory — a failed drag surfaces no error. // IDropSource::QueryContinueDrag and withdrawing per ui::decideOsDragTurn. macOS/Linux: SWELL's
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths); // 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 // 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 // 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. // Side-effect-free; safe on every mouse-move.
bool pointerOverHostWindow(int screenX, int screenY); 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 } // namespace reasampler
+30 -11
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 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`. 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 **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 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. `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 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 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 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 ### 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 | | Writer | Authority begins | Authority ends |
|---|---|---| |---|---|---|
| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model | | 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) | | Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) |
| State restore (`setState`) | the call | the call returns | | State restore (`setState`) | the call | the call returns |
| Bake reset (`adoptBakedCapture`) | 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 | | 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** | | **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" Every writer above except the contour courier and the last two writes the model directly, so for
is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never those "authority ends" is just "the write happened". Reload seed is not itself a model write —
touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193` `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 — 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 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 allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the
@@ -229,13 +242,19 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
## Gotchas ## Gotchas
- **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a - **`reasampler_processor.h` carries a small, documented ceiling exception.** It was described as
false premise.** It was described as ONE class declaration; it also carried two namespace-scope ONE class declaration; it also carried two namespace-scope aggregates (`MasterBusMeter`,
aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are `LoadedInstrument`) and the automation lane's own state. All three are split out —
now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds `processor_snapshot.h` and `automation_channel.h`, on the same grounds `editor_interaction.h`
`editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is was split out of `reasampler_editor.h` in this directory: neither is behaviour. What remains is
behaviour. What remains is under the ceiling. Its bulk is the drain-slot proof and the the class declaration itself, a handful of lines over the ceiling since the live-contour block
RT-discipline constraints, which the comment conventions name as keep-worthy. (`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 - **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 `Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
@@ -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 // 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. // rather than letting it fall through to a marker grab under the cursor.
if (index < 0) return true; if (index < 0) return true;
publishContoursLive(); // the new point is heard before the first move, not after it
} }
drag_ = DragKind::kSplineNode; drag_ = DragKind::kSplineNode;
curvePointIndex_ = index; curvePointIndex_ = index;
@@ -188,7 +189,7 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
dragCurveRect_ = waveArea.rect; dragCurveRect_ = waveArea.rect;
dragStartX_ = x; dragStartX_ = x;
dragStartY_ = y; dragStartY_ = y;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP invalidate(); // the model commit lands on WM_LBUTTONUP
return true; return true;
} }
@@ -235,7 +236,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag( splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag(
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_), dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
splineOverlayBox(overlay), dx, y - dragStartY_); splineOverlayBox(overlay), dx, y - dragStartY_);
invalidate(); // live feedback; commit on release publishContoursLive(); // heard now; the model write stays the release's commit
invalidate();
return; return;
} }
+45 -5
View File
@@ -13,6 +13,7 @@
#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only) #include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only)
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
#include "core/version/app_version.h" // vstOutputName — channel-forked window class name
using namespace Steinberg; using namespace Steinberg;
@@ -20,7 +21,32 @@ namespace reasampler::vst {
#ifdef _WIN32 #ifdef _WIN32
namespace { 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 // 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 // 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); HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return; if (!parent) return;
HINSTANCE hInst = // Own module, not the parent's hInstance — see moduleHandleFor() above.
reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE)); HINSTANCE hInst = reinterpret_cast<HINSTANCE>(
moduleHandleFor(reinterpret_cast<void*>(&ReaSamplerEditor::wndProc)));
if (!hInst) hInst = GetModuleHandle(nullptr); if (!hInst) hInst = GetModuleHandle(nullptr);
static bool classRegistered = false; static bool classRegistered = false;
@@ -114,7 +141,7 @@ void ReaSamplerEditor::attachedToParent() {
WNDCLASSW wc{}; WNDCLASSW wc{};
wc.lpfnWndProc = &ReaSamplerEditor::wndProc; wc.lpfnWndProc = &ReaSamplerEditor::wndProc;
wc.hInstance = hInst; wc.hInstance = hInst;
wc.lpszClassName = kChildClassName; wc.lpszClassName = childClassName().c_str();
wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
// CS_DBLCLKS is what makes WM_LBUTTONDBLCLK arrive at all. It also REPLACES the second // 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 // WM_LBUTTONDOWN of a double-click, so every surface that counted two downs (Browse's
@@ -134,7 +161,7 @@ void ReaSamplerEditor::attachedToParent() {
refreshFromBank(); refreshFromBank();
const ViewRect& r = getRect(); 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); r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
if (childHwnd_) { if (childHwnd_) {
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this)); 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 // 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 // performEdit. The capture-lost path normally closes it; this does not rely on Windows
// delivering WM_CAPTURECHANGED before the window goes away. // 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 (processor_) processor_->endParamGesture();
if (childHwnd_) { if (childHwnd_) {
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away 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. // drag used.
if (self->dragCommitsLive(self->drag_, self->dragParamId_)) { if (self->dragCommitsLive(self->drag_, self->dragParamId_)) {
self->commitLive(); 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 // Peer of onMouseUp's bracket close, and after the rollback for the same
+4
View File
@@ -251,6 +251,10 @@ void ReaSamplerEditor::commitLive() {
processor_->publishLiveParams(); processor_->publishLiveParams();
} }
void ReaSamplerEditor::publishContoursLive() {
if (processor_) processor_->publishLiveContours(params_.play);
}
bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) { bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) {
return paramId == -2 || return paramId == -2 ||
paramId == static_cast<int>(ParamControl::kVoiceCount) || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
@@ -164,6 +164,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// top of the first block after this, which is where the swap below becomes visible // 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. // too — so the new snapshot's first note reads it.
sample.live = &automationLive_; sample.live = &automationLive_;
sample.liveContours = &liveContours_;
// BEFORE the publish: the publish is what makes the audio thread re-merge, and the // 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, // 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 // a merge in the window between them would resolve them against the previous
@@ -175,6 +176,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// publishLiveParams never holds reloadMutex_, so this is the only nesting. // publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_); std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack)); 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 resolvedId = selId; // the concrete pick that resolved
} }
+8
View File
@@ -293,6 +293,14 @@ AutomationChannel::ReleaseProof ReaSamplerProcessor::publishLiveParams() {
return AutomationChannel::ReleaseProof::fromPublish(before, liveParams_.generation()); 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() { SampleRefs ReaSamplerProcessor::sampleRefs() {
std::lock_guard<std::mutex> lock(refsMutex_); std::lock_guard<std::mutex> lock(refsMutex_);
return sampleRefs_; return sampleRefs_;
+6
View File
@@ -267,6 +267,12 @@ private:
// the knob. Does not repaint; callers already do. UI thread only. // the knob. Does not repaint; callers already do. UI thread only.
void commitLive(); 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 // 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 // live per deckParamCommit; an envelope-node drag is live in EITHER mode — see
// liveCommitFor (deck_groups.h) for why. // liveCommitFor (deck_groups.h) for why.
+14 -4
View File
@@ -23,6 +23,7 @@
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set) #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/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter #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/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/engine/meter_accumulate.h" // the meter's block-rate folds + consume
#include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding #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::ComponentState;
using instrument::map::InstrumentParams; using instrument::map::InstrumentParams;
using instrument::map::PlaySeconds;
using instrument::map::SampleRefEntry; using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs; using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault; using instrument::map::kPreviewVelocityDefault;
@@ -226,6 +228,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. // 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(); 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 // 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 // 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. // fixed stereo, so a mode change never renegotiates host I/O.
@@ -415,6 +422,9 @@ private:
// Both live_ and draining_ observe this same block — a block owned by a snapshot would // 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. // leave the drain's still-sounding voices deaf to the knob under them.
instrument::engine::LiveParams liveParams_; 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 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 // 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 // and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane
@@ -432,10 +442,10 @@ private:
// The host lane's state and its release protocol; automation_channel.h owns the mechanism and // 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. // this directory's CLAUDE.md the authority model it enforces.
AutomationChannel automation_; AutomationChannel automation_;
// Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams) // Serializes the writer sites of liveParams_ (reloadInstrument, publishLiveParams) and of
// only — separate from reloadMutex_ so a knob drag's publish never blocks behind a // liveContours_ (reloadInstrument, publishLiveContours) only — separate from reloadMutex_ so
// reload's WAV decode. The audio thread never takes this; process() only reads via // a drag's publish never blocks behind a reload's WAV decode. The audio thread never takes
// LiveParams::read's lock-free seqlock retry. // this; it only reads via the seqlock's lock-free retry.
std::mutex livePublishMutex_; std::mutex livePublishMutex_;
// The output rate the loaded capture's play params were resolved at (SampleData:: // 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 // outputRate — the host's), so a live republish resolves the stored wall-clock seconds to
+4 -3
View File
@@ -2,9 +2,9 @@
## Scope ## 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_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 `panel_state.h`), plus `draw_kit`, the shared LICE draw shell also consumed by the
VST3 editor (`shell/instrument/`). VST3 editor (`shell/instrument/`).
@@ -47,12 +47,13 @@ live in `shell/bank_ops`, a sibling directory, not here.
## Modules ## 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_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_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_render` — the WM_PAINT draw.
- `panel_input` — click/wheel/keyboard routing + the new-content auto-tag timer. - `panel_input` — click/wheel/keyboard routing + the new-content auto-tag timer.
- `panel_drag` — the hover + card-drag state machine + drop dispatch. - `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_thumbnails` — the PCM→envelope thumbnail cache + the bank-change fingerprint pass.
- `panel_audition` — the preview-playback engine. - `panel_audition` — the preview-playback engine.
- `panel_bank_ops` — the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. - `panel_bank_ops` — the menu/prompt UX skin over the promptless `shell/bank_ops` verbs.
+27 -47
View File
@@ -148,11 +148,13 @@ void applyDragCursor(CardGesture g) {
SetCursor(LoadCursor(nullptr, idc)); SetCursor(LoadCursor(nullptr, idc));
} }
} // namespace
// The out-of-client half of the same thin lookup: pure class -> pure cue -> stock cursor. The // 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 // 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 // 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 // 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) { void applyDropCue(DropCue cue) {
const char* idc = nullptr; const char* idc = nullptr;
switch (cue) { switch (cue) {
@@ -169,15 +171,7 @@ void applyDropCue(DropCue cue) {
SetCursor(LoadCursor(nullptr, idc)); SetCursor(LoadCursor(nullptr, idc));
} }
// One independent evaluation of the drag's target: the resolved class plus the live facts its // Shared with panel_drag_os.cpp (lawHandsOffAtCursor).
// 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
};
LiveDrop resolveLiveDrop(int x, int y) { LiveDrop resolveLiveDrop(int x, int y) {
RECT cr{}; RECT cr{};
GetClientRect(g_panel.hwnd, &cr); GetClientRect(g_panel.hwnd, &cr);
@@ -199,10 +193,11 @@ LiveDrop resolveLiveDrop(int x, int y) {
const DropProbe probe = probeDropTarget(sp.x, sp.y); const DropProbe probe = probeDropTarget(sp.x, sp.y);
ctx.surface = probe.surface; ctx.surface = probe.surface;
ctx.haveTrack = probe.track != nullptr; ctx.haveTrack = probe.track != nullptr;
// Two probes because they answer two independent questions: what REAPER named, and // Separate probes because they answer independent questions: what REAPER named, whether
// whether we are still inside REAPER at all. The law's hand-off gate reads both — see // we are still inside REAPER at all, and whether plugin code owns the window. The law's
// ui::DropContext::pointerOffHost. // hand-off gates pair them — see ui::DropContext::pointerOffHost.
ctx.pointerOffHost = !pointerOverHostWindow(sp.x, sp.y); ctx.pointerOffHost = !pointerOverHostWindow(sp.x, sp.y);
ctx.pointerOverPluginEditor = pointerOverPluginEditor(sp.x, sp.y);
out.track = probe.track; out.track = probe.track;
out.screenX = sp.x; out.screenX = sp.x;
} }
@@ -219,39 +214,7 @@ LiveDrop resolveLiveDrop(int x, int y) {
return out; return out;
} }
// The law's one irreversible transition: DoDragDrop takes mouse capture and runs its own modal namespace {
// 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));
}
// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, // 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 // 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 // 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). // the visual feedback then — a drop-target highlight, not a hover).
if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); 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
+18 -2
View File
@@ -536,11 +536,27 @@ void registerAccel();
void unregisterAccel(); void unregisterAccel();
// panel_drag.cpp — the card-drag/hover state machine (pure mirror: core/ui/card_drag). // panel_drag.cpp — the card-drag/hover state machine (pure mirror: core/ui/card_drag).
// Per-mouse-move work stays plain free-function calls. // Per-mouse-move work stays plain free-function calls. wParam carries WM_MOUSEMOVE's own
void onMouseMove(int x, int y); // button-state flags (MK_LBUTTON etc) so a move with the button already up can be told apart
// from GetAsyncKeyState's live-polled state.
void onMouseMove(int x, int y, WPARAM wParam);
void onLBtnUp(int x, int y); void onLBtnUp(int x, int y);
void handleRightClick(int x, int y); void handleRightClick(int x, int y);
void resetDragState(); void resetDragState();
void maybeShowTooltip(); void maybeShowTooltip();
// 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"). Shared with panel_drag_os.cpp.
struct LiveDrop {
DropClass cls = DropClass::None;
MediaTrack* track = nullptr;
int screenX = 0; // the evaluated point in screen coords; meaningful only outside the client
};
LiveDrop resolveLiveDrop(int x, int y);
void applyDropCue(DropCue cue);
// panel_drag_os.cpp — the OS hand-off / resume seam split out of panel_drag.cpp.
void handOffToOs();
} // namespace reasampler::panel } // namespace reasampler::panel
+1 -1
View File
@@ -152,7 +152,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
return 0; return 0;
} }
case WM_MOUSEMOVE: case WM_MOUSEMOVE:
onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), wParam);
return 0; return 0;
case WM_LBUTTONUP: case WM_LBUTTONUP:
onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
+31
View File
@@ -11,6 +11,7 @@
#include "../src/core/instrument/bake/bake_render.h" #include "../src/core/instrument/bake/bake_render.h"
#include "../src/core/instrument/engine/limiter.h" #include "../src/core/instrument/engine/limiter.h"
#include "../src/core/instrument/engine/live_contours.h"
#include "../src/core/instrument/engine/live_params.h" #include "../src/core/instrument/engine/live_params.h"
#include <cmath> #include <cmath>
@@ -338,6 +339,36 @@ int main() {
CHECK(sameSamples(audio, reference)); CHECK(sameSamples(audio, reference));
} }
// --- The bake detaches liveContours too, not just live -------------------------------
// A contour drag in flight publishes into liveContours (see reasampler_processor.h); a
// render that observed it would print whatever the user happens to be dragging instead
// of the dialed shape already sitting in play.ampSpline.contour.
{
instrument::engine::LiveContourBlock block;
SampleData s = makeSample(/*stereo=*/false, /*frames=*/1000);
s.play.playMode = PlayMode::Trigger;
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = instrument::engine::VelocityCurve::flat(); // dialed: unity
// Publish a very different contour — ramping down to silence — into the block.
s.liveContours = &block;
{
PlayParams dragged = s.play;
dragged.ampSpline.contour = instrument::engine::VelocityCurve::rampDown();
block.publish(instrument::engine::foldContours(dragged));
}
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);
CHECK(s.liveContours == &block); // the caller's own snapshot was not detached
SampleData detached = s;
detached.liveContours = nullptr;
const BakeAudio reference = renderBake(detached, plan, kUnity, kNoLimiter);
CHECK(sameSamples(audio, reference));
}
// --- Regression baseline: the neutral render is the source, sample for sample -------- // --- Regression baseline: the neutral render is the source, sample for sample --------
// A Trigger voice at its own root under Varispeed reads at ratio exactly 1 and hits no // A Trigger voice at its own root under Varispeed reads at ratio exactly 1 and hits no
// filter, so every printed frame equals its source frame PROVIDED the amp curve's gain at // filter, so every printed frame equals its source frame PROVIDED the amp curve's gain at
+152 -19
View File
@@ -3,8 +3,9 @@
// //
// Covers: // Covers:
// * The full class matrix: every ReaperSurface x {single, multi} x {track, no track} x // * The full class matrix: every ReaperSurface x {single, multi} x {track, no track} x
// {over a host window, off it}, inside and outside the client rect, plus the half-open edge // {over a host window, off it} x {plugin code under the pointer, not}, inside and outside the
// and a non-zero panel origin. // client rect, plus the half-open edge and a non-zero panel origin.
// * The OS drag loop's per-turn verdict (decideOsDragTurn), exhaustively.
// * Reversibility and speed-independence, stated as properties: a class transition sequence // * Reversibility and speed-independence, stated as properties: a class transition sequence
// resolves the same forwards and backwards, and one unresolvable evaluation cannot change // resolves the same forwards and backwards, and one unresolvable evaluation cannot change
// any later evaluation's outcome. // any later evaluation's outcome.
@@ -50,6 +51,16 @@ static DropContext offHost(ReaperSurface surface = ReaperSurface::Other, bool si
return c; return c;
} }
// The same drag with the shell's plugin-code proof set: the window under the pointer is a plugin's
// own editor. Defaults to where it matters — an FX surface REAPER attributed to a track.
static DropContext overPlugin(ReaperSurface surface = ReaperSurface::FxSurface, bool single = true,
bool haveTrack = true, bool offHostToo = false) {
DropContext c = ctx(surface, single, haveTrack);
c.pointerOverPluginEditor = true;
c.pointerOffHost = offHostToo;
return c;
}
// Every surface the law enumerates, so the matrix tests iterate rather than list. // Every surface the law enumerates, so the matrix tests iterate rather than list.
static const ReaperSurface kAllSurfaces[] = { static const ReaperSurface kAllSurfaces[] = {
ReaperSurface::TrackPanel, ReaperSurface::FxSurface, ReaperSurface::FxEmbed, ReaperSurface::TrackPanel, ReaperSurface::FxSurface, ReaperSurface::FxEmbed,
@@ -78,10 +89,11 @@ static void testInsideClientIsAlwaysInternal() {
CHECK(decideDropClass(200, 150, kPanel, ctx(s, single)) == DropClass::Internal); CHECK(decideDropClass(200, 150, kPanel, ctx(s, single)) == DropClass::Internal);
CHECK(decideDropClass(0, 0, kPanel, ctx(s, single)) == DropClass::Internal); CHECK(decideDropClass(0, 0, kPanel, ctx(s, single)) == DropClass::Internal);
CHECK(decideDropClass(399, 299, kPanel, ctx(s, single)) == DropClass::Internal); CHECK(decideDropClass(399, 299, kPanel, ctx(s, single)) == DropClass::Internal);
// Even a stale off-host proof cannot reinterpret a point inside our own client rect: // Even a stale proof of either kind cannot reinterpret a point inside our own client
// the inside-client answer is resolved ahead of the hand-off gate, so re-entering the // rect: the inside-client answer is resolved ahead of both hand-off gates, so
// panel always resumes the bank-to-bank gesture. // re-entering the panel always resumes the bank-to-bank gesture.
CHECK(decideDropClass(200, 150, kPanel, offHost(s, single)) == DropClass::Internal); CHECK(decideDropClass(200, 150, kPanel, offHost(s, single)) == DropClass::Internal);
CHECK(decideDropClass(200, 150, kPanel, overPlugin(s, single)) == DropClass::Internal);
} }
} }
} }
@@ -119,7 +131,8 @@ static void testSingleOverTrackPanelIsInstrumentDrop() {
DropClass::InstrumentDrop); DropClass::InstrumentDrop);
} }
// The FX chain / floating-FX windows keep their existing outcome. // The FX chain / floating-FX windows' own chrome — REAPER's preset bar, the chain list — keeps its
// existing outcome: no plugin-code proof, so the instrument drop.
static void testSingleOverFxSurfaceIsInstrumentDrop() { static void testSingleOverFxSurfaceIsInstrumentDrop() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) == CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
DropClass::InstrumentDrop); DropClass::InstrumentDrop);
@@ -198,7 +211,8 @@ static void testNullTrackWithSurfaceRefuses() {
// --- The hand-off gate --------------------------------------------------------- // --- The hand-off gate ---------------------------------------------------------
// THE REGRESSION FLOOR. No reading of REAPER's hit-test can reach the modal OLE loop while the // THE REGRESSION FLOOR. No reading of REAPER's hit-test can reach the modal OLE loop while the
// pointer is over a host window — asserted over EVERY surface the probe can report, both payload // pointer is over a host window and no plugin code owns it (ctx() sets neither proof) — asserted
// over EVERY surface the probe can report, both payload
// sizes and both track verdicts, not just the toolbar/transport cell that was reported. The // sizes and both track verdicts, not just the toolbar/transport cell that was reported. The
// defect was structural (an unnamed token read as "the user left REAPER"), so the guarantee has // defect was structural (an unnamed token read as "the user left REAPER"), so the guarantee has
// to be structural too. // to be structural too.
@@ -231,9 +245,10 @@ static void testOffHostHandsOffOnlyWhereReaperNamedNothing() {
} }
// The shipped exception the token condition exists for: REAPER runs a bridged plugin's UI in // The shipped exception the token condition exists for: REAPER runs a bridged plugin's UI in
// reaper_host*.exe, so the window-ownership test reports off-host over a floating bridged FX // reaper_host*.exe, so the process test reports off-host over a bridged FX window even though the
// editor even though the pointer never left REAPER. There the recognised token vetoes the // pointer never left REAPER. The recognised token vetoes the PROCESS route, so on its own that
// hand-off and the surface's own REAPER-internal outcome stands — identical to the native case. // proof changes nothing over a named surface — whether the bridged editor then hands off is the
// plugin-code route's call alone (testBridgedEditorHandsOffThroughThePluginRoute).
static void testOffHostOverANamedSurfaceKeepsTheReaperOutcome() { static void testOffHostOverANamedSurfaceKeepsTheReaperOutcome() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::FxSurface, true, true)) == CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::FxSurface, true, true)) ==
DropClass::InstrumentDrop); DropClass::InstrumentDrop);
@@ -264,13 +279,71 @@ static void testOffHostOverANamedSurfaceKeepsTheReaperOutcome() {
} }
} }
// --- The plugin-editor route ---------------------------------------------------
// THE FEATURE: over a plugin's own editor inside an FX window, the files go to the plugin through
// the OS — whatever the payload size and whether or not REAPER attributed a track, since the
// plugin, not a track, is the receiver.
static void testPluginEditorOverFxSurfaceHandsOff() {
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel,
overPlugin(ReaperSurface::FxSurface, single, haveTrack)) ==
DropClass::OsHandoff);
}
}
}
// The same point WITHOUT the proof is the FX window's chrome: the instrument drop for a single
// card, the multi refusal otherwise. The proof is the whole difference, in both directions.
static void testSameFxSurfaceWithoutTheProofKeepsItsOutcome() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin(ReaperSurface::FxSurface)) ==
DropClass::OsHandoff);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface, false)) ==
DropClass::Refuse);
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin(ReaperSurface::FxSurface, false)) ==
DropClass::OsHandoff);
}
// Plugin code under the pointer anywhere REAPER did not name an FX surface — the TCP, the MCP, the
// embed strip, the arrange, an extension window docked in REAPER (Other) — changes nothing: the
// token confines the route to FX windows. Asserted over every such cell, on-host and off.
static void testPluginProofOffAnFxSurfaceChangesNothing() {
for (ReaperSurface s : kAllSurfaces) {
if (s == ReaperSurface::FxSurface) continue;
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
for (bool off : {false, true}) {
const DropContext without =
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack);
CHECK(decideDropClass(kOutX, kOutY, kPanel,
overPlugin(s, single, haveTrack, off)) ==
decideDropClass(kOutX, kOutY, kPanel, without));
}
}
}
}
}
// A bridged editor: the process proof AND the plugin proof are both set, REAPER names the FX
// window. The token vetoed the process route; the plugin route still hands off.
static void testBridgedEditorHandsOffThroughThePluginRoute() {
for (bool haveTrack : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel,
overPlugin(ReaperSurface::FxSurface, true, haveTrack,
/*offHostToo=*/true)) == DropClass::OsHandoff);
}
}
// --- Not-a-drag ---------------------------------------------------------------- // --- Not-a-drag ----------------------------------------------------------------
// No armed samples, or not dragging -> None regardless of position or surface. // No armed samples, or not dragging -> None regardless of position or surface.
static void testNoDragOrNoSamplesIsNone() { static void testNoDragOrNoSamplesIsNone() {
for (ReaperSurface s : kAllSurfaces) { for (ReaperSurface s : kAllSurfaces) {
for (bool off : {false, true}) { for (int proof = 0; proof < 3; ++proof) { // none, off-host, plugin editor
DropContext c = off ? offHost(s) : ctx(s); DropContext c = proof == 1 ? offHost(s) : proof == 2 ? overPlugin(s) : ctx(s);
c.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/false}; c.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/false};
CHECK(decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::None); CHECK(decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::None);
CHECK(decideDropClass(200, 150, kPanel, c) == DropClass::None); CHECK(decideDropClass(200, 150, kPanel, c) == DropClass::None);
@@ -299,12 +372,22 @@ static void testClassTransitionsAreReversible() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert); CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal); CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
// The hand-off leg is irreversible only because the SHELL goes modal on it. The law itself // The hand-off legs stay stateless too: evaluating an off-host or plugin-editor context leaves
// stays stateless across it: evaluating an off-host context leaves the next in-REAPER // the next in-REAPER evaluation exactly where it was — which is what lets the OS loop withdraw
// evaluation exactly where it was. // on the law's own answer (decideOsDragTurn) rather than on anything remembered.
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost()) == DropClass::OsHandoff); CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost()) == DropClass::OsHandoff);
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert); CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal); CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
// Through a plugin's editor and back out over its own window's chrome: the instrument drop is
// still there after the pass, and so is everything after it.
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin()) == DropClass::OsHandoff);
CHECK(decideDropClass(kOutX, kOutY, kPanel, fx) == DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::TrackPanel)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin()) == DropClass::OsHandoff);
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
} }
// One unresolvable evaluation (a surface with no track, which refuses) followed by a resolvable // One unresolvable evaluation (a surface with no track, which refuses) followed by a resolvable
@@ -331,10 +414,11 @@ static void testDragSpeedCannotChangeTheOutcome() {
const DropContext destination = ctx(ReaperSurface::TrackPanel); const DropContext destination = ctx(ReaperSurface::TrackPanel);
const DropClass flick = decideDropClass(kOutX, kOutY, kPanel, destination); const DropClass flick = decideDropClass(kOutX, kOutY, kPanel, destination);
// The slow path crosses everything else first, off-host legs included. // The slow path crosses everything else first, off-host and plugin-editor legs included.
for (ReaperSurface s : kAllSurfaces) { for (ReaperSurface s : kAllSurfaces) {
(void)decideDropClass(kOutX - 10, kOutY, kPanel, ctx(s)); (void)decideDropClass(kOutX - 10, kOutY, kPanel, ctx(s));
(void)decideDropClass(kOutX - 10, kOutY, kPanel, offHost(s)); (void)decideDropClass(kOutX - 10, kOutY, kPanel, offHost(s));
(void)decideDropClass(kOutX - 10, kOutY, kPanel, overPlugin(s));
(void)decideDropClass(200, 150, kPanel, ctx(s)); // and back through the client (void)decideDropClass(200, 150, kPanel, ctx(s)); // and back through the client
} }
CHECK(decideDropClass(kOutX, kOutY, kPanel, destination) == flick); CHECK(decideDropClass(kOutX, kOutY, kPanel, destination) == flick);
@@ -350,9 +434,11 @@ static void testNoLiveOutsideCombinationResolvesToNone() {
for (bool single : {true, false}) { for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) { for (bool haveTrack : {true, false}) {
for (bool off : {false, true}) { for (bool off : {false, true}) {
const DropClass c = decideDropClass( for (bool plugin : {false, true}) {
kOutX, kOutY, kPanel, DropContext c0 =
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack)); off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack);
c0.pointerOverPluginEditor = plugin;
const DropClass c = decideDropClass(kOutX, kOutY, kPanel, c0);
CHECK(c != DropClass::None); CHECK(c != DropClass::None);
CHECK(c != DropClass::Internal); CHECK(c != DropClass::Internal);
} }
@@ -360,6 +446,7 @@ static void testNoLiveOutsideCombinationResolvesToNone() {
} }
} }
} }
}
// Every class carries a cue, and only the two the shell deliberately does not draw map to a // Every class carries a cue, and only the two the shell deliberately does not draw map to a
// no-cursor cue — so "will this work" is answerable before release for every resolvable class. // no-cursor cue — so "will this work" is answerable before release for every resolvable class.
@@ -490,6 +577,44 @@ static void testAllSkippedSelectionKeepsInternalDrag() {
CHECK(!h.handOffToOs); CHECK(!h.handOffToOs);
} }
// --- The OS drag loop, once handed off ---------------------------------------------
// The whole truth table, one cell per line so a regression names its cell.
static void testOsDragTurnTruthTable() {
// Button held: stay with the OS while the law still hands off, withdraw the moment it doesn't.
CHECK(decideOsDragTurn(false, true, true) == OsDragTurn::Continue);
CHECK(decideOsDragTurn(false, true, false) == OsDragTurn::Withdraw);
// Released: the OS drops only where the law hands off; over a REAPER surface the release is
// given back to the panel, so REAPER's own outcome runs instead of a CF_HDROP import.
CHECK(decideOsDragTurn(false, false, true) == OsDragTurn::Drop);
CHECK(decideOsDragTurn(false, false, false) == OsDragTurn::WithdrawAtRelease);
// Escape ends the drag outright in every cell — never read as a release, never a withdrawal
// that would resume a drag the user just cancelled.
CHECK(decideOsDragTurn(true, true, true) == OsDragTurn::Cancel);
CHECK(decideOsDragTurn(true, true, false) == OsDragTurn::Cancel);
CHECK(decideOsDragTurn(true, false, true) == OsDragTurn::Cancel);
CHECK(decideOsDragTurn(true, false, false) == OsDragTurn::Cancel);
}
// End to end with the law: a hand-off started over a plugin's editor, then the pointer moves onto
// that FX window's chrome with the button held — withdrawn, and the resumed drag's release there is
// the instrument drop, not an OS drop. Moving back onto the editor keeps the OS drag.
static void testPassOverAPluginEditorIsWithdrawnOnReaperSurfaces() {
auto handsOff = [](const DropContext& c) {
return decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::OsHandoff;
};
CHECK(decideOsDragTurn(false, true, handsOff(overPlugin())) == OsDragTurn::Continue);
CHECK(decideOsDragTurn(false, true, handsOff(ctx(ReaperSurface::FxSurface))) ==
OsDragTurn::Withdraw);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
DropClass::InstrumentDrop);
CHECK(decideOsDragTurn(false, false, handsOff(ctx(ReaperSurface::Arrange))) ==
OsDragTurn::WithdrawAtRelease);
CHECK(decideOsDragTurn(false, false, handsOff(overPlugin())) == OsDragTurn::Drop);
// Leaving REAPER entirely is handed off by the other route and held the same way.
CHECK(decideOsDragTurn(false, true, handsOff(offHost())) == OsDragTurn::Continue);
}
int main() { int main() {
testInsideClientIsAlwaysInternal(); testInsideClientIsAlwaysInternal();
testBoundaryHalfOpen(); testBoundaryHalfOpen();
@@ -513,6 +638,11 @@ int main() {
testOffHostOverANamedSurfaceKeepsTheReaperOutcome(); testOffHostOverANamedSurfaceKeepsTheReaperOutcome();
testNoDragOrNoSamplesIsNone(); testNoDragOrNoSamplesIsNone();
testPluginEditorOverFxSurfaceHandsOff();
testSameFxSurfaceWithoutTheProofKeepsItsOutcome();
testPluginProofOffAnFxSurfaceChangesNothing();
testBridgedEditorHandsOffThroughThePluginRoute();
testClassTransitionsAreReversible(); testClassTransitionsAreReversible();
testUnresolvableEvaluationDoesNotAffectTheNext(); testUnresolvableEvaluationDoesNotAffectTheNext();
testDragSpeedCannotChangeTheOutcome(); testDragSpeedCannotChangeTheOutcome();
@@ -532,6 +662,9 @@ int main() {
testResolvablePayloadHandsOff(); testResolvablePayloadHandsOff();
testAllSkippedSelectionKeepsInternalDrag(); testAllSkippedSelectionKeepsInternalDrag();
testOsDragTurnTruthTable();
testPassOverAPluginEditorIsWithdrawnOnReaperSurfaces();
if (g_fail == 0) std::printf("All tests passed.\n"); if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
} }
+438
View File
@@ -0,0 +1,438 @@
// Standalone tests for LIVE CONTOUR DELIVERY — a drawn EG edited while notes sound — no VST3,
// no REAPER, no framework. Asserts that each of the three contours (amp, pitch, filter) moves
// the note already sounding and the next one struck, that the swap is absorbed rather than
// stepped, that what start() latched (a Staged EG) stays latched, that an unchanged publish is
// byte-identical, that two engines sharing one block both follow (the drain slot), and that the
// whole audio-side path allocates nothing. This executable replaces the global allocator to
// prove the last one, which is why these tests are not in live_delivery_tests.
#include "../src/core/instrument/engine/live_contours.h"
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <new>
#include <vector>
using namespace reasampler;
using instrument::engine::ContourPoints;
using instrument::engine::CurveDomain;
using instrument::engine::LiveContourBlock;
using instrument::engine::contourPoints;
using instrument::engine::foldContours;
// --- Allocation counter -----------------------------------------------------------------
// Counts every global allocation while armed. Replacing the global operator new is the only way
// to observe allocations made inside sampler_core without instrumenting it.
static std::atomic<long> g_allocations{0};
static std::atomic<bool> g_countAllocations{false};
static void* countedAlloc(std::size_t n) {
if (g_countAllocations.load(std::memory_order_relaxed)) {
g_allocations.fetch_add(1, std::memory_order_relaxed);
}
if (void* p = std::malloc(n ? n : 1)) return p;
throw std::bad_alloc();
}
void* operator new(std::size_t n) { return countedAlloc(n); }
void* operator new[](std::size_t n) { return countedAlloc(n); }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete[](void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
void operator delete[](void* p, std::size_t) noexcept { std::free(p); }
// ------------------------------------------------------------------------------------------
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
constexpr double kPi = 3.14159265358979323846;
constexpr int kRate = 48000;
constexpr std::size_t kBlock = 256;
constexpr int kRoot = 60;
// Every frame 1.0, Trigger (a drawn EG is a Trigger fact — resolvePlay's job on the real path),
// played at the root with a flat velocity curve: an output frame IS the amplitude.
static SampleData dcSample(std::size_t frames) {
SampleData s;
s.frames.assign(frames, 1.0f);
s.sampleRate = kRate;
s.rootNote = kRoot;
s.play.playMode = PlayMode::Trigger;
return s;
}
static VelocityCurve flatAt(double y, CurveDomain domain) {
return VelocityCurve::fromPoints({{0.0, y}, {127.0, y}}, domain);
}
// Three points rather than two, so a publish that changes the point COUNT is what lands.
static VelocityCurve flatAt3(double y, CurveDomain domain) {
return VelocityCurve::fromPoints({{0.0, y}, {63.0, y}, {127.0, y}}, domain);
}
static double maxAbsDelta(const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
double worst = 0.0;
for (std::size_t i = from + 1; i < to && i < v.size(); ++i) {
worst = (std::max)(worst, std::fabs(static_cast<double>(v[i]) - v[i - 1]));
}
return worst;
}
static double peakOf(const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
double peak = 0.0;
for (std::size_t i = from; i < to && i < v.size(); ++i) {
peak = (std::max)(peak, std::fabs(static_cast<double>(v[i])));
}
return peak;
}
// One held note through a one-voice engine observing `block`, with `edited`'s contours
// published at the top of block `swapBlock` (-1: never). The initial publish is `sample.play`'s
// own contours, the state a reload leaves.
static std::vector<AudioSample> renderHeld(SampleData& sample, LiveContourBlock& block,
const PlayParams* edited, int swapBlock, int blocks) {
sample.liveContours = &block;
block.publish(foldContours(sample.play));
VoiceEngine engine(1, sample);
engine.noteOn(kRoot, 127);
std::vector<AudioSample> out;
for (int b = 0; b < blocks; ++b) {
if (edited && b == swapBlock) block.publish(foldContours(*edited));
engine.render(out, kBlock);
}
return out;
}
// --- The fold ------------------------------------------------------------------------------
static void testTheFoldCarriesEveryPointAndItsHardFlag() {
std::vector<VelocityPoint> pts = {{0.0, 0.1}, {40.0, 0.9}, {90.0, 0.2}, {127.0, 0.7}};
VelocityCurve c = VelocityCurve::fromPoints(pts, CurveDomain::Unipolar);
c.setHard(2, true);
const ContourPoints folded = contourPoints(c);
CHECK(folded.count == 4);
for (std::size_t i = 0; i < 4; ++i) {
CHECK(folded.points[i].velocity == c.points()[i].velocity);
CHECK(folded.points[i].value == c.points()[i].value);
CHECK(folded.points[i].hard == c.points()[i].hard);
}
CHECK(folded.points[2].hard);
PlayParams p;
p.ampSpline.contour = flatAt(0.3, CurveDomain::Unipolar);
p.pitchSpline.contour = c;
p.filterSpline.contour = flatAt3(0.6, CurveDomain::Unipolar);
const auto all = foldContours(p);
CHECK(all.amp.count == 2 && all.amp.points[1].value == 0.3);
CHECK(all.pitch.count == 4 && all.pitch.points[1].value == c.points()[1].value);
CHECK(all.filter.count == 3 && all.filter.points[2].value == 0.6);
}
// --- Amp -----------------------------------------------------------------------------------
static void testAHeldNoteFollowsAnAmpContourEditWithoutAStep() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 12);
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 12);
const std::size_t swapAt = 4 * kBlock;
CHECK(moved[swapAt - 1] == 1.0f);
// Absorbed: the first frame read through the new contour reproduces the old level exactly...
CHECK(moved[swapAt] == 1.0f);
// ...and glides from there. An unabsorbed swap would step by 0.75 in one frame.
CHECK(maxAbsDelta(moved, swapAt - 1, swapAt + 64) < 0.05);
CHECK(moved[swapAt + 32] < 0.9f);
// The held note lands on the edited contour, where the unedited run stays on the old one.
CHECK(std::fabs(moved.back() - 0.25) < 1e-6);
CHECK(still.back() == 1.0f);
}
// The processor marshals note-ons BEFORE the block renders, so a publish and a note-on landing
// in one block reach the engine through startVoice first. The voice already sounding must be
// rebound there too: left on the buffer the read just displaced, it would never see this edit.
static void testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
LiveContourBlock block;
s.liveContours = &block;
block.publish(foldContours(s.play));
VoiceEngine engine(2, s);
engine.noteOn(kRoot, 127);
std::vector<AudioSample> out;
for (int b = 0; b < 4; ++b) engine.render(out, kBlock);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
block.publish(foldContours(edited));
engine.noteOn(kRoot, 127); // the second voice, struck in the swap block
for (int b = 0; b < 8; ++b) engine.render(out, kBlock);
const std::size_t swapAt = 4 * kBlock;
// The sounding voice's absorbed 1.0 plus the fresh voice's snapped 0.25 — the fresh one
// takes the edit outright even though the snapshot still carries the old contour.
CHECK(std::fabs(out[swapAt] - 1.25) < 1e-6);
// Both land on the edit: 0.25 + 0.25. A sounding voice that missed it would leave 1.25.
CHECK(std::fabs(out.back() - 0.5) < 1e-6);
}
static void testANoteStruckAfterAnEditPlaysItFromItsFirstFrame() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
LiveContourBlock block;
s.liveContours = &block;
block.publish(foldContours(s.play));
VoiceEngine engine(1, s);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
block.publish(foldContours(edited));
engine.noteOn(kRoot, 127);
std::vector<AudioSample> out;
engine.render(out, kBlock);
CHECK(out[0] == 0.25f);
// The snapshot really does still say 1.0: with no block the same engine plays it.
SampleData bare = s;
bare.liveContours = nullptr;
VoiceEngine bareEngine(1, bare);
bareEngine.noteOn(kRoot, 127);
std::vector<AudioSample> bareOut;
bareEngine.render(bareOut, kBlock);
CHECK(bareOut[0] == 1.0f);
}
// --- Pitch ---------------------------------------------------------------------------------
// A ramp source under Varispeed: an output frame is the read head, so the frame-to-frame step
// IS the pitch ratio.
static void testAHeldNoteFollowsAPitchContourEdit() {
SampleData s;
s.frames.resize(1u << 15);
for (std::size_t i = 0; i < s.frames.size(); ++i) s.frames[i] = static_cast<float>(i);
s.sampleRate = kRate;
s.rootNote = kRoot;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 12.0;
s.play.pitchSpline.mode = EnvMode::Spline;
s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar);
PlayParams edited = s.play;
edited.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar); // +1 x 12 st = an octave
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 12);
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 12);
const std::size_t swapAt = 4 * kBlock;
const std::size_t n = moved.size();
CHECK(std::fabs((moved[swapAt - 1] - moved[swapAt - 2]) - 1.0) < 1e-3);
CHECK(std::fabs((moved[n - 1] - moved[n - 2]) - 2.0) < 1e-3);
CHECK(std::fabs((still[n - 1] - still[n - 2]) - 1.0) < 1e-3);
// Absorbed here too: the first frame read through the new contour still advances at the old
// ratio, where an unabsorbed swap would already step by 2.
CHECK(std::fabs((moved[swapAt + 1] - moved[swapAt]) - 1.0) < 1e-3);
}
// --- Filter --------------------------------------------------------------------------------
static void testAHeldNoteFollowsAFilterContourEdit() {
SampleData s;
s.frames.resize(100000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / 8.0));
}
s.sampleRate = kRate;
s.rootNote = kRoot;
s.play.playMode = PlayMode::Trigger;
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 0.1f;
s.play.filter.settings.morphNorm = 1.0f; // low-pass
s.play.filter.modAmount = 0.8;
s.play.filterSpline.mode = EnvMode::Spline;
s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar);
PlayParams edited = s.play;
edited.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar); // corner 0.1 -> 0.9
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 16);
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 16);
const std::size_t tail = moved.size() - 1024;
const double openedPeak = peakOf(moved, tail, moved.size());
const double closedPeak = peakOf(still, tail, still.size());
// A 6 kHz tone through the corner the edit opened, against the unedited note's, which stays
// filtered away (measured ~0.18 vs ~0.0002 — the margins below are loose on purpose).
CHECK(openedPeak > 0.1);
CHECK(openedPeak > 10.0 * closedPeak);
}
// --- What stays latched, and what must not move --------------------------------------------
// Staged|Spline is a note-on fact: an EG that start() left Staged ignores any contour publish.
static void testAStagedEnvelopeIgnoresContourPublishes() {
SampleData s = dcSample(200000);
// A staged shape that is not flat from frame 0, so a leak would show.
s.play.trigAhd.decayFrames = 20000;
s.play.trigAhd.holdFraction = 0.0;
PlayParams hostile = s.play;
hostile.ampSpline.contour = flatAt3(0.0, CurveDomain::Unipolar);
hostile.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar);
hostile.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar);
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> published = renderHeld(s, blockA, &hostile, 3, 10);
const std::vector<AudioSample> none = renderHeld(base, blockB, nullptr, -1, 10);
CHECK(published == none);
CHECK(none[3 * kBlock + 100] < 1.0f); // the staged decay is really running
}
// Republishing the contour a voice is already reading rebinds it mid-read; the read must not
// move by a bit. A curved, multi-segment contour with a hard point so the re-located segment
// and its tangents are genuinely exercised.
static void testRepublishingAnUnchangedContourIsByteIdentical() {
SampleData s = dcSample(20000);
s.play.ampSpline.mode = EnvMode::Spline;
VelocityCurve c = VelocityCurve::fromPoints(
{{0.0, 0.0}, {20.0, 1.0}, {60.0, 0.3}, {100.0, 0.9}, {127.0, 0.0}},
CurveDomain::Unipolar);
c.setHard(2, true);
s.play.ampSpline.contour = c;
const PlayParams same = s.play;
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> republished = renderHeld(s, blockA, &same, 20, 60);
const std::vector<AudioSample> none = renderHeld(base, blockB, nullptr, -1, 60);
CHECK(republished == none);
CHECK(peakOf(none, 0, none.size()) > 0.9); // the contour really is being read
}
// The drain slot: a displaced snapshot and the live one both observe the instance's ONE block,
// so a note ringing out of either follows the edit.
static void testTwoEnginesSharingOneBlockBothFollow() {
SampleData a = dcSample(200000);
a.play.ampSpline.mode = EnvMode::Spline;
a.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
SampleData b = a;
LiveContourBlock block;
a.liveContours = &block;
b.liveContours = &block;
block.publish(foldContours(a.play));
VoiceEngine live(1, a), drain(1, b);
live.noteOn(kRoot, 127);
drain.noteOn(kRoot, 127);
std::vector<AudioSample> outLive, outDrain;
for (int i = 0; i < 2; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); }
PlayParams edited = a.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
block.publish(foldContours(edited));
for (int i = 0; i < 8; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); }
CHECK(std::fabs(outLive.back() - 0.25) < 1e-6);
CHECK(std::fabs(outDrain.back() - 0.25) < 1e-6);
}
// --- Allocation ----------------------------------------------------------------------------
// The counter itself must be live, or the zero below would prove nothing.
static void testTheAllocationCounterSeesAnAllocation() {
g_allocations.store(0);
g_countAllocations.store(true);
std::vector<float>* v = new std::vector<float>(16);
g_countAllocations.store(false);
delete v;
CHECK(g_allocations.load() >= 2); // the vector object and its buffer
}
// Every audio-side step of a contour swap — the block-boundary read, the rebind of every
// sounding voice on all three EGs, a note struck in the swap block, and the renders around them
// — runs with the counter armed. Only the UI thread's publish is outside it (it allocates
// nothing either, but it is not the audio side).
static void testTheAudioSideOfAContourSwapAllocatesNothing() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 0.0; // bound, so rebound, but transposing nothing
s.play.pitchSpline.mode = EnvMode::Spline;
s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar);
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 1.0f;
s.play.filter.modAmount = 0.0;
s.play.filterSpline.mode = EnvMode::Spline;
s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
edited.pitchSpline.contour = flatAt3(0.0, CurveDomain::Bipolar);
edited.filterSpline.contour = flatAt3(0.5, CurveDomain::Unipolar);
LiveContourBlock block;
s.liveContours = &block;
block.publish(foldContours(s.play));
VoiceEngine engine(4, s);
std::vector<AudioSample> buf(kBlock, 0.0f);
const auto renderBlock = [&] {
std::fill(buf.begin(), buf.end(), 0.0f);
engine.render(buf.data(), kBlock);
};
g_allocations.store(0);
g_countAllocations.store(true);
engine.noteOn(kRoot, 127);
for (int b = 0; b < 4; ++b) renderBlock();
g_countAllocations.store(false);
block.publish(foldContours(edited)); // UI thread
g_countAllocations.store(true);
engine.noteOn(kRoot, 127); // startVoice's rebind + snap
renderBlock(); // the block boundary's read finds nothing newer: already taken
g_countAllocations.store(false);
const double afterEdit = buf[kBlock - 1];
block.publish(foldContours(s.play)); // and back, so the render path does the read itself
g_countAllocations.store(true);
for (int b = 0; b < 8; ++b) renderBlock();
g_countAllocations.store(false);
CHECK(g_allocations.load() == 0);
// Not vacuous: both swaps really landed inside the counted window, through a wide-open
// low-pass whose DC gain is unity. Two voices on the edited 0.25 contour by the end of the
// swap block (2.0 had it not landed), then both back on 1.0 through the render path's read.
CHECK(std::fabs(afterEdit - 0.5) < 1e-3);
CHECK(std::fabs(buf[kBlock - 1] - 2.0) < 1e-3);
}
int main() {
testTheFoldCarriesEveryPointAndItsHardFlag();
testAHeldNoteFollowsAnAmpContourEditWithoutAStep();
testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne();
testANoteStruckAfterAnEditPlaysItFromItsFirstFrame();
testAHeldNoteFollowsAPitchContourEdit();
testAHeldNoteFollowsAFilterContourEdit();
testAStagedEnvelopeIgnoresContourPublishes();
testRepublishingAnUnchangedContourIsByteIdentical();
testTwoEnginesSharingOneBlockBothFollow();
testTheAllocationCounterSeesAnAllocation();
testTheAudioSideOfAContourSwapAllocatesNothing();
if (g_fail == 0) std::printf("live_contours tests passed\n");
return g_fail == 0 ? 0 : 1;
}
+33
View File
@@ -1167,6 +1167,38 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
CHECK(proportional); CHECK(proportional);
} }
// A note-on that lands in the same block boundary as a publish must not let the already-
// sounding voice miss it: startVoice used to call refreshLive() bare, which consumes the
// generation stamp without applying it to the voices already active — a held note that shared
// a block with a note-on then never saw the publish at all (not merely delayed a block).
static void testHeldVoiceReceivesALivePublishThatCoincidesWithANoteOn() {
SampleData s;
s.frames.assign(200000, 1.0f); // DC: the envelope alone shapes the output
s.sampleRate = kRate;
s.rootNote = 60;
s.play.adsr.attackFrames = 0;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 480000; // long enough that release never intrudes
LiveParams block;
s.live = &block;
VoiceEngine engine(2, s);
engine.noteOn(60, 100); // voice A, sustaining at the dialed 1.0
LiveValues quieter = foldLive(s.play, s.keyTrack);
quieter.adsr.sustainLevel = 0.2;
block.publish(quieter);
engine.noteOn(61, 100); // voice B starts in the SAME generation as the publish
std::vector<AudioSample> out;
for (int i = 0; i < 20; ++i) engine.render(out, 512); // let voice A's glide settle
// Both voices settle at 0.2 — a held voice that missed the publish would still sit at 1.0,
// for a combined 1.2 rather than 0.4.
CHECK(std::fabs(static_cast<double>(out.back()) - 0.4) < 0.01);
}
int main() { int main() {
testStageDurationChangeHoldsPhase(); testStageDurationChangeHoldsPhase();
testShortenedStageStillLandsContinuously(); testShortenedStageStillLandsContinuously();
@@ -1189,6 +1221,7 @@ int main() {
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan(); testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
testPitchRatioAndVelocityGainStayLatched(); testPitchRatioAndVelocityGainStayLatched();
testVelocityGainSurvivesAHostilePublishThatReallyLands(); testVelocityGainSurvivesAHostilePublishThatReallyLands();
testHeldVoiceReceivesALivePublishThatCoincidesWithANoteOn();
if (g_fail == 0) std::printf("live_delivery tests passed\n"); if (g_fail == 0) std::printf("live_delivery tests passed\n");
return g_fail == 0 ? 0 : 1; return g_fail == 0 ? 0 : 1;
} }
+237
View File
@@ -0,0 +1,237 @@
// Standalone tests for reasampler::ui::window_origin — no REAPER, no Win32, no test framework.
// Asserts the per-window code fold and the chain rule the plugin-editor hand-off proof rests on.
#include "../src/core/ui/window_origin.h"
#include <cstdio>
#include <string>
#include <vector>
using namespace reasampler::ui;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static const std::string kWinDir = "C:\\Windows";
static WindowFacts inModule(const std::string& path, bool host = false) {
WindowFacts f;
f.procResolved = true;
f.hostModule = host;
f.modulePath = path;
return f;
}
static WindowFacts ownModule(const std::string& path) {
WindowFacts f;
f.procResolved = true;
f.modulePath = path;
f.ownModule = true;
return f;
}
// --- classifyWindowCode --------------------------------------------------------
// REAPER's own chrome and containers: the class procedure lives in the executable.
static void testHostExecutableIsHost() {
CHECK(classifyWindowCode(inModule("C:\\Program Files\\REAPER (x64)\\reaper.exe", true),
kWinDir) == WindowCode::Host);
}
// Standard controls and the dialog class are implemented by OS modules, wherever the OS keeps
// them — System32, SysWOW64 for a 32-bit host, and the side-by-side store comctl32 v6 loads from.
static void testOsModulesAreSystem() {
CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), kWinDir) ==
WindowCode::System);
CHECK(classifyWindowCode(inModule("C:\\Windows\\SysWOW64\\user32.dll"), kWinDir) ==
WindowCode::System);
CHECK(classifyWindowCode(
inModule("C:\\Windows\\WinSxS\\amd64_microsoft.windows.common-controls_6595b64144ccf1df"
"_6.0.26100.1_none_x\\comctl32.dll"),
kWinDir) == WindowCode::System);
}
// The same directory spelled differently by two APIs still compares equal: case, separator
// direction, a trailing separator on the root, and the long-path prefix.
static void testSystemMatchIgnoresSpelling() {
CHECK(classifyWindowCode(inModule("c:/WINDOWS/system32/USER32.DLL"), kWinDir) ==
WindowCode::System);
CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), "C:\\WINDOWS\\") ==
WindowCode::System);
CHECK(classifyWindowCode(inModule("\\\\?\\C:\\Windows\\System32\\user32.dll"), kWinDir) ==
WindowCode::System);
}
// Containment, not string prefix: a sibling directory whose name merely starts with the OS
// directory's is plugin territory, and so is the OS directory itself named as a file path.
static void testSiblingDirectoryIsNotSystem() {
CHECK(classifyWindowCode(inModule("C:\\WindowsApps\\thing.dll"), kWinDir) ==
WindowCode::Plugin);
CHECK(classifyWindowCode(inModule("C:\\Windows2\\thing.dll"), kWinDir) == WindowCode::Plugin);
CHECK(classifyWindowCode(inModule("C:\\Windows"), kWinDir) == WindowCode::Plugin);
}
// A plugin binary anywhere outside the OS directory — the VST3 bundle, a VST2 folder, a helper
// DLL a plugin loads beside itself — is Plugin.
static void testPluginBinariesArePlugin() {
CHECK(classifyWindowCode(
inModule("C:\\Program Files\\Common Files\\VST3\\Serum2.vst3\\Contents\\x86_64-win\\"
"Serum2.vst3"),
kWinDir) == WindowCode::Plugin);
CHECK(classifyWindowCode(inModule("D:\\VstPlugins\\Qt5Widgets.dll"), kWinDir) ==
WindowCode::Plugin);
}
// Another process's window is plugin code by the process boundary alone, whatever its other facts
// claim: a procedure address from another process resolved in ours is meaningless.
static void testOtherProcessIsPluginRegardlessOfModule() {
WindowFacts f = inModule("C:\\Program Files\\REAPER (x64)\\reaper.exe", true);
f.otherProcess = true;
CHECK(classifyWindowCode(f, kWinDir) == WindowCode::Plugin);
WindowFacts g;
g.otherProcess = true; // nothing resolved at all
CHECK(classifyWindowCode(g, kWinDir) == WindowCode::Plugin);
}
// No module (a charset-mismatch handle, generated code) is no evidence — never Plugin.
static void testUnresolvedProcIsUnknown() {
WindowFacts f;
f.modulePath = "C:\\VST\\x.dll"; // ignored without a resolved procedure
CHECK(classifyWindowCode(f, kWinDir) == WindowCode::Unknown);
}
// Without the OS directory a standard control cannot be told from plugin code, so every non-host
// module degrades to Unknown — the failure must not read REAPER's buttons as a plugin.
static void testMissingSystemRootNeverYieldsPlugin() {
CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), "") ==
WindowCode::Unknown);
CHECK(classifyWindowCode(inModule("C:\\VST\\x.dll"), "") == WindowCode::Unknown);
CHECK(classifyWindowCode(inModule("C:\\VST\\x.dll"), "\\") == WindowCode::Unknown);
CHECK(classifyWindowCode(inModule("C:\\REAPER\\reaper.exe", true), "") == WindowCode::Host);
}
// Our own ReaSampler 9000 module — stable or beta, whichever the shell set ownModule for — is
// Instrument, not Plugin: FX chrome we own, so a drag over it must not hand off to the OS.
static void testOwnModuleIsInstrument() {
CHECK(classifyWindowCode(
ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000.vst3"), kWinDir) ==
WindowCode::Instrument);
CHECK(classifyWindowCode(
ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000_beta.vst3"),
kWinDir) == WindowCode::Instrument);
}
// --- isPluginEditorChain -------------------------------------------------------
// Pointer on REAPER's FX-window chrome: a standard control or a REAPER container, under the
// dialog-class root. No plugin code anywhere in the chain.
static void testChromeChainIsNotPlugin() {
CHECK(!isPluginEditorChain({WindowCode::System, WindowCode::Host, WindowCode::System}));
CHECK(!isPluginEditorChain({WindowCode::Host, WindowCode::System}));
CHECK(!isPluginEditorChain({WindowCode::Host}));
}
// Pointer on the plugin's editor window itself.
static void testPluginWindowChainIsPlugin() {
CHECK(isPluginEditorChain({WindowCode::Plugin, WindowCode::Host, WindowCode::System}));
}
// A standard control INSIDE the plugin's editor (a native text box) is System on its own; the
// plugin's window above it is what makes the point the plugin's.
static void testStandardControlInsidePluginIsPlugin() {
CHECK(isPluginEditorChain(
{WindowCode::System, WindowCode::Plugin, WindowCode::Host, WindowCode::System}));
}
// A bridged plugin's editor embedded across the process boundary: the hit window is Plugin by
// process, REAPER's container and root above it are in-process.
static void testBridgedEmbeddedEditorIsPlugin() {
WindowFacts bridged;
bridged.otherProcess = true;
CHECK(isPluginEditorChain({classifyWindowCode(bridged, kWinDir), WindowCode::Host,
WindowCode::System}));
}
// A pointer over our own instrument's editor — Instrument code, not Plugin — is not hand-off
// proof: it stays REAPER's FX chrome, unlike a genuine plugin editor (testPluginWindowChainIsPlugin).
static void testOwnInstrumentChainIsNotPlugin() {
CHECK(!isPluginEditorChain({WindowCode::Instrument, WindowCode::Host, WindowCode::System}));
}
// Another plugin's editor sitting alongside our own instrument in the same chain is still proof —
// only OUR module is exempted, nobody else's.
static void testOtherPluginBesideOwnInstrumentIsStillPlugin() {
CHECK(isPluginEditorChain(
{WindowCode::Plugin, WindowCode::Instrument, WindowCode::Host, WindowCode::System}));
}
// Unknown anywhere, and nothing else, is not proof; an empty chain (no window at all) is not.
static void testUnknownAndEmptyChainsAreNotPlugin() {
CHECK(!isPluginEditorChain({WindowCode::Unknown, WindowCode::Host, WindowCode::System}));
CHECK(!isPluginEditorChain({WindowCode::Unknown}));
CHECK(!isPluginEditorChain({}));
}
// --- isOwnModulePath -----------------------------------------------------------
static void testOwnModulePathCaseFolds() {
CHECK(isOwnModulePath("C:\\VST3\\reasampler_9000.vst3", "reasampler_9000.vst3"));
CHECK(isOwnModulePath("C:\\VST3\\ReaSampler_9000.VST3", "reasampler_9000.vst3"));
}
static void testOwnModulePathBundlePathForwardAndBackSlash() {
CHECK(isOwnModulePath(
"C:\\VST3\\reasampler_9000.vst3\\Contents\\x86_64-win\\reasampler_9000.vst3",
"reasampler_9000.vst3"));
CHECK(isOwnModulePath(
"C:/VST3/reasampler_9000.vst3/Contents/x86_64-win/reasampler_9000.vst3",
"reasampler_9000.vst3"));
}
static void testOwnModulePathStableAndBetaDoNotMatchEachOther() {
CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000_beta.vst3", "reasampler_9000.vst3"));
CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000.vst3", "reasampler_9000_beta.vst3"));
}
static void testOwnModulePathBareFilenameWithNoSeparatorMatches() {
CHECK(isOwnModulePath("reasampler_9000.vst3", "reasampler_9000.vst3"));
}
static void testOwnModulePathEmptyPathDoesNotMatch() {
CHECK(!isOwnModulePath("", "reasampler_9000.vst3"));
}
static void testOwnModulePathTrailingSeparatorDoesNotMatch() {
CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000.vst3\\", "reasampler_9000.vst3"));
}
int main() {
testHostExecutableIsHost();
testOsModulesAreSystem();
testSystemMatchIgnoresSpelling();
testSiblingDirectoryIsNotSystem();
testPluginBinariesArePlugin();
testOtherProcessIsPluginRegardlessOfModule();
testUnresolvedProcIsUnknown();
testMissingSystemRootNeverYieldsPlugin();
testOwnModuleIsInstrument();
testChromeChainIsNotPlugin();
testPluginWindowChainIsPlugin();
testStandardControlInsidePluginIsPlugin();
testBridgedEmbeddedEditorIsPlugin();
testOwnInstrumentChainIsNotPlugin();
testOtherPluginBesideOwnInstrumentIsStillPlugin();
testUnknownAndEmptyChainsAreNotPlugin();
testOwnModulePathCaseFolds();
testOwnModulePathBundlePathForwardAndBackSlash();
testOwnModulePathStableAndBetaDoNotMatchEachOther();
testOwnModulePathBareFilenameWithNoSeparatorMatches();
testOwnModulePathEmptyPathDoesNotMatch();
testOwnModulePathTrailingSeparatorDoesNotMatch();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}