diff --git a/CONTEXT.md b/CONTEXT.md index a3479da..5efd279 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1043,3 +1043,288 @@ No new REAPER *audio* API. New surfaces to verify before use: **Build-time residual (not a fork):** the owned-file manifest's exact persistence shape (sibling `"reasampler"` ext-state key vs. folded into the `banks` blob). + +--- + +# MIDI-playback instrument — additive phase spec (Phase S — Sampler) + +> **New pillar, its own lettered phase, and — uniquely — its own build artifact.** +> Every prior phase (M / D / B / R / V) ships inside the one `reaper_reasampler` +> extension binary. Phase S does **not**: a REAPER extension *cannot* be a +> MIDI-triggered instrument (it is not a node in any track's signal chain), so the +> instrument is a **second, separate binary** — a native **VST3** plugin the user +> instantiates on an instrument track — that reads ReaSampler's banks and plays them +> MIDI-triggered. Namespaced **`S` (Sampler)** rather than "D" (which would collide +> with Design View). The `M`/`D`/`B`/`R`/`V` extension pillars are untouched. Product +> framing, the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, and the +> settled decision record: `docs/product/midi-playback.md`. Same standing discipline: +> **verify every Steinberg VST3 SDK and REAPER/SWELL API name/signature against the +> vendored headers before use.** + +## What it is + +A native **VST3 sampler instrument** — a separate product/artifact from the +extension — that maps ReaSampler's captured bank samples across a MIDI keyboard and +plays them back with a real voice engine (polyphony, velocity, envelopes). The +extension stays the sole owner of **capture + organization**; the instrument is the +**playback surface**. The two are *tightly integrated but distinct acts*: the +extension captures and organizes; the instrument plays. Neither crosses into the +other's role — the instrument never captures, the extension never becomes an +instrument. + +**Why a VST3 and not the extension (load-bearing, settled — see D1/D5/D6 below).** An +instrument track's "read live MIDI, emit audio per-voice, in REAPER's +routing/record/render path" contract belongs to VST/VST3/CLAP/JSFX plugins, hosted +through an entirely different mechanism than the extension API. The extension SDK's +audio-adjacent surfaces (`Audio_RegHardwareHook`, `kbd_OnMidiEvent`, `PlayPreview`, +`pcmsrc` subclassing) are each the wrong tool for a live-MIDI instrument — the full +reasoning is in `docs/product/midi-playback.md` §1. The instrument is therefore a +standard VST3 plugin; this is not an engineering-around-able limitation, it is what +the plugin *format* is. + +## The three locked decisions this spec assumes + +Settled by Daniel (2026-07-26); everything below assumes them. Reasoning preserved in +`docs/product/midi-playback.md` §4. + +- **D1 — native VST3.** Not JSFX. Full sampler sophistication, clean integration, and + access to the REAPER VST-host bridge. JSFX retired (cross-platform-for-free is + worthless under D5, and JSFX gets no bridge). +- **D5 — Windows-only, VST3-only, REAPER-only.** No cross-platform DSP/build/signing + matrix, no multi-format wrapper, no standalone-in-other-hosts concern. REAPER-coupling + via the bridge is intended. This is the single biggest simplifier — it deletes most of + what makes VST3 painful. +- **D6 — two products, tightly integrated.** A separate artifact, but **not** a + divorced file-only companion: via the VST-host bridge it reads the live + `"reasampler"` project ext-state and is project-aware. + +## The VST-host bridge (the integration mechanism, stated once) + +A VST3 *hosted inside REAPER* can call back into REAPER's own API by resolving +function pointers by name over the host callback +(`hostcb(&effect, 0xdeadbeef, 0xdeadf00d, 0, "FunctionName", 0.0)` — the same +string-keyed API table the extension uses; verified in `video_processor.h` and the +`reaper_plugin_functions.h` `GetProjExtState`/`SetProjExtState`/`EnumProjExtState` +entries). The plugin can also fetch its **host context** — the track/take/project it +was instantiated in (opcode `0xdeadf00e`). **Consequence:** the instrument reads the +*same* live `"reasampler"` ext-state that `persist` writes, follows the active +project, and needs no "point me at the bank folder" wiring — it asks REAPER which +project it is in. This capability exists *because* the plugin is hosted in REAPER; it +is the technical affordance D6 leaned on. **Must-verify before build:** confirm the +bridge opcodes and the by-name resolution against the vendored +`vendor/reaper-sdk/sdk/` headers (`reaper_plugin.h`, `video_processor.h`, +`reaper_plugin_functions.h`) — the framing doc's opcode citations are verified from +those headers but the exact call marshalling should be confirmed at the spike. + +## The two seams (audio via files, mapping via live state) + +- **File seam (audio, permanent).** The sample **audio** is the on-disk 32-bit-float + WAVs — project-relative, travelling with the `.rpp` via the M4 machinery. The + instrument resolves those paths **the same way `persist` does** (a shared convention, + not a re-implementation). There is no live PCM stream across the bridge, by design. +- **Live-state seam (the mapping, via the bridge).** For everything that is *not* raw + audio — the bank index, the mapping, which project is active — the instrument reads + the live `"reasampler"` ext-state via the bridge. It sees what `persist` last wrote + and follows the active project. + +## The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26) + +**The split model (option iii) is the settled answer.** It mirrors the +capture/placement separation: + +- **Bank intrinsics (facts about the captured file) live on `Sample`.** **Root note** + (the MIDI note the sample was recorded at, so it can be repitched across the + keyboard — distinct from the existing optional *musical key* field) and **loop + points** (sustain-loop start/end for held notes; sample-accurate, zero-crossing-aware) + are *facts about the file*, analogous to sample rate, length, and peaks. They are added + to `Sample` as an **additive field extension** — the same shape as how `provenance` + was added in M1: new optional fields with JSON round-trip, populated at/after capture, + defaulting cleanly for pre-existing samples. This keeps the bank a clean, + tool-agnostic library (WAVs + facts, readable by anything). +- **The performance map (a creative arrangement) lives in the instrument.** **Key + zones** (low/high note per sample), **velocity layers**, **round-robin groups**, + **amplitude envelopes** (ADSR), and per-sample tuning/gain trim are a *performance + choice*, not a fact about a file — they belong to the instrument, not the bank. Under + the live-state seam the instrument may still *read* performance-map data out of shared + `"reasampler"` state, so "who owns which field" is a data-ownership decision, not a + transport one. + +**Why the intrinsic fields are added early (D-B, the backfill-cliff reasoning).** The +`Sample` field addition is scheduled as an **early Phase S point** even though the +instrument that consumes them lands later. Rationale (the *design-the-seam-even-if-you- +defer-the-feature* instinct, same as Fork R-D's owned-file manifest): if the fields are +added only when the instrument needs them, every sample captured before then lacks a +root note / loop points and must be backfilled by hand. Adding the fields now — so +capture starts populating them (or at least defaulting them cleanly) — costs almost +nothing and closes the cliff. The field addition touches the **extension** codebase +(`bank_model` + capture + persist), is independently shippable, and lands before the +instrument build leans on it. + +## Scope tiers (D-C, settled 2026-07-26 — Tier 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. diff --git a/PLAN.md b/PLAN.md index 12a08ab..88d1adf 100644 --- a/PLAN.md +++ b/PLAN.md @@ -435,3 +435,248 @@ untouched. Both docs of record: `docs/product/removal-and-prune.md` §Fork R-C/R-D/R-E and CONTEXT.md §Prune (Settled forks). + +--- + +# Phase V — Versioning & release (release-milestone pillar, own lettered namespace) + +> **New pillar, own lettered namespace.** Version scheme + beta side-channel — the +> release-deployment path M11 forward-implies but that had no phase or points. +> Namespaced **`V` (Versioning)** alongside `M`/`D`/`B`/`R` because it is a distinct +> concern (build identity + channel isolation) that touches CMake, `main.cpp`'s +> forever-stable command-id contract, and the `"reasampler"` ext-state — not a +> capture step. Product framing + the full option analysis: `docs/product/versioning- +> and-release.md`. **Forks 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). diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index ed47925..d9d5f0d 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -1,14 +1,72 @@ # MIDI playback — opportunity & design-space framing Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured -banks. This is a **discussion-shaping doc, not a build plan** — no phase, no PLAN.md -points, no settled forks yet. It exists so Daniel and the-boss can react to an honest -map of the option space before anything is scoped. +banks. This began as a discussion-shaping doc; with all forks now settled it has become +the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN.md +§Phase S** and its authoritative spec in **CONTEXT.md §Phase S** — this doc holds the +*why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled +decision record). -Status: framed by product-designer (2026-07-26). Grounded in the vendored REAPER SDK -headers (`vendor/reaper-sdk/sdk/reaper_plugin.h`, `reaper_plugin_functions.h`) — the -plugin-format claims below are checked against those headers, not asserted. The -genuine forks are flagged as **Daniel's to decide**; nothing here pre-decides them. +Status: framed by product-designer (2026-07-26), **revised 2026-07-26 (r4)**. The +"no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the +instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D" +collides with the existing Design View phase). **PLAN.md §Phase S is now the +authoritative roadmap; CONTEXT.md §Phase S is the authoritative spec.** This doc is the +framing/decision record they point back to. Prior revisions (a) established that a REAPER +*extension* cannot be a MIDI instrument, (b) corrected a material omission — REAPER's +**VST-host bridge**, which lets a VST3 plugin *hosted inside REAPER* call back into +REAPER's own API by resolving function pointers by name over the host callback — (c) [r3] +folded in **Daniel's locked decisions** (D1, D5, D6) and added the honest **bare-VST3 +assessment** (§1a). This revision [r4] records **Daniel's calls on the four residual +forks D-A..D-D** (all settled 2026-07-26) and points to the now-live phase docs. +Grounded in +the vendored REAPER SDK headers (`vendor/reaper-sdk/sdk/reaper_plugin.h`, +`reaper_plugin_functions.h`, `video_processor.h`, `reaper_plugin_fx_embed.h`), REAPER's +published VST-extensions SDK page (`reaper.fm/sdk/vst/vst_ext.php`), and the Steinberg +VST3 SDK documentation (portal + class reference — cited inline in §1a). Where a claim is +**experienced estimate** rather than a **verified-from-source** fact, it is flagged as +such in §1a. The genuine remaining forks are flagged as **Daniel's to decide**; nothing +here pre-decides them. + +> ## Locked decisions (SETTLED — do not re-present as open) +> +> Daniel has nailed these down. Everything downstream assumes them. +> +> - **D6 → DECIDED: two products, but integrated.** The instrument is a *separate* +> product/artifact from the ReaSampler extension, but *tightly integrated* via the +> VST-host bridge — it reads live `"reasampler"` project ext-state and is project-aware. +> **Not** a divorced, file-only companion. (The old §4 D6 reasoning is preserved below, +> marked decided.) +> - **D1 → DECIDED: native VST3.** JSFX is off the table. The instrument is a native VST3 +> plugin. (The old JSFX option and its whole-doc entanglement are retired below, marked +> decided; the JSFX reasoning is kept as the record of *why* it was considered and set +> aside.) +> - **D5 → DECIDED: no cross-platform, no multiformat. REAPER-specific, Windows-only.** +> The existing extension is Windows-only; Daniel does not work on other platforms. So: +> **no mac/Linux DSP/build/signing matrix, no CLAP-for-portability argument, no +> "runs standalone in other hosts" concern.** REAPER-coupling via the bridge is fine and +> intended. This collapses several costs the prior draft carried (§1a, §4). +> +> **What these lock-downs do to the shape:** the doc is no longer weighing "extension vs. +> plugin," "JSFX vs. native," or "portable vs. coupled." It is weighing **how to build one +> native, Windows-only, REAPER-coupled VST3 instrument** — and the single biggest live +> question inside that is now **bare Steinberg VST3 SDK vs. JUCE** (§1a, D-A below). + +> **The bridge, stated once, up front (the correction).** A hosted VST is not limited to +> scraping bank WAVs + JSON off disk. REAPER hands the plugin its host callback; calling +> `hostcb(&effect, 0xdeadbeef, 0xdeadf00d, 0, "FunctionName", 0.0)` resolves *any* REAPER +> API function by name — the same string-keyed API surface the extension uses (verified: +> `video_processor.h` line 44 imports `video_CreateVideoProcessor` exactly this way; +> `GetProjExtState`/`SetProjExtState`/`EnumProjExtState` are ordinary entries in that same +> string-keyed table, `reaper_plugin_functions.h` lines 8376/8796/9969). The plugin can +> also fetch its **host context** — the track/take/project it's instantiated in (sibling +> opcode `0xdeadf00e`, `video_processor.h` line 40; CLAP has `clap_get_reaper_context`, +> `reaper_plugin.h` line 142). **Consequence:** a native ReaSampler instrument can read +> the *same* `"reasampler"` project ext-state that `persist` writes — live, project-aware, +> following the active project — not a file it re-parses off disk. This is a +> REAPER-VST-specific capability (it exists because the plugin is hosted *in REAPER*), and +> it is the thumb on the scale the prior draft failed to weigh. The cost of leaning on it +> is stated honestly in D1/D2: it makes the native plugin **REAPER-coupled.** --- @@ -84,56 +142,239 @@ the playback engine must be a **standard instrument plugin** (VST3 / CLAP / JSFX *not* the extension. The extension SDK is the wrong format for that job, and this is the single most important thing for Daniel to internalize before scoping anything. -### The three honest options +### The shape, now that D1/D6 are locked -**Option A — a real VSTi/instrument plugin (JUCE or bare VST3/CLAP SDK) that reads -ReaSampler's banks.** A separate build artifact: a VST3 (and/or CLAP) sampler plugin -that the user instantiates on an instrument track. It reads the bank JSON + WAV layout -ReaSampler writes, maps samples across the keyboard, and plays them MIDI-triggered with -a real voice engine. This is the "sophisticated sampler" answer. -- *Gives you:* everything an instrument is — polyphony, velocity, envelopes, the works, - fully integrated into REAPER's routing/render/record path like any VSTi. -- *Costs:* a **second codebase in a second plugin format**, almost certainly a new - dependency (JUCE is the pragmatic choice; bare VST3 SDK is more code, CLAP is leaner - but younger). It is a real DSP/voice-engine build, not a weekend. Cross-platform DSP, - its own build/release/signing story, its own UI toolkit. This is a **product-sized - commitment**, not a feature. +The old three-way option set (native VSTi / JSFX / hybrid) has collapsed to a single +resolved shape: -**Option B — a JSFX sampler.** JSFX is REAPER's built-in scriptable plugin format -(text `.jsfx` files, JIT-compiled by REAPER, hostable as an instrument). A JSFX -instrument *can* receive live MIDI and emit audio on a track. It can load samples -(`Xen`-style file reads / the JSFX file/serialize API) and play them back. -- *Gives you:* a real in-track instrument with **zero new binary, zero new SDK, zero - JUCE** — ships as a text file alongside the extension, cross-platform for free - (REAPER runs the JIT everywhere it runs). -- *Costs:* JSFX is a constrained DSP scripting language, not C++. A polyphonic - multisample engine with round-robin/velocity-layers/streaming is *doable* but you're - writing DSP in JSFX's idiom, and large-sample streaming / disk I/O is more awkward - than in a native plugin. Reading ReaSampler's JSON index from JSFX is friction (JSFX - is not a general-purpose file parser). Best fit for a **minimal-to-mid** sampler, a - real ceiling for a **sophisticated** one. +**A separate native VST3 instrument that consumes ReaSampler's banks, tightly integrated +via the bridge.** The extension stays the sole owner of **capture + organization** (its +whole existing identity and the load-bearing "capture and placement are separate acts" +principle). The instrument is a *separate build artifact* — a native VST3 sampler the user +instantiates on an instrument track — that maps samples across the keyboard and plays them +MIDI-triggered with a real voice engine. It reaches ReaSampler's project state directly via +the VST-host bridge: it reads the live `"reasampler"` ext-state `persist` writes, knows its +own host project, and follows the active project. This is the "two products, but +integrated" shape D6 locked in. The extension never becomes an instrument; the instrument +never captures. -**Option C — the hybrid (recommended framing to explore first).** Keep the extension -as the sole owner of **capture + organization** (which is its whole existing identity -and the load-bearing "capture and placement are separate acts" principle). Add a -**separate instrument** (Option A *or* B) that **consumes the banks** as a -shared-artifact contract. The extension never becomes an instrument; the instrument -never captures. Each does what its format is good at. This is the honest shape of the -whole thing — the two options above are really "which instrument technology" *within* -the hybrid, because the extension is staying regardless. +*What this gives:* everything an instrument is — polyphony, velocity, envelopes, the works, +fully in REAPER's routing/render/record path like any VSTi — **plus** live, project-aware +integration with the extension's state, not a divorced file-reader. -The real fork, then, is **not** "extension vs. plugin" (the extension stays either -way) — it is **"which instrument format consumes the banks: JSFX or native VSTi/CLAP,"** -and **"how much sampler do we actually want."** Those are §3 and §4. +*What it costs:* a **second codebase**, the Steinberg VST3 SDK (or JUCE) as a dependency, +and a real DSP/voice-engine build — a product-sized commitment, not a feature. The +Windows-only + VST3-only + REAPER-only lock-downs (D5) *remove* the costs the prior draft +carried around cross-platform DSP, code-signing, and multi-format wrappers — a material +simplification. The remaining cost question is almost entirely **"how much scaffolding does +the VST3 surface demand, and do we take JUCE to get it"** — answered honestly in §1a. + +> **JSFX — retired (D1 DECIDED: native VST3).** Prior drafts weighed a JSFX sampler +> (REAPER's built-in scriptable format: zero new binary, zero SDK, cross-platform free) as +> the cheap-prototype path. It is off the table. The reasons it lost, for the record: (1) +> JSFX is a constrained DSP scripting language — a polyphonic multisample engine with +> round-robin/velocity-layers/streaming is doable but fights the idiom, and large-sample +> disk streaming is awkward; (2) JSFX gets **no VST-host bridge**, so it could never read +> `persist`'s live `"reasampler"` ext-state — it would be a permanently file-coupled +> consumer needing a sidecar seam, which is exactly the *loose* companion D6 rejected; (3) +> its "pure core" would be JSFX code, outside the CTest harness (see D3). With D5 locking +> Windows-only, JSFX's one real edge — cross-platform-for-free — is worth nothing here. +> Native VST3 wins cleanly given the locks. + +--- + +## 1a. How crazy is bare VST3 without JUCE? (the honest assessment) + +Daniel's question, directly: *how crazy is it to comply with the VST3 surface without +something like JUCE?* Short answer: **not crazy — the audio-processing side is a +few-hundred-lines-of-ceremony-you-write-once problem, not a tar pit. The one genuine +question is the editor UI, and ReaSampler is unusually well-positioned to answer it +without JUCE.** The detail, honestly, with estimate-vs-verified flagged. + +### What the raw Steinberg VST3 SDK actually demands + +A working VST3 instrument must present these interfaces (all VST3 interfaces descend from +`FUnknown`, a COM-like base with `queryInterface` / `addRef` / `release` — **verified**, +Steinberg VST3 SDK class reference): + +- **`IComponent`** — the plugin's identity and bus/state setup: `initialize`, + `setActive`, `getBusCount` / `getBusInfo`, `activateBus`, `setState` / `getState`. +- **`IAudioProcessor`** — the DSP contract: `setBusArrangements`, `setupProcessing` + (`ProcessSetup`: sample rate, block size, symbolic sample size), `setProcessing`, and + the hot path **`process(ProcessData&)`** — where you read MIDI events off the event + input bus and write audio to the output bus. For an *instrument* you declare an **event + input bus** (MIDI in) and an **audio output bus**, no audio input. (**Verified**: + `IAudioProcessor` reference; the instrument bus topology is standard.) +- **`IEditController`** — parameter model + editor: `getParameterCount` / + `getParameterInfo`, `getState` / `setState`, `setComponentState`, normalized↔plain + parameter conversion, and `createView("editor")` returning an `IPlugView` if you have a + GUI. +- **`IPluginFactory`** (via the module's exported **`GetPluginFactory`**) — enumerates the + classes the module offers (the processor and, in the two-component model, the + controller), keyed by class UIDs. On Windows the module also exports **`InitDll` / + `ExitDll`** (bundle entry points differ per-OS, but D5 makes Windows the only target, so + it's just these two plus `GetPluginFactory`). (**Experienced estimate** on the exact + Windows export names — the SDK's `dllmain.cpp` / `public.sdk` main glue provides these; + I have not re-read the header this session, so treat the exact symbol names as + to-verify-against-`public.sdk/source/main/` before build, not as a load-bearing claim.) + +**The COM plumbing is real but bounded.** `queryInterface`/`addRef`/`release` plus the +class-factory macros (`BEGIN_FACTORY` / `DEF_CLASS2` / `END_FACTORY`, and the +`DECLARE_FUNKNOWN_METHODS` / `IMPLEMENT_REFCOUNT` helper macros) are **provided by the +SDK's `pluginterfaces` and `public.sdk` layers** — you do not hand-write refcounting; you +invoke macros. (**Experienced estimate** on the exact macro names — these are the +long-standing VST3 SDK factory macros; verify spelling against the vendored SDK headers at +build time.) This is the "ceremony you write once" — it is copy-adapt-from-the-example +work, not design work. + +**And the SDK hands you base classes that absorb most of it.** The critical fact for +Daniel's question: you do **not** implement those four interfaces from scratch. The SDK's +`public.sdk` layer provides: + +- **`AudioEffect`** (with `Component` / `AudioEffect` base) — implements `IComponent` + + `IAudioProcessor` boilerplate; you override `initialize` (declare busses), `setupProcessing`, + `setActive`, `setState`/`getState`, and `process`. +- **`EditControllerEx1`** — implements `IEditController` boilerplate; you override + parameter registration and state. +- **`SingleComponentEffect`** — **combines processor and controller into one class** + (descends from `EditControllerEx1` and the component hierarchy). You override + `initialize` (call `addAudioOutput` + `addEventInput`), `setupProcessing`, and + `process`. (**Verified**: Steinberg SDK `SingleComponentEffect` class reference — "default + implementation for a non-distributable Plug-in that combines processor and edit + controller in one component." The SDK cautions to prefer the two-component split for + distributable plugins, but for a **REAPER-only, non-distributable** instrument (D5/D6), + `SingleComponentEffect` is exactly the sanctioned shortcut and cuts the interface surface + roughly in half.) + +**Honest quantification of the audio side:** with `SingleComponentEffect` + the factory +macros, a *silent-but-loading* VST3 instrument skeleton — factory, class registration, +module entry, bus setup, empty `process` — is on the order of **~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 -The obvious shared artifact is what ReaSampler already produces: **the bank folder -(project-relative WAVs) + the bank/index JSON.** The question is whether that JSON is -*sufficient* for a playback engine, or whether playback needs mapping data the index -doesn't carry today. +There are two seams, and with native VST3 locked (D1) the instrument gets **both**: + +- **File seam (audio, always).** The sample **audio** is WAVs on disk — there's no getting + a live PCM stream across the bridge, nor would you want to. WAVs are project-relative and + travel with the `.rpp` (M4 machinery). This is the permanent seam for sample audio. +- **Live-state seam (the mapping / index, via the bridge).** For **everything that isn't + the raw audio** — the index, the mapping data, which project's bank is active — a native + VST instance reads the `"reasampler"` project ext-state directly via the bridge + (`GetProjExtState` / `EnumProjExtState`, resolved by name over `hostcb`), and knows its + own host project via the context callback. It sees what `persist` last wrote, follows the + active project, and needs no "point me at the right bank folder" wiring — it *asks REAPER* + which project it's in. This is the tight integration D6 locked in. + +So: **audio comes across as files; the mapping comes across as live shared state.** The +design question below — "is the ext-state/index sufficient, or does playback need mapping +it doesn't carry" — is unchanged. What the bridge settles is *where that mapping lives*: +between extension and instrument as **live shared `"reasampler"` state**, not a file one +writes and the other re-parses. **What the current index carries** (from `bank_model`'s `Sample`, per CONTEXT.md §Data model): id, display name, relative path, source range, channel count, sample rate, @@ -179,18 +420,16 @@ opinionated part. It also means **the bank index grows only by file-intrinsic fi seam ReaSampler already knows how to add (mirror of how `provenance` was added as a field in M1 and populated later). -**A note on format:** if the instrument is native (Option A), it reads the bank JSON -directly — trivial. If it's JSFX (Option B), reading arbitrary JSON is friction; the -seam might need a **simpler sidecar** (a flat `.txt`/key-value map ReaSampler writes -next to the bank, JSFX-parseable) rather than making JSFX parse the index JSON. That's -a concrete cost of the JSFX path and a reason the seam design and the format choice are -coupled. - -**Portability caveat:** the bank is project-relative and travels with the `.rpp` -(M4 machinery). Any instrument consuming it must resolve paths the same way — so the -instrument needs to know the *current* project bank folder. A native plugin instance -on a track can be told its folder (saved in plugin state); a JSFX likewise. This is a -solvable wiring detail but a real one — flag it, don't hand-wave it. +**Wiring note (native, the locked path).** The native instance sidesteps the "point me at +the right bank folder" problem entirely: via the context callback it asks REAPER which +project it's in, then reads that project's bank location straight from `"reasampler"` +ext-state — no user wiring, no saved folder path in the instrument's own state. This is the +cleanest possible integration and it's the one D6 chose. The one thing to keep deliberate: +the WAV **audio** still resolves via the project-relative path machinery (M4), so the +instrument must resolve those paths the same way `persist` does — a shared convention, not a +hand-wave. Since D5 locks REAPER-only/Windows-only, the old "but it won't run in other +hosts" concern is **moot by design** — the bridge dependency is intended, not a narrowing to +regret. --- @@ -203,8 +442,9 @@ tier above is optional depth, not a prerequisite for the one below. **Tier 0 — "the bank plays."** One sample mapped chromatically across the keyboard from a root note; monophonic-or-basic-polyphony; a simple amp envelope; velocity → volume. Point one bank sample at a MIDI track and play it repitched. *This is the smallest -thing that delivers the promise* and is the honest MVP. On the JSFX path this is a -genuinely small build; on the native path it's the skeleton of the plugin. +thing that delivers the promise* and is the honest MVP. On the native path this is the +skeleton of the VST3 plugin — the `SingleComponentEffect` shell (§1a) plus a single-voice +core, with the editor deferrable behind a parameters-only default view. **Tier 1 — "a keymap."** Multiple samples zoned across the keyboard (key ranges), each with its own root note. Now a captured *kit* (several one-shots) or a *multisampled @@ -239,74 +479,181 @@ instrument keeps its own copy of the samples"). --- -## 4. Risks & open decisions — Daniel's to call +## 4. Decisions — settled and residual -None of these are pre-decided here. Each is a genuine fork. +### Settled (DECIDED — reasoning preserved, not re-opened) -**D1 — Instrument format: JSFX vs. native VSTi (VST3/CLAP, likely via JUCE).** The -central fork. JSFX = no new binary, no JUCE, cross-platform free, real ceiling on -sophistication and awkward bank-JSON reading. Native = full sophistication and clean -JSON integration, at the cost of a whole second codebase in a second format with its -own build/release/dependency/signing story. *My lean, for discussion only:* if the goal -is Tier 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. +These are locked. The reasoning is kept as the record of *why*, so the choices don't get +silently re-litigated. -**D2 — Whether to take a JUCE (or any external plugin-SDK) dependency at all.** The -project today is a clean C++ extension with two vendored submodules and a proud -pure-core/shell discipline. A native instrument means a *third* major dependency and a -second build target of a fundamentally different kind. That's a real architectural -weight. JSFX sidesteps it entirely. Flagging it as its own decision because "should we -depend on JUCE" is a bigger standing commitment than "should we build a sampler." +**D1 — Instrument format → DECIDED: native VST3.** JSFX is off the table (retirement +reasoning in §1, in the JSFX box). Native VST3 gives full sampler sophistication, clean +integration, and the VST-host bridge (live `"reasampler"` ext-state, project-awareness, +embedded-UI affordance). The cost — a second codebase in a plugin format — is accepted. +The old "JSFX-first cheap prototype" lean is withdrawn: with D5 locking Windows-only, +JSFX's one advantage (cross-platform-for-free) is worthless here, and its inability to +reach the bridge makes it the wrong tool for the *integrated* product D6 chose. -**D3 — Does the pure-core discipline survive the format boundary?** ReaSampler's -identity is *pure REAPER-free testable core + thin shells*. A sampler's **voice engine, -envelope math, key/velocity mapping, and repitch logic are exactly the kind of pure, -testable core** this project excels at — they could live in a REAPER-free, DAW-free, -unit-tested module (mirror of `bank_model`/`peaks`/`view_mode_model`) with the plugin -format (JSFX or VST3 wrapper) as the thin shell around it. **This is the most -ReaSampler-native way to build it** and I'd argue strongly for it regardless of D1: the -sampler DSP core is pure and tested; the format is a shell. The open question is whether -that discipline can hold across a *different plugin format* — with native it's clean C++ -so yes; with JSFX the "pure core" would be JSFX code, harder to unit-test in the CTest -harness. That tension is real and feeds back into D1. +**D5 — Cross-platform / multiformat → DECIDED: none. Windows-only, VST3-only, +REAPER-only.** The extension is Windows-only; Daniel does not work on other platforms. +This *removes* costs prior drafts carried: no mac/Linux DSP/build/signing matrix, no +multi-format wrapper, no "runs standalone in other hosts" concern, no CLAP-for-portability +argument. REAPER-coupling via the bridge is intended, not a narrowing to regret. This is +the single biggest simplifier — it is *why* bare VST3 is reasonable (§1a): most of what +makes VST3 painful (multi-format, cross-platform) is deleted. -**D4 — Where the mapping lives (seam fork i/ii/iii from §2).** Bank-owned map, -instrument-owned map, or split (file-intrinsics in the bank, performance-map in the -instrument). *My lean:* **(iii) split** — it's the one that honors ReaSampler's -capture/placement instinct and keeps the bank tool-agnostic. But it's Daniel's call -whether ReaSampler should author instrument definitions at all, or stay purely a -sample library that a *separate* mapping tool arranges. +**D6 — One product or two → DECIDED: two products, but tightly integrated.** The +instrument is a *separate artifact* from the extension (the extension stays the pure +capture/organize tool; the instrument is the playback surface — capture and placement, and +now playback, stay distinct acts). But it is **not** a divorced file-only companion: via +the bridge it reads the live `"reasampler"` project ext-state and is project-aware — "two +faces of one tool sharing one state model." The prior draft's open question ("could they +only ever share a file?") is resolved: no, and the shared-live-state integration is the +chosen shape. The retired reasoning for why the loose-companion reading lost: it only ever +looked clean because the prior draft under-weighted the bridge; once the bridge is on the +scale, the integrated reading has the stronger technical affordance, and Daniel took it. -**D5 — Cross-platform.** The extension is already cross-platform (SWELL). JSFX inherits -that for free. A native plugin re-opens the full cross-platform DSP + UI + build matrix -(Win/mac/Linux, code-signing on mac, etc.) as a *separate* artifact. A cost that lands -entirely on the native path. +**D3 — Pure-core discipline across the format boundary → CONFIRMED holds (see §1a).** Not +a fork so much as a checked assumption: the sampler's voice engine, envelope math, +key/velocity mapping, and repitch/interpolation live in a pure REAPER-free, DAW-free, +unit-tested core (mirror of `bank_model`/`peaks`/`view_mode_model`); the VST3 wrapper is +the thin shell. Confirmed clean for native, and — importantly — **invariant under +bare-vs-JUCE** (§1a): the shell choice doesn't touch the core. This is the most +ReaSampler-native way to build it and it's assumed, not debated, going forward. -**D6 — Is this even one product?** The reframe worth surfacing: ReaSampler's thesis is -"a precision *capture/organize* tool; capture and placement are separate acts." A -MIDI-playback instrument is a *different act* — playback. There's a legitimate reading -where the instrument is a **companion product** that shares the bank format, not a -feature *of* ReaSampler — the way a sample library and a sampler that reads it are -related-but-distinct products. That framing might keep ReaSampler sharp (it stays the -capture tool it is) while letting the instrument evolve on its own clock and format. -The alternative reading — it's all one integrated sampler-workstation — is also -coherent. **Which of those two ReaSampler *is* is the highest-order question here, and -it's Daniel's to answer before format/tier decisions mean much.** +### Residual forks — SETTLED (Daniel, 2026-07-26; reasoning preserved, not re-opened) + +All four residual decisions are now called. Each is marked **SETTLED** with Daniel's +choice and the reasoning kept as the record of *why* — do not re-litigate. They are +scoped into **PLAN.md §Phase S** / **CONTEXT.md §Phase S**. + +**D-A — SETTLED: bare Steinberg VST3 SDK + LICE editor (no JUCE).** *(The central fork. +§1a is the assessment that fed it. The sub-question — who draws the editor? — was the +whole fork, because §1a showed the audio-processing scaffolding is bounded either way.)* +Daniel took **Option A**: bare Steinberg SDK, `SingleComponentEffect` for the bounded +audio scaffolding, editor drawn in the **same LICE/SWELL stack `bank_panel` already +uses** — no JUCE dependency, no AGPL-or-pay posture, house-consistent UI. The +`IPlugView`↔LICE bridge (the one real unknown) is **not** a gate on the decision (the +decision is made) but remains the right *first implementation step*: it is scheduled as +**Phase S's opening spike (S1)** to convert §1a's experienced-estimates — Windows +module-export names, factory-macro spellings, exact bridge marshalling — into verified +fact before the engine build leans on them. (The stale §1a "is VSTGUI even bundled" +question is dropped as moot under Option A; VSTGUI remains a noted fallback rung only if +the LICE bridge proves gnarlier than the panel work suggests, with JUCE the last resort +behind that.) The full option analysis (A/B/C, the JUCE license posture, the LICE-bridge +caveat) is preserved below as the record. + +- *Option A — bare Steinberg SDK + LICE editor.* Take only the VST3 SDK (permissive, + royalty-free, no revenue gate). Use `SingleComponentEffect` for the ~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 - companion product sharing the bank format? Everything else sits under this. -2. **D1 — JSFX-first prototype, or straight to native?** Given a target of Tier 0–1 to - start, and the pure-core-as-shell discipline (D3) held either way. +1. **D-A → bare Steinberg VST3 SDK + LICE editor** (no JUCE). The `IPlugView`↔LICE bridge + is the opening implementation spike (**S1**), not a decision gate. +2. **D-B → split seam; root note + loop points added to `Sample` now** (**S2**, in the + extension) to close the backfill cliff. +3. **D-C → Tier 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 -concrete enough to write an actual phase spec. Until then this stays a framing doc with -no PLAN.md footprint — deliberately, so we don't scope an instrument before deciding -whether we're building one. +**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6, sequenced by +dependency order: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded +UI); **CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the +pure/shell split in the new artifact, the must-verify SDK/bridge surfaces). This doc is the +framing/decision record they point back to. The "no PLAN.md footprint" era is over. + +--- + +## Sources (for §1a's verified claims) + +- Steinberg VST3 SDK — `SingleComponentEffect` class reference (combined processor + + controller; "non-distributable" caveat): + https://steinbergmedia.github.io/vst3_doc/vstsdk/classSteinberg_1_1Vst_1_1SingleComponentEffect.html +- Steinberg VST3 SDK — `IAudioProcessor` class reference (process/setupProcessing/bus + contract): + https://steinbergmedia.github.io/vst3_doc/vstinterfaces/classSteinberg_1_1Vst_1_1IAudioProcessor.html +- VST3 Developer Portal (overview, tutorials, example plugins — AGain / Note Expression + Synth as the skeleton copy-source): https://steinbergmedia.github.io/vst3_dev_portal/ +- JUCE 8 EULA (dual AGPLv3 / commercial; tier structure): + https://juce.com/legal/juce-8-licence/ +- JUCE forum — revenue limits & JUCE 8 pricing (Starter ~$20K/yr free, Indie ~$200K/yr + limit, ~$3,500): https://forum.juce.com/t/revenue-limits-for-juce-tiers/61058 + +*Estimate-vs-verified honesty note:* the interface list, `SingleComponentEffect`'s role, +the instrument bus topology, and the JUCE license terms are **verified** from the sources +above. The **line-count band (~200–400)**, the **exact Windows module-export symbol names** +(`InitDll`/`ExitDll`/`GetPluginFactory`), the **factory-macro spellings** +(`BEGIN_FACTORY`/`DEF_CLASS2`), **VSTGUI being bundled in the vendored SDK**, and the +**`IPlugView`↔LICE bridge difficulty** are **experienced estimates** grounded in how the +SDK is shaped — each flagged inline in §1a and each cheap to convert to fact by reading the +vendored SDK headers / running a spike before any of it lands in a plan. diff --git a/docs/product/versioning-and-release.md b/docs/product/versioning-and-release.md new file mode 100644 index 0000000..847662e --- /dev/null +++ b/docs/product/versioning-and-release.md @@ -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. diff --git a/src/actions.cpp b/src/actions.cpp index 1beb0d1..86931be 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -545,7 +545,6 @@ void doBankCreate() { return; } persistBankOp("ReaSampler: create bank"); - ShowConsoleMsg(("ReaSampler: created bank \"" + name + "\".\n").c_str()); } // Rename a bank: prompt for which bank (by current display name) and the new name. @@ -571,8 +570,6 @@ void doBankRename() { return; } persistBankOp("ReaSampler: rename bank"); - ShowConsoleMsg(("ReaSampler: renamed \"" + which + "\" -> \"" + newName + "\".\n") - .c_str()); } // Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail: @@ -615,7 +612,6 @@ void doBankDelete() { return; } persistBankOp("ReaSampler: delete bank"); - ShowConsoleMsg(("ReaSampler: deleted bank \"" + which + "\".\n").c_str()); } // Evacuate a named bank: move every member back to the pool (index-only, collapse by @@ -637,7 +633,6 @@ void doBankEvacuate() { return; } persistBankOp("ReaSampler: evacuate bank"); - ShowConsoleMsg(("ReaSampler: evacuated \"" + which + "\" to the pool.\n").c_str()); } // Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), @@ -652,10 +647,6 @@ void doBankActivateNext() { if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded) if (!g_session->book().setActiveBank(target)) return; persistBankOp("ReaSampler: activate bank"); - const Bank* b = g_session->book().bank(target); - ShowConsoleMsg(("ReaSampler: active bank -> \"" + - (b ? b->displayName : target) + "\".\n") - .c_str()); } // Activate the pool directly (the common "back to the default target" jump). Bindable @@ -663,7 +654,6 @@ void doBankActivateNext() { void doBankActivatePool() { if (!g_session->book().setActiveBank(kPoolBankId)) return; persistBankOp("ReaSampler: activate bank"); - ShowConsoleMsg("ReaSampler: active bank -> \"Pool\".\n"); } // Move or copy the panel's selected samples into a named destination bank (prompted @@ -698,7 +688,10 @@ void doBankTransferSelected(bool copy) { return; } - int ok = 0, collapsed = 0, absent = 0; + // Tally per-sample transfer outcomes so the no-op guardrail below can decide whether + // the index actually mutated (R-B). The console summary m11 stripped is gone; the + // counts remain because the verb-aware undo guardrail is driven by them. + int ok = 0, collapsed = 0; for (const std::string& sampleId : selected) { const TransferResult r = copy ? g_session->book().copySample(sampleId, srcId, destId) @@ -707,8 +700,9 @@ void doBankTransferSelected(bool copy) { case TransferResult::Moved: case TransferResult::Copied: ++ok; break; case TransferResult::Collapsed: ++collapsed; break; - case TransferResult::RejectedSampleAbsent: ++absent; break; - // Unknown-bank / same-bank are pre-checked above; treat defensively as no-ops. + // RejectedSampleAbsent and unknown-bank / same-bank (pre-checked above) are + // no-ops for the guardrail; nothing mutated for those ids. + case TransferResult::RejectedSampleAbsent: case TransferResult::RejectedUnknownBank: case TransferResult::RejectedSameBank: break; } @@ -726,12 +720,6 @@ void doBankTransferSelected(bool copy) { std::string("ReaSampler: ") + verb + " sample(s)"; persistBankOp(label.c_str()); } - std::string log = std::string("ReaSampler: ") + verb + " -> \"" + destName + - "\": " + std::to_string(ok) + " " + verb + "d"; - if (collapsed) log += ", " + std::to_string(collapsed) + " collapsed on hash"; - if (absent) log += ", " + std::to_string(absent) + " no longer present"; - log += ".\n"; - ShowConsoleMsg(log.c_str()); } // Remove the panel's selected samples from the SOURCE bank (the focused region's @@ -788,25 +776,20 @@ void doBankRemoveSelected() { // Perform the removes (this-bank scope). Pass ids by value — no BankIndex& is cached // across the loop's mutations. Count real drops so the no-op guardrail can skip the - // undo point when nothing was removed (every id was already absent). - int removed = 0, absent = 0; + // undo point when nothing was removed (every id was already absent). The per-outcome + // console summary was dropped (m11 chatter policy); only the "did anything change?" + // signal the undo guardrail needs is retained. + int removed = 0; for (const std::string& sampleId : selected) { - switch (book.removeSample(sampleId, srcId, RemoveScope::ThisBank)) { - case RemoveResult::Removed: ++removed; break; - case RemoveResult::RejectedSampleAbsent: ++absent; break; - // Unknown bank cannot occur — srcId was resolved to a live bank above. - case RemoveResult::RejectedUnknownBank: break; - } + if (book.removeSample(sampleId, srcId, RemoveScope::ThisBank) == + RemoveResult::Removed) + ++removed; + // RejectedSampleAbsent / RejectedUnknownBank are no-ops for the guardrail. + // (Unknown bank cannot occur — srcId was resolved to a live bank above.) } // No-op guardrail (R-B): open an undo point only if the index actually mutated. if (removed > 0) persistBankOp("ReaSampler: remove sample(s)"); - - std::string log = "ReaSampler: removed " + std::to_string(removed) + - (removed == 1 ? " sample" : " samples"); - if (absent) log += ", " + std::to_string(absent) + " no longer present"; - log += ".\n"; - ShowConsoleMsg(log.c_str()); } } // namespace diff --git a/src/capture.cpp b/src/capture.cpp index 183baf6..85d1b42 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -52,7 +52,6 @@ #define REAPERAPI_WANT_Main_OnCommand #define REAPERAPI_WANT_Main_SaveProject #define REAPERAPI_WANT_Master_GetTempo -#define REAPERAPI_WANT_ShowConsoleMsg #include "reaper_plugin_functions.h" namespace reasampler { diff --git a/src/main.cpp b/src/main.cpp index 49ec89e..609717f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -135,7 +135,7 @@ static ReaProject* g_rtCaptureProject = nullptr; // Commit a finished realtime capture (a Done tick/abort with an Ok result): add the // Sample to the ACTIVE bank (g_session.bank() resolves to book.activeIndex() — B2), -// persist + MarkProjectDirty, log. Shared by the tick-completion path and the abort +// persist + MarkProjectDirty. Shared by the tick-completion path and the abort // paths. On a non-Ok result, logs the failure only. static void CommitRealtimeResult(const reasampler::CaptureResult& res) { @@ -144,20 +144,13 @@ static void CommitRealtimeResult(const reasampler::CaptureResult& res) ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str()); return; } - reasampler::AddResult added = g_session.bank().add(res.sample); + g_session.bank().add(res.sample); // B-cap: record the file the capture created in the owned-file manifest, at the same // point the Sample is added and before the same persist. Recorded regardless of the // index AddResult — even a hash-collapse still WROTE a file the tool owns, and the // manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index). g_session.owned().add(res.sample.relativePath); g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp) - - std::string log = "ReaSampler: " + res.message + "\n"; - log += " bank size now " + std::to_string(g_session.bank().size()) + - (added == reasampler::AddResult::Added ? " (added)\n" - : added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n" - : " (rejected)\n"); - ShowConsoleMsg(log.c_str()); } // Advance any in-flight realtime capture one tick. Cheap when none is running (a @@ -619,7 +612,7 @@ static void RunCapture(const reasampler::CaptureActionDef& def) } // Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2). - reasampler::AddResult added = g_session.bank().add(res.sample); + g_session.bank().add(res.sample); // B-cap: record the created file in the owned-file manifest, at the same point the // Sample is added and before the same persist. Recorded regardless of the index // AddResult — even a hash-collapse still WROTE a file the tool owns, and the manifest @@ -630,13 +623,6 @@ static void RunCapture(const reasampler::CaptureActionDef& def) // travels with the .rpp. saveToActiveProject also clears the retired legacy key and // calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys. g_session.saveToActiveProject(); - - std::string log = "ReaSampler: " + res.message + "\n"; - log += " bank size now " + std::to_string(g_session.bank().size()) + - (added == reasampler::AddResult::Added ? " (added)\n" - : added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n" - : " (rejected)\n"); - ShowConsoleMsg(log.c_str()); } // STARTS the REALTIME track capture and returns immediately — the record runs across @@ -708,15 +694,6 @@ static void RunCaptureRealtimeTrack() // completion across ticks (UI stays responsive). g_rtCaptureProject = EnumProjects(-1, nullptr, 0); g_rtCapture = std::move(st); - // With a tail mode the recorded window runs PAST the range end (Auto: +8 s then - // decay-trim; Manual: +the set length), so the completion note names the window, - // not just the range end. - const char* doneWhen = - (tail.mode == reasampler::TailMode::None) - ? "the bank updates when it reaches the range end." - : "the bank updates after the extra tail window (past the range end)."; - ShowConsoleMsg((std::string("ReaSampler: realtime capture started — recording in " - "the background; ") + doneWhen + "\n").c_str()); } // Cancels the in-flight realtime capture on demand (bindable action). Force-terminates @@ -758,27 +735,22 @@ static void RunInsertSelected(bool conform) reasampler::InsertResult res = reasampler::runInsert(&g_session, req); - std::string msg; switch (res.status) { case reasampler::InsertStatus::Ok: - msg = "ReaSampler: inserted onto " + std::to_string(res.inserted) + - (res.inserted == 1 ? " track" : " tracks") + - (conform ? " (conformed to tempo)" : " (native length)") + "\n"; - break; + break; // success — no console chatter case reasampler::InsertStatus::NoSelection: // "select a track first" is printed by runInsert when no track is // selected; this branch covers the no-panel-selection case. - msg = "ReaSampler insert: nothing selected in the bank panel.\n"; + ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n"); break; case reasampler::InsertStatus::NoProject: - msg = "ReaSampler insert: no saved project, so the bank has no location.\n"; + ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n"); break; case reasampler::InsertStatus::NothingResolved: - msg = "ReaSampler insert: selected sample(s) could not be resolved to a file.\n"; + ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n"); break; } - ShowConsoleMsg(msg.c_str()); } // REAPER calls this for EVERY action fired anywhere; claim only our own id, @@ -1024,7 +996,5 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // requests a deferred reload that the next timer tick drains (see the hook comment). rec->Register("projectconfig", (void*)&g_projectConfig); - ShowConsoleMsg("ReaSampler loaded.\n"); - return 1; // success — REAPER keeps us loaded }