docs: record Phase Gamma Wave 4's landed track and collapse the wave to it

This commit is contained in:
2026-08-02 20:30:04 -04:00
parent d3f5e6ec17
commit a9c166b8c2
2 changed files with 108 additions and 229 deletions
+72
View File
@@ -1248,3 +1248,75 @@ unlimited — and Daniel has ruled that a future track will change the bake to p
limiter. Until that lands this is a recorded, known limitation, not an oversight. limiter. Until that lands this is a recorded, known limitation, not an oversight.
**Neither track has been verified in a running DAW; both are asserted in CTest only.** **Neither track has been verified in a running DAW; both are asserted in CTest only.**
### Γ-W4-T1 — vst3-parameter-set
The instrument now reports its automatable parameters to the host: 44 of 44 issue, under a
FOREVER-FROZEN `ParamID` table — blocks of 100 per deck group in signal-flow order, steps
of 10 within a block, a curve dial at its outer knob's id + 1 — each with a real plain
range, `units` string and display precision at the host boundary, not a raw normalized
float. The exposed set is DERIVED from `deckParamCommit` / `liveCommitFor`, never
hand-maintained: a control qualifies iff its class is `Live` or `NoteOnLatched`. Everything
else — play mode, the pitch engine, filter enable, the three Staged↔Spline toggles, voice
count, Poly|Mono, Retrigger|Legato, and the limiter enable — is OMITTED from the list
entirely rather than exposed read-only, a named limitation rather than a silent one. A new
pure module, `core/instrument/param` (`param_id`, `param_units`, `param_format`,
`param_live`, `param_merge`), holds the id table, the plain-value layer, the one formatter
per unit category (eight of them), and the audio thread's block-boundary merge decision;
`shell/instrument/instrument_params` adapts it onto `Steinberg::Vst::Parameter` and decides
nothing itself.
**Both VST3 delivery channels are serviced.** An earlier pass routed host automation
through `IEditController::setParamNormalized` alone — the SDK documents that as the
GUI-update channel only ("should update the according GUI element(s) only") — while
`ProcessData::inputParameterChanges` is the audio-side one; the SDK's own
`SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`)
drains the queue in `process()` *and* implements `setParamNormalized`. Both are now
serviced.
**A host automation point's authority is bounded, not permanent.** It outranks the model
only between the point landing and the UI thread folding it into the model and
republishing — at most one UI tick — never a later restore, bake reset, or knob move. An
earlier pass made the hold permanent, which silently defeated `setState`, preset load,
undo, and the bake's reset for any parameter that had ever carried an automation point.
The model is now written down in full — `shell/instrument/CLAUDE.md`'s "THE AUTHORITY
MODEL" section — and enforced by the pure `param_merge`; `test_param_merge` asserts both
halves: that a held point outranks the model until the model catches up, and that a writer
after the release reaches the audio again.
**Two rulings, both Daniel, 2026-08-02.** (1) Pitch key-track and Trigger length promote
from `Reload` to `NoteOnLatched` — the promotion that takes the count to 44 of 44 and
issues ids 1000 and 1450. It was **not** the predicate-only change the plan anticipated:
key-track lives on `InstrumentParams`, not `PlaySeconds`, so the host's write path could
not reach it without `LiveValues` and `foldLive`'s input widening and `Voice::start`
taking the two latched values as arguments beside rate; the new `param::valueHomeFor`
guard closes the class of bug this exposed (a promoted control with no home would have
no-oped silently in both directions) by asserting every exposed control has a home and
branching the shell's own read/write paths on it. (2) The curve-shape dials' ±0.01
snap-to-centre band now applies on the mouse-drag path only, never on a host-facing map —
*"our continuous ranges should be continuous."*
**Two adjacent SDK surfaces were assessed and left unimplemented, with dispositions
recorded rather than re-surveyed later.** `IMidiMapping` — no CC vocabulary fits what's
exposed, and REAPER's own per-parameter MIDI learn is expected to cover the case.
`IParameterFunctionName` and `IAutomationState` are also not implemented; the latter
reports the host's automation mode for the whole plug-in, not per parameter, so it cannot
answer the bake's "is this parameter automated" question.
**The bake's reset now notifies the host, and its one remaining gap is named rather than
hidden.** Every internal writer of an exposed parameter's value goes through the one
`beginEdit`/`performEdit`/`endEdit` path, the bake's reset included. What it cannot do:
clear a host automation lane. If a reset-class parameter carries one, the lane replays its
curve onto audio the bake already baked that processing into — double processing — and
`IAutomationState`'s whole-plugin (not per-parameter) granularity means there is no way to
detect or refuse it. Documented as a boundary of the bake's fidelity claim, not discovered
later as a bug against Phase Ξ.
**The per-sample voice path is byte-identical across the whole track.**
**Not verified in a running DAW — CTest-asserted only.** `docs/TODO.md` carries the
residual DAW-verification items: whether REAPER renders `ParameterInfo::units` beside the
formatted string, whether REAPER's MIDI learn actually covers the un-shipped `IMidiMapping`
case, the three migration round trips (a pre-parameter project, a save/reopen in an older
binary, automation drawn and replayed), whether an offline render replays automation, and
whether REAPER restores instance state through `setState` rather than `setComponentState`.
+36 -229
View File
@@ -1121,243 +1121,50 @@ not own the processor's live block, the limiter DSP itself, the parameter surfac
### Γ-W4 — VST3 parameters ### Γ-W4 — VST3 parameters
**Depends on every earlier wave, and each dependency is a hard prerequisite rather than a **Depended on every earlier wave in this phase** — W1-T1's taper module (the host-facing
courtesy:** normalization itself), W1-T2 and W2-T1's control inventory and three-state commit
predicate (the exposed set is exactly `Live NoteOnLatched`), W3-T1's final MASTER
inventory, and W3-T2's completed bake reset list — each a hard prerequisite rather than a
courtesy. See `docs/COMPLETED.md` for the full narrative of each.
1. **← W1-T1.** The taper and the 10 s ceiling **are** the host-facing normalization, and **The track has landed** — Γ-W4-T1 (`vst3-parameter-set`) — see `docs/COMPLETED.md` for
W1-T1 is also what extracts them into the one pure module the host reads through. Declaring the full narrative. One track: the storage decision governed every part of the work, so a
parameters against a taper that is still moving is the one-way door this whole phase is pure/host-wiring split would have been serial and bought no concurrency.
ordered around.
2. **← W1-T2 and W2-T1.** Every control that could be a parameter must exist before the list
is declared. The list is derived from the control inventory; an inventory still growing
produces a list that has to be re-frozen, and it cannot be.
3. **← W2-T1 specifically.** `deckParamCommit` becoming three-valued is the *prerequisite* of
the classification, not an incidental: the exposed set is exactly `Live NoteOnLatched`.
4. **← W3-T1.** MASTER's inventory (limiter toggle, GR bubble, reserved cell) is the last
change to what controls exist at all.
5. **← W3-T2.** The bake's reset list must already be complete, so this track adds the
host-notification obligation once rather than amending an amendment.
**One track.** The storage decision governs every part of the work — the projection rule, the
migration path, what `getParamNormalized` returns, and what the bake's reset must do — exactly
as Ξ-W2-T1's crossing decision governs its chain. Every candidate split (a pure
model/classification half and a host-wiring half) is **serial**, so it buys no concurrency and
puts the decision on one side of a boundary and its consequences on the other.
#### Γ-W4-T1 — `vst3-parameter-set` #### Γ-W4-T1 — `vst3-parameter-set`
**Goal.** The instrument reports its automatable parameters to the host, under a frozen id **Landed** — see `docs/COMPLETED.md` for the full narrative. The instrument now reports
contract and a logical order — which also hands it REAPER's whole per-parameter modulation its automatable parameters to the host under a FOREVER-FROZEN `ParamID` table (blocks of
block (LFO, envelope follower, MIDI link, parameter linking) for free. 100 per deck group in signal-flow order, steps of 10 within a block, a curve dial at its
outer knob's id + 1) — 44 of 44 issue, derived from `deckParamCommit` / `liveCommitFor`
rather than hand-maintained, each with real units, a plain range and display precision at
the host boundary. A new pure module, `core/instrument/param`, holds the id table, the
plain-value layer, the one formatter per unit category, and the audio thread's
block-boundary merge decision; `shell/instrument/instrument_params` adapts it onto
`Steinberg::Vst::Parameter` and decides nothing.
**Consolidates:** nothing from the seventeen. **Ruling 1** (Daniel, 2026-08-01): *"correct the **Both VST3 delivery channels are serviced** — the controller's `setParamNormalized` and
phase gamma plan to account for complying with the VST3 standard for parameter reporting… by the audio thread's `IParameterChanges` drain, mirroring the SDK's own
the end of gamma we have the automatable params reported. Make the parameter order logical."* `SingleComponentEffect` sample rather than the controller-only delivery an earlier pass
shipped. **A host automation point's authority is bounded**, not permanent: it outranks
the model only until the UI thread folds it in and republishes — at most one UI tick —
never a later restore, bake reset or knob move. `shell/instrument/CLAUDE.md`'s "THE
AUTHORITY MODEL" section states it, `param_merge` enforces it.
**Spec:** `docs/product/parameter-automation.md`**§§610 are the specification; §§15 are **Two rulings, both Daniel, 2026-08-02.** Pitch key-track and Trigger length promote from
the analysis behind it.** Read §6.1 (storage), §6.3 (the freeze), §7 (the classification) and `Reload` to `NoteOnLatched` — the promotion that takes the count to 44 of 44 and issues
§8 (the one-way-door sweep) before scoping. **Today the plugin has zero parameters:** ids 1000 and 1450, and which needed `LiveValues`/`foldLive` widened and `Voice::start`
`ReaSamplerProcessor::initialize` (`reasampler_processor.cpp:56-73`) never populates handed the two latched values, not just a predicate flip; the new `param::valueHomeFor`
`SingleComponentEffect::parameters`, so `getParameterCount()` returns the SDK default 0. This guard closes the class of bug the promotion exposed. And the curve-shape dials' ±0.01
track introduces the whole surface. snap-to-centre band now applies on the mouse-drag path only, never on a host-facing map.
**Surface boundary — owns:** a **new pure parameter-identity module** (the frozen id table, **The bake's reset now notifies the host; the double-processing limitation (a host
the `DeckParam``ParamID` mapping, the derived exposed set, the unit assignment — with its automation lane outranking the reset) is a named, documented boundary, not discovered
own `<module>_tests` target), `shell/instrument/reasampler_processor` + `processor_state` (the later.** `IMidiMapping`, `IParameterFunctionName` and `IAutomationState` are all assessed
`IEditController` parameter surface and the `IParameterChanges` read), and NOT implemented.
`shell/instrument/editor_controls.cpp` and the editor's drag-commit sites (the
`beginEdit`/`performEdit`/`endEdit` bracketing), and the bake's reset step **for the
notification path only**. **Does not own** the taper (W1-T1's module, consumed), any control's
value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
**Behavior.** **Not verified in a running DAW — CTest-asserted only.** `docs/TODO.md` carries the
- **The blob stays authoritative; a parameter is a THIRD SURFACE onto the one model** residual DAW-verification items.
a peer of the deck knob and the overlay node, not a second copy of the value.
`docs/product/parameter-automation.md` §6.1 states the load, host→plugin, plugin→host and
save rules, and the two verified findings that closed the fork: this plugin is a
`SingleComponentEffect`, where the SDK itself collapses `IComponent::setState` and
`IEditController::setState` (`vstsinglecomponenteffect.h:41-47`), so there is one state and
§3.3's drift hazard describes a split-component design we do not use; and the blob is a
**cross-artifact contract** the extension's `instrument_drop` writes, which
parameters-as-truth would silently make partial.
- **`ParamID` is an independent, hand-assigned, FOREVER-FROZEN table** — blocks of 100 per
deck group **in signal-flow order** (Γ-F7), steps of 10 within a block, a curve dial at its
outer knob's id + 1, blocks starting at 1000. **The full 44-id assignment is stated at
§6.2** and is to be transcribed, not re-derived. §6.2 also for why hand-assignment beats
derivation and why the within-block order is seeded ONCE rather than tracked against
`cellIds`; **§6.3 for the freeze invariant, which is to be stated in the table's header with
the same force as the command-id strings, the class UIDs and the payload field order.**
- **The exposed set is DERIVED from `deckParamCommit` / `liveCommitFor`, never
hand-maintained** — a control is a parameter iff its class is `Live` or `NoteOnLatched`.
**44 parameters** at the end of Γ-W3, enumerated by group in §7.1.
- **Everything else is OMITTED from the list entirely**, not exposed-and-flagged: the reload
and rebuild tiers, all structural state, and the limiter enable (§3.8, settled). §7.2
states why omission beats `kIsReadOnly`, and names the limitation plainly — the user cannot
automate filter on/off, play mode, the pitch engine, Staged↔Spline or polyphony, and the
unlock is to give the control a live path first.
- **Every exposed parameter reports REAL UNITS to the host** (Ruling 3). Each declares a
**plain range**, a **`units` string**, and a **display precision**; the complete
eight-category table covering all 44 — ranges, units, precision, and which taper each
category carries — is `docs/product/parameter-automation.md` §6.7.1, and it is a
specification, not a suggestion. VST3's wire format stays normalized (it cannot be
otherwise); the requirement is met through the **plain-value layer** the SDK provides, whose
direct precedent in the vendored tree is `public.sdk/samples/vst/common/logscale.h:221-229`
overriding `toPlain`/`toNormalized` for a log law — the exact shape our log ms and log2
semitone knobs need.
- **`normalizedParamToPlain` / `plainParamToNormalized` / `getParamStringByValue` /
`getParamValueByString` route through W1-T1's taper module and the ONE formatter per unit
category.** Three functions that agree today is a defect; the host's normalization, the
needle angle and the overlay node must be the same function. **`toNormalized` IS the
taper** — §6.7.3 — which is what makes the phase's one-way-door ordering structurally true
rather than a warning someone has to remember.
- **`stepCount = 0` on all 44, and the editor's shift-snap is never exposed as `stepCount`.**
Snapped drag and parameter continuity are independent axes; `stepCount` quantizes the
parameter permanently, including for the host's automation, and freezes into the forever
contract. The sweep is done and clean (§6.7.6) — every discrete control is reload or rebuild
tier and therefore already omitted, so continuity is structural rather than lucky.
- **`IParameterChanges` is observed at BLOCK boundaries, stated in the header** — the last
point in a block wins. 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.
- **A host parameter change takes the control's existing commit tier and no other.** Nothing
on the automation path may reach `reloadInstrument` or `rebuildVoiceEngine` — which §7.2's
omissions guarantee structurally rather than by care.
- **`IUnitInfo`: one unit per deck group**, mirroring the group inventory rather than the
editor's rows. **Order is signal flow — Γ-F7, RULED** (§6.4). Presentation index order is
ascending id, so identity and presentation agree by construction.
- **The filter's four report plain units WITHOUT being re-tapered.** `toPlain` is read-side
only; reporting Hz / Q / drive depth means calling `filterCutoffHzFromNorm`,
`filterQFromNorm` and `filterDriveDepthFromNorm` — the filter module's own frozen laws,
which `deckValueLabel` already calls today — not restating them. **The one additive piece:
`filterNormFromDriveDepth` does not exist and must be added in `filter_params`**, beside the
two inverses that do; the analytic inverse of a frozen law is not a change to it. §6.7.5.
- **No `kIsBypass` on anything.** The plugin is an instrument and exposes no bypass
parameter; the limiter is a safety device, not a bypass, and binding it there would hand
the host a control that restarts the component.
- **The bake's reset gains a notification obligation** (§9): every internal writer of a value
that is an exposed parameter must go through the one `beginEdit`/`performEdit`/`endEdit`
path, and the bake's reset is the codebase's first non-gesture writer. **Enumerating those
sites is part of this track**, not a follow-up.
- **Two adjacent SDK surfaces are assessed, with dispositions, so they are not re-surveyed:**
`IMidiMapping` is **in scope and nearly free** (a CC → `ParamID` map, one function);
`IParameterFunctionName` is **not implemented** (its vocabulary is compressor/panner
semantics that name nothing here); `IAutomationState` is **not implemented** (it reports the
host's automation mode for the whole plug-in, not per parameter, so it cannot answer the one
question §9 would have wanted it for).
**Acceptance criteria.**
- **The host lists exactly the derived set, in the ruled order, with no parameter the
predicate does not classify `Live` or `NoteOnLatched`** — asserted against the predicate,
not against a literal count.
- **Every id in the table is asserted unique, in its group's block, and on its step** — and a
test fails if any id changes value, which is what makes the freeze mechanical rather than
cultural. **The asserted values are §6.2's table verbatim**, including the signal-flow block
sequence; a test that recomputes the ids from `cellIds` would defeat the freeze it exists
to hold.
- **`toPlain(info.defaultNormalizedValue)` compares EXACTLY equal to the default**, per
parameter, against a default-constructed `PlaySeconds` (and against `master_gain`'s unity),
so a host's reset-to-default and the editor's double-click land on the same value.
**`defaultNormalizedValue` is computed as `toNormalized(default)`, not written as a
literal** — a grep finds no normalized default constant. If the exactness fails, it is a
W1-T1 defect surfacing here, not a defect of this track. **Round-trip exactness at
arbitrary values is NOT asserted** — it is not required (§6.7.7) and asserting it would
over-constrain the taper.
- **`getParamStringByValue` prints what the editor's knob label prints**, digit for digit, at
the same stored value, across every unit category — asserted by calling **the same
formatter** from both sides in one test, not by comparing two independently produced
strings. **A grep finds exactly one formatter per unit category** and no `snprintf` of a
parameter value outside it.
- **Every exposed parameter's `units` and plain range match §6.7.1**, asserted per parameter;
`stepCount` is asserted **zero on all 44**.
- **A host automation lane moving a Live parameter moves a sounding note; a lane moving a
NoteOnLatched parameter takes effect on the next note and does NOT trigger a reload or an
engine rebuild** — assert the tier, not just the sound. All three NoteOnLatched controls now
have a delivery test in `tests/test_live_delivery.cpp` (rate, key-track, Trigger length), each
asserting BOTH halves: the sounding note byte-identical, the next note taking the value.
- **A host automation point's AUTHORITY IS BOUNDED**, and its release is where both review passes
went wrong: it outranks the model only until the UI thread has folded it in and republished.
`shell/instrument/CLAUDE.md`'s Authority section states the model, `param_merge` enforces it,
`test_param_merge` fails if a hold is never released or is released too early.
- **No automation path reaches `reloadInstrument` or `rebuildVoiceEngine`.**
- **A project saved before this change opens with every parameter reading the blob's value
and sounds identical**; a project saved by this build opens in an older binary with its
sound intact; and a project with automation drawn, saved and reopened, replays against the
same plain values.
- **`process()` takes no new indirection and no new per-sample work** — the parameter read is
a block-boundary act. **Correction to the original wording ("on the existing live-publish
path"):** it cannot be, and the SDK is what decides that. `IParameterChanges` is delivered ON
the audio thread, and the model's publish path allocates (`resolvePlay` copies velocity curves
and spline contours), so the drain lands in `process()` and patches the live block in place.
The block-boundary rule is unchanged and the per-sample path is untouched; what moved is which
thread performs the fold.
- **The bake's reset notifies the host**, verified by the host's displayed value following it
rather than snapping back on next touch.
- **The double-processing limitation is documented, not discovered** — a bake whose
reset-class parameter carries a host lane is a named boundary of the bake's fidelity claim
(§9), stated in the product doc and in this track's review.
- **The lane-linearity consequence is stated in the header, not left to be found** — under a
tapered parameter a straight line drawn in a host automation lane is **not** linear in the
plain unit (exponential in ms, linear in octaves on cutoff, linear in dB on master gain).
This is standard and desirable, it follows directly from Ruling 3 plus the taper, and §6.7.4
gives it per category. Writing it down is the acceptance criterion; changing the taper to
avoid it is not an option.
- **The filter's four are proven untouched**: a regression baseline shows their audio
unchanged, and their persisted `*Norm` values are byte-identical across a save/reload that
passes through the parameter surface. Reporting Hz/Q/depth changed display only.
**Open questions.**
- **No [Daniel] questions. Γ-F7 is RULED — signal flow** (2026-08-01, *"signal flow order."*),
and Ruling 3 (real units) arrived specified rather than forked. **There is no unanswered
[Daniel]-class question in this track or anywhere in this plan.**
- **CLOSED from the SDK, not the DAW.** Two things were bundled here and they separate.
(a) The DELIVERY CHANNEL: `ivsteditcontroller.h` documents `setParamNormalized` as the
GUI-update channel ("should update the according GUI element(s) only"), and the SDK's own
`SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains
`ProcessData::inputParameterChanges` in `process()` while also implementing
`setParamNormalized`. **Both are serviced.** This was never a DAW question — the headers
answer it, and building on the paragraph alone is exactly what the first pass did.
(b) The ORDERING of `setState` against the first parameter block: no longer a question, but not
for the reason the second pass gave. A point held by the audio thread is re-applied over every
merge only until the UI thread has folded it into the model — the hold is bounded, and a lane
that is genuinely DRIVING re-sends and so keeps outranking the restore, while a lane that sent
one point and had it folded does not. That is the correct reading of the rule "a lane in
read/write mode outranks a plug-in-side set" (stated in `docs/product/parameter-automation.md`
§6.1 as reasoning from the host's replay behaviour, not from a header — the SDK does not spell
it out). The second pass's unbounded latch made the claim true by making every later writer
permanently deaf; `shell/instrument/CLAUDE.md`'s Authority section is the model now.
(c) **STILL OPEN, and it is the half the bundled `[verify, FIRST]` originally asked**: that
REAPER calls `setState` (not `setComponentState`) on a single-component plug-in — a state
ENTRY-POINT question, not a delivery-channel one. Recorded in `docs/TODO.md` rather than closed:
`vstsinglecomponenteffect.h:41-47` does collapse the names as §6.1 claims, our overrides land on
the `IComponent` pair with `setEditorState`/`getEditorState` left at the base's `kNotImplemented`,
and the blob has shipped through payload v1…v16, so the behaviour is very likely fine — but that
is inference, not observation. The rest of the DAW work is in `docs/TODO.md` too, and none of it
can change the frozen contract.
- **[verify]** whether REAPER renders `ParameterInfo::units` beside the string
`getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own
convention** — digits in the string, unit carried separately, which is what
`RangeParameter::toString` and the `Parameter` constructor's signature both express. 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 id table nor the editor, because
display strings are explicitly not frozen (§6.7.1). Do not discover this after shipping.
- **RULED (Daniel, 2026-08-02): promote both.** **Key-track** and **Trigger length** move from
`Reload` to `NoteOnLatched` (§7.4) and are exposed; ids **1000** and **1450** issue and the
count is **44 of 44**. **The promotion is NOT the predicate-only change this bullet originally
advertised** — the predicate flip is the smallest part of it. Key-track lives on
`InstrumentParams`, not `PlaySeconds`, so the host's write path (`setDeckParam`/`deckParamNorm`)
structurally could not see it and id 1000 would have no-oped in both directions with no
compile-time guard; both controls also had to reach the engine, which meant widening
`LiveValues` and `foldLive`'s input and handing `Voice::start` the two latched values as
arguments beside the rate. The guard that closes the class is `param::valueHomeFor` — asserted
over the exposed set (every control has a home, the instance-scalar set has exactly two
members) AND branched on by the shell's own read and write paths, so the three cannot drift.
- **[propose at review]** whether to ship a default `IMidiMapping` CC table here or leave MIDI
control to REAPER's host-side learn. Either is defensible; **skipping it silently is not.**
- **[propose at review]** whether this track spends the reserved payload rung. §6.1 says
nothing new is persisted and therefore it should not; if the `setState` verification says
otherwise, it takes the reserved rung and says so.
- **Closed, do not reopen:** Rate lifted from latched to live (§3.5 records the cost); the
limiter enable made automatable (§3.8 — its one reopening condition was the reload/activation
decoupling, which landed in Γ-W3, so the condition is discharged rather than pending).
--- ---