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:
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
Reference in New Issue
Block a user