docs: spec Phase Γ — the instrument's control surface

Two-row deck reflow (sound/contour), double-height MASTER with limiter and
meter, PITCH/RATE deck, unit-driven knob law, contour-trace fix, and a
re-approached loop/crossfade UX. Folds rulings Γ-F1..Γ-F5; opens Γ-F6.
This commit is contained in:
2026-08-01 16:29:32 -04:00
parent 6c982cd617
commit eb777f55e1
4 changed files with 2316 additions and 44 deletions
+254
View File
@@ -0,0 +1,254 @@
# VST3 automation parameters — scoping, not scheduling
> Daniel, 2026-08-01: *"we need to scope the whole parameter system for automation, which
> will require redesigning the existing wiring."* And, on Rate being latched at note-on:
> *"we will need to analyze ALL the controls for LIVE use/automation use."*
**This is NOT Phase Γ work.** It is framing and open questions, written now because Phase Γ
makes two decisions whose cost changes permanently the day parameters ship, and because
knowing that is what makes Γ's ordering correct rather than arbitrary. Nothing here is
scheduled; nothing here is settled.
The one thing this doc *does* assert is a sequencing constraint — §4.
---
## 1. Current state, verified
**The instrument has zero VST3 automatable parameters.**
- `ReaSamplerProcessor::initialize` never populates the parameter list, so
`getParameterCount()` returns the SDK default **0**. A host sees no parameters at all.
- All state lives in the **`ComponentState` blob** — envelope v1…v11, params payload
v1…v13 (`core/instrument/map/component_state_io.h`), a cross-artifact contract the
extension's `instrument_drop` and the instrument's processor both read and write.
- `DeckParam` ids are **runtime-only and explicitly free to reorder** — the editor's
`ParamControl` is an alias of the same enum, and nothing persists an id.
Consequently the instrument today has: no host automation, no MIDI learn, no parameter
linking, no host-side modulation, and no generic FX panel. Everything is reached through the
plugin's own editor.
---
## 2. What is actually on the table (name the prize properly)
Framing this as "automation" undersells it. **REAPER gives every exposed VST3 parameter a
parameter-modulation block for free** — an LFO, an envelope, an audio-follower/sidechain,
MIDI link, and parameter linking, per parameter, with no plugin-side work beyond declaring
the parameter.
So exposing parameters is not primarily about drawing automation lanes. It is the
instrument **gaining a modulation system it would otherwise have to design, build, persist
and draw itself.** A sampler with a host-provided LFO on filter cutoff, an envelope-follower
on drive, and MIDI-CC on pitch offset is a materially different instrument, and none of that
is our code.
That is the reason this is worth a phase. It is also the reason the parameter list's design
matters more than the plumbing: the list *is* the modulation matrix's rows.
---
## 3. The hard problems, in the order they bite
### 3.1 Parameter IDs become FOREVER-STABLE
A VST3 `ParamID` is recorded in the project file. Once shipped, the id ↔ meaning mapping is
**frozen forever** — the same discipline the extension's command-id strings and the VST3
class UIDs already carry, and the same discipline the params-payload version ladder carries.
**`DeckParam`'s "free to reorder" property dies the day parameters ship.** Anything that
wants to renumber, regroup or reorder that enum has to happen first. This is not a
theoretical concern: Phase Γ adds two entries to it.
Open: is `ParamID` the `DeckParam` value directly, or an independent id space with an
explicit mapping table? Direct is simpler and tempting; an independent space is what lets
the enum keep being a UI-ordering convenience. **Lean: independent id space with an explicit
frozen table**, on the same reasoning the command-id family already uses — the display order
and the wire identity should not be the same number.
### 3.2 The taper becomes the host-facing contract
VST3 parameters are normalized `[0,1]` with the plugin owning the taper
(`toPlain`/`toNormalized`). An automation envelope a user draws in REAPER is drawn against
the **normalized** value.
**Therefore: re-tapering a parameter after it ships silently re-interprets every recorded
automation point in every saved project.** A 40 %-of-travel node on an attack knob means
16 ms under a log taper and 800 ms under a linear one. There is no version ladder that can
fix this, because the data is in the host's project file, not ours.
Today, re-tapering is **free** — normalization exists only in `ui/deck_values.cpp` as a
display/interaction layer, and the payload stores raw engine values as doubles. That is a
property worth spending while we still have it. See §4.
### 3.3 Two sources of truth
VST3 saves parameter values *and* calls `setState`/`setComponentState`. A value that lives
in both can drift — a project reloaded with automation could restore the blob's value and
then have it immediately overwritten by the host's parameter value, or the reverse,
depending on call order.
Two coherent answers, both viable, neither obviously right:
- **(a) Parameters are the source of truth** for everything automatable; the blob carries
only non-automatable structure — sample refs, velocity curves, spline contours, mode
selections, the loaded capture. Cleanest host semantics; largest rewrite; and it splits
the parameter set across two storage mechanisms, which the "one parameter set" invariant
went out of its way to unify.
- **(b) The blob stays authoritative; parameters are a projection** with one defined sync
direction and a defined precedence at load. Smallest change; preserves the cross-artifact
blob contract intact; risks exactly the drift class above if the precedence is not
airtight.
Open. This is the load-bearing architectural decision of that phase and it should be made
first, the way Ξ-W2-T1's crossing decision was.
### 3.4 Which controls can be parameters at all — three classes
The good news: **this analysis is already done once, in one place.** `isLiveDeckParam` /
`liveCommitFor` (`ui/deck_groups`) is exactly "which controls can change without a rebuild,"
which is the same question automation asks. Phase Γ widens it from two states to three
(§3.5). The parameter work should widen the *same* decision point again rather than start a
second table — that is the standing rule (`core/instrument/CLAUDE.md`: *"which controls are
live is ONE decision, recorded in ONE place"*).
| Class | Examples | Automatable? |
|---|---|---|
| **Continuous, live-safe** | filter cutoff/Q/morph/drive/mod amt/key-track, every stage time and level, pitch env depth, master gain, pitch offset | **Yes** — the live tier already delivers them at block boundaries |
| **Discrete / rebuild-tier** | voice count, Poly/Mono, Retrig/Legato, Gate/Trigger, Staged/Spline, pitch engine, filter law | **Only if** each gains a live path, or is exposed as a stepped parameter that is explicitly *not* safe to automate at rate. Today they route through `rebuildVoiceEngine` or a full reload — neither is RT-safe at automation rates |
| **Structural** | the loaded capture, `SampleRefs`, the three velocity curves, the three spline contours, loop points, root note | **No.** These are not scalars; they stay in the blob |
| **Latency-changing** | the **limiter enable** | **No** — and for a reason unrelated to the live tier. Flipping it changes reported latency, which the SDK defines as a host deactivate/reactivate. §3.8 |
The awkward middle class is the second row, and it is where the design work is.
### 3.5 Live vs. latched is a per-parameter decision, and Γ opens the seam
Phase Γ settles that **Rate is latched at note-on**, and — importantly — settles it as a
*third commit class* (`Live` / `NoteOnLatched` / `Reload`) recorded in the same
`deck_groups` predicate, rather than as a special case at a call site. See
`docs/product/instrument-control-surface.md` §2.3.
That is exactly the vocabulary the parameter system needs. A VST3 parameter has to declare
what it means to move it mid-note, and the answer is per parameter:
- **Live** — the sounding voice follows (the φ-holding `applyLive` rule).
- **Note-on latched** — published, but read only by `snapLive`. Automation still works; it
just takes effect on the next note. Rate is here, and the *reason* it is here is recorded:
loop resolution and contour mapping are note-on folds, so live rate means re-folding a
resolved loop mid-note without a discontinuity.
- **Not automatable** — rebuild or structural.
**Lifting Rate from latched to live is a real feature with a named cost, not a flag flip.**
When someone proposes it, that is the paragraph to read first.
### 3.6 Parameter count, grouping, and the generic panel
~60 `DeckParam`s plus the non-deck controls (voice count, master gain, key-track, preview
velocity, limiter — though the limiter enable is **excluded** from the automatable set, see
§3.8). REAPER's generic FX panel and every automation-lane picker will list all of them flat
unless they are grouped.
VST3's answer is `IUnitInfo` — a unit tree that maps naturally onto the deck's own group
structure (PITCH/RATE, FILTER, VELOCITY, VOICE, the three envelopes, MASTER). Also needed
per parameter: a display name that survives truncation, `ParameterInfo::units`,
`stepCount` for discretes, and the right flags (`kCanAutomate`, `kIsBypass` — the limiter
toggle is emphatically **not** the plugin's bypass parameter).
Open: does the unit tree mirror the deck's *visual* grouping (which Phase Γ has just
reflowed into two rows) or the engine's signal flow? They currently agree. Keeping them
agreeing is a constraint worth stating before they diverge.
### 3.7 Sample-accurate automation vs. block-boundary observation
The live-parameter seqlock is observed **once per `render()` and once per note-on** — block
boundaries, by design, and that design is what keeps the per-sample path free of
indirection. VST3's `IParameterChanges` can carry multiple points inside one block.
Block-boundary application is standard, acceptable, and what most instruments do. Reading
the points sample-accurately would put a per-sample "has a parameter changed" question on
the hot path, which the phase-wide guardrail forbids in its current form. **Lean: block
boundary, explicitly, and say so — then revisit only if a user reports audible stepping on a
fast automation curve.**
### 3.8 Latency reporting — SETTLED, and it removes one control from the parameter list
**Fork Γ-F2 is ruled (Daniel, 2026-08-01): the limiter has lookahead and the plugin reports
latency DYNAMICALLY** — zero when the limiter is off, the lookahead when it is on. Do not
plan against a zero-latency instrument.
The consequence for this doc is concrete and it is a **subtraction from the parameter list**:
> **The limiter enable is NOT automatable.** It goes in §3.4's third class, and the reason
> is not that it lacks a live path — it is that changing it changes the plugin's reported
> latency, and the vendored SDK defines `restartComponent(kLatencyChanged)` as *"the host
> has to deactivate and reactivate the plug-in"*
> (`pluginterfaces/vst/ivsteditcontroller.h:105-108`). In this plugin a deactivate frees
> every sounding voice and a reactivate re-decodes the WAV. **An automation lane toggling
> that parameter would deactivate the plugin on every flip.**
Two corollaries the parameter work must carry rather than rediscover:
- **It is also not the plugin's `kIsBypass` parameter.** A safety limiter is not a bypass;
binding it to `kIsBypass` would hand the host a control that restarts the component.
- **Latency reporting must be derived from persisted state, not from a transient.** The SDK
states the new latency is what `getLatencySamples` returns *after* `setActive(true)` — and
this plugin's `setActive(false)` frees essentially everything. Whatever holds the limiter
flag must survive that cycle.
Full reasoning, the SDK quotes, and the required verification steps are in
`docs/product/instrument-control-surface.md` §3.1.1. **One fork remains open there
(Γ-F6)** — whether the deactivate/reactivate cost is acceptable in REAPER, with constant
reported latency as the pre-agreed fallback. If that fallback is taken, the limiter enable
becomes automatable again and this section shrinks to a footnote; check which way it went
before writing the parameter list.
---
## 4. The sequencing assertion — the one thing this doc claims
**The knob-taper work (Phase Γ item D) must land before the parameter system. Not
alongside; before.**
- Re-tapering is **free today** — normalization is a UI layer, the payload stores raw engine
doubles, and a re-taper moves the needle angle and nothing else (saved projects reload
bit-identical).
- Re-tapering is **permanently expensive after parameters ship** — the taper becomes the
host-facing normalization, and changing it re-interprets recorded automation in project
files we do not own and cannot migrate (§3.2).
The same argument, weaker but real, applies to `DeckParam` additions and reordering (§3.1):
Phase Γ adds Rate and Pitch to that enum, and doing it now costs one exhaustive-switch
update; doing it after parameters ship costs a frozen-id decision.
**So the tension the brief flags is real, and it resolves in Γ's favour.** Both item D and
the parameter system touch `deck_values.cpp` and `editor_controls.cpp`, and the ordering is
not a coin flip: item D first, by a wide margin.
---
## 5. Open questions, collected
Not one of these is a Phase Γ blocker. They are what a future phase's first wave answers.
1. **Blob vs. parameters as the source of truth** (§3.3) — the architectural decision, made
first, before any wiring.
2. **`ParamID` space** — direct `DeckParam` values, or an independent frozen table (§3.1).
*Lean: independent.*
3. **The discrete/rebuild-tier controls** (§3.4) — for each of voice count, Poly/Mono,
Retrig/Legato, Gate/Trigger, Staged/Spline, pitch engine, filter law: gain a live path,
expose as non-automatable, or omit from the parameter list entirely.
4. **Per-parameter live/latched classification** (§3.5) — the three-state predicate Phase Γ
opens must be filled in for every exposed parameter, including whether Rate is lifted to
live and at what cost.
5. **Unit tree shape** (§3.6) — mirror the deck's visual grouping, or the signal flow.
6. **Sample-accurate vs. block-boundary** (§3.7). *Lean: block boundary, stated explicitly.*
7. **Does the extension's `instrument_drop` need to know?** It writes the same
`ComponentState` bytes. If parameters become the source of truth for automatable values
(option (a)), a blob the extension writes would no longer fully determine the instrument's
sound. That is a cross-artifact contract change and it is the sharpest hidden cost of
option (a).
8. **Migration.** Every saved project predates parameters. On first load a parameter's value
comes from the blob; after that the host owns it. Confirm that path is lossless, and
confirm what happens to a project saved *by* the new build and opened by an older one
(the blob still carries everything, so it should be fine — verify, do not assume).