Files
reasampler/docs/product/midi-playback.md
T
daniel a4e33b1c04 docs(s-view): spec the r11 Sample-face recomposition (knob deck, curve popup, full-width hero) for Wave B
Adds S-VIEW-11/12/13 to PLAN, r11 addendum, and forks R11-F1 (window default height) + R11-F2 (Zone-panel knob parity) for Daniel.
2026-07-27 18:25:32 -04:00

1388 lines
99 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-27 (r11)**. r11 records the
**Sample-face recomposition** (Daniel's post-landing DAW pass, 2026-07-27): all linear sliders →
grouped radial knobs, compact mode toggles, the inline velocity-curve box → a preview button +
popup editor with right-click node delete, and the hero waveform full-width — the layout spec for
the Wave B editor rebuild. Two new forks open (R11-F1 hero height/default window, R11-F2
Zone-panel parity); all r9/r10 forks — including S-VIEW-F3 — are settled and landed. See the r11
Addendum in §4. r9 records the
**three-view editor redesign** (Sample / Browse / Zone) and its follow-up settlements: the
S-VIEW-F1 and S-VIEW-F2 forks are now **SETTLED** (2026-07-27) — **preview velocity persists** via
the instrument's own VST3 component state (envelope bump to v6, *not* the extension's project
ext-state), and the **envelope overlay's nodes are draggable** via a new pure `envelope_edit`
module — and S-VIEW builds **directly on the finished, merged Phase L L3 look-and-feel** in a
**single implementation pass** (no two-pass restyle-after; L3 landed 2026-07-27, commit `c53683e`).
Only S-VIEW-F3 (Browse modal presentation) remains open. Full r9 record in the Addendum below. r8
records Daniel's **duration-preserving correction** (2026-07-26, verbatim: *"isn't that ratio stuff
going to change the playback rate? I want duration-preserving repitching"*): the ratio path is
**varispeed** (pitch/duration coupled), so S16 is reshaped from "pitch envelope only" into a
**pitch-engine mode — Varispeed vs Preserve — plus the pitch envelope** riding either engine.
The WDL verdict flips: `WDL_SimplePitchShifter` (duration-preserving OLA), previously dismissed
as the wrong tool, is **now the Preserve-engine candidate** and got a real per-voice RT
viability assessment. Two S16 forks flagged: **S16-F1** (engine default — lean Preserve) and
**S16-F2** (Preserve implementation — WDL shifter first, hand-rolled pure module held). See the
r8 Addendum in §4. r7 records the
**sampling-modes engine directive** (Daniel, 2026-07-26): Trigger vs Gate play modes (Gate =
AHDSR, Trigger = one-shot with %-length + fades), a modifiable start point in both, and an
off-by-default AD pitch envelope — specced as **new Phase S points S15/S16**, with the WDL
pitch surface swept and reported. See the "sampling modes" Addendum in §4. r6 records the
**workflow-first reframe of S10** (Daniel, 2026-07-26): the editor's default face becomes a
**capture browser + guided single-capture setup**, a fresh instance is **silent with a "pick
a capture" empty state** (reversing the S4 first-sample auto-play), and multi-zone editing is
demoted to an opt-in Zones panel — see the r6 Addendum in §4. r6 also settles **S-NAME-1**
(rename the binary filename too, UID locked). r5 records the post-DAW-test directives on the
S1S6 instrument: the product name **ReaSampler 9000** and the **"better than RS5K" UX
overhaul** (Phase S points S10S13) — see the r5 Addendum in §4. r4 (below) settled the four
residual forks D-A..D-D. 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 **~200400 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 02 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 01 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 01 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 ~200400 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 01 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-01 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.
### Addendum — two directions set post-scoping (Daniel, 2026-07-26)
After Phase S was scoped (D-A..D-D), Daniel set two further directions. These are
**settled directions**, not open forks — specced as new Phase S points (S7S9), not
re-litigated. Recorded here per the doc's settled-decisions convention.
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ PLAN.md S7).**
Captures are often stereo; the current mono downmix is a Tier-0 simplification. The
engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with
the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus
arrangement (`setBusArrangements`) so mono/stereo just works in REAPER's routing. Honest
scope: **this is an S3-core extension, not a shell hack** — the core is mono-per-sample by
design today, so stereo mode grows a channel dimension (2-channel sample data, per-voice
stereo render, per-channel loop/interp). Mono mode keeps the existing downmix path.
Cross-mode policy: mono-source-in-stereo → dual-mono; stereo-source-in-mono → downmix
(existing). The toggle is instrument-owned per-instance state (D-B: a performance choice,
never a bank fact). Sequenced **first after the editor/embed work** because it touches the
engine Daniel smoke-tests.
**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md
S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
+ auto-assign to the active instance. The **extension owns ingest** (it has arrange
access, Media-Explorer access, and the drop-target surface on its own panels); the
**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts,
with the honest SDK reality verified against the vendored headers:
- *(a) Arrange capture → bank → assign* — a one-click action reusing the existing capture
path; **never inserts a timeline item** (capture/placement separation intact).
- *(b) Media Explorer import → bank → assign* — the ME surface is **thin**
(`OpenMediaExplorer` + `MediaExplorerGetLastPlayedFileInfo` are the whole contract; no
enumerate-selected, no ME-drop-handler), so ME import is **single-file, pull-on-action**,
not a push/drop from inside the ME. Spike: does the last-played-file read work for a
merely-*selected* file?
- *(c) Drag-and-drop* — REAPER exposes **no** drag-drop registration API; drop handling is
SWELL/Win32 on ReaSampler's *own* panel HWNDs. Drop *onto the VST3 editor window* relayed
to the extension as a bank-ingest request is a genuine **cross-artifact spike**, not a
promise (drop-onto-panel is the shipped path if it proves gnarly).
- *(d) Recapture / ingest auto-refresh (→ S9)* — because instances reference sample **ids**,
a recapture landing under the same id (M10) or an ingest touching the active bank should
refresh live instances **hands-free**. The missing trigger is a **bank-generation counter**
in `"reasampler"` ext-state: the extension bumps it on any bank-content mutation; the
instrument polls it **off the audio thread** on a safe cadence and calls its existing
`reloadFromBank()` on change (reusing S4's atomic handoff). This seam serves both S8 ingest
and M10 recapture.
*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the
ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both
are honestly-flagged as spikes in PLAN.md S8, not promised.
### Addendum — product name + UX overhaul (Daniel, 2026-07-26, post-S1S6 DAW test)
Daniel DAW-tested the S1S6 instrument and set two directives. These are **settled
directions**, specced as new Phase S points (S10S13) and a product-name convention — not
open forks (the two flagged forks below are the only calls left to Daniel).
**The instrument's product name is `ReaSampler 9000`.** The extension stays **ReaSampler**
(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is
a checklist item (PLAN.md §Phase S — product name; CONTEXT.md §Product name): the VST3
class **display name** string, the `IPlugView` editor title band (today "ReaSampler
Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-bearing):** the
**VST3 class UID must NOT change** — instances in saved projects key off it; a UID change
orphans every existing instance. The name change is **display-string-only** on the code
side. *Fork S-NAME-1 (Daniel's call):* the on-disk **binary filename** — renaming it
(`reasampler_9000.vst3`) carries compat weight (REAPER keys a saved project's plugin
reference partly by filename), so the r5 lean was **keep the filename, change only display
strings**; flagged, not decided. *(Now SETTLED in r6, below: rename the filename too, UID
locked, compat is a DAW-verify — the r5 lean is superseded.)*
**The UX bar is "better than ReaSamplOMatic5000."** Verdict verbatim: "okay it works, but
the UX is awful." The S1S6 editor was spike-grade — a clickable list, zone rows with
**seven ±1 nudge/delete mini-buttons** each, text labels, **no keyboard, no waveform, no
drag, no scroll.** Setting a zone range by ±1 clicks is the catastrophe. The overhaul
(S10S13) makes "better than RS5K" *specific*:
- **RS5K's strengths, matched or beaten.** Drag-a-file-onto-it load (→ S13 relay);
note-range + a visual keyboard (→ S10 — RS5K uses two *number fields*, so a **draggable
keyboard strip** beats it); waveform with draggable start/end/loop markers (→ S11); ADSR
sliders (→ S12). Velocity layers / round-robin stay Tier 2 (held).
- **RS5K's weaknesses, our opening.** RS5K is **one-sample-per-instance** (forcing track
sprawl — one instance per drum) with **no multi-zone view in a single instance**.
ReaSampler 9000 is **multi-zone in one instrument** (S5), so the keyboard-strip editor
showing *every* zone at once is a capability RS5K structurally lacks. That is the
sharpest "better than RS5K" claim, and it's free — it falls out of the existing model.
The overhaul honors every settled constraint: **LICE/SWELL only** (D-A), **pure geometry
modules** for all layout/hit-test (mirror of `mode_switch`/`editor_geometry`/`embed_strip`),
**RT discipline untouched** (edits commit off-thread), the instrument stays a **read-only
bank consumer** (loop/root/ADSR edits are the instrument's performance map, D-B — never
written to the bank). Component-state persistence and read-only-over-bank stay settled.
*Sequencing (product recommendation).* **S10 leads** — the nudge-button zone editor is the
friction Daniel hits on every test pass, so removing it buys the most felt improvement per
unit of work and de-risks the drag-state machine S11/S12 reuse. Against the queued **S7
(stereo)**: S10 should land **before or interleaved with** S7 — S7 is real engine work but
the *reason* Daniel keeps smoke-testing is the editor, and every test pass is currently
taxed by the UX; the two are orthogonal (S7 = engine/bus, S10 = editor/geometry), so there
is no hard ordering, but the live wound is the editor. Honest counter: if the stereo
*sound* is what blocks real use, S7-first is defensible — but "it works, the UX is awful"
names the editor as the wound.
### Addendum — S10 workflow-first reframe + S-NAME-1 settled (Daniel, 2026-07-26, r6)
After the r5 UX-overhaul directive was specced (keymap-first S10), Daniel reframed the
workflow before S10 was implemented. This **revises S10** and settles S-NAME-1. Settled
directions, not open forks — recorded here per the doc's settled-decisions convention; PLAN.md
§S10 and CONTEXT.md §Phase S (workflow hierarchy) carry the spec.
**The reframe, verbatim (Daniel, 2026-07-26):** *"We need to think hard about the workflow
with this plugin. Have a giant list of 'item' blocks is visually useless. When the plugin is
loaded, we should not have any samples selected. We also need to show the peaks for each
capture. Filters for a specific bank would be useful. We need to be graphic and descriptive
with the controls, and guide the user QUICKLY towards setting up a sampler. Most of the time
the zones won't be used, but it's a nice-to-have. So we should optimize the UX for working
with individual captures, not a huge list of everything."*
**What changed in S10 (the hierarchy is Daniel's; details are product judgment):**
1. **Primary flow = one capture, fast.** The metric is **time-to-first-note**: open → pick a
capture → see it → play it. The default face serves the single capture, not a keymap.
2. **Fresh instance is SILENT — nothing auto-selected (policy reversal of S4).** The S4
"first sample plays" convenience is **removed**: open with no stored selection → the
instrument plays nothing and shows a **"pick a capture" empty state**, not auto-play of
sample #1. Concretely retires the `selectSample` first-sample fallback (`sample_map.cpp`)
and the processor's Tier-0 fallback that resolved it (an empty stored id → silence). This
is a deliberate reversal of the S4 default, recorded as such — not a regression.
3. **Capture browser, not an item list.** Scannable cards with **peak thumbnails** (the
`Sample` peaks bank_model already carries — the same data the dock panel thumbnails draw),
name, root/key badge, and a **bank filter** (bank_book named banks). "A giant list of item
blocks" is the named anti-pattern; the browser is designed for scanning by eye. `SampleChoice`
grows to carry the peaks + badge + bank (today it is only `{id, displayName}`).
4. **Graphic, descriptive controls with a guided fast path.** Once a capture is picked, a
prominent single-capture setup surface (root note, play-mode basics, level); the keyboard
strip serves the single-capture case first (shows the capture's root).
5. **Zones demoted to an opt-in "Zones" panel (S10-Z), not the default face.** "Most of the
time the zones won't be used." The keyboard-strip drag machinery is still built (it serves
both the single-capture root-set and the opt-in zoning), but multi-zone editing is behind a
toggle. Some S12 list ergonomics **pulled into S10**: the browser card layout, peak
thumbnails, and bank filter are S10's; S12 keeps **scroll** + **type-to-filter search**
layered over the S10 browser. S11's waveform is the same surface S10 shows for the picked
capture ("see it"). No renumber — S11/S12/S13 keep their numbers and their boundaries were
annotated, not moved wholesale.
**S-NAME-1 → SETTLED: rename the binary filename too.** The r5 lean (keep the filename,
display-strings-only) is superseded. The on-disk module is renamed to match the product (e.g.
`reasampler_9000.vst3`) — full surface: **CMake `OUTPUT_NAME`**, factory vendor/name strings,
editor title, embed label. The **VST3 class UID stays locked** as the compat anchor.
**Compat is a DAW-verify, not an asserted fact:** the working assumption is REAPER rebinds a
saved instance by class UID (not filename), so a rename with an unchanged UID keeps saved
projects working — but a web check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's
binding can be more nuanced than "UID only" (an FXID match is involved), so UID-only rebinding
is **not** safe to assert from source. Verify by save-rename-reopen in the DAW; if REAPER keys
partly on filename, fall back to keeping the filename and record that as shipped.
### Addendum — sampling modes (Trigger/Gate) + pitch envelope (Daniel, 2026-07-26)
> **Superseded in part by the r8 Addendum below (2026-07-26).** Daniel's duration-preserving
> correction reshaped S16 from "pitch envelope only" into a Varispeed/Preserve pitch-engine
> mode, and **flipped this addendum's WDL verdict** — `WDL_SimplePitchShifter` (called the
> "wrong tool" in item 3 below) is now the Preserve-engine candidate. Read this as the r7
> point-in-time record; the r8 Addendum carries the current S16 shape.
Daniel directed a set of engine features for the sampler, specced as **new Phase S points
S15 (Trigger vs Gate) and S16 (pitch envelope)**. **The feature set is settled** — recorded
here per the doc's settled-decisions convention; PLAN.md §S15/S16 and CONTEXT.md §Sampling
modes carry the spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
granularity); the WDL question was resolved by inspection.
**Directive, verbatim (Daniel, 2026-07-26):** *"let's have product spec out some features
for the sampler: Sampling mode: Trigger vs Gate. Gate has an AHDSR envelope. Trigger has
fade in, % length, and fade out. Both modes have modifiable start point, Gate has modifiable
loop points too. In addition to amp env, there will be a pitch envelope/curve (AD?) which is
off by default. Explore using WDL pitch capabilities."*
**What was specced (the shape is product judgment; the feature set is Daniel's):**
1. **Play mode — Gate vs Trigger (S15), per-sample/per-zone, instrument-owned (D-B).**
- **Gate** = classic held note: the current ADSR grows a **Hold** stage → **AHDSR**
(hold=0 is exactly today's ADSR, back-compat); note-off → release; **sustain loop
applies** (S11's loop markers become Gate-mode UI).
- **Trigger** = one-shot drum-pad: note-on fires a **% of sample length** with a
**fade-in** and **fade-out**, **ignores note-off**, **no loop**. Fade default
**equal-power** (click-free); note-off is a no-op (choke held, fork S15-F1).
- **Both:** a **modifiable start point** (non-zero initial read position).
- **Confirmed from `sampler_core.cpp`:** the read loop already advances by an arbitrary
per-frame ratio with linear interp and applies a per-frame amp tick, so both envelopes
are per-frame amplitude functions and the start point is a non-zero initial `readPos_`
— no resampler or voice-lifecycle rewrite.
2. **Pitch envelope — AD, off by default (S16).** A per-voice AD curve biasing the read
increment (the classic pitch drop). **RT clean, confirmed:** the resampler is already an
arbitrary per-frame `readPos_ += ratio_`, so the envelope is a per-frame multiply of
`ratio_` by `2^(semitones/12)`**hand-rolled, no new resampler, no WDL dependency**.
Off by default → bit-identical to pre-S16.
3. **WDL pitch capabilities — verified, not lore (full surface swept).** The whole vendored
WDL pitch/resample surface is two headers: **`resample.h`** (`WDL_Resampler`, a real
sinc/linear RT-suitable resampler — its sinc mode *beats* the core's 2-point linear interp
for base-repitch quality at a CPU cost; **held as an optional quality upgrade**, not
needed for S15/S16) and **`simple_pitchshift.h`** (`WDL_SimplePitchShifter`, a time-domain
OLA *duration-preserving* pitch shifter — wrong tool for a sampler; `set_formant_shift` is
an **empty stub**). **No elastique / formant-preserving / time-stretch exists in WDL**
REAPER's elastique is licensed (zplane), not in the vendored tree. **Recommendation:** S16
modulation stays hand-rolled; `WDL_Resampler` (sinc) is the only WDL piece worth adopting
and only as a held base-repitch quality upgrade.
4. **Sequencing.** S15 before S16 (S16 reuses S15's param plumbing). Both are S3-core
extensions but **channel-count-agnostic by construction** (per-frame amplitude + read-rate,
pre-mix), so they **compose with S7 stereo** rather than conflicting. Core halves land in
CTest independently of the editor; the mode toggle / Trigger handles / AD control surface
through the S10/S11 waveform + setup work.
**Forks flagged (leans given):** *S15-F1 (choke on note-off)***held**, out of S15 scope
(Trigger ignores note-off; choke-groups are Tier-3-adjacent). *S15-F2 (param granularity)*
**lean per-zone only** (the single capture is already a one-zone map), flagged because it
touches S10's single-capture setup surface.
### Addendum — duration-preserving correction: S16 becomes pitch-engine modes (Daniel, 2026-07-26, r8)
**Correction, verbatim (Daniel, 2026-07-26):** *"isn't that ratio stuff going to change the
playback rate? I want duration-preserving repitching."* Daniel is right about the mechanics.
The r7 S16 spec modulated pitch by biasing the per-frame read ratio (`readPos_ += ratio_`) —
that is **varispeed**: pitch and duration are coupled (an octave up halves the note's
duration). Daniel wants **duration-preserving** repitch (a transposed note keeps its length).
This reshapes S16 and **flips the r7 WDL verdict** on `WDL_SimplePitchShifter`.
**What changed (the reframe, then the spec):**
1. **The reframe — this is a mode, not a replacement.** Both behaviors are musically
legitimate, so the answer is not "swap varispeed for preserve" but **a per-zone/per-capture
pitch-engine mode**:
- **Varispeed** (current path, cheap, zero-latency) — pitch/duration coupled. The
**classic sampler / RS5K** default; right for **drums / one-shots** (pitch-down-lengthens-
the-hit is a feature there).
- **Preserve** (duration-preserving) — a pitch shifter transposes the output while the read
holds the source duration. Right for **tempo-locked loops and phrases** (a transposed loop
still lines up to the bar) — which is what captured banks skew toward (project slices).
The pitch envelope (r7's S16 body) then rides **either** engine: under Varispeed it biases
the read ratio (as specced); under Preserve it biases the shifter's shift amount. So the
envelope is preserved, re-homed onto the engine seam.
2. **Fork S16-F1 (Daniel's call): the default engine. Lean Preserve.** Argued honestly:
Preserve because Daniel asked for it **unprompted** (reads as his expectation) and the
capture workflow is **loop/phrase-heavy**; but Varispeed is the **classic-sampler
expectation**, is **cheaper + zero-latency**, is **bit-identical to today's shipped feel**,
and is what percussive one-shots want. Recommendation: **default Preserve, prominent cheap
per-zone toggle to Varispeed.** Daniel's call.
3. **WDL verdict corrected — `WDL_SimplePitchShifter` is now the right category.** Under
"duration-preserving is the requirement," r7's dismissal ("wrong tool, duration-preserving
OLA") inverts: **duration-preserving is exactly what we need.** A real per-voice RT
viability assessment (from `vendor/WDL/WDL/simple_pitchshift.h`):
- **API:** push/pull block (`GetBuffer`/`BufferDone`/`GetSamples`); `set_shift(2^(semi/12))`
for pitch with an **independent** `set_tempo(1.0)` duration knob — pitch and duration
separately controllable, exactly Preserve.
- **Per-voice:** modest memory (OLA ring ≈ window·srate ≈ a few KB/voice at the 50 ms
quality-0 window). CPU cheap (O(length), a few mults + one OLA crossfade/frame, **no
FFT**) → **N polyphonic voices each running one is feasible** in RT discipline.
- **Costs owned:** (i) **onset latency** ~half-window (~25 ms @ 50 ms) — the load-bearing
cost; pre-warm at voice-allocation, and it lands on sustained/loop material (Varispeed
serves tight one-shots); (ii) **queue-growth** allocation in `BufferDone` — settled by a
silence pre-warm so no `process`-thread allocation in steady state; (iii) **basic
quality** (SimpleWindowed warble on big transpositions; `set_formant_shift` is an empty
stub → no formant preservation) — acceptable for loops, replaceable by route (b).
- **Fork S16-F2:** **route (a)** `WDL_SimplePitchShifter` (low-cost proof) vs **route (b)** a
hand-rolled pure `pitch_shift` OLA/granular module (house pattern, CTest-testable, full
control). **Lean (a) first, (b) held** as the quality/latency upgrade — same
`PitchEngine::Preserve` contract behind the seam.
4. **Not proposed / restated ceilings.** `WDL_Resampler` is a *resampler* (couples duration) —
a held **Varispeed-quality** upgrade, **not** a Preserve engine. **elastique is NOT
available** (licensed zplane, not vendored). JUCE / rubberband / signalsmith are each a
**new-dependency fork carrying full D-A weight** (bare-VST3-no-framework is the locked D-A
choice) — **not proposed**.
5. **S15 interaction (cleaner under Preserve).** Trigger's **%-length** becomes **pitch-
independent** under Preserve (wall-clock stable under transpose — cleaner than Varispeed,
where transposing a Trigger also scales its length); Gate's **sustain loop** contract under
Preserve is *loop the source read, shift the output* (loop points stay source-frame facts);
the **start point** is engine-independent (a source-frame offset). Channel-agnostic for S7
(the shifter is `set_nch`-aware; one instance per voice carries all channels).
6. **RT/CPU honesty.** Preserve is **meaningfully heavier** than varispeed — a per-voice DSP
object with its own budget, pre-warm, and a possible **Preserve-mode-specific voice cap**
(below the Varispeed cap) if per-voice cost demands it. Put in Verify: pre-warm → no
`process` allocation; measure per-voice CPU + onset latency against the polyphony cap. Treat
S16's Preserve-engine point as the phase's next real DSP spike, not a thin envelope add-on.
**Where the spec lives:** PLAN.md §S16 (reshaped to "pitch engine modes + pitch envelope",
with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16 interaction note;
CONTEXT.md §Pitch engine modes — Varispeed vs Preserve + the corrected WDL surface finding.
### Addendum — VST channel isolation (Daniel, 2026-07-26)
**Daniel's directive (2026-07-26, settled):** *"support the beta/stable channels for the VST
as well. The VST in beta should talk to the beta extension only."* This extends Phase V's V4
beta/stable split — which fully isolated the **extension** per channel — to the **ReaSampler
9000 VST3 instrument**. Spec'd as **S18**, an immediate Phase S wave, dispatchable in parallel
with or right after the in-flight waves (S9 ext_keys, S15/S16 processor/editor) — it touches
`vst_entry.cpp` / `reasampler_vst.h` / the CMake VST3 block, mostly disjoint from those.
**Established honestly from the tree — what already works vs. what's missing:**
- **Already isolated (the V4↔S4 reconcile did this): data pairing.** A beta-built VST already
*reads* the beta namespace — `ext_keys.h`'s `kProjExtNamespace()` delegates to
`app_version::extStateNamespace()`, and every wire key (`banks`, `assign_request`, S9's
generation key, S17's blob key, any future key) is a plain constant *under* that namespace.
Channel data-isolation is therefore **structural, not per-key** — new keys inherit it for
free. No S18 work here.
- **Missing: the VST's *plugin identity*.** Its class UID, binary filename, and display
strings are single-valued (same for both channels), so two installed channels would collide
on UID and filename. S18 closes exactly this.
**The shape of S18 (mirrors V4's philosophy — one channel per binary, one bit drives it):**
1. **A UID pair.** The stable class UID is locked forever (S-NAME-1). Beta needs its own
forever-stable UID (a second constant, minted once, locked identically). Both frozen
forever; the channel bit selects which is compiled in. **One class per binary, not both**
— the V4 fully-isolated-binary philosophy, so a beta build never presents the stable
identity. Saved-project isolation follows: a beta-saved instance rebinds only to the beta
VST. *Fork S18-F1 (Daniel's call):* mint the beta UID **now** (lean — mirrors the stable
UID minted at the S1 spike, removes a pre-ship landmine, zero cost unused) vs. defer to
first beta release behind a locked-once placeholder.
2. **Channel-derived binary + display identity.** `reasampler_9000` / `reasampler_9000_beta`
filename (mirror the extension's `OUTPUT_NAME` fork); "ReaSampler 9000" / "ReaSampler 9000
beta" display; editor title + embed label channel-aware — all from the ONE bit via
`app_version` accessors, no scattered `#ifdef`s (the V4 invariant).
3. **The pairing guarantee, stated as an invariant.** A channel's VST talks to that channel's
extension only, because identity keeps the plugins distinct and the channel-derived
namespace keeps the data distinct. **Structural, not per-key** — S8/S9/S17's cross-artifact
keys all inherit it; a future key that forgets to isolate is impossible by construction.
4. **DAW-verify contract.** Both channels installed side-by-side; each browser sees only its
channel's banks; stable-project + beta-VST = clean empty (not error); the S-NAME-1
rename/rebind test extends to the beta UID.
**Where the spec lives:** PLAN.md §S18; CONTEXT.md §VST3 channel identity — the UID pair + the
pairing surface. The pairing surface's data half is already load-bearing V4 machinery; S18
adds only the identity fork on top.
---
### Addendum — three-view editor model + drop-to-FX + default window size (Daniel, 2026-07-27, r9)
**Framing.** Daniel opened a fresh design pass on the ReaSampler 9000 **editor** after living
with the S10S18 UX in the DAW. The instrument *works* and the capture browser is genuinely
good — but the two-view editor (today's "Browser" + "Zones" toggle) misallocates its space,
the default window is undersized for a 1080p world, and the drop-a-capture-onto-FX gesture is
broken in practice. The directive: **make the one job — pick a capture, tune it, play it —
fast, easy, and fun. Style is a critical ingredient. No spreadsheet aesthetics.** These are the
`r9` calls. Authoritative spec: **CONTEXT.md §Phase S — editor view-model redesign (S-VIEW)**;
build roadmap: **PLAN.md §Phase S — editor view-model redesign**.
**The reference devices (the north star for control density).** Daniel named Ableton **Simpler**
and a Kilohearts/Phase-Plant **sampler group** as the composition targets. Both share one
grammar, and it is the grammar the redesign adopts:
- **A large waveform up top is the hero**, with markers and the envelope drawn *over it at
accurate time* — not a separate abstract envelope box. Simpler shades the sustain-loop region
teal directly on the waveform; Phase Plant traces the amp envelope as a bright curve across the
sample. The waveform is where the eye lives.
- **A thin, dense value-strip of labelled controls** sits under the waveform (Simpler's
Gain/Start/Loop/Length/Fade ribbon; Phase Plant's LEVEL/SEMI/HARMONIC/SHIFT/PHASE row) — bold
ALL-CAPS micro-labels over a value, compact, scannable in one pass.
- **Root note is fenced off as its own affordance** (Phase Plant boxes "Root C 4 +00" and an
"Offset" knob to the right; Simpler puts Key% and Transp in the pitch cluster). Root is not
buried in a list of sliders — it is a first-class, always-visible control.
- **Modular horizontal bands.** Each concern (sample/pitch, then envelope) is its own band with
its own drawn visual. This is how both devices avoid the spreadsheet look: every band leads
with a *picture* (waveform, filter curve, envelope shape), and the numbers hang off it.
**The three calls (r9).**
1. **Three views, not two — and they are NOT a flat toggle.** Today "Browser" and "Zone" sit as
two equal segments. That framing is wrong: it implies the browser and the zone editor are
peers, when in truth **the loaded sample is the home** and browsing/zoning are things you
*reach for*. So: **Sample is the home view** (the default face — the hero waveform, the
envelope overlay, all the per-sample controls). **Browse** is a **modal page layered over
Sample** — a full-window overlay you summon to change the loaded capture (select + confirm),
the way a file-open sheet layers over a document. **Zone** is a **dedicated editing surface**
opened by its own button when you want to map across the keyboard. This is a
*document-with-modal-picker* model (Simpler's own shape: the device face is the sample, and
loading a new one is a distinct act), not a three-way radio. *Why the reframe matters:* it
makes "I just want to play this capture" the zero-click default, and "I want a different one"
a single deliberate gesture, instead of making the user re-choose their whole stance every
time. See CONTEXT.md §S-VIEW for the precise navigation model.
2. **The Sample view earns the hero treatment; Browse gets ruthlessly cut.** Browse today
carries a waveform preview, root-note piano-roll, loop-point labels, a track-root message, and
a Mono/Stereo toggle — all of which are *tuning* concerns, not *choosing* concerns. Browse's
only job is **pick a capture**; the grid thumbnails already show the waveform, so every one of
those tuning affordances moves to Sample (where the hero waveform lives) and Browse shrinks to
filters + grid + select/confirm. Conversely, Sample **gains a new envelope overlay** drawn on
the hero waveform at accurate time (the Simpler/Phase-Plant move), a **preview-trigger button +
velocity knob** (audition the sample at its root without a MIDI controller — the single most
"fun/fast" affordance the current editor lacks), and inherits the Mono/Stereo toggle and every
"Modes-and-down" control that lives on Zone today.
3. **Two engineering prerequisites, framed but routed to implementation.** The **drop-to-FX bug**
(dropping a capture onto a track's FX chain does not instantiate + init ReaSampler 9000) and
the **undersized default window** are not design decisions — they are a bug and a one-line
default. Both are framed in CONTEXT.md §S-VIEW with the SDK reality swept (drop-to-FX: the S17
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign; window size: the
`getSize`/`checkSizeConstraint` mechanism is verified), and both are flagged for
staff-engineer, not for a product fork.
**New parameters this introduces (both instrument performance state, D-B — never bank facts):**
- **Key-tracking (Zone view).** A 0%200% scalar on how sample pitch tracks the keyboard around
the root note. **100% (default) = standard 12-tone equal-temperament tracking** (a semitone up
the keyboard = a semitone of repitch — exactly today's behavior). 0% = the sample plays at root
pitch on every key (a fixed, un-tracked one-shot — the classic drum-machine "no tracking"
mode). 200% = double-rate tracking (an octave of keyboard spans two octaves of pitch — an
intentional special effect). This is a *per-zone performance choice*, additive on
`PerformanceZone`, defaulting to 100% so existing playback is bit-identical. It is the sampler
idiom every serious sampler has (RS5K's "Pitch for start/end note", Kontakt's "Tracking",
Simpler's "Key %" — visible in the reference screenshot at "Key 100%"). *Why 200% and not
higher:* the useful musical range is 0200%; past 200% it is noise, and a bounded slider reads
cleaner than an open one.
- **Preview velocity (Sample view).** The velocity level the preview-trigger button fires at,
set by an adjacent knob. It is a *utility* setting for the audition button (like a metronome
level), not a musical parameter of the sample — but **it PERSISTS across reloads** (S-VIEW-F1
SETTLED, Daniel 2026-07-27). The seam matters and was verified against the existing VST source,
not recalled: it lives in the **instrument's own VST3 component state** (the envelope-versioned
`ComponentState` blob the processor round-trips through `getState`/`setState`, bumped to v6 for a
new top-level `previewVelocity` field), **not** the extension's `persist` project ext-state —
that module is REAPER-project-scoped and extension-owned, so it would make the level
project-global instead of per-instance and route an instrument concern through a bank-read-only
seam. See CONTEXT.md §S-VIEW for the round-trip and back-compat lift. This is what makes the
preview button *fun*: tap it hard or soft without reaching for a controller — and it remembers.
**Two visual components the redesign commits to:**
- **The envelope overlay — with DRAGGABLE nodes** — on the Sample view's hero waveform: the amp
envelope (AHDSR for Gate, the fade/%-length shape for Trigger) drawn as a curve *at accurate
wall-clock time* across the waveform, the way Simpler draws its amp envelope over the sample and
Phase Plant traces it in bright blue. This is the single biggest "reads like a real sampler, not
a spreadsheet" move — the envelope stops being four abstract sliders and becomes a *shape you see
over the sound*. And it is **directly editable** (S-VIEW-F2 SETTLED, Daniel 2026-07-27 — in
scope, not deferred): the breakpoints are **draggable handles** (X → segment time, Y → level on
the sustain node; monotonic-in-time, range-clamped), the exact ADSR-editor grammar Simpler /
Phase Plant / Serum all use. The sliders stay as the precise numeric-entry surface, and **both
surfaces read/write the same envelope params** — one source of truth, so a drag moves the sliders
and a slider edit re-lays the nodes with no divergence. The geometry/hit-test math lives in a new
pure `envelope_edit` module (mirror of `card_drag`, unit-tested at the clamp/monotonic
boundaries); the shell only draws the handles and routes the mouse. This is the affordance that
makes envelope-shaping *feel* like a sampler instead of a form.
- **The real piano-keyboard pattern** on the Zone view's keyboard strip. Today's spectral strip
is pretty but does not *read as a keyboard* — Daniel's note. The redesign keeps the pastel
spectral hue but overlays the **actual alternating white/black key pattern** (bright/dark per
the palette) so the strip is instantly identifiable as a keyboard. It need not be *shaped* like
a keyboard (no protruding black keys) — just carry the pattern as an overlay, so a glance reads
"this is C, this is F#" without counting.
**What does NOT change (guardrails restated).** The instrument stays a **read-only bank
consumer** — every new control (key-tracking, preview velocity, the loop/start markers Sample
inherits) is *performance map*, never written to `Sample` or the bank (D-B). LICE/SWELL drawing
only, all layout/hit-test in pure geometry modules (D-A). RT discipline untouched — the
preview-trigger fires a note through the same voice engine off the audio-thread commit path, no
new audio-thread work. The VST3 class UID is unchanged — a view reorganization plus additive
persisted fields is not a compat event; saved instances rebind and restore. And this is an
*editor* redesign: the S3 voice engine, the keymap resolution, and the component-state format
(extended additively — `keyTrack` per-zone, `previewVelocity` per-instance via an envelope bump to
v6, both with back-compat defaults on read) are the same load-bearing core.
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW); PLAN.md
§Phase S — editor view-model redesign. This Addendum is the *why*; those are the *what/how*.
---
### Addendum — velocity → amp transfer-curve editor (Daniel, 2026-07-27, r10)
**Framing.** Folds one more control into the S-VIEW redesign: a **visual velocity → amp
transfer-curve editor**. Today the engine maps velocity to gain *linearly* (`velocityGain_ =
velocity / 127.0`, `sampler_core.cpp:261`), applied once at note-on in `Voice::start()`. Daniel
wants that mapping to become an **editable transfer curve** — a bezier from a default flat line to
an arbitrary multi-point curve — so velocity dynamics are fully shapeable per sound.
**Directive, verbatim (Daniel, 2026-07-27):** *"By default any velocity plays at full level. Add
a visual transfer-curve editor: an editable curve from the default flat y=1 to any bezier curve
with arbitrary points, where x = velocity (0127) and y = amp scalar (01). Enables very dynamic
control over velocity → level."*
**The reference grammar.** A 2-D transfer-curve editor is the standard sampler/synth velocity-
response surface — Kontakt's velocity/mod curve editor, Serum's env/curve nodes, Ableton's
velocity device, any DAW's automation-curve editor. X is the input (velocity, 0127), Y is the
output multiplier (amp scalar, 01), and draggable control points bend the mapping. It is the
same direct-manipulation, one-source-multiple-views instinct as the r9 envelope overlay — and it
**reuses the r9 `envelope_edit` house pattern almost verbatim** (a pure geometry/clamp module +
a LICE shell that draws handles and routes the mouse).
**The four calls (r10).**
1. **State home → per-zone (`PerformanceZone`), sibling of the amp envelope + keyTrack.** Velocity
response is a *per-sound performance characteristic*, exactly like the AHDSR envelope, the pitch
engine, and the r9 key-tracking scalar — all of which already live on `PerformanceZone` (D-B,
instrument-owned, never a bank fact). A punchy drum wants a different velocity curve than a
sustained pad, so the curve must vary per zone, not per instance. This rules out `ComponentState`
(per-instance, where preview velocity correctly lives — that is a *utility* setting, one per
instrument; the transfer curve is a *musical* setting, one per sound). The curve is an additive
field on `PerformanceZone`, defaulting to the flat identity, and rides the **zones-payload
version axis** — NOT the envelope version axis (contrast preview velocity's envelope-v6 bump,
which is a per-instance top-level field). The single-capture Sample face reads/writes the same
one-zone storage site (S15-F2), so the Sample view and the Zone view share one curve store.
2. **Default → flat y=1 (all velocities full level), which is a DELIBERATE behavior change — fork
R10-F1.** Daniel's verbatim default is *"any velocity plays at full level"* — a flat curve at
y=1. That is **not** bit-identical to today's shipped linear `velocity/127` map: today a soft hit
is quieter; under a flat-y=1 default every hit plays at unity. This is the one genuine fork the
feature carries, and it is Daniel's call:
- *Option A (Daniel's stated default): flat y=1.* Honors the verbatim directive; velocity is
inert until the user draws a curve. Con: **not** back-compat — already-saved instances (and new
captures) get louder/flatter dynamics than today's build until a curve is drawn. Every existing
zone's felt response changes.
- *Option B: default = the current linear ramp (y = x/127).* Bit-identical to today's engine; the
editor's "flat y=1" is then one drawn state among many, not the default. Con: contradicts the
verbatim *"by default … full level"* — the default line would be a diagonal, not flat.
- *Recommendation: Option A (flat y=1) as the stored default, because it is what Daniel asked for
and the feature's whole point is that velocity is opt-in dynamics — but flag it loudly as a
shipped-behavior change so it is a decision, not a silent regression.* If the changed feel on
existing projects is unwanted, Option B is the safe fallback and costs only the default curve's
control-point values. **Daniel's call.** (Whichever wins, the *stored* default is a curve the
editor can draw and the core can evaluate — the two options differ only in which curve is
seeded.)
3. **Pure module → `velocity_curve` (REAPER/LICE-free, unit-tested).** Mirror of `envelope_edit` /
`card_drag`. Two responsibilities: **(a) evaluation** — `eval(velocity 0127) → amp scalar 01`
for the voice engine (a bezier through the control points, clamped to the 0127 × 01 box,
monotonic-in-x by construction so a given velocity has exactly one output); and **(b) editing** —
add / move / delete control points, each clamped into the box, x-ordered (a point cannot cross
its neighbours in x, same monotonic grammar as the envelope nodes), with a point hit-test
(point → which control point, pick radius) and a pixel-delta → clamped-point inverse map. Both
halves are unit-tested at the boundaries: a known curve + known velocity asserts the eval output;
a known drag asserts the clamped point set and the box/order constraints. The flat identity curve
(whichever default R10-F1 picks) is a named constructor. **No RT concern in the module**
evaluation is called at note-on, not per frame (see call 4).
4. **Voice-engine application point → `Voice::start()`, replacing the linear `velocity/127`.**
Confirmed from source: `sampler_core.cpp:261` computes `velocityGain_ = velocity / 127.0` **once
at note-on** inside `Voice::start()`; the per-frame render path (`advanceFrame`, line 408:
`gain = amp * velocityGain_`) then just multiplies the cached scalar. So the transfer curve
slots in at exactly one line: `velocityGain_ = curve.eval(velocity)` at note-on — **off the
audio-thread-hostile per-frame path**, evaluated once per voice, honoring the house RT
discipline (no new per-frame work, no allocation in `process`). The curve travels to the voice
the same way the AHDSR/keyTrack params do: on the zone's `SampleData::play` bundle (resolved from
the stored `PerformanceZone` at keymap build), read by the voice at `start()`. The pure core owns
the eval; the voice reads it.
**The curve-editor UI — Sample view, near the envelope overlay.** It lives on the **Sample view**
(the r9 home face), as a compact band adjacent to the hero-waveform envelope overlay — the two are
the same grammar (a drawn 2-D curve with draggable handles) and reading them side by side is
natural (amp-over-time next to amp-over-velocity). Draws through the **L1 kit** like every S-VIEW
surface: a bordered box (X = velocity 0127, Y = amp 01), the bezier traced in an accent hue,
small draggable node markers at each control point (hover/drag-lit via kit states), add-point on
click in empty space, delete on a modifier-click or drag-off. The shell (`reasampler_editor.cpp`)
does the LICE draw + mouse routing; **all geometry/hit-test/clamp math is in the pure
`velocity_curve` module**. On the Zone view the same editor appears in the per-zone param panel
(one curve per zone). Additive and bit-identical-for-existing-projects only under R10-F1 Option B;
under Option A (the lean) it is additive-but-behavior-changing, per call 2.
**What does NOT change (guardrails).** Instrument stays a **read-only bank consumer** — the curve
is *performance map* on `PerformanceZone`, never written to `Sample` or the bank (D-B). LICE/SWELL
drawing only; all math in the pure `velocity_curve` module (D-A). RT discipline untouched — eval
is at note-on, not per frame. VST3 class UID unchanged — an additive per-zone field is not a compat
event. Zones-payload version bump only; the envelope version axis (preview velocity's v6) is
independent and untouched.
**Wave-plan slot (concurrency-aware — six engineers are in worktrees now).** Because the curve
lands on `PerformanceZone`, it is **blocked by Wave 1 track T-KEYTRK** (which owns the
`PerformanceZone` schema + the zones-payload version bump right now). The velocity-curve field must
sequence as a **LATER additive payload bump AFTER T-KEYTRK merges**, so the two do not collide on a
single payload version number — T-KEYTRK's `keyTrack` field and this `velocityCurve` field are
sequential additive extensions of the same zones-payload record, not simultaneous ones. It is
**not** blocked by T-STATE (that track owns the per-instance `ComponentState` v5→v6 envelope bump
for preview velocity — a different struct on a different version axis). Concretely this is:
- **a follow-on foundation track** (pure `velocity_curve` module + the core application point at
`Voice::start` + the additive `PerformanceZone` field + payload bump), gated on T-KEYTRK; plus
- **a Wave 2 shell-integration item** (the Sample-view + Zone-panel curve-editor UI drawn through
the L1 kit, routing mouse through `velocity_curve`), gated on the foundation track and composing
with the S-VIEW-2 Sample face + S-VIEW-3 envelope-overlay work.
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW), velocity-curve
sub-section; PLAN.md §Phase S — editor view-model redesign (S-VIEW-9/S-VIEW-10 + fork R10-F1). This
Addendum is the *why*; those are the *what/how*.
---
### Addendum — Sample-face recomposition: knob deck, curve popup, full-width hero (Daniel, 2026-07-27, r11)
**Framing.** S-VIEW-1..10 landed and Daniel DAW-tested the three-view editor. The verdict on the
*model* is good (Sample-as-home holds); the verdict on the *composition* is not: a 12-row stack of
full-width horizontal sliders and full-width two-segment toggles reads as a settings form, not an
instrument. r11 recomposes the Sample face into the hardware-sampler grammar the references
(Simpler, Phase Plant, and every hardware groovebox) actually use — **a big waveform and a deck of
small labelled knobs in functional clusters** — and drives the **Wave B editor rebuild**.
**Directives (Daniel, 2026-07-27, relayed):**
- **Radial knobs (issue 4).** All the linear sliders become smaller radial knobs — minimal arc
(~6→4 o'clock), needle indicator, vertical drag. The knob *primitive* is being built on its own
track; r11 specs the layout that consumes it. Group the controls "in a reasonable way," and
**group the envelope controls together intuitively** as a unit.
- **Mode toggles (issue 4).** The pitch-engine and Gate/Trigger toggles must **not** be
full-width — size and place them sensibly within the grouped composition.
- **Velocity curve as button + popup (issue 3d).** Replace the inline curve box with a **small
square preview button** (the curve drawn on it in miniature) **to the right of the
preview-velocity control**; clicking it opens a **popup** hosting the full-size curve editor.
This frees the **waveform preview to become full-width**.
- **Right-click delete (issue 3c).** In the popup curve editor, right-click removes a control
point.
**The composition calls (r11).**
1. **Three fenced knob groups: AMP ENVELOPE / PITCH / PITCH ENV.** The grouping follows the
parameter taxonomy the engine already has, not an invented one. AMP ENVELOPE hosts the
Gate|Trigger toggle in its caption row (the toggle *selects the envelope shape*, so it heads
the envelope group — that is the "envelope controls as a unit" directive made structural) over
the AHDSR knobs (Gate) or Fade In · Length % · Fade Out (Trigger, reordered to time order).
PITCH hosts the Varisp|Preserve toggle + the Key Track knob (key tracking is repitch math — it
lives with the engine that applies it). PITCH ENV is its own small envelope unit (Off|On
caption toggle + P.Attack/P.Decay/P.Depth), mirroring AMP ENVELOPE's grammar.
2. **Toggles live in the group caption rows.** Compact two-segment toggles (Mono/Stereo-sized)
right-anchored in each group's micro-caps caption row — not-full-width, and *placed where they
explain themselves* (each toggle captions the knobs it reconfigures).
3. **The hero inverts from fixed to elastic.** Collapsing ~312px of slider rows into a ~92px deck
makes the control surface the fixed band and the full-width waveform the elastic one — the
Simpler/Phase-Plant proportion. Fork **R11-F1** (Daniel): keep the 840×620 default (hero
~430px, waveform-dominant — the lean) or shrink the default to ~840×520.
4. **Popup over inline for the velocity curve.** The inline box bought a permanently visible
editor at the cost of 168px of hero width for a control most sessions touch once. The
mini-button keeps the curve *visible* (live miniature, glanceable) while the popup gives the
*editor* more room than the inline box ever had. All landed interactions carry over; right-click
becomes the primary node-delete, with Alt-click and drag-off kept as alternates — nothing
dropped.
5. **One grammar on both surfaces (fork R11-F2, lean yes).** The Zone param panel should adopt the
same knob deck + button/popup — Sample and Zone already edit one storage site (S15-F2); the lean
is that they should speak one control grammar too. Daniel's call (it grows Wave B scope).
**What does NOT change (guardrails).** Zero engine/state/persistence change — this is a view
recomposition of *existing* controls; no new params, no component-state bump, VST3 class UID
unchanged. All drawing through the L1 kit by palette role; all layout/hit-test in new pure modules
(`knob_deck`, `curve_popup` — mirrors of `action_bar`/`overflow_menu`); the knobs and the hero's
envelope nodes remain two surfaces on one param model (S-VIEW-F2's structural sync, untouched).
The full inventory contract (every landed element → its r11 home) is in the CONTEXT.md spec.
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW) → "The
Sample-face recomposition (r11)"; PLAN.md §Phase S — editor Wave B (S-VIEW-11/12/13 + forks
R11-F1/R11-F2). This Addendum is the *why*; those are the *what/how*.
---
## 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 01 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.
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.
Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Addendum in §4):
7. **Product name → `ReaSampler 9000`** (VST3 class UID unchanged; **binary filename renamed
too — S-NAME-1 SETTLED r6**, compat is a DAW-verify).
8. **UX overhaul → workflow-first, "better than RS5K"** (**S10S13**; S10 **reframed r6**):
S10 = **capture browser (peak thumbnails + bank filter) + guided single-capture setup**,
**silent-on-open / no auto-select** (reverses S4), multi-zone editing demoted to an opt-in
Zones panel (S10-Z); S11 waveform + draggable loop points; S12 scroll/search over the S10
browser + numeric entry + ADSR; S13 drop-to-load folding in the S8 relay. Metric:
time-to-first-note.
9. **Visual design language → modern/sleek, system-wide — moved to its own Phase L
(2026-07-26).** The look-and-feel work (a shared LICE drawing kit + the surfaces that
adopt it) was originally drafted here as Phase S points S0-DS + S14; it has been **lifted
out of Phase S into its own Phase L** (Look-and-feel) on `dev`, taken up by a parallel team
so Phase S feature work proceeds ungated. S0-DS → **L1** (shared kit); S14 → **L2**
(expanded to a thorough dock-panel layout redesign per DS-3); VST editor + embed restyle →
**L3** (gated on Phase S landing on dev). Forks DS-1 (LICE + WDL free game, no external
frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and
DS-3 (thorough panel layout) are all **SETTLED (2026-07-26)**. Framing + palette + the three
visual directions + forks: `docs/product/visual-design-language.md` (on `dev`); roadmap +
spec: **PLAN.md §Phase L + CONTEXT.md §Phase L** (on `dev`). **S10S13 build with the
current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers
Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26,
post-S1S6 DAW test).
10. **Sampling modes + pitch engine → engine features** (**S15** Trigger vs Gate, **S16**
pitch-engine modes + pitch envelope; see the "sampling modes" r7 + "duration-preserving"
r8 Addenda in §4). Gate = AHDSR held note (hold added to today's ADSR); Trigger = one-shot
with %-length + fade-in/out, ignores note-off; both carry a modifiable start point; Gate
keeps loop points. **S16 reshaped (r8, Daniel's duration-preserving correction):** a per-
zone **pitch-engine mode — Varispeed** (current, cheap, pitch/duration coupled — classic
sampler, right for drums) **vs Preserve** (duration-preserving via a per-voice pitch
shifter — right for tempo-locked loops/phrases). Pitch envelope = per-voice AD, off by
default, riding either engine (biases `ratio_` under Varispeed, the shift amount under
Preserve). WDL verdict corrected: **`WDL_SimplePitchShifter` is the Preserve-engine
candidate** (duration-preserving OLA — RT-viable per-voice with pre-warm; the load-bearing
cost is onset latency), `WDL_Resampler` (sinc) held as a Varispeed-quality upgrade only; no
formant-preserving/elastique in WDL. Forks: **S16-F1** (engine default — lean Preserve,
Daniel's call), **S16-F2** (Preserve impl — WDL shifter first / hand-rolled held), plus
S15-F1 (choke, held) / S15-F2 (param granularity, lean per-zone). Feature set settled;
the engine default is Daniel's fork.
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1S6 the original
dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10S13** the ReaSampler
9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine features);
**CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the channel-mode
/ ingest / bank-generation / sampling-mode / pitch-engine contracts, the UX-overhaul spec,
the product-name convention, the pure/shell split, the WDL finding, 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 (~200400)**, 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.