10ceb95ddd
Settle forks D-A..D-D: bare Steinberg VST3 SDK + LICE editor; split seam with root-note/loop-points on Sample now; Tier 0-1 committed; embedded TCP/MCP UI scheduled. Phase S added to PLAN.md and CONTEXT.md spec.
660 lines
44 KiB
Markdown
660 lines
44 KiB
Markdown
# MIDI playback — opportunity & design-space framing
|
||
|
||
Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured
|
||
banks. This began as a discussion-shaping doc; with all forks now settled it has become
|
||
the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN.md
|
||
§Phase S** and its authoritative spec in **CONTEXT.md §Phase S** — this doc holds the
|
||
*why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled
|
||
decision record).
|
||
|
||
Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r4)**. The
|
||
"no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the
|
||
instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D"
|
||
collides with the existing Design View phase). **PLAN.md §Phase S is now the
|
||
authoritative roadmap; CONTEXT.md §Phase S is the authoritative spec.** This doc is the
|
||
framing/decision record they point back to. Prior revisions (a) established that a REAPER
|
||
*extension* cannot be a MIDI instrument, (b) corrected a material omission — REAPER's
|
||
**VST-host bridge**, which lets a VST3 plugin *hosted inside REAPER* call back into
|
||
REAPER's own API by resolving function pointers by name over the host callback — (c) [r3]
|
||
folded in **Daniel's locked decisions** (D1, D5, D6) and added the honest **bare-VST3
|
||
assessment** (§1a). This revision [r4] records **Daniel's calls on the four residual
|
||
forks D-A..D-D** (all settled 2026-07-26) and points to the now-live phase docs.
|
||
Grounded in
|
||
the vendored REAPER SDK headers (`vendor/reaper-sdk/sdk/reaper_plugin.h`,
|
||
`reaper_plugin_functions.h`, `video_processor.h`, `reaper_plugin_fx_embed.h`), REAPER's
|
||
published VST-extensions SDK page (`reaper.fm/sdk/vst/vst_ext.php`), and the Steinberg
|
||
VST3 SDK documentation (portal + class reference — cited inline in §1a). Where a claim is
|
||
**experienced estimate** rather than a **verified-from-source** fact, it is flagged as
|
||
such in §1a. The genuine remaining forks are flagged as **Daniel's to decide**; nothing
|
||
here pre-decides them.
|
||
|
||
> ## Locked decisions (SETTLED — do not re-present as open)
|
||
>
|
||
> Daniel has nailed these down. Everything downstream assumes them.
|
||
>
|
||
> - **D6 → DECIDED: two products, but integrated.** The instrument is a *separate*
|
||
> product/artifact from the ReaSampler extension, but *tightly integrated* via the
|
||
> VST-host bridge — it reads live `"reasampler"` project ext-state and is project-aware.
|
||
> **Not** a divorced, file-only companion. (The old §4 D6 reasoning is preserved below,
|
||
> marked decided.)
|
||
> - **D1 → DECIDED: native VST3.** JSFX is off the table. The instrument is a native VST3
|
||
> plugin. (The old JSFX option and its whole-doc entanglement are retired below, marked
|
||
> decided; the JSFX reasoning is kept as the record of *why* it was considered and set
|
||
> aside.)
|
||
> - **D5 → DECIDED: no cross-platform, no multiformat. REAPER-specific, Windows-only.**
|
||
> The existing extension is Windows-only; Daniel does not work on other platforms. So:
|
||
> **no mac/Linux DSP/build/signing matrix, no CLAP-for-portability argument, no
|
||
> "runs standalone in other hosts" concern.** REAPER-coupling via the bridge is fine and
|
||
> intended. This collapses several costs the prior draft carried (§1a, §4).
|
||
>
|
||
> **What these lock-downs do to the shape:** the doc is no longer weighing "extension vs.
|
||
> plugin," "JSFX vs. native," or "portable vs. coupled." It is weighing **how to build one
|
||
> native, Windows-only, REAPER-coupled VST3 instrument** — and the single biggest live
|
||
> question inside that is now **bare Steinberg VST3 SDK vs. JUCE** (§1a, D-A below).
|
||
|
||
> **The bridge, stated once, up front (the correction).** A hosted VST is not limited to
|
||
> scraping bank WAVs + JSON off disk. REAPER hands the plugin its host callback; calling
|
||
> `hostcb(&effect, 0xdeadbeef, 0xdeadf00d, 0, "FunctionName", 0.0)` resolves *any* REAPER
|
||
> API function by name — the same string-keyed API surface the extension uses (verified:
|
||
> `video_processor.h` line 44 imports `video_CreateVideoProcessor` exactly this way;
|
||
> `GetProjExtState`/`SetProjExtState`/`EnumProjExtState` are ordinary entries in that same
|
||
> string-keyed table, `reaper_plugin_functions.h` lines 8376/8796/9969). The plugin can
|
||
> also fetch its **host context** — the track/take/project it's instantiated in (sibling
|
||
> opcode `0xdeadf00e`, `video_processor.h` line 40; CLAP has `clap_get_reaper_context`,
|
||
> `reaper_plugin.h` line 142). **Consequence:** a native ReaSampler instrument can read
|
||
> the *same* `"reasampler"` project ext-state that `persist` writes — live, project-aware,
|
||
> following the active project — not a file it re-parses off disk. This is a
|
||
> REAPER-VST-specific capability (it exists because the plugin is hosted *in REAPER*), and
|
||
> it is the thumb on the scale the prior draft failed to weigh. The cost of leaning on it
|
||
> is stated honestly in D1/D2: it makes the native plugin **REAPER-coupled.**
|
||
|
||
---
|
||
|
||
## The need, stated plainly
|
||
|
||
ReaSampler today **captures and organizes** audio into per-project banks (on-disk
|
||
32-bit float WAVs + a JSON index, multi-bank via `bank_book`). It has **no way to
|
||
play those samples back from a MIDI keyboard.** The panel can *audition* a sample on
|
||
click — but a one-shot, one-voice audition is not an instrument. There is no
|
||
key→sample mapping, no velocity response, no polyphony, no envelope, no round-robin.
|
||
|
||
The ask: *what would let me play my captured banks MIDI-triggered, and what does the
|
||
REAPER surface actually allow toward that?*
|
||
|
||
The rest of this doc is organized as the task framed it: (1) the plugin-format
|
||
reality, honestly; (2) the integration seam between capture and playback; (3) product
|
||
shape and scope tiers; (4) the forks that are Daniel's to call.
|
||
|
||
---
|
||
|
||
## 1. The plugin-format reality (the honest part)
|
||
|
||
**The load-bearing fact: a REAPER extension cannot be a MIDI-triggered instrument.**
|
||
This is not a limitation we can engineer around by trying harder — it is what the
|
||
plugin *format* is. ReaSampler today is a `reaper_*.dll` loaded at startup via the
|
||
extension API (`ReaperPluginEntry`). That format gives an extension enormous reach
|
||
over the *project* — tracks, items, actions, rendering, project state — but it is
|
||
**not a node in any track's audio signal chain.** An instrument track's "read live
|
||
MIDI, emit audio per-voice" contract belongs to VST/VST3/CLAP/JSFX plugins, which
|
||
REAPER hosts through an entirely different mechanism.
|
||
|
||
What the extension SDK *does* expose near audio/MIDI, verified in the headers, and why
|
||
none of it is "be an instrument":
|
||
|
||
- **`Audio_RegHardwareHook` + `audio_hook_register_t::OnAudioBuffer`** (`reaper_plugin.h`
|
||
~1228). This is a real-time callback into the audio thread, called pre- and
|
||
post-REAPER-processing, handing you `GetBuffer(isOutput, idx)` for the **hardware
|
||
I/O** channels. It is a *hardware tap*, not a track insert. You could technically
|
||
mix sample playback into the hardware output buffer here — but you'd be bypassing
|
||
REAPER's entire routing, metering, recording, and rendering path. Output from this
|
||
hook **cannot be recorded, rendered, routed, or FX-processed** as track audio. It is
|
||
the wrong tool for an instrument by construction, and it runs in the audio thread
|
||
with all the real-time-safety hazards that implies. Rejected on sight for this
|
||
purpose — noting it only to be complete.
|
||
|
||
- **`kbd_OnMidiEvent` / `kbd_OnMidiList`** (`reaper_plugin_functions.h` ~4138). REAPER
|
||
*informs* the extension of MIDI events (for action-triggering — this is how MIDI can
|
||
fire the capture/insert actions we already plan to bind). It is **notification, not a
|
||
routable MIDI input** feeding a synth voice allocator. Great for "MIDI note fires the
|
||
capture-to-slot action"; useless as an instrument's note input.
|
||
|
||
- **`PlayPreview` / `PlayTrackPreview` + `preview_register_t`** (`reaper_plugin.h`
|
||
~1302). This is REAPER's **preview-playback** mechanism, and **ReaSampler already
|
||
uses it** — `bank_panel.cpp` streams a bank WAV through a caller-owned
|
||
`preview_register_t` for click-audition (`PCM_Source_CreateFromFile` +
|
||
`PlayPreview`). This is the one audio-emitting surface the extension already drives,
|
||
and it is load-bearing for the hybrid option below. But note its shape: it plays *a*
|
||
source at *a* position with *a* volume — it is a fire-and-forget preview, **not a
|
||
polyphonic, per-note, velocity-scaled, envelope-shaped voice engine.** You do not get
|
||
MIDI→voice allocation for free; you'd be building a sampler engine on top of a
|
||
preview primitive.
|
||
|
||
- **`pcmsrc` registration + `PCM_source` subclassing** (`reaper_plugin.h` ~804, the
|
||
`PCM_SOURCE_EXT_*` surface). An extension *can* register a custom `PCM_source` type.
|
||
This is the surface REAPER itself and extensions like SWS use for custom media. It is
|
||
a real seam — but it produces a *media source* (something an item plays), not a
|
||
*live-MIDI instrument*. Interesting for "a bank-backed media source" but not the
|
||
keyboard-instrument ask.
|
||
|
||
**Conclusion, stated for the record:** to get a genuine MIDI-triggered instrument —
|
||
play a note, hear the mapped sample, velocity-scaled, polyphonic, with an envelope —
|
||
the playback engine must be a **standard instrument plugin** (VST3 / CLAP / JSFX),
|
||
*not* the extension. The extension SDK is the wrong format for that job, and this is
|
||
the single most important thing for Daniel to internalize before scoping anything.
|
||
|
||
### The shape, now that D1/D6 are locked
|
||
|
||
The old three-way option set (native VSTi / JSFX / hybrid) has collapsed to a single
|
||
resolved shape:
|
||
|
||
**A separate native VST3 instrument that consumes ReaSampler's banks, tightly integrated
|
||
via the bridge.** The extension stays the sole owner of **capture + organization** (its
|
||
whole existing identity and the load-bearing "capture and placement are separate acts"
|
||
principle). The instrument is a *separate build artifact* — a native VST3 sampler the user
|
||
instantiates on an instrument track — that maps samples across the keyboard and plays them
|
||
MIDI-triggered with a real voice engine. It reaches ReaSampler's project state directly via
|
||
the VST-host bridge: it reads the live `"reasampler"` ext-state `persist` writes, knows its
|
||
own host project, and follows the active project. This is the "two products, but
|
||
integrated" shape D6 locked in. The extension never becomes an instrument; the instrument
|
||
never captures.
|
||
|
||
*What this gives:* everything an instrument is — polyphony, velocity, envelopes, the works,
|
||
fully in REAPER's routing/render/record path like any VSTi — **plus** live, project-aware
|
||
integration with the extension's state, not a divorced file-reader.
|
||
|
||
*What it costs:* a **second codebase**, the Steinberg VST3 SDK (or JUCE) as a dependency,
|
||
and a real DSP/voice-engine build — a product-sized commitment, not a feature. The
|
||
Windows-only + VST3-only + REAPER-only lock-downs (D5) *remove* the costs the prior draft
|
||
carried around cross-platform DSP, code-signing, and multi-format wrappers — a material
|
||
simplification. The remaining cost question is almost entirely **"how much scaffolding does
|
||
the VST3 surface demand, and do we take JUCE to get it"** — answered honestly in §1a.
|
||
|
||
> **JSFX — retired (D1 DECIDED: native VST3).** Prior drafts weighed a JSFX sampler
|
||
> (REAPER's built-in scriptable format: zero new binary, zero SDK, cross-platform free) as
|
||
> the cheap-prototype path. It is off the table. The reasons it lost, for the record: (1)
|
||
> JSFX is a constrained DSP scripting language — a polyphonic multisample engine with
|
||
> round-robin/velocity-layers/streaming is doable but fights the idiom, and large-sample
|
||
> disk streaming is awkward; (2) JSFX gets **no VST-host bridge**, so it could never read
|
||
> `persist`'s live `"reasampler"` ext-state — it would be a permanently file-coupled
|
||
> consumer needing a sidecar seam, which is exactly the *loose* companion D6 rejected; (3)
|
||
> its "pure core" would be JSFX code, outside the CTest harness (see D3). With D5 locking
|
||
> Windows-only, JSFX's one real edge — cross-platform-for-free — is worth nothing here.
|
||
> Native VST3 wins cleanly given the locks.
|
||
|
||
---
|
||
|
||
## 1a. How crazy is bare VST3 without JUCE? (the honest assessment)
|
||
|
||
Daniel's question, directly: *how crazy is it to comply with the VST3 surface without
|
||
something like JUCE?* Short answer: **not crazy — the audio-processing side is a
|
||
few-hundred-lines-of-ceremony-you-write-once problem, not a tar pit. The one genuine
|
||
question is the editor UI, and ReaSampler is unusually well-positioned to answer it
|
||
without JUCE.** The detail, honestly, with estimate-vs-verified flagged.
|
||
|
||
### What the raw Steinberg VST3 SDK actually demands
|
||
|
||
A working VST3 instrument must present these interfaces (all VST3 interfaces descend from
|
||
`FUnknown`, a COM-like base with `queryInterface` / `addRef` / `release` — **verified**,
|
||
Steinberg VST3 SDK class reference):
|
||
|
||
- **`IComponent`** — the plugin's identity and bus/state setup: `initialize`,
|
||
`setActive`, `getBusCount` / `getBusInfo`, `activateBus`, `setState` / `getState`.
|
||
- **`IAudioProcessor`** — the DSP contract: `setBusArrangements`, `setupProcessing`
|
||
(`ProcessSetup`: sample rate, block size, symbolic sample size), `setProcessing`, and
|
||
the hot path **`process(ProcessData&)`** — where you read MIDI events off the event
|
||
input bus and write audio to the output bus. For an *instrument* you declare an **event
|
||
input bus** (MIDI in) and an **audio output bus**, no audio input. (**Verified**:
|
||
`IAudioProcessor` reference; the instrument bus topology is standard.)
|
||
- **`IEditController`** — parameter model + editor: `getParameterCount` /
|
||
`getParameterInfo`, `getState` / `setState`, `setComponentState`, normalized↔plain
|
||
parameter conversion, and `createView("editor")` returning an `IPlugView` if you have a
|
||
GUI.
|
||
- **`IPluginFactory`** (via the module's exported **`GetPluginFactory`**) — enumerates the
|
||
classes the module offers (the processor and, in the two-component model, the
|
||
controller), keyed by class UIDs. On Windows the module also exports **`InitDll` /
|
||
`ExitDll`** (bundle entry points differ per-OS, but D5 makes Windows the only target, so
|
||
it's just these two plus `GetPluginFactory`). (**Experienced estimate** on the exact
|
||
Windows export names — the SDK's `dllmain.cpp` / `public.sdk` main glue provides these;
|
||
I have not re-read the header this session, so treat the exact symbol names as
|
||
to-verify-against-`public.sdk/source/main/` before build, not as a load-bearing claim.)
|
||
|
||
**The COM plumbing is real but bounded.** `queryInterface`/`addRef`/`release` plus the
|
||
class-factory macros (`BEGIN_FACTORY` / `DEF_CLASS2` / `END_FACTORY`, and the
|
||
`DECLARE_FUNKNOWN_METHODS` / `IMPLEMENT_REFCOUNT` helper macros) are **provided by the
|
||
SDK's `pluginterfaces` and `public.sdk` layers** — you do not hand-write refcounting; you
|
||
invoke macros. (**Experienced estimate** on the exact macro names — these are the
|
||
long-standing VST3 SDK factory macros; verify spelling against the vendored SDK headers at
|
||
build time.) This is the "ceremony you write once" — it is copy-adapt-from-the-example
|
||
work, not design work.
|
||
|
||
**And the SDK hands you base classes that absorb most of it.** The critical fact for
|
||
Daniel's question: you do **not** implement those four interfaces from scratch. The SDK's
|
||
`public.sdk` layer provides:
|
||
|
||
- **`AudioEffect`** (with `Component` / `AudioEffect` base) — implements `IComponent` +
|
||
`IAudioProcessor` boilerplate; you override `initialize` (declare busses), `setupProcessing`,
|
||
`setActive`, `setState`/`getState`, and `process`.
|
||
- **`EditControllerEx1`** — implements `IEditController` boilerplate; you override
|
||
parameter registration and state.
|
||
- **`SingleComponentEffect`** — **combines processor and controller into one class**
|
||
(descends from `EditControllerEx1` and the component hierarchy). You override
|
||
`initialize` (call `addAudioOutput` + `addEventInput`), `setupProcessing`, and
|
||
`process`. (**Verified**: Steinberg SDK `SingleComponentEffect` class reference — "default
|
||
implementation for a non-distributable Plug-in that combines processor and edit
|
||
controller in one component." The SDK cautions to prefer the two-component split for
|
||
distributable plugins, but for a **REAPER-only, non-distributable** instrument (D5/D6),
|
||
`SingleComponentEffect` is exactly the sanctioned shortcut and cuts the interface surface
|
||
roughly in half.)
|
||
|
||
**Honest quantification of the audio side:** with `SingleComponentEffect` + the factory
|
||
macros, a *silent-but-loading* VST3 instrument skeleton — factory, class registration,
|
||
module entry, bus setup, empty `process` — is on the order of **~200–400 lines of
|
||
adapt-from-example ceremony**, written once, then largely untouched. The AGain / Note
|
||
Expression Synth examples that ship with the SDK are exactly this skeleton and are the
|
||
copy-source. This is **not** a tar pit. The tar-pit reputation VST3 has comes from (a)
|
||
multi-format wrappers (AU/AAX/VST2 — **not our problem**, D5) and (b) the GUI, addressed
|
||
next. (**Experienced estimate** on the line-count band — grounded in the shape of the SDK
|
||
examples, not a line-counted measurement; treat as an order-of-magnitude honest estimate,
|
||
not a promise.)
|
||
|
||
### What JUCE buys — and whether ReaSampler needs it
|
||
|
||
JUCE exists largely to solve problems D5 has already deleted for us. Weighed against the
|
||
locked constraints:
|
||
|
||
| What JUCE provides | Do we need it, given the locks? |
|
||
|---|---|
|
||
| **Multi-format wrapper** (one codebase → VST3/AU/AAX/VST2/standalone) | **No.** Single format, VST3 only, REAPER only. This is JUCE's biggest reason to exist and it's moot here. |
|
||
| **Plugin boilerplate / `AudioProcessor`** | Marginal. The Steinberg SDK's `SingleComponentEffect` already absorbs the VST3 boilerplate; JUCE's abstraction sits *on top of* the same SDK. |
|
||
| **Parameter management** (`AudioProcessorValueTreeState`) | Nice-to-have. Genuinely convenient, but a sampler's parameter set (envelope, gain, tuning) is small; hand-rolling over `IEditController` is bounded. |
|
||
| **DSP utilities** (`juce::dsp`, filters, oscillators, interpolators) | Nice-to-have for Tier 3 (filters/LFOs). For Tier 0–2 the sampler DSP — repitch interpolation, envelopes, voice allocation — is exactly the pure core we *want* to write and test ourselves (D3). |
|
||
| **Editor/UI framework** (`juce::Component`, graphics, widgets) | **The one real pull.** VST3 gives you `IPlugView` and *no toolkit whatsoever*. Something has to draw the editor. This is the crux — see below. |
|
||
|
||
**Costs of JUCE, honestly:** it is a large dependency (a whole framework, not a library);
|
||
it brings its own build system (Projucer / CMake integration) and idioms that would sit
|
||
oddly beside ReaSampler's lean two-submodule CMake discipline; and its **licensing** is a
|
||
real standing commitment. JUCE 8 is **dual-licensed AGPLv3 or commercial**. The free tier
|
||
(Starter) is usable below **~$20K/yr revenue** and — notably in JUCE 8 — **no longer
|
||
requires a splash screen**. Above that, **Indie (~$200K/yr revenue limit) is a paid
|
||
license (~$3,500 as of JUCE 8)**, and Pro above that. (**Verified via web**, JUCE forum +
|
||
license pages — see Sources.) For a personal/ReaSampler-scale tool the free Starter tier
|
||
likely applies today, but taking JUCE means accepting AGPL-or-pay as a permanent posture
|
||
on the instrument. That's a bigger standing commitment than the Steinberg SDK, which is a
|
||
**permissive** (proprietary-but-royalty-free, GPLv3-optional) license with no revenue
|
||
gate.
|
||
|
||
### The UI is the real question — and ReaSampler is unusually well-armed
|
||
|
||
Strip everything else away and the honest "reach for JUCE?" decision reduces to one thing:
|
||
**who draws the editor?** VST3 gives you an `IPlugView` seat and nothing to fill it with.
|
||
The options on Windows-only (D5):
|
||
|
||
1. **JUCE** — for its `Component` graphics stack alone. This is the *usual* reason indie
|
||
devs take JUCE, and if ReaSampler had no UI competence it'd be the default.
|
||
2. **VSTGUI** — the UI toolkit that **ships with the Steinberg SDK itself**. Lighter than
|
||
JUCE, purpose-built for VST editors, no extra dependency beyond the SDK you already
|
||
took. A real middle option. (**Experienced estimate** — VSTGUI is bundled with the VST3
|
||
SDK; verify the vendored SDK includes it before relying on it.)
|
||
3. **Win32 / GDI / Direct2D directly** — Windows-only makes this viable; you own an HWND in
|
||
the `IPlugView`.
|
||
4. **The same LICE/SWELL stack the extension already uses.** — **This is the one worth
|
||
staring at for Daniel specifically.**
|
||
|
||
**Why option 4 changes the calculus for *this* project.** ReaSampler's `bank_panel` is
|
||
**already a LICE-drawn UI** — a real, working, docked, custom-drawn panel (grid,
|
||
thumbnails, tab strip, hit-testing) built on LICE/SWELL, the same stack REAPER itself and
|
||
SWS use. That's **67 LICE call-sites in `bank_panel.cpp`** today. The team (Daniel + the
|
||
implementers) has already paid the learning cost of drawing a custom audio-tool UI in
|
||
LICE. The usual "take JUCE because hand-rolling a plugin GUI from nothing is miserable"
|
||
argument is **much weaker here than for a typical indie dev**, because ReaSampler is not
|
||
starting from nothing — it has demonstrated LICE UI competence and a house style. A VST3
|
||
`IPlugView` that hosts a LICE-drawn surface would (a) reuse existing UI muscle, (b) keep
|
||
the instrument's look consistent with the panel, and (c) avoid the JUCE dependency and its
|
||
license posture entirely. There's even the **embedded-UI affordance** (D7 below) that lets
|
||
a REAPER-hosted plugin draw inline in the TCP/MCP using this same Cockos surface.
|
||
|
||
*The honest caveat:* wiring LICE into a VST3 `IPlugView` (window creation, sizing,
|
||
event routing from the host into your draw/hit-test loop) is **integration work with real
|
||
edges** — you're bridging the SDK's view lifecycle to a LICE `HWND`/bitmap. It's not free,
|
||
and it's less trodden than "drop in a JUCE editor." But it is *the same class of work
|
||
ReaSampler already did* to dock `bank_panel`, not a new competence. (**Experienced
|
||
estimate** on the difficulty band — grounded in how `IPlugView` and LICE each work, not a
|
||
built prototype. Flag: the exact `IPlugView`↔LICE bridge should be spiked before it's
|
||
promised in a plan.)
|
||
|
||
### The pure-core discipline (D3) lands cleanly here
|
||
|
||
**Confirmed: the discipline holds, and JUCE-vs-bare doesn't change it.** The sampler's
|
||
voice engine, envelope math, key/velocity mapping, repitch/interpolation, and keymap
|
||
resolution are pure DSP + data — **exactly the kind of REAPER-free, DAW-free, unit-tested
|
||
core** this project already excels at (`bank_model` / `peaks` / `view_mode_model` are the
|
||
template). They live in a pure module, tested in the CTest harness outside any host. The
|
||
VST3 wrapper — `SingleComponentEffect` subclass, bus setup, `process` marshalling MIDI→core
|
||
and core→audio-buffer, the `IPlugView` editor, and the bridge calls that read
|
||
`"reasampler"` ext-state — is the **thin shell**, the only part that touches VST3 or REAPER
|
||
at all. This is precisely ReaSampler's load-bearing split, transplanted to a new format.
|
||
|
||
Native VST3 makes this *cleaner* than the retired JSFX path would have (JSFX's "core" would
|
||
be JSFX script, un-unit-testable in CTest). And **JUCE-vs-bare doesn't move it**: whether
|
||
the shell is a bare-SDK `SingleComponentEffect` or a `juce::AudioProcessor`, the pure core
|
||
underneath is identical, REAPER-free, and tested the same way. The format choice is a
|
||
*shell* choice; the core is invariant. That's a reassuring result — it means D-A (bare vs.
|
||
JUCE, below) can be decided on shell ergonomics and dependency posture alone, without
|
||
risking the part of the architecture Daniel most cares about.
|
||
|
||
### Bottom line for Daniel's question
|
||
|
||
Complying with the VST3 surface without JUCE is **reasonable, not crazy** — the
|
||
audio-processing scaffolding is bounded, example-driven ceremony that
|
||
`SingleComponentEffect` cuts down further, and the D5 lock-downs delete JUCE's biggest
|
||
justification (multi-format). The **only** place JUCE earns its weight is the editor UI —
|
||
and ReaSampler's existing LICE competence makes even *that* argument weaker than it would
|
||
be for a typical indie. My honest lean (D-A below): **bare Steinberg SDK + LICE editor**,
|
||
with JUCE as the fallback if the `IPlugView`↔LICE bridge proves gnarlier than the panel
|
||
work suggests. But it's a genuine fork and it's Daniel's — laid out in §4.
|
||
|
||
---
|
||
|
||
## 2. The integration seam
|
||
|
||
There are two seams, and with native VST3 locked (D1) the instrument gets **both**:
|
||
|
||
- **File seam (audio, always).** The sample **audio** is WAVs on disk — there's no getting
|
||
a live PCM stream across the bridge, nor would you want to. WAVs are project-relative and
|
||
travel with the `.rpp` (M4 machinery). This is the permanent seam for sample audio.
|
||
- **Live-state seam (the mapping / index, via the bridge).** For **everything that isn't
|
||
the raw audio** — the index, the mapping data, which project's bank is active — a native
|
||
VST instance reads the `"reasampler"` project ext-state directly via the bridge
|
||
(`GetProjExtState` / `EnumProjExtState`, resolved by name over `hostcb`), and knows its
|
||
own host project via the context callback. It sees what `persist` last wrote, follows the
|
||
active project, and needs no "point me at the right bank folder" wiring — it *asks REAPER*
|
||
which project it's in. This is the tight integration D6 locked in.
|
||
|
||
So: **audio comes across as files; the mapping comes across as live shared state.** The
|
||
design question below — "is the ext-state/index sufficient, or does playback need mapping
|
||
it doesn't carry" — is unchanged. What the bridge settles is *where that mapping lives*:
|
||
between extension and instrument as **live shared `"reasampler"` state**, not a file one
|
||
writes and the other re-parses.
|
||
|
||
**What the current index carries** (from `bank_model`'s `Sample`, per CONTEXT.md §Data
|
||
model): id, display name, relative path, source range, channel count, sample rate,
|
||
length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier,
|
||
provenance, timestamp. Notably it *already* has an optional key field and capture
|
||
tempo — the seeds of pitch-mapping are there.
|
||
|
||
**What a playback engine additionally needs** — none of which the index carries today:
|
||
|
||
- **Root note** (the pitch the sample was recorded at, so it can be repitched across
|
||
the keyboard). The optional `key` is close but is "musical key," not "root MIDI
|
||
note" — different thing.
|
||
- **Key range / zone** (low note, high note) — which keys trigger this sample.
|
||
- **Velocity layers** (which sample plays at which velocity band; a mapping of samples
|
||
to velocity zones).
|
||
- **Round-robin groups** (cycle through N samples on repeated same-note hits).
|
||
- **Loop points** (sustain loop start/end for held notes; sample-accurate,
|
||
zero-crossing-aware — CONTEXT already flags loop/zero-crossing handling as
|
||
day-one-relevant for wavetable material).
|
||
- **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes.
|
||
- **Tuning/gain trim** per sample.
|
||
|
||
**The design decision this forces:** does the *mapping* (key ranges, velocity layers,
|
||
round-robin, envelopes) live —
|
||
|
||
- **(i) in the bank index** (ReaSampler owns the instrument definition; the plugin is a
|
||
dumb player of a ReaSampler-authored map), or
|
||
- **(ii) in the instrument plugin's own state** (the plugin owns the map; the bank is
|
||
just a WAV+metadata pool it maps *over*), or
|
||
- **(iii) split** — the bank carries per-sample *intrinsics* (root note, loop points —
|
||
facts about the file), and the instrument carries the *arrangement* (zones, layers,
|
||
envelopes — a performance choice)?
|
||
|
||
**(iii) is the principled answer and worth leading with.** It mirrors ReaSampler's own
|
||
deepest instinct — the *capture/placement separation*. Root note and loop points are
|
||
**facts about the captured file** (analogous to sample rate, length, peaks) and belong
|
||
in the bank, computed or set at/after capture. Key zones, velocity layers, envelopes,
|
||
round-robin are a **performance mapping** — a creative arrangement of those facts — and
|
||
belong in the instrument. This keeps the bank a clean, portable, tool-agnostic library
|
||
(a bank is still just WAVs + facts, readable by anything) while the instrument owns the
|
||
opinionated part. It also means **the bank index grows only by file-intrinsic fields**
|
||
(root note, loop points), which is a small, safe, additive change — exactly the kind of
|
||
seam ReaSampler already knows how to add (mirror of how `provenance` was added as a
|
||
field in M1 and populated later).
|
||
|
||
**Wiring note (native, the locked path).** The native instance sidesteps the "point me at
|
||
the right bank folder" problem entirely: via the context callback it asks REAPER which
|
||
project it's in, then reads that project's bank location straight from `"reasampler"`
|
||
ext-state — no user wiring, no saved folder path in the instrument's own state. This is the
|
||
cleanest possible integration and it's the one D6 chose. The one thing to keep deliberate:
|
||
the WAV **audio** still resolves via the project-relative path machinery (M4), so the
|
||
instrument must resolve those paths the same way `persist` does — a shared convention, not a
|
||
hand-wave. Since D5 locks REAPER-only/Windows-only, the old "but it won't run in other
|
||
hosts" concern is **moot by design** — the bridge dependency is intended, not a narrowing to
|
||
regret.
|
||
|
||
---
|
||
|
||
## 3. Product shape & scope tiers
|
||
|
||
Sketched as tiers, minimal → sophisticated. The point of tiering is that **Tier 0
|
||
delivers the core promise** ("play my captured banks from a MIDI keyboard") and each
|
||
tier above is optional depth, not a prerequisite for the one below.
|
||
|
||
**Tier 0 — "the bank plays."** One sample mapped chromatically across the keyboard from
|
||
a root note; monophonic-or-basic-polyphony; a simple amp envelope; velocity → volume.
|
||
Point one bank sample at a MIDI track and play it repitched. *This is the smallest
|
||
thing that delivers the promise* and is the honest MVP. On the native path this is the
|
||
skeleton of the VST3 plugin — the `SingleComponentEffect` shell (§1a) plus a single-voice
|
||
core, with the editor deferrable behind a parameters-only default view.
|
||
|
||
**Tier 1 — "a keymap."** Multiple samples zoned across the keyboard (key ranges), each
|
||
with its own root note. Now a captured *kit* (several one-shots) or a *multisampled
|
||
instrument* (same instrument sampled at several pitches) plays correctly. This is where
|
||
the **root-note + key-range** seam fields (§2) earn their place. Still one sample per
|
||
key-region.
|
||
|
||
**Tier 2 — "expressive."** Velocity layers (soft vs. hard hits pick different samples),
|
||
round-robin (repeated hits cycle samples — the anti-machine-gun feature), full ADSR,
|
||
per-sample tuning/gain trim, sustain loops for held notes. This is a *real* sampler and
|
||
where "sophisticated" lives. It's also where the mapping data (§2) gets rich enough that
|
||
the "who owns the map" fork (i/ii/iii) really bites.
|
||
|
||
**Tier 3 — "instrument polish."** Filter + filter envelope, LFOs, per-voice pan, choke
|
||
groups (hi-hat open/closed), maybe a modest built-in FX slot. Standard sampler feature
|
||
creep. Explicitly *not* needed to deliver value; a direction to leave room for, not
|
||
commit to.
|
||
|
||
**The recommended reading:** Tier 0–1 is the "does this even belong in ReaSampler's
|
||
world" proof. If the answer is yes, Tier 2 is where it becomes a tool people reach for.
|
||
Tier 3 is optional forever. **Scope the decision at Tier 0–1 first** and treat 2/3 as
|
||
held futures — don't let a Tier-3 feature list drive the format choice.
|
||
|
||
### The one-source-multiple-views angle
|
||
|
||
Worth flagging because it recurs in Daniel's work: the bank is **one source** (WAVs +
|
||
facts). The panel is one view (organize/audition). A MIDI instrument is **another view
|
||
of the same source** (play). That's a clean framing — the instrument doesn't fork the
|
||
bank, it consumes it — and it argues for keeping the bank as the single authoritative
|
||
artifact and the instrument as a pure consumer (seam option ii/iii, never "the
|
||
instrument keeps its own copy of the samples").
|
||
|
||
---
|
||
|
||
## 4. Decisions — settled and residual
|
||
|
||
### Settled (DECIDED — reasoning preserved, not re-opened)
|
||
|
||
These are locked. The reasoning is kept as the record of *why*, so the choices don't get
|
||
silently re-litigated.
|
||
|
||
**D1 — Instrument format → DECIDED: native VST3.** JSFX is off the table (retirement
|
||
reasoning in §1, in the JSFX box). Native VST3 gives full sampler sophistication, clean
|
||
integration, and the VST-host bridge (live `"reasampler"` ext-state, project-awareness,
|
||
embedded-UI affordance). The cost — a second codebase in a plugin format — is accepted.
|
||
The old "JSFX-first cheap prototype" lean is withdrawn: with D5 locking Windows-only,
|
||
JSFX's one advantage (cross-platform-for-free) is worthless here, and its inability to
|
||
reach the bridge makes it the wrong tool for the *integrated* product D6 chose.
|
||
|
||
**D5 — Cross-platform / multiformat → DECIDED: none. Windows-only, VST3-only,
|
||
REAPER-only.** The extension is Windows-only; Daniel does not work on other platforms.
|
||
This *removes* costs prior drafts carried: no mac/Linux DSP/build/signing matrix, no
|
||
multi-format wrapper, no "runs standalone in other hosts" concern, no CLAP-for-portability
|
||
argument. REAPER-coupling via the bridge is intended, not a narrowing to regret. This is
|
||
the single biggest simplifier — it is *why* bare VST3 is reasonable (§1a): most of what
|
||
makes VST3 painful (multi-format, cross-platform) is deleted.
|
||
|
||
**D6 — One product or two → DECIDED: two products, but tightly integrated.** The
|
||
instrument is a *separate artifact* from the extension (the extension stays the pure
|
||
capture/organize tool; the instrument is the playback surface — capture and placement, and
|
||
now playback, stay distinct acts). But it is **not** a divorced file-only companion: via
|
||
the bridge it reads the live `"reasampler"` project ext-state and is project-aware — "two
|
||
faces of one tool sharing one state model." The prior draft's open question ("could they
|
||
only ever share a file?") is resolved: no, and the shared-live-state integration is the
|
||
chosen shape. The retired reasoning for why the loose-companion reading lost: it only ever
|
||
looked clean because the prior draft under-weighted the bridge; once the bridge is on the
|
||
scale, the integrated reading has the stronger technical affordance, and Daniel took it.
|
||
|
||
**D3 — Pure-core discipline across the format boundary → CONFIRMED holds (see §1a).** Not
|
||
a fork so much as a checked assumption: the sampler's voice engine, envelope math,
|
||
key/velocity mapping, and repitch/interpolation live in a pure REAPER-free, DAW-free,
|
||
unit-tested core (mirror of `bank_model`/`peaks`/`view_mode_model`); the VST3 wrapper is
|
||
the thin shell. Confirmed clean for native, and — importantly — **invariant under
|
||
bare-vs-JUCE** (§1a): the shell choice doesn't touch the core. This is the most
|
||
ReaSampler-native way to build it and it's assumed, not debated, going forward.
|
||
|
||
### Residual forks — SETTLED (Daniel, 2026-07-26; reasoning preserved, not re-opened)
|
||
|
||
All four residual decisions are now called. Each is marked **SETTLED** with Daniel's
|
||
choice and the reasoning kept as the record of *why* — do not re-litigate. They are
|
||
scoped into **PLAN.md §Phase S** / **CONTEXT.md §Phase S**.
|
||
|
||
**D-A — SETTLED: bare Steinberg VST3 SDK + LICE editor (no JUCE).** *(The central fork.
|
||
§1a is the assessment that fed it. The sub-question — who draws the editor? — was the
|
||
whole fork, because §1a showed the audio-processing scaffolding is bounded either way.)*
|
||
Daniel took **Option A**: bare Steinberg SDK, `SingleComponentEffect` for the bounded
|
||
audio scaffolding, editor drawn in the **same LICE/SWELL stack `bank_panel` already
|
||
uses** — no JUCE dependency, no AGPL-or-pay posture, house-consistent UI. The
|
||
`IPlugView`↔LICE bridge (the one real unknown) is **not** a gate on the decision (the
|
||
decision is made) but remains the right *first implementation step*: it is scheduled as
|
||
**Phase S's opening spike (S1)** to convert §1a's experienced-estimates — Windows
|
||
module-export names, factory-macro spellings, exact bridge marshalling — into verified
|
||
fact before the engine build leans on them. (The stale §1a "is VSTGUI even bundled"
|
||
question is dropped as moot under Option A; VSTGUI remains a noted fallback rung only if
|
||
the LICE bridge proves gnarlier than the panel work suggests, with JUCE the last resort
|
||
behind that.) The full option analysis (A/B/C, the JUCE license posture, the LICE-bridge
|
||
caveat) is preserved below as the record.
|
||
|
||
- *Option A — bare Steinberg SDK + LICE editor.* Take only the VST3 SDK (permissive,
|
||
royalty-free, no revenue gate). Use `SingleComponentEffect` for the ~200–400 lines of
|
||
once-written audio scaffolding, and draw the editor in **LICE/SWELL — the stack
|
||
`bank_panel` already uses** (67 LICE call-sites today). *Pro:* no JUCE dependency, no
|
||
AGPL-or-pay posture, house-consistent UI, reuses existing UI muscle, lean CMake
|
||
discipline preserved. *Con:* the `IPlugView`↔LICE bridge is real integration work with
|
||
edges (window lifecycle, sizing, event routing) — less trodden than dropping in a JUCE
|
||
editor, and it should be **spiked before it's promised**.
|
||
- *Option B — JUCE.* Take JUCE for its editor framework + parameter management + DSP
|
||
utilities. *Pro:* the editor is a solved problem, `AudioProcessorValueTreeState` is
|
||
convenient, `juce::dsp` helps at Tier 3. *Con:* a large framework dependency with its own
|
||
build system and idioms sitting oddly beside the two-submodule discipline; and the
|
||
**license posture** — AGPLv3-or-commercial, free Starter tier below ~$20K/yr revenue (no
|
||
splash screen in JUCE 8), Indie ~$3,500 with a ~$200K/yr limit above that. For most of
|
||
what JUCE solves (multi-format), **D5 already deleted the need.**
|
||
- *Option C — bare SDK + VSTGUI (the SDK's own bundled toolkit).* A middle path: no JUCE,
|
||
but a purpose-built VST editor toolkit instead of hand-bridging LICE. Lighter than JUCE,
|
||
no extra dependency beyond the SDK. Worth a look if the LICE bridge proves gnarly.
|
||
|
||
*My honest lean:* **Option A (bare SDK + LICE editor)**, precisely because ReaSampler is
|
||
the atypical case where the "take JUCE for the GUI" default is weakest — it already has
|
||
working LICE UI competence and a house style. JUCE's headline value (multi-format) is moot
|
||
under D5. Fall back to JUCE (Option B) *only if* the `IPlugView`↔LICE spike shows the
|
||
bridge is genuinely painful; consider VSTGUI (Option C) as the middle rung before
|
||
conceding to a full framework. **Concrete ask: greenlight a small `IPlugView`↔LICE spike
|
||
before committing** — it's the one unknown that decides A vs. B, and §1a's LICE-bridge and
|
||
Windows-export-name claims are experienced estimates that a spike would convert to fact.
|
||
|
||
**D-B — SETTLED: split seam (option iii), and the intrinsic fields are added NOW.**
|
||
*(The mapping-ownership fork, old D4. Options were: (i) bank-owned map, (ii)
|
||
instrument-owned map, or (iii) split.)* Daniel took **(iii) split** — and, critically,
|
||
called that the `Sample` intrinsic fields land **now**, not deferred. **Root note + loop
|
||
points become bank intrinsics on `Sample`** (facts about the captured file, like sample
|
||
rate/length/peaks) — a small additive change, the same shape as adding `provenance` in
|
||
M1. **Zones, velocity layers, round-robin, envelopes are the instrument's performance
|
||
map** (a creative arrangement, not a file fact). The live-state seam (§2) means the
|
||
instrument reads even the performance-map out of shared `"reasampler"` state, so "who owns
|
||
which field" is a data-ownership decision, not a transport one. Adding the fields now
|
||
closes the **backfill cliff** (the *design-the-seam-even-if-you-defer-the-feature*
|
||
instinct, same as Fork R-D's owned-file manifest): every sample captured before the
|
||
fields exist would otherwise lack a root note / loop points and need hand-backfilling. The
|
||
field addition touches the **extension** codebase, is independently shippable, and is
|
||
scheduled as an **early Phase S point (S2)** ahead of the instrument that consumes it.
|
||
|
||
**D-C — SETTLED: Tier 0–1 scoped now; Tier 2 held; Tier 3 optional-forever.** *(The
|
||
tier-scope fork, §3. Tiers: 0 "the bank plays" / 1 "a keymap" / 2 "expressive" / 3
|
||
"polish".)* Daniel took the recommended scope: **Tier 0** ("the bank plays" — one sample,
|
||
chromatic, amp envelope, velocity→volume) then **Tier 1** ("a keymap" — zoned multisamples
|
||
with per-sample root notes) are the committed scope and the "does this belong in
|
||
ReaSampler's world" proof (Phase S points S4/S5). **Tier 2** ("expressive" — velocity
|
||
layers, round-robin, ADSR, loops) is held as an explicit follow-on — **noted, not
|
||
specified** (its points are not drawn up). **Tier 3** ("polish" — filters, LFOs, choke
|
||
groups) is optional-forever. The Tier-0–1 editor and DSP needs are modest, which is part of
|
||
why the D-A "no JUCE" call is comfortable.
|
||
|
||
**D-D — SETTLED: embedded TCP/MCP UI is SCHEDULED (not deferred).** *(The
|
||
inline-in-REAPER-UI lever.)* A REAPER-hosted VST3 can draw its own UI *inline in the
|
||
track/mixer control panel* (`reaper_plugin_fx_embed.h`: VST3 implements
|
||
`IReaperUIEmbedInterface`; the same Cockos surface REAPER's own JS/embedded FX use) — a
|
||
compact keymap/level strip inline in the TCP/MCP, not only in its own window. Because it
|
||
uses the **same LICE-class drawing as the D-A editor path**, it composes naturally with the
|
||
bare-SDK-plus-LICE build. **Daniel's call: schedule this, don't defer it** — it is a real,
|
||
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.
|
||
|
||
---
|
||
|
||
## Where this landed
|
||
|
||
With D1/D5/D6 locked and **D-A..D-D all settled (2026-07-26)**, the instrument is scoped
|
||
into **Phase S** — a native VST3 sampler as a **second build artifact** alongside the
|
||
`reaper_reasampler` extension. The settled set:
|
||
|
||
1. **D-A → bare Steinberg VST3 SDK + LICE editor** (no JUCE). The `IPlugView`↔LICE bridge
|
||
is the opening implementation spike (**S1**), not a decision gate.
|
||
2. **D-B → split seam; root note + loop points added to `Sample` now** (**S2**, in the
|
||
extension) to close the backfill cliff.
|
||
3. **D-C → Tier 0–1 committed** (**S4/S5**); Tier 2 held (noted, not specified); Tier 3
|
||
optional-forever.
|
||
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
|
||
framing/decision record they point back to. The "no PLAN.md footprint" era is over.
|
||
|
||
---
|
||
|
||
## Sources (for §1a's verified claims)
|
||
|
||
- Steinberg VST3 SDK — `SingleComponentEffect` class reference (combined processor +
|
||
controller; "non-distributable" caveat):
|
||
https://steinbergmedia.github.io/vst3_doc/vstsdk/classSteinberg_1_1Vst_1_1SingleComponentEffect.html
|
||
- Steinberg VST3 SDK — `IAudioProcessor` class reference (process/setupProcessing/bus
|
||
contract):
|
||
https://steinbergmedia.github.io/vst3_doc/vstinterfaces/classSteinberg_1_1Vst_1_1IAudioProcessor.html
|
||
- VST3 Developer Portal (overview, tutorials, example plugins — AGain / Note Expression
|
||
Synth as the skeleton copy-source): https://steinbergmedia.github.io/vst3_dev_portal/
|
||
- JUCE 8 EULA (dual AGPLv3 / commercial; tier structure):
|
||
https://juce.com/legal/juce-8-licence/
|
||
- JUCE forum — revenue limits & JUCE 8 pricing (Starter ~$20K/yr free, Indie ~$200K/yr
|
||
limit, ~$3,500): https://forum.juce.com/t/revenue-limits-for-juce-tiers/61058
|
||
|
||
*Estimate-vs-verified honesty note:* the interface list, `SingleComponentEffect`'s role,
|
||
the instrument bus topology, and the JUCE license terms are **verified** from the sources
|
||
above. The **line-count band (~200–400)**, the **exact Windows module-export symbol names**
|
||
(`InitDll`/`ExitDll`/`GetPluginFactory`), the **factory-macro spellings**
|
||
(`BEGIN_FACTORY`/`DEF_CLASS2`), **VSTGUI being bundled in the vendored SDK**, and the
|
||
**`IPlugView`↔LICE bridge difficulty** are **experienced estimates** grounded in how the
|
||
SDK is shaped — each flagged inline in §1a and each cheap to convert to fact by reading the
|
||
vendored SDK headers / running a spike before any of it lands in a plan.
|