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
+264 -1
View File
@@ -1289,6 +1289,109 @@ someday-note. It is polish, not a Tier-0 need, so it sequences last in the phase
is on the roadmap. **Must-verify before build:** the `IReaperUIEmbedInterface` contract is on the roadmap. **Must-verify before build:** the `IReaperUIEmbedInterface` contract
and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`. and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`.
## Channel mode — mono | stereo (D-E, decided 2026-07-26; PLAN.md S7)
**Decided direction: the instrument gets a per-instance channel-mode toggle — 1 (mono) or
2 (stereo) — that negotiates the REAPER audio bus automatically.** Captures are often
stereo; the current mono downmix is a Tier-0 simplification, not a permanent shape.
- **Mono mode keeps today's path.** The decode-side downmix stands: a stereo source in
mono mode downmixes (the existing policy), a mono source plays as-is. No engine change
for mono.
- **Stereo mode is an S3-core extension, not a shell hack (honest).** The S3 core is
**mono-per-sample by design today** — `SampleData::frames` is one mono stream,
`Voice::renderFrame` returns a single value, `VoiceEngine::render` writes one channel.
Stereo mode grows the core a **channel dimension**: 2-channel decoded PCM, per-voice
**stereo** render (per-channel fractional read + linear interpolation + loop), and a
per-channel mix in the engine. Mono stays the degenerate (single-channel) case, so
existing mono behavior is unchanged. This is why S7 sequences first after the editor/embed
work: it touches the engine Daniel smoke-tests.
- **Where the toggle lives.** Per-instance component state (setState/getState), alongside
the selected sample — a **performance choice the instrument owns**, never written to the
bank (D-B: not a file fact). Default preserves current behavior (mono).
- **Cross-mode policy (settled).** Mono source + stereo mode → **dual-mono** (same signal
both channels, centered). Stereo source + mono mode → **downmix** (the existing
decode-side policy). The bank's per-sample channel-count intrinsic (already on `Sample`)
tells the shell how many channels to decode into `SampleData`.
- **Bus negotiation (the "works with the REAPER bus automatically" requirement).** The VST3
implements `setBusArrangements` so the output bus reports mono or stereo per the
instance's channel mode, and REAPER's routing follows without 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`.
## Ingest through the bank — the extension owns ingest (decided "option 1", 2026-07-26; PLAN.md S8)
**Decided: loading a sample into the sampler is ONE gesture — capture/import-into-bank AND
auto-assign to the active sampler instance — and the *extension* owns it.** The instrument
stays a **read-only bank consumer**; it never captures and never imports. The extension is
the right owner: it has arrange access, Media-Explorer access, and the drop-target surface
on its own docked panels. Ingest lives in the *extension* codebase (actions + `bank_panel` +
capture/import add-path), routing through the existing capture add-path and the live
`"reasampler"` seam the instrument already reads.
**The three ingest surfaces, with the honest SDK reality (verified against the vendored
headers):**
- **Arrange capture → bank → assign.** A one-click action captures the selected item /
time-selection into the bank (reusing the existing capture request path —
`CountSelectedMediaItems` / `GetSelectedMediaItem` + `GetSet_LoopTimeRange` are already the
capture inputs) and assigns the resulting `Sample` id to the target instance. **It never
inserts a timeline item** — the capture/placement separation is load-bearing; assignment
is a bank-index + instance-selection act, not a placement.
- **Media Explorer import → bank → assign.** The Media-Explorer surface is **thin**:
`OpenMediaExplorer` (open/select a file) and `MediaExplorerGetLastPlayedFileInfo` (read the
*one* last-played/selected file path + its selection range/pitch/vol/rate) 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 fired
while a file is selected in the ME — not a push/drop from inside the ME. **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.** REAPER exposes **no** drag-drop registration
API. Drop handling is on ReaSampler's *own* HWNDs via SWELL/Win32 (`WM_DROPFILES` /
`IDropTarget` on the docked `bank_panel` HWND — the surface the panel already owns) → ingest
→ assign. **Assess-and-flag (spike, not promised):** 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 over an
agreed seam). This crosses the two-artifact boundary and the relay is unproven; if gnarly,
drop-onto-panel is the shipped path and drop-onto-editor is deferred.
**The assign seam.** The ingest action names the target instance (lean: the active/
last-focused instance, discovered via the host context the bridge already resolves) and hands
it the new sample id — the same instance-owned selection state S4 already persists, so a
reload picks it up. With the change-detection seam (below) the assignment refreshes
hands-free; without it the ingest action pokes the target instance's reload directly.
**Guardrail (load-bearing, restated):** ingest is an *extension* act. Any instrument code
path that captures, imports, inserts a timeline item, or writes back into the bank is a bug —
the instrument reads and plays only.
## Bank-generation change-detection — hands-free refresh (decided 2026-07-26; PLAN.md S9)
Instances reference sample **ids**. So a **recapture** (M10) landing under the same id — or
an **ingest** (S8) touching the active bank — should refresh playing instances **hands-free**,
without re-opening each editor. The missing trigger: a **bank-generation counter** in
`"reasampler"` ext-state.
- **Writer (extension).** A monotonic **bank-generation counter**, stamped into
`"reasampler"` ext-state under a new forever-stable `ext_keys.h` constant, 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, **never** `process`), compare to the last-seen value, and call
the existing off-thread `reloadFromBank()` on change — reusing S4's atomic pointer-swap
handoff (graveyard-reclaim) so a mid-play refresh does not glitch. No new audio-thread work;
no allocation in `process`.
- **Cadence + safety.** A low-frequency UI timer, coalescing multiple bumps between polls into
one reload (build-time residual). The read already tolerates a stale value by design (it
reloads on the *next* poll). **Must-verify before build:** 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.
This seam serves **both** S8 ingest and M10 recapture; the writer side is extension-only and
independent of S8, so it can land alongside either.
## REAPER / Steinberg API surface (verify all signatures) ## REAPER / Steinberg API surface (verify all signatures)
- **VST3 SDK (a new vendored dependency — vendor it at the spike).** `FUnknown` and the - **VST3 SDK (a new vendored dependency — vendor it at the spike).** `FUnknown` and the
@@ -1307,10 +1410,154 @@ and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embe
- **Embedded UI (D-D, later point).** `IReaperUIEmbedInterface` and the embed - **Embedded UI (D-D, later point).** `IReaperUIEmbedInterface` and the embed
message/lifecycle contract — verify against message/lifecycle contract — verify against
`vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h` before use. `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h` before use.
- **VST3 bus arrangement (S7 channel mode).** `setBusArrangements` /
`getBusArrangement` and REAPER's mono/stereo instrument-bus expectations — verify against
the vendored Steinberg SDK + `reaper_vst3_interfaces.h`.
- **Ingest surfaces (S8).** `InsertMedia` is the placement path (untouched by ingest);
`CountSelectedMediaItems` / `GetSelectedMediaItem` + `GetSet_LoopTimeRange` are the
arrange-capture inputs (already the capture path's); `OpenMediaExplorer` +
`MediaExplorerGetLastPlayedFileInfo` are the *whole* Media-Explorer contract (thin — no
enumerate-selected, no ME-drop-handler). Drop handling is SWELL/Win32 on ReaSampler's own
panel HWNDs — REAPER exposes **no** drag-drop registration API. All verified against
`reaper_plugin_functions.h`.
- **Bank-generation seam (S9).** New forever-stable `ext_keys.h` key for the generation
counter; read over the same bridge `GetProjExtState` path S4 already uses. No new API —
confirm no torn-read hazard on the integer key.
- **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the - **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the
`IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event `IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event
routing) — the least-trodden edge of the phase. routing) — the least-trodden edge of the phase.
## Drop-and-load — drag a capture onto a track's FX button (S17 spec)
**The gesture.** While a capture is dragged out of the `bank_panel`, a track's TCP **FX
button** becomes a drop zone. Dropping the capture there **instantiates a ReaSampler 9000
on that track with the dragged capture already loaded and selected for playback** — one
gesture from bank to playable instrument. This is the *third* integration gesture: capture
(extension), placement-into-arrange (extension), and now **placement-of-the-player**
(this wave). It is drop-and-load, not drop-to-arrange — no media item touches the timeline.
**Why it needs a new drag mode (the CF_HDROP path can't carry it).** Today's drag-out
(M11) becomes an **OS file drag** (`CF_HDROP` via `drag_out` + `drag_out_win`) the moment
the pointer leaves the panel client rect. REAPER's TCP FX button is **not** a native drop
target that instantiates a plugin-with-a-file, so this feature cannot ride the OS-drag
path: an OS drop of a WAV onto the FX area does not create "an instrument preloaded with
that WAV." It requires an **internal drag** where the extension itself tracks the pointer
over REAPER's own UI, detects the FX-button hover, and on release **drives the insert
itself**. The extension is the actor for the whole gesture.
**The two-part mechanism.**
1. **Internal-drag hover detection (extension-side, pure + shell).** The `drag_out` pure
module gains a **third `DragGesture`** beyond `Internal` (bank-to-bank) and `OsDrag`
(M11) — `InstrumentDrop`. The gesture decision is refined: leaving the panel client
rect no longer *immediately* means OS-bound. Instead:
- Pointer **inside** the panel client rect → `Internal` (unchanged bank-to-bank drag).
- Pointer **outside the panel but still over REAPER's own window/UI** →
`InstrumentDrop` (new — the shell hover-tracks the TCP FX button and highlights it).
- Pointer **left REAPER entirely** (Explorer / another app) → `OsDrag` (unchanged M11).
The pure module stays REAPER-free: it decides `InstrumentDrop` vs. `OsDrag` from
position **plus an "over-REAPER's-own-UI" predicate the shell supplies** (the shell owns
the REAPER window/hit query; the pure layer owns the set/boundary algebra). Mirror of how
M11 kept `decideGesture` pure over a rect the shell supplied. The shell then resolves the
pointer to a track + FX-button hotspot, highlights it, and on release drives the drop.
2. **FX-button drop → add-VST + load-capture (extension-side shell, then instrument
seam).** On release over an FX button the shell:
- Adds a fresh instance: `TrackFX_AddByName(track, "VST3:ReaSampler 9000", /*recFX*/
false, /*instantiate*/ <negative>)`. **Verified present** in
`reaper_plugin_functions.h`:
`int TrackFX_AddByName(MediaTrack* track, const char* fxname, bool recFX, int
instantiate)` — a **negative** `instantiate` always creates a new effect (per the
header comment); the `"VST3:"` prefix selects the format. Captures the returned FX
index (or `-1` on failure).
- **Loads the dragged capture into that instance via the load-capture seam** (below).
- Wraps the whole thing in one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the
gesture is one Ctrl-Z — the same discipline the bank verbs use.
**The ReaSampler 9000 load-capture seam (the hard coupling — MUST be added; does not yet
exist).** The Phase S spec today gives the instrument a **live-state *read* seam** (it
reads bank index + mapping from `"reasampler"` ext-state via the bridge — §The two seams)
but **no entry point for an external actor to say "this fresh instance should play *this
specific* capture."** Reading the bank is not the same as being *pointed at one sample*.
This wave is the reason to add that seam, and the seam lands **inside the instrument**
(the `phase-s` artifact), not the extension. Two candidate mechanisms — **the choice is a
Phase S open question:**
- **(A) Fresh-instance ext-state handshake.** The extension writes a small "pending load"
hint into `"reasampler"` ext-state keyed to the target track/FX (e.g. the capture id +
a target GUID); a freshly-instantiated ReaSampler 9000 reads it on init via the same
bridge it already uses, claims + clears the hint, and self-selects that capture. **Pro:**
reuses the existing bridge; no new VST3 surface. **Con:** a cross-process handshake with
a claim/clear race to get right; "which instance claims which hint" needs a stable key.
- **(B) VST3 `setState` / preset injection.** The extension builds the instance's
component state (the same blob `getState`/`setState` round-trips) with the capture
pre-selected and injects it right after `TrackFX_AddByName`. **Pro:** deterministic, no
shared-state race, uses the instrument's own persistence format. **Con:** the extension
must know and construct the instrument's state blob format — a tighter cross-artifact
coupling to a format that is itself still being built in Phase S; verify whether the
extension can set an added FX's state through the REAPER API (candidate:
`TrackFX_SetNamedConfigParm` / a state-set path — **not yet confirmed against
`reaper_plugin_functions.h`**).
Lean: **(A)** keeps the artifacts loosely coupled through the one ext-state seam they
already share and avoids the extension hard-coding the instrument's state format — but
it inherits the claim/clear race. Daniel to decide (open question below).
**Coexistence with the OS drag-out (disambiguation contract).** The two OS-vs-internal
modes are disambiguated **by pointer location, not a mode toggle** — the user never picks
"OS drag" vs. "instrument drop"; the extension infers it from where the pointer is when
released. The M11 boundary (left the client rect) is *refined*, not replaced: leaving the
rect now asks "over REAPER's UI → InstrumentDrop, else → OsDrag." Both M11 OS drag-out and
the internal bank-to-bank drag must remain **byte-for-byte unchanged** in their own
regions — this wave only inserts a new middle case. Multi-capture payloads are a
disambiguation input too (see open question — instrument drop is naturally single-capture;
a multi-capture drag over the FX button is either rejected or loads the first).
**Precision / invariant implications (drop-and-load).**
- **Explicit user-driven placement — consistent with capture↔placement separation.** This
is a *deliberate placement gesture*: the user chooses to put a playing instrument on a
track, exactly as inserting an item into the arrange is a deliberate 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 three distinct acts; this
is placement-of-the-player, not a capture and not a timeline insert.
- **No private sample copy.** The instantiated instrument consumes the one authoritative
bank (it resolves the WAV via the shared M4 project-relative machinery like any
ReaSampler 9000 instance); the seam hands it a *reference* (a capture identity), never a
copied file. Any path that copies bytes into the instance is a bug.
- **The internal drag stays pure-decidable and testable.** The new `InstrumentDrop`
gesture is decided in the `drag_out` pure module (REAPER-free) over a shell-supplied
predicate; the M11 `drag_out` unit tests must not regress.
**Open questions (Daniel / Phase S team to decide).**
- **Load-capture seam mechanism (A vs. B above).** The single load-bearing Phase S design
choice this wave forces. Lean is (A) for loose coupling; needs Daniel's call.
- **Multi-capture drag over an FX button** — reject (only single-capture drags arm
`InstrumentDrop`), or load the first / a keymap of all? Tier-0 leans reject-or-first;
a multi-capture keymap load is a Tier-1 stretch.
- **FX-button hotspot vs. whole TCP.** Does the drop zone have to be the FX button
specifically, or is dropping anywhere on the target track's TCP enough (simpler hit
resolution, arguably clearer target)? Depends on what the SDK exposes (see must-verify).
- **Numbering vs. the `phase-s` worktree (reconciled on merge to dev).** Authored on dev
as a provisional S7; renumbered to **S17** on merge, since the worktree's authoritative
Phase S set (S7 stereo, S8 ingest, S9 change-detection, S10S16) took the lower labels.
Drop-and-load 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). See PLAN.md §S17.
**Must-verify before build (drop-and-load).**
- `TrackFX_AddByName` — **verified present** (`reaper_plugin_functions.h`): signature and
the `"VST3:"`-prefix + negative-`instantiate` semantics confirmed from the header.
- **Pointer→track / FX-button hit resolution during a drag** — **not yet confirmed.**
Candidates: `GetTrackFromPoint` / `GetThingFromPoint` (verify names + signatures against
`reaper_plugin_functions.h`); whether the FX button specifically is addressable vs. the
TCP as a whole is an open verification that also decides the "hotspot vs. whole TCP"
question.
- **Instance state injection (only if seam mechanism (B) is chosen)** — whether the
extension can set a just-added FX's state via the REAPER API
(`TrackFX_SetNamedConfigParm` or similar) — **not yet confirmed against
`reaper_plugin_functions.h`.** Moot if (A) is chosen.
## Non-goals / guardrails ## Non-goals / guardrails
- **The instrument never captures and never inserts into the arrange.** Playback is a - **The instrument never captures and never inserts into the arrange.** Playback is a
@@ -1319,6 +1566,13 @@ and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embe
- **The instrument keeps no private copy of the samples.** It consumes the one - **The instrument keeps no private copy of the samples.** It consumes the one
authoritative bank; per-instance sample stores are a non-goal (they refork the source authoritative bank; per-instance sample stores are a non-goal (they refork the source
the one-source-multiple-views instinct keeps single). the one-source-multiple-views instinct keeps single).
- **The instrument never ingests (S8).** Capture, import, and drop-ingest are *extension*
acts; the instrument only reads and plays. A drop onto the editor window (if the spike
proves it viable) is *relayed to the extension* as an ingest request — the instrument
never writes the bank itself.
- **Channel mode is a performance choice, not a bank fact (S7).** The mono/stereo toggle is
per-instance component state, never written to `Sample` or the bank (D-B). The bank's
per-sample channel-count intrinsic is a *file fact*; the play mode is the instrument's.
- **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do - **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do
not add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target. not add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
- **The pure core stays REAPER-free *and* VST3-free.** The voice engine / envelope / - **The pure core stays REAPER-free *and* VST3-free.** The voice engine / envelope /
@@ -1329,7 +1583,16 @@ and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embe
else in Phase S lives in the *second* artifact and does not alter the extension's else in Phase S lives in the *second* artifact and does not alter the extension's
M/D/B/R/V pillars. M/D/B/R/V pillars.
- **Do not spec Tier 2/3.** Tier 2 is held (noted, not specified); Tier 3 is - **Do not spec Tier 2/3.** Tier 2 is held (noted, not specified); Tier 3 is
optional-forever. Do not let their feature lists drive Tier 01's build shape. optional-forever. Do not let their feature lists drive Tier 01's build shape. **Note:**
S7 stereo is *not* a Tier-2 feature — it is a channel-count dimension on the existing
Tier 01 engine, orthogonal to Tier 2's velocity-layers / round-robin / per-sample trim.
(S7's stereo loop read is the same loop the core already has, extended per-channel — not
the Tier-2 "sustain loops" feature.)
- **Drop-and-load must not regress the two existing drags.** S17 inserts a new middle case
(`InstrumentDrop`) between the M11 OS drag-out and the internal bank-to-bank drag; both
existing gestures stay byte-for-byte unchanged in their own regions. Drop-and-load never
inserts a media item into the arrange and never copies sample bytes into the instance —
it hands the new instance a *reference* to an already-captured bank sample.
- **Verify Steinberg SDK, bridge, embed, and LICE-view surfaces** against the vendored - **Verify Steinberg SDK, bridge, embed, and LICE-view surfaces** against the vendored
headers before use — several §1a claims are experienced estimates until the spike headers before use — several §1a claims are experienced estimates until the spike
confirms them. confirms them.
+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 - [ ] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects
the live keymap/levels. 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) ## Phase S — held and optional-forever (noted, not specified)
- **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun), - **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 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. `0xdeadf00d`/`0xdeadf00e` opcodes are VST2-only and do not apply.
- **`IReaperUIEmbedInterface`** — embed contract + message/lifecycle, against - **`IReaperUIEmbedInterface`** — embed contract + message/lifecycle, against
`reaper_plugin_fx_embed.h` (needed only at S6). `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.
--- ---
+62 -4
View File
@@ -609,6 +609,55 @@ in-phase later point on the Phase S roadmap (**S6**), sequenced *after* the main
`IPlugView` editor exists (it composes with that LICE path), not a someday-note. It is `IPlugView` editor exists (it composes with that LICE path), not a someday-note. It is
polish rather than a Tier-0 need, so it sequences last — but it is on the roadmap. polish rather than a Tier-0 need, so it sequences last — but it is on the roadmap.
### Addendum — two directions set post-scoping (Daniel, 2026-07-26)
After Phase S was scoped (D-A..D-D), Daniel set two further directions. These are
**settled directions**, not open forks — specced as new Phase S points (S7S9), not
re-litigated. Recorded here per the doc's settled-decisions convention.
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ PLAN.md S7).**
Captures are often stereo; the current mono downmix is a Tier-0 simplification. The
engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with
the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus
arrangement (`setBusArrangements`) so mono/stereo just works in REAPER's routing. Honest
scope: **this is an S3-core extension, not a shell hack** — the core is mono-per-sample by
design today, so stereo mode grows a channel dimension (2-channel sample data, per-voice
stereo render, per-channel loop/interp). Mono mode keeps the existing downmix path.
Cross-mode policy: mono-source-in-stereo → dual-mono; stereo-source-in-mono → downmix
(existing). The toggle is instrument-owned per-instance state (D-B: a performance choice,
never a bank fact). Sequenced **first after the editor/embed work** because it touches the
engine Daniel smoke-tests.
**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md
S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
+ auto-assign to the active instance. The **extension owns ingest** (it has arrange
access, Media-Explorer access, and the drop-target surface on its own panels); the
**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts,
with the honest SDK reality verified against the vendored headers:
- *(a) Arrange capture → bank → assign* — a one-click action reusing the existing capture
path; **never inserts a timeline item** (capture/placement separation intact).
- *(b) Media Explorer import → bank → assign* — the ME surface is **thin**
(`OpenMediaExplorer` + `MediaExplorerGetLastPlayedFileInfo` are the whole contract; no
enumerate-selected, no ME-drop-handler), so ME import is **single-file, pull-on-action**,
not a push/drop from inside the ME. Spike: does the last-played-file read work for a
merely-*selected* file?
- *(c) Drag-and-drop* — REAPER exposes **no** drag-drop registration API; drop handling is
SWELL/Win32 on ReaSampler's *own* panel HWNDs. Drop *onto the VST3 editor window* relayed
to the extension as a bank-ingest request is a genuine **cross-artifact spike**, not a
promise (drop-onto-panel is the shipped path if it proves gnarly).
- *(d) Recapture / ingest auto-refresh (→ S9)* — because instances reference sample **ids**,
a recapture landing under the same id (M10) or an ingest touching the active bank should
refresh live instances **hands-free**. The missing trigger is a **bank-generation counter**
in `"reasampler"` ext-state: the extension bumps it on any bank-content mutation; the
instrument polls it **off the audio thread** on a safe cadence and calls its existing
`reloadFromBank()` on change (reusing S4's atomic handoff). This seam serves both S8 ingest
and M10 recapture.
*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the
ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both
are honestly-flagged as spikes in PLAN.md S8, not promised.
--- ---
## Where this landed ## Where this landed
@@ -626,10 +675,19 @@ into **Phase S** — a native VST3 sampler as a **second build artifact** alongs
4. **D-D → embedded TCP/MCP UI scheduled** (**S6**), after the main editor exists — on the 4. **D-D → embedded TCP/MCP UI scheduled** (**S6**), after the main editor exists — on the
roadmap, not deferred. roadmap, not deferred.
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1S6, sequenced by Two further directions set post-scoping (2026-07-26; see the Addendum in §4):
dependency order: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
UI); **CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the 5. **D-E → channel mode (mono | stereo), per-instance, bus-negotiated** (**S7**) — an
pure/shell split in the new artifact, the must-verify SDK/bridge surfaces). This doc is the S3-core channel-dimension extension, sequenced first after the editor/embed work.
6. **Ingest through the bank ("option 1"), extension-owned** (**S8**) + **bank-generation
hands-free refresh** (**S9**) — one-gesture capture/import + assign; the instrument stays
a read-only consumer.
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1S6 the original
dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
UI; then **S7** stereo, **S8** ingest, **S9** change-detection); **CONTEXT.md §Phase S** is
the spec (seam-field semantics, scope contracts, the channel-mode / ingest / bank-generation
contracts, the pure/shell split, the must-verify SDK/bridge surfaces). This doc is the
framing/decision record they point back to. The "no PLAN.md footprint" era is over. framing/decision record they point back to. The "no PLAN.md footprint" era is over.
--- ---