Merge dev into phase-b-multibank (m11 console cleanup + Phase S/V docs) before dev promotion

# Conflicts:
#	src/actions.cpp
#	src/main.cpp
This commit is contained in:
2026-07-26 15:41:25 -04:00
7 changed files with 1418 additions and 191 deletions
+285
View File
@@ -1043,3 +1043,288 @@ No new REAPER *audio* API. New surfaces to verify before use:
**Build-time residual (not a fork):** the owned-file manifest's exact persistence
shape (sibling `"reasampler"` ext-state key vs. folded into the `banks` blob).
---
# MIDI-playback instrument — additive phase spec (Phase S — Sampler)
> **New pillar, its own lettered phase, and — uniquely — its own build artifact.**
> Every prior phase (M / D / B / R / V) ships inside the one `reaper_reasampler`
> extension binary. Phase S does **not**: a REAPER extension *cannot* be a
> MIDI-triggered instrument (it is not a node in any track's signal chain), so the
> instrument is a **second, separate binary** — a native **VST3** plugin the user
> instantiates on an instrument track — that reads ReaSampler's banks and plays them
> MIDI-triggered. Namespaced **`S` (Sampler)** rather than "D" (which would collide
> with Design View). The `M`/`D`/`B`/`R`/`V` extension pillars are untouched. Product
> framing, the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, and the
> settled decision record: `docs/product/midi-playback.md`. Same standing discipline:
> **verify every Steinberg VST3 SDK and REAPER/SWELL API name/signature against the
> vendored headers before use.**
## What it is
A native **VST3 sampler instrument** — a separate product/artifact from the
extension — that maps ReaSampler's captured bank samples across a MIDI keyboard and
plays them back with a real voice engine (polyphony, velocity, envelopes). The
extension stays the sole owner of **capture + organization**; the instrument is the
**playback surface**. The two are *tightly integrated but distinct acts*: the
extension captures and organizes; the instrument plays. Neither crosses into the
other's role — the instrument never captures, the extension never becomes an
instrument.
**Why a VST3 and not the extension (load-bearing, settled — see D1/D5/D6 below).** An
instrument track's "read live MIDI, emit audio per-voice, in REAPER's
routing/record/render path" contract belongs to VST/VST3/CLAP/JSFX plugins, hosted
through an entirely different mechanism than the extension API. The extension SDK's
audio-adjacent surfaces (`Audio_RegHardwareHook`, `kbd_OnMidiEvent`, `PlayPreview`,
`pcmsrc` subclassing) are each the wrong tool for a live-MIDI instrument — the full
reasoning is in `docs/product/midi-playback.md` §1. The instrument is therefore a
standard VST3 plugin; this is not an engineering-around-able limitation, it is what
the plugin *format* is.
## The three locked decisions this spec assumes
Settled by Daniel (2026-07-26); everything below assumes them. Reasoning preserved in
`docs/product/midi-playback.md` §4.
- **D1 — native VST3.** Not JSFX. Full sampler sophistication, clean integration, and
access to the REAPER VST-host bridge. JSFX retired (cross-platform-for-free is
worthless under D5, and JSFX gets no bridge).
- **D5 — Windows-only, VST3-only, REAPER-only.** No cross-platform DSP/build/signing
matrix, no multi-format wrapper, no standalone-in-other-hosts concern. REAPER-coupling
via the bridge is intended. This is the single biggest simplifier — it deletes most of
what makes VST3 painful.
- **D6 — two products, tightly integrated.** A separate artifact, but **not** a
divorced file-only companion: via the VST-host bridge it reads the live
`"reasampler"` project ext-state and is project-aware.
## The VST-host bridge (the integration mechanism, stated once)
A VST3 *hosted inside REAPER* can call back into REAPER's own API by resolving
function pointers by name over the host callback
(`hostcb(&effect, 0xdeadbeef, 0xdeadf00d, 0, "FunctionName", 0.0)` — the same
string-keyed API table the extension uses; verified in `video_processor.h` and the
`reaper_plugin_functions.h` `GetProjExtState`/`SetProjExtState`/`EnumProjExtState`
entries). The plugin can also fetch its **host context** — the track/take/project it
was instantiated in (opcode `0xdeadf00e`). **Consequence:** the instrument reads the
*same* live `"reasampler"` ext-state that `persist` writes, follows the active
project, and needs no "point me at the bank folder" wiring — it asks REAPER which
project it is in. This capability exists *because* the plugin is hosted in REAPER; it
is the technical affordance D6 leaned on. **Must-verify before build:** confirm the
bridge opcodes and the by-name resolution against the vendored
`vendor/reaper-sdk/sdk/` headers (`reaper_plugin.h`, `video_processor.h`,
`reaper_plugin_functions.h`) — the framing doc's opcode citations are verified from
those headers but the exact call marshalling should be confirmed at the spike.
## The two seams (audio via files, mapping via live state)
- **File seam (audio, permanent).** The sample **audio** is the on-disk 32-bit-float
WAVs — project-relative, travelling with the `.rpp` via the M4 machinery. The
instrument resolves those paths **the same way `persist` does** (a shared convention,
not a re-implementation). There is no live PCM stream across the bridge, by design.
- **Live-state seam (the mapping, via the bridge).** For everything that is *not* raw
audio — the bank index, the mapping, which project is active — the instrument reads
the live `"reasampler"` ext-state via the bridge. It sees what `persist` last wrote
and follows the active project.
## The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)
**The split model (option iii) is the settled answer.** It mirrors the
capture/placement separation:
- **Bank intrinsics (facts about the captured file) live on `Sample`.** **Root note**
(the MIDI note the sample was recorded at, so it can be repitched across the
keyboard — distinct from the existing optional *musical key* field) and **loop
points** (sustain-loop start/end for held notes; sample-accurate, zero-crossing-aware)
are *facts about the file*, analogous to sample rate, length, and peaks. They are added
to `Sample` as an **additive field extension** — the same shape as how `provenance`
was added in M1: new optional fields with JSON round-trip, populated at/after capture,
defaulting cleanly for pre-existing samples. This keeps the bank a clean,
tool-agnostic library (WAVs + facts, readable by anything).
- **The performance map (a creative arrangement) lives in the instrument.** **Key
zones** (low/high note per sample), **velocity layers**, **round-robin groups**,
**amplitude envelopes** (ADSR), and per-sample tuning/gain trim are a *performance
choice*, not a fact about a file — they belong to the instrument, not the bank. Under
the live-state seam the instrument may still *read* performance-map data out of shared
`"reasampler"` state, so "who owns which field" is a data-ownership decision, not a
transport one.
**Why the intrinsic fields are added early (D-B, the backfill-cliff reasoning).** The
`Sample` field addition is scheduled as an **early Phase S point** even though the
instrument that consumes them lands later. Rationale (the *design-the-seam-even-if-you-
defer-the-feature* instinct, same as Fork R-D's owned-file manifest): if the fields are
added only when the instrument needs them, every sample captured before then lacks a
root note / loop points and must be backfilled by hand. Adding the fields now — so
capture starts populating them (or at least defaulting them cleanly) — costs almost
nothing and closes the cliff. The field addition touches the **extension** codebase
(`bank_model` + capture + persist), is independently shippable, and lands before the
instrument build leans on it.
## Scope tiers (D-C, settled 2026-07-26 — Tier 01 committed, Tier 2 held, Tier 3 optional-forever)
Tiers are minimal → sophisticated; **Tier 0 delivers the core promise** and each tier
above is optional depth, not a prerequisite for the one below.
- **Tier 0 — "the bank plays" (committed).** One sample mapped chromatically across
the keyboard from its root note; basic polyphony; a simple amp envelope; velocity →
volume. The honest MVP: point a bank sample at a MIDI track and play it repitched.
On the native path this is the `SingleComponentEffect` skeleton plus a single-voice
core, editor deferrable behind a parameters-only default view.
- **Tier 1 — "a keymap" (committed).** Multiple samples zoned across the keyboard (key
ranges), each with its own root note — a captured *kit* or a *multisampled instrument*
plays correctly. This is where the root-note + key-range seam fields earn their place.
One sample per key-region.
- **Tier 2 — "expressive" (HELD — noted, not specified).** Velocity layers, round-robin
(the anti-machine-gun feature), full ADSR, per-sample tuning/gain trim, sustain loops.
Where it becomes a tool people reach for. **Explicitly a follow-on** — its points are
not drawn up in this spec; it is recorded as the next depth increment once Tier 01
proves the instrument belongs in ReaSampler's world.
- **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch envelopes,
LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave room for,
never a commitment. Do **not** let a Tier-3 feature list inflate the build-shape
decisions.
## The build shape (D-A, settled 2026-07-26 — bare Steinberg VST3 SDK + LICE editor)
**Settled: bare Steinberg VST3 SDK, no JUCE, with the editor drawn in the same
LICE/SWELL stack `bank_panel` already uses.** Reasoning (full assessment in
`docs/product/midi-playback.md` §1a and §4 D-A):
- **The audio-processing scaffolding is bounded.** Using `SingleComponentEffect` (the
SDK's combined processor+controller base — sanctioned for a non-distributable,
REAPER-only plugin under D5/D6) plus the SDK's factory macros, a silent-but-loading
VST3 instrument skeleton is order-of-magnitude a few-hundred lines of
adapt-from-example ceremony, written once. The AGain / Note Expression Synth SDK
examples are the copy-source. Not a tar pit.
- **D5 deletes JUCE's biggest justification.** JUCE exists largely for multi-format /
cross-platform, both of which D5 removed. Its one genuine remaining pull is the editor
UI — and ReaSampler is the atypical case where even that is weak, because it already
has a working, docked, custom-drawn LICE UI (`bank_panel`) and a house style. Drawing
the editor in a VST3 `IPlugView` that hosts a LICE surface reuses that muscle, keeps
the look house-consistent, and avoids JUCE's AGPL-or-pay license posture (the Steinberg
SDK is permissive, no revenue gate).
- **The one real edge — the `IPlugView`↔LICE bridge** (window lifecycle, sizing, event
routing from the host into the draw/hit-test loop) — is *the same class of work*
ReaSampler already did to dock `bank_panel`, not a new competence, but it is less
trodden than dropping in a JUCE editor. It is therefore the phase's **opening spike**
(below), which also converts §1a's experienced-estimates (Windows module-export
symbol names, factory-macro spellings, exact bridge marshalling) into verified fact
before the engine build leans on them. VSTGUI (the SDK's bundled toolkit) is the noted
fallback rung *only if* the LICE bridge proves gnarlier than the panel work suggests;
JUCE is the last resort behind that.
## The pure core (D3 — the load-bearing split, transplanted)
**The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation,
and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module** — the
mirror of `bank_model` / `peaks` / `view_mode_model` / `bank_book`, tested in the CTest
harness outside any host. This is the heart of the phase; **test it hard.** The VST3
wrapper — the `SingleComponentEffect` subclass, bus setup, the `process` call
marshalling MIDI→core and core→audio-buffer, the `IPlugView` LICE editor, and the bridge
calls that read `"reasampler"` ext-state — is the **thin shell**, the only part that
touches VST3 or REAPER at all. Critically, this split is **invariant under the build-shape
choice**: whether the shell is bare-SDK or (hypothetically) JUCE, the pure core is
identical, REAPER-free, and tested the same way. The format choice is a shell choice; the
core is invariant.
## Module architecture (preserve the pure/shell split — in the new artifact)
Pure (no REAPER types, no VST3 types, unit-tested — the mirror of `bank_model`):
- **Sampler core** — voice allocation/polyphony, amplitude envelope (ADSR), key→sample
and velocity→sample mapping (the keymap), repitch/interpolation from root note, and
keymap resolution. REAPER-free *and* VST3-free, unit-tested in CTest against known
signals (mirror of how `peaks` asserts an envelope). This is D3's pure core and the
heart of the phase.
Shell (VST3-facing / REAPER-facing, thin):
- **VST3 wrapper** — `SingleComponentEffect` subclass: `initialize` (declare an event
input bus + an audio output bus, no audio input), `setupProcessing`, `setActive`,
`setState`/`getState`, and the hot-path `process` that reads MIDI off the event bus,
drives the pure core, and writes the core's per-voice audio to the output bus. Plus the
module factory (`GetPluginFactory` + Windows `InitDll`/`ExitDll` — verify exact export
names at the spike).
- **`IPlugView` LICE editor** — hosts a LICE-drawn surface in the VST3 view seat
(window creation/sizing, host→draw/hit-test event routing). Reuses the `bank_panel`
LICE/SWELL competence and house style.
- **Bridge/state reader** — resolves `GetProjExtState`/`EnumProjExtState` by name over
the host callback, fetches the host project context, reads the live `"reasampler"`
ext-state (bank index + intrinsic fields + performance map), and resolves WAV audio
paths the same project-relative way `persist` does.
## Precision / invariant implications
- **The bank is one source; the instrument is another view of it (never a fork).** The
instrument is a pure *consumer* of the bank — it does not copy samples, does not own a
private sample store, and does not mutate the bank. The bank stays the single
authoritative artifact (the one-source-multiple-views instinct). Any instrument path
that writes back into the bank or keeps its own sample copies is a bug.
- **Capture/placement/playback stay distinct acts.** The instrument reads and plays; it
never captures and never inserts into the arrange. The capture load-bearing principle
is untouched — Phase S adds a *third* distinct act (playback) without weakening the
capture↔placement separation.
- **`Sample` field addition is additive and lossless.** Root note + loop points are new
optional fields with JSON round-trip, defaulting cleanly for samples captured before
the addition — the same additive, backward-compatible shape as `provenance` (M1). No
existing `Sample` field changes; no `BankIndex` behavior changes.
- **Relative-paths-only survives.** The instrument resolves audio via the M4
project-relative machinery; it introduces no absolute paths.
## Embedded TCP/MCP UI (D-D, settled 2026-07-26 — SCHEDULED as a later Phase S point)
A REAPER-hosted VST3 can draw its own UI **inline in the track/mixer control panel**
via `reaper_plugin_fx_embed.h` (the plugin implements `IReaperUIEmbedInterface`; the
same Cockos surface REAPER's own embedded FX use). A ReaSampler instrument can render a
compact keymap/level strip inline in the TCP/MCP, not only in its own window. Because
this uses the **same LICE-class drawing as the D-A editor path**, it composes naturally
with the bare-SDK-plus-LICE build — the groundwork is the groundwork.
**Settled: scheduled, not deferred.** This is a real, in-phase later point — it lands
**after** the main `IPlugView` editor exists (it composes with that LICE path), not a
someday-note. It is polish, not a Tier-0 need, so it sequences last in the phase; but it
is on the roadmap. **Must-verify before build:** the `IReaperUIEmbedInterface` contract
and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`.
## REAPER / Steinberg API surface (verify all signatures)
- **VST3 SDK (a new vendored dependency — vendor it at the spike).** `FUnknown` and the
`IComponent` / `IAudioProcessor` / `IEditController` interface family; the
`SingleComponentEffect` / `EditControllerEx1` / `AudioEffect` base classes; the class
factory (`GetPluginFactory` + factory macros); `IPlugView` for the editor;
`ProcessData` / `ProcessSetup` for the hot path. **Verify** interface members, the
base-class overrides, factory-macro spellings, and the Windows module-export symbol
names (`InitDll`/`ExitDll`/`GetPluginFactory`) against the vendored SDK at the spike —
the framing doc flags several of these as experienced estimates.
- **REAPER VST-host bridge.** `hostcb` opcode `0xdeadf00d` (resolve API function by
name) and `0xdeadf00e` (host context); the by-name resolution of
`GetProjExtState`/`SetProjExtState`/`EnumProjExtState`. **Verify** against
`vendor/reaper-sdk/sdk/reaper_plugin.h` + `video_processor.h` +
`reaper_plugin_functions.h`.
- **Embedded UI (D-D, later point).** `IReaperUIEmbedInterface` and the embed
message/lifecycle contract — verify against
`vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h` before use.
- **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the
`IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event
routing) — the least-trodden edge of the phase.
## Non-goals / guardrails
- **The instrument never captures and never inserts into the arrange.** Playback is a
read-only act over the bank. Any instrument path that captures, places a timeline item,
or writes back into the bank is a bug — reject in review.
- **The instrument keeps no private copy of the samples.** It consumes the one
authoritative bank; per-instance sample stores are a non-goal (they refork the source
the one-source-multiple-views instinct keeps single).
- **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do
not add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
- **The pure core stays REAPER-free *and* VST3-free.** The voice engine / envelope /
keymap / repitch module takes no VST3 or REAPER type at its boundary — the shell
marshals. Any VST3 or REAPER type leaking into the core is a bug (the D3 split).
- **Additive to the extension.** The `Sample` intrinsic-field addition is additive
(new optional fields; no existing field or `BankIndex` behavior changes); everything
else in Phase S lives in the *second* artifact and does not alter the extension's
M/D/B/R/V pillars.
- **Do not spec Tier 2/3.** Tier 2 is held (noted, not specified); Tier 3 is
optional-forever. Do not let their feature lists drive Tier 01's build shape.
- **Verify Steinberg SDK, bridge, embed, and LICE-view surfaces** against the vendored
headers before use — several §1a claims are experienced estimates until the spike
confirms them.
+245
View File
@@ -435,3 +435,248 @@ untouched.
Both docs of record: `docs/product/removal-and-prune.md` §Fork R-C/R-D/R-E and
CONTEXT.md §Prune (Settled forks).
---
# Phase V — Versioning & release (release-milestone pillar, own lettered namespace)
> **New pillar, own lettered namespace.** Version scheme + beta side-channel — the
> release-deployment path M11 forward-implies but that had no phase or points.
> Namespaced **`V` (Versioning)** alongside `M`/`D`/`B`/`R` because it is a distinct
> concern (build identity + channel isolation) that touches CMake, `main.cpp`'s
> forever-stable command-id contract, and the `"reasampler"` ext-state — not a
> capture step. Product framing + the full option analysis: `docs/product/versioning-
> and-release.md`. **Forks V1V4 SETTLED (Daniel, 2026-07-26)** — semver via
> `project(VERSION)` + ext-state version stamp prioritized first-wave; plain `-beta`
> suffix; console line + panel readout (about-box deferred); and **beta ships as a
> separate, fully isolated coexisting binary (beta-in-isolation)** — a reversal of the
> note's original one-at-a-time recommendation. Deploy/CD wiring (now two named
> artifacts per platform) hands off to dev-ops. Build-scoped points to be drawn up.
## Settled decisions (Daniel, 2026-07-26 — see `docs/product/versioning-and-release.md`)
- **V1 — version scheme: APPROVED as recommended.** Semver, single source of truth in
CMake `project(reaper_reasampler VERSION x.y.z)`, threaded into the binary. **The
`"reasampler"` ext-state writing-version stamp is prioritized to the first wave, not
deferred** — every project saved without the stamp is harder to migrate later, so
the migration seam lands early.
- **V2 — beta suffix: plain `-beta`.** `project(VERSION)` owns the release triple; beta
carries a `-beta` suffix. `git describe` decoration considered and rejected for
legibility.
- **V3 — user-visible home: recommendation accepted.** Startup console line
(`"ReaSampler x.y.z loaded"`) + a bank-panel version/channel readout; about-box
deferred. Panel placement is the residual polish call.
- **V4 — beta channel shape: BETA-IN-ISOLATION (full coexistence).** *Reverses the
original recommendation.* Beta ships as a **separate binary** (`reaper_reasampler_
beta`) with an **isolated ext-state namespace** (distinct from stable's
`"reasampler"` — a beta cannot corrupt a stable project's saved state) and an
**isolated forever-stable command-id prefix** (beta/stable keybindings don't
collide), so both install and run side-by-side. Built through a compile-time channel
flag (`-DREASAMPLER_CHANNEL=beta`) as the mechanism. **Two permanent commitments
locked in:** a second forever-stable command-id prefix and a second ext-state
namespace. **Dev-ops:** the build now produces two named artifacts (stable + beta)
per platform.
---
# Phase S — MIDI-playback instrument (native VST3 sampler; a second build artifact)
> **New pillar, own lettered namespace, and — uniquely — a second build artifact.**
> Every prior phase ships inside the one `reaper_reasampler` extension binary; Phase
> S does not. A REAPER extension *cannot* be a MIDI-triggered instrument (it is not a
> node in any track's signal chain), so the instrument is a **separate native VST3
> plugin** the user instantiates on an instrument track, reading ReaSampler's banks
> and playing them MIDI-triggered. Namespaced **`S` (Sampler)** rather than "D"
> (Daniel's call — "D" collides with Design View). Authoritative spec: **CONTEXT.md
> §MIDI-playback instrument — additive phase spec (Phase S)**. Product framing +
> the settled decision record (D1/D5/D6 locked, D-A..D-D settled 2026-07-26):
> `docs/product/midi-playback.md`. When a point lands, doc-keeper moves it to
> `COMPLETED.md`.
>
> **Locked (see `docs/product/midi-playback.md` §4):** D1 native VST3 (not JSFX);
> D5 Windows-only / VST3-only / REAPER-only; D6 two products, tightly integrated via
> the VST-host bridge (live `"reasampler"` ext-state, project-aware). **Settled forks
> (2026-07-26):** D-A bare Steinberg VST3 SDK + LICE editor (no JUCE); D-B split seam
> with root-note + loop-points added to `Sample` *now*; D-C Tier 01 committed (Tier 2
> held, Tier 3 optional-forever); D-D embedded TCP/MCP UI **scheduled** as a later
> in-phase point (after the main editor exists).
>
> **Second build artifact (load-bearing, flagged up front):** Phase S produces a
> *separate* VST3 binary alongside `reaper_reasampler`. The Steinberg VST3 SDK is a
> **new vendored dependency** (vendor at the spike — an implementation-time
> prerequisite, not done here), and CMake grows a second target with Windows VST3
> module-export/bundle wiring. Both are established by S1 so nothing downstream leans
> on an unbuilt target.
## S1 — opening spike: VST3 skeleton + `IPlugView`↔LICE bridge (proof + second target)
**Goal:** Stand up the second build artifact and prove the two least-trodden
unknowns before the engine build leans on them: (1) a silent-but-loading VST3
`SingleComponentEffect` skeleton that REAPER hosts, and (2) an `IPlugView` that hosts
a LICE-drawn surface. Converts §1a's experienced-estimates (Windows module-export
names, factory-macro spellings, exact bridge marshalling) into verified fact.
CONTEXT.md §Phase S (build shape, module architecture, API surface).
**Prerequisite (implementation-time):** vendor the Steinberg VST3 SDK (a new
submodule/dependency alongside `reaper-sdk` / `WDL`); confirm whether VSTGUI is
bundled (moot for D-A but resolves the noted fallback rung).
**Verify (in DAW):** the VST3 skeleton loads in REAPER on an instrument track,
enumerates via `GetPluginFactory`, sets up an event-in + audio-out bus, and runs an
empty `process` without error; an `IPlugView` opens and draws a LICE surface with a
working hit-test; the VST-host bridge resolves `GetProjExtState` by name and reads a
known `"reasampler"` value. Nothing plays yet — this is the loading/drawing/bridge
proof.
- [ ] CMake second target: a separate VST3 module artifact built alongside
`reaper_reasampler` (Windows VST3 export/bundle wiring; `GetPluginFactory` +
`InitDll`/`ExitDll`**verify exact export names against the vendored SDK**).
- [ ] `SingleComponentEffect` skeleton: factory + class registration, `initialize`
declaring an event-input bus + an audio-output bus (no audio input),
`setupProcessing`, `setActive`, empty `process`. Loads silently in REAPER.
- [ ] `IPlugView`↔LICE bridge spike: open a plugin editor window hosting a LICE-drawn
surface (window creation/sizing, host→draw/hit-test event routing), reusing the
`bank_panel` LICE/SWELL competence. **The decision's one real unknown — prove it
here.** (VSTGUI is the noted fallback only if this proves gnarlier than the panel
work suggests.)
- [ ] Bridge read spike: resolve `GetProjExtState`/`EnumProjExtState` by name over the
host callback (`hostcb` opcode `0xdeadf00d`), fetch host project context
(`0xdeadf00e`), and read a known `"reasampler"` ext-state value. **Verify opcodes +
marshalling against `reaper_plugin.h` / `video_processor.h` /
`reaper_plugin_functions.h`.**
## S2 — `Sample` intrinsic fields (root note + loop points; in the *extension*)
**Goal:** Add the two bank-intrinsic seam fields to `Sample`**root note** (MIDI
note the sample was recorded at; distinct from the existing optional *musical key*)
and **loop points** (sustain-loop start/end, sample-accurate, zero-crossing-aware) —
as an additive field extension with JSON round-trip, populated at/after capture. This
touches the **extension** codebase, is independently shippable, and lands early to
close the backfill cliff before the instrument consumes the fields. CONTEXT.md
§Phase S (seam fields, D-B). **Same additive shape as `provenance` (M1).**
**Verify:** CTest green. Round-trip lossless across the new fields; pre-existing
samples (no root note / loop points) deserialize with clean defaults (no loss, no
migration break); capture populates root note where derivable and loop points where
set; relative-paths-only unaffected; `BankIndex` behavior unchanged (purely
additive).
**Depends on:** nothing in Phase S (extension-only; can land before or in parallel
with S1).
- [ ] Add `rootNote` (optional MIDI note) + `loopStart`/`loopEnd` (optional
sample-accurate loop points) to `Sample`; JSON serialize/deserialize with clean
defaults for samples lacking them (additive, backward-compatible — mirror of how
`provenance` was added).
- [ ] Populate the fields on capture where derivable (root note) / settable (loop
points); leave them cleanly empty otherwise. No existing `Sample` field changes.
- [ ] Tests: full round-trip lossless including the new fields; a legacy `Sample`
JSON (no new fields) parses with defaults and re-serializes without loss; additive
invariant (no change to existing fields, dedup, tier, or `BankIndex` behavior).
## S3 — pure sampler core (voice engine / envelope / keymap / repitch)
**Goal:** The REAPER-free **and** VST3-free sampler core — voice allocation/polyphony,
amplitude envelope (ADSR), key→sample and velocity→sample mapping (the keymap),
repitch/interpolation from root note, keymap resolution — unit-tested in CTest against
known signals. **The heart of the phase (D3); the mirror of
`bank_model`/`peaks`/`view_mode_model`/`bank_book`; test it hard.** The core is
invariant under the build-shape choice — no VST3 or REAPER type at its boundary.
CONTEXT.md §Phase S (pure core, module architecture).
**Verify:** CTest green. Voice allocation is correct under polyphony (note-on/off,
voice stealing where bounded); ADSR shape asserted against a known signal (mirror of
`peaks`); repitch from root note produces the expected pitch ratio; keymap resolution
maps a (note, velocity) to the correct sample/zone; the core takes and returns only
plain data (no VST3/REAPER types) — enforced by the test target linking neither SDK.
**Depends on:** S2 (consumes `rootNote` / loop points as core inputs).
- [ ] Voice engine: polyphonic voice allocation (note-on/off, bounded voice stealing),
per-voice state, mono-and-basic-polyphony sufficient for Tier 0.
- [ ] Amplitude envelope (ADSR) math — asserted against a known signal.
- [ ] Repitch/interpolation from root note (chromatic pitch ratio across the
keyboard); loop-point-aware sustain for held notes.
- [ ] Keymap model + resolution: key ranges/zones (Tier-1 shape) and the
(note, velocity) → sample/zone query; Tier-0 chromatic-from-single-root as the
degenerate case.
- [ ] Tests: voice allocation under polyphony + stealing; ADSR envelope shape;
repitch pitch-ratio correctness; keymap resolution (single-root chromatic + zoned);
core boundary is plain-data-only (no VST3/REAPER types).
## S4 — Tier 0: "the bank plays" (single sample, chromatic)
**Goal:** The honest MVP — one bank sample mapped chromatically across the keyboard
from its root note, basic polyphony, a simple amp envelope, velocity→volume. Wire the
S3 core into the S1 VST3 shell over the live-state seam (bridge-read bank + audio via
the M4 project-relative path machinery). Editor deferrable behind a parameters-only
default view. CONTEXT.md §Phase S (Tier 0, seams). **Delivers the core promise.**
**Verify (in DAW):** on an instrument track, the VST3 plays a chosen bank sample
MIDI-triggered, repitched chromatically from its root note, with basic polyphony,
an amp envelope, and velocity→volume; it reads the live `"reasampler"` bank via the
bridge and resolves the WAV audio the same project-relative way `persist` does;
following the active project works; it never captures and never inserts into the
arrange (read-only over the bank).
**Depends on:** S1, S2, S3.
- [ ] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus,
drive the S3 core, write per-voice audio to the output bus.
- [ ] Live-state seam: read the bank index + selected sample's root note from
`"reasampler"` ext-state via the bridge; resolve the WAV audio path the M4
project-relative way (shared convention with `persist`, not re-implemented).
- [ ] Sample selection UI (minimal, in the `IPlugView` LICE editor or a
parameters-only default view): choose which bank sample this instance plays.
- [ ] Tier-0 playback: chromatic-from-root, basic polyphony, amp envelope,
velocity→volume — plays in REAPER's routing/record/render path like any VSTi.
## S5 — Tier 1: "a keymap" (zoned multisamples, per-sample root notes)
**Goal:** Multiple bank samples zoned across the keyboard (key ranges), each with its
own root note — a captured *kit* (one-shots) or a *multisampled instrument* (same
instrument sampled at several pitches) plays correctly. One sample per key-region.
CONTEXT.md §Phase S (Tier 1). **Where the root-note + key-range seam fields earn
their place.**
**Verify (in DAW):** a keymap of several bank samples plays correctly zoned across
the keyboard, each repitched from its own root note within its range; a captured kit
and a multisampled instrument both play as expected; the keymap is authored in the
instrument (performance map) while root notes come from the bank intrinsics (S2);
editing the keymap does not touch the bank.
**Depends on:** S4.
- [ ] Keymap editor in the `IPlugView` LICE editor: assign bank samples to key ranges
(low/high note per sample), each with its own root note (from S2 intrinsics,
overridable in the performance map).
- [ ] Tier-1 playback: zoned resolution — a note picks its zone's sample and repitches
from that sample's root note; one sample per key-region.
- [ ] Performance-map persistence: the keymap (zones, per-sample assignment) is the
instrument's own state — held in the instrument (read/written over the live
`"reasampler"` seam per D-B's data-ownership split), never written back as a bank
intrinsic.
## S6 — embedded TCP/MCP UI (D-D — scheduled in-phase, after the editor)
**Goal:** Render a compact keymap/level strip **inline in the track/mixer control
panel** via `reaper_plugin_fx_embed.h` (`IReaperUIEmbedInterface`) — the same
Cockos surface REAPER's own embedded FX use — so the instrument draws inline, not only
in its own window. Composes with the S1/S5 LICE editor path (same LICE-class drawing).
**Scheduled, not deferred (D-D settled 2026-07-26):** a real later point, sequenced
last because it is polish over a Tier-0 need — but on the roadmap. CONTEXT.md §Phase S
(embedded UI, D-D).
**Verify (in DAW):** the instrument draws a compact inline strip in the TCP/MCP (not
only its own editor window); the inline surface reflects and (where offered) edits the
keymap/levels; the embed lifecycle is clean (open/close/resize); the same LICE drawing
as the main editor is reused.
**Depends on:** S5 (composes over the existing LICE editor). **Must-verify before
build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle against
`vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`.
- [ ] Implement `IReaperUIEmbedInterface` on the VST3; draw a compact keymap/level
strip inline in the TCP/MCP using the same LICE surface as the editor.
- [ ] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects
the live keymap/levels.
## Phase S — held and optional-forever (noted, not specified)
- **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun),
full ADSR, per-sample tuning/gain trim, sustain loops. The next depth increment once
Tier 01 proves the instrument belongs — **its points are not drawn up here.**
- **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch
envelopes, LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave
room for, never a commitment.
## Phase S — must-verify-before-build (carried from CONTEXT.md §Phase S)
- **Steinberg VST3 SDK surface** — interface members, base-class overrides,
factory-macro spellings, Windows module-export symbol names
(`InitDll`/`ExitDll`/`GetPluginFactory`), and whether VSTGUI is bundled. Several are
§1a experienced-estimates until S1 confirms them against the vendored SDK.
- **VST-host bridge** — opcodes `0xdeadf00d` (resolve-by-name) / `0xdeadf00e` (host
context) and the exact call marshalling, against `reaper_plugin.h` /
`video_processor.h` / `reaper_plugin_functions.h`.
- **`IReaperUIEmbedInterface`** — embed contract + message/lifecycle, against
`reaper_plugin_fx_embed.h` (needed only at S6).
+467 -120
View File
@@ -1,14 +1,72 @@
# MIDI playback — opportunity & design-space framing
Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured
banks. This is a **discussion-shaping doc, not a build plan** — no phase, no PLAN.md
points, no settled forks yet. It exists so Daniel and the-boss can react to an honest
map of the option space before anything is scoped.
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). Grounded in the vendored REAPER SDK
headers (`vendor/reaper-sdk/sdk/reaper_plugin.h`, `reaper_plugin_functions.h`) — the
plugin-format claims below are checked against those headers, not asserted. The
genuine forks are flagged as **Daniel's to decide**; nothing here pre-decides them.
Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r4)**. The
"no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the
instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D"
collides with the existing Design View phase). **PLAN.md §Phase S is now the
authoritative roadmap; CONTEXT.md §Phase S is the authoritative spec.** This doc is the
framing/decision record they point back to. Prior revisions (a) established that a REAPER
*extension* cannot be a MIDI instrument, (b) corrected a material omission — REAPER's
**VST-host bridge**, which lets a VST3 plugin *hosted inside REAPER* call back into
REAPER's own API by resolving function pointers by name over the host callback — (c) [r3]
folded in **Daniel's locked decisions** (D1, D5, D6) and added the honest **bare-VST3
assessment** (§1a). This revision [r4] records **Daniel's calls on the four residual
forks D-A..D-D** (all settled 2026-07-26) and points to the now-live phase docs.
Grounded in
the vendored REAPER SDK headers (`vendor/reaper-sdk/sdk/reaper_plugin.h`,
`reaper_plugin_functions.h`, `video_processor.h`, `reaper_plugin_fx_embed.h`), REAPER's
published VST-extensions SDK page (`reaper.fm/sdk/vst/vst_ext.php`), and the Steinberg
VST3 SDK documentation (portal + class reference — cited inline in §1a). Where a claim is
**experienced estimate** rather than a **verified-from-source** fact, it is flagged as
such in §1a. The genuine remaining forks are flagged as **Daniel's to decide**; nothing
here pre-decides them.
> ## Locked decisions (SETTLED — do not re-present as open)
>
> Daniel has nailed these down. Everything downstream assumes them.
>
> - **D6 → DECIDED: two products, but integrated.** The instrument is a *separate*
> product/artifact from the ReaSampler extension, but *tightly integrated* via the
> VST-host bridge — it reads live `"reasampler"` project ext-state and is project-aware.
> **Not** a divorced, file-only companion. (The old §4 D6 reasoning is preserved below,
> marked decided.)
> - **D1 → DECIDED: native VST3.** JSFX is off the table. The instrument is a native VST3
> plugin. (The old JSFX option and its whole-doc entanglement are retired below, marked
> decided; the JSFX reasoning is kept as the record of *why* it was considered and set
> aside.)
> - **D5 → DECIDED: no cross-platform, no multiformat. REAPER-specific, Windows-only.**
> The existing extension is Windows-only; Daniel does not work on other platforms. So:
> **no mac/Linux DSP/build/signing matrix, no CLAP-for-portability argument, no
> "runs standalone in other hosts" concern.** REAPER-coupling via the bridge is fine and
> intended. This collapses several costs the prior draft carried (§1a, §4).
>
> **What these lock-downs do to the shape:** the doc is no longer weighing "extension vs.
> plugin," "JSFX vs. native," or "portable vs. coupled." It is weighing **how to build one
> native, Windows-only, REAPER-coupled VST3 instrument** — and the single biggest live
> question inside that is now **bare Steinberg VST3 SDK vs. JUCE** (§1a, D-A below).
> **The bridge, stated once, up front (the correction).** A hosted VST is not limited to
> scraping bank WAVs + JSON off disk. REAPER hands the plugin its host callback; calling
> `hostcb(&effect, 0xdeadbeef, 0xdeadf00d, 0, "FunctionName", 0.0)` resolves *any* REAPER
> API function by name — the same string-keyed API surface the extension uses (verified:
> `video_processor.h` line 44 imports `video_CreateVideoProcessor` exactly this way;
> `GetProjExtState`/`SetProjExtState`/`EnumProjExtState` are ordinary entries in that same
> string-keyed table, `reaper_plugin_functions.h` lines 8376/8796/9969). The plugin can
> also fetch its **host context** — the track/take/project it's instantiated in (sibling
> opcode `0xdeadf00e`, `video_processor.h` line 40; CLAP has `clap_get_reaper_context`,
> `reaper_plugin.h` line 142). **Consequence:** a native ReaSampler instrument can read
> the *same* `"reasampler"` project ext-state that `persist` writes — live, project-aware,
> following the active project — not a file it re-parses off disk. This is a
> REAPER-VST-specific capability (it exists because the plugin is hosted *in REAPER*), and
> it is the thumb on the scale the prior draft failed to weigh. The cost of leaning on it
> is stated honestly in D1/D2: it makes the native plugin **REAPER-coupled.**
---
@@ -84,56 +142,239 @@ 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 three honest options
### The shape, now that D1/D6 are locked
**Option A — a real VSTi/instrument plugin (JUCE or bare VST3/CLAP SDK) that reads
ReaSampler's banks.** A separate build artifact: a VST3 (and/or CLAP) sampler plugin
that the user instantiates on an instrument track. It reads the bank JSON + WAV layout
ReaSampler writes, maps samples across the keyboard, and plays them MIDI-triggered with
a real voice engine. This is the "sophisticated sampler" answer.
- *Gives you:* everything an instrument is — polyphony, velocity, envelopes, the works,
fully integrated into REAPER's routing/render/record path like any VSTi.
- *Costs:* a **second codebase in a second plugin format**, almost certainly a new
dependency (JUCE is the pragmatic choice; bare VST3 SDK is more code, CLAP is leaner
but younger). It is a real DSP/voice-engine build, not a weekend. Cross-platform DSP,
its own build/release/signing story, its own UI toolkit. This is a **product-sized
commitment**, not a feature.
The old three-way option set (native VSTi / JSFX / hybrid) has collapsed to a single
resolved shape:
**Option B — a JSFX sampler.** JSFX is REAPER's built-in scriptable plugin format
(text `.jsfx` files, JIT-compiled by REAPER, hostable as an instrument). A JSFX
instrument *can* receive live MIDI and emit audio on a track. It can load samples
(`Xen`-style file reads / the JSFX file/serialize API) and play them back.
- *Gives you:* a real in-track instrument with **zero new binary, zero new SDK, zero
JUCE** — ships as a text file alongside the extension, cross-platform for free
(REAPER runs the JIT everywhere it runs).
- *Costs:* JSFX is a constrained DSP scripting language, not C++. A polyphonic
multisample engine with round-robin/velocity-layers/streaming is *doable* but you're
writing DSP in JSFX's idiom, and large-sample streaming / disk I/O is more awkward
than in a native plugin. Reading ReaSampler's JSON index from JSFX is friction (JSFX
is not a general-purpose file parser). Best fit for a **minimal-to-mid** sampler, a
real ceiling for a **sophisticated** one.
**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.
**Option C — the hybrid (recommended framing to explore first).** Keep the extension
as the sole owner of **capture + organization** (which is its whole existing identity
and the load-bearing "capture and placement are separate acts" principle). Add a
**separate instrument** (Option A *or* B) that **consumes the banks** as a
shared-artifact contract. The extension never becomes an instrument; the instrument
never captures. Each does what its format is good at. This is the honest shape of the
whole thing — the two options above are really "which instrument technology" *within*
the hybrid, because the extension is staying regardless.
*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.
The real fork, then, is **not** "extension vs. plugin" (the extension stays either
way) — it is **"which instrument format consumes the banks: JSFX or native VSTi/CLAP,"**
and **"how much sampler do we actually want."** Those are §3 and §4.
*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
The obvious shared artifact is what ReaSampler already produces: **the bank folder
(project-relative WAVs) + the bank/index JSON.** The question is whether that JSON is
*sufficient* for a playback engine, or whether playback needs mapping data the index
doesn't carry today.
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,
@@ -179,18 +420,16 @@ opinionated part. It also means **the bank index grows only by file-intrinsic fi
seam ReaSampler already knows how to add (mirror of how `provenance` was added as a
field in M1 and populated later).
**A note on format:** if the instrument is native (Option A), it reads the bank JSON
directly — trivial. If it's JSFX (Option B), reading arbitrary JSON is friction; the
seam might need a **simpler sidecar** (a flat `.txt`/key-value map ReaSampler writes
next to the bank, JSFX-parseable) rather than making JSFX parse the index JSON. That's
a concrete cost of the JSFX path and a reason the seam design and the format choice are
coupled.
**Portability caveat:** the bank is project-relative and travels with the `.rpp`
(M4 machinery). Any instrument consuming it must resolve paths the same way — so the
instrument needs to know the *current* project bank folder. A native plugin instance
on a track can be told its folder (saved in plugin state); a JSFX likewise. This is a
solvable wiring detail but a real one — flag it, don't hand-wave it.
**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.
---
@@ -203,8 +442,9 @@ 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 JSFX path this is a
genuinely small build; on the native path it's the skeleton of the plugin.
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
@@ -239,74 +479,181 @@ instrument keeps its own copy of the samples").
---
## 4. Risks & open decisions — Daniel's to call
## 4. Decisions — settled and residual
None of these are pre-decided here. Each is a genuine fork.
### Settled (DECIDED — reasoning preserved, not re-opened)
**D1 — Instrument format: JSFX vs. native VSTi (VST3/CLAP, likely via JUCE).** The
central fork. JSFX = no new binary, no JUCE, cross-platform free, real ceiling on
sophistication and awkward bank-JSON reading. Native = full sophistication and clean
JSON integration, at the cost of a whole second codebase in a second format with its
own build/release/dependency/signing story. *My lean, for discussion only:* if the goal
is Tier 01, **prototype in JSFX first** — it proves the seam and the value with near-zero
format commitment, and if it hits a ceiling the seam you designed still serves a later
native build. Reach for native when Tier 2+ is a firm goal, not before. But this is
squarely Daniel's call and depends on how sophisticated he actually wants this.
These are locked. The reasoning is kept as the record of *why*, so the choices don't get
silently re-litigated.
**D2Whether to take a JUCE (or any external plugin-SDK) dependency at all.** The
project today is a clean C++ extension with two vendored submodules and a proud
pure-core/shell discipline. A native instrument means a *third* major dependency and a
second build target of a fundamentally different kind. That's a real architectural
weight. JSFX sidesteps it entirely. Flagging it as its own decision because "should we
depend on JUCE" is a bigger standing commitment than "should we build a sampler."
**D1Instrument 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.
**D3Does the pure-core discipline survive the format boundary?** ReaSampler's
identity is *pure REAPER-free testable core + thin shells*. A sampler's **voice engine,
envelope math, key/velocity mapping, and repitch logic are exactly the kind of pure,
testable core** this project excels at — they could live in a REAPER-free, DAW-free,
unit-tested module (mirror of `bank_model`/`peaks`/`view_mode_model`) with the plugin
format (JSFX or VST3 wrapper) as the thin shell around it. **This is the most
ReaSampler-native way to build it** and I'd argue strongly for it regardless of D1: the
sampler DSP core is pure and tested; the format is a shell. The open question is whether
that discipline can hold across a *different plugin format* — with native it's clean C++
so yes; with JSFX the "pure core" would be JSFX code, harder to unit-test in the CTest
harness. That tension is real and feeds back into D1.
**D5Cross-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.
**D4Where the mapping lives (seam fork i/ii/iii from §2).** Bank-owned map,
instrument-owned map, or split (file-intrinsics in the bank, performance-map in the
instrument). *My lean:* **(iii) split** — it's the one that honors ReaSampler's
capture/placement instinct and keeps the bank tool-agnostic. But it's Daniel's call
whether ReaSampler should author instrument definitions at all, or stay purely a
sample library that a *separate* mapping tool arranges.
**D6One 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.
**D5Cross-platform.** The extension is already cross-platform (SWELL). JSFX inherits
that for free. A native plugin re-opens the full cross-platform DSP + UI + build matrix
(Win/mac/Linux, code-signing on mac, etc.) as a *separate* artifact. A cost that lands
entirely on the native path.
**D3Pure-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.
**D6 — Is this even one product?** The reframe worth surfacing: ReaSampler's thesis is
"a precision *capture/organize* tool; capture and placement are separate acts." A
MIDI-playback instrument is a *different act* — playback. There's a legitimate reading
where the instrument is a **companion product** that shares the bank format, not a
feature *of* ReaSampler — the way a sample library and a sampler that reads it are
related-but-distinct products. That framing might keep ReaSampler sharp (it stays the
capture tool it is) while letting the instrument evolve on its own clock and format.
The alternative reading — it's all one integrated sampler-workstation — is also
coherent. **Which of those two ReaSampler *is* is the highest-order question here, and
it's Daniel's to answer before format/tier decisions mean much.**
### 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.
---
## What this doc is asking for
## Where this landed
A direction on the two highest-order forks, in order:
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. **D6 — one product or two?** Is the instrument a feature of ReaSampler, or a
companion product sharing the bank format? Everything else sits under this.
2. **D1 — JSFX-first prototype, or straight to native?** Given a target of Tier 01 to
start, and the pure-core-as-shell discipline (D3) held either way.
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.
Once those two are called, the seam fields (§2, D4) and the tier scope (§3) become
concrete enough to write an actual phase spec. Until then this stays a framing doc with
no PLAN.md footprint — deliberately, so we don't scope an instrument before deciding
whether we're building one.
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1S6, sequenced by
dependency order: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
UI); **CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the
pure/shell split in the new artifact, the must-verify SDK/bridge surfaces). This doc is the
framing/decision record they point back to. The "no PLAN.md footprint" era is over.
---
## Sources (for §1a's verified claims)
- Steinberg VST3 SDK — `SingleComponentEffect` class reference (combined processor +
controller; "non-distributable" caveat):
https://steinbergmedia.github.io/vst3_doc/vstsdk/classSteinberg_1_1Vst_1_1SingleComponentEffect.html
- Steinberg VST3 SDK — `IAudioProcessor` class reference (process/setupProcessing/bus
contract):
https://steinbergmedia.github.io/vst3_doc/vstinterfaces/classSteinberg_1_1Vst_1_1IAudioProcessor.html
- VST3 Developer Portal (overview, tutorials, example plugins — AGain / Note Expression
Synth as the skeleton copy-source): https://steinbergmedia.github.io/vst3_dev_portal/
- JUCE 8 EULA (dual AGPLv3 / commercial; tier structure):
https://juce.com/legal/juce-8-licence/
- JUCE forum — revenue limits & JUCE 8 pricing (Starter ~$20K/yr free, Indie ~$200K/yr
limit, ~$3,500): https://forum.juce.com/t/revenue-limits-for-juce-tiers/61058
*Estimate-vs-verified honesty note:* the interface list, `SingleComponentEffect`'s role,
the instrument bus topology, and the JUCE license terms are **verified** from the sources
above. The **line-count band (~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.
+398
View File
@@ -0,0 +1,398 @@
# Versioning & release — product notes
Framing behind two release-milestone (M11-adjacent) capabilities Daniel wants made
concrete and decidable:
1. **A release version scheme** — so a "real" stable build can be deployed and
identified.
2. **A beta side-channel** — so development can continue and a beta build run
*alongside* the stable one without the beta clobbering the release.
This doc holds the *why*, the forks, and a recommendation. When Daniel picks, the
tickable points land in `PLAN.md` and the deploy/build wiring hands off to dev-ops.
This is a framing note; it changes no source or CMake.
Status: framed by product-designer (2026-07-26); **all four forks settled by Daniel
(2026-07-26).** V1 approved as recommended (ext-state version stamp prioritized to
the first wave); V2 plain `-beta` suffix (`git describe` decoration rejected); V3
recommendation accepted (console line + panel readout, about-box deferred); **V4
reversed the recommendation** — Daniel chose **beta-in-isolation / full coexistence**
(separate binary, isolated ext-state namespace, isolated command-id prefix) rather
than the branch-discipline/one-at-a-time path the note originally recommended. The
"Recommendation" and "Open decisions" sections below have been superseded to reflect
the settled state; each records what was chosen and why. Deploy/build wiring hands off
to dev-ops.
> **Note on V4 phrasing (provisional).** The-boss's read of "beta-in-isolation" as
> the separate-binary + isolated-namespace + isolated-command-id-prefix coexistence
> path is provisional pending a final confirm from Daniel. Written that way here; a
> minor correction is a cheap edit.
---
## The one constraint that shapes everything: REAPER's startup dlopen
REAPER, at startup only, scans `UserPlugins/` and `dlopen()`s **every** file
matching `reaper_*.dll|dylib|so`, then calls each one's `ReaperPluginEntry`
(CLAUDE.md §REAPER extension contract). Three consequences drive every decision
below:
- **Two matching files load simultaneously.** If both `reaper_reasampler.dll` and
`reaper_reasampler_beta.dll` sit in `UserPlugins/`, REAPER loads *both* — two
independent extension instances in one REAPER process. This is the mechanism a
beta side-channel would exploit, and also its central hazard.
- **No hot reload.** Deploy = copy the binary in, restart REAPER. Any scheme is
"restart to pick up," never live-swap.
- **Everything is process-global inside REAPER.** Two coexisting instances share
one REAPER, one Actions list, one project, one ext-state store. Anything keyed by
a global string (command ids, ext-state namespace, docked-window identity) is a
potential collision surface between the two.
Two sharp edges follow directly and recur throughout this note:
- **The `STABLE_FOREVER_STRING` command-id contract** (CLAUDE.md; `main.cpp:41`,
prefix `CEREBELLUM_REASAMPLER_`). Command-id strings are minted once and **never
changed after shipping** — user keybindings key off them. Two coexisting binaries
that register the *same* id strings collide in REAPER's Actions list.
- **The `"reasampler"` project ext-state namespace** (shared by the `banks`,
`view_state`, `project_guid`, `tail_setting` keys, plus the retired `bank_index`).
Both binaries reading/writing the same namespace on the same open project means a
**beta can read — and rewrite — a stable project's saved bank/view state.** Given
the forward-only migrations already in the design (legacy `bank_index` retired
after promotion; `banks` authoritative thereafter — CONTEXT.md §Multi-bank), a
beta that writes a newer schema into a project a user then reopens in stable is a
real corruption path, not a theoretical one.
Everything below is really about how much of that global surface a beta channel is
allowed to touch.
---
# Part 1 — Version scheme + where the version lives
## What we have today
- No version anywhere. `CMakeLists.txt:2` is `project(reaper_reasampler LANGUAGES
CXX)` — no `VERSION`. The binary announces itself only as `"ReaSampler loaded.\n"`
to the console (`main.cpp:960`). There is no number a user, a bug report, or a
future migration can key off.
- The natural user-visible readout already exists: the docked LICE bank panel, and
the console (`ShowConsoleMsg`). A version has cheap homes; none is wired.
## The number itself (V1) — SETTLED (2026-07-26): **semver, sourced from CMake; ext-state stamp lands ASAP / first wave**
> **Decision (V1).** Approved as recommended. Semver, single source of truth in CMake
> `project(reaper_reasampler VERSION x.y.z)`, threaded into the binary. **The
> `"reasampler"` ext-state writing-version stamp is prioritized to the first wave, not
> deferred** — Daniel emphasized ASAP because *every project saved without the stamp
> is harder to migrate later*, so the migration seam must exist before more real
> projects accrue un-stamped state. Treat the stamp as an early, high-priority
> deliverable that ships with (or ahead of) the first versioned build.
Recommend **semantic versioning** (`MAJOR.MINOR.PATCH`) with the number's single
source of truth in `CMakeLists.txt` via `project(reaper_reasampler VERSION x.y.z)`,
threaded into the binary as a compile definition and surfaced to the user.
Semver fits because ReaSampler already has the two events semver exists to signal,
and they matter here specifically:
- **MAJOR / MINOR** track user-visible capability (a new pillar landing: Phase R
prune, M9 slots).
- **PATCH** tracks fixes.
- Most importantly, ReaSampler carries **persisted, migrating project state** (the
`"reasampler"` ext-state, forward-only migrations). A version stamped *into the
saved blob* is what lets a future build say "this project was written by 1.4, I am
1.6, run the 1.4→1.6 migration" — or refuse gracefully. That is a concrete,
already-latent need, not ceremony. **Recommend stamping the writing version into
the ext-state blob** as part of whichever release wave ships (small addition to
the persist section; a sibling `schema`/`app_version` field).
**Source of truth: CMake `project(VERSION)`.** One number, in the build system,
flowed outward — never hand-edited in a header. `project(... VERSION x.y.z)`
populates `PROJECT_VERSION` / `PROJECT_VERSION_MAJOR|MINOR|PATCH`, which a
`target_compile_definitions` (e.g. `REASAMPLER_VERSION="…"`) threads into the
binary. This is the standard CMake idiom and keeps the tag, the binary, and any
about-string in lockstep from one edit.
**Alternatives considered:**
- **Git-derived version (`git describe --tags`)** baked at configure time. Pro: the
build literally cannot disagree with the tag; encodes commits-since-tag + dirty
state, which is *excellent for a beta* ("1.4.0-beta.3+7.gab12cd"). Con: needs git
present at build and a tag discipline; a source tarball without `.git` builds
"unknown." **This was originally recommended for the beta suffix — Daniel rejected
it** (V2 settled below: a plain `-beta` suffix is more legible than a decorated
`git describe` string). `project(VERSION)` still owns the release triple; the beta
simply carries `-beta`. (See V2.)
- **A hand-maintained `version.h`.** Rejected — a second source of truth that drifts
from the tag. The whole point of one source is that release can't ship a binary
that lies about its number.
- **Date-based / CalVer (`2026.07`).** Coherent, but ReaSampler's changes are
capability-shaped (pillars landing), not time-shaped, and CalVer says nothing
about migration compatibility, which is the load-bearing use here. Semver earns
its place; CalVer doesn't.
## Where the user sees it (V3) — SETTLED (2026-07-26): **panel readout + startup log; about box deferred**
> **Decision (V3).** Recommendation accepted (Daniel deferred to product-designer
> judgment — "whatever"). Startup console line (`"ReaSampler x.y.z loaded"`) + a
> bank-panel version/channel readout ship; the about-box stays deferred. Panel
> placement (header corner / footer / folded into existing chrome) remains a small
> residual polish call, same class as the settled active-bank-indicator placement.
Three candidate homes, cheap to expensive:
1. **Startup console line** — upgrade `"ReaSampler loaded.\n"` to `"ReaSampler
x.y.z loaded.\n"`. Nearly free, and it lands the version in exactly the place a
user copies from when filing a bug. **Do this regardless of the other choices.**
2. **Bank-panel readout** — a small version string in the docked LICE panel
(header corner or footer, near the existing tail toggle / mode switch). Always
visible, no new window, matches the LICE-drawn house style. **Recommend this as
the primary user-facing home.**
3. **An "about" action / dialog** — a bindable `ReaSampler: about` that pops
version + build channel + build hash. More than the moment needs; a SWELL dialog
is real surface to maintain. **Defer** — the panel readout + console line cover
the actual need (identify the running build). Pick this up only if a beta channel
makes "which build am I running" a frequent question.
Recommendation: **(1) + (2) now, (3) deferred.** The version wants to be visible
*passively* (panel) and *copyably* (console), and a beta channel makes the panel
readout do double duty as the channel indicator (Part 2).
**Open decision V3 for Daniel:** panel placement — header corner vs. footer strip
vs. folded into the existing mode-switch/tail-toggle chrome. This is a small panel
polish call, same class as the settled "active-bank indicator placement" residual.
---
# Part 2 — Beta side-channel
The goal: keep shipping a stable build users depend on, while running a beta of
in-progress work **alongside** it, so the beta can be exercised in real projects
without the beta's newer/rougher state clobbering the stable install or stable
projects.
The design axis is **coexistence**: must stable and beta load into the *same REAPER
at the same time* (true side-by-side), or is it enough to run *one at a time* with
clean, safe switching? That axis splits the options.
## Option A — Separate binary name, both load simultaneously (true coexistence)
Ship `reaper_reasampler_beta.dll` next to `reaper_reasampler.dll`. REAPER loads
both; the user has a stable ReaSampler and a beta ReaSampler live in one session.
This is the most powerful shape — and the most dangerous, because of the three
global-collision surfaces the startup-dlopen constraint creates. **A separate
binary name alone does not isolate them; it just makes them coexist.** For Option A
to be safe, *three* things must diverge in lockstep, not just the filename:
1. **Command-id strings must diverge.** Both binaries register actions; if the beta
mints `CEREBELLUM_REASAMPLER_CAPTURE_TRACK` too, REAPER's Actions list has two
entries claiming one id — collision, and the `STABLE_FOREVER_STRING` contract is
violated. The beta needs its **own prefix** (e.g. `CEREBELLUM_REASAMPLER_BETA_`).
Sharp edge: that means the beta's ids are a *distinct forever-family* — a user's
beta keybindings won't carry to stable, and vice versa. That is arguably correct
(they're different installs), but it must be a deliberate decision, because once
the beta ships those beta ids are *also* forever-stable. **You are minting a
second permanent id namespace, not a throwaway.**
2. **The ext-state namespace must diverge — or the beta corrupts stable projects.**
This is the severe one. If the beta writes `banks`/`view_state` under
`"reasampler"`, a project saved by the beta carries beta-schema state that stable
then reads (and the forward-only migration may have already retired the key
stable expected). The beta must write under its **own namespace** (e.g.
`"reasampler_beta"`). Consequence, and it cuts both ways: a **beta cannot see a
stable project's bank** (different namespace), so testing the beta against a real
populated project means the bank looks empty until re-captured. That is the price
of isolation, and it is the *right* price — a beta that shares stable's namespace
is a data-loss bug waiting to happen. **This divergence is non-negotiable if
Option A is chosen.**
3. **The docked-window / docker identity should diverge**, so the two panels are
distinguishable and don't fight over one dock slot. Lower-severity (a UX
annoyance, not corruption), but part of the same "everything global must fork"
picture.
Net: Option A delivers genuine side-by-side at the cost of forking *every global
identity the extension owns*. It is a **compile-time-parameterized second product**,
not a branch artifact — which points straight at Option C as the *mechanism* for how
you'd actually build it.
## Option B — One binary, branch discipline, one installed at a time
`dev` → beta builds, `main` → release builds; the version string carries a
`-beta`/`-dev` suffix so the running build self-identifies; **only one is installed
at a time.** No coexistence — you swap the binary and restart REAPER to change
channels.
- **Pro:** *zero* collision surface. Same command ids, same ext-state namespace, one
file — because only one is ever loaded. Nothing forks. Simplest by a wide margin.
- **Pro:** matches the repo's existing branch reality (`dev` is the working branch,
`main` the release branch — visible in the current git state).
- **Con:** no true side-by-side. To A/B stable against beta you swap files and
restart. For a solo developer this is often *entirely fine* — the friction is a
file copy + restart, not a corruption risk.
- **Sharp edge (the reason it's still not free):** if the beta writes a
newer/experimental ext-state schema into a real project, then you swap back to
stable and open that project, **stable reads beta-written state.** One-binary
branch discipline removes the *simultaneous* collision but not the *sequential*
one — the project file is the shared surface across a channel swap. Mitigation:
either (a) keep the beta strictly schema-compatible with stable (no ext-state
shape changes on the beta channel — often true, since most beta work is behavior,
not persistence), or (b) accept "don't open beta-touched real projects in stable"
as a discipline, or (c) stamp the writing version into the blob (V1) so stable can
at least *detect* and refuse/migrate rather than silently mis-read.
## Option C — Compile-time build flag (the mechanism, usable under A or B)
A CMake/preprocessor switch (`-DREASAMPLER_CHANNEL=beta`) that, in one build tree,
sets: output name, command-id prefix, ext-state namespace, version suffix, and the
panel channel indicator. This is not really a *third* strategy — it's *how you
implement* the divergence Option A demands, or *how you stamp the suffix* Option B
wants. It's the knob; A and B are policies for the knob.
- Under **Option A**, the flag is what forks the two binaries from one source
cleanly — flip `REASAMPLER_CHANNEL`, get the beta's name/prefix/namespace/suffix
as a coordinated set, so the three-way divergence can't get out of sync by hand.
- Under **Option B**, the flag just sets the suffix + a channel tag; name/prefix/
namespace stay shared because only one loads.
The flag is worth having either way because it makes "what makes a beta a beta" a
**single, auditable definition** instead of scattered `#ifdef`s.
## Decision (V4) — SETTLED (2026-07-26): **Option A — beta-in-isolation / full coexistence**
> **This reverses the note's original recommendation.** The note originally
> recommended Option B (branch discipline, one installed at a time) built on the
> Option C channel flag, with Option A (true simultaneous coexistence) *seam-designed
> but deferred*. **Daniel chose the coexistence path instead.** The superseded
> recommendation prose is retained below the decision (struck through in intent, kept
> for the reasoning trail) so a future reader sees what was weighed; the decision here
> governs.
**Settled shape: beta ships as a separate, fully isolated binary that coexists with
stable in one REAPER.** Concretely, all three global identities fork (this *is*
Option A, built through the Option C compile-time channel flag as the mechanism):
- **Separate binary**`reaper_reasampler_beta` alongside `reaper_reasampler`, so
REAPER's startup dlopen loads both and the user runs stable and beta side-by-side.
- **Isolated ext-state namespace** — the beta writes under its own namespace (e.g.
`"reasampler_beta"`), distinct from stable's `"reasampler"`, so **a beta can never
read or rewrite a stable project's saved bank/view/tail state.** The price is
accepted: a beta does not see a stable project's bank (it looks empty until
re-captured under the beta namespace). That is the correct price — isolation over
convenience.
- **Isolated forever-stable command-id prefix** — the beta mints its own prefix (e.g.
`CEREBELLUM_REASAMPLER_BETA_`), so beta and stable actions never collide in
REAPER's one Actions list and their keybindings stay independent.
The compile-time channel flag (`-DREASAMPLER_CHANNEL=beta`, Option C) remains the
implementation mechanism: one flip coordinates output name + command-id prefix +
ext-state namespace + `-beta` version suffix + panel channel badge as a single
auditable definition, so the three-way divergence can't drift by hand.
### The two permanent commitments this locks in (recorded honestly)
Choosing coexistence over the deferred-seam path accepts two commitments that are
**permanent once the first beta ships** — exactly the prices the original
recommendation flagged as reasons to defer:
1. **A second forever-stable command-id prefix.** The beta's ids are their own
forever-family (`STABLE_FOREVER_STRING` contract applies to them too the moment a
beta ships). Beta keybindings do not carry to stable and vice versa. Irreversible.
2. **A second ext-state namespace.** `"reasampler_beta"` is permanent — and its
isolation means beta and stable never share a project's bank. This is the safety
property, but it is also a fork of your own test data that you own from day one.
Both are deliberate and accepted; recorded here so no future reader treats them as
oversights.
### Deploy / CD implication (dev-ops hand-off)
The build now **produces two named artifacts per platform**`reaper_reasampler`
(stable) and `reaper_reasampler_beta` (beta) — selected by `REASAMPLER_CHANNEL`.
That is a dev-ops handoff: the pipeline builds, names, and publishes both channels
(three platform artifacts each). See Part 3.
### The ext-state safety property, now settled by isolation
The sharp edge the original note flagged as the genuine V4 sub-decision — *can a beta
write experimental ext-state that a stable build later reads?* — is **resolved by the
isolated namespace: no.** Beta writes only under `"reasampler_beta"`; stable reads
only `"reasampler"`. There is no shared-project corruption path across the channels,
neither simultaneous nor sequential. V1's writing-version stamp still lands first-wave
(it guards *within-channel* forward migration — stable-1.4 reading stable-1.6 state —
which isolation does not address), but it is no longer load-bearing for cross-channel
safety.
---
### Superseded recommendation (retained for the reasoning trail)
> The following was the note's original V4 recommendation. **It is superseded by the
> settled decision above** (Daniel chose coexistence). Kept because the trade-off
> reasoning — the permanent-price argument in particular — is what the decision was
> weighed against.
~~Start with **Option B (branch discipline, one at a time) implemented through a
compile-time channel flag (Option C)**~~: ship stable as `reaper_reasampler` from
`main`; build beta from `dev` via the channel flag with only a `-beta` suffix + panel
badge; **do not fork the command-id prefix or ext-state namespace**, since under B
only one binary loads and forking prematurely commits a second forever-stable id
family for no gain. Then treat **Option A (true coexistence) as a later, deliberate
upgrade** if swap-and-restart proved too slow — the upgrade being clean precisely
because B was built on the channel flag (flip A on = extend the flag to fork name +
`_BETA_` prefix + `"reasampler_beta"` namespace). The stated reason to defer A: it
forces minting a **second permanent command-id namespace** and a **second ext-state
namespace** on day one — a large, permanent price for a convenience (simultaneous
compare) that might not be needed. **Daniel weighed that price and chose to pay it:
the value of running stable and beta side-by-side, with hard isolation guaranteeing a
beta can never corrupt stable state, outweighed avoiding the two permanent
commitments.**
---
# Part 3 — Deploy / CD touchpoints (hand-off to dev-ops)
Framing only — the actual pipeline is dev-ops's to author. The strategy above
implies these touchpoints:
- **Tag → version.** Release is cut from a git tag on `main`; `project(VERSION)` is
bumped to match the tag (or the tag is derived from it — pick one direction and
keep it one-way). Beta builds from `dev` carry `git describe` in the suffix.
- **Channel is a build parameter.** `-DREASAMPLER_CHANNEL=release|beta` selects the
suffix/badge now (Option B) and, if Option A is ever turned on, the output
name/prefix/namespace too. One flag, one definition of "what is a beta."
- **Artifact per platform.** The binary is `reaper_*.dll|.dylib|.so`; the macOS/Linux
builds need the SWELL resgen step (CLAUDE.md §SWELL dialog resources) baked into
the pipeline. Three platform artifacts per channel per release.
- **Install is copy-in + restart** (no hot reload). A release "deploy" is publishing
the artifact for the user to drop into `UserPlugins/`; there is no server-side
rollout. Any auto-update story is out of scope here and would be its own note.
- **The ext-state version stamp (V1)** is the one piece of forward-compatibility
plumbing the pipeline should ensure ships in the first versioned release, so every
subsequent build can reason about older saved state.
---
# Settled decisions (all four — Daniel, 2026-07-26)
- **V1 — version scheme: APPROVED as recommended.** Semver, single source of truth in
CMake `project(reaper_reasampler VERSION x.y.z)`, threaded into the binary. **The
`"reasampler"` ext-state writing-version stamp is prioritized to the first wave** —
every project saved without it is harder to migrate later, so the migration seam
lands early, not deferred.
- **V2 — beta suffix: plain `-beta`.** `project(VERSION)` owns the release triple;
beta builds carry a `-beta` suffix. The `git describe` decoration was considered and
**rejected** for legibility ("-beta is better than a random string").
- **V3 — user-visible home: recommendation accepted.** Startup console line
(`"ReaSampler x.y.z loaded"`) + a bank-panel version/channel readout; about-box
deferred. Panel placement remains a small residual polish call.
- **V4 — beta channel shape: BETA-IN-ISOLATION (full coexistence).** *Reverses the
original recommendation.* Beta ships as a **separate binary**
(`reaper_reasampler_beta`) with an **isolated ext-state namespace** and an
**isolated forever-stable command-id prefix**, so stable and beta install and run
side-by-side with no shared-state corruption path. Locks in two permanent
commitments — a second forever-stable command-id prefix and a second ext-state
namespace — both accepted. Dev-ops implication: the build now produces two named
artifacts (stable + beta) per platform.
+16 -33
View File
@@ -545,7 +545,6 @@ void doBankCreate() {
return;
}
persistBankOp("ReaSampler: create bank");
ShowConsoleMsg(("ReaSampler: created bank \"" + name + "\".\n").c_str());
}
// Rename a bank: prompt for which bank (by current display name) and the new name.
@@ -571,8 +570,6 @@ void doBankRename() {
return;
}
persistBankOp("ReaSampler: rename bank");
ShowConsoleMsg(("ReaSampler: renamed \"" + which + "\" -> \"" + newName + "\".\n")
.c_str());
}
// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail:
@@ -615,7 +612,6 @@ void doBankDelete() {
return;
}
persistBankOp("ReaSampler: delete bank");
ShowConsoleMsg(("ReaSampler: deleted bank \"" + which + "\".\n").c_str());
}
// Evacuate a named bank: move every member back to the pool (index-only, collapse by
@@ -637,7 +633,6 @@ void doBankEvacuate() {
return;
}
persistBankOp("ReaSampler: evacuate bank");
ShowConsoleMsg(("ReaSampler: evacuated \"" + which + "\" to the pool.\n").c_str());
}
// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool),
@@ -652,10 +647,6 @@ void doBankActivateNext() {
if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded)
if (!g_session->book().setActiveBank(target)) return;
persistBankOp("ReaSampler: activate bank");
const Bank* b = g_session->book().bank(target);
ShowConsoleMsg(("ReaSampler: active bank -> \"" +
(b ? b->displayName : target) + "\".\n")
.c_str());
}
// Activate the pool directly (the common "back to the default target" jump). Bindable
@@ -663,7 +654,6 @@ void doBankActivateNext() {
void doBankActivatePool() {
if (!g_session->book().setActiveBank(kPoolBankId)) return;
persistBankOp("ReaSampler: activate bank");
ShowConsoleMsg("ReaSampler: active bank -> \"Pool\".\n");
}
// Move or copy the panel's selected samples into a named destination bank (prompted
@@ -698,7 +688,10 @@ void doBankTransferSelected(bool copy) {
return;
}
int ok = 0, collapsed = 0, absent = 0;
// Tally per-sample transfer outcomes so the no-op guardrail below can decide whether
// the index actually mutated (R-B). The console summary m11 stripped is gone; the
// counts remain because the verb-aware undo guardrail is driven by them.
int ok = 0, collapsed = 0;
for (const std::string& sampleId : selected) {
const TransferResult r =
copy ? g_session->book().copySample(sampleId, srcId, destId)
@@ -707,8 +700,9 @@ void doBankTransferSelected(bool copy) {
case TransferResult::Moved:
case TransferResult::Copied: ++ok; break;
case TransferResult::Collapsed: ++collapsed; break;
case TransferResult::RejectedSampleAbsent: ++absent; break;
// Unknown-bank / same-bank are pre-checked above; treat defensively as no-ops.
// RejectedSampleAbsent and unknown-bank / same-bank (pre-checked above) are
// no-ops for the guardrail; nothing mutated for those ids.
case TransferResult::RejectedSampleAbsent:
case TransferResult::RejectedUnknownBank:
case TransferResult::RejectedSameBank: break;
}
@@ -726,12 +720,6 @@ void doBankTransferSelected(bool copy) {
std::string("ReaSampler: ") + verb + " sample(s)";
persistBankOp(label.c_str());
}
std::string log = std::string("ReaSampler: ") + verb + " -> \"" + destName +
"\": " + std::to_string(ok) + " " + verb + "d";
if (collapsed) log += ", " + std::to_string(collapsed) + " collapsed on hash";
if (absent) log += ", " + std::to_string(absent) + " no longer present";
log += ".\n";
ShowConsoleMsg(log.c_str());
}
// Remove the panel's selected samples from the SOURCE bank (the focused region's
@@ -788,25 +776,20 @@ void doBankRemoveSelected() {
// Perform the removes (this-bank scope). Pass ids by value — no BankIndex& is cached
// across the loop's mutations. Count real drops so the no-op guardrail can skip the
// undo point when nothing was removed (every id was already absent).
int removed = 0, absent = 0;
// undo point when nothing was removed (every id was already absent). The per-outcome
// console summary was dropped (m11 chatter policy); only the "did anything change?"
// signal the undo guardrail needs is retained.
int removed = 0;
for (const std::string& sampleId : selected) {
switch (book.removeSample(sampleId, srcId, RemoveScope::ThisBank)) {
case RemoveResult::Removed: ++removed; break;
case RemoveResult::RejectedSampleAbsent: ++absent; break;
// Unknown bank cannot occur — srcId was resolved to a live bank above.
case RemoveResult::RejectedUnknownBank: break;
}
if (book.removeSample(sampleId, srcId, RemoveScope::ThisBank) ==
RemoveResult::Removed)
++removed;
// RejectedSampleAbsent / RejectedUnknownBank are no-ops for the guardrail.
// (Unknown bank cannot occur — srcId was resolved to a live bank above.)
}
// No-op guardrail (R-B): open an undo point only if the index actually mutated.
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
std::string log = "ReaSampler: removed " + std::to_string(removed) +
(removed == 1 ? " sample" : " samples");
if (absent) log += ", " + std::to_string(absent) + " no longer present";
log += ".\n";
ShowConsoleMsg(log.c_str());
}
} // namespace
-1
View File
@@ -52,7 +52,6 @@
#define REAPERAPI_WANT_Main_OnCommand
#define REAPERAPI_WANT_Main_SaveProject
#define REAPERAPI_WANT_Master_GetTempo
#define REAPERAPI_WANT_ShowConsoleMsg
#include "reaper_plugin_functions.h"
namespace reasampler {
+7 -37
View File
@@ -135,7 +135,7 @@ static ReaProject* g_rtCaptureProject = nullptr;
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
// Sample to the ACTIVE bank (g_session.bank() resolves to book.activeIndex() — B2),
// persist + MarkProjectDirty, log. Shared by the tick-completion path and the abort
// persist + MarkProjectDirty. Shared by the tick-completion path and the abort
// paths. On a non-Ok result, logs the failure only.
static void CommitRealtimeResult(const reasampler::CaptureResult& res)
{
@@ -144,20 +144,13 @@ static void CommitRealtimeResult(const reasampler::CaptureResult& res)
ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str());
return;
}
reasampler::AddResult added = g_session.bank().add(res.sample);
g_session.bank().add(res.sample);
// B-cap: record the file the capture created in the owned-file manifest, at the same
// point the Sample is added and before the same persist. Recorded regardless of the
// index AddResult — even a hash-collapse still WROTE a file the tool owns, and the
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
g_session.owned().add(res.sample.relativePath);
g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp)
std::string log = "ReaSampler: " + res.message + "\n";
log += " bank size now " + std::to_string(g_session.bank().size()) +
(added == reasampler::AddResult::Added ? " (added)\n"
: added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n"
: " (rejected)\n");
ShowConsoleMsg(log.c_str());
}
// Advance any in-flight realtime capture one tick. Cheap when none is running (a
@@ -619,7 +612,7 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
}
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
reasampler::AddResult added = g_session.bank().add(res.sample);
g_session.bank().add(res.sample);
// B-cap: record the created file in the owned-file manifest, at the same point the
// Sample is added and before the same persist. Recorded regardless of the index
// AddResult — even a hash-collapse still WROTE a file the tool owns, and the manifest
@@ -630,13 +623,6 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
g_session.saveToActiveProject();
std::string log = "ReaSampler: " + res.message + "\n";
log += " bank size now " + std::to_string(g_session.bank().size()) +
(added == reasampler::AddResult::Added ? " (added)\n"
: added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n"
: " (rejected)\n");
ShowConsoleMsg(log.c_str());
}
// STARTS the REALTIME track capture and returns immediately — the record runs across
@@ -708,15 +694,6 @@ static void RunCaptureRealtimeTrack()
// completion across ticks (UI stays responsive).
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
g_rtCapture = std::move(st);
// With a tail mode the recorded window runs PAST the range end (Auto: +8 s then
// decay-trim; Manual: +the set length), so the completion note names the window,
// not just the range end.
const char* doneWhen =
(tail.mode == reasampler::TailMode::None)
? "the bank updates when it reaches the range end."
: "the bank updates after the extra tail window (past the range end).";
ShowConsoleMsg((std::string("ReaSampler: realtime capture started — recording in "
"the background; ") + doneWhen + "\n").c_str());
}
// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates
@@ -758,27 +735,22 @@ static void RunInsertSelected(bool conform)
reasampler::InsertResult res = reasampler::runInsert(&g_session, req);
std::string msg;
switch (res.status)
{
case reasampler::InsertStatus::Ok:
msg = "ReaSampler: inserted onto " + std::to_string(res.inserted) +
(res.inserted == 1 ? " track" : " tracks") +
(conform ? " (conformed to tempo)" : " (native length)") + "\n";
break;
break; // success — no console chatter
case reasampler::InsertStatus::NoSelection:
// "select a track first" is printed by runInsert when no track is
// selected; this branch covers the no-panel-selection case.
msg = "ReaSampler insert: nothing selected in the bank panel.\n";
ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n");
break;
case reasampler::InsertStatus::NoProject:
msg = "ReaSampler insert: no saved project, so the bank has no location.\n";
ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n");
break;
case reasampler::InsertStatus::NothingResolved:
msg = "ReaSampler insert: selected sample(s) could not be resolved to a file.\n";
ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n");
break;
}
ShowConsoleMsg(msg.c_str());
}
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
@@ -1024,7 +996,5 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
// requests a deferred reload that the next timer tick drains (see the hook comment).
rec->Register("projectconfig", (void*)&g_projectConfig);
ShowConsoleMsg("ReaSampler loaded.\n");
return 1; // success — REAPER keeps us loaded
}