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:
+285
@@ -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
|
**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).
|
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 0–1 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 0–1
|
||||||
|
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 0–1'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.
|
||||||
|
|||||||
@@ -435,3 +435,248 @@ untouched.
|
|||||||
|
|
||||||
Both docs of record: `docs/product/removal-and-prune.md` §Fork R-C/R-D/R-E and
|
Both docs of record: `docs/product/removal-and-prune.md` §Fork R-C/R-D/R-E and
|
||||||
CONTEXT.md §Prune (Settled forks).
|
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 V1–V4 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 0–1 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 0–1 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
@@ -1,14 +1,72 @@
|
|||||||
# MIDI playback — opportunity & design-space framing
|
# MIDI playback — opportunity & design-space framing
|
||||||
|
|
||||||
Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured
|
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
|
banks. This began as a discussion-shaping doc; with all forks now settled it has become
|
||||||
points, no settled forks yet. It exists so Daniel and the-boss can react to an honest
|
the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN.md
|
||||||
map of the option space before anything is scoped.
|
§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
|
Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r4)**. The
|
||||||
headers (`vendor/reaper-sdk/sdk/reaper_plugin.h`, `reaper_plugin_functions.h`) — the
|
"no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the
|
||||||
plugin-format claims below are checked against those headers, not asserted. The
|
instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D"
|
||||||
genuine forks are flagged as **Daniel's to decide**; nothing here pre-decides them.
|
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
|
*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 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
|
The old three-way option set (native VSTi / JSFX / hybrid) has collapsed to a single
|
||||||
ReaSampler's banks.** A separate build artifact: a VST3 (and/or CLAP) sampler plugin
|
resolved shape:
|
||||||
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.
|
|
||||||
|
|
||||||
**Option B — a JSFX sampler.** JSFX is REAPER's built-in scriptable plugin format
|
**A separate native VST3 instrument that consumes ReaSampler's banks, tightly integrated
|
||||||
(text `.jsfx` files, JIT-compiled by REAPER, hostable as an instrument). A JSFX
|
via the bridge.** The extension stays the sole owner of **capture + organization** (its
|
||||||
instrument *can* receive live MIDI and emit audio on a track. It can load samples
|
whole existing identity and the load-bearing "capture and placement are separate acts"
|
||||||
(`Xen`-style file reads / the JSFX file/serialize API) and play them back.
|
principle). The instrument is a *separate build artifact* — a native VST3 sampler the user
|
||||||
- *Gives you:* a real in-track instrument with **zero new binary, zero new SDK, zero
|
instantiates on an instrument track — that maps samples across the keyboard and plays them
|
||||||
JUCE** — ships as a text file alongside the extension, cross-platform for free
|
MIDI-triggered with a real voice engine. It reaches ReaSampler's project state directly via
|
||||||
(REAPER runs the JIT everywhere it runs).
|
the VST-host bridge: it reads the live `"reasampler"` ext-state `persist` writes, knows its
|
||||||
- *Costs:* JSFX is a constrained DSP scripting language, not C++. A polyphonic
|
own host project, and follows the active project. This is the "two products, but
|
||||||
multisample engine with round-robin/velocity-layers/streaming is *doable* but you're
|
integrated" shape D6 locked in. The extension never becomes an instrument; the instrument
|
||||||
writing DSP in JSFX's idiom, and large-sample streaming / disk I/O is more awkward
|
never captures.
|
||||||
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.
|
|
||||||
|
|
||||||
**Option C — the hybrid (recommended framing to explore first).** Keep the extension
|
*What this gives:* everything an instrument is — polyphony, velocity, envelopes, the works,
|
||||||
as the sole owner of **capture + organization** (which is its whole existing identity
|
fully in REAPER's routing/render/record path like any VSTi — **plus** live, project-aware
|
||||||
and the load-bearing "capture and placement are separate acts" principle). Add a
|
integration with the extension's state, not a divorced file-reader.
|
||||||
**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.
|
|
||||||
|
|
||||||
The real fork, then, is **not** "extension vs. plugin" (the extension stays either
|
*What it costs:* a **second codebase**, the Steinberg VST3 SDK (or JUCE) as a dependency,
|
||||||
way) — it is **"which instrument format consumes the banks: JSFX or native VSTi/CLAP,"**
|
and a real DSP/voice-engine build — a product-sized commitment, not a feature. The
|
||||||
and **"how much sampler do we actually want."** Those are §3 and §4.
|
Windows-only + VST3-only + REAPER-only lock-downs (D5) *remove* the costs the prior draft
|
||||||
|
carried around cross-platform DSP, code-signing, and multi-format wrappers — a material
|
||||||
|
simplification. The remaining cost question is almost entirely **"how much scaffolding does
|
||||||
|
the VST3 surface demand, and do we take JUCE to get it"** — answered honestly in §1a.
|
||||||
|
|
||||||
|
> **JSFX — retired (D1 DECIDED: native VST3).** Prior drafts weighed a JSFX sampler
|
||||||
|
> (REAPER's built-in scriptable format: zero new binary, zero SDK, cross-platform free) as
|
||||||
|
> the cheap-prototype path. It is off the table. The reasons it lost, for the record: (1)
|
||||||
|
> JSFX is a constrained DSP scripting language — a polyphonic multisample engine with
|
||||||
|
> round-robin/velocity-layers/streaming is doable but fights the idiom, and large-sample
|
||||||
|
> disk streaming is awkward; (2) JSFX gets **no VST-host bridge**, so it could never read
|
||||||
|
> `persist`'s live `"reasampler"` ext-state — it would be a permanently file-coupled
|
||||||
|
> consumer needing a sidecar seam, which is exactly the *loose* companion D6 rejected; (3)
|
||||||
|
> its "pure core" would be JSFX code, outside the CTest harness (see D3). With D5 locking
|
||||||
|
> Windows-only, JSFX's one real edge — cross-platform-for-free — is worth nothing here.
|
||||||
|
> Native VST3 wins cleanly given the locks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1a. How crazy is bare VST3 without JUCE? (the honest assessment)
|
||||||
|
|
||||||
|
Daniel's question, directly: *how crazy is it to comply with the VST3 surface without
|
||||||
|
something like JUCE?* Short answer: **not crazy — the audio-processing side is a
|
||||||
|
few-hundred-lines-of-ceremony-you-write-once problem, not a tar pit. The one genuine
|
||||||
|
question is the editor UI, and ReaSampler is unusually well-positioned to answer it
|
||||||
|
without JUCE.** The detail, honestly, with estimate-vs-verified flagged.
|
||||||
|
|
||||||
|
### What the raw Steinberg VST3 SDK actually demands
|
||||||
|
|
||||||
|
A working VST3 instrument must present these interfaces (all VST3 interfaces descend from
|
||||||
|
`FUnknown`, a COM-like base with `queryInterface` / `addRef` / `release` — **verified**,
|
||||||
|
Steinberg VST3 SDK class reference):
|
||||||
|
|
||||||
|
- **`IComponent`** — the plugin's identity and bus/state setup: `initialize`,
|
||||||
|
`setActive`, `getBusCount` / `getBusInfo`, `activateBus`, `setState` / `getState`.
|
||||||
|
- **`IAudioProcessor`** — the DSP contract: `setBusArrangements`, `setupProcessing`
|
||||||
|
(`ProcessSetup`: sample rate, block size, symbolic sample size), `setProcessing`, and
|
||||||
|
the hot path **`process(ProcessData&)`** — where you read MIDI events off the event
|
||||||
|
input bus and write audio to the output bus. For an *instrument* you declare an **event
|
||||||
|
input bus** (MIDI in) and an **audio output bus**, no audio input. (**Verified**:
|
||||||
|
`IAudioProcessor` reference; the instrument bus topology is standard.)
|
||||||
|
- **`IEditController`** — parameter model + editor: `getParameterCount` /
|
||||||
|
`getParameterInfo`, `getState` / `setState`, `setComponentState`, normalized↔plain
|
||||||
|
parameter conversion, and `createView("editor")` returning an `IPlugView` if you have a
|
||||||
|
GUI.
|
||||||
|
- **`IPluginFactory`** (via the module's exported **`GetPluginFactory`**) — enumerates the
|
||||||
|
classes the module offers (the processor and, in the two-component model, the
|
||||||
|
controller), keyed by class UIDs. On Windows the module also exports **`InitDll` /
|
||||||
|
`ExitDll`** (bundle entry points differ per-OS, but D5 makes Windows the only target, so
|
||||||
|
it's just these two plus `GetPluginFactory`). (**Experienced estimate** on the exact
|
||||||
|
Windows export names — the SDK's `dllmain.cpp` / `public.sdk` main glue provides these;
|
||||||
|
I have not re-read the header this session, so treat the exact symbol names as
|
||||||
|
to-verify-against-`public.sdk/source/main/` before build, not as a load-bearing claim.)
|
||||||
|
|
||||||
|
**The COM plumbing is real but bounded.** `queryInterface`/`addRef`/`release` plus the
|
||||||
|
class-factory macros (`BEGIN_FACTORY` / `DEF_CLASS2` / `END_FACTORY`, and the
|
||||||
|
`DECLARE_FUNKNOWN_METHODS` / `IMPLEMENT_REFCOUNT` helper macros) are **provided by the
|
||||||
|
SDK's `pluginterfaces` and `public.sdk` layers** — you do not hand-write refcounting; you
|
||||||
|
invoke macros. (**Experienced estimate** on the exact macro names — these are the
|
||||||
|
long-standing VST3 SDK factory macros; verify spelling against the vendored SDK headers at
|
||||||
|
build time.) This is the "ceremony you write once" — it is copy-adapt-from-the-example
|
||||||
|
work, not design work.
|
||||||
|
|
||||||
|
**And the SDK hands you base classes that absorb most of it.** The critical fact for
|
||||||
|
Daniel's question: you do **not** implement those four interfaces from scratch. The SDK's
|
||||||
|
`public.sdk` layer provides:
|
||||||
|
|
||||||
|
- **`AudioEffect`** (with `Component` / `AudioEffect` base) — implements `IComponent` +
|
||||||
|
`IAudioProcessor` boilerplate; you override `initialize` (declare busses), `setupProcessing`,
|
||||||
|
`setActive`, `setState`/`getState`, and `process`.
|
||||||
|
- **`EditControllerEx1`** — implements `IEditController` boilerplate; you override
|
||||||
|
parameter registration and state.
|
||||||
|
- **`SingleComponentEffect`** — **combines processor and controller into one class**
|
||||||
|
(descends from `EditControllerEx1` and the component hierarchy). You override
|
||||||
|
`initialize` (call `addAudioOutput` + `addEventInput`), `setupProcessing`, and
|
||||||
|
`process`. (**Verified**: Steinberg SDK `SingleComponentEffect` class reference — "default
|
||||||
|
implementation for a non-distributable Plug-in that combines processor and edit
|
||||||
|
controller in one component." The SDK cautions to prefer the two-component split for
|
||||||
|
distributable plugins, but for a **REAPER-only, non-distributable** instrument (D5/D6),
|
||||||
|
`SingleComponentEffect` is exactly the sanctioned shortcut and cuts the interface surface
|
||||||
|
roughly in half.)
|
||||||
|
|
||||||
|
**Honest quantification of the audio side:** with `SingleComponentEffect` + the factory
|
||||||
|
macros, a *silent-but-loading* VST3 instrument skeleton — factory, class registration,
|
||||||
|
module entry, bus setup, empty `process` — is on the order of **~200–400 lines of
|
||||||
|
adapt-from-example ceremony**, written once, then largely untouched. The AGain / Note
|
||||||
|
Expression Synth examples that ship with the SDK are exactly this skeleton and are the
|
||||||
|
copy-source. This is **not** a tar pit. The tar-pit reputation VST3 has comes from (a)
|
||||||
|
multi-format wrappers (AU/AAX/VST2 — **not our problem**, D5) and (b) the GUI, addressed
|
||||||
|
next. (**Experienced estimate** on the line-count band — grounded in the shape of the SDK
|
||||||
|
examples, not a line-counted measurement; treat as an order-of-magnitude honest estimate,
|
||||||
|
not a promise.)
|
||||||
|
|
||||||
|
### What JUCE buys — and whether ReaSampler needs it
|
||||||
|
|
||||||
|
JUCE exists largely to solve problems D5 has already deleted for us. Weighed against the
|
||||||
|
locked constraints:
|
||||||
|
|
||||||
|
| What JUCE provides | Do we need it, given the locks? |
|
||||||
|
|---|---|
|
||||||
|
| **Multi-format wrapper** (one codebase → VST3/AU/AAX/VST2/standalone) | **No.** Single format, VST3 only, REAPER only. This is JUCE's biggest reason to exist and it's moot here. |
|
||||||
|
| **Plugin boilerplate / `AudioProcessor`** | Marginal. The Steinberg SDK's `SingleComponentEffect` already absorbs the VST3 boilerplate; JUCE's abstraction sits *on top of* the same SDK. |
|
||||||
|
| **Parameter management** (`AudioProcessorValueTreeState`) | Nice-to-have. Genuinely convenient, but a sampler's parameter set (envelope, gain, tuning) is small; hand-rolling over `IEditController` is bounded. |
|
||||||
|
| **DSP utilities** (`juce::dsp`, filters, oscillators, interpolators) | Nice-to-have for Tier 3 (filters/LFOs). For Tier 0–2 the sampler DSP — repitch interpolation, envelopes, voice allocation — is exactly the pure core we *want* to write and test ourselves (D3). |
|
||||||
|
| **Editor/UI framework** (`juce::Component`, graphics, widgets) | **The one real pull.** VST3 gives you `IPlugView` and *no toolkit whatsoever*. Something has to draw the editor. This is the crux — see below. |
|
||||||
|
|
||||||
|
**Costs of JUCE, honestly:** it is a large dependency (a whole framework, not a library);
|
||||||
|
it brings its own build system (Projucer / CMake integration) and idioms that would sit
|
||||||
|
oddly beside ReaSampler's lean two-submodule CMake discipline; and its **licensing** is a
|
||||||
|
real standing commitment. JUCE 8 is **dual-licensed AGPLv3 or commercial**. The free tier
|
||||||
|
(Starter) is usable below **~$20K/yr revenue** and — notably in JUCE 8 — **no longer
|
||||||
|
requires a splash screen**. Above that, **Indie (~$200K/yr revenue limit) is a paid
|
||||||
|
license (~$3,500 as of JUCE 8)**, and Pro above that. (**Verified via web**, JUCE forum +
|
||||||
|
license pages — see Sources.) For a personal/ReaSampler-scale tool the free Starter tier
|
||||||
|
likely applies today, but taking JUCE means accepting AGPL-or-pay as a permanent posture
|
||||||
|
on the instrument. That's a bigger standing commitment than the Steinberg SDK, which is a
|
||||||
|
**permissive** (proprietary-but-royalty-free, GPLv3-optional) license with no revenue
|
||||||
|
gate.
|
||||||
|
|
||||||
|
### The UI is the real question — and ReaSampler is unusually well-armed
|
||||||
|
|
||||||
|
Strip everything else away and the honest "reach for JUCE?" decision reduces to one thing:
|
||||||
|
**who draws the editor?** VST3 gives you an `IPlugView` seat and nothing to fill it with.
|
||||||
|
The options on Windows-only (D5):
|
||||||
|
|
||||||
|
1. **JUCE** — for its `Component` graphics stack alone. This is the *usual* reason indie
|
||||||
|
devs take JUCE, and if ReaSampler had no UI competence it'd be the default.
|
||||||
|
2. **VSTGUI** — the UI toolkit that **ships with the Steinberg SDK itself**. Lighter than
|
||||||
|
JUCE, purpose-built for VST editors, no extra dependency beyond the SDK you already
|
||||||
|
took. A real middle option. (**Experienced estimate** — VSTGUI is bundled with the VST3
|
||||||
|
SDK; verify the vendored SDK includes it before relying on it.)
|
||||||
|
3. **Win32 / GDI / Direct2D directly** — Windows-only makes this viable; you own an HWND in
|
||||||
|
the `IPlugView`.
|
||||||
|
4. **The same LICE/SWELL stack the extension already uses.** — **This is the one worth
|
||||||
|
staring at for Daniel specifically.**
|
||||||
|
|
||||||
|
**Why option 4 changes the calculus for *this* project.** ReaSampler's `bank_panel` is
|
||||||
|
**already a LICE-drawn UI** — a real, working, docked, custom-drawn panel (grid,
|
||||||
|
thumbnails, tab strip, hit-testing) built on LICE/SWELL, the same stack REAPER itself and
|
||||||
|
SWS use. That's **67 LICE call-sites in `bank_panel.cpp`** today. The team (Daniel + the
|
||||||
|
implementers) has already paid the learning cost of drawing a custom audio-tool UI in
|
||||||
|
LICE. The usual "take JUCE because hand-rolling a plugin GUI from nothing is miserable"
|
||||||
|
argument is **much weaker here than for a typical indie dev**, because ReaSampler is not
|
||||||
|
starting from nothing — it has demonstrated LICE UI competence and a house style. A VST3
|
||||||
|
`IPlugView` that hosts a LICE-drawn surface would (a) reuse existing UI muscle, (b) keep
|
||||||
|
the instrument's look consistent with the panel, and (c) avoid the JUCE dependency and its
|
||||||
|
license posture entirely. There's even the **embedded-UI affordance** (D7 below) that lets
|
||||||
|
a REAPER-hosted plugin draw inline in the TCP/MCP using this same Cockos surface.
|
||||||
|
|
||||||
|
*The honest caveat:* wiring LICE into a VST3 `IPlugView` (window creation, sizing,
|
||||||
|
event routing from the host into your draw/hit-test loop) is **integration work with real
|
||||||
|
edges** — you're bridging the SDK's view lifecycle to a LICE `HWND`/bitmap. It's not free,
|
||||||
|
and it's less trodden than "drop in a JUCE editor." But it is *the same class of work
|
||||||
|
ReaSampler already did* to dock `bank_panel`, not a new competence. (**Experienced
|
||||||
|
estimate** on the difficulty band — grounded in how `IPlugView` and LICE each work, not a
|
||||||
|
built prototype. Flag: the exact `IPlugView`↔LICE bridge should be spiked before it's
|
||||||
|
promised in a plan.)
|
||||||
|
|
||||||
|
### The pure-core discipline (D3) lands cleanly here
|
||||||
|
|
||||||
|
**Confirmed: the discipline holds, and JUCE-vs-bare doesn't change it.** The sampler's
|
||||||
|
voice engine, envelope math, key/velocity mapping, repitch/interpolation, and keymap
|
||||||
|
resolution are pure DSP + data — **exactly the kind of REAPER-free, DAW-free, unit-tested
|
||||||
|
core** this project already excels at (`bank_model` / `peaks` / `view_mode_model` are the
|
||||||
|
template). They live in a pure module, tested in the CTest harness outside any host. The
|
||||||
|
VST3 wrapper — `SingleComponentEffect` subclass, bus setup, `process` marshalling MIDI→core
|
||||||
|
and core→audio-buffer, the `IPlugView` editor, and the bridge calls that read
|
||||||
|
`"reasampler"` ext-state — is the **thin shell**, the only part that touches VST3 or REAPER
|
||||||
|
at all. This is precisely ReaSampler's load-bearing split, transplanted to a new format.
|
||||||
|
|
||||||
|
Native VST3 makes this *cleaner* than the retired JSFX path would have (JSFX's "core" would
|
||||||
|
be JSFX script, un-unit-testable in CTest). And **JUCE-vs-bare doesn't move it**: whether
|
||||||
|
the shell is a bare-SDK `SingleComponentEffect` or a `juce::AudioProcessor`, the pure core
|
||||||
|
underneath is identical, REAPER-free, and tested the same way. The format choice is a
|
||||||
|
*shell* choice; the core is invariant. That's a reassuring result — it means D-A (bare vs.
|
||||||
|
JUCE, below) can be decided on shell ergonomics and dependency posture alone, without
|
||||||
|
risking the part of the architecture Daniel most cares about.
|
||||||
|
|
||||||
|
### Bottom line for Daniel's question
|
||||||
|
|
||||||
|
Complying with the VST3 surface without JUCE is **reasonable, not crazy** — the
|
||||||
|
audio-processing scaffolding is bounded, example-driven ceremony that
|
||||||
|
`SingleComponentEffect` cuts down further, and the D5 lock-downs delete JUCE's biggest
|
||||||
|
justification (multi-format). The **only** place JUCE earns its weight is the editor UI —
|
||||||
|
and ReaSampler's existing LICE competence makes even *that* argument weaker than it would
|
||||||
|
be for a typical indie. My honest lean (D-A below): **bare Steinberg SDK + LICE editor**,
|
||||||
|
with JUCE as the fallback if the `IPlugView`↔LICE bridge proves gnarlier than the panel
|
||||||
|
work suggests. But it's a genuine fork and it's Daniel's — laid out in §4.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. The integration seam
|
## 2. The integration seam
|
||||||
|
|
||||||
The obvious shared artifact is what ReaSampler already produces: **the bank folder
|
There are two seams, and with native VST3 locked (D1) the instrument gets **both**:
|
||||||
(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
|
- **File seam (audio, always).** The sample **audio** is WAVs on disk — there's no getting
|
||||||
doesn't carry today.
|
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
|
**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,
|
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
|
seam ReaSampler already knows how to add (mirror of how `provenance` was added as a
|
||||||
field in M1 and populated later).
|
field in M1 and populated later).
|
||||||
|
|
||||||
**A note on format:** if the instrument is native (Option A), it reads the bank JSON
|
**Wiring note (native, the locked path).** The native instance sidesteps the "point me at
|
||||||
directly — trivial. If it's JSFX (Option B), reading arbitrary JSON is friction; the
|
the right bank folder" problem entirely: via the context callback it asks REAPER which
|
||||||
seam might need a **simpler sidecar** (a flat `.txt`/key-value map ReaSampler writes
|
project it's in, then reads that project's bank location straight from `"reasampler"`
|
||||||
next to the bank, JSFX-parseable) rather than making JSFX parse the index JSON. That's
|
ext-state — no user wiring, no saved folder path in the instrument's own state. This is the
|
||||||
a concrete cost of the JSFX path and a reason the seam design and the format choice are
|
cleanest possible integration and it's the one D6 chose. The one thing to keep deliberate:
|
||||||
coupled.
|
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
|
||||||
**Portability caveat:** the bank is project-relative and travels with the `.rpp`
|
hand-wave. Since D5 locks REAPER-only/Windows-only, the old "but it won't run in other
|
||||||
(M4 machinery). Any instrument consuming it must resolve paths the same way — so the
|
hosts" concern is **moot by design** — the bridge dependency is intended, not a narrowing to
|
||||||
instrument needs to know the *current* project bank folder. A native plugin instance
|
regret.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
**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.
|
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
|
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
|
thing that delivers the promise* and is the honest MVP. On the native path this is the
|
||||||
genuinely small build; on the native path it's the skeleton of the plugin.
|
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
|
**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
|
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
|
These are locked. The reasoning is kept as the record of *why*, so the choices don't get
|
||||||
central fork. JSFX = no new binary, no JUCE, cross-platform free, real ceiling on
|
silently re-litigated.
|
||||||
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 0–1, **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.
|
|
||||||
|
|
||||||
**D2 — Whether to take a JUCE (or any external plugin-SDK) dependency at all.** The
|
**D1 — Instrument format → DECIDED: native VST3.** JSFX is off the table (retirement
|
||||||
project today is a clean C++ extension with two vendored submodules and a proud
|
reasoning in §1, in the JSFX box). Native VST3 gives full sampler sophistication, clean
|
||||||
pure-core/shell discipline. A native instrument means a *third* major dependency and a
|
integration, and the VST-host bridge (live `"reasampler"` ext-state, project-awareness,
|
||||||
second build target of a fundamentally different kind. That's a real architectural
|
embedded-UI affordance). The cost — a second codebase in a plugin format — is accepted.
|
||||||
weight. JSFX sidesteps it entirely. Flagging it as its own decision because "should we
|
The old "JSFX-first cheap prototype" lean is withdrawn: with D5 locking Windows-only,
|
||||||
depend on JUCE" is a bigger standing commitment than "should we build a sampler."
|
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.
|
||||||
|
|
||||||
**D3 — Does the pure-core discipline survive the format boundary?** ReaSampler's
|
**D5 — Cross-platform / multiformat → DECIDED: none. Windows-only, VST3-only,
|
||||||
identity is *pure REAPER-free testable core + thin shells*. A sampler's **voice engine,
|
REAPER-only.** The extension is Windows-only; Daniel does not work on other platforms.
|
||||||
envelope math, key/velocity mapping, and repitch logic are exactly the kind of pure,
|
This *removes* costs prior drafts carried: no mac/Linux DSP/build/signing matrix, no
|
||||||
testable core** this project excels at — they could live in a REAPER-free, DAW-free,
|
multi-format wrapper, no "runs standalone in other hosts" concern, no CLAP-for-portability
|
||||||
unit-tested module (mirror of `bank_model`/`peaks`/`view_mode_model`) with the plugin
|
argument. REAPER-coupling via the bridge is intended, not a narrowing to regret. This is
|
||||||
format (JSFX or VST3 wrapper) as the thin shell around it. **This is the most
|
the single biggest simplifier — it is *why* bare VST3 is reasonable (§1a): most of what
|
||||||
ReaSampler-native way to build it** and I'd argue strongly for it regardless of D1: the
|
makes VST3 painful (multi-format, cross-platform) is deleted.
|
||||||
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.
|
|
||||||
|
|
||||||
**D4 — Where the mapping lives (seam fork i/ii/iii from §2).** Bank-owned map,
|
**D6 — One product or two → DECIDED: two products, but tightly integrated.** The
|
||||||
instrument-owned map, or split (file-intrinsics in the bank, performance-map in the
|
instrument is a *separate artifact* from the extension (the extension stays the pure
|
||||||
instrument). *My lean:* **(iii) split** — it's the one that honors ReaSampler's
|
capture/organize tool; the instrument is the playback surface — capture and placement, and
|
||||||
capture/placement instinct and keeps the bank tool-agnostic. But it's Daniel's call
|
now playback, stay distinct acts). But it is **not** a divorced file-only companion: via
|
||||||
whether ReaSampler should author instrument definitions at all, or stay purely a
|
the bridge it reads the live `"reasampler"` project ext-state and is project-aware — "two
|
||||||
sample library that a *separate* mapping tool arranges.
|
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.
|
||||||
|
|
||||||
**D5 — Cross-platform.** The extension is already cross-platform (SWELL). JSFX inherits
|
**D3 — Pure-core discipline across the format boundary → CONFIRMED holds (see §1a).** Not
|
||||||
that for free. A native plugin re-opens the full cross-platform DSP + UI + build matrix
|
a fork so much as a checked assumption: the sampler's voice engine, envelope math,
|
||||||
(Win/mac/Linux, code-signing on mac, etc.) as a *separate* artifact. A cost that lands
|
key/velocity mapping, and repitch/interpolation live in a pure REAPER-free, DAW-free,
|
||||||
entirely on the native path.
|
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
|
### Residual forks — SETTLED (Daniel, 2026-07-26; reasoning preserved, not re-opened)
|
||||||
"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
|
All four residual decisions are now called. Each is marked **SETTLED** with Daniel's
|
||||||
where the instrument is a **companion product** that shares the bank format, not a
|
choice and the reasoning kept as the record of *why* — do not re-litigate. They are
|
||||||
feature *of* ReaSampler — the way a sample library and a sampler that reads it are
|
scoped into **PLAN.md §Phase S** / **CONTEXT.md §Phase S**.
|
||||||
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.
|
**D-A — SETTLED: bare Steinberg VST3 SDK + LICE editor (no JUCE).** *(The central fork.
|
||||||
The alternative reading — it's all one integrated sampler-workstation — is also
|
§1a is the assessment that fed it. The sub-question — who draws the editor? — was the
|
||||||
coherent. **Which of those two ReaSampler *is* is the highest-order question here, and
|
whole fork, because §1a showed the audio-processing scaffolding is bounded either way.)*
|
||||||
it's Daniel's to answer before format/tier decisions mean much.**
|
Daniel took **Option A**: bare Steinberg SDK, `SingleComponentEffect` for the bounded
|
||||||
|
audio scaffolding, editor drawn in the **same LICE/SWELL stack `bank_panel` already
|
||||||
|
uses** — no JUCE dependency, no AGPL-or-pay posture, house-consistent UI. The
|
||||||
|
`IPlugView`↔LICE bridge (the one real unknown) is **not** a gate on the decision (the
|
||||||
|
decision is made) but remains the right *first implementation step*: it is scheduled as
|
||||||
|
**Phase S's opening spike (S1)** to convert §1a's experienced-estimates — Windows
|
||||||
|
module-export names, factory-macro spellings, exact bridge marshalling — into verified
|
||||||
|
fact before the engine build leans on them. (The stale §1a "is VSTGUI even bundled"
|
||||||
|
question is dropped as moot under Option A; VSTGUI remains a noted fallback rung only if
|
||||||
|
the LICE bridge proves gnarlier than the panel work suggests, with JUCE the last resort
|
||||||
|
behind that.) The full option analysis (A/B/C, the JUCE license posture, the LICE-bridge
|
||||||
|
caveat) is preserved below as the record.
|
||||||
|
|
||||||
|
- *Option A — bare Steinberg SDK + LICE editor.* Take only the VST3 SDK (permissive,
|
||||||
|
royalty-free, no revenue gate). Use `SingleComponentEffect` for the ~200–400 lines of
|
||||||
|
once-written audio scaffolding, and draw the editor in **LICE/SWELL — the stack
|
||||||
|
`bank_panel` already uses** (67 LICE call-sites today). *Pro:* no JUCE dependency, no
|
||||||
|
AGPL-or-pay posture, house-consistent UI, reuses existing UI muscle, lean CMake
|
||||||
|
discipline preserved. *Con:* the `IPlugView`↔LICE bridge is real integration work with
|
||||||
|
edges (window lifecycle, sizing, event routing) — less trodden than dropping in a JUCE
|
||||||
|
editor, and it should be **spiked before it's promised**.
|
||||||
|
- *Option B — JUCE.* Take JUCE for its editor framework + parameter management + DSP
|
||||||
|
utilities. *Pro:* the editor is a solved problem, `AudioProcessorValueTreeState` is
|
||||||
|
convenient, `juce::dsp` helps at Tier 3. *Con:* a large framework dependency with its own
|
||||||
|
build system and idioms sitting oddly beside the two-submodule discipline; and the
|
||||||
|
**license posture** — AGPLv3-or-commercial, free Starter tier below ~$20K/yr revenue (no
|
||||||
|
splash screen in JUCE 8), Indie ~$3,500 with a ~$200K/yr limit above that. For most of
|
||||||
|
what JUCE solves (multi-format), **D5 already deleted the need.**
|
||||||
|
- *Option C — bare SDK + VSTGUI (the SDK's own bundled toolkit).* A middle path: no JUCE,
|
||||||
|
but a purpose-built VST editor toolkit instead of hand-bridging LICE. Lighter than JUCE,
|
||||||
|
no extra dependency beyond the SDK. Worth a look if the LICE bridge proves gnarly.
|
||||||
|
|
||||||
|
*My honest lean:* **Option A (bare SDK + LICE editor)**, precisely because ReaSampler is
|
||||||
|
the atypical case where the "take JUCE for the GUI" default is weakest — it already has
|
||||||
|
working LICE UI competence and a house style. JUCE's headline value (multi-format) is moot
|
||||||
|
under D5. Fall back to JUCE (Option B) *only if* the `IPlugView`↔LICE spike shows the
|
||||||
|
bridge is genuinely painful; consider VSTGUI (Option C) as the middle rung before
|
||||||
|
conceding to a full framework. **Concrete ask: greenlight a small `IPlugView`↔LICE spike
|
||||||
|
before committing** — it's the one unknown that decides A vs. B, and §1a's LICE-bridge and
|
||||||
|
Windows-export-name claims are experienced estimates that a spike would convert to fact.
|
||||||
|
|
||||||
|
**D-B — SETTLED: split seam (option iii), and the intrinsic fields are added NOW.**
|
||||||
|
*(The mapping-ownership fork, old D4. Options were: (i) bank-owned map, (ii)
|
||||||
|
instrument-owned map, or (iii) split.)* Daniel took **(iii) split** — and, critically,
|
||||||
|
called that the `Sample` intrinsic fields land **now**, not deferred. **Root note + loop
|
||||||
|
points become bank intrinsics on `Sample`** (facts about the captured file, like sample
|
||||||
|
rate/length/peaks) — a small additive change, the same shape as adding `provenance` in
|
||||||
|
M1. **Zones, velocity layers, round-robin, envelopes are the instrument's performance
|
||||||
|
map** (a creative arrangement, not a file fact). The live-state seam (§2) means the
|
||||||
|
instrument reads even the performance-map out of shared `"reasampler"` state, so "who owns
|
||||||
|
which field" is a data-ownership decision, not a transport one. Adding the fields now
|
||||||
|
closes the **backfill cliff** (the *design-the-seam-even-if-you-defer-the-feature*
|
||||||
|
instinct, same as Fork R-D's owned-file manifest): every sample captured before the
|
||||||
|
fields exist would otherwise lack a root note / loop points and need hand-backfilling. The
|
||||||
|
field addition touches the **extension** codebase, is independently shippable, and is
|
||||||
|
scheduled as an **early Phase S point (S2)** ahead of the instrument that consumes it.
|
||||||
|
|
||||||
|
**D-C — SETTLED: Tier 0–1 scoped now; Tier 2 held; Tier 3 optional-forever.** *(The
|
||||||
|
tier-scope fork, §3. Tiers: 0 "the bank plays" / 1 "a keymap" / 2 "expressive" / 3
|
||||||
|
"polish".)* Daniel took the recommended scope: **Tier 0** ("the bank plays" — one sample,
|
||||||
|
chromatic, amp envelope, velocity→volume) then **Tier 1** ("a keymap" — zoned multisamples
|
||||||
|
with per-sample root notes) are the committed scope and the "does this belong in
|
||||||
|
ReaSampler's world" proof (Phase S points S4/S5). **Tier 2** ("expressive" — velocity
|
||||||
|
layers, round-robin, ADSR, loops) is held as an explicit follow-on — **noted, not
|
||||||
|
specified** (its points are not drawn up). **Tier 3** ("polish" — filters, LFOs, choke
|
||||||
|
groups) is optional-forever. The Tier-0–1 editor and DSP needs are modest, which is part of
|
||||||
|
why the D-A "no JUCE" call is comfortable.
|
||||||
|
|
||||||
|
**D-D — SETTLED: embedded TCP/MCP UI is SCHEDULED (not deferred).** *(The
|
||||||
|
inline-in-REAPER-UI lever.)* A REAPER-hosted VST3 can draw its own UI *inline in the
|
||||||
|
track/mixer control panel* (`reaper_plugin_fx_embed.h`: VST3 implements
|
||||||
|
`IReaperUIEmbedInterface`; the same Cockos surface REAPER's own JS/embedded FX use) — a
|
||||||
|
compact keymap/level strip inline in the TCP/MCP, not only in its own window. Because it
|
||||||
|
uses the **same LICE-class drawing as the D-A editor path**, it composes naturally with the
|
||||||
|
bare-SDK-plus-LICE build. **Daniel's call: schedule this, don't defer it** — it is a real,
|
||||||
|
in-phase later point on the Phase S roadmap (**S6**), sequenced *after* the main
|
||||||
|
`IPlugView` editor exists (it composes with that LICE path), not a someday-note. It is
|
||||||
|
polish rather than a Tier-0 need, so it sequences last — but it is on the roadmap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
1. **D-A → bare Steinberg VST3 SDK + LICE editor** (no JUCE). The `IPlugView`↔LICE bridge
|
||||||
companion product sharing the bank format? Everything else sits under this.
|
is the opening implementation spike (**S1**), not a decision gate.
|
||||||
2. **D1 — JSFX-first prototype, or straight to native?** Given a target of Tier 0–1 to
|
2. **D-B → split seam; root note + loop points added to `Sample` now** (**S2**, in the
|
||||||
start, and the pure-core-as-shell discipline (D3) held either way.
|
extension) to close the backfill cliff.
|
||||||
|
3. **D-C → Tier 0–1 committed** (**S4/S5**); Tier 2 held (noted, not specified); Tier 3
|
||||||
|
optional-forever.
|
||||||
|
4. **D-D → embedded TCP/MCP UI scheduled** (**S6**), after the main editor exists — on the
|
||||||
|
roadmap, not deferred.
|
||||||
|
|
||||||
Once those two are called, the seam fields (§2, D4) and the tier scope (§3) become
|
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6, sequenced by
|
||||||
concrete enough to write an actual phase spec. Until then this stays a framing doc with
|
dependency order: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
|
||||||
no PLAN.md footprint — deliberately, so we don't scope an instrument before deciding
|
UI); **CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the
|
||||||
whether we're building one.
|
pure/shell split in the new artifact, the must-verify SDK/bridge surfaces). This doc is the
|
||||||
|
framing/decision record they point back to. The "no PLAN.md footprint" era is over.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources (for §1a's verified claims)
|
||||||
|
|
||||||
|
- Steinberg VST3 SDK — `SingleComponentEffect` class reference (combined processor +
|
||||||
|
controller; "non-distributable" caveat):
|
||||||
|
https://steinbergmedia.github.io/vst3_doc/vstsdk/classSteinberg_1_1Vst_1_1SingleComponentEffect.html
|
||||||
|
- Steinberg VST3 SDK — `IAudioProcessor` class reference (process/setupProcessing/bus
|
||||||
|
contract):
|
||||||
|
https://steinbergmedia.github.io/vst3_doc/vstinterfaces/classSteinberg_1_1Vst_1_1IAudioProcessor.html
|
||||||
|
- VST3 Developer Portal (overview, tutorials, example plugins — AGain / Note Expression
|
||||||
|
Synth as the skeleton copy-source): https://steinbergmedia.github.io/vst3_dev_portal/
|
||||||
|
- JUCE 8 EULA (dual AGPLv3 / commercial; tier structure):
|
||||||
|
https://juce.com/legal/juce-8-licence/
|
||||||
|
- JUCE forum — revenue limits & JUCE 8 pricing (Starter ~$20K/yr free, Indie ~$200K/yr
|
||||||
|
limit, ~$3,500): https://forum.juce.com/t/revenue-limits-for-juce-tiers/61058
|
||||||
|
|
||||||
|
*Estimate-vs-verified honesty note:* the interface list, `SingleComponentEffect`'s role,
|
||||||
|
the instrument bus topology, and the JUCE license terms are **verified** from the sources
|
||||||
|
above. The **line-count band (~200–400)**, the **exact Windows module-export symbol names**
|
||||||
|
(`InitDll`/`ExitDll`/`GetPluginFactory`), the **factory-macro spellings**
|
||||||
|
(`BEGIN_FACTORY`/`DEF_CLASS2`), **VSTGUI being bundled in the vendored SDK**, and the
|
||||||
|
**`IPlugView`↔LICE bridge difficulty** are **experienced estimates** grounded in how the
|
||||||
|
SDK is shaped — each flagged inline in §1a and each cheap to convert to fact by reading the
|
||||||
|
vendored SDK headers / running a spike before any of it lands in a plan.
|
||||||
|
|||||||
@@ -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
@@ -545,7 +545,6 @@ void doBankCreate() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
persistBankOp("ReaSampler: create bank");
|
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.
|
// Rename a bank: prompt for which bank (by current display name) and the new name.
|
||||||
@@ -571,8 +570,6 @@ void doBankRename() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
persistBankOp("ReaSampler: rename bank");
|
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:
|
// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail:
|
||||||
@@ -615,7 +612,6 @@ void doBankDelete() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
persistBankOp("ReaSampler: delete bank");
|
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
|
// Evacuate a named bank: move every member back to the pool (index-only, collapse by
|
||||||
@@ -637,7 +633,6 @@ void doBankEvacuate() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
persistBankOp("ReaSampler: evacuate bank");
|
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),
|
// 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 (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded)
|
||||||
if (!g_session->book().setActiveBank(target)) return;
|
if (!g_session->book().setActiveBank(target)) return;
|
||||||
persistBankOp("ReaSampler: activate bank");
|
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
|
// Activate the pool directly (the common "back to the default target" jump). Bindable
|
||||||
@@ -663,7 +654,6 @@ void doBankActivateNext() {
|
|||||||
void doBankActivatePool() {
|
void doBankActivatePool() {
|
||||||
if (!g_session->book().setActiveBank(kPoolBankId)) return;
|
if (!g_session->book().setActiveBank(kPoolBankId)) return;
|
||||||
persistBankOp("ReaSampler: activate bank");
|
persistBankOp("ReaSampler: activate bank");
|
||||||
ShowConsoleMsg("ReaSampler: active bank -> \"Pool\".\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move or copy the panel's selected samples into a named destination bank (prompted
|
// Move or copy the panel's selected samples into a named destination bank (prompted
|
||||||
@@ -698,7 +688,10 @@ void doBankTransferSelected(bool copy) {
|
|||||||
return;
|
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) {
|
for (const std::string& sampleId : selected) {
|
||||||
const TransferResult r =
|
const TransferResult r =
|
||||||
copy ? g_session->book().copySample(sampleId, srcId, destId)
|
copy ? g_session->book().copySample(sampleId, srcId, destId)
|
||||||
@@ -707,8 +700,9 @@ void doBankTransferSelected(bool copy) {
|
|||||||
case TransferResult::Moved:
|
case TransferResult::Moved:
|
||||||
case TransferResult::Copied: ++ok; break;
|
case TransferResult::Copied: ++ok; break;
|
||||||
case TransferResult::Collapsed: ++collapsed; break;
|
case TransferResult::Collapsed: ++collapsed; break;
|
||||||
case TransferResult::RejectedSampleAbsent: ++absent; break;
|
// RejectedSampleAbsent and unknown-bank / same-bank (pre-checked above) are
|
||||||
// Unknown-bank / same-bank are pre-checked above; treat defensively as no-ops.
|
// no-ops for the guardrail; nothing mutated for those ids.
|
||||||
|
case TransferResult::RejectedSampleAbsent:
|
||||||
case TransferResult::RejectedUnknownBank:
|
case TransferResult::RejectedUnknownBank:
|
||||||
case TransferResult::RejectedSameBank: break;
|
case TransferResult::RejectedSameBank: break;
|
||||||
}
|
}
|
||||||
@@ -726,12 +720,6 @@ void doBankTransferSelected(bool copy) {
|
|||||||
std::string("ReaSampler: ") + verb + " sample(s)";
|
std::string("ReaSampler: ") + verb + " sample(s)";
|
||||||
persistBankOp(label.c_str());
|
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
|
// 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
|
// 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
|
// 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).
|
// undo point when nothing was removed (every id was already absent). The per-outcome
|
||||||
int removed = 0, absent = 0;
|
// 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) {
|
for (const std::string& sampleId : selected) {
|
||||||
switch (book.removeSample(sampleId, srcId, RemoveScope::ThisBank)) {
|
if (book.removeSample(sampleId, srcId, RemoveScope::ThisBank) ==
|
||||||
case RemoveResult::Removed: ++removed; break;
|
RemoveResult::Removed)
|
||||||
case RemoveResult::RejectedSampleAbsent: ++absent; break;
|
++removed;
|
||||||
// Unknown bank cannot occur — srcId was resolved to a live bank above.
|
// RejectedSampleAbsent / RejectedUnknownBank are no-ops for the guardrail.
|
||||||
case RemoveResult::RejectedUnknownBank: break;
|
// (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.
|
// No-op guardrail (R-B): open an undo point only if the index actually mutated.
|
||||||
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
|
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
|
} // namespace
|
||||||
|
|||||||
@@ -52,7 +52,6 @@
|
|||||||
#define REAPERAPI_WANT_Main_OnCommand
|
#define REAPERAPI_WANT_Main_OnCommand
|
||||||
#define REAPERAPI_WANT_Main_SaveProject
|
#define REAPERAPI_WANT_Main_SaveProject
|
||||||
#define REAPERAPI_WANT_Master_GetTempo
|
#define REAPERAPI_WANT_Master_GetTempo
|
||||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
|
||||||
#include "reaper_plugin_functions.h"
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|||||||
+7
-37
@@ -135,7 +135,7 @@ static ReaProject* g_rtCaptureProject = nullptr;
|
|||||||
|
|
||||||
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
|
// 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),
|
// 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.
|
// paths. On a non-Ok result, logs the failure only.
|
||||||
static void CommitRealtimeResult(const reasampler::CaptureResult& res)
|
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());
|
ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str());
|
||||||
return;
|
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
|
// 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
|
// 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
|
// 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).
|
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
|
||||||
g_session.owned().add(res.sample.relativePath);
|
g_session.owned().add(res.sample.relativePath);
|
||||||
g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp)
|
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
|
// 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).
|
// 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
|
// 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
|
// 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
|
// 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
|
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
|
||||||
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
|
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
|
||||||
g_session.saveToActiveProject();
|
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
|
// 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).
|
// completion across ticks (UI stays responsive).
|
||||||
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
|
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
|
||||||
g_rtCapture = std::move(st);
|
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
|
// 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);
|
reasampler::InsertResult res = reasampler::runInsert(&g_session, req);
|
||||||
|
|
||||||
std::string msg;
|
|
||||||
switch (res.status)
|
switch (res.status)
|
||||||
{
|
{
|
||||||
case reasampler::InsertStatus::Ok:
|
case reasampler::InsertStatus::Ok:
|
||||||
msg = "ReaSampler: inserted onto " + std::to_string(res.inserted) +
|
break; // success — no console chatter
|
||||||
(res.inserted == 1 ? " track" : " tracks") +
|
|
||||||
(conform ? " (conformed to tempo)" : " (native length)") + "\n";
|
|
||||||
break;
|
|
||||||
case reasampler::InsertStatus::NoSelection:
|
case reasampler::InsertStatus::NoSelection:
|
||||||
// "select a track first" is printed by runInsert when no track is
|
// "select a track first" is printed by runInsert when no track is
|
||||||
// selected; this branch covers the no-panel-selection case.
|
// 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;
|
break;
|
||||||
case reasampler::InsertStatus::NoProject:
|
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;
|
break;
|
||||||
case reasampler::InsertStatus::NothingResolved:
|
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;
|
break;
|
||||||
}
|
}
|
||||||
ShowConsoleMsg(msg.c_str());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
|
// 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).
|
// requests a deferred reload that the next timer tick drains (see the hook comment).
|
||||||
rec->Register("projectconfig", (void*)&g_projectConfig);
|
rec->Register("projectconfig", (void*)&g_projectConfig);
|
||||||
|
|
||||||
ShowConsoleMsg("ReaSampler loaded.\n");
|
|
||||||
|
|
||||||
return 1; // success — REAPER keeps us loaded
|
return 1; // success — REAPER keeps us loaded
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user