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