docs: spec Phase S stereo (S7), bank-ingest (S8), change-detection (S9)

Settled 2026-07-26: per-instance mono|stereo mode with automatic bus
negotiation (S3-core extension); ingest routes through the bank, extension-
owned, instrument stays read-only; bank-generation counter for hands-free
recapture refresh. ME + drop-target SDK limits verified; spikes flagged.
This commit is contained in:
2026-07-26 17:36:27 -04:00
parent 6ae843345c
commit ed2df9c1d5
3 changed files with 576 additions and 5 deletions
+250
View File
@@ -254,6 +254,230 @@ build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle agains
- [ ] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects
the live keymap/levels.
## S7 — stereo channel mode (mono | stereo; core channel dimension + bus negotiation)
**Goal:** Give the instrument a per-instance **channel-mode toggle — 1 (mono) or 2
(stereo)** — that "works with the REAPER audio bus automatically." Mono keeps today's
downmix path; stereo grows the S3 core a **channel dimension** (2-channel sample data,
per-voice stereo render, stereo interp/loop) and negotiates the VST3 output bus so
mono/stereo just works in REAPER's routing. **This is an S3-core extension, not a shell
hack** — it touches the engine Daniel smoke-tests, so it sequences first after the
editor/embed work. CONTEXT.md §Phase S (channel mode, D-E). **Decided direction
(2026-07-26); leans below are build-time residuals, not open forks.**
**Verify (in DAW):** an instance set to stereo plays a stereo capture in true stereo,
its VST3 output bus negotiated to 2 channels via `setBusArrangements` so REAPER routes it
without manual channel wiring; an instance set to mono plays the existing downmix path; a
mono source in stereo mode plays dual-mono (centered); a stereo source in mono mode
downmixes (existing policy); the mode is per-instance state that survives project
save/reopen (component state, like the selected sample); the pure core's stereo render is
asserted against a known two-channel signal (mirror of `peaks`), and mono behavior is
unchanged (regression).
**Depends on:** S3 (extends the core), S4 (extends the process/bus shell). Independent of
S8/S9.
- [ ] Core channel dimension (pure, S3 extension): `SampleData` carries N-channel
(1 or 2) decoded PCM; `Voice::renderFrame` and `VoiceEngine::render` produce a
per-channel frame; stereo linear interpolation + loop read per channel. Mono stays the
degenerate case (single channel) — no behavior change for existing mono play. Tests:
stereo render asserted against a known 2-channel signal; mono render unchanged.
- [ ] Channel-mode toggle as per-instance state: `mono | stereo` in the instrument's own
component state (setState/getState, alongside the selected sample); default preserves
current behavior (mono). Cross-mode policy: **mono source + stereo mode → dual-mono**
(same signal both channels, centered); **stereo source + mono mode → downmix** (the
existing decode-side policy). The toggle lives in the instrument, never written to the
bank (a performance choice, not a file fact — D-B).
- [ ] Shell: decode fills 1- or 2-channel `SampleData` per the source's channel count
(the S2/bank channel-count intrinsic already exists); the process path renders the
active mode's channel count into the output bus.
- [ ] VST3 bus negotiation: implement `setBusArrangements` so the output bus reports
mono or stereo per the instance's channel mode, and REAPER's routing follows
automatically (no manual channel wiring). **Must-verify before build:** the
`setBusArrangements` / `getBusArrangement` contract and REAPER's mono/stereo instrument
bus expectations against the vendored Steinberg SDK + `reaper_vst3_interfaces.h`.
## S8 — ingest through the bank (one gesture: capture/import into bank + assign to instance)
**Goal:** Loading a sample into the sampler is **one gesture** — capture/import-into-bank
**and** auto-assign to the active sampler instance. **The extension owns ingest** (it has
arrange access, media-explorer access, and drop-target surface on its own panels); the
instrument stays a **read-only bank consumer**. This lives in the *extension* codebase
(actions + bank_panel + capture/insert), routing through the existing capture add-path and
the live `"reasampler"` seam the instrument already reads. CONTEXT.md §Phase S
(ingest-through-bank contract). **Decided direction "option 1" (2026-07-26).**
**Verify (in DAW):** a one-click "capture selected item / time-selection into the bank and
assign to the active instance" action captures via the existing capture path (never
auto-inserting into the arrange — load-bearing principle intact) and the target instance
plays the new sample on its next reload; a Media Explorer file imports into the bank and
assigns the same way; a file dropped onto a ReaSampler panel surface ingests into the bank
and assigns; the instrument never captures or imports (read-only over the bank throughout).
**Depends on:** S4 (an instance to assign to), M7 capture add-path, B2 (active-bank add
target). Best paired with S9 so assignment refreshes hands-free; functional without it
(assign triggers a reload on the target instance directly).
- [ ] "Capture selected item / time-selection into bank + assign to active instance"
action (`command_id`/`gaccel`/`hookcommand`, MIDI-bindable): reuse the existing capture
request path (`CountSelectedMediaItems`/`GetSelectedMediaItem` + `GetSet_LoopTimeRange`
as the capture inputs), add the resulting `Sample` to the active bank, then assign its
id to the target instance. **Never inserts a timeline item** (capture/placement stay
separate — the assignment is a bank-index + instance-selection act, not a placement).
- [ ] Media Explorer import → bank → assign: read the Media Explorer's current selection
via `MediaExplorerGetLastPlayedFileInfo` (path + selection range), import the file into
the bank (existing import/capture add-path), assign to the target instance. **Honest SDK
limit (verified against the vendored headers):** the Media-Explorer surface is thin —
`OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the *one*
last-played/selected file + its range) are the whole contract; there is **no**
enumerate-selected-files and **no** register-a-drop-handler-on-the-Media-Explorer API.
So ME import is *single-file, pull-on-action* (an action the user fires while a file is
selected in the ME), not a push/drop from inside the Media Explorer. **Spike:** confirm
`MediaExplorerGetLastPlayedFileInfo` returns a usable path+range for a merely-*selected*
(not-yet-played) file, or whether a play is required first.
- [ ] Drag-and-drop onto ReaSampler surfaces: accept an OS file drop onto the docked
`bank_panel` (and its bank/tab regions) → ingest into the bank → assign. **Honest SDK
limit (verified):** REAPER exposes **no** drag-drop registration API; drop handling is on
ReaSampler's *own* HWNDs via SWELL/Win32 (`WM_DROPFILES` / an `IDropTarget` on the panel
HWND), the same surface the panel already owns. **Assess-and-flag (spike, do not promise
here):** a drop *onto the VST3 editor window* — whether the `IPlugView` HWND can accept an
OS file drop and relay it to the extension as a bank-ingest request (the instrument does
**not** ingest; it forwards a request to the extension over an agreed seam). Reported
honestly as a spike because it crosses the two-artifact boundary and the relay mechanism
is unproven; if it proves gnarly, drop-onto-panel is the shipped path and drop-onto-editor
is deferred.
- [ ] "Assign to instance" seam: how the ingest action names the target instance and hands
it the new sample id. Lean (build-time residual, not a fork): the active/last-focused
instance is the target, discovered via the host context the bridge already resolves; the
assignment is the same instance-owned selection state S4 already persists, so a reload
picks it up. If the change-detection seam (S9) exists, assignment refreshes hands-free;
without it, the ingest action pokes the target instance's reload directly.
## S9 — bank-generation change-detection (recapture / ingest refreshes instances hands-free)
**Goal:** Because instances reference sample **ids**, a **recapture** (M10) landing under
the same id — or an **ingest** (S8) touching the active bank — should refresh playing
instances **hands-free**, without the user re-opening each editor. Add a **bank-generation
counter** to `"reasampler"` ext-state that the extension bumps on any bank-content
mutation, and that the instrument polls off the audio thread on a safe cadence, calling its
existing `reloadFromBank()` when the generation changes. CONTEXT.md §Phase S
(bank-generation seam). **Closes the missing change-detection trigger the recapture
auto-update story needs.**
**Verify (in DAW):** a recapture that regenerates a sample already assigned to a live
instance refreshes that instance's playback within a bounded cadence, no editor re-open; an
ingest (S8) that updates the active bank likewise refreshes assigned instances; the poll
runs off the audio thread (never in `process`) and triggers the existing off-thread reload
path; instances not referencing a changed sample do not audibly glitch (reload is atomic —
the S4 graveyard-reclaim handoff); a project with no generation stamp (pre-S9) defaults
cleanly (treated as generation 0; first bump refreshes).
**Depends on:** S4 (the off-thread `reloadFromBank` + atomic handoff this drives). Writer
side is extension-only and independent of S8; consumed by S8 and M10 recapture. Best landed
alongside S8.
- [ ] Writer (extension): a monotonic **bank-generation counter** stamped into
`"reasampler"` ext-state (new `ext_keys.h` constant — forever-stable spelling), bumped
on every bank-content mutation that changes what an instance would play (capture add,
recapture-in-place, sample-remove, move/copy affecting the active bank). Additive to the
persist blob; defaults to 0 for projects saved before the stamp exists.
- [ ] Reader (instrument): poll the generation over the bridge on a safe **off-audio-thread
cadence** (a UI/timer tick, not `process`), compare to the last-seen value, and call the
existing `reloadFromBank()` on change — reusing S4's atomic pointer-swap handoff so a
refresh mid-play does not glitch. No new audio-thread work; no allocation in `process`.
- [ ] Cadence + coalescing: pick a poll interval that is responsive but cheap (build-time
residual — a low-frequency UI timer, coalescing multiple bumps between polls into one
reload). **Must-verify before build:** that a bridge ext-state read on the instrument's
UI/timer thread is safe against a concurrent extension write (the read already tolerates a
stale value by design — it reloads on the *next* poll; confirm no torn-read hazard for the
single integer generation key).
## S17 — drop-and-load: drag a capture onto a track's FX button → instantiate ReaSampler 9000 with the capture loaded
**Goal:** Turn a bank capture into a playable instrument in one gesture. Today a drag
out of the `bank_panel` becomes an OS file drag once it leaves the panel (M11 —
`drag_out` + `drag_out_win`, CF_HDROP). This wave adds a **second, internal drag mode**:
while a capture is dragged, a track's TCP **FX button** lights as a drop zone, and
dropping there instantiates a **ReaSampler 9000** (the Phase S VST3 sampler) on that
track with the dragged capture **already loaded and selected** for playback. The
extension drives the whole gesture itself — REAPER's FX button is not a native
plugin-with-file drop target, so this cannot ride the CF_HDROP path. CONTEXT.md §Phase S
(drop-and-load — internal-drag hover mode + the FX-button drop → add-VST + load-capture
seam). Product framing: `docs/product/midi-playback.md` (drop-and-load — the third
integration gesture).
**Consistent with the load-bearing principle (make the reasoning explicit):** this is an
**explicit, user-driven placement gesture** — the user is deliberately choosing to place
a playing instrument on a track, exactly as inserting an item into the arrange is a
deliberate placement act. It does **not** auto-capture (the file already exists in the
bank) and does **not** insert a media item into the timeline; it instantiates a *reader*
of the bank on a track and points it at one already-captured sample. Capture, placement,
and playback stay distinct acts; drop-and-load is a placement-of-the-player gesture, not
a capture and not a timeline insert.
**Two-part mechanism:**
- **(a) Internal-drag hover mode.** A drag armed with a *single* capture that stays
*inside* REAPER's own UI (does not cross to Explorer / another app) is tracked by the
extension: it detects the pointer hovering a track's TCP FX button, highlights it as a
drop target, and on release drives the insert. This is a *third* `DragGesture` beyond
the existing `Internal` (bank-to-bank) and `OsDrag` (M11) — call it `InstrumentDrop`.
- **(b) FX-button drop → add-VST + load-capture.** On drop, the extension adds a
ReaSampler 9000 instance to the target track via `TrackFX_AddByName` (verified present
in `reaper_plugin_functions.h`; signature
`int TrackFX_AddByName(MediaTrack*, const char* fxname, bool recFX, int instantiate)`
use `"VST3:ReaSampler 9000"` and a negative `instantiate` to always create a new
instance), then pushes the dragged capture's identity into that instance so it plays
that sample — via the **load-capture seam** (below).
**The ReaSampler 9000 load-capture seam (the hard Phase S coupling — MUST be added):**
The current Phase S spec gives the instrument a *live-state read* seam (it reads the bank
index + mapping from `"reasampler"` ext-state via the bridge) but **no entry point for an
external actor to say "instantiate playing *this specific* capture."** This wave is the
reason to add that seam. It is a Phase S dependency, not extension-side, and must land in
the instrument before drop-and-load's drop half can work end-to-end. See CONTEXT.md
§Phase S for the two candidate seam mechanisms (fresh-instance ext-state handshake vs.
VST3 `setState` preset injection) and the open question on which is chosen.
**Coexistence with the M11 OS drag-out (disambiguation, load-bearing):** the two drag
modes are disambiguated by **where the pointer goes**, not by a mode toggle. Inside the
panel client rect → `Internal` (unchanged). Left the panel but still over REAPER's own
window/UI → `InstrumentDrop` (new — hover-tracks the FX button). Left REAPER entirely
(Explorer / another app) → `OsDrag` (unchanged M11). The M11 `decideGesture` boundary
(pointer left the client rect) is **refined**, not replaced: leaving the client rect no
longer immediately means OS-bound; it means "resolve which of InstrumentDrop / OsDrag by
whether the pointer is over REAPER's UI." Single-capture vs. multi-capture also
disambiguates — see open questions.
**Verify (in DAW):** dragging a single capture from the dock over a track's FX button
highlights it; dropping instantiates ReaSampler 9000 on that track with the dragged
capture loaded, selected, and MIDI-playable immediately (no manual pick step); the OS
drag-out to Explorer / another DAW still works unchanged; the internal bank-to-bank drag
still works unchanged; no media item is ever inserted into the arrange; the instrument
holds no private copy (it reads the one authoritative bank).
**Depends on:** M11 (`drag_out` gesture machinery — the mode it extends); **Phase S S4**
(a loadable, playing ReaSampler 9000 instance must exist) **AND the new load-capture seam
added inside ReaSampler 9000**. Composes with — but is distinct from — **S8** (ingest
through the bank: capture/import/drop *into* the bank) and **S13** (drop-to-load *inside*
the editor). S17 is the third integration gesture: drop *onto a track's FX button* to
instantiate a player. Gated on the rest of Phase S; the instrument seam is a Phase S
artifact, not extension-only.
- [ ] Extend the `drag_out` pure module with the third gesture: `decideGesture` (or a
successor) returns `InstrumentDrop` when a drag armed with a single capture is over
REAPER's UI outside the panel client rect, `OsDrag` only when it has left REAPER
entirely, `Internal`/`OsDrag`/`None` otherwise unchanged. Pure over (drag state +
pointer + panel rect + an "over-own-UI" predicate the shell supplies). Unit-tested —
the existing `drag_out` invariants (M11) must not regress.
- [ ] Shell (extension): hover-track the pointer over REAPER's UI during the drag,
resolve the hovered track + its FX button (verify the TCP/FX-button hit surface against
the SDK — see must-verify), highlight it as a drop target, and on release drive the
drop. Extends the `bank_panel` drag hook alongside the M11 `drag_out_win` path.
- [ ] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:ReaSampler 9000",
false, /*instantiate*/ negative)` to always add a fresh instance; capture the returned
FX index; then invoke the load-capture seam to point the new instance at the dragged
capture. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the
whole gesture is one Ctrl-Z (mirrors the bank-verb undo discipline).
- [ ] **ReaSampler 9000 (Phase S artifact):** add the **load-capture seam** — the entry
point that lets the just-added instance be told which capture to play (mechanism chosen
per the CONTEXT.md open question). This is the cross-artifact half; it lands in the
instrument, not the extension.
- [ ] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across
single- and multi-capture payloads; FX-button hit resolution (pure geometry where it
can be factored out); M11 OS drag-out and internal bank-to-bank drag both unchanged.
> **Numbering reconciliation (resolved on merge to dev).** This wave was authored on
> **dev** as a provisional **S7** while dev's Phase S ran only S1S6. On merge with the
> `phase-s` worktree, the worktree's authoritative numbering (S7 stereo, S8 ingest, S9
> change-detection, S10S16) took the lower labels, so drop-and-load was renumbered to
> **S17** — the next free label past the worktree's Phase S set. It stays a distinct
> integration gesture (drop onto a track's FX button), a sibling of but not the same as
> S8 (ingest into the bank) and S13 (drop-to-load inside the editor).
## Phase S — held and optional-forever (noted, not specified)
- **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun),
full ADSR, per-sample tuning/gain trim, sustain loops. The next depth increment once
@@ -273,6 +497,32 @@ build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle agains
`0xdeadf00d`/`0xdeadf00e` opcodes are VST2-only and do not apply.
- **`IReaperUIEmbedInterface`** — embed contract + message/lifecycle, against
`reaper_plugin_fx_embed.h` (needed only at S6).
- **VST3 bus arrangement (S7)** — `setBusArrangements` / `getBusArrangement` and REAPER's
mono/stereo instrument-bus expectations, against the vendored Steinberg SDK +
`reaper_vst3_interfaces.h`. The channel-mode toggle depends on the output bus
re-negotiating cleanly.
- **Media Explorer surface (S8)** — confirmed thin against the vendored headers:
`OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the one
last-played/selected file + range) are the whole contract; **no** enumerate-selected and
**no** ME-drop-handler API. Spike: does `MediaExplorerGetLastPlayedFileInfo` return a
usable path+range for a merely-selected (not-yet-played) file?
- **Drop targets (S8)** — REAPER exposes **no** drag-drop registration API (verified); drop
handling is on ReaSampler's own panel HWNDs via SWELL/Win32 (`WM_DROPFILES` / `IDropTarget`).
Drop-onto-VST3-editor relayed as a bank-ingest request is an **unproven cross-artifact
spike**, not a promise.
- **Bank-generation ext-state read (S9)** — confirm no torn-read hazard on the single
integer generation key for a bridge read on the instrument's UI/timer thread concurrent
with an extension write.
- **Drop-and-load (S17) — three surfaces.** (1) `TrackFX_AddByName` — **verified present**
in `reaper_plugin_functions.h` (signature confirmed; the `"VST3:"` name prefix and the
negative-`instantiate`-always-adds semantics are documented in the header comment). (2)
**TCP / FX-button hit resolution** — how the extension resolves the pointer-under-cursor
to a track and its FX-button hotspot during a drag: **not yet confirmed against the
SDK/SWELL** — `GetTrackFromPoint` / `GetThingFromPoint` are candidates to verify against
`reaper_plugin_functions.h`; whether the FX button specifically is addressable (vs. the
TCP as a whole) is an open verification. (3) The **ReaSampler 9000 load-capture seam** —
a *new* interface added inside the Phase S instrument; its mechanism is a Phase S design
choice (see CONTEXT.md §Phase S open question), not a pre-existing SDK surface.
---