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
+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
(SWS issue #802 names a specific way it might not hold).
### Phase Ω — Responsiveness, and the control surface's second pass
Ω-W1's five tracks landed together, merged to `dev` as `1159d36` (2026-08-03):
- **Ω-W1-T1 `mode-switch-responsiveness`.** Narrowed the mode-switch undo mask off blanket
`UNDO_STATE_ALL`, bracketed the flag-write phase in a `PreventUIRefresh` RAII pair
(`UiRefreshHold`), and replaced the linear GUID scan with a hash map (`TrackByGuid`, an
`unordered_map`). Also shipped a deferred FX-park queue splitting the synchronous mode-switch
apply from per-FX offline/online. A follow-on split of that drain by kind, **Ω-W2-T4**
(`70bd29d`), was **reverted** (`2f96dd3`): FX-park state was left stranded across save/reopen,
so forced synchronous FX-park drains returned — and with them the mode-switch hitch — until a
re-snapshot-after-discard fix lands.
- **Ω-W1-T2 `mode-switch-persist`.** A mode switch now writes only the `view_state` ext-state
key (`saveViewStateOnly`) instead of the full multi-key session save on every switch.
- **Ω-W1-T3 `meter-rate`.** A second timer (`kMeterTimerIntervalMs = 16` ms, ~60 FPS) drains the
master meter independently of the 500 ms bank/bake poll (`kSyncTimerIntervalMs`).
- **Ω-W1-T4 `deck-reflow-focus-toggles`.** Moved Filter Mod onto the Filter Envelope deck,
centred Trigger's reserved knob-cell runs, converted the two-segment toggles (the
"Envelope"/"Filter"/"Limiter" enables and chrome "Loop") to single buttons, and made clicking
anywhere on an envelope deck focus its overlay.
- **Ω-W1-T5 `overlay-mapping`.** Unified the marker/overlay coordinate mapping with the
waveform draw chain around one `frameToX`/`OverlayArea`; Trigger now hides the loop marks and
caption outright, and Gate-with-loop-off draws them grey-disabled and still grabbable.
Two Ω-W2 tracks landed the same day. **Ω-W2-T2 `content-detect-throttle`** (`84afd1b`)
throttled the panel's new-content enumeration to `kDetectIntervalMs = 500` ms, down from the
~30 Hz timer tick it rode before. **Ω-W2-T5 `snap-radius`** (`aa87d81`) bounded
`nearestZeroCrossing`'s fan-out to `kMarkerGrabWidth` (5 px) and added Ctrl-defeats-snap on a
marker drag; `docs/product/instrument-control-surface.md` §4.1 now documents the gesture.
**Also landed alongside Ω (2026-08-03), outside the plan:**
- `4b19556` **Insert as FX** — a panel button + bindable action loading ReaSampler 9000 onto
the selected track with the focused capture.
- `098727e` **Panel polish** — a crosshair arrange-drop cue, and Re-capture / Insert Conform
moved to the "⋯" overflow menu.
- `a5febbd` **Drag-out hand-off fix** — the card drag stays internal while the pointer is over
REAPER.
**Still open, in `docs/PLAN.md`:** Ω-W2-T1 `view-topology-cache` (measurement-gated on
Ω-W1-T1's landed result; never dispatched) and Ω-W2-T3 `omega-verification-sweep` (the DAW
verification record for Ω-W1; not run).
**Superseded history.** Ω-W1-T1's deferred FX-park queue, a follow-up snapshot guard
(`bd86ede`, 2026-08-05, "refuse to park when the pre-park truth is gone"), and an undo-collapse
fix (`03f7fb5`, 2026-08-06) all landed against the synchronous FX-park approach the Aug 3
revert left in place. **All of it was superseded on 2026-08-21** by the Design View
mode-switch redesign below, which moved per-FX offline/online off the switch path a second
time — onto a background reclaimer — and collapsed the undo footprint through the new
`src/shell/undo/` module.
### Design View mode-switch redesign — FX off the switch path, one undo point
Four waves, merged to `dev` as `da84012`, that took the per-FX offline/online work
@@ -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
pure test can't measure the proportional font without pulling shell drawing into
`core/ui`; overflow end-ellipsizes rather than breaking layout.
### Live spline-envelope drags: held and new notes follow a dragged knot
Dragging a knot on a Spline-mode amp/pitch/filter envelope in ReaSampler 9000's editor
is now audible during the drag, for both held and new notes — releasing still commits
exactly as before, in one undo point. The three contours travel to the audio thread on
their own seqlock-published, double-buffered block (`core/instrument/engine/live_contours.h`,
`seqlock.h`), separate from `LiveValues` so a knob publish, an automation merge or the
live-block compare never carry the ~9 KB contour payload. Landed in the same merge:
closing the editor mid-drag rolls the published contours back to the pre-grab snapshot
(`removedFromParent`); the resample bake detaches live contours before its own render
(`bake_render.cpp`); a pre-existing bug is fixed where a live-parameter publish arriving
in the same block as a note-on failed to reach the already-held, sounding voices —
`startVoice`'s `refreshLive()` consumed the new generation without applying it to active
voices (`VoiceEngine::startVoice` now calls `applyLiveToActive()`); and the amp spline's read is
clamped to `[0,1]` to absorb a swap-step overshoot from the smoother. Not DAW-verified —
checks are tracked in `docs/SEPT_VERIFY.md` §5.
### Plugin-editor drop hand-off
Dragging a bank card onto a third-party plugin's own editor window (e.g. Serum 2's
oscillator) now hands the file to Windows as an ordinary OS file drop, so the plugin
loads it — the same outcome as dragging the file in from Explorer. REAPER's other
surfaces keep their existing outcomes: dropping on the FX chain list or FX window
chrome still loads ReaSampler 9000, and TCP/MCP/arrange are unchanged. ReaSampler
9000's own editor is exempted from the hand-off (`WindowFacts::ownModule`, a basename
match against the channel's own `.vst3`) — a drop on it still loads the instrument.
The OS hand-off is reversible mid-drag: moving back over REAPER (including after
leaving REAPER for Explorer) hands the drag back to the panel's own drop behavior.
Stock REAPER plugins (ReaEQ etc.) now count as plugin editors for this purpose. Landed
in the same merge: stable and beta ReaSampler 9000 editors previously shared one
Windows window class, so side-by-side installs could run the wrong channel's code —
each channel now registers its own class. Mechanism detail: `src/core/ui/CLAUDE.md`
(`window_origin`) and `src/shell/actions/drag_out_win.h`. Not DAW-verified — checks
are tracked in `docs/SEPT_VERIFY.md` §4.
+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 |
| **Ω-F6** | **ACCEPTED, and it is a spec amendment: separate the FX parking's UI half from its processing half.** Visibility / routing / FX-enable apply **synchronously**; per-FX offline/online **defers to a later idle tick** — *"the switch doesn't happen during playback anyway… the UI is the priority when toggling modes"* | Ω-W1-T1 |
**SUPERSEDED — Ω-F6 and every deferred-park instruction under Ω-W1-T1 below (the new park-queue TU, its `OnTimer` drain, its `target_sources` line).** The deferral cost a SECOND undo point and could not be made to cost one: REAPER's undo surface has no append-to-point and no block held across a return to the message loop, so per-FX offline/online now runs inline in `applyMode`'s own block and the queue is deleted. The hitch is back on the switch's synchronous path, deliberately. The rest of Ω-W1-T1 (the honest undo mask, compare-before-write, `PreventUIRefresh`, the `resolve` hash map) stands as written. Two of that track's acceptance criteria below are superseded with it: "paints its new state before the park work runs" assumed the split that no longer exists — a switch now parks synchronously before anything paints — and "two rapid switches leave every track in the state the SECOND switch specifies" assumed a queue that could apply both — the synchronous latch REFUSES a switch fired while another is already applying, so a rapid second switch is dropped, not applied.
**Verified by Daniel, not a fork:** the deck knob-spacing defect is in the **Trigger** face.
**Gate is correct and must stay pixel-identical** — an acceptance criterion of Ω-W1-T4, not a
hope.
**Doubly superseded — read this as history, not as current behaviour.** Ω-F6's deferred
FX-park queue (the park-queue TU, its `OnTimer` drain, its `target_sources` line) shipped
with Ω-W1-T1 on 2026-08-03. A follow-on split of that drain by kind, Ω-W2-T4 (`70bd29d`),
was reverted (`2f96dd3`): FX-park state was left stranded across save/reopen, so forced
synchronous FX-park drains returned — per-FX offline/online back to running inline in
`applyMode`'s own block — and with them the mode-switch hitch, until a
re-snapshot-after-discard fix lands. A snapshot guard (`bd86ede`, 2026-08-05) and an
undo-collapse fix (`03f7fb5`, 2026-08-06) then landed
against that synchronous approach. **All of it was superseded again on 2026-08-21** by the
Design View mode-switch redesign (`docs/COMPLETED.md`), which moved per-FX offline/online
off the switch path a second time — this time onto a background reclaimer — and collapsed
the switch's undo footprint through the new `src/shell/undo/` module. Nothing in this
paragraph describes current behaviour; see `docs/COMPLETED.md` for what actually ships.
### Phase-Ω acceptance criteria
@@ -433,458 +441,11 @@ These bind every track in this phase, in addition to the plan-wide set above.
### Ω-W1 — The five defects, in parallel
**Depends on:** nothing. **Five tracks, disjoint at the file level.** Items 2, 3 and 4 are ONE
track on purpose: they land in the same deck modules and the same pinned-geometry test files,
so splitting them means re-deriving `test_deck_groups_measured.cpp` three times. Items 5 and 6
are one track because they overlap inside `paintWaveform`'s mark-draw loop — one deletes and
gates it, the other changes the `mx` it computes.
| Track | Owns |
|---|---|
| **T1** `mode-switch-responsiveness` | `shell/view/view.{h,cpp}` + one new deferred-park TU + its drain in `app/main.cpp` |
| **T2** `mode-switch-persist` | `shell/actions/design_view_actions.cpp` + `shell/persist/ext_state_io.cpp` — dirty-key gating |
| **T3** `meter-rate` | the editor's timer/paint path + the hero-envelope cache |
| **T4** `deck-reflow-focus-toggles` | Ω.2 + Ω.3 + Ω.4 — the deck's composition, layout, paint, input and pinned tests |
| **T5** `overlay-mapping` | Ω.5 + Ω.6 — the waveform overlay's marks and its one coordinate mapping |
**Shared files in the wave, named rather than discovered at merge.** All textual adjacency
unless marked otherwise.
| File | Tracks | Nature |
|---|---|---|
| `src/shell/instrument/reasampler_editor.h` | T3 (meter clock + timer members), T4 (deck focus state), T5 (nothing new expected) | **The one to watch.** Additive member declarations in different sections of a header with ~36 lines of ceiling margin between them. Whichever lands second rebases AND re-checks the line count |
| `src/shell/instrument/editor_paint_waveform.cpp` | T3 (the envelope-cache call site at `paintWaveform`'s bin/`computeEnvelope` block), T5 (the mark, caption and mapping regions below it) | Disjoint regions of one function's body; whichever lands second rebases |
| `src/shell/instrument/editor_controls.cpp` | T4 (`faceLayout`'s `deckDescs` build), T5 (`waveMarksFor` / `grabbableMarks`) | Different functions, no shared state |
| `src/app/CMakeLists.txt` | T1 (one `target_sources` line for the new park-queue TU) | Ω's only CMake edit. **Cross-phase adjacency:** Λ-W2-T2, Λ-W2-T3, Λ-W4-T3 and Λ-W5-T1 all own regions of this file. Ω lands first; Λ rebases |
| `src/app/main.cpp` | T1 (the drain call inside `OnTimer`) | **Cross-phase adjacency:** Λ-W2-T1 owns this file's `REAPERAPI_LoadAPI` failure branch — a different region |
#### Ω-W1-T1 — `mode-switch-responsiveness`
**Goal.** A mode switch redraws immediately. The expensive half of the switch still happens; it
just stops being the thing the user waits for.
**Spec:** Ω.7 and rulings Ω-F5, Ω-F6 above; no product doc.
**Surface boundary — owns:** `src/shell/view/view.cpp` (the `applyMode` body — the undo block,
the park/restore loops, the parent-visibility loop, `resolve`), `src/shell/view/view.h`, ONE
new TU pair beside them for the deferred queue, one `target_sources` line in
`src/app/CMakeLists.txt`, the drain call in `src/app/main.cpp`'s `OnTimer`, and
`src/shell/view/CLAUDE.md`'s FX-parking caveat. **Does not own:** `core/view` (that is
Ω-W2-T1's), `design_view_actions.cpp` or `ext_state_io.cpp` (T2's), `panel_input.cpp`
(Ω-W2-T2's), or `view_solo.cpp`.
**The solo surface index is EXONERATED.** It costs ~2T extra API calls, sub-millisecond. Say so
in the brief so it is not re-litigated: the magnitude is two older O(project) costs that are
invisible on a scratch project and quadratic-feeling on a real one.
**Behavior.**
- **The undo mask (`view.cpp:563`).** `Undo_EndBlock2(proj, undoLabel.c_str(), -1)` — `-1` is
`UNDO_STATE_ALL` (`reaper_plugin.h:1540`), which includes `UNDO_STATE_FX` (`:1542`). REAPER
marshals **every plugin's state chunk in the project** into the undo record, on every switch
**and** every tag/untag reapply. The honest mask is
`UNDO_STATE_TRACKCFG|UNDO_STATE_ITEMS|UNDO_STATE_MISCCFG`, with `UNDO_STATE_FX` OR'd in only
when the park/restore loop actually toggled an offline state this apply. The second
`-1` at `:607` (`mintManagedLanes`) is a separate call site — assess it, do not assume it.
- **Unconditional `TrackFX_SetOffline`.** `parkFxOffline` (`:190-195`) calls it on **every FX of
every parked track**, including tracks already parked and already offline — unloading and
re-instantiating plugins for no state change. `restoreFxOffline` (`:200-204`) writes verbatim
from the snapshot the same way. Both compare before writing.
- **`PreventUIRefresh` is used NOWHERE in `src/`** (verified: zero matches across the tree)
though the SDK exports it (`reaper_plugin_functions.h:5583`). ~10T flag writes land with UI
refresh live. Bracket the write phase, and keep the existing
`TrackList_AdjustWindows(false)` + `UpdateArrange()` as the one deliberate rebuild after the
bracket closes. **Balanced pairing is mandatory** — an early return between the two calls
leaves REAPER's UI frozen.
- **Compare before write.** `applyFlags` (`:182-186`) and the parent-visibility loop
(`:521-531`) write unconditionally, so a reapply that changed nothing still pays every write.
- **`resolve` is a linear scan** (`:122-128`) called once per parked / restored / parent / lane
track — O(T²). `readFolderEntries` (`:103-120`) already builds `handleByGuid` and can hand
back a hash map instead of a vector of pairs.
- **Ω-F6, and it is the headline.** Split the synchronous apply from the deferred park:
visibility, routing and FX-enable apply **inside** `applyMode`; per-FX offline/online is
enqueued and drained on a later idle tick. **The spec must state what the queue does when a
second switch arrives before the first's park has run** — the answer is that a queue entry is
keyed by track GUID and the LATEST intent wins (an enqueued park superseded by a restore
cancels, not stacks), because the two are inverses and replaying both is both slower and
observably wrong. A project close, a track deletion, or a project switch drains the queue
without applying it.
- **Where the queue lives.** `src/shell/view/view.cpp` measures **642 lines** today and is
already over the ~600 ceiling with its seam blocked (`docs/TODO.md`). The queue is therefore
a **new TU beside it**, not more of `view.cpp` — `src/shell/view/` has no `CMakeLists.txt` of
its own, so this costs exactly one `target_sources` line in `src/app/CMakeLists.txt` (where
`view.cpp` and `view_solo.cpp` are already listed). This is the one structural decision in
the track and it moves `view.cpp` toward the ceiling rather than further past it.
- **The invariant amendment.** `src/shell/view/CLAUDE.md`'s "Documented caveat" bullet accepts a
load hitch on the return to the active mode. After this track the hitch is still real but no
longer sits on the switch's synchronous path — the caveat must say so, and must say what the
deferral does NOT change (the plugin still re-instantiates; un-persisted internal state is
still lost).
**Acceptance criteria.**
- On a project with enough tracks and FX to reproduce it, the mode switch **paints its new
state before the park work runs**, and the wall-clock to first paint is recorded before and
after. `[verify — DAW]`, discharged by Ω-W2-T3.
- A tag/untag reapply on an unchanged project performs **zero** `TrackFX_SetOffline` calls and
**zero** redundant flag writes.
- The undo record for one mode switch no longer carries FX state when no offline state changed;
**one switch is still one Ctrl-Z**, and Ctrl-Z still restores what it restored before.
- The park/restore round trip — the phase's trust anchor — still returns every driven flag to
its captured value, including when the deferral means the park lands on a later tick.
- Two switches in rapid succession leave every track in the state the SECOND switch specifies,
with no track left offline that should be online.
- `PreventUIRefresh` is balanced on every exit path from `applyMode`, including the two early
returns already there.
- `view.cpp` is no longer than it is today.
**Prerequisites.** None; concurrent with T2–T5. **Discharges:** Ω.7 (the structural half),
Ω-F5, Ω-F6.
#### Ω-W1-T2 — `mode-switch-persist`
**Goal.** A mode switch stops rewriting the whole session to record that one key changed.
**Spec:** Ω.7's lower-priority half; ruling Ω-F5 (both entry surfaces).
**Surface boundary — owns:** `src/shell/actions/design_view_actions.cpp` (`persistViewState` and
the two callers `doActivateMode` / `doToggleMode`) and `src/shell/persist/ext_state_io.cpp`
(`saveToActiveProject`). **Does not own:** `view.cpp`, `session.cpp`, or any panel TU.
**Behavior.**
- The panel-click path routes through `Main_OnCommand` → `doActivateMode`
(`panel_input.cpp:330-334`), which calls `persistViewState()` →
`saveToActiveProject()` — a **seven-key** ext-state write (`ext_state_io.cpp:140-190`: banks,
the legacy-key clear, view, tail, owned_files, version stamp, bank generation) plus a
`MarkProjectDirty`, every switch, when only `view_state` changed. Serializing the bank book
and the tracking ledger is the expensive part and neither moved.
- **Gate by dirty key, not by call site.** A per-key dirty flag on the session, or a
`saveViewStateOnly` entry point beside the full save — the track picks, and states why. The
full save must remain the default for every other caller.
- **`saveToActiveProject`'s return contract must not change.** `shell/persist/CLAUDE.md` is
explicit: `false` means *no active/saved project and NOTHING was written*, and it may never
grow an observational third failure mode. A narrowed write returns on the same rule.
- The degraded-ledger suppression (`tracking::ledgerDegraded`) and the version stamp both stay
exactly where they are on the full path.
**Acceptance criteria.**
- A mode switch on a saved project writes `view_state` and nothing else; a capture, a bank op
and a project save each still write everything they wrote before.
- `MarkProjectDirty` still fires on the narrowed path — the state DID change.
- Both entry surfaces (the footer segment and the bindable action) take the same path.
- The write-count difference is measured and recorded.
**Prerequisites.** None; concurrent with T1. **Discharges:** Ω.7 (the persist half).
#### Ω-W1-T3 — `meter-rate`
**Goal.** The master meter moves at 60 FPS, and a paint costs what one frame should.
**Spec:** Ω.1; no product doc. `src/shell/instrument/CLAUDE.md` already records that "a
meter-rate timer remains a separate change and is not in" — this is that change.
**Surface boundary — owns:** `src/shell/instrument/editor_platform.cpp` (the timer set-up and
the `WM_PAINT`/`invalidate` path), `editor_session.cpp` (the meter block currently inside
`onSyncTimer`), `editor_paint.cpp` (the paint entry's bitmap and dirty-rect handling), the
hero-envelope cache's accessor in `editor_paint_waveform.cpp`, and `reasampler_editor.h`'s
meter/timer/cache members. **Does not own:** `meter_ballistics.h`, `master_meter`, the
processor, any deck or overlay module.
**Ballistics need no rework.** Every constant in `core/instrument/engine/meter_ballistics.h` is
per-second, and elapsed time is measured rather than assumed — the rate is a UI-cadence
problem, not a ballistics one. **Nothing changes on the audio thread.**
**Behavior.**
- **The cause.** There is exactly ONE timer in the tree: `kSyncTimerIntervalMs = 500`
(`editor_platform.cpp:33`), the bank/bake change-detection poll. The meter's advance +
`invalidate` sit inside `onSyncTimer` (`editor_session.cpp:113-134`), so 2 FPS is the
ceiling, and `WM_TIMER` coalescing under host load takes it below that.
- **The recommended shape: a SECOND timer id on the same child window**, running only the meter
drain, advance and invalidate. **The 500 ms poll body is untouched** — `pollBankSync`,
`bakeAvailable`, `resolveBakeHoldNeeded` and the bake run must not speed up (a bridge read
plus a bank parse each), and the tick-counted UI decays (`kBakeMessageTicks`,
`dropHintTicks_`) keep their meaning for free. **The rejected alternative** is raising the
shared interval and converting those decays to elapsed time: it is one fewer timer and it
silently shortens two user-visible banners by ~15× if the conversion is missed anywhere.
- **`masterBusMeter()` is a CONSUMING drain** (exchange-to-identity) and must keep **exactly
ONE caller**, or the two clocks steal windows from each other. Moving it to the meter timer
keeps that; leaving a copy behind in `onSyncTimer` breaks it.
- **The meter block must stay AHEAD of the in-flight-drag guard.** On its own timer this is
structural rather than positional — state it, because the reason (a drag suppresses the
reload poll but the bus keeps sounding) survives the move.
- **The clock resolution is a real hazard at 16 ms.** Today the elapsed measure is
`GetTickCount64` (`editor_session.cpp:114`), whose granularity is ~15.6 ms — at 60 FPS the
measured delta quantizes to 0 or 16 and a zero-delta advance does nothing but burn a frame.
Move the meter clock to a high-resolution source off the audio thread, and skip the advance
on a zero delta rather than passing it in.
- **Three paint costs, all of which the higher rate exposes:**
1. `invalidate()` (`editor_platform.cpp:65-66`) dirties the **whole client area**
(`InvalidateRect(hwnd, nullptr, FALSE)`) and `paint` never reads `ps.rcPaint`
(`:149-155`). The meter needs its own rect invalidated and the paint needs to honour the
dirty rect.
2. `paint` allocates a **full-size `LICE_SysBitmap` per paint** (`editor_paint.cpp:28`).
Hoist it to a member resized on `WM_SIZE`, where `thumbCache_.clear()` already lives.
3. `paintWaveform` recomputes `computeEnvelope` over the **entire decoded PCM every paint**
(`editor_paint_waveform.cpp:204-212`). The decode is cached (`channelPcmFor` /
`monoPcmFor`); the hero envelope is not. Cache it keyed on sample id + bin count + lane
count, busted by `refreshFromBank` and by a resize — the same discipline `marksCache_` and
`thumbCache_` already use.
- **Target 60 FPS.** 30 is the fallback **only** if the paint-cost work cannot get there, and
choosing it requires the measurement that justifies it.
**Acceptance criteria.**
- The meter visibly tracks transients at the shipped rate; the measured frame interval is
recorded, with the rate chosen and why. `[verify — DAW]`, discharged by Ω-W2-T3.
- A meter-only frame does **not** repaint the deck, chrome or waveform bands.
- No allocation in the steady-state paint path.
- With no editor open, nothing changed: the meter timer is bound to the child window exactly as
the sync timer is.
- The bake affordance, the Hold applicability, the bake message and the drop-hint banner all
behave exactly as before — same cadence, same durations.
- `masterBusMeter()` has exactly one call site.
- The first meter read is still DISCARDED (session history, not a window) and the clip latch is
still NOT discarded with it.
**Prerequisites.** None; concurrent with T1, T2, T4, T5. **Discharges:** Ω.1.
#### Ω-W1-T4 — `deck-reflow-focus-toggles`
**Goal.** Filter Mod sits with the envelope it modulates, the Trigger face's knobs sit at their
natural pitch, a two-state toggle is one button, and clicking an envelope deck anywhere selects
its overlay.
**Spec:** Ω.2, Ω.3, Ω.4 and rulings Ω-F3, Ω-F4. Deck composition law:
`src/core/instrument/ui/CLAUDE.md`'s `knob_deck` and `deck_groups` bullets.
**Surface boundary — owns:** `src/core/instrument/ui/deck_groups.{h,cpp}`,
`knob_deck.{h,cpp}`, `deck_values.cpp` (the toggle commit seam only),
`src/core/instrument/ui/sample_chrome.{h,cpp}`, `src/shell/instrument/editor_paint_deck.cpp`,
`editor_input_deck.cpp`, `editor_paint_chrome.cpp`, `editor_input_chrome.cpp`,
`editor_input.cpp` (the focus-clear arm of the mouse-down dispatch only), `editor_controls.cpp`
(`faceLayout`'s `deckDescs` build only), and `tests/test_deck_groups.cpp`,
`tests/test_deck_groups_measured.cpp`, `tests/test_knob_deck.cpp`. **Does not own:**
`waveform_view`, `envelope_overlay`, `spline_edit`, or any `editor_*_waveform` TU (T5's), and
**no `param/` file whatsoever**.
**Behavior — Ω.2, the move.**
- `sampleDeckGroups` (`deck_groups.cpp`) is the sole deck-composition authority. Move
`kFilterModAmt` out of FILTER's `cellIds` (`:62-68`) into FILTER ENV's (`:80-89`), **in both
faces**, landing **last in the run** — the `kPitchEnvDepth` precedent (`:50-53`).
- **NO VST3 parameter-id change.** `kFilterModAmt` keeps its frozen id (`kParamFilterModAmount`
= 1240) and its unit; ids are keyed on `DeckParam`, never on deck group. `param_id.h`'s
header comment says the blocks run "per deck group in SIGNAL-FLOW order" (`:34`) — that
becomes untrue for exactly one row. **Annotate it; never renumber.** The frozen-table rule is
the whole reason the annotation is the correct fix.
- **This move costs the filter tie-line, and that cost is stated rather than discovered.**
Today `test_deck_groups_measured.cpp` pins both rows' filter groups to one right edge (x=640
block-relative) — the only reason the row block was widened 1020→1028. After the move the
Sound row loses 60 px and the Contour row gains 60, and with the two filter groups then equal
in width the tie is **unreachable at any block width** under the space-between law. The
alignment goes; the ruling stands. Re-derive that fixture from the new contract, record the
loss in it, and **do not change `kDeckRowBlockW` or the editor floor to chase it** — revisiting
the floor is a separate proposal, not this track's to make.
**Behavior — Ω.2, the spacing.**
- The defect is a **layout-algorithm artifact, not a constant**. A group's cell run is a
reserved width, and a `-1` reserve donates its slot to the surviving cells
(`knob_deck.cpp:123-138`), so Trigger's FILTER ENV gets ~100 px cells and AMP ENV ~75 against
the standard 60. Gate is uniform at 60 because it has no reserves.
- **The law: cells stay at `kDeckCellW`; the run is CENTRED in the reserve.** It is the only
candidate satisfying both of Daniel's constraints — group width stays stable across a mode
flip (which is why the reserves exist) and knobs keep their natural pitch. Gate is then
pixel-identical by construction, because with no reserves the centred run and the divided run
are the same run.
- **The two rejected laws, recorded so they are not re-proposed:** *(a) drop the reserves and
let the group narrow in Trigger* — reflows every neighbour on a mode flip, which is the thing
the reserves were introduced to prevent; *(b) keep the division but cap the cell width* —
leaves an asymmetric residue at one end and re-introduces the dead-slot look the division law
replaced.
- A consequence worth stating: once the run is centred, **where a `-1` sits in `cellIds` stops
mattering** — only the count does. The reserves become pure width accounting.
**Behavior — Ω.3, single buttons.**
- Today every deck toggle is structurally two segments (`DeckToggleDesc` `knob_deck.h:69-72`,
`placeToggle` `knob_deck.cpp:82-94`, `hitTestDeck`'s seg0/seg1 `:259-268`, `drawToggle`
`editor_paint_deck.cpp:117-137`). **Two variants replace it:**
- **Enable** — one button, label names what it controls, **`AccentPrimary` when active and
dim gray when off** (Ω-F3, Ω-F4). Applies to: the envelope enable (`kPitchEnvEnable`,
today `Off|On`) → **"Envelope"**; `kFilterEnable` → **"Filter"**; `kLimiterEnable` →
**"Limiter"** (it currently names nothing it controls); and the chrome row's
`Loop Off|Loop On` → **"Loop"**.
- **Mode selector** — one button whose **label reads the current mode**, with **no dim-gray
state** (Ω-F3). Applies to: the three `Stg|Spl` env-mode toggles → **"Stage"/"Spline"**,
and the chrome row's `Mono|Stereo`.
- **A third visual state survives and must not be collapsed into "off":** a control refused
by the mode (the chrome Loop enable in Trigger, `kFilterLaw` while the filter is off) draws
**Disabled and inert**. Off is live and clickable; Disabled is not.
- **Explicitly NOT converted, and that is a boundary rather than an oversight:** `kPlayMode`
(`Gate|Trigger`), `kPitchEngine` (`Varisp|Presrv`), `kVoiceMode` (`Poly|Mono`), `kFilterLaw`
(`Band|Notch`) and `kMonoTrigger` (`Retrig|Legato`) keep their two-segment form. Daniel named
a set; this track ships that set. The mode-selector variant generalizes to them the day he
asks.
- **Three load-bearing risks the implementation must answer:**
1. **A single button carries no `segment`**, so the commit must derive the NEXT state from the
current one. `setDeckParam`'s `segment == 1` contract (`deck_values.cpp:135-162`) is the
seam; `nextOverlaySelection` (`deck_groups.cpp:298-302`) is the existing precedent for
computing a next discrete state purely.
2. **`kLimiterEnable`'s split commit must survive VERBATIM** (`editor_input_deck.cpp:67-79`):
it commits the parameter set and calls `setLimiterEnabled`, which only ARMS the host's
`restartComponent(kLatencyChanged)` for the sync tick to deliver. Nothing on that path may
start calling into the host from inside a mouse handler.
3. **MASTER has ZERO caption slack** — its caption row (46 + toggle + radio) and its knob row
(`kDeckCellW` + `kDeckColumnGap` + `kMeterColumnW`) both measure 130, which is exactly
`kDeckSpanningW` − 2·`kDeckGroupPadX`. **So the "Limiter" button must be ≤ 64 px** (what
the two 32 px segments occupy today). Wider and `kDeckSpanningW` grows, which pushes
`kEditorMinWidth` into the 82 px budget against `kEditorCeilingWidth`. **Every other group
absorbs a wider button free** — verified: each is knob-row-bound with 50+ px of caption
headroom.
- **`hitTestKnobFace` runs no toggle-precedence pass** (`knob_deck.h:217-224`) and is only
correct while no toggle rect overlaps a knob circle. Any button that grows must be re-checked
against that, or given the same precedence order.
**Behavior — Ω.4, the deck as the overlay target.**
- Remove the three overlay radios from the env groups' descriptors. **`DeckRadioDesc` itself
must SURVIVE** — MASTER uses a passive one for the GR lamp (`deck_groups.cpp:140`), and the
`passive` skip in `hitTestDeck` (`:255-258`) is what keeps a readout from growing a gesture.
- Clicking anywhere in an envelope deck — panel background, knob, or button — focuses that
envelope's overlay. The focused deck takes an **`AccentPrimary`** border (Ω-F4). Focus clears
when the user clicks a control surface outside it; **the overlay itself is excluded from
stealing or clearing focus**, so editing the thing you selected does not deselect it.
- **Five design constraints, each of which breaks something if missed:**
1. The focus write must land **before** the `hit.kind` switch in `mouseDownDeck`
(`editor_input_deck.cpp:31-37`) and must **not consume the click**, or every knob grab and
every toggle commit on a focused deck dies.
2. **`DeckHit` carries no group id** (`knob_deck.h:190-195`). Either the shell scans
`dl.groups` for containment or `DeckHit` gains one — the latter is the smaller lie.
3. **`hitTestDeck` returns a miss for a click on group padding** (`knob_deck.cpp:252-278`),
which is exactly the "deck panel background" Daniel wants to hit. Containment against
`g.box` is the test, not the hit kind.
4. **A deck click SETS focus idempotently; it does not toggle to none.** Otherwise every knob
tweak on the focused deck unfocuses it. This retires `nextOverlaySelection`'s
re-click-clears branch (`deck_groups.cpp:301`) and replaces the radio-id map with a
group-id one.
5. **Refuse focus changes while a drag is in flight**, and keep `overlayEnv_` transient view
state with **no persistence path** — it is what is drawn, not what is played.
**Acceptance criteria.**
- **The Gate deck face is pixel-identical to today**, group boxes and cell rects both, asserted
in `test_deck_groups_measured.cpp` — except for FILTER and FILTER ENV, whose widths move by
exactly one cell in opposite directions.
- Trigger's FILTER ENV and AMP ENV cells measure `kDeckCellW`, centred in their reserves, and
each group's box width is **identical in both modes**.
- `kFilterModAmt`'s `ParamId` is unchanged; `exposedParams()` returns the same list in the same
order; no `param/` file is edited.
- Every converted control's commit produces the same audible result as the segment it replaced,
including the limiter's latency-restart arm.
- `kEditorMinWidth` and `kEditorCeilingWidth` are unchanged, and the 82 px budget is unspent.
- Clicking a knob, a button and the bare panel of an envelope deck all focus that overlay; the
first two still perform their own action; clicking the overlay does not change focus.
- The deck's pure fixtures cover the new composition, the centred run and the focus map — the
shell layer is not where any of the three is asserted.
**Prerequisites.** None; concurrent with T1, T2, T3, T5. **Discharges:** Ω.2, Ω.3, Ω.4, Ω-F3,
Ω-F4.
#### Ω-W1-T5 — `overlay-mapping`
**Goal.** The loop marks say what the mode means, and every overlay that rides the waveform
lands on the frame it names.
**Spec:** Ω.5, Ω.6 and rulings Ω-F1, Ω-F2; `docs/product/instrument-control-surface.md`
§6.3(d) and §6.4 **as amended by Ω-F2**.
**Surface boundary — owns:** `src/core/instrument/ui/waveform_view.{h,cpp}`,
`src/shell/instrument/editor_paint_waveform.cpp` (the mark, caption and overlay regions),
`editor_input_waveform.cpp`, `editor_controls.cpp` (`waveMarksFor` / `grabbableMarks` only),
`src/core/instrument/ui/spline_edit.h`'s `splineOverlayBox` and `envelope_overlay`, and
`tests/test_waveform_view.cpp` + `tests/test_component_geometry.cpp`. **Does not own:** any
deck module or `editor_*_deck` TU (T4's), the envelope-cache region of
`editor_paint_waveform.cpp` (T3's), or — **without an explicit proposal** —
`src/shell/panel/draw_kit.cpp` and `src/core/ui/component_geometry.cpp`.
**Behavior — Ω.5, the marks.**
- **Trigger ⇒ the loop pair and the crossfade mark are HIDDEN entirely.** Not Disabled, not
dim: absent. **Gate with loop off ⇒ they are still drawn and still grabbable**, styled **grey
disabled** rather than today's dim teal at `kMarkAlphaDisabled`
(`editor_paint_waveform.cpp:51-53, 285-305`). The drag-to-set-loop gesture survives, which is
the whole reason they stay grabbable.
- **The `"LOOP \xe2\x80\x94 GATE ONLY"` caption (`editor_paint_waveform.cpp:254`) is removed
unconditionally** — in Trigger there is no span left to centre it in.
- **The two sibling captions SURVIVE** (`:255`): `DRAG TO SET LOOP` when the pair is parked and
`LOOP OFF` when a span is retained. Their box derives from the marks' span, which still
exists in Gate, and they carry the one thing the grey styling cannot — *which* off state you
are in, and that dragging will fix it. **This is a [propose]-class call made here rather than
left open**; reversing it means deleting information, not styling.
- **The seams.** `waveMarksFor`'s `present[]` (`editor_controls.cpp:145`) is the correct place
to suppress — it also skips label layout. `grabbableMarks` (`:162`) is the separate
grabbability fold and must NOT follow it into hiding for the Gate-off case.
- **Warning to the track:** `resolveWaveformClaim`'s smallest-area arbitration and `capAtPoint`'s
reverse-order resolve (`editor_input_waveform.cpp:65, 89, 111, 212`) are both sensitive to
marks entering and leaving the set — `capAtPoint`'s reverse order exists precisely so a
coincident pair stays separable and the crossfade (the one mark with no column) can never be
shadowed (`core/instrument/CLAUDE.md`, `waveform_view`). Changing which marks are in the set
changes what those two resolve, which is the regression risk in this half of the track.
**Behavior — Ω.6, the mapping. This is a CLASS defect, not a START-marker instance.**
Two chains that do not agree, verified this pass:
| | Marker / overlay chain | Waveform draw chain |
|---|---|---|
| Area | `waveformOverlayArea` returns the **FULL band, no inset** (`waveform_view.cpp:23-25`) | insets **2 px each side**; `waveformColumnCount` = `width − 4` (`component_geometry.cpp:95-98`), columns start at `box.x + 2` (`draw_kit.cpp:292, 311`) |
| Domain | `[0, N]` **closed** (`clampFrame`, `waveform_view.cpp:15-19`) | `[0, N)` **half-open** (`columnMinMax`, `peaks.cpp:69-71`) |
| Rounding | **round-half-up** (`frameToX`, `:51-58`) | **truncation** |
| Phase | a 2 px marker line centred on an integer (`editor_paint_waveform.cpp:298, :301`) | a 1 px column centred at +0.5 |
Four independent discrepancies. The 2 px inset dominates — frame 0's marker sits 2 px left of
the first column and the error reverses sign toward the end — and the closed domain puts the
last frame one pixel outside the band. **Everything riding the overlay inherits all four:** the
loop span fill, the crossfade wedges, the staged envelope polyline, and the spline contour —
whose `spline_edit.h` guarantee that it uses "the FULL area, no inset, so the drawn contour
stays 1:1 with the sample's time axis" **is currently false by 2 px.**
- **Mandate ONE mapping both chains read**, so the drift cannot recur. Two routes:
- **Inset the overlay** (recommended) — instrument-only, and the overlay is already the
distinct `OverlayArea` type, so the change has one construction site.
- **De-inset the waveform** — `[propose]`, **requires justification before it is taken**: it
touches the shared `draw_kit`, which also serves the docked bank panel and the browse-card
thumbnails, so its blast radius is three surfaces rather than one.
- **The zero-crossing snap must be preserved EXACTLY *by this track*.** `nearestZeroCrossing`
(`waveform_view.cpp:181-210`) fans out symmetrically, probing `t−d` before `t+d` so an
equidistant tie resolves to the lower frame; it runs on the mono cache at drag time and is
exempt for the crossfade handle. **Behaviour-identical after the change** is an acceptance
criterion, not an assumption.
> **Amended, ruling R3 (Daniel, 2026-08-03) — read this as scope, not as a verdict on the
> snap.** This clause bound T5 to change the coordinate mapping WITHOUT perturbing the snap,
> and **T5 met it: the fan-out it received is the fan-out it returned, and its tie-rule
> fixtures pass unmodified.** The snap's behaviour was subsequently changed on purpose by a
> DIFFERENT track — **Ω-W2-T5 `snap-radius`**, which bounds the fan-out to
> `kMarkerGrabWidth` — so a later reader finding a bounded snap in the tree is looking at
> Ω-W2-T5's work, not at a T5 violation. **Bounding which frames are REACHABLE does not
> change how a reachable tie RESOLVES**, which is why T5's fixtures survive it untouched.
- **One question the track must ANSWER, not dodge:** what "sample-accurate" means when
`frames < columns` and one frame spans many pixel columns. A marker must land somewhere
inside its own frame's column span; which end, and whether the inverse `xToFrame`
(`:60-70`) still round-trips, are the track's to specify.
- **The two pinned fixtures are re-DERIVED from the new contract, never re-baselined.**
`test_waveform_view.cpp` and `test_component_geometry.cpp` pin the current mapping by
construction; a test updated to match whatever the code now prints has stopped being a test.
**Acceptance criteria.**
- One mapping function serves both chains; a second one anywhere in the tree fails review.
- Every mark, the loop span fill, both crossfade wedges, the staged polyline and the spline
contour land on the same pixel as the waveform column for the frame they name — at frame 0,
at the last frame, and at an interior frame, asserted in the pure fixtures.
- `frames < columns` and `frames > columns` are both covered.
- The zero-crossing snap returns the identical frame for the identical input, ties included.
**(Scope criterion on THIS track, discharged. Superseded phase-wide by R3 — the snap's
radius bound is Ω-W2-T5's deliberate change, and this track's tie-rule fixtures pass
unmodified under it.)**
- Trigger shows no loop marks and no loop caption; Gate-with-loop-off shows grey marks that
still drag, and dragging one still turns the enable on.
- `docs/product/instrument-control-surface.md` §6.3(d) and §6.4 agree with the shipped
behaviour.
**Prerequisites.** None; concurrent with T1–T4. **Discharges:** Ω.5, Ω.6, Ω-F1, Ω-F2.
**All five tracks have landed** — T1 `mode-switch-responsiveness`, T2 `mode-switch-persist`,
T3 `meter-rate`, T4 `deck-reflow-focus-toggles`, T5 `overlay-mapping` — merged to `dev` as
`1159d36` (2026-08-03). See `docs/COMPLETED.md` for the full narrative. (T1's deferred
FX-park queue was reverted the same day and its downstream mechanics were superseded again
on 2026-08-21 — see the rulings note above.)
---
@@ -894,32 +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
fix** (the Λ-W3 precedent).
| Track | Owns |
|---|---|
| **T1** `view-topology-cache` | `src/core/view/` — tree / `parentOf` / handle-map memoization |
| **T2** `content-detect-throttle` | `src/shell/panel/panel_input.cpp` — the 30 Hz detector's cadence |
| **T3** `omega-verification-sweep` | `docs/VERIFICATION.md` — the DAW record for all of Ω-W1 |
| **T5** `snap-radius` | `src/core/instrument/ui/waveform_view.{h,cpp}` + the marker-drag input path — **spec'd nowhere; see below** |
| Track | Owns | Status |
|---|---|---|
| **T1** `view-topology-cache` | `src/core/view/` — tree / `parentOf` / handle-map memoization | Open — measurement-gated |
| **T2** `content-detect-throttle` | `src/shell/panel/panel_input.cpp` — the 30 Hz detector's cadence | **Landed**, `84afd1b` (2026-08-03) |
| **T3** `omega-verification-sweep` | `docs/VERIFICATION.md` — the DAW record for all of Ω-W1 | Open — not run |
| **T5** `snap-radius` | `src/core/instrument/ui/waveform_view.{h,cpp}` + the marker-drag input path | **Landed**, `aa87d81` (2026-08-03) |
**Shared files in the wave:** none. T1–T3 touch three disjoint directories, and T5 touches a
fourth (Ω-W1-T5's surface, which lands first).
**Ω-W2-T5 has no spec section here, on purpose.** It arrived from ruling R3 — Daniel,
2026-08-03, recorded in `docs/product/single-cycle-and-wavetable.md` §7.1 and summarized at
"Flagged for awareness" item 4 above — after this phase was written, and is **committed on
branch `omega-w2-t5-snap-radius` (`b7b7e88`)**: it
bounds `nearestZeroCrossing`'s fan-out to `kMarkerGrabWidth` (5 px) and adds
**Ctrl-defeats-snap** on a marker drag. **Numbered T5 rather than T4 because it inherits
Ω-W1-T5's file surface; there is no Ω-W2-T4.** Two things it needs from other owners:
- **Flagged for doc-keeper:** its record belongs in `docs/COMPLETED.md`. This plan names it
only so the phase's own superseded criteria can point at what superseded them.
- **Flagged for doc-keeper — a documentation gap, not a defect.** *Ctrl-defeats-snap on a
marker drag* is a new **user-visible gesture** with no home in
`docs/product/instrument-control-surface.md`, and §4.1's exclusion block there — which
excludes waveform markers from the modifier law because *"overloading Shift there would
collide with a shipped gesture"* — now reads as a claim that markers carry no modifier at
all. That block needs amending and the gesture needs documenting.
**T2 and T5 have landed** — see `docs/COMPLETED.md` for the full narrative. T5 arrived from
ruling R3 (Daniel, 2026-08-03, recorded in `docs/product/single-cycle-and-wavetable.md` §7.1)
after this phase was written and inherited Ω-W1-T5's file surface rather than getting its own
spec section here; there is no Ω-W2-T4. Its documentation gap (Ctrl-defeats-snap on a marker
drag, previously unhomed in `docs/product/instrument-control-surface.md`) has since been
closed — that doc's §4.1 now carries the gesture.
#### Ω-W2-T1 — `view-topology-cache` *(measurement-gated on Ω-W1-T1's result)*
@@ -949,39 +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
as not worth taking.
#### Ω-W2-T2 — `content-detect-throttle`
**Goal.** The auto-tag detector stops spending main-thread time 30 times a second on exactly
the projects where the switch is slowest.
**Spec:** Ω.7's third cost.
**Surface boundary — owns:** `src/shell/panel/panel_input.cpp` (`detectNewContent` /
`enumerateLiveGuids` and their cadence inside `bankPanelRefresh`). **Does not own:** any other
panel TU, `view.cpp`, or the auto-tag RULES — only how often they are asked.
**Behavior.** `detectNewContent` (`panel_input.cpp:147`) runs `enumerateLiveGuids` (`:106-135`)
every tick of REAPER's `"timer"` registration (`main.cpp:354`, ~30/sec), regardless of panel
state — O(T + I) REAPER string calls plus roughly 5 allocations per track and 6 per item per
tick (a per-element-type count, not the earlier `~4(T+I)` derivation, which undercounted; the
ratio the throttle buys is unaffected by the correction). Throttle the ENUMERATION, not the
detection semantics: the same GUIDs must still be detected, the first-poll and `reloadPending`
re-baseline guards must still fire before any diff, and the lane-minting pass must still run
only on a tick that tagged. A slower cadence is a latency change to an invisible background
tag, and the track states the chosen interval and why.
**Acceptance criteria.**
- New tracks and items are still auto-tagged into the active mode, and a project reload still
re-baselines rather than mass-tagging.
- The Ρ-W1-T1 rule survives: an added GUID that already carries a membership record is still
dropped, so a render-in-place result is not re-tagged to Design.
- The per-tick allocation count is derived, not measured: it needs live REAPER-side track/item
counts and a runtime allocation trap neither CTest nor this repo's tooling provides.
`[verify — DAW]`.
- The panel's own repaint cadence is unaffected.
**Prerequisites.** None beyond Ω-W1. **Discharges:** Ω.7's polling cost.
#### Ω-W2-T3 — `omega-verification-sweep`
**Goal.** The DAW-observable half of this phase is recorded rather than assumed.
+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
either the identity is confirmed to survive all four, or a degradation is found and
the keying is amended.
## Spline-drag abort exits republish the editor's snapshot, not the processor's model
**Context (what shipped — the live spline-envelope drags track).** Both spline-drag abort
exits — `ReaSamplerEditor::removedFromParent` and the `WM_CAPTURECHANGED` handler in
`src/shell/instrument/editor_platform.cpp` — roll a drag back by restoring
`params_ = dragStartParams_`, the editor's own pre-grab snapshot, then republishing
contours from it.
**The wart.** `dragStartParams_` is a snapshot taken by the editor at mouse-down. If
`setState` or a bake reload lands mid-drag — either of which can rewrite the processor's
own model out from under the editor — the rollback re-couriers the STALE snapshot over
the model rather than the model's own current value, silently reverting whatever landed
during the drag.
**Intended fix.** Have the processor republish from its own params on these two abort
paths, rather than the editor courier-ing its own stale pre-grab copy.
**Priority / risk.** Not yet triaged.
**Done looks like.** A `setState` or bake reload landing mid-drag survives a subsequent
capture-loss or editor-close abort intact — the abort rolls the drag back without
clobbering the intervening model change.
## `reasampler_processor.h`'s preview mailbox is a candidate seam for the ceiling exception
**Context (what shipped — the live spline-envelope drags track).** `reasampler_processor.h`
is a documented ~600-line-ceiling exception (`src/shell/instrument/CLAUDE.md` Gotchas):
after `processor_snapshot.h` and `automation_channel.h` were split out, what remains is
the class declaration itself, plus a handful of lines over the ceiling since the
live-contour block landed beside `liveParams_`.
**The wart.** No further seam was found that isn't behaviour — except one candidate not
yet taken: the preview mailbox (the packed-sequence post/consume pair) looks like it could
move into its own header-inline `PreviewMailbox` aggregate without adding indirection to
`process()`'s access.
**Intended fix.** Revisit once the pending sample-rate branch lands — it is expected to
add roughly 10 more lines to the file, which may tip the decision.
**Priority / risk.** Not yet triaged.
**Done looks like.** Either the preview mailbox is split into its own header-inline
aggregate with `process()`'s access left zero-cost, or the file is re-affirmed as a
documented exception once the sample-rate branch's size impact is known.