docs: close out Ξ-W1-T1, Ξ-W1-T2 and Θ-W3-T1 into COMPLETED; map the note directory; document the Release build

This commit is contained in:
2026-07-31 07:02:54 -04:00
parent 98594df878
commit 87d7ceb066
4 changed files with 168 additions and 456 deletions
+16 -5
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use. **ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
Per-module detail — what each file owns, its invariants — lives in the twenty-one per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below. Per-module detail — what each file owns, its invariants — lives in the twenty-two per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
## Settled decisions ## Settled decisions
@@ -46,6 +46,16 @@ On a multi-config generator (Visual Studio, Xcode) the bare `ctest` command abov
reports every test as "Not Run" — add `-C Debug` (or whichever config was built) to reports every test as "Not Run" — add `-C Debug` (or whichever config was built) to
resolve the test executables. Single-config generators (Ninja, Make) need no such flag. resolve the test executables. Single-config generators (Ninja, Make) need no such flag.
On a multi-config generator, `cmake --build build` with no `--config` builds **Debug**
there is no `CMAKE_BUILD_TYPE`, no `CMAKE_CXX_FLAGS`, and no IPO/LTO setting anywhere in
the build, so nothing is optimized or inlined at that default. The performance
guardrails and structural heuristics below (header-inline hot paths, "no LTO
configured") presume an **optimizing** build. Shipping, installing, or judging
performance requires the Release config explicitly:
cmake --build build --config Release
ctest --test-dir build -C Release
Every pure module has a corresponding `<module>_tests` executable target that runs without REAPER or a DAW. Targets are declared per directory: each `src/**/CMakeLists.txt` owns its own libraries and their test targets, added via `add_subdirectory` from the root, which keeps only repo-global settings (version, channel, vendor paths). `cmake/reasampler_targets.cmake` holds the two shared declaration helpers. The two loadable-module targets are `reaper_reasampler` (the REAPER extension `.dll`/`.dylib`/`.so`) and `reasampler_vst` (the VST3 instrument; Windows-only, omitted if the `vendor/vst3sdk` slice is absent). The `sample_usage_tests` executable target runs the pure unit tests for `sample_usage` (no REAPER, no DAW). Every pure module has a corresponding `<module>_tests` executable target that runs without REAPER or a DAW. Targets are declared per directory: each `src/**/CMakeLists.txt` owns its own libraries and their test targets, added via `add_subdirectory` from the root, which keeps only repo-global settings (version, channel, vendor paths). `cmake/reasampler_targets.cmake` holds the two shared declaration helpers. The two loadable-module targets are `reaper_reasampler` (the REAPER extension `.dll`/`.dylib`/`.so`) and `reasampler_vst` (the VST3 instrument; Windows-only, omitted if the `vendor/vst3sdk` slice is absent). The `sample_usage_tests` executable target runs the pure unit tests for `sample_usage` (no REAPER, no DAW).
### Beta channel build ### Beta channel build
@@ -69,19 +79,20 @@ Add the generated file to the appropriate `APPLE` / Linux `target_sources` block
### Install / reload ### Install / reload
There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folder (Options → Show REAPER resource path) and restart REAPER. Extensions load at startup only. There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on a multi-config generator — not the default `Debug/` output) into REAPER's `UserPlugins/` folder (Options → Show REAPER resource path) and restart REAPER. Extensions load at startup only.
## Architecture: the load-bearing split ## Architecture: the load-bearing split
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-one directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth. `core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-two directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
| Directory | Scope | | Directory | Scope |
|---|---| |---|---|
| `src/app/` | REAPER extension entry point | | `src/app/` | REAPER extension entry point |
| `src/core/audio/` | pure audio-data math | | `src/core/audio/` | pure audio-data math |
| `src/core/capture/` | pure logic behind the capture pillar | | `src/core/capture/` | pure logic behind the capture pillar |
| `src/core/instrument/` | pure VST3-instrument core (engine / map / ui) | | `src/core/instrument/` | pure VST3-instrument core (engine / map / note / ui) |
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages | | `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
| `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
| `src/core/json/` | the hand-rolled JSON lexical layer | | `src/core/json/` | the hand-rolled JSON lexical layer |
| `src/core/model/` | the pure bank/sample index and its multi-bank container | | `src/core/model/` | the pure bank/sample index and its multi-bank container |
| `src/core/reclaim/` | pure prune orphan computation | | `src/core/reclaim/` | pure prune orphan computation |
@@ -104,7 +115,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
The top-level split is by the pure/shell discipline: `core/` never includes REAPER or VST3 SDK The top-level split is by the pure/shell discipline: `core/` never includes REAPER or VST3 SDK
types; `shell/` is where those host types are actually touched — the discriminator is "may this types; `shell/` is where those host types are actually touched — the discriminator is "may this
file touch a host type, REAPER *or* VST3 SDK." Subsystem directories sit beneath `core/` (see the file touch a host type, REAPER *or* VST3 SDK." Subsystem directories sit beneath `core/` (see the
table above); `core/instrument/` further subdivides into `engine/` / `map/` / `ui/`. Namespaces table above); `core/instrument/` further subdivides into `engine/` / `map/` / `note/` / `ui/`. Namespaces
mirror directories — `reasampler::<subsystem>` for `core/`, house style for `shell/`. `app/` holds mirror directories — `reasampler::<subsystem>` for `core/`, house style for `shell/`. `app/` holds
`main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch. `main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch.
+85
View File
@@ -228,3 +228,88 @@ uniform key widths, note-name tooltips, root displayed and settable.
carries a "confirm the fix survives DPI scaling" item and now genuinely inherits it. carries a "confirm the fix survives DPI scaling" item and now genuinely inherits it.
- A stale-hover latch was fixed across **all** drag kinds and both drag-termination - A stale-hover latch was fixed across **all** drag kinds and both drag-termination
paths, wider than the strip work that surfaced it. paths, wider than the strip work that surfaced it.
### Θ-W3-T1 — live-parameter-delivery
Continuous playback controls are now delivered live to sounding voices instead of being
latched at note-on. New `src/core/instrument/engine/live_params.{h,cpp}` holds a
seqlock-published `LiveValues` block owned at **processor-instance scope**, above
`LoadedInstrument`, so `live_` and `draining_` observe the same one (a drain-slot voice
tracks the knob, which is the desired behavior). `foldLive(const PlayParams&)` is the
single derivation from the value type; `PlayParams` stays a plain copyable value type.
**Daniel's two decisions, both implemented:**
- **Reload tier = Grouping B.** Continuous knobs live (filter cutoff/Q/morph/drive/mod
amount/key-track; every stage time and level on all three envelopes). Root note, loop
span, and start frame still trigger a full reload.
- **Mid-stage rule = candidate (iv), hold normalized stage position.** φ =
elapsed/duration held fixed across a duration change, then advancing at
1/newDuration — expressed over normalized position specifically so Θ-W3-T2's
per-segment curve exponent composes with it.
**Deviations from spec:**
- **Trigger's %-length and fades are NOT live** — they are baked into `SampleData` at
build, so reload is the only tier that can deliver them. Consequence: a Trigger-mode
instance gets zero live amp delivery until Θ-W3-T2 folds the fade pair into the AHD.
The five non-live exclusions (`kKeyTrack`, `kFilterVel`, `kTrigLength`,
`kTrigFadeIn`, `kTrigFadeOut`) are documented in `src/core/instrument/ui/deck_groups.h`,
now their single home.
- Open question 3 resolved as **F2 + seqlock**; open question 4 (sub-block resolution)
was not built but not foreclosed — the writer interface assumes no UI thread; open
question 5 verified — a live edit still persists, `commitLive` keeps the
`setInstrumentParams` write.
- A filter envelope only advances while its depth is non-zero (the exact-skip at
`modAmount == 0`), which is what keeps the at-rest path byte-identical.
### Ξ-W1-T1 — tracking-consolidation
Consolidates the provenance/usage territory into one system: the retired
`owned_manifest` gives way to a new `src/core/tracking/` directory holding
`origin_ledger` (the record family — `OriginRecord`/`OriginKind`, the insertion-ordered
`OriginLedger`, its JSON codec, and the `Fresh`/`Loaded`/`Unreadable`/`FutureVersion`
load classification) and `tracking_authority` (the one decision surface:
`pruneProtection` and `tiedUsageExists`). Both prune's protected set and the resample's
replace-vs-add decision are computed from one borrowed `TrackingState`, so the two
safety-critical consumers cannot drift apart. `isAbsolutePath` was hoisted out to a new
`src/core/util/relative_path.h`, shared with `bank_model`'s `Sample.relativePath`.
**Deviations from spec:**
- The deferred persisted-instance-identity fix was **not** folded in — open question 5
resolved as "restate the deferral." `docs/TODO.md` already carries the sharpened
rationale (the session-epoch candidate and its sibling-drop flaw); not duplicated here.
- `sample_usage` deliberately **stays in `core/wire`** — the consolidation is of the
*decisions*, not the codecs.
- A realtime record interrupted by a project switch strands an untracked WAV in the old
project's bank folder. Resolved as document-don't-delete (prune is the exclusive
deletion authority); `docs/TODO.md` carries the entry.
- `PruneReport` fields were renamed; a malformed ledger is now reported as a distinct
blocker with its own recovery instructions.
### Ξ-W1-T2 — note-program-model
Lands the programmed-capture-signal model as a new pure module directory,
`src/core/instrument/note/` — a fourth peer of `engine/`/`map/`/`ui/` under
`core/instrument/` — holding `musical_division` (the 1/6464/1 ladder with
dotted/triplet multipliers, the 39-entry picker order), `tempo` (validated BPM plus
every beats↔seconds↔ms conversion), and `note_program` (`Velocity`, the denominated
`OffsetAmount`, the anchored `StartOffset`/`EndOffset`, the `NoteProgram` record, and
`resolveNote`).
**Deviations / resolutions from spec:**
- Open question "negative offsets" resolved: both directions are legal and the sign is
uniform (positive is later in time); only an *inverted* window is refused, reported
via `ResolvedNote::windowCollapsed`.
- Open question "denomination seam" confirmed: note length is musical-division-only;
the ms/beats duality belongs to the offsets alone. An offset stores the denomination
it was **entered in**, deriving the other view on demand, so a beats offset follows a
tempo change and a ms offset holds still.
- Module name/location resolved as `src/core/instrument/note/` — three modules, not
one, with the layering enforced by the CMake link line.
- **Beyond spec:** every value type closes its domain at construction behind a single
normalizing door (`makeDivision`, `offsetOf`, `Tempo::fromBpm`, `Velocity::of`), with
private value constructors. Consequence: `resolveNote` needs no failure path and
`ResolvedNote` no validity flag, because every returned field is finite for every
constructible program and tempo. Junk detection is relocated to the future codec,
which sees both the bytes it read and the value construction produced. `NoteProgram`
deliberately carries no MIDI note number — render pitch is deferred to Ξ-W2 as an
additive field.
+62 -449
View File
@@ -28,21 +28,19 @@ transliterate: **Θ → `th`**, **Ξ → `xi`**. So Θ-W1-T1 dispatches into
## Decision state ## Decision state
**Two questions in this plan await a Daniel decision, and both are in Θ-W3-T1.** Everything carried forward from `TODO-1.0.md` is classified **[verify]** (answerable by
Everything carried forward from `TODO-1.0.md` is still classified **[verify]** reading code or running the DAW) or **[propose]** (a design call made at implementation
(answerable by reading code or running the DAW) or **[propose]** (a design call made at review with a proposal, not a Daniel call); that classification is preserved per
implementation review with a proposal, not a Daniel call); that classification is question, attached to the track that will answer it.
preserved per question, attached to the track that will answer it.
Θ-W3-T1 (`live-parameter-delivery`) is the exception, and it is an honest one: it is not Θ-W3-T1's two genuine **[Daniel]** questions — which no amount of code-reading could
from `TODO-1.0.md` — it arose from Θ-W2-T1's implementation review — and it carries two answer — are both ruled on and the track has landed; see `docs/COMPLETED.md` for the
genuine **[Daniel]** questions that no amount of code-reading answers. **Which edits stop full narrative. **Reload tier = Grouping B** (continuous knobs live: filter cutoff/Q/
triggering an instrument reload** is an edit-model call about the product's shape, and morph/drive/mod amount/key-track, every envelope stage time and level; root note, loop
**what a stage-time change does to a voice already inside that stage** is sound-defining. span, and start frame still trigger a full reload). **Mid-stage rule = candidate (iv),
Both are stated in that track with candidates laid out and neither pre-picked. The track hold normalized stage position** (φ = elapsed/duration held fixed across a duration
should not be dispatched at full scope until they are answered; a reduced-scope fallback change, then advancing at 1/newDuration). No other track in this plan currently carries
is named there so the track is not hard-blocked if Daniel would rather rule after seeing an unanswered **[Daniel]**-class question.
the mechanism work.
### Flagged for awareness — not blocking, but decision-grade ### Flagged for awareness — not blocking, but decision-grade
@@ -55,19 +53,6 @@ the mechanism work.
deferred relay shape; see that track. Consequence to hold: if every candidate fails deferred relay shape; see that track. Consequence to hold: if every candidate fails
verification, item 15's "one click from inside the VST" framing is what gives, not verification, item 15's "one click from inside the VST" framing is what gives, not
the read-only invariant — the fallback is a bindable extension-side action. the read-only invariant — the fallback is a bindable extension-side action.
2. **Item 17's "100% robust" and `docs/TODO.md`'s deferred persisted-instance-identity
fix sit one day apart and point opposite ways.** On 2026-07-28 Daniel accepted
shipping the safe-but-incomplete usage tracking and deferring the persisted-nonce
fix; on 2026-07-29 he required the tracking be "consolidated and made 100% robust."
My reading: those do not conflict — "robust" is a *safety* strength claim (no
destructive act follows from ambiguity), while the deferred wart is a *completeness*
one (prune stops reclaiming after a reopen; the bank folder grows, nothing is lost).
Ξ-W1-T1 carries this as a [propose] question: fold the deferred fix in, or restate
the deferral explicitly in `docs/TODO.md` terms. I am flagging it because the
consolidation is the natural moment to do it, not because it blocks.
3. **Phase Ξ Wave 1 is concurrency-safe with Phase Θ** (from Θ-W2 onward). See
"Running Θ and Ξ concurrently" below. Whether to spend a specialist that way is a
scheduling call, not a plan decision.
## Phase-wide acceptance criteria ## Phase-wide acceptance criteria
@@ -204,10 +189,12 @@ rework. T1 first means every parameter T2 introduces is authored into the delive
mechanism from the start. The cost of the ordering is honest and worth stating: T2 is the mechanism from the start. The cost of the ordering is honest and worth stating: T2 is the
wave's user-visible payload and T1 delays it by one track. wave's user-visible payload and T1 delays it by one track.
T1 must specify its mid-stage rule in terms that survive T2 — see that track's open T1 has landed and specified its mid-stage rule in terms that survive T2: **hold
question 2, whose candidates are deliberately expressed over *normalized stage position* normalized stage position** — φ = elapsed/duration held fixed across a duration change,
rather than over output level, because T2's exponent is a pure map of that position and then advancing at 1/newDuration (Daniel's pick among the candidates T1 laid out; see
must compose with the rule rather than invalidate it. `docs/COMPLETED.md`). It is expressed over *normalized stage position* rather than over
output level specifically so T2's exponent is a pure map of that position and composes
with the rule rather than invalidating it.
Beyond the ordering, the W3 collision is the phase's densest, which is why neither track Beyond the ordering, the W3 collision is the phase's densest, which is why neither track
splits further: the envelope parameter model, the per-sample envelope evaluation, the splits further: the envelope parameter model, the per-sample envelope evaluation, the
@@ -219,247 +206,30 @@ forward/inverse map pair. The serialization is the correct answer.
#### Θ-W3-T1 — `live-parameter-delivery` #### Θ-W3-T1 — `live-parameter-delivery`
**Goal.** Retire note-on latching as the delivery model for continuous playback controls: **Θ-W3-T1 has landed** — see `docs/COMPLETED.md` for the full narrative. Continuous
a knob moved while a note is sounding changes *that* note, not merely the next one. playback controls (filter cutoff/Q/morph/drive/mod amount/key-track; every stage time
Establishes the live-parameter block and its publish path, splits the editor's commit and level on all three envelopes) are now delivered live to sounding voices via a
routing so a parameter edit stops rebuilding the instrument, and makes reachable a seqlock-published `LiveValues` block owned at processor-instance scope, above
property the filter DSP was deliberately built with and that nothing currently exercises. `LoadedInstrument`, so a drain-slot voice keeps tracking the knob (the desired
behavior). `foldLive(const PlayParams&)` is the single derivation from the value type.
**Consolidates:** nothing from `TODO-1.0.md`. This track carries no item number — it Both of Daniel's ruling questions are answered and implemented: **reload tier =
arose from Θ-W2-T1's implementation review. See "Work in this plan that is not one of the Grouping B** (root note, loop span, and start frame still reload; everything else
seventeen" below the traceability table. continuous goes live), and **mid-stage rule = candidate (iv), hold normalized stage
position** (φ = elapsed/duration held fixed across a duration change, then advancing
**Origin — Daniel's ruling, recorded verbatim** (2026-07-30, on the filter's latched at 1/newDuration) — expressed over normalized position specifically so T2's
cutoff): per-segment curve exponent, below, composes with it. **Trigger's %-length and fades
are NOT live** — they are baked into `SampleData` at build, so a Trigger-mode instance
> *"hell no, I was going to bring that up for the other envelopes. We must live compute, gets zero live amp delivery until T2 folds the fade pair into the AHD. The five
> latching the parameters at note on is not acceptable. long term these will be non-live exclusions (`kKeyTrack`, `kFilterVel`, `kTrigLength`, `kTrigFadeIn`,
> automatable parameters."* `kTrigFadeOut`) are documented in `src/core/instrument/ui/deck_groups.h`, now their
single home.
The ruling rejects the **precedent, not one instance of it.** Every playback parameter is
latched into the `Voice` at note-on today — the filter (Θ-W2-T1), the amp AHDSR, and the
pitch envelope alike. It also silently defeats a property that was built on purpose:
`src/core/instrument/engine/filter/CLAUDE.md` records that `prepare()` does **not** clear
state precisely so a live parameter move glides rather than clicks. Nothing reaches it.
**The architectural finding — read this before scoping the track.** A staff-engineer
attempted the live-cutoff fix inside Θ-W2-T1 and correctly stopped. The fix is **not
local to the engine:**
- An editor knob release calls `commitAndReload()``reloadInstrument()`, which re-reads
the bridge, **re-decodes the WAV from disk**, builds a fresh `SampleData` +
`VoiceEngine`, and atomic-swaps it into `live_`.
- The displaced instrument moves to `draining_`, where already-sounding voices keep
rendering **their own frozen `SampleData`**. New note-ons route only to `live_`.
- Therefore a `Voice` that read `sample_->play` per frame instead of latching would
**still** not move a sustaining note: that note lives in a different snapshot, and that
snapshot is by design never updated again.
Two consequences bind the whole track. The live-parameter block's ownership must sit
**above** `LoadedInstrument` so `live_` and `draining_` observe the same one — a block
owned by a snapshot reproduces the defect exactly. And the set of edits that trigger a
reload has to shrink; that is the open product question below.
**Three commit tiers already exist. The work is reassigning edits across them, not
inventing a taxonomy.**
1. **Full reload**`commitAndReload()``reloadInstrument()`: bridge read, WAV
re-decode, fresh engine, snapshot swap. Today: every parameter edit.
2. **Engine rebuild**`rebuildVoiceEngine()`: rebuilds around the already-decoded
`SampleData`, no disk, same drain-slot swap. Today: voice count, voice mode, mono
trigger.
3. **Live**`masterGain_`: a lock-free atomic the audio thread reads per block and
ramps toward per sample (`gainCurrent_` / `gainRampStep_`), no rebuild, no snapshot.
Today: master gain alone.
Tier 3 is the shape this track generalizes, and it is a **house precedent already
shipped and already zipper-free** — not a pattern imported from elsewhere. Read it before
designing the block.
**Behavior — what must become live.** Every continuous user-facing playback control:
- **Filter** — cutoff, Q, morph, drive, mod amount, key-track.
- **All three envelopes** — every stage time and every stage level, on the amp AHDSR, the
pitch envelope, and the filter AHDSR alike.
**Behavior — what stays latched per note, where making it live is a bug.** These are
facts about the note event, not controls, and a design that "generalizes" them into the
live block has misread the ruling:
- **Velocity**, and everything derived from it — in particular the velocity-curve
evaluation result (`velocityGain_`, `filterVelOffset_`), which is evaluated once in
`Voice::start` on purpose.
- **The note number**, and the pitch ratio derived from it against the root note
(`baseRatio_`). Moving a root-note or key-track control must not retune a sounding
note; the legato `retune()` path is the only sanctioned mid-note pitch move.
- **The sample/capture identity and its decoded PCM.** Loading a different capture is a
new sound, not a parameter change.
**Behavior — the live block.** Requirements, stated as constraints rather than as a
chosen design:
- **Ownership outlives every `LoadedInstrument`.** One block per processor instance,
observed identically by `live_` and `draining_`. A drain-slot voice therefore keeps
tracking the knob — that is the *desired* behavior (it is the note the user is
hearing), and it should be stated so nobody later "fixes" it.
- **`PlayParams` stays a plain copyable value type.** `component_state_io`, `sample_map`,
and the editor all pass it by value; atomics cannot simply be pushed into it.
- **No lock on the audio path**, and **no torn read**: the editor writes on the UI
thread while `process()` reads on the audio thread, so observing half of one edit and
half of another mid-block is a real hazard the design must close, not a theoretical one.
- **No constructor growth, and no dependency grab-bag.** An `IServiceProvider`-shaped
parameter object threaded into `VoiceEngine`/`Voice` constructors is a smell and is
rejected. One candidate that satisfies both: a single `const LiveParams*` field on
`SampleData`, defaulting to `nullptr` so the bare core behaves exactly as today
(latched, byte-identical, existing tests untouched) and the shell sets it on the path
`SampleData` already travels. That is a [propose]-class mechanism note, not a mandate.
**Behavior — the per-frame constraint (Daniel's standing non-negotiable).** Root
`CLAUDE.md`'s performance guardrails and structural heuristic 3 both bind here:
- **No allocation and no virtual dispatch on the per-voice-per-sample path.** A per-frame
copy of a params struct is a violation; so is an added header→TU indirection.
`envelopes.h`'s evaluators and `Voice::advanceFrame` are header-inline by RT
constraint (no LTO configured) — concrete, no common base, no virtual `tick()`.
- **Observation happens at block boundaries, not per frame.** The voice already caches
its filter parameters in members (`filterCutoffNorm_`, `filterModAmount_`,
`filterKeyTrack_`, …); the minimal live implementation refreshes those cached members
at a block boundary instead of only at note-on, leaving the per-sample shape unchanged.
At 512 frames / 48 kHz that is ~93 Hz of control resolution — ample for a hand on a
knob, and the coarse floor for automation later.
- **Smoothing is required, and it is what makes the block-rate step inaudible.** A
block-rate jump in a base value is a step; `masterGain_`'s per-sample linear ramp
toward the target is the in-house answer. **The ramp must terminate exactly**, not
asymptotically: `tickFilterCutoff`'s two exact skips compare the value itself, so a
never-quite-arriving one-pole would pin the filter on the always-re-solve path forever.
Cost while a cutoff ramp is running is known and affordable — 15.5 ns/frame/voice
measured in `engine/filter/CLAUDE.md`, ~1.2% of one core at 16 voices — and it returns
to the skip path when the ramp completes.
**Acceptance criteria.**
- With a note held, sweeping filter cutoff, Q, morph, drive, mod amount, or key-track
audibly moves *that* note. Same for every stage time and stage level on all three
envelopes.
- A knob move on a live parameter performs **no** WAV re-decode and **no** snapshot
rebuild — verified against `reloadInstrument`, not by ear alone.
- A note sounding out of the **drain slot** responds to a live parameter move identically
to a `live_` voice.
- **No click, no step, no zipper** on any live parameter move, at any block size —
including a full-range cutoff sweep at maximum Q, and a sustain-level change on a note
held in Sustain.
- **The per-sample path costs nothing at rest.** With no live value changed, the render
is unchanged in call/inline shape from today; all live observation is at block
boundaries. No allocation, no lock, no virtual call is added on the audio thread.
- The audio thread never observes a partially-applied edit within one block.
- `PlayParams` is still a plain copyable value type, still passed by value by
`component_state_io`, `sample_map`, and the editor.
- **Velocity, the velocity-curve result, the note number, the pitch ratio, and the
decoded PCM are still latched at note-on.** Dragging a velocity-curve point does not
retune or re-gain a sounding note.
- **With no live block attached the core is byte-identical to today**`sampler_core`'s
existing regression tests pass unchanged, including the bare-engine baselines.
- **Migration:** a project saved before this change reopens sounding identical. Persisted
values are unchanged; `ComponentState` gains no version bump unless the chosen
representation forces one, and if it does, the bump is additive and pre-existing blobs
lift with no audible change.
- The filter DSP's glide property is finally exercised by a test: a cutoff move across a
`prepare()` without a `reset()` produces no output discontinuity.
**Open questions.**
1. **Which edits stop triggering a reload? [Daniel]** Sample/capture selection genuinely
needs a reload; filter cutoff plainly does not. The line between them is an edit-model
decision, not an implementation one. Three candidate groupings, in widening order:
- **Grouping C — continuous controls only.** Only knob-valued continuous controls go
live (filter cutoff/Q/morph/drive/mod/key-track, envelope times and levels). Every
discrete toggle — play mode, pitch engine, filter law, filter enable, channel mode,
velocity curve — keeps reloading. Narrowest blast radius, smallest verification
surface, and it fully satisfies the ruling as stated.
- **Grouping B — capture-anchored edits reload.** C, plus: the three capture-anchored
overrides (root note, loop span, start frame) also reload, because they name
positions in the decoded PCM and `loadSelection` already treats them as one family.
Everything else goes live or drops to the tier-2 rebuild.
- **Grouping A — only the sound source reloads.** Reload if and only if the identity
of the decoded audio changes: capture selection and channel mode (a decode policy).
Everything else is live or a rebuild. Widest live surface, largest verification
surface, and the one that most nearly matches "these will be automatable
parameters."
No pick is made here. **Proposed fallback if Daniel would rather rule after seeing the
mechanism run:** build the mechanism and ship Grouping C, leaving B and A reachable as
later reassignments rather than rework — the tier a given edit sits in is a routing
decision at the editor's commit site, not a property of the block. That is a scope
proposal, not a decision taken.
2. **What does a stage-time change do to a voice already inside that stage? [Daniel]**
Sound-defining, and there is no single right answer. Bounded by one non-negotiable:
**it must not click.** A discontinuity in output level on a parameter move is a
defect, and a sustain level changed while a voice is held must glide, not step. The
candidates, with the click property of each named:
- **(i) Jump / recompute from absolute elapsed.** Keep `framesInStage_`, divide by the
new duration. This is what today's code would do if params were simply swapped —
`level_ = framesInStage_ / attackFrames`. Steps the level discontinuously. Fails the
non-negotiable unaided.
- **(ii) Clamp.** As (i), but a stage whose new duration is already exceeded completes
immediately. Still steps.
- **(iii) Re-derive rate from the current level.** Hold the level, recompute the
per-frame advance so the stage completes at the new duration measured *from now*.
Continuous by construction. Costs: the stage's total time becomes elapsed +
remaining rather than the dialed value, and it needs a rate representation. This is
what a hardware EG with a rate DAC does.
- **(iv) Hold normalized stage position.** Keep phase φ = elapsed / duration fixed
across the change, then advance at 1/newDuration. Continuous in level (φ unchanged →
level unchanged) and the remaining stage takes its share of the dialed duration.
- **(v) Track absolute elapsed against the new duration.** Identical to (i); listed so
it is visibly not a distinct third option.
- **(vi) Keep (i) and smooth the envelope output.** Accept the recompute and put a
short declick ramp on the envelope's output level — the codebase already owns a
bounded-blend declick primitive (`kDeclickDecay`, `seedDeclick`) built for exactly
this class of step.
Only **(iii)** and **(iv)** are continuous without added machinery, and both compose
with T2's curve exponent because both are expressed over normalized position, which
the exponent is a pure map of. **(vi)** is the option that makes the cheap rule
acceptable, at the cost of a second smoother. Sustain is a separate sub-case under
every candidate: it is a *level*, not a timed stage, so a live sustain edit is a direct
level step and needs the ramp regardless of which rule wins.
3. **Representation: atomics in the value type, or a second live representation?
[propose]** Two forks, both with real costs:
- **F1 — atomics in the value type.** Push atomics into the live-relevant fields of
`PlayParams` / `FilterParams` / `AdsrParams`. One model, no mapping to drift.
Blast radius is wide and probably disqualifying: atomics are non-copyable, and
`PlayParams` is passed by value by `component_state_io`, `sample_map`, and the
editor — the plain-copyable-value-type constraint above is a hard bound on how far
this fork can go.
- **F2 — a second live-parameter representation.** A parallel block the audio thread
reads, published by the UI thread. Keeps the value type plain; the cost is that the
model exists twice and the mapping between them can drift. Mitigation to require if
this fork wins: derive the live block from `PlayParams` through exactly **one**
explicit fold function, so there is a single writer and a single site to keep in
step. Three publication mechanisms sit under F2, and the choice among them is the
torn-read answer: a **seqlock** (odd/even generation counter, audio thread copies
the block once per block and retries on a torn read — the standard single-writer
lock-free pattern); **per-parameter atomics** (simplest, exactly what `masterGain_`
does today, but offers no coherence *across* parameters — a set like an envelope's
A/H/D/S/R can be observed mid-edit); or a **double-buffered block published by
atomic pointer swap** (coherent, no retry, needs a reclaim rule — and the processor
already owns that muscle in `live_`/`draining_`/`graveyard_`).
4. **Does the block need sample-accurate (sub-block) resolution now? [propose]** Daniel's
ruling names automation as the long-term destination, and VST3 delivers parameter
changes on the audio thread with sample offsets inside `ProcessData` — so per-block is
the coarse floor, not the ceiling. Building sub-block splitting now is speculative;
*foreclosing* it is the failure mode. The requirement this track must carry either
way: **the block's writer interface must not assume a UI thread**, so the audio
thread's own parameter-change queue can drive it later without a redesign.
5. **Does a live edit still persist immediately? [verify]** Today `commitAndReload` also
writes the edited set into `params_`, which is what `getState` serializes. A live path
must keep making that UI-thread write so a saved project carries the edit — but that
write is no longer the audio thread's source. Confirm by reading that nothing else
depends on the reload as its persistence trigger.
#### Θ-W3-T2 — `staged-envelope-curves` #### Θ-W3-T2 — `staged-envelope-curves`
**Prerequisite: Θ-W3-T1 must land first** — see the wave preamble. Both tracks write **Prerequisite: Θ-W3-T1 has landed** — see `docs/COMPLETED.md`. Both tracks write
`envelopes.h`'s stage math, and this track's curve exponent must be authored as a map of `envelopes.h`'s stage math, and this track's curve exponent must be authored as a map of
whatever normalized stage position T1's mid-stage rule establishes. the normalized stage position T1's mid-stage rule holds fixed (φ = elapsed/duration,
candidate (iv)).
**Goal.** Grow the envelope-overlay editor from an amp-only fixture into the shared **Goal.** Grow the envelope-overlay editor from an amp-only fixture into the shared
graphical surface for every envelope, give every envelope shapeable segments, fix the graphical surface for every envelope, give every envelope shapeable segments, fix the
@@ -944,174 +714,35 @@ schedule preference; the feature is not expressible.
### Ξ-W1 — Consolidated tracking, and the programmed-note model ### Ξ-W1 — Consolidated tracking, and the programmed-note model
**Depends on:** nothing in this phase. **Concurrency-safe with Phase Θ from Θ-W2 onward**
see the note at the end of this phase.
**Two tracks, in priority order.** Disjoint: T1 is entirely extension-side record-keeping;
T2 is a new pure module with no existing call site.
#### Ξ-W1-T1 — `tracking-consolidation` #### Ξ-W1-T1 — `tracking-consolidation`
**Goal.** Make the provenance/usage territory **one system, 100% robust** — recipe **Landed** — see `docs/COMPLETED.md` for the full narrative. The provenance/usage
provenance, live-instance usage, and recapture lineage as facets of the same territory is now one system: a new `src/core/tracking/` directory holds `origin_ledger`
record-keeping, answering both safety-critical consumers from one place. (the record family — `OriginRecord`/`OriginKind`, the insertion-ordered `OriginLedger`,
its JSON codec, and the `Fresh`/`Loaded`/`Unreadable`/`FutureVersion` load
**Consolidates item 17.** classification) and `tracking_authority` (the one decision surface: `pruneProtection`
and `tiedUsageExists`), retiring `core/model/owned_manifest`. Both prune's protected set
**Surface boundary — owns:** `core/model/provenance`, `core/model/owned_manifest`, and the resample's replace-vs-add decision are computed from one borrowed
`core/wire/sample_usage`, `core/reclaim/prune_reconcile`, `shell/persist/` `TrackingState`, so the two safety-critical consumers cannot drift apart.
(`usage_scan`, `prune_fs`, `ext_state_io`'s manifest/provenance keys), `sample_usage` deliberately **stays in `core/wire`** — the consolidation is of the
`shell/actions/prune_action`. Instrument-side touch is limited to the usage-publish block decisions, not the codecs. The deferred persisted-instance-identity fix was **not**
at the tail of `reloadInstrument` in `shell/instrument/processor_reload.cpp` — **this is folded in — the deferral is restated in `docs/TODO.md`, its one home. This track
the one file shared with Phase Θ; if run concurrently, this track owns that block and landed `OriginRecord`'s birth-time `parentSampleId` chain as the lineage mechanism, but
Θ-W1-T1 does not touch it.** **Ξ-W2-T1's "naming and lineage" open question (jointly held with this track) is
unaffected and still theirs to close** — display naming and user-readable iteration
**Behavior.** lineage were not decided here.
- **One system, not three mechanisms.** Today the territory holds two separately-grown
mechanisms plus one new demand:
1. the **capture-recipe fingerprint** — a thin reproducibility record of how a capture
was made, deliberately not a restorable chain, conservatively recording nothing when
the situation is ambiguous;
2. the **instance-usage tracking** — each live instance declares the captures it holds,
so prune can never delete a capture a live instance is using, with a fail-safe stance
that unreadable usage state halts prune entirely;
3. item 15's demand for **recapture lineage** — records tying usages of a capture to it
"by the provenance/recaptureing system," deciding replace-vs-add at bake time.
These stop being separate ad-hoc mechanisms. Every consumer — the prune's protection
decision, the resample's replace-vs-add decision, and any future lineage reader — is
answered from the one system.
- **What "100% robust" observably means.** Daniel stated the strength, not the mechanics.
Three observable implications follow from the stakes, and no further specifics are
invented:
- **No silent gaps.** Every capture the system itself creates is tracked from the moment
of its creation; a recapture carries its lineage **from birth, never backfilled**.
There is no window in which a system-created file exists untracked.
- **Fail-safe on unreadable or ambiguous state.** Tracking state that cannot be read
never yields the destructive answer: the prune deletes nothing (today's settled stance
— preserved and generalized, not relaxed), and the resample never takes the replace
branch on unreadable lineage.
- **Consumers cannot disagree.** The prune's protection answer and the resample's
tied-usage answer are different questions with different universes — item 15 settles
that the replace-vs-add universe is **narrower** than the prune-protection universe —
but both are computed from the same records, so they cannot drift apart.
- **Existing guarantees are the floor.** Consolidation must not weaken anything settled:
prune still deletes only the system's own orphans and never a referenced or live-held
capture; the fail-safe abort on unreadable usage state survives; the recipe
fingerprint's record-nothing-when-ambiguous conservatism survives **for the recipe
half**. The lineage half is the one place that conservatism is foreclosed — item 15's
replace-vs-add must be computable, so a recapture's lineage record is mandatory.
- **Why this is safety-critical, stated once:** this territory gates the system's only
file-deletion authority (prune) and its only capture-replacement act (resample) — the
two places where a tracking error loses a user's audio or sound.
**Acceptance criteria.**
- **One consolidated tracking system answers both safety-critical consumers:** the prune's
protected set and the resample's replace-vs-add decision are each computed from it, per
their own settled rules; **no separate ad-hoc tracking mechanism remains in the
territory.**
- **No silent gaps:** a recapture created by item 15's bake is tracked from the instant it
exists — a bake followed immediately by a prune, or by a second bake, behaves correctly
with no window in which the recapture is untracked or its lineage absent. (Testable
ahead of item 15 by simulating a system-created file through the same path.)
- **Fail-safe throughout:** with tracking state made unreadable, the prune deletes nothing
(and reports what blocked it, per today's behavior) and the resample never takes the
replace branch; **no destructive act follows from ambiguity, anywhere in the territory.**
- **Every protection settled today holds undiminished after consolidation:** a capture
held by a live instance cannot be pruned; files the system did not create are
untouchable; unreadable usage state still halts the prune.
- **A pre-existing bank lifts into the consolidated system with no loss of protection and
no spurious lineage**, and never-recorded remains distinguishable from unreadable.
- **Item 15's other-references case is decidable:** for any capture, "does provenance-tied
usage exist" has a definite yes/no answer.
- The pure fold decisions stay pure and unit-tested without a DAW; the REAPER/filesystem
half stays in `shell/persist`.
**Open questions.** None awaits a Daniel decision — the requirement and its strength are
his; the shape is review work.
- **The consolidated shape [propose].** What "one system" concretely is — one record
family, one authority, how the three facets relate — is design work proposed at review.
- **The lineage record [propose, jointly with item 15's naming-and-lineage question].**
What constitutes a "usage tie," when it is written, whether it is ever severed, and
whether iteration lineage is user-readable from the bank.
- **The recipe-fingerprint half of a recapture [propose].** A resample's recipe is the
instrument's own settings, not a track chain; whether the fingerprint records a
resample-shaped recipe — and what its conservatism means there — is proposed at review.
(The lineage half has no record-nothing option; the recipe half may keep one.)
- **Never-recorded vs. unreadable [propose].** Pre-existing captures predate lineage
records, and the two absences demand opposite treatment: never-recorded means no tied
usage exists (replace is legitimate); unreadable means fail-safe. How the consolidated
system distinguishes them — and how pre-existing banks lift in without weakening any
protection they enjoy today — is proposed at review.
- **NEW — does this absorb the deferred persisted-instance-identity fix? [propose].**
`docs/TODO.md` carries "Persist ReaSampler 9000 instance identity to let prune reclaim
de-referenced captures after reopen," deferred 2026-07-28 as low-risk (safe, but the
bank folder grows unbounded after a reopen). "Consolidated and made 100% robust"
arrived the next day. My reading is that they do not conflict — robustness is a *safety*
claim, the wart is a *completeness* one — but the consolidation is the natural moment to
revisit it, and the constraint it must handle is unchanged (a persisted identity is
inherited by a Ctrl+D in-place duplicate; a divergent clone must still be detected and
protected fail-safe without reintroducing the sibling-drop bug). **Fold it in or
explicitly restate the deferral at review; do not leave it ambiguous.**
---
#### Ξ-W1-T2 — `note-program-model` #### Ξ-W1-T2 — `note-program-model`
**Goal.** Land the programmed-capture-signal model as a pure, tested module: musical **Landed** — see `docs/COMPLETED.md` for the full narrative. The programmed-capture-
divisions, tempo resolution, and offset anchoring — the arithmetic both the bake and the signal model is a new pure module directory, `src/core/instrument/note/` — a fourth
popup will read. peer of `engine/`/`map/`/`ui/` under `core/instrument/` — holding `musical_division`,
`tempo`, and `note_program` (`Velocity`, the denominated `OffsetAmount`, the anchored
**Consolidates item 15 (the capture-signal model; the bake is Ξ-W2-T1 and the popup UI is `StartOffset`/`EndOffset`, `NoteProgram`, `resolveNote`). **Both open questions below
Ξ-W3-T1).** are answered, for Ξ-W3-T1:** negative offsets are legal in both directions (sign
uniform, positive is later in time; only an inverted window is refused, reported via
**Surface boundary — owns:** a new pure module (`core/instrument/map/note_program` or `ResolvedNote::windowCollapsed`), and the denomination seam is confirmed — note length
similar, name [propose]) plus its tests target and the CMake row. New files only; no stays musical-division-only, and an offset stores the denomination it was entered in.
existing file edited except `CMakeLists.txt`. Disjoint from T1 by construction.
**Behavior.**
- **Note length is a musical division, not a free duration.** Chosen from divisions
spanning **1/64th through 64/1, with dotted and triplet multipliers** (Daniel's
examples: `1/8.`, `1/4t`, `1/16`, `4/1`).
- **Beats resolve against the project tempo under the cursor.** A beat-denominated value —
the note-length division always, the offsets when expressed in beats — resolves to time
against "the project tempo under the item cursor" (Daniel's phrase; read plainly: the
tempo in effect at the project's cursor position when the preview or bake runs). The
module takes the tempo as a parameter; **reading it from REAPER is the shell's job**,
not this module's.
- **Offsets anchor to note-on and note-off.** The start offset is relative to the
programmed note's **note-on**; the end offset is relative to its **note-off**.
- **Offsets are expressed in ms AND in beats** — the two denominations are two views of
one stored value, and the conversion is this module's. Note length is
musical-division-only.
- **Velocity is explicit** — a plain 1127 value carried in the same record. Material
because the velocity transfer curves modulate amp (and, post-Θ, pitch and filter) at
that velocity.
- **The record is one struct**, round-trippable, that the bake renders from and the popup
edits. One source of truth: **preview and bake cannot diverge** because they read the
same record through the same resolver.
**Acceptance criteria.**
- The division set spans 1/64 through 64/1 inclusive, with dotted and triplet variants,
and each resolves to the correct duration at a given tempo — asserted at the extremes
and at Daniel's four named examples.
- The same division yields a correspondingly different duration when the supplied tempo
differs (tests assert the proportionality, not a hardcoded rate).
- Offsets round-trip losslessly between ms and beats at a given tempo.
- Anchoring is explicit in the type: a start offset is note-on-relative and an end offset
is note-off-relative, and the resolved window is computed from both plus the note length.
- **No hardcoded sample rate and no hardcoded tempo** anywhere in the module (the standing
rate-free-seconds ruling).
- Builds and tests without REAPER, VST3, or a DAW.
**Open questions.**
- **Are negative offsets meaningful? [propose]** Unchanged from the source doc. A negative
start offset (capture beginning before note-on) is plausible; a negative end offset
(truncating before note-off) is also plausible. Propose the answer with the type.
- **The denomination seam [propose].** The first answer round expressed the offsets "in ms
AND in beats" while note length is musical-division-only. The plain reading is that the
ms/beats duality applies **to the offsets only**. If an ms display or entry for note
length seems wanted at implementation, propose it at review rather than assuming either
way.
--- ---
@@ -1360,24 +991,6 @@ must be closed.
--- ---
### Running Θ and Ξ concurrently
**Ξ-W1 is concurrency-safe with Phase Θ from Θ-W2 onward.** Ξ-W1-T1 is extension-side
record-keeping (`core/model`, `core/wire`, `core/reclaim`, `shell/persist`,
`shell/actions`); Ξ-W1-T2 is a new pure module. Neither is on Θ's critical path.
**Two conditions.**
1. **Not during Θ-W1.** Θ-W1-T1 re-seams the whole instrument, including
`processor_reload.cpp`, which is the one file Ξ-W1-T1 also touches (the usage-publish
block). Wait for Θ-W1 to land.
2. **Ξ-W1-T1 owns the usage-publish block** for the duration; no Θ track edits it.
**Recommendation:** run Ξ-W1 concurrently if a specialist is spare — it ships prune
robustness early and removes the largest dependency from Ξ-W2's critical path. Otherwise
sequence it after Θ. This is a scheduling call, not a plan decision.
---
## Traceability — all seventeen items ## Traceability — all seventeen items
The check that nothing was dropped. Every row points at a track that exists above. The check that nothing was dropped. Every row points at a track that exists above.
+5 -2
View File
@@ -1,8 +1,8 @@
# src/core/instrument — pure VST3-instrument core (engine / map / ui) # src/core/instrument — pure VST3-instrument core (engine / map / note / ui)
## Scope ## Scope
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in four
subdirectories: subdirectories:
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting, - **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
@@ -11,6 +11,9 @@ subdirectories:
`ComponentState` codec, and the small pure helpers the engine/shell share `ComponentState` codec, and the small pure helpers the engine/shell share
(bank-generation sync, bridge-read marshalling, note-name parsing, Trigger (bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
frame↔fraction conversion). frame↔fraction conversion).
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo
resolution, and anchored start/end offsets — the one record and resolver a
capture-signal popup and the offline bake read from, so they cannot diverge.
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band - **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
interiors, waveform, keyboard strip, capture browser, param controls, envelope interiors, waveform, keyboard strip, capture browser, param controls, envelope
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is