1003 lines
64 KiB
Markdown
1003 lines
64 KiB
Markdown
# VST3 automation parameters — the Phase Γ specification
|
||
|
||
> 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."*
|
||
|
||
**Superseding ruling, Daniel 2026-08-01 (later the same day):** *"correct the phase gamma
|
||
plan to account for complying with the VST3 standard for parameter reporting… by the end of
|
||
gamma we have the automatable params reported. Make the parameter order logical."*
|
||
|
||
**This IS Phase Γ work.** It ships as **Γ-W4-T1 `vst3-parameter-set`**, the phase's last
|
||
track. This doc was written as scoping and has been promoted in place: §§1–5 are the
|
||
original analysis (still correct, still the reasoning), and **§§6–10 are the specification**
|
||
— the ID scheme and its freeze invariant, the per-control classification, the one-way-door
|
||
sweep, the resample interaction, and the build shape.
|
||
|
||
**Nothing in this doc is open to Daniel any longer.** The storage architecture (§3.3, decided
|
||
in §6.1), the ID space (§3.1, decided in §6.2) and **Γ-F7, the parameter order — RULED
|
||
signal flow, 2026-08-01** (§6.4) are all settled. **Ruling 3 (real units at the host
|
||
boundary) is specified at §6.7**, which also carries the one-formatter invariant, the
|
||
`stepCount` sweep, and the resolution of the apparent conflict between "report real units"
|
||
and "do not re-taper the filter."
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
**DECIDED — §6.2: an independent id space with an explicit frozen table.** Direct
|
||
`DeckParam` values were simpler and were rejected: they would freeze an enum whose own
|
||
header declares it *"runtime-only… free to change"*, and they cannot express the cases
|
||
where the two spaces genuinely differ (a `DeckParam` that is not a parameter, a parameter
|
||
with no deck cell). The reasoning is the command-id family's: display order and 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.
|
||
|
||
**Ruling 3 makes this literal rather than analogous:** `toPlain`/`toNormalized` are not merely
|
||
*like* our taper, they **are** it — one extracted pure module read by the knob, the overlay
|
||
and the host alike. §6.7.3.
|
||
|
||
### 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.
|
||
|
||
**DECIDED — (b), the blob stays authoritative. See §6.1 for the reasoning, the two
|
||
findings that settled it, and the precedence rule that closes the drift risk.** This was
|
||
going to be handed to the track as its opening architecture decision, the way Ξ-W2-T1's
|
||
crossing decision was. It is not, because two verified facts collapse the fork rather
|
||
than balancing it — a fork with a dominated option in it is not a fork.
|
||
|
||
### 3.4 Which controls can be parameters at all — three classes
|
||
|
||
The good news: **this analysis is already done once, in one place.** `deckParamCommit` /
|
||
`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. **Fork Γ-F6 is now RULED (Daniel,
|
||
2026-08-01): dynamic latency ships as specced and the deactivate/reactivate is accepted.**
|
||
There is no constant-reported-latency fallback — that option is closed, not shelved — so
|
||
**this section does not shrink to a footnote and the limiter enable does not become
|
||
automatable.** Plan against the not-automatable classification; it is settled.
|
||
|
||
**One future condition could reopen it, and it is worth knowing about.** The restart is only
|
||
expensive because *this plugin's* `setActive(true)` re-decodes the WAV — not because the SDK
|
||
requires it. `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") files that
|
||
reduction, and **"the limiter enable is wanted automatable" is one of its named trigger
|
||
conditions.** If the parameter work genuinely needs that lane, the answer is to do the
|
||
decoupling first, not to re-litigate the classification.
|
||
|
||
---
|
||
|
||
## 4. The sequencing assertion — now satisfied inside one phase
|
||
|
||
**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.
|
||
|
||
**What changed when the parameter system moved INTO Phase Γ.** The assertion above was
|
||
written to justify Γ running before a later phase. It now governs **wave order inside one
|
||
phase**: Γ-W1-T1 lands the taper, Γ-W4-T1 lands the parameters, and two whole waves of new
|
||
controls sit between them so that every parameter exists before any parameter is declared.
|
||
The argument is unchanged; only its blast radius shrank, which is strictly better — the two
|
||
halves are now reviewed against each other rather than across a phase boundary.
|
||
|
||
**And it grew a second clause.** Daniel's ruling *"extend the stage lengths to 10 s"*
|
||
reverses Γ-F3 and moves `kEnvTimeMaxSeconds` 2.0 → 10.0 into **Γ-W1-T1, beside the taper**,
|
||
for exactly the reason stated here: a range endpoint is part of the host-facing
|
||
normalization every bit as much as the curve between the endpoints is. §8 sweeps for every
|
||
other constant with that property.
|
||
|
||
---
|
||
|
||
## 5. Open questions — status after the promotion
|
||
|
||
The scoping pass collected eight, and Γ-F7 was opened beside them. **All nine are closed**;
|
||
one (migration) survives as a verification rather than a question. Ruling 3 added a tenth
|
||
line, closed on arrival.
|
||
|
||
| # | Question | Status |
|
||
|---|---|---|
|
||
| 1 | Blob vs. parameters as the source of truth (§3.3) | **CLOSED — (b), blob authoritative.** §6.1 |
|
||
| 2 | `ParamID` space (§3.1) | **CLOSED — independent frozen table.** §6.2 |
|
||
| 3 | The discrete/rebuild-tier controls (§3.4) | **CLOSED — omitted from the list entirely**, not exposed-and-flagged. §7.2 |
|
||
| 4 | Per-parameter live/latched classification (§3.5) | **CLOSED — the list is DERIVED from the three-state predicate**, never hand-maintained. §7 |
|
||
| 5 | Unit tree shape (§3.6) | **CLOSED — the deck's groups, in signal-flow order**, layout-independent. §6.4 |
|
||
| 6 | Sample-accurate vs. block-boundary (§3.7) | **CLOSED — block boundary, stated explicitly.** §6.5 |
|
||
| 7 | Does the extension's `instrument_drop` need to know? | **CLOSED — no.** It was the sharpest cost of option (a), and (a) is not taken. The blob a drop writes still fully determines the sound. §6.1 |
|
||
| 8 | Migration | **CLOSED as a design question, OPEN as a verification.** §6.6 |
|
||
| **Γ-F7** | **Parameter ORDER — signal flow, or the editor's visual rows?** | **CLOSED — SIGNAL FLOW** (Daniel, 2026-08-01: *"signal flow order."*). The block table needed no renumbering; visual order would have. §6.4 |
|
||
| **Ruling 3** | **Real units at the host boundary** | **CLOSED on arrival** (Daniel, 2026-08-01). Satisfied through the plain-value layer, not the wire format. Unit/precision table, the one-formatter invariant, the `stepCount` sweep, and the filter resolution: §6.7 |
|
||
|
||
---
|
||
|
||
## 6. The specification — architecture, identity, order
|
||
|
||
### 6.1 Storage: the blob stays authoritative; parameters are a projection (option (b))
|
||
|
||
**Decided, not deferred.** Two verified facts collapse §3.3's fork:
|
||
|
||
1. **This plugin is a `SingleComponentEffect`, so there is only ONE state.** The SDK's own
|
||
header `#define`s `setState`/`getState` to `setEditorState`/`getEditorState` around its
|
||
include of `vsteditcontroller.h`, *"work around for the name clash of
|
||
IComponent::setState and IEditController::setState"*
|
||
(`public.sdk/source/vst/vstsinglecomponenteffect.h:41-47`). `setComponentState` exists to
|
||
feed a **separate** controller in a split-component plug-in; we have no separate
|
||
controller. §3.3's "a value that lives in both can drift, depending on call order"
|
||
describes a hazard of a design we do not use. **The drift class it warns about is
|
||
substantially smaller than the scoping pass assumed.**
|
||
2. **The blob is a cross-artifact contract.** The extension's `instrument_drop` writes the
|
||
identical bytes to build a transient `.vstpreset` (`map/component_state_io.h`). Under
|
||
option (a) a blob the extension writes would no longer fully determine the sound — a
|
||
shipped seam would silently become partial. That is not a trade, it is a regression.
|
||
|
||
Add the repo's own invariant — *"the instrument holds ONE loaded capture and ONE set of
|
||
playback parameters… every playback parameter edits in exactly one place"* — and option (a),
|
||
which splits the parameter set across two storage mechanisms, is arguing against the
|
||
invariant Θ-W1-T1 was run to establish.
|
||
|
||
**The rule, stated so it cannot drift:**
|
||
|
||
> **`InstrumentParams` / `PlaySeconds` is THE model. The `ComponentState` blob is its only
|
||
> persistence. A VST3 parameter is a THIRD SURFACE onto that model — a peer of the deck
|
||
> knob and the overlay node, not a second copy of the value.**
|
||
>
|
||
> - **Load:** the blob restores the model; every exposed parameter's normalized value is
|
||
> then *derived* from the model and pushed to the controller. The SDK is explicit that
|
||
> this push must not be reflected back to the host: *"The controller must never pass this
|
||
> value-change back to the host via the IComponentHandler. It should update the according
|
||
> GUI element(s) only"* (`ivsteditcontroller.h:430-433`).
|
||
> - **Host → plugin:** a parameter change writes the model through the **existing commit
|
||
> tiers** (`shell/instrument/CLAUDE.md`) — live publish or note-on latch. It never takes a
|
||
> fourth route.
|
||
> - **Plugin → host:** an editor gesture writes the model AND notifies the host, bracketed
|
||
> `beginEdit` / `performEdit` / `endEdit` (`ivsteditcontroller.h:175-185`), on the UI
|
||
> thread as the SDK requires.
|
||
> - **Save:** `getState` serializes the model. Nothing new is persisted.
|
||
>
|
||
> **The residual risk is not drift, it is authority:** a host automation lane, once written,
|
||
> outranks anything the plugin sets, because the host replays it. That is inherent to
|
||
> automation and is not a defect to design away — but it has one sharp consequence for the
|
||
> resample bake, and that is §9.
|
||
|
||
**[verify] at the track, before wiring:** that REAPER calls `setState` (not
|
||
`setComponentState`) on a single-component plug-in, and the ordering of `setState` against
|
||
the first `IParameterChanges` block after a project load. Verify against the vendored SDK
|
||
and in the DAW — do not build on the paragraph above without it.
|
||
|
||
### 6.2 The ID space: hand-assigned constants in one frozen table
|
||
|
||
**`ParamID` is an independent space from `DeckParam`, defined by a hand-written table of
|
||
named constants in one pure module.** Not derived, not computed, not an enum cast.
|
||
|
||
**Why hand-assigned rather than derived.** A derived id is a function of something else —
|
||
enum position, group index, a hash of a name — and every one of those inputs is a thing
|
||
that must then never change. Deriving does not remove the freeze; it hides it behind a
|
||
mechanism, and the day someone reorders the source enum the ids move silently. A literal
|
||
table makes the freeze **visible at the point of change**: you cannot renumber it by
|
||
accident, because renumbering it means editing the numbers.
|
||
|
||
**The layout — blocks of 100 per deck group, steps of 10 within a block:**
|
||
|
||
| Block | Group | Reserved |
|
||
|---|---|---|
|
||
| `1000` | PITCH/RATE | 1000–1099 |
|
||
| `1100` | PITCH ENV | 1100–1199 |
|
||
| `1200` | FILTER | 1200–1299 |
|
||
| `1300` | FILTER ENV | 1300–1399 |
|
||
| `1400` | AMP ENVELOPE | 1400–1499 |
|
||
| `1500` | VELOCITY | 1500–1599 (reserved; empty today — §7.3) |
|
||
| `1600` | VOICE | 1600–1699 (reserved; empty today — §7.2) |
|
||
| `1700` | MASTER | 1700–1799 |
|
||
|
||
- **Within a block, ids step by 10** in the group's own left-to-right cell order. Nine free
|
||
slots sit between every pair of neighbours, so **a control added later lands numerically
|
||
adjacent to its siblings instead of appended at the end of the whole table.** That is the
|
||
concrete content of "make the parameter order logical": the order is a property of the
|
||
numbering scheme, maintained by construction, not by remembering to re-sort.
|
||
- **A curve-exponent inner dial takes its outer knob's id + 1.** The dial is a property of
|
||
the knob it sits inside — the codebase already says so (`curveParamFor`, "each is the
|
||
INNER DIAL of the stage knob it shapes") — so its id says so too. This is why the step is
|
||
10 and not 2: it leaves room for the dial *and* for a future sibling knob.
|
||
- **Blocks start at 1000, not 0.** `0` is a plausible accident (a default-initialised
|
||
`ParamID`), and a scheme whose first legitimate id is also its most likely bug value is a
|
||
scheme that cannot tell them apart.
|
||
- **An empty block is still reserved.** VELOCITY and VOICE expose nothing today. Their
|
||
blocks exist so that if either ever gains an automatable control it lands in its own
|
||
group's range rather than in whatever range happened to be free.
|
||
|
||
**The block sequence above IS signal-flow order, and that is now the ruled order (§6.4).**
|
||
The table was authored in the deck's own `sampleDeckGroups` sequence, which today happens to
|
||
be both the signal flow and the pre-reflow visual order; Γ-W3-T1's reflow is what separates
|
||
them. Because Daniel ruled signal flow, **no block moves** — had the ruling gone to visual
|
||
rows, every block from 1100 up would have been renumbered before it was ever frozen.
|
||
|
||
**Within a block, the order is the group's own semantic order, seeded ONCE at the freeze and
|
||
never re-seeded.** Γ-F7's reasoning applies recursively: a group's `cellIds` order is exactly
|
||
as mobile as the row order is, and a future width fit can reorder cells inside a group as
|
||
easily as the reflow reorders groups. So the within-block sequence is a property of *this
|
||
table*, not a tracked function of `cellIds`. It is seeded from the cell order as it stands at
|
||
Γ-W3, which is semantic in every group today — envelope stages in temporal order, filter
|
||
cells in solve order — and that is what makes the seeding safe rather than arbitrary.
|
||
|
||
#### The table, stated
|
||
|
||
44 parameters. Curve dials take their outer knob's id + 1 (§6.2 above); a `+1` row is always
|
||
the inner dial of the row above it.
|
||
|
||
| ID | Parameter | `units` | ID | Parameter | `units` |
|
||
|---|---|---|---|---|---|
|
||
| **1000** | Key-track (pitch) | `%` | **1300** | Filter env attack | `ms` |
|
||
| **1010** | Rate | `%` | 1301 | └ attack curve | — |
|
||
| **1020** | Pitch offset | `st` | **1310** | Filter env hold | `ms` |
|
||
| | | | **1320** | Filter env decay | `ms` |
|
||
| **1100** | Pitch env attack | `ms` | 1321 | └ decay curve | — |
|
||
| 1101 | └ attack curve | — | **1330** | Filter env sustain | `%` |
|
||
| **1110** | Pitch env hold | `%` | **1340** | Filter env release | `ms` |
|
||
| **1120** | Pitch env decay | `ms` | 1341 | └ release curve | — |
|
||
| 1121 | └ decay curve | — | **1350** | Filter trig attack | `ms` |
|
||
| **1130** | Pitch env depth | `st` | 1351 | └ attack curve | — |
|
||
| | | | **1360** | Filter trig hold | `%` |
|
||
| **1200** | Filter morph | `%` | **1370** | Filter trig decay | `ms` |
|
||
| **1210** | Filter cutoff | `Hz` | 1371 | └ decay curve | — |
|
||
| **1220** | Filter Q | — | | | |
|
||
| **1230** | Filter drive | — | **1400** | Amp attack | `ms` |
|
||
| **1240** | Filter mod amount | `%` | 1401 | └ attack curve | — |
|
||
| **1250** | Filter velocity amount | `%` | **1410** | Amp hold | `ms` |
|
||
| **1260** | Key-track (filter) | `%` | **1420** | Amp decay | `ms` |
|
||
| | | | 1421 | └ decay curve | — |
|
||
| **1700** | Master gain | `dB` | **1430** | Amp sustain | `%` |
|
||
| | | | **1440** | Amp release | `ms` |
|
||
| *1500–1599* | *VELOCITY — reserved, empty* | | 1441 | └ release curve | — |
|
||
| *1600–1699* | *VOICE — reserved, empty* | | **1450** | Trigger length | `%` |
|
||
| | | | **1460** | Amp trig attack | `ms` |
|
||
| | | | 1461 | └ attack curve | — |
|
||
| | | | **1470** | Amp trig hold | `%` |
|
||
| | | | **1480** | Amp trig decay | `ms` |
|
||
| | | | 1481 | └ decay curve | — |
|
||
|
||
**Presentation index order is ascending id** — 1000, 1010, 1020, 1100, 1101, 1110, … 1700.
|
||
Identity order and presentation order therefore agree by construction, which is §6.4's
|
||
requirement rather than a coincidence to be maintained. **The count is asserted against the
|
||
predicate, never against this table** (§7); the table is what the predicate's members are
|
||
*numbered* as, not what decides membership.
|
||
|
||
### 6.3 The freeze invariant
|
||
|
||
State this in the ID table's header, with the same force as its three siblings.
|
||
|
||
> **THE PARAMETER-ID TABLE IS FOREVER-FROZEN.** A VST3 `ParamID` is recorded by the host
|
||
> into automation lanes inside project files this repo does not own and cannot migrate.
|
||
> From the first shipped build that exposes parameters:
|
||
>
|
||
> - **No id is ever reassigned, reused, or re-pointed.** If a control's meaning genuinely
|
||
> changes, it takes a NEW id and the old one is **retired** — marked dead in the table,
|
||
> never re-issued.
|
||
> - **No exposed parameter's normalization ever changes** — not its taper, not either range
|
||
> endpoint, not its `stepCount`. The normalization *is* the meaning of every recorded
|
||
> automation point. §8 enumerates every constant this now freezes.
|
||
> - **A parameter's meaning never depends on a mode.** The Gate-face and Trigger-face stage
|
||
> times are separate stored fields and take separate ids; an id whose plain value means
|
||
> one thing in Gate and another in Trigger is unautomatable by construction.
|
||
> - **A new control takes the next free slot inside its own group's block**, never the next
|
||
> number at the end of the table.
|
||
>
|
||
> This sits on the same footing as the extension's `"STABLE_FOREVER_STRING"` command ids
|
||
> (root `CLAUDE.md`, "REAPER extension contract"), the two VST3 class UIDs
|
||
> (`core/wire/reasampler_uid.h`, FOREVER-FROZEN), and the params-payload field order
|
||
> (`map/component_state_io.h`, "EVERY wire format below is FROZEN"). It is the fourth
|
||
> member of that family, not a new kind of rule.
|
||
|
||
**One property the table BUYS, and it is the reason to prefer it over deriving:**
|
||
`DeckParam`'s header says the enum is *"runtime-only… free to change."* With an independent
|
||
table that stays true — the reflow, a future regrouping, a new deck can all reorder the enum
|
||
freely. Deriving ids from it would have quietly repealed that sentence.
|
||
|
||
### 6.4 Order and grouping — Γ-F7, CLOSED: signal flow
|
||
|
||
Two orders exist and they are not the same thing:
|
||
|
||
- **Identity order** — the id numbers, frozen by §6.3.
|
||
- **Presentation order** — the sequence `getParameterInfo(index)` reports, which is what a
|
||
host's FX-parameter list and every automation-lane picker display.
|
||
|
||
They should agree (a list whose numbering runs backwards against its display is a trap), so
|
||
this is one decision, and it is a forever one on both counts.
|
||
|
||
> **RULED (Daniel, 2026-08-01): signal-flow order.** *"signal flow order."*
|
||
|
||
**The order is PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE →
|
||
MASTER**, which is the deck's own documented ordering rule (`sampleDeckGroups`, *"left to
|
||
right, in SIGNAL-FLOW order: pitch → filter → amp, then the two instance-wide groups"*), with
|
||
each group's cells in the semantic order §6.2's table freezes. Identity order and
|
||
presentation order are the same sequence; the block table in §6.2 stands unchanged, because
|
||
it was already authored in this order.
|
||
|
||
**The rejected alternative was the editor's visual rows** after Γ-W3-T1 — row 1 PITCH/RATE,
|
||
FILTER, VELOCITY, VOICE; row 2 PITCH ENV, FILTER ENV, AMP; MASTER spanning. Same membership,
|
||
different sequence. Do not re-propose it: the id table is frozen from Γ-W4-T1, so re-ordering
|
||
is no longer a preference question at all.
|
||
|
||
**Why signal flow — the argument a future reader will come looking for.** The obvious
|
||
objection to this table is that its numbering does not match what is on screen, and someone
|
||
will eventually propose "fixing" that. The answer is that the two *cannot* both be satisfied
|
||
forever, and only one of the two axes holds still:
|
||
|
||
> **The editor's visual layout has already moved twice** — Θ-W6-T1 grew the window floor
|
||
> 840 → 980, and Γ-W3-T1 takes it to 1190 and re-rows every group into two categorical rows
|
||
> with a double-height MASTER. Within-row order is decided by *width fitting*, not by meaning.
|
||
> **Binding a permanently-frozen id order to a demonstrably mobile layout guarantees the two
|
||
> drift apart** — and after the first drift the order is neither logical *nor* matching, which
|
||
> is strictly worse than either. **Signal flow is the axis that does not move.** It is also
|
||
> the deck's own stated rule, so the order is not a new invention that has to be remembered;
|
||
> it is the one the codebase already documents.
|
||
|
||
The counter-argument, recorded because it was real: a user hunting a parameter in REAPER's
|
||
flat list finds it fastest if the list reads in the order their eyes scan the plugin window.
|
||
That is answered — partly — by `IUnitInfo` grouping, which lets a host present the parameters
|
||
under their group names rather than as one flat run. It is not fully answered, and that
|
||
residual cost is accepted knowingly.
|
||
|
||
§3.6 asked whether the unit tree mirrors the visual grouping or the signal flow and noted
|
||
"they currently agree"; the reflow is what makes them stop agreeing, and this ruling is the
|
||
answer.
|
||
|
||
**Grouping: `IUnitInfo`, one unit per deck group**, mirroring the group inventory (not the
|
||
rows) — unchanged by the ruling, which decided only the sequence.
|
||
|
||
### 6.5 What the plugin must implement, as behaviour
|
||
|
||
Not architecture — the engineer's call is *how*. These are the observable contracts:
|
||
|
||
- **`getParameterCount` / `getParameterInfo`** report exactly the §7 list, in §6.4's order,
|
||
each with: the frozen `id`; a `title` that survives truncation and a distinct
|
||
`shortTitle`; `units` and a plain range per **§6.7**, which is where Ruling 3's real-unit
|
||
contract lives; `stepCount = 0` on all 44 (§6.7's sweep); `defaultNormalizedValue`
|
||
**computed** per §6.7's tightening of §8's exactness rule; the group's `unitId`; and
|
||
`kCanAutomate`.
|
||
- **`kIsBypass` is not set on anything.** The limiter is a safety device, not a bypass, and
|
||
binding it to `kIsBypass` would hand the host a control that restarts the component
|
||
(§3.8). The plugin exposes **no** bypass parameter — it is an instrument.
|
||
- **`setParamNormalized` / `getParamNormalized`** are the model projection of §6.1, not a
|
||
cached shadow value.
|
||
- **`normalizedParamToPlain` / `plainParamToNormalized` / `getParamStringByValue` /
|
||
`getParamValueByString` all route through the ONE taper module Γ-W1-T1 extracts and the ONE
|
||
formatter per unit category.** This is the structural point of that extraction: the host's
|
||
normalization, the knob's needle angle, and the overlay node's position must be the *same
|
||
function*, not three functions that agree today. **§6.7 states both invariants in full** —
|
||
it is the specification for this bullet, not a gloss on it.
|
||
- **`IParameterChanges` is observed at BLOCK boundaries** — the last point in the block wins
|
||
for that block (§3.7). Sample-accurate application would put a per-sample "did anything
|
||
change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids.
|
||
**State it in the header rather than leaving it to be discovered**; revisit only if
|
||
audible stepping is reported on a fast curve.
|
||
- **A host parameter change takes the control's existing commit tier and no other.** Live
|
||
parameters publish; note-on-latched parameters publish and are read by `snapLive`. Nothing
|
||
on the automation path may reach `reloadInstrument` or `rebuildVoiceEngine` — which is
|
||
precisely what §7.2's omissions guarantee.
|
||
- **`IMidiMapping` is in scope and nearly free** (`ivsteditcontroller.h:558-568`): declaring
|
||
a CC → `ParamID` map costs one function and gives every exposed parameter MIDI control.
|
||
**[propose at review]** whether to ship a default map in this track or leave it to REAPER's
|
||
host-side MIDI learn; do not silently skip it without saying so.
|
||
- **`IParameterFunctionName` is assessed and NOT implemented**
|
||
(`ivstparameterfunctionname.h`). Its vocabulary is compressor/panner/dry-wet semantics
|
||
(`Comp:GainReduction`, `DryWetMix`, `Randomize`); none of it names anything this
|
||
instrument exposes. Recorded so it is not re-surveyed.
|
||
- **`IAutomationState` is assessed and NOT implemented**
|
||
(`ivstautomationstate.h`). It reports the host's automation mode for the **whole plug-in**
|
||
(`kNoAutomation` / `kReadState` / `kWriteState`), not per parameter — so it cannot answer
|
||
"is THIS parameter automated," which is the only question §9 would have wanted it for.
|
||
|
||
### 6.6 Migration — the verification, not a design
|
||
|
||
Every saved project predates parameters, and under §6.1 that is a non-event: the blob
|
||
restores the model and the parameters are derived from it. Three things are nonetheless
|
||
**[verify], not assume**:
|
||
|
||
1. A pre-parameter project opens with every parameter reading the blob's value, and sounds
|
||
identical.
|
||
2. A project saved by the new build and opened by an **older** binary still restores fully —
|
||
the blob carries everything, so it should; prove it.
|
||
3. A project with automation drawn on a parameter, saved and reopened, replays that
|
||
automation against the same plain values — the round trip through the frozen taper is
|
||
exact at both ends.
|
||
|
||
### 6.7 Real units at the host boundary — Ruling 3
|
||
|
||
**Ruling numbering.** `docs/PLAN.md` numbers Daniel's Phase-Γ rulings in the order they
|
||
arrived: **Ruling 1** = VST3 parameters ship in Γ; **Ruling 2** = the 10 s ceiling and the
|
||
knob-interaction law it grew; **Ruling 3** = this one. The same numbers are used here.
|
||
|
||
> **Ruling 3 (Daniel, 2026-08-01):** *"The parameter values exposed to the VST host should be
|
||
> in real units, such that the host automation lanes report usable values."*
|
||
|
||
**This is satisfiable, and the mechanism matters, because the naive reading of it is
|
||
impossible.** VST3's automation *wire format* is normalized `0..1` and cannot be anything
|
||
else: `setParamNormalized` / `getParamNormalized` (`ivsteditcontroller.h:429-433`) are the
|
||
interface, and what the host writes into a lane and replays from it is a normalized double.
|
||
There is no VST3 in which a lane stores milliseconds.
|
||
|
||
What delivers the requirement — **usable values in the host's UI and lane readouts** — is the
|
||
**plain-value layer**, which the vendored SDK provides and which we are simply not using yet:
|
||
|
||
| Surface | Where | What it does |
|
||
|---|---|---|
|
||
| `Parameter::toPlain` / `toNormalized` | `public.sdk/source/vst/vstparameters.h:88-90` | **virtual** — overriding them is how a non-linear law is expressed to the host |
|
||
| `RangeParameter` | `vstparameters.h:110-138` | carries plain min/max and does the *linear* mapping for free |
|
||
| `getParamStringByValue` | `ivsteditcontroller.h:418` | the display string the host shows in its readout |
|
||
| `ParameterInfo::units` | `ivsteditcontroller.h:52` | the unit string (`"ms"`, `"st"`, `"dB"`, `"%"`, `"Hz"`) |
|
||
| `normalizedParamToPlain` / `plainParamToNormalized` | `ivsteditcontroller.h:424-426` | the controller-level pair a host calls to read a lane in plain units |
|
||
|
||
**The direct precedent is in the vendored tree.** `public.sdk/samples/vst/common/logscale.h`
|
||
overrides exactly this pair for a logarithmic law — `toPlain` returns `logScale.scale(norm)`,
|
||
`toNormalized` returns `logScale.invscale(plain)` (`logscale.h:221-229`), with `toString`
|
||
printing the *plain* value. That is precisely the shape our log-tapered ms knobs and the
|
||
log2 semitone knobs need. We are not inventing a mechanism; we are using the one the SDK
|
||
ships and Steinberg's own sample demonstrates.
|
||
|
||
#### 6.7.1 Every exposed parameter declares a plain range, a unit, and a display precision
|
||
|
||
Eight unit categories, covering all 44. **Precision and formatting are a product decision
|
||
about what a user reads in a lane, and they are decided here.**
|
||
|
||
| Category | Parameters (count) | `units` | Plain range | Display | Taper (norm → plain) |
|
||
|---|---|---|---|---|---|
|
||
| **Time** | the 14 stage times | `ms` | 0 … 10000 | one decimal below 10 ms, integer at and above (`0.5`, `3`, `104`, `10000`) | log (Γ-W1-T1) |
|
||
| **Semitones** | Pitch offset, Pitch env depth (2) | `st` | −24 … +24 | one decimal, **always signed** (`+3.5`, `-12.0`, `+0.0`) | log2, centre-expanded (Γ-W1-T1) |
|
||
| **Percent, unipolar** | amp/filter sustain, three hold fractions, Trigger length, morph (7) | `%` | 0 … 100 | integer | identity ×100 |
|
||
| **Percent, 0–200** | key-track ×2 (1000, 1260) (2) | `%` | 0 … 200 | integer | identity ×200 |
|
||
| **Percent, bipolar** | filter mod amount, filter velocity amount (2) | `%` | −100 … +100 | integer, **always signed** | identity ×100 |
|
||
| **Percent, Rate** | Rate (1) | `%` | 50 … 200 | **one decimal** | exponential in ratio (linear in semitones, Γ-W2-T1) |
|
||
| **Decibels** | Master gain (1) | `dB` | −60 … +24 | one decimal, always signed; **norm 0 reads `-inf`** | linear in dB (`engine/master_gain`) |
|
||
| **Hertz** | Filter cutoff (1) | `Hz` | 20 … 20000 | integer | log Hz — **frozen v9 law, read-side only** (§6.7.5) |
|
||
| **Dimensionless** | 12 curve exponents, filter Q, filter drive (14) | *(empty)* | exponents 0.1 … 10; Q 0.1 … 10; drive 0 … 4 | two decimals | exponents: `curve_law` log travel. Q/drive: **frozen v9 laws, read-side only** |
|
||
|
||
14 + 2 + 7 + 2 + 2 + 1 + 1 + 1 + 14 = **44.**
|
||
|
||
**Four formatting decisions inside that table, each with its reason:**
|
||
|
||
1. **Time never switches to seconds**, so the ceiling reads `10000 ms` and not `10 s`. This
|
||
is the existing rule (`deck_values.h`: *"the editor reads in one unit so two stage times
|
||
are comparable at a glance"*) surviving the 10 s ceiling intact, and it is *forced* on the
|
||
host side anyway — `ParameterInfo::units` is a static string per parameter and cannot
|
||
change with magnitude.
|
||
2. **Rate reads to one decimal, not integer percent.** Its snap grid is whole semitones
|
||
(§4.2 of the control-surface spec), and semitone steps do not land on integer percent
|
||
(+1 st = 105.946 %). An integer display would print a snapped position as a value the
|
||
snap cannot actually produce.
|
||
3. **Cutoff's `k` abbreviation is RETIRED** — cutoff reads integer Hz on both surfaces
|
||
(`240`, `12800`), not `12.8k`. `units` is one static string per parameter, so a
|
||
magnitude-switching unit is not expressible; and the label band demonstrably fits eight
|
||
characters, since the 10 s ceiling already forces `10000 ms`. **The rejected alternative
|
||
was to keep `12.8k` in the editor and accept a host/editor divergence on cutoff alone** —
|
||
which is exactly the defect class §6.7.2 exists to forbid, for the sake of one idiom.
|
||
4. **The `^` on a curve exponent is static cell chrome, not part of the value.** The editor's
|
||
label keeps its caret; the value string is `1.00`. A constant prefix cannot diverge, and
|
||
`^` is not a unit.
|
||
|
||
**What is frozen here and what is not.** The **plain range** and the **norm↔plain law** are
|
||
frozen with the id (§6.3) — they are the meaning of every recorded automation point. The
|
||
`units` string, the `title`/`shortTitle`, and the display precision are **not frozen**: they
|
||
are what a user reads, not what a lane stores, and may be improved at any time.
|
||
|
||
#### 6.7.2 ONE formatter, two consumers — an invariant, not a review check
|
||
|
||
> **A unit category has exactly ONE pure formatter, and the editor and the host are both its
|
||
> callers.** The formatter returns the **digits** of a plain value in that category's one
|
||
> `units` string — no embedded unit, no magnitude-switched unit, no width-conditional
|
||
> abbreviation, and no caller-side branch that changes what the digits are. The editor's knob
|
||
> label renders `digits` (plus its own static chrome — the unit suffix, the `^` prefix); the
|
||
> host receives `digits` from `getParamStringByValue` and `units` from
|
||
> `ParameterInfo::units`. **There is no second implementation on either side.**
|
||
>
|
||
> **The editor and the host disagreeing about what a value reads as is a defect class, and it
|
||
> is forbidden structurally rather than caught at review** — the same discipline, and the same
|
||
> reason, as *"three functions that agree today is a defect"* for the taper.
|
||
|
||
This has a consequence for the two formatters that exist today: **they stop embedding their
|
||
unit.** `formatEnvTimeMs` (`core/instrument/ui/deck_values.h`) returns `"104 ms"` today and
|
||
must return `"104"`; `formatMasterGainLabel` (`core/instrument/engine/master_gain.h`) returns
|
||
`"-12.0dB"` and must return `"-12.0"` (and `"-inf"` at norm 0, which stays inside the
|
||
formatter so both surfaces show it). `deckValueLabel`
|
||
(`shell/instrument/editor_controls.cpp`) stops carrying `snprintf` format strings of its own
|
||
and becomes a dispatch to the per-category formatters — it is today the *second*
|
||
implementation for every category except time and dB, which is precisely the drift this
|
||
invariant closes.
|
||
|
||
**[verify] at the track:** whether REAPER renders `ParameterInfo::units` beside the string
|
||
from `getParamStringByValue`, or shows the string alone. The SDK's own convention is
|
||
digits-only in the string with `units` carried separately (`RangeParameter::toString` prints
|
||
the number; the `Parameter` constructor takes `units` as its own argument), so that is what
|
||
we ship. **If REAPER shows no unit at all, the fallback is to append the unit inside the one
|
||
formatter** — a one-line change in one place, touching neither the frozen table nor the
|
||
editor, because display strings are not frozen (§6.7.1).
|
||
|
||
#### 6.7.3 `toNormalized` IS the taper
|
||
|
||
**Γ-W1-T1's extracted taper module is not merely *consulted* by the parameter layer — on the
|
||
norm↔plain axis it *is* the parameter layer.** `toPlain` is the taper's forward map;
|
||
`toNormalized` is its inverse. **Three consumers, one law:**
|
||
|
||
1. the editor's knob drag (`ui/deck_values`),
|
||
2. the AHDSR overlay's schematic axis and its drag inverse (`ui/envelope_overlay` +
|
||
`ui/envelope_edit`),
|
||
3. the host's `normalizedParamToPlain` / `plainParamToNormalized` (this track).
|
||
|
||
**This is what makes the one-way-door reasoning structurally true rather than a warning.**
|
||
The plan says re-tapering after Γ-W4-T1 silently re-interprets every recorded automation
|
||
point; the reason that cannot be forgotten is that there is no second place a taper could be
|
||
changed. The host reads the same function the needle does.
|
||
|
||
#### 6.7.4 The consequence to know: a straight line in a lane is not linear in real units
|
||
|
||
Under a tapered parameter, **a linear ramp drawn in a host automation lane sweeps
|
||
non-linearly in the plain unit.** This is standard, generally desirable, and follows directly
|
||
from Ruling 3 combined with the taper ruling — so it is written down rather than discovered:
|
||
|
||
- **Time (log):** a straight lane ramp sweeps **exponentially in ms** — most of its travel is
|
||
spent in the first few hundred milliseconds, which is where a stage time is musically
|
||
decided.
|
||
- **Cutoff (log Hz):** a straight ramp is **linear in octaves** — the musically right filter
|
||
sweep, and the reason every synth taper looks like this.
|
||
- **Master gain (linear in dB):** a straight ramp is a **linear dB fade**, i.e. exponential in
|
||
amplitude — the musically right fade.
|
||
- **Rate (linear in semitones):** a straight ramp is a **linear pitch glide** and an
|
||
exponential sweep in ratio/percent.
|
||
- **Pitch offset, pitch env depth (log2, centre-expanded):** a straight ramp moves **slowly
|
||
near zero and fast at the extremes**, in semitones. This is the one category where the
|
||
taper is chosen for knob resolution rather than for musical linearity, and it is the one a
|
||
user is most likely to notice as unexpected.
|
||
- **Percent, dimensionless, curve exponents:** identity or near-identity — a straight ramp is
|
||
straight.
|
||
|
||
**The remedy, if a user wants a literal-units ramp, is the host's own curve tools**, not a
|
||
change to the taper. There is no version of this in which lanes are stored in plain units.
|
||
|
||
#### 6.7.5 Plain units for the filter's four is NOT re-tapering
|
||
|
||
The plan forbids re-mapping cutoff / Q / morph / drive: they persist as `*Norm` doubles in
|
||
payload v9, so their laws are already wire-frozen and re-tapering them would re-tune every
|
||
saved project independently of automation (§8, row 4). Ruling 3 requires them to report real
|
||
units. **An engineer will read those two as contradictory. They are not, and here is why:**
|
||
|
||
> **`toPlain` is a pure READ-side mapping.** It does not touch the stored value.
|
||
> `getParamNormalized` returns the stored `cutoffNorm` unchanged; `setParamNormalized` writes
|
||
> it unchanged; the payload writes the same double it always has. The *only* thing `toPlain`
|
||
> changes is what the host **displays**. The frozen law stays frozen precisely because
|
||
> `toPlain` is the law — reporting Hz means *calling* it, not replacing it.
|
||
|
||
The codebase already does exactly this and has since the filter shipped: `deckValueLabel`
|
||
prints cutoff via `filterCutoffHzFromNorm`, Q via `filterQFromNorm`, and drive via
|
||
`filterDriveDepthFromNorm` — the filter module's own laws
|
||
(`core/instrument/engine/filter/filter_params.h`). **The parameter layer calls those; it does
|
||
not restate them.** For morph, plain % is `norm × 100`.
|
||
|
||
**One gap, and it is additive.** `plainParamToNormalized` and `getParamValueByString` need
|
||
the inverse of each law. `filterNormFromCutoffHz` and `filterNormFromQ` already exist and are
|
||
documented as exact inverses; **`filterDriveDepthFromNorm` has no published inverse and one
|
||
must be added.** Adding the analytic inverse of a frozen law is not a change to the law — it
|
||
has exactly the standing the two existing inverses already have, and it belongs in
|
||
`filter_params`, beside them, not in the parameter module.
|
||
|
||
**Stated once so the two rules never read as a collision again:** the prohibition forbids
|
||
*editing* `filterCutoffHzFromNorm` and its peers; the requirement is satisfied by *calling*
|
||
them.
|
||
|
||
#### 6.7.6 Shift-snap must NOT become `stepCount` — and the sweep
|
||
|
||
**These are two different things and conflating them would freeze the wrong one forever:**
|
||
|
||
- **Shift-snap is a DRAG interaction** — a modifier held during a mouse gesture in the editor
|
||
(control-surface spec §4.1–4.2). It affects which values a *drag* produces and nothing else.
|
||
- **`ParameterInfo::stepCount` quantizes the PARAMETER** — permanently, for every writer
|
||
including the host's automation, and it is part of the frozen normalization (§6.3 names
|
||
`stepCount` explicitly).
|
||
|
||
Exposing the snap grid as `stepCount` would make continuous cents unreachable from a host
|
||
lane, quantize every automation curve to the snap grid, and freeze that quantization into a
|
||
contract that cannot be revised. **The two axes are independent, and the codebase already
|
||
proves it:** filter cutoff *displays* in Hz, *snaps* in whole percent of its normalized
|
||
position, and would be *automated* continuously — three different grids on one control, none
|
||
of which is the others.
|
||
|
||
> **Every continuous-valued parameter ships `stepCount = 0`.**
|
||
|
||
**The sweep, run over all 44: none carries a non-zero `stepCount`.** Every one of the 44 is
|
||
continuous — the 14 times, the 2 semitone throws, the 12 percent controls, the 12 curve
|
||
exponents, cutoff, Q, drive, master gain. **And that is structural rather than lucky:**
|
||
`stepCount > 0` is only meaningful for a discrete control, and §7's predicate excludes every
|
||
discrete control by construction — discrete controls are reload or rebuild tier (§7.2), which
|
||
is why the classification and the continuity coincide.
|
||
|
||
**The one case that would change this**, named so it is not a surprise: if a discrete control
|
||
ever gains a live path and therefore qualifies (voice count is the obvious candidate, 1–32),
|
||
`stepCount` becomes a live decision *at that moment* and freezes with the new id. §7.2
|
||
already names that door from the other side.
|
||
|
||
#### 6.7.7 The exact-preimage requirement, re-checked against this ruling
|
||
|
||
**Unchanged in force. Tightened in two ways, with one explicit NON-requirement added.**
|
||
|
||
1. **Tightening — `defaultNormalizedValue` is COMPUTED, never a literal.** It is
|
||
`toNormalized(default)`, evaluated against the one taper at table construction, where
|
||
`default` is read off a default-constructed `PlaySeconds` (the standing rule that there is
|
||
no second table of defaults). A hand-written normalized literal is a second source of truth
|
||
for the default and drifts from the taper silently — precisely the failure the whole
|
||
one-formatter/one-taper discipline exists to prevent.
|
||
2. **Tightening — the assertion moves onto the pair the host actually calls.** Γ-W1-T1 still
|
||
owns the taper *shape* that makes exactness achievable, and still fails there if it is not.
|
||
Γ-W4-T1 additionally asserts `toPlain(info.defaultNormalizedValue)` compares **exactly**
|
||
equal to the default, per parameter, on the host-facing functions. The prior wording made
|
||
this an inference from the taper's own tests; it is now a direct test of the contract.
|
||
3. **NON-requirement, stated so nobody over-constrains the taper: `toNormalized(toPlain(n))
|
||
== n` is NOT required for arbitrary `n`.** No log map satisfies that exactly in double,
|
||
and demanding it would rule out the taper the phase has already chosen. Exactness is
|
||
required **at the defaults**; monotonicity is required **everywhere**; round-trip exactness
|
||
at arbitrary points is required **nowhere**.
|
||
4. **The filter's four are exact by identity and need no work.** Their stored value *is* the
|
||
normalized one, so `defaultNormalizedValue` is the stored default verbatim and `toPlain`
|
||
never participates in the reset path.
|
||
5. **Master gain's unity remains the sharpest case** (≈ 0.714 norm), for the reason §8 row 7
|
||
gives: a host reset that lands a hair off unity is an audible gain error, not a cosmetic
|
||
one.
|
||
|
||
---
|
||
|
||
## 7. The classification — which controls become parameters
|
||
|
||
**The list is DERIVED, never hand-maintained.** Its rule is one line:
|
||
|
||
> **A control is an exposed VST3 parameter if and only if its commit class is `Live` or
|
||
> `NoteOnLatched`.** Everything else is omitted from the parameter list entirely.
|
||
|
||
That makes `deckParamCommit` / `liveCommitFor` — already *"THE home for why each excluded
|
||
control is excluded"* — the single source for the parameter list too, which is the standing
|
||
rule (`core/instrument/CLAUDE.md`: *"which controls are live is ONE decision, recorded in ONE
|
||
place"*) applied once more rather than a second table opened beside it.
|
||
|
||
### 7.1 Exposed — 44 parameters
|
||
|
||
Counts are against the control inventory as it stands at the end of Γ-W3; the track asserts
|
||
the count against the predicate rather than against this table.
|
||
|
||
| Group | Parameters | Class |
|
||
|---|---|---|
|
||
| **PITCH/RATE** (3) | Rate; Pitch offset; Key-track | Rate **NoteOnLatched** (Γ, settled); Pitch **Live**; Key-track **NoteOnLatched** (see §7.4) |
|
||
| **PITCH ENV** (6) | attack, hold-fraction, decay, depth + attack/decay curve exponents | **Live** |
|
||
| **FILTER** (7) | morph, cutoff, Q, drive, mod amount, velocity amount, key-track | **Live** |
|
||
| **FILTER ENV** (13) | AHDSR A/H/D/S/R; Trigger AHD A/H/D; five curve exponents | **Live** |
|
||
| **AMP ENVELOPE** (14) | AHDSR A/H/D/S/R; Trigger AHD A/H/D; Trigger length; five curve exponents | **Live**, except Trigger length **NoteOnLatched** (§7.4) |
|
||
| **VELOCITY** (0) | — | its three cells are curve popups, not scalars (§7.3) |
|
||
| **VOICE** (0) | — | rebuild tier (§7.2) |
|
||
| **MASTER** (1) | Master gain | **Live** |
|
||
|
||
**Both play-mode faces are exposed simultaneously**, because both are stored simultaneously
|
||
and always have been. A Trigger stage time is a real parameter whose effect is silent while
|
||
Gate is selected — which is the same relationship a disabled filter's cutoff already has, and
|
||
far better than an id whose meaning depends on a mode (§6.3).
|
||
|
||
### 7.2 Omitted — the reload and rebuild tiers, and why "omit" beats "expose read-only"
|
||
|
||
**Discrete / reload tier:** play mode (Gate|Trigger), pitch engine (Varispeed|Preserve),
|
||
filter enable, filter morph law, pitch-envelope enable, the three Staged|Spline mode
|
||
toggles. **Rebuild tier:** voice count, Poly|Mono, Retrigger|Legato. **Latency-changing:**
|
||
the limiter enable (§3.8 — settled, and its one reopening condition is named there).
|
||
|
||
The scoping pass left three options (gain a live path / expose non-automatable / omit).
|
||
**Omit.** An exposed-but-not-automatable parameter is still listed in the host's generic
|
||
panel and is still *settable from it*, which would let a generic-panel drag reach
|
||
`reloadInstrument` from a host thread — strictly worse than the control being absent. VST3's
|
||
`kIsReadOnly` would prevent that, at the cost of a visible row the user cannot move, in a
|
||
list whose whole purpose is things you can move. Neither beats absence.
|
||
|
||
**Named limitation, stated rather than discovered:** the user cannot automate filter on/off,
|
||
play mode, the pitch engine, Staged↔Spline, or polyphony. The unlock is written down and is
|
||
not this track's: **give the control a live path first, then it qualifies by the same
|
||
rule that excluded it.** Nothing about the parameter system needs to change to admit it.
|
||
|
||
**One door this closes by omission** — if voice count were exposed, its 1–32 ceiling would
|
||
become a frozen `stepCount` and could never be raised. It is not exposed, so it is not
|
||
frozen; a future "expose voice count" proposal must accept that freeze as part of the price.
|
||
|
||
### 7.3 Omitted — structural state
|
||
|
||
The loaded capture and `SampleRefs`; root note; loop start/end, the loop crossfade, and
|
||
**the new loop enable** (Γ-W2-T2, user-owned from that track on); start frame; channel mode;
|
||
the three velocity transfer curves; the three spline contours; the three overlay-select
|
||
radios (view state, never persisted); preview velocity (a utility, not a musical parameter).
|
||
|
||
None of these are scalars with a taper, and several are not scalars at all. They stay in the
|
||
blob, which is exactly what §6.1's split is for.
|
||
|
||
### 7.4 Two promotions the three-state predicate makes available
|
||
|
||
Γ-W2-T1 introduces `NoteOnLatched` for Rate. Two controls that are excluded from the live
|
||
set **for the note-on-latch reason** currently route through the reload tier, and the new
|
||
state fits them exactly:
|
||
|
||
- **Key-track** — `deckParamCommit`'s header already says it *"feed[s] values a voice
|
||
latches at note-on by design (the pitch ratio…), so live delivery would retune… a note
|
||
already struck."* That sentence describes `NoteOnLatched`, not `Reload`.
|
||
- **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."*
|
||
Same shape.
|
||
|
||
**[propose at review, Γ-W4-T1]** promote both. The promotion aligns the routing with the
|
||
predicate's own stated semantics — and it is what makes them automatable, since today they
|
||
would re-decode a WAV per automation point. **If either promotion is refused, that control
|
||
simply drops out of the parameter list.** The list follows the predicate; the predicate is
|
||
never bent to fill the list.
|
||
|
||
**Not promoted, and not proposed for promotion: Rate to Live.** §3.5 records the cost;
|
||
that paragraph is the first thing to read if it is ever proposed.
|
||
|
||
---
|
||
|
||
## 8. One-way doors — the sweep
|
||
|
||
**Deliverable, run 2026-08-01 against `dev`.** Once parameters ship, anything that
|
||
participates in a parameter's normalization is frozen as hard as the id itself. This is the
|
||
complete list of such constants, with a disposition for each. **Two were already known; five
|
||
are new; three are already frozen for an unrelated reason and are recorded so they are not
|
||
"tidied" later.**
|
||
|
||
**Scheduled ahead of the parameter track — action required:**
|
||
|
||
| # | Constant / property | Disposition |
|
||
|---|---|---|
|
||
| 1 | **The ms and semitone tapers** (`ui/deck_values`) | Γ-W1-T1. Known. The taper *is* the host normalization. |
|
||
| 2 | **`kEnvTimeMaxSeconds` / `kGateStageMaxSeconds` 2.0 → 10.0** | Γ-W1-T1, per Daniel's ruling. A range endpoint is normalization. Γ-F3 reversed. |
|
||
| 3 | **Every default must have an EXACT normalized preimage** — NEW | Γ-W1-T1 acceptance criterion. `ParameterInfo::defaultNormalizedValue` is normalized, so a host's reset-to-default arrives as `toPlain(defaultNorm)`. Γ-W1-T1 makes `resetDeckParam` **bypass** the taper, which fixes the editor's reset and **cannot fix the host's** — the host has no bypass. The taper must therefore be designed so every default round-trips exactly, or the host's reset lands a hair off and the "double-click lands exactly on its default" criterion holds on one surface and not the other. **This is the sharpest new finding in the sweep.** **Tightened by Ruling 3 — see §6.7.7**: `defaultNormalizedValue` is computed rather than written, the assertion moves onto `toPlain`, and arbitrary-point round-trip exactness is explicitly NOT required. |
|
||
| 4 | **Do NOT re-taper the filter's four normalized controls** — NEW | Γ-W1-T1 exclusion. Cutoff/Q/morph/drive persist as `cutoffNorm`/`resonanceNorm`/`morphNorm`/`driveNorm` in the **v9 payload** — their laws are *already* wire-frozen today, and re-mapping them would re-tune every saved project independently of automation. §4.2's snap-unit table names them, which is a display concern; nothing in the taper pass may touch their norm↔plain law. **Ruling 3 does NOT conflict with this** — reporting Hz/Q/drive-depth to the host means *calling* the frozen laws, not replacing them; §6.7.5 works it out and names the one additive gap (drive has no published inverse). |
|
||
|
||
**Frozen by this work — record only, no change wanted:**
|
||
|
||
| # | Constant | Note |
|
||
|---|---|---|
|
||
| 5 | `kKeyTrackMax` = 2.0 (0–200 %) | NEW. Becomes the frozen normalization of both key-track parameters. If 0–200 % is ever going to be thought wrong, now is when it is free. Recommendation: **no change.** |
|
||
| 6 | The curve-exponent domain 0.1 … 10 and its log travel (`core/util/curve_law.h`) | NEW. Twelve exposed inner dials. Recommendation: **no change.** |
|
||
| 7 | Master gain's dB taper, −∞ … +24 dB, unity ≈ 0.714 norm (`engine/master_gain`) | NEW. Already a shared pure module, which is why the needle and the audio cannot drift; the host joins as a third consumer. Unity is a default → it is a **#3 case**, and the sharpest one, because a host reset that lands a hair off unity is audible as a gain error. |
|
||
| 8 | `kPitchDepthMaxSemis` / `kVelocityPitchRangeSemitones` = 24.0 | Already frozen — the v12 wire format stores the pitch curve's y as a fraction of it. Now doubly frozen. The plan already forbids minting a second ±24 constant. |
|
||
| 9 | Rate's 50–200 % (±12 st linear) and Pitch's ±24 st centre-expanded laws | Set at birth in Γ-W2-T1, two waves before parameters. **Closed by ordering** — recorded so nobody "tunes Rate's range" in a later phase. |
|
||
| 10 | The parameter INDEX order and the `IUnitInfo` unit ids | NEW, softer than the id freeze but user-facing and cached by some hosts. Set once, at Γ-W4-T1. §6.4. |
|
||
|
||
**Swept and clear — not doors:**
|
||
|
||
- **The params-payload ladder.** Under §6.1 the blob stays authoritative, so payload growth
|
||
continues on its own independent axis exactly as before. Automation adds no constraint to
|
||
it.
|
||
- **`stepCount`.** No discrete parameter is exposed (§7.2), so no step count is frozen — swept
|
||
explicitly over all 44 under Ruling 3 and confirmed: **every one ships `stepCount = 0`**, and
|
||
the coincidence is structural rather than lucky. §6.7.6, which also forbids the specific
|
||
mistake of exposing the editor's shift-snap grid as `stepCount`.
|
||
- **Hold fractions, sustain level, Trigger length.** Natural `[0,1]`, identity taper, nothing
|
||
to freeze beyond the identity.
|
||
- **The limiter's −0.3 dBTP ceiling, `kMaxCurvePoints` = 128, the editor floor/budget
|
||
constants, loop and crossfade frame domains.** None participate in a parameter
|
||
normalization.
|
||
|
||
**Nothing further was found.** The sweep covered: every constant `ui/deck_values` reads or
|
||
writes; every `*Norm` field in the payload ladder; `core/util/curve_law.h`;
|
||
`engine/master_gain`; `engine/play_params.h`'s range constants; and the two range constants
|
||
Phase Γ itself introduces.
|
||
|
||
---
|
||
|
||
## 9. What automation does to the resample bake — and it is not small
|
||
|
||
Ξ-W2's bake ends by **resetting the baked-in parameters to default**, "destructive to the
|
||
dialled settings by design." Exposing those same values as VST3 parameters changes that step
|
||
in two ways. Both are **Γ-W4-T1's** to carry, because Γ-W4-T1 is what creates them.
|
||
|
||
1. **The reset must notify the host.** After §6.1, writing the model without a
|
||
`beginEdit`/`performEdit`/`endEdit` bracket leaves the host displaying — and, on the next
|
||
touch, re-imposing — the pre-bake value. **Every internal writer of a model value that is
|
||
an exposed parameter must go through the one notification path**, and the bake's reset is
|
||
the first non-gesture writer in the codebase. Enumerating those sites is part of the
|
||
track, not a follow-up.
|
||
|
||
2. **A host automation lane outranks the reset, and we cannot clear it.** An automation lane
|
||
lives in the host's project data. If a reset-class parameter carries one, the lane replays
|
||
its curve onto audio that already has that processing baked in — **double processing**,
|
||
and Ξ-W2-T1's own acceptance criterion ("after the click… sounds as the dialled instrument
|
||
sounded just before the click") does not hold in that case.
|
||
|
||
**There is no detection available.** `IAutomationState` reports the host's automation mode
|
||
for the whole plug-in, not per parameter (`ivstautomationstate.h:43-53`), and nothing else
|
||
in the SDK's edit-controller surface answers "is this parameter automated." So "refuse the
|
||
bake when an automated parameter would be reset" and "reset only the un-automated ones"
|
||
are both unbuildable, not merely unattractive.
|
||
|
||
**Disposition: a named, documented limitation.** The bake resets the plugin's values; it
|
||
cannot clear the host's lanes, and the user's remedy is to remove the lane. Stated in the
|
||
product doc and in the track's acceptance criteria as a *known* boundary of the bake's
|
||
fidelity claim — **not discovered later as a bug report against Ξ.**
|
||
|
||
---
|
||
|
||
## 10. Build shape
|
||
|
||
**Γ-W4-T1 `vst3-parameter-set`** — one track, the last in Phase Γ, specified in
|
||
`docs/PLAN.md`. It depends on all three earlier waves: on Γ-W1-T1 for the taper module that
|
||
becomes the host normalization, on Γ-W2-T1 for the three-state predicate and the two new
|
||
controls, and on Γ-W3 for the final control inventory and a bake whose reset list is already
|
||
complete (`docs/PLAN.md`, Γ-W3-T2).
|
||
|
||
**It takes no rung of the params-payload ladder under §6.1** — nothing new is persisted. A
|
||
rung is nonetheless **reserved** for it in case the architecture verification (§6.1's
|
||
`[verify]`) forces a persisted field; if unspent, the rung falls through to the next phase.
|