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
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)
- **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
message/lifecycle contract — verify against
`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
`IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event
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
- **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
authoritative bank; per-instance sample stores are a non-goal (they refork the source
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
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 /
@@ -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
M/D/B/R/V pillars.
- **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
headers before use — several §1a claims are experienced estimates until the spike
confirms them.