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:
+264
-1
@@ -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, S10–S16) 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 0–1's build shape.
|
||||
optional-forever. Do not let their feature lists drive Tier 0–1's build shape. **Note:**
|
||||
S7 stereo is *not* a Tier-2 feature — it is a channel-count dimension on the existing
|
||||
Tier 0–1 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.
|
||||
|
||||
Reference in New Issue
Block a user