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

Two-row deck reflow (sound/contour), double-height MASTER with limiter and
meter, PITCH/RATE deck, unit-driven knob law, contour-trace fix, and a
re-approached loop/crossfade UX. Folds rulings Γ-F1..Γ-F5; opens Γ-F6.
This commit is contained in:
2026-08-01 16:29:32 -04:00
parent 6c982cd617
commit eb777f55e1
4 changed files with 2316 additions and 44 deletions
+749 -8
View File
@@ -2,7 +2,9 @@
The post-1.0 roadmap. Seventeen queued items consolidated into overlapping areas and The post-1.0 roadmap. Seventeen queued items consolidated into overlapping areas and
sequenced into a Phase → Wave → Track hierarchy that implementation specialists can be sequenced into a Phase → Wave → Track hierarchy that implementation specialists can be
dispatched against directly. dispatched against directly**plus Phase Γ**, which did not come from those seventeen
(it came from a direct interview, 2026-08-01) and is scoped in
`docs/product/instrument-control-surface.md`.
## What this doc is, and how it relates to the others ## What this doc is, and how it relates to the others
@@ -23,8 +25,8 @@ dispatched against directly.
cited section rather than reading a file whole. cited section rather than reading a file whole.
**Worktree slug convention:** `p<phase>-w<wave>-t<track>-<slug>`. Greek phase letters **Worktree slug convention:** `p<phase>-w<wave>-t<track>-<slug>`. Greek phase letters
transliterate: **Θ → `th`**, **Ξ → `xi`**. So Θ-W1-T1 dispatches into transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**. So Θ-W1-T1 dispatches into
`pth-w1-t1-zone-retirement`. `pth-w1-t1-zone-retirement` and Γ-W1-T1 into `pg-w1-t1-knob-interaction-law`.
## Decision state ## Decision state
@@ -39,8 +41,13 @@ full narrative. **Reload tier = Grouping B** (continuous knobs live: filter cuto
morph/drive/mod amount/key-track, every envelope stage time and level; root note, loop morph/drive/mod amount/key-track, every envelope stage time and level; root note, loop
span, and start frame still trigger a full reload). **Mid-stage rule = candidate (iv), span, and start frame still trigger a full reload). **Mid-stage rule = candidate (iv),
hold normalized stage position** (φ = elapsed/duration held fixed across a duration hold normalized stage position** (φ = elapsed/duration held fixed across a duration
change, then advancing at 1/newDuration). No other track in this plan currently carries change, then advancing at 1/newDuration). **Phase Γ opened five [Daniel]-class forks (Γ-F1…Γ-F5) and all five are ruled** (Daniel,
an unanswered **[Daniel]**-class question. 2026-08-01) — the rulings are folded into the tracks below and indexed in
`docs/product/instrument-control-surface.md` §8.1. **One new fork, Γ-F6, is open**: it was
surfaced by the Γ-F2 ruling out of the vendored VST3 SDK, and Daniel has not seen it. It sits
on Γ-W1-T2, is **not blocking** — it is gated on a DAW measurement that track performs first
— and it has a pre-agreed fallback. No other track in this plan carries an unanswered
**[Daniel]**-class question.
### Flagged for awareness — not blocking, but decision-grade ### Flagged for awareness — not blocking, but decision-grade
@@ -54,10 +61,28 @@ an unanswered **[Daniel]**-class question.
verification, item 15's "one click from inside the VST" framing is what gives, not verification, item 15's "one click from inside the VST" framing is what gives, not
the read-only invariant — the fallback is a bindable extension-side action. the read-only invariant — the fallback is a bindable extension-side action.
2. **Phase Γ must land before Ξ-W2, and this is a correctness point, not a preference.**
Ξ-W2's settled reset scope enumerates parameters by name; Γ adds rate, pitch offset and
the limiter flag, so shipping the bake first means its reset list is incomplete on the
day it lands. Γ's product doc pre-classifies all three against the ratified rule (all
**reset**), so this costs no Daniel decision — only ordering. Second, weaker reason: Γ
owns params-payload v14 and v15, and Ξ-W3's programmed-signal persistence will want the
next rung; two phases contending for the ladder is the fight Θ's organizing constraint
exists to avoid.
3. **The taper work inside Γ-W1-T1 is a one-way door with respect to automation.** Once
VST3 parameters exist, the taper *is* the host-facing normalization, and re-tapering
re-interprets every recorded automation point in project files we do not own and cannot
migrate. Re-tapering is free today and permanently expensive afterwards. See
`docs/product/parameter-automation.md` §4 — that doc is scoping only, nothing in it is
scheduled here. **The same door applies to the stage-time ceiling**, which Γ-F3 left at
2.0 s with a 10 s ambition recorded in `docs/TODO.md`: if that ceiling is ever raised, it
wants to happen before the parameter system, not after.
## Phase-wide acceptance criteria ## Phase-wide acceptance criteria
These bind every track in both phases and are stated once here rather than repeated These bind every track in all three phases and are stated once here rather than repeated
per track. per track. **Phase Γ adds five of its own**, stated in its phase header.
### Structural (root `CLAUDE.md`, Daniel 2026-07-28) ### Structural (root `CLAUDE.md`, Daniel 2026-07-28)
@@ -613,6 +638,702 @@ must be closed.
--- ---
## Phase Γ — The instrument's control surface
**Ships:** the deck reflowed into two categorical rows with a double-height MASTER bus deck,
a PITCH/RATE deck with playback-rate and baseline-pitch controls, a master limiter with
dynamic reported latency and a real output meter, one consistent knob interaction/taper law
across every variable control, a fix for staged contour traces drawing straight, and a
re-approached loop/crossfade marker UX under an explicit chrome-row loop enable.
**Consolidates:** none of the seventeen. Phase Γ came from a direct interview with Daniel
(2026-08-01); the product reasoning, the measured layout table, the invariant collisions and
the fork rulings are in **`docs/product/instrument-control-surface.md`**. Read §1.2 (the
layout table) and §7 (collisions) before dispatching any track here — every number in this
phase is derived there, and `docs/TODO.md`'s old deck-rework geometry is superseded.
**Fork state.** The phase's five original forks are **all ruled** (Daniel, 2026-08-01) —
indexed at spec §8.1, folded into the tracks below:
- **Γ-F1** — `kEditorMinHeight` stays **680**.
- **Γ-F2** — the limiter has **lookahead with DYNAMIC reported latency** (zero when off,
the lookahead when on, reported to host PDC). *This inverted the product recommendation;*
W1-T2's scope grows accordingly — spec §3.1.1.
- **Γ-F3** — the stage-time ceiling stays **2.0 s** in this phase. The 10 s ambition is
carried in `docs/TODO.md` with its rationale and its prerequisites.
- **Γ-F4** — there **is** an explicit loop enable, and it lives on the **chrome row**, not
in a deck. W2-T2's scope grows accordingly — spec §6.4.
- **Γ-F5** — MASTER's reserved slot is **one** cell. The 90 px headroom argument behind
that is spec §1.6 and governs every future control addition.
**One fork remains open — Γ-F6, on W1-T2** (spec §8.2): the VST3 SDK defines
`restartComponent(kLatencyChanged)` as a host **deactivate/reactivate**, and this plugin's
`setActive` is destructive in both directions. Not blocking; gated on a DAW measurement W1-T2
performs first, with constant reported latency as the pre-agreed fallback.
**Sequencing against Phase Ξ — Γ runs BEFORE Ξ-W2.** Two reasons, both the same shape as
Ξ's own stated gate:
1. **The bake bakes the control surface.** Ξ-W2's settled reset scope enumerates parameters
by name; Γ adds rate, pitch offset and the limiter flag. Shipping Ξ-W2 first means its
reset list is incomplete on the day it lands. (Γ's doc §3.4 pre-classifies all three
against the ratified rule — all **reset** — so this is a sequencing point, not a new
Daniel question.)
2. **One params-payload ladder.** Γ takes v14 and v15. Ξ-W2 does not currently bump the
payload, but Ξ-W3's programmed-signal persistence will, and two phases contending for the
ladder is exactly the fight Θ's organizing constraint calls out.
**The organizing constraint.** Four surfaces are single-writer and dictate the wave shape:
`ui/deck_values.cpp` (the taper law, then the two new controls), `editor_paint_waveform.cpp`
(the contour trace, then the loop marks), the deck descriptors in `ui/deck_groups.cpp` +
`ui/knob_deck.cpp` (the new group, then the reflow), and the params-payload ladder. **Every
wave boundary below is one of those collisions**, not a preference. Where a wave has more
than one track, the tracks are disjoint by surface.
**Phase-wide acceptance criteria** (in addition to the ones stated at the top of this file):
- **Bypassed means byte-identical.** With the limiter off, the per-sample output path is
byte-identical to today's bare ramped multiply — the same discipline that makes
`live == nullptr` byte-identical to the pre-live core and the filter's exact skip at
`modAmount == 0` hold the at-rest path unchanged.
- **No `ComponentState` sound change.** A project saved before this phase reopens sounding
identical: absent rate lifts to 100 %, absent pitch offset to 0 st, absent limiter flag to
bypassed. Re-tapering a knob (Γ-W1-T1) changes needle angles only — the payload stores raw
engine doubles, so saved values reload bit-identical.
- **`kVelocityPitchRangeSemitones` / `kPitchDepthMaxSemis` (24.0) does not move.** It is
load-bearing in the v12 wire format. The new Pitch knob **reads** it; it does not mint a
second ±24 constant.
- **The window floor is 1190 × 680 and must not exceed 1280 × 720.** A track that pushes the
floor past 1280 has failed, not overrun. **`kEditorMinHeight` stays 680** (Γ-F1). The
remaining **90 px of width headroom is the budget for the life of this layout** — one deck
cell is 60 px, so there is room for exactly one more, once. Spec §1.6 states the ledger;
read it before adding any control. Chrome-row additions are a **separate purse** (they are
paid for out of the title slot, not the floor) and must not be charged against this one.
- **Reported latency is zero unless the limiter is on.** `getLatencySamples()` returns 0 with
the limiter bypassed, in every track and at every point in the phase. Only W1-T2 may
introduce a non-zero value, and only under the limiter-on condition.
- **Geometry stays pure.** Every new layout, cap, label and hit-test rule lands in a pure
CTest-covered module (`knob_deck`, `sample_bands`, `waveform_view`), never in a painter.
---
### Γ-W1 — Foundations
**Depends on:** nothing in this phase. All three tracks are disjoint by surface — T1 owns
editor input and the value/taper layer, T2 owns the engine and processor, T3 owns the
waveform painter's envelope trace. None touches the deck descriptors.
#### Γ-W1-T1 — `knob-interaction-law`
**Goal.** One consistent, unit-category-driven interaction and taper rule across every
variable control, landed **before** any new control is added so the new ones are authored
into it rather than retro-fitted.
**Spec:** `docs/product/instrument-control-surface.md` §4.
**Surface boundary — owns:** `core/instrument/ui/deck_values` (the taper maps, the
snap-unit table, `resetDeckParam`), `core/instrument/ui/param_slider` (the drag law),
`shell/instrument/editor_input_*` (modifier read + re-anchor), and the modifier-reading
helper the three input paths share. **Does not own** any deck descriptor, any parameter, or
the waveform painter.
**Behavior.**
- **Shift snaps to whole numbers in the control's displayed unit**; **Ctrl scales the drag by
0.05**; **Shift+Ctrl = Shift wins** (Ctrl is ignored — with an integer-quantized output a
finer drag yields the same sequence, so this is identity, not a compromise).
- **Snap unit by category:** ms knobs → whole ms; semitone knobs (incl. Rate, when it
arrives) → whole semitones; percent/fraction knobs → whole percent; the 12 curve-exponent
inner dials → whole numbers (which puts 1.0, the linear neutral, one snap away); master
gain → whole dB; already-integer controls unchanged. Full table in the spec §4.2.
- **Mid-drag modifier transitions re-anchor** — on every press *and* release during an active
drag, the current value becomes the anchor value and the current cursor position the anchor
position. The value is continuous across the transition; only the rate changes. Without
this the grab-anchored absolute drag (`kKnobDragRangePixels = 128`) jumps by
`(1 0.05) ×` the accumulated delta.
- **Millisecond knobs become log-scaled.** Exactly 0 s at norm 0 and exactly
`kEnvTimeMaxSeconds` at norm 1, monotone throughout; **10 ms lands within 0.120.20 of
travel and 100 ms within 0.420.52**. The ceiling stays **2.0 s** — it reads
`kGateStageMaxSeconds`, which the AHDSR overlay's schematic scale is derived from, and the
two must agree.
- **Semitone knobs become log2/centre-expanded.** Symmetric, exactly 0 at centre, exactly
±`kPitchDepthMaxSemis` at the ends, monotone; **±7 st reached at 5058 % of each
half-travel**.
- **`resetDeckParam` bypasses the taper** — it writes the default value directly instead of
round-tripping through `norm → value`. This *removes* the power-of-two dependency the
header currently documents rather than working around it; that comment
(`deck_values.h:42-46`) becomes wrong and must be rewritten.
- **Scope is the parameter, not the widget.** Deck knobs (outer ring and inner dial),
envelope stage nodes and curve knots all honour it — they are surfaces onto one model, and
a snap on one but not the others is a divergence. **Waveform markers are explicitly
excluded**: they carry a shipped zero-crossing snap on the same modifier space and their
domain is frames.
**Acceptance criteria.**
- Every taper change is verified **persistence-neutral**: a project saved before the change
reopens with bit-identical stored values and identical audio; only needle angles move.
- Holding Shift mid-drag on each unit category lands the documented whole unit; releasing it
does not jump the value.
- Holding and releasing Ctrl mid-drag is continuous — no step at either transition.
- Double-clicking any knob (outer ring and inner dial independently) lands **exactly** on its
default at every taper, verified against a default-constructed `PlaySeconds` rather than a
round trip.
- The log/log2 landmark positions above are asserted in `deck_values`' own tests.
- One shared modifier-read helper serves all drag surfaces; no second modifier grammar exists.
**Open questions.** None **[Daniel]** — fork Γ-F3 is ruled: **the ceiling stays 2.0 s.** The
10 s ambition Daniel described (*"a horrifically long decay with tight exp"*) is carried as a
`docs/TODO.md` entry, and **this track lands both of its prerequisites**: the log taper (which
is what makes a higher ceiling usable at the low end rather than unusable) and the reset
bypass (which retires the power-of-two dependency — 2.0 is a power of two, 10.0 is not).
**Neither is optional on that basis alone** — they are already required by this track — but
the engineer should know the reset bypass is doing double duty, and should not "simplify" it
back into a norm round-trip.
#### Γ-W1-T2 — `master-bus-audio`
**Goal.** The master limiter and the meter's **audio and publication halves**, plus **the
plugin's first latency reporting** — no editor drawing. Landing the audio ahead of the deck is
what lets Γ-W3 draw against real published state instead of a stub.
**Spec:** `docs/product/instrument-control-surface.md` §3.1, **§3.1.1 (latency — read this
first)**, §3.23.3, §3.5, §7.10, §8.2.
**Surface boundary — owns:** a new pure limiter module and a new pure meter-ballistics
module under `core/instrument/engine/` (each with its own `<module>_tests` target),
`shell/instrument/reasampler_processor` (the chain, the published block state, **and the
`getLatencySamples` / `restartComponent(kLatencyChanged)` path**), and **params payload v14**
(the limiter enable flag). **Does not own** MASTER's deck geometry or any drawing — that is
Γ-W3-T1.
**Behavior.**
- **Chain:** `voice mixer → master gain (existing ramped multiply) → limiter (bypassable) →
output bus`, with the meter tapped at the **bus output, post-limiter**.
- **Limiter: a single toggle, no configurable controls.** Baked ceiling **0.3 dBTP**.
Default **off**. **No makeup gain, ever, of any kind** — transparent at rest.
Stereo-linked detection (max |L|,|R| drives one gain) so the image is not moved.
- **True-peak detection is sidechain-only** — an oversampled detector in the sidechain, never
oversampling the signal path. Factor is the engineer's call under the measure gate.
- **Lookahead, with DYNAMIC reported latency (Γ-F2, ruled).** `getLatencySamples()` returns
**0** when the limiter is off and **the lookahead in samples** when it is on; the toggle
calls `IComponentHandler::restartComponent(kLatencyChanged)`. **None of this exists today** —
there is no `getLatencySamples` override, no `kLatencyChanged`, and no `restartComponent`
call site anywhere in `src/`; the plugin ships the SDK default of 0. This track introduces
the plugin's first latency reporting.
- **The restart is the risky part and it is fenced.** The SDK defines `kLatencyChanged` as a
host **deactivate/reactivate** (`pluginterfaces/vst/ivsteditcontroller.h:105-108`), and this
plugin's `setActive` is destructive in both directions (`reasampler_processor.cpp:85-109`) —
deactivate frees every sounding voice, reactivate re-decodes the WAV. Four requirements, all
acceptance criteria:
1. **Verify the whole call sequence against the vendored Steinberg SDK** before writing it,
including the ordering rule that the new latency is what `getLatencySamples` returns
*after* `setActive(true)` — so **the reported value must derive from persisted state, not
from a transient the deactivate clears.**
2. **Prove the restart does not disturb the output bus arrangement.** The output stays one
permanently-stereo bus, never renegotiated.
3. **Ship a regression test in the spirit of `testDualMonoStereoSampleRendersCentered`** —
a dual-mono capture rendered across a limiter toggle stays centered, L ≡ R.
4. **`restartComponent` is never called from `process()`.** Main/UI thread only, and
coalesced so repeated clicks produce one restart per settled state.
**This is NOT the change `reasampler_processor.cpp:66-68` forbids.** That warning is against
reintroducing per-mode **bus** renegotiation (`kIoChanged` class), which panned a dual-mono
capture hard right in the host's pin re-routing; `kLatencyChanged` is a different flag and the
bus is untouched. But the precedent — mid-session `restartComponent` in this plugin has
already shipped one real regression — is exactly why (2) and (3) are non-negotiable.
- **Flipping the toggle during playback: apply immediately, do NOT defer to a transport
boundary** (product ruling, spec §3.1.1). A deferred restart leaves the plugin misaligned by
the lookahead with no visible cue, which is worse than a visible interruption; and the host,
not the plugin, schedules the deactivate/reactivate anyway. Two mitigations are in scope:
the engage/disengage is covered by a short (≤ 10 ms) equal-gain crossfade so **the plugin
emits no discontinuity of its own**, and the limiter enable is classified **not automatable**
(`docs/product/parameter-automation.md` §3.8) so nothing can flip it at rate. It is also
**not** the plugin's `kIsBypass` parameter.
- **Per block the processor publishes, as relaxed atomics:** per-channel peak `max|x|`, a
latched clip flag, and the block's maximum gain reduction. **No dB conversion, no
ballistics, no hold timers on the audio thread** — the UI converts and runs ballistics from
block peaks and elapsed time. This widens the existing advisory-peak pattern
(`reasampler_processor.h:109-113`), which is not reusable as-is.
- **Meter ballistics (pure, unit-tested):** instantaneous rise; **fall 20 dB/s**; peak-hold
latched at the running max, **held 1.5 s**, then falling at the same rate; scale **linear in
dB over 60…+6 dBFS**; clip latches at block peak ≥ 0 dBFS and is cleared on request.
- **`ComponentState` payload v14** appends the limiter flag as a strict suffix on the existing
discipline; a v13 blob is a strict prefix and lifts to bypassed.
**Acceptance criteria.**
- **With the limiter bypassed the rendered output is byte-identical to the pre-change build**,
asserted by a regression baseline, not by ear.
- With the limiter engaged, no output sample exceeds the ceiling on program material that
exceeds it by up to +12 dB; with it bypassed and gain driven, the output does exceed
0 dBFS (proving the toggle is doing the work).
- **Nothing is louder at rest with the limiter on.** A signal that never reaches the threshold
is bit-identical engaged and bypassed.
- No allocation, no lock, no transcendental on the per-sample path; the measure-and-report
gate reports per-voice-block CPU with the limiter engaged at 32 voices.
- The meter-ballistics module is pure and CTest-covered: rise, 20 dB/s fall, 1.5 s hold, clip
latch/clear, and the dB↔pixel map are all asserted without a host.
- A project saved before this change reopens with the limiter bypassed and sounding identical.
- **`getLatencySamples()` returns exactly 0 with the limiter off**, and the lookahead in
samples with it on — asserted against the persisted flag, and correct across a
deactivate/reactivate cycle.
- **A dual-mono capture rendered across a limiter toggle stays centered** (L ≡ R), and the
output bus arrangement after a latency-change restart is identical to before it.
- **The plugin emits no hard step at the toggle** — the engage/disengage crossfade is asserted
on a rendered signal, not judged by ear.
**Open questions.**
- **[Daniel] fork Γ-F6 — is the deactivate/reactivate cost acceptable?** (spec §8.2.) **Not
blocking, and this is the track's FIRST deliverable:** a verification spike in REAPER —
flip the limiter with notes held, during playback and while stopped, and **record what
actually happens** (do notes cut? is the re-decode perceptible? does transport hiccup?).
The SDK mandates the deactivate/reactivate; what REAPER does with it is DAW-verifiable only.
Ship the ruled dynamic-latency design regardless; if the observed behaviour is as ugly as
the SDK's worst case allows, the **pre-agreed fallback is constant reported latency** (the
delay line engaged whether or not the limiter is on), which needs one word from Daniel and
**no DSP change** — only the latency-reporting predicate moves. Report the measurement to
Daniel with a recommendation; do not choose the fallback unilaterally.
- **[verify]** `temp_cortex/` has already been assessed and **rejected** (spec §3.5) — do not
re-litigate it, and do not transplant from it.
- **[verify]** whether `setActive(true)`'s `reloadInstrument()` can reuse the already-decoded
`SampleData` on a latency-restart reactivate rather than re-reading the bridge and
re-decoding the WAV. If it cannot cheaply, that is an **accepted cost to be recorded**, not
a reason to restructure `setActive` — its destructive shape is deliberate and its reasoning
(ghost sustained voices on reactivate) is documented at the call site.
#### Γ-W1-T3 — `contour-trace-curves`
**Goal.** Staged envelope segments draw as the curve their exponent defines, so the
mid-segment knot stops floating off its own trace.
**Spec:** `docs/product/instrument-control-surface.md` §5.
**Surface boundary — owns:** `shell/instrument/editor_paint_waveform.cpp`'s staged-envelope
trace and any pure tessellation helper it needs. **Does not own** the loop/crossfade marks
(Γ-W2-T2), `envelope_overlay`'s vertex model, or the drawn-EG (spline) trace.
**Behavior.** The defect is verified: `editor_paint_waveform.cpp:218` drops knots
(`if (v.knot) continue;`) and joins the remaining vertices with straight strokes, and
`curveMap` is never called in the paint path even though the exponent is in scope at `:211`.
Knot *positioning* already honours the exponent via `curveMidLevel`
(`envelope_overlay.cpp:94-105`) — that divergence is the visible symptom. The fix draws each
sloped stage through **the same `curveMap` the audio uses**, so trace and sound cannot
diverge; tessellation approach is the engineer's call.
**Acceptance criteria.**
- **At every exponent the knot's centre lies on the trace, within 1 px** — the reported defect,
stated as the gate.
- **At exponent 1.0 the segment is visually identical to today's straight line.**
- No visible faceting at the widest segment the canvas can produce; a fixed low tessellation
count is not acceptable at full width.
- All three envelopes, both play modes, all sloped stages (attack/decay/release) — one paint
path, one fix.
- The established trace grammar is unchanged: one weight, `kEnvTracePx = 2.0`, through the
analytic stroker. Both overlay layout policies (AHDSR right-anchored schematic, AHD 1:1)
are honoured unchanged. The spline overlay's own trace is untouched.
- **Audio is unchanged** — this is a drawing defect only; a regression baseline proves it.
---
### Γ-W2 — New controls, and the overlay's marks
**Depends on Γ-W1 for:** T1 depends on W1-T1 — Rate and Pitch must be authored into the
finished taper/modifier law, not retro-fitted into it (and the semitone taper must exist
before a second semitone knob does). T2 depends on W1-T3 — both write
`editor_paint_waveform.cpp`, and running them together is a merge fight in one file.
**Disjointness — restated after the Γ-F4 ruling, because it changed.** T1 owns the parameter
model, the engine and the deck descriptors; T2 owns the waveform band's marks and their pure
geometry **and now also the chrome row's loop enable**. The two are disjoint at the module
level with **one named exception: `shell/instrument/editor_session.cpp`.** T1 may touch it for
the third commit tier's routing; **T2 owns `pickedMarkers` and `applyMarkers` there and nothing
else.** The partition is by function and the two do not overlap — this is a textual merge
adjacency, not a semantic contention — but it is a shared file in a phase whose wave boundaries
are otherwise single-writer surfaces, so it is stated rather than discovered at merge. Whichever
track lands second rebases onto the first.
**The format ladder stays clean.** The loop enable maps onto the existing
`SampleLoop::hasLoop`, which is already persisted and whose `start`/`end` are already written
unconditionally — **no new field, no version bump** — so T1 keeps sole ownership of payload v15
exactly as specced.
#### Γ-W2-T1 — `pitch-rate-deck`
**Goal.** PITCH becomes **PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the
existing Varisp|Presrv toggle, with both new controls wired through the engine.
**Spec:** `docs/product/instrument-control-surface.md` §2.
**Surface boundary — owns:** `core/instrument/engine/play_params.h` +
`core/instrument/map/play_seconds.h` (the two new fields),
`core/instrument/map/component_state_io` + `params_payload` (**payload v15**),
`core/instrument/engine/voice.{h,cpp}` (the compounding and the note-on latch),
`core/instrument/ui/deck_groups` (the PITCH/RATE descriptor **and** the three-state live
predicate), `core/instrument/ui/deck_values` (the two new bindings). **Does not own** the
deck's row layout — that is Γ-W3-T1 — nor the real time-stretcher (Γ-W4-T1).
**Behavior.**
- **Rate: 50 %200 %, default 100 % at true knob centre, exponential taper** — 50 % = 12 st,
200 % = +12 st, musically symmetric. This is **linear in semitones over ±12** and is the
stated exception to W1-T1's centre-expansion law (which applies to semitone knobs whose
throw exceeds ±12).
- **Pitch: a baseline pitch offset, ±24 semitones**, centred, on W1-T1's centre-expanded
semitone taper. **Reads `kPitchDepthMaxSemis`; does not mint a second constant.**
- **Varispeed:** keytrack ratio × rate ratio × pitch-offset ratio **compound into a single
read-increment multiply**; the rate offset applies to the varispeed pitch. Composes with
the pitch envelope's existing per-frame `ratio_` multiply — **no new per-sample stage**.
- **Preserve:** rate is an **absolute** value driving **duration only**; keytrack and pitch
offset drive the pitch shifter. **Interim implementation:** a resampled read with the
resulting pitch change cancelled in the existing SOLA shifter. This is an explicit duration
control, **not** a covert Preserve path — `core/instrument/CLAUDE.md`'s "never wire
`WDL_Resampler` as the duration-preserving path" is untouched. Quality is Γ-W4-T1's.
- **Rate is latched at note-on**, delivered by a **third commit class**: published into the
live block like any live parameter, read only by `snapLive`, never by `applyLive`.
`isLiveDeckParam`/`liveCommitFor` widens from two states to three
(`Live` / `NoteOnLatched` / `Reload`) in that one predicate — **not** a second table, and
**not** the reload tier (a swept knob must never trigger a WAV re-decode). **Record the
reason in the header:** loop resolution and contour mapping are note-on folds, so live rate
means re-folding a resolved loop and re-mapping a contour mid-note.
- **Pitch is live** — under Varispeed one more factor in a multiply the pitch envelope already
performs; under Preserve an addend to a shift the pitch envelope already modulates.
- **Loop points scale with rate; contours scale with rate.** Neither rewrites stored values:
the loop is source-frame facts traversed at the new increment (Varispeed) or the new read
rate (Preserve), and a contour is a function of normalized position. **Staged envelope stage
times do NOT scale** — 30 ms is 30 ms at any rate. That asymmetry is deliberate: a contour is
of the sample, a staged envelope is of the performance.
- **Deck descriptor:** three cells; `captionWidth` **70**, hard ceiling **80** (above that the
caption row overtakes the 180 px knob row and the group exceeds 192). If the text will not
fit at 80, narrow the `Varisp|Presrv` segments 48 → 44 (ceiling becomes 88) — **do not widen
the group**.
- **Payload v15** appends both fields as a strict suffix; a v14 blob lifts to rate 100 % /
pitch 0 st, bit-identical playback.
**Acceptance criteria.**
- Rate at 50 % plays an octave down and half speed under Varispeed; at 200 %, an octave up and
double speed. Under Preserve the same settings change duration only — pitch is unchanged
within the shifter's tolerance.
- Rate at exactly 100 % and Pitch at exactly 0 st render **bit-identical** to the
pre-change build, in both engines.
- Shift-drag on Rate lands on whole semitones (so an octave and a fifth are reachable by
hand); Shift-drag on Pitch lands on whole semitones; Ctrl gives cents on both.
- **A Rate change while a note sounds does not alter that note**; the next note-on takes it.
**It does not trigger a reload or an engine rebuild** — assert the tier, not just the sound.
- A Pitch change **does** move a sounding note, in both engines.
- With a loop set, changing Rate changes the loop's audible period without moving either
waveform marker.
- The PITCH/RATE group measures **exactly 192 px**; adding the two `DeckParam`s produces a
compile error in `isLiveDeckParam`'s exhaustive switch until they are classified.
- A v14 project reopens at rate 100 % / pitch 0 st and sounds identical.
**Open questions.** None **[Daniel]**. **[propose at review]** the exact interim
resample+cancel composition and its clamp behaviour at the range extremes, which Γ-W4-T1 then
replaces.
#### Γ-W2-T2 — `loop-crossfade-ux`
**Goal.** Give the loop an explicit enable, make the loop and crossfade marks legible, and
paint the crossfade where it is actually heard.
**Spec:** `docs/product/instrument-control-surface.md` §6 — **read §6.1 (the diagnosis),
§6.4 (the enable) and §6.5 (trade-offs) in full before starting.** This is the phase's one
genuinely designed surface; the sections are the brief.
**Surface boundary — owns:** `shell/instrument/editor_paint_waveform.cpp`'s marker/loop draw,
`shell/instrument/editor_input_waveform.cpp`'s marker hit-test routing,
`core/instrument/ui/waveform_view` (cap rects, label boxes, the label-suppression rule — all
pure, all CTest-covered), and — **new, from the Γ-F4 ruling** —
`core/instrument/ui/sample_chrome` (the enable's rect in the toolbar control run),
`shell/instrument/editor_paint_chrome` + `editor_input_chrome` (its draw and hit-test), and
`shell/instrument/editor_session.cpp`'s **`pickedMarkers` / `applyMarkers` only** (the
retention rule — see the wave header for the shared-file partition). **Does not own**
`loop_span`, the crossfade model, any parameter, or any `ComponentState` version. **This
track changes drawing, hit-testing and one editor-state retention rule — no format change.**
**Behavior.**
- **An explicit loop enable on the CHROME ROW (Γ-F4, ruled).** A two-segment `Loop Off|On`
toggle joins the toolbar row's right-anchored control run, **immediately left of the
`Mono|Stereo` toggle**, with Browse still rightmost. Loop is a waveform-overlay concept and
**has no deck** — a deck cell was never the right home. Because the run is right-anchored
and the title slot absorbs it, **this costs zero window width and none of the 90 px
headroom**; if the title will not hold its text at the 1190 floor, **the enable's segments
narrow — the floor does not move.**
- **The enable IS `SampleLoop::hasLoop`. No new field, no version bump.** The field already
exists (`play_params.h:210`), is already what `resolveLoop` refuses on
(`loop_span.cpp:12`), and is already persisted in the payload's `loopOverride` block —
where **`start`/`end` are written unconditionally whatever `hasLoop` says**
(`params_payload.cpp:31-36`), so the wire can already carry "off, with a span remembered."
What changes is the field's *provenance*: today it is derived from the marker gesture, and
after this track it is **user-owned**, with the gestures as shortcuts onto it.
- **Collapse-to-off survives as a shortcut, not as a second state machine.** `hasLoop` is the
single authority; four gestures reach it:
| Gesture | Enable | Span | Crossfade |
|---|---|---|---|
| Enable → On | on | retained | retained |
| Enable → Off | off | **retained** | **retained** |
| Collapse the span onto itself | off | **destroyed**, re-parked at `defaultLoopBounds` | **zeroed** |
| Drag either loop mark while off | **on** | takes the drag | retained, re-clamped |
- **Two consequent behaviour changes, each with its reason.** (a) `pickedMarkers`'
re-park (`editor_session.cpp:221-226`) currently triggers on `!hasLoop`; it must become
conditional on the span being **invalid** (collapsed / inverted / out of range) rather than
on the enable being off — a toggle whose off→on does not restore what was there is a delete
button, not a toggle. (b) `applyMarkers`' crossfade zeroing (`:240-243`) moves from "the
enable is off" to "the span was destroyed." **The original reasoning is preserved, not
overruled:** it zeroes so a stale length cannot silently re-apply against a span that no
longer exists; with the span retained, its clamp bound is retained too and there is nothing
stale.
- **The "drag me" affordance splits into two off-states.** Off with **no span ever set** —
pair parked at `defaultLoopBounds`, Disabled, caption `DRAG TO SET LOOP`. Off with a **span
retained** — pair Disabled *at its own positions*, caption `LOOP OFF` (there is nothing to
"set"). In both, **dragging a mark turns the enable on** — the shipped drag-to-create
gesture survives and now teaches the enable by demonstration.
- **In Trigger the enable draws Disabled and inert, and does NOT clear `hasLoop`** —
Disabled-not-hidden, the same grammar as the marks, with its state restored on the return to
Gate. This transitively covers the drawn-EG case via `enforceGateUnavailableWhileDrawn`
(`play_params.h:198-205`), which forces Trigger whenever an envelope is drawn — one
predicate, not a second rule. **Disabled-but-grabbable (the off marks) vs.
Disabled-and-inert (Trigger) is deliberate:** the user's own off is reversible by the very
gesture on offer; Trigger's refusal comes from the engine and no drag can talk it out of it.
- **One mark grammar: line + shaped cap + label. The cap IS the grip.** Four marks:
**START** (`accent/primary`, solid right-pointing triangle cap, solid line — the only
primary-ink mark, because it is the only one always in effect); **LOOP** (`accent/secondary`,
L-cap opening right); **END** (`accent/secondary`, L-cap opening left); **XFADE**
(`accent/secondary` reduced alpha, ramp cap, **dashed** line — a soft boundary). This
replaces the bare 10 px orphan tab that today marks the crossfade with no line of its own.
- **Labels** in `Font::Micro`/`TextDim`, drawn **beneath** the trace and handles in z-order.
A mark's label **re-draws on top on hover or drag** of that mark. **A label is suppressed if
its box would overlap one already placed**; placement order is grabbed/hovered first, then
START, LOOP, END, XFADE. Occlusion by an envelope node is **accepted and named** — the cap
shape carries the identity permanently, the label is for learning.
- **The crossfade moves to `[loopEnd crossfade, loopEnd)`** — where it is audible. The
handle moves to the loop-end side; **drag direction is unchanged** (left lengthens), so the
muscle memory survives.
- **The crossfade region draws as a top-and-bottom edge wedge, NEVER as a second fill.** A
triangular band at the overlay's top and bottom edges growing from zero at
`loopEnd crossfade` to ~10 px at `loopEnd`. **This is a hard constraint:** the region is
now *inside* the loop span, where a translucent fill would stack on the 0.20 loop fill, and
the envelope trace crossing that fill is a known, accepted under-floor pair at 2.25:1
(`editor_paint_waveform.cpp:28-34`), whose own note says the FILL is what changes if it is
ever resolved. **The loop fill's peak alpha must stay exactly 0.20.**
- **The ingredient draws as a ghost.** `[loopStart crossfade, loopStart)` draws the mirror
wedge at half alpha, no handle — **a hairline dashed outline at rest, filling in on hover or
drag of the crossfade handle**. This makes the `crossfade ≤ min(start, loopLength)` clamp
self-explanatory: the fade stops growing exactly when the ghost's left edge reaches START or
LOOP, so the user sees the reason instead of hitting an invisible wall.
- **Trigger mode:** the loop pair and the crossfade mark draw **Disabled and are not
grabbable**, with a dim `LOOP — GATE ONLY` caption — Disabled rather than hidden, matching
the editor's existing Gate-segment grammar, and because hiding a set loop on a mode flip
destroys information the user put there. START stays fully live.
**Acceptance criteria.**
- The four marks are distinguishable by ink and cap shape with the labels suppressed, and
named when they are not.
- **The shaded crossfade region sits over the frames where the fade is audible** — verify
against a rendered loop, not by reading the code.
- Every mark is grabbable by its cap; grabbing a mark shows its label.
- The crossfade at its clamp shows the ghost's left edge coincident with the bounding mark.
- **The loop fill's peak alpha is unchanged at 0.20** and the accepted 2.25:1 trace pair is
neither improved nor worsened.
- In Trigger, no loop mark accepts a grab, the chrome enable is Disabled and inert, and the
reason is on screen. Returning to Gate restores the enable's prior state.
- **Turning the enable off and on again restores the loop exactly** — same span, same
crossfade, no re-park. Collapsing the span instead turns it off, re-parks at
`defaultLoopBounds` and zeroes the crossfade. Both paths asserted.
- **Dragging a loop mark while the enable is off turns it on**, in both off-states.
- **The enable costs no window width:** `kEditorMinWidth` is unchanged by this track, asserted
by the same derived test that guards the floor.
- **No `ComponentState` version moves; no new persisted field; `resolveLoop` is untouched;
audio is unchanged.** The enable round-trips save/reload through the existing
`loopOverride` block, in both states, with the span retained across an off.
- All cap/label/suppression geometry is pure and unit-tested; no hit-test math in the painter.
The enable's rect lands in `sample_chrome` alongside the rest of the control run.
**Open questions.**
- **[propose at review, then verify by hand]** The claim-arbitration inputs change:
`markerHandleRect` today gives a tab to the crossfade only, and `resolveWaveformClaim`
breaks ties by smallest nominal target area. Giving every mark a cap-grip changes the
candidate set **and every nominal area in it**. The arbitration must be re-derived, and
`docs/TODO.md`'s open entry *"Pre-existing staged-envelope-node shadow at zero-attack"*
must be **re-evaluated against the new cap geometry and its outcome recorded** — resolved or
worsened, either is acceptable, silence is not.
- **No [Daniel] questions.** Fork Γ-F4 is ruled — there **is** an explicit enable and it is on
the chrome row, in this track. The prior framing ("an enable needs a cell, so it is a Γ-W3
layout decision") was wrong and is retired: loop has no deck, so it never needed one.
- **[propose at review]** every site that currently *infers* `hasLoop` — two in
`editor_input_waveform` (`:255`, `:258`), two in `editor_session` (`:222`, `:236`) — is now
writing to a user-visible control rather than to an internal flag. Re-read each in that
light; "it still compiles" is not a disposition.
- **Named escalation, not a fallback to take silently:** if the top strip reads crowded in the
DAW, the pre-designed answer is the marker rail (spec §6.2, Direction 2) — a larger build
that would also dissolve the arbitration problem structurally. Escalate; do not improvise a
half-rail.
---
### Γ-W3 — The reflow
**Depends on Γ-W2 for:** the PITCH/RATE descriptor (W2-T1) — the reflow measures the real
three-cell group, and laying it out against a forecast of that group means re-measuring
afterward. **Depends on Γ-W1 for:** W1-T2's published meter/GR/clip state, which MASTER's
deck draws; drawing against a stub would mean building the meter twice.
**One track.** The row law, the group inventory, the double-height deck and the window floor
are one geometry decision spread over `knob_deck`, `deck_groups`, `sample_bands` and the deck
painter. Splitting it would put two tracks in the same three pure modules.
#### Γ-W3-T1 — `deck-reflow`
**Goal.** Two categorical rows plus a double-height MASTER bus deck, inside a 1280 × 720
ceiling, returning 112 px to the waveform.
**Spec:** `docs/product/instrument-control-surface.md` §1 (the whole section, incl. the §1.2
measured table **and §1.6, the headroom ledger**) and §3.23.3 (what MASTER draws). **§7 lists
the invariants this track invalidates or widens — read it before touching `knob_deck.h`.**
**Surface boundary — owns:** `core/instrument/ui/knob_deck` (the row law, the double-height
group, the justification), `core/instrument/ui/deck_groups` (row assignment, FILTER's toggle
move, MASTER's inventory), `core/instrument/ui/sample_bands` (`kEditorMinWidth`), and
`shell/instrument/editor_paint_deck` (the MASTER meter/limiter/bubble draw). **Does not own**
any parameter, the limiter DSP, or the waveform band.
**Behavior.**
- **Row 1 (sound), one row, non-negotiable:** PITCH/RATE 192 · FILTER 432 · VELOCITY 192 ·
VOICE 164 = **980** natural.
- **Row 2 (contour):** PITCH ENV 252 · FILTER ENV 312 · AMP ENVELOPE 312 = **876** natural.
- **MASTER is double-height (216 px) and right-anchored**, outside both rows, 142 px wide.
- **FILTER's `Band|Notch` moves from its row-toggle position to the caption corner**, taking
the group 524 → **432** (92 px). It occupies FILTER's currently-unused `captionToggle2`
slot — **no new geometry is required**.
- **VOICE keeps its `Retrig|Legato` row toggle.** Moving it to the caption makes VOICE
*wider* (226, not narrower), because its caption row is the binding side. Verified; do not
"fix" it.
- **Justification law, applied to BOTH rows:** space-between within the row block; slack
divided equally among the row's (n1) gutters, integer residue to the leftmost;
**no gutter narrower than `kDeckGroupGap` (12)**. **Decks are never stretched.** MASTER is
not part of either row's justification.
- **Row block = 1020 px at the floor**, giving row 1 gutters 12/14/14 and row 2 gutters 72/72,
at which width **FILTER's right edge and FILTER ENV's right edge both land on x = 636**.
That tie-line, row 2's equal gutters, and row 1's minimum gutter being exactly
`kDeckGroupGap` all hold at 1020 and only at 1020 — **this is why the floor is 1190 and not
1186.** Above the floor the tie-line drifts and that is accepted (spec §1.3).
- **`kEditorMinWidth` 980 → 1190**; `kEditorMinHeight` **stays 680** (Γ-F1, ruled — the
reflow's 112 px goes entirely to the waveform); deck band height 328 → **216**; waveform
band at the floor 246 → **358**.
- **The 90 px of remaining headroom is the budget for the life of this layout**, and one deck
cell is 60 px. **This is why MASTER's reserved slot is ONE cell** (Γ-F5, ruled): two would
spend 60 of the 90 up front on a control nobody has named, leaving 30 — which would freeze
row 1 forever, since any later row-1 addition needs 60. Widening MASTER later costs the same
60 it would cost now, and by then the trade is against a real control instead of a guess.
**State this ledger where a future reader will hit it** — spec §1.6 is its home, and a
reader proposing a new knob needs to see it before they propose.
- **MASTER's interior** (spec §1.4, exact to the pixel): caption row with the limiter toggle
and a **round** 12 px `warn` GR bubble in the far corner (non-interactive — the same slot the
envelope decks' radio uses; round so it reads as a lamp, not a control); **gain knob in the
upper-left cell at box-relative y = 26** and a **reserved empty slot at y = 138** — i.e. the
two cells land on row 1's and row 2's knob baselines exactly, which is what stitches the
spanning deck to both rows; **meter column 62 px wide × 186 px tall** on the right.
- **Three rules not to generalise wrongly:** MASTER's left column uses **fixed cell slots at
the two baselines, NOT the horizontal run-division law** (that law would stretch one knob
over 186 px); the reserved slot **draws nothing** (blank reads as breathing room, a dashed
placeholder reads as unfinished); the meter is **one rect spanning both baselines**, not two
per-row meters.
- **The meter draws W1-T2's published state**, with the ballistics run on the UI timer.
**Bar count follows the same `LaneSplit` decision `waveformSurface` already folds** (channel
mode ∧ source channel count) — one wide bar when the waveform draws one lane, two skinnier
bars when it draws two. Not a second rule: a mono source in stereo mode is dual-mono, and
two identical bars would be a lie.
- **Meter appearance:** bar in `accent/primary`; peak-hold tick 2 px in `text/primary`; clip
cap in `warn`, latched, click-to-clear; scale linear in dB over 60…+6 with ticks every
6 dB and numerals at 0/12/24/36/48/60, the 0 dB tick heavier. **No green/yellow/red
segmentation** — `warn` stays reserved for clip states.
**Acceptance criteria.**
- At the floor width the deck lays out in **exactly two rows plus the spanning MASTER**,
**by construction** — asserted against the group inventory, not observed as a wrap outcome.
- Every group's width matches the §1.2 table exactly, **in both Gate and Trigger** (row 2's
natural width is mode-stable at 876 because the reserve slots hold FILTER ENV and AMP at
312 in both modes — assert it).
- Row 1 and row 2 are **flush left and flush right**; at the floor width the filter tie-line
is exact (both edges at x = 636) and row 2's two gutters are equal.
- **`kEditorMinWidth` is 1190 and the floor is ≤ 1280 × 720**, asserted by a derived test
rather than a literal (the Θ-W6-T1 precedent).
- The waveform band is **358 px at the floor**, and the deck band is 216.
- MASTER's gain knob shares a knob baseline with FILTER's knobs; its reserved slot shares one
with AMP ENVELOPE's.
- The meter reads correctly in mono and stereo, the peak-hold tick holds 1.5 s, the clip cap
latches and clears, and the GR bubble lights only while the limiter reduces gain.
- **With the limiter engaged the clip cap never latches** on material the limiter is catching;
if it does, that is a defect report against W1-T2, not a user error.
- `knob_deck`'s and `sample_bands`' tests are updated to the new law, and the invalidated
notes in `knob_deck.h` (the fourteen-pixel headroom figure; the cells-and-floor pairing) are
**re-derived, not deleted** — spec §7.1, §7.4.
**Open questions.**
- **[propose at review]** Whether the greedy whole-group wrap survives at all as a sub-floor
degrade, or is replaced outright by explicit row assignment. What is **not** optional: at
and above the floor width the layout is the specified arrangement, reached by construction.
`DeckLayout::rowCount`/`::height` change meaning either way (spec §7.3).
- **No [Daniel] questions.** Forks Γ-F5 (**one cell**) and Γ-F1 (**680 stays**) are both
ruled; they are stated in Behavior above, not carried here as options.
- **[verify]** `deck_groups.cpp`'s `kEnvModeSegW = 23` ceiling rises to **47** once PITCH ENV
is on row 2 (AMP binds at 55). No change is required; the comment stating the old ceiling
stops being true and must be corrected (spec §7.2).
---
### Γ-W4 — The Preserve time-stretcher
**Depends on Γ-W2 for:** Rate existing as a shipped, working control on the interim
resample+cancel path. Landing the stretcher against a working feature makes it a measurable
quality upgrade with an A/B; landing them together would make one track responsible for both
a feature and an algorithm.
**One track.** **This is the phase's largest unknown** — flagged the way Ξ-W2-T1's crossing
was. If the measure-and-report gate fails, what gives is the *algorithm choice*, not Rate:
the interim path already ships in W2-T1, so the fallback is "keep the interim path and record
why," not "cut the feature."
#### Γ-W4-T1 — `preserve-time-stretch`
**Goal.** Replace the interim resample+cancel composition with a real pitch-preserving
time-stretcher written from established state-of-the-art literature.
**Spec:** `docs/product/instrument-control-surface.md` §2.5.
**Surface boundary — owns:** `core/instrument/engine/pitch_shift` and whatever new pure
module the stretcher needs, plus `voice.cpp`'s Preserve read path. **Does not own** any
parameter, any UI, or the varispeed path.
**Behavior and constraints.** The algorithm is **the engineer's call under a
measure-and-report gate — this plan deliberately names none.** The constraints:
- **CPU stance (Daniel, verbatim intent):** *"we should be efficient but accept the cost of
high-quality algorithm choices. It's 2026, most people's computers can handle audio with
ease. Just don't be wasteful."*
- **RT-safe:** no allocation, no I/O, no lock in `process()`; buffers sized at voice
allocation or at the off-audio-thread reload, on `pitch_shift`'s existing pre-warm
precedent.
- **Per-voice state, holding up at the 32-voice ceiling.** The gate is 32 simultaneous
Preserve voices at 50 % and 200 %, not one voice at 100 %.
- **No new third-party dependency** (`pitch_shift`'s standing property).
- **No dispatch on the per-sample path** — concrete, inlineable types; no `IStretcher`.
- **Onset behaviour is a regression surface.** GA2 eliminated Preserve's ~25 ms onset latency
by priming the ring with the actual upcoming source. **A stretcher that reintroduces an
onset delay or a first-frame smear is a regression, not a trade-off.**
**Acceptance criteria.**
- **Rate 100 % with no shift is bit-identical to the un-stretched read.**
- Preserve speaks on frame 0 — no added onset latency, no first-frame smear, in any
rate/shift combination.
- No audible metallic or phasey artefacting on sustained tonal material at ±6 st and
75133 % rate; transient material at 50 % / 200 % is no worse smeared than the interim path.
- The Gate sustain-loop contract is unchanged: **loop the source, shift the output** — loop
points remain source-frame facts.
- **Measure and report before the algorithm is final:** per-voice CPU at 32 voices, added
latency (must be zero at the onset), and A/B recordings against the interim path on three
material classes (one-shot, tonal sustain, full-mix bounce). Report to Daniel; the choice
is not final until he has heard the A/Bs.
**Open questions.** **[propose, with a measurement step]** the algorithm family itself.
**[verify]** that `core/instrument/CLAUDE.md`'s *"`WDL_Resampler` is not a Preserve engine —
never wire it as the duration-preserving path"* is honoured: Rate legitimately changes
duration under Preserve, so a resampled read is an explicit duration control, but the
*pitch-preserving* mechanism must not be a resampler.
---
## Traceability — all seventeen items ## Traceability — all seventeen items
The check that nothing was dropped. Every row points at a track that exists above. The check that nothing was dropped. Every row points at a track that exists above.
@@ -652,6 +1373,13 @@ proof it exists to give.
from `TODO-1.0.md`, and not a track this plan originally scoped. The second such track from `TODO-1.0.md`, and not a track this plan originally scoped. The second such track
in this plan today; if others appear, they belong on this list rather than in the in this plan today; if others appear, they belong on this list rather than in the
table. table.
- **All of Phase Γ** (`pg-*`). Seven tracks from a direct interview with Daniel
(2026-08-01), not from `TODO-1.0.md`. Listed here as a block rather than per track,
because the whole phase is outside the source doc; the product reasoning lives in
`docs/product/instrument-control-surface.md` and the automation scoping it defers in
`docs/product/parameter-automation.md`. Γ-W3-T1 additionally **discharges** the
`docs/TODO.md` deck-rework entry, whose original "one row of taller decks with
within-deck stacking" shape Daniel explicitly superseded.
### Deliberate compressions ### Deliberate compressions
@@ -713,8 +1441,21 @@ Phase Ξ — The resample loop (W1 concurrency-safe with Θ from Θ-W
W1 Consolidated tracking, and the programmed-note model W1 Consolidated tracking, and the programmed-note model
T1 tracking-consolidation .................. 17 T1 tracking-consolidation .................. 17
T2 note-program-model ...................... 15 (model) T2 note-program-model ...................... 15 (model)
W2 The bake chain [requires all of Phase Θ] W2 The bake chain [requires all of Phase Θ, and Phase Γ before it]
T1 resample-bake-chain ..................... 15 (chain) T1 resample-bake-chain ..................... 15 (chain)
W3 The capture-signal popup W3 The capture-signal popup
T1 capture-signal-popup .................... 15 (popup) T1 capture-signal-popup .................... 15 (popup)
Phase Γ — The instrument's control surface (none of the seventeen; runs before Ξ-W2)
W1 Foundations [3 tracks, disjoint by surface]
T1 knob-interaction-law ....... modifiers + ms/semitone tapers + reset bypass
T2 master-bus-audio ........... limiter + meter ballistics + processor [payload v14]
T3 contour-trace-curves ....... staged traces draw curved, knot on its trace
W2 New controls, and the overlay's marks [2 tracks]
T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [payload v15]
T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard
W3 The reflow [1 track]
T1 deck-reflow ................ two rows + double-height MASTER; floor 1190x680
W4 Preserve time-stretch [1 track]
T1 preserve-time-stretch ...... real stretcher [measure-and-report gate]
``` ```
+40 -36
View File
@@ -208,50 +208,54 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
**Done looks like.** Switching projects mid-record leaves the recorded file with a ledger record in the project it belongs to, so a later prune of that project can reclaim it normally. **Done looks like.** Switching projects mid-record leaves the recorded file with a ledger record in the project it belongs to, so a later prune of that project can reclaim it normally.
## The deck layout needs a real rework — one row, taller decks, controls stacked within a deck ## Raise the stage-time ceiling above 2 s for long-decay sound design
**Context (what shipped — Θ-W4-T2).** The VELOCITY deck group landed with the amp, pitch, **Context (Daniel, 2026-08-01 — Phase Γ fork Γ-F3).** Every stage-time knob spans `[0, kEnvTimeMaxSeconds]` (`core/instrument/ui/deck_values.h:22`), which reads the AHDSR overlay's schematic scale `kGateStageMaxSeconds = 2.0` (`core/instrument/ui/envelope_overlay.h:85`). Γ-F3 asked whether Phase Γ's log taper should also raise that ceiling. **Ruled: not in Phase Γ — the ceiling stays 2.0 s.** But the ambition is real and is recorded here rather than dropped: Daniel *"can foresee wanting 10-second stages"* for certain sound-design cases, his example being **"a horrifically long decay with tight exp[onent]"** — a very long stage whose curve exponent puts almost all the motion at one end. That shape is unreachable at a 2 s ceiling.
and filter velocity-curve buttons together, but the group placement lands wherever the
existing per-row deck layout happens to put it. Daniel does not want multiple rows of
decks. He wants **one row**, with the decks **much taller** to accommodate the controls —
knobs stacked *within* a deck rather than strung out in a long line of small knobs. His
example: the filter's static control knobs go **above** the filter envelope knobs.
**He has further design notes coming**, so this is not ready to implement — it is
recorded here so it is not lost and not attempted piecemeal.
**The wart.** Θ-W4-T2's acceptance criterion "VELOCITY sits immediately to the left of **Why it is deferred rather than done.** It is a musical-range question, not a correctness one, and the right time to answer it is with the new taper in the DAW under the hand — a 5× ceiling change judged from a spec is a guess.
the VOICE group" is **not met at the default window size**. This is **superseded by the
deferred rework**, not silently dropped — recorded here so a later reader does not see an
unmet criterion with no explanation.
**Intended fix.** Not yet specified beyond the shape above (one row, taller decks, **Two things landing in Phase Γ make it cheap afterwards.**
within-deck knob stacking) — Daniel's further design notes are still to come. 1. **The log taper (Γ-W1-T1) is what makes a higher ceiling usable rather than unusable.** Under the current linear map, a 10 s ceiling would put 10 ms at 0.001 of travel — one pixel of a 128 px drag — so the ceiling and the low-end resolution were in direct conflict. Under the log taper the low end keeps its resolution regardless of where the ceiling sits (Γ-W1-T1's landmarks: 10 ms within 0.120.20 of travel, 100 ms within 0.420.52). **The taper is the prerequisite, and it is why this is a follow-up rather than a dead end.**
2. **`resetDeckParam` bypassing the taper (also Γ-W1-T1) removes the power-of-two blocker.** `deck_values.h:42-46` records that exact default recovery currently depends on the ceiling being a power of two — `x/2^n*2^n` is lossless, an arbitrary ceiling is not. **2.0 is a power of two; 10.0 is not**, so under today's norm round-trip a 10 s ceiling would land every stage-time reset a mantissa bit off its own default. Γ-W1-T1 makes reset write the default value directly, which retires that dependency outright.
**The constraint the fix MUST handle.** Not yet known — this is exactly why the rework is **The constraint the change MUST handle.** `kEnvTimeMaxSeconds` and `kGateStageMaxSeconds` **move together, or not at all.** The AHDSR overlay's schematic scale is derived from the ceiling so that a maxed knob lands exactly at the canvas edge — the agreement requirement is documented at `deck_values.h:19-22`, and `deck_values.h` reads the overlay's constant rather than restating it precisely so the two cannot drift. Raising the ceiling therefore also stretches every drawn AHDSR: at 10 s, a 30 ms attack occupies 0.3% of the schematic's stage domain and becomes visually indistinguishable from zero. **Whether the overlay's schematic scale should stay linear in seconds at a 10 s ceiling, or take a taper of its own, is the real design question underneath this entry** — the constant change is trivial; keeping the drawing legible is not.
deferred rather than attempted against the current spec.
**The measured geometry — the data the rework starts from.** Measured at the 840 px **Priority / risk.** Low / deferred by ruling. No correctness risk: the persisted payload stores raw seconds, so a ceiling change re-tapers needle angles only and every saved project reloads bit-identical (the same persistence-neutrality Γ-W1-T1's own taper changes are held to). **One timing risk that is NOT low:** if VST3 automation parameters ship before this, the ceiling becomes part of the host-facing normalization and moving it silently re-interprets every recorded automation point — the same one-way door `docs/product/parameter-automation.md` §4 states for the taper itself. **If this is wanted, it wants to happen before the parameter system, not after.**
floor window, `availWidth = 824`, Gate mode. Group widths: PITCH 150, PITCH ENV 204,
FILTER 440, FILTER ENV 252, AMP 252, VELOCITY 156, VOICE 152, MASTER 60. Row 1 = PITCH +
PITCH ENV + FILTER (818 of 824). Row 2 = FILTER ENV + AMP + VELOCITY (684). Row 3 = VOICE
+ MASTER (224). Deck height 280 px; waveform band 246 px against a 150 px two-lane floor,
so 96 px of margin. No reordering can fit two rows — 1666 px of groups plus 72 px of gaps
against a 1648 px two-row capacity. Two rows first fit at a 936 px window.
**Unresolved measurement discrepancy.** The VELOCITY↔VOICE adjacency threshold was **Done looks like.** A stage time of several seconds is reachable by hand with no loss of resolution below 100 ms; the AHDSR overlay still reads legibly at both ends of the range; `kEnvTimeMaxSeconds` and `kGateStageMaxSeconds` still agree; and a project saved at the old ceiling reloads with identical stored seconds and identical audio.
measured at a 864 px window by the implementation and stated as 872 px by code review;
the implementation flagged the difference rather than asserting the reviewer wrong, and
it was not re-measured because the deferral made it moot.
**Priority / risk.** Deliberate deferral, not oversight. Daniel wants to finish his ## The deck layout rework — SPECCED, and the original shape SUPERSEDED
design notes before this is attempted, to avoid building a layout that gets reworked
piecemeal.
**Done looks like.** One row of decks, each much taller than today's, with each deck's **Status (2026-08-01): no longer a deferral. The design notes Daniel owed this entry have
controls stacked internally (e.g. the filter's static control knobs above its envelope arrived, and they change the shape.** The rework is specced in
knobs) per Daniel's design notes once they land. The VELOCITY↔VOICE adjacency criterion `docs/product/instrument-control-surface.md` §1 and sequenced as **Phase Γ** in
is re-evaluated (or explicitly retired) against the new layout rather than the old `docs/PLAN.md`. This entry is retained only until that work lands, because one loose end
per-row one. below (the Θ-W4-T2 acceptance criterion) still needs an explicit disposition.
**What was superseded, and confirmed superseded by Daniel.** The original entry recorded a
directive of Daniel's for **one row of much *taller* decks with knobs stacked *within* a
deck** (his example: the filter's static knobs above its envelope knobs). **The new framing
replaces that.** The decks stay **single-height with knobs side-by-side**; what becomes
one row is the **sound** category (PITCH/RATE, FILTER, VELOCITY, VOICE), with the three
envelope decks on a second **contour** row and MASTER as a double-height deck spanning both.
The within-deck stacking idea is retired, not deferred.
**The measured-geometry block that used to live here has been deleted, not moved.** It was
taken at the 840 px floor with `kDeckCellW = 48` and is wrong twice over — Θ-W6-T1 changed
both the floor (980) and the cell metrics (60 × 74). The current, re-derived geometry — every
group's width, both row totals, and the resulting 1190 × 680 floor — is the table in
`docs/product/instrument-control-surface.md` §1.2. **Do not resurrect the old numbers.**
The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with
them; it was measured against a layout that no longer exists.
**The one live loose end.** Θ-W4-T2's acceptance criterion *"VELOCITY sits immediately to
the left of the VOICE group"* is not met at the default window size today. Under the new
layout it **is** met by construction — row 1 is PITCH/RATE, FILTER, VELOCITY, VOICE, in that
order, at every window width — so the criterion is satisfied rather than retired. Confirm it
when Phase Γ-W3 lands and remove this entry.
**Done looks like.** Phase Γ-W3 (`deck-reflow`) has landed; the VELOCITY↔VOICE adjacency
criterion is confirmed met at the floor width; this entry is removed.
## The AA waveform stroke's cost on the docked bank panel's card thumbnails ## The AA waveform stroke's cost on the docked bank panel's card thumbnails
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
# VST3 automation parameters — scoping, not scheduling
> Daniel, 2026-08-01: *"we need to scope the whole parameter system for automation, which
> will require redesigning the existing wiring."* And, on Rate being latched at note-on:
> *"we will need to analyze ALL the controls for LIVE use/automation use."*
**This is NOT Phase Γ work.** It is framing and open questions, written now because Phase Γ
makes two decisions whose cost changes permanently the day parameters ship, and because
knowing that is what makes Γ's ordering correct rather than arbitrary. Nothing here is
scheduled; nothing here is settled.
The one thing this doc *does* assert is a sequencing constraint — §4.
---
## 1. Current state, verified
**The instrument has zero VST3 automatable parameters.**
- `ReaSamplerProcessor::initialize` never populates the parameter list, so
`getParameterCount()` returns the SDK default **0**. A host sees no parameters at all.
- All state lives in the **`ComponentState` blob** — envelope v1…v11, params payload
v1…v13 (`core/instrument/map/component_state_io.h`), a cross-artifact contract the
extension's `instrument_drop` and the instrument's processor both read and write.
- `DeckParam` ids are **runtime-only and explicitly free to reorder** — the editor's
`ParamControl` is an alias of the same enum, and nothing persists an id.
Consequently the instrument today has: no host automation, no MIDI learn, no parameter
linking, no host-side modulation, and no generic FX panel. Everything is reached through the
plugin's own editor.
---
## 2. What is actually on the table (name the prize properly)
Framing this as "automation" undersells it. **REAPER gives every exposed VST3 parameter a
parameter-modulation block for free** — an LFO, an envelope, an audio-follower/sidechain,
MIDI link, and parameter linking, per parameter, with no plugin-side work beyond declaring
the parameter.
So exposing parameters is not primarily about drawing automation lanes. It is the
instrument **gaining a modulation system it would otherwise have to design, build, persist
and draw itself.** A sampler with a host-provided LFO on filter cutoff, an envelope-follower
on drive, and MIDI-CC on pitch offset is a materially different instrument, and none of that
is our code.
That is the reason this is worth a phase. It is also the reason the parameter list's design
matters more than the plumbing: the list *is* the modulation matrix's rows.
---
## 3. The hard problems, in the order they bite
### 3.1 Parameter IDs become FOREVER-STABLE
A VST3 `ParamID` is recorded in the project file. Once shipped, the id ↔ meaning mapping is
**frozen forever** — the same discipline the extension's command-id strings and the VST3
class UIDs already carry, and the same discipline the params-payload version ladder carries.
**`DeckParam`'s "free to reorder" property dies the day parameters ship.** Anything that
wants to renumber, regroup or reorder that enum has to happen first. This is not a
theoretical concern: Phase Γ adds two entries to it.
Open: is `ParamID` the `DeckParam` value directly, or an independent id space with an
explicit mapping table? Direct is simpler and tempting; an independent space is what lets
the enum keep being a UI-ordering convenience. **Lean: independent id space with an explicit
frozen table**, on the same reasoning the command-id family already uses — the display order
and the wire identity should not be the same number.
### 3.2 The taper becomes the host-facing contract
VST3 parameters are normalized `[0,1]` with the plugin owning the taper
(`toPlain`/`toNormalized`). An automation envelope a user draws in REAPER is drawn against
the **normalized** value.
**Therefore: re-tapering a parameter after it ships silently re-interprets every recorded
automation point in every saved project.** A 40 %-of-travel node on an attack knob means
16 ms under a log taper and 800 ms under a linear one. There is no version ladder that can
fix this, because the data is in the host's project file, not ours.
Today, re-tapering is **free** — normalization exists only in `ui/deck_values.cpp` as a
display/interaction layer, and the payload stores raw engine values as doubles. That is a
property worth spending while we still have it. See §4.
### 3.3 Two sources of truth
VST3 saves parameter values *and* calls `setState`/`setComponentState`. A value that lives
in both can drift — a project reloaded with automation could restore the blob's value and
then have it immediately overwritten by the host's parameter value, or the reverse,
depending on call order.
Two coherent answers, both viable, neither obviously right:
- **(a) Parameters are the source of truth** for everything automatable; the blob carries
only non-automatable structure — sample refs, velocity curves, spline contours, mode
selections, the loaded capture. Cleanest host semantics; largest rewrite; and it splits
the parameter set across two storage mechanisms, which the "one parameter set" invariant
went out of its way to unify.
- **(b) The blob stays authoritative; parameters are a projection** with one defined sync
direction and a defined precedence at load. Smallest change; preserves the cross-artifact
blob contract intact; risks exactly the drift class above if the precedence is not
airtight.
Open. This is the load-bearing architectural decision of that phase and it should be made
first, the way Ξ-W2-T1's crossing decision was.
### 3.4 Which controls can be parameters at all — three classes
The good news: **this analysis is already done once, in one place.** `isLiveDeckParam` /
`liveCommitFor` (`ui/deck_groups`) is exactly "which controls can change without a rebuild,"
which is the same question automation asks. Phase Γ widens it from two states to three
(§3.5). The parameter work should widen the *same* decision point again rather than start a
second table — that is the standing rule (`core/instrument/CLAUDE.md`: *"which controls are
live is ONE decision, recorded in ONE place"*).
| Class | Examples | Automatable? |
|---|---|---|
| **Continuous, live-safe** | filter cutoff/Q/morph/drive/mod amt/key-track, every stage time and level, pitch env depth, master gain, pitch offset | **Yes** — the live tier already delivers them at block boundaries |
| **Discrete / rebuild-tier** | voice count, Poly/Mono, Retrig/Legato, Gate/Trigger, Staged/Spline, pitch engine, filter law | **Only if** each gains a live path, or is exposed as a stepped parameter that is explicitly *not* safe to automate at rate. Today they route through `rebuildVoiceEngine` or a full reload — neither is RT-safe at automation rates |
| **Structural** | the loaded capture, `SampleRefs`, the three velocity curves, the three spline contours, loop points, root note | **No.** These are not scalars; they stay in the blob |
| **Latency-changing** | the **limiter enable** | **No** — and for a reason unrelated to the live tier. Flipping it changes reported latency, which the SDK defines as a host deactivate/reactivate. §3.8 |
The awkward middle class is the second row, and it is where the design work is.
### 3.5 Live vs. latched is a per-parameter decision, and Γ opens the seam
Phase Γ settles that **Rate is latched at note-on**, and — importantly — settles it as a
*third commit class* (`Live` / `NoteOnLatched` / `Reload`) recorded in the same
`deck_groups` predicate, rather than as a special case at a call site. See
`docs/product/instrument-control-surface.md` §2.3.
That is exactly the vocabulary the parameter system needs. A VST3 parameter has to declare
what it means to move it mid-note, and the answer is per parameter:
- **Live** — the sounding voice follows (the φ-holding `applyLive` rule).
- **Note-on latched** — published, but read only by `snapLive`. Automation still works; it
just takes effect on the next note. Rate is here, and the *reason* it is here is recorded:
loop resolution and contour mapping are note-on folds, so live rate means re-folding a
resolved loop mid-note without a discontinuity.
- **Not automatable** — rebuild or structural.
**Lifting Rate from latched to live is a real feature with a named cost, not a flag flip.**
When someone proposes it, that is the paragraph to read first.
### 3.6 Parameter count, grouping, and the generic panel
~60 `DeckParam`s plus the non-deck controls (voice count, master gain, key-track, preview
velocity, limiter — though the limiter enable is **excluded** from the automatable set, see
§3.8). REAPER's generic FX panel and every automation-lane picker will list all of them flat
unless they are grouped.
VST3's answer is `IUnitInfo` — a unit tree that maps naturally onto the deck's own group
structure (PITCH/RATE, FILTER, VELOCITY, VOICE, the three envelopes, MASTER). Also needed
per parameter: a display name that survives truncation, `ParameterInfo::units`,
`stepCount` for discretes, and the right flags (`kCanAutomate`, `kIsBypass` — the limiter
toggle is emphatically **not** the plugin's bypass parameter).
Open: does the unit tree mirror the deck's *visual* grouping (which Phase Γ has just
reflowed into two rows) or the engine's signal flow? They currently agree. Keeping them
agreeing is a constraint worth stating before they diverge.
### 3.7 Sample-accurate automation vs. block-boundary observation
The live-parameter seqlock is observed **once per `render()` and once per note-on** — block
boundaries, by design, and that design is what keeps the per-sample path free of
indirection. VST3's `IParameterChanges` can carry multiple points inside one block.
Block-boundary application is standard, acceptable, and what most instruments do. Reading
the points sample-accurately would put a per-sample "has a parameter changed" question on
the hot path, which the phase-wide guardrail forbids in its current form. **Lean: block
boundary, explicitly, and say so — then revisit only if a user reports audible stepping on a
fast automation curve.**
### 3.8 Latency reporting — SETTLED, and it removes one control from the parameter list
**Fork Γ-F2 is ruled (Daniel, 2026-08-01): the limiter has lookahead and the plugin reports
latency DYNAMICALLY** — zero when the limiter is off, the lookahead when it is on. Do not
plan against a zero-latency instrument.
The consequence for this doc is concrete and it is a **subtraction from the parameter list**:
> **The limiter enable is NOT automatable.** It goes in §3.4's third class, and the reason
> is not that it lacks a live path — it is that changing it changes the plugin's reported
> latency, and the vendored SDK defines `restartComponent(kLatencyChanged)` as *"the host
> has to deactivate and reactivate the plug-in"*
> (`pluginterfaces/vst/ivsteditcontroller.h:105-108`). In this plugin a deactivate frees
> every sounding voice and a reactivate re-decodes the WAV. **An automation lane toggling
> that parameter would deactivate the plugin on every flip.**
Two corollaries the parameter work must carry rather than rediscover:
- **It is also not the plugin's `kIsBypass` parameter.** A safety limiter is not a bypass;
binding it to `kIsBypass` would hand the host a control that restarts the component.
- **Latency reporting must be derived from persisted state, not from a transient.** The SDK
states the new latency is what `getLatencySamples` returns *after* `setActive(true)` — and
this plugin's `setActive(false)` frees essentially everything. Whatever holds the limiter
flag must survive that cycle.
Full reasoning, the SDK quotes, and the required verification steps are in
`docs/product/instrument-control-surface.md` §3.1.1. **One fork remains open there
(Γ-F6)** — whether the deactivate/reactivate cost is acceptable in REAPER, with constant
reported latency as the pre-agreed fallback. If that fallback is taken, the limiter enable
becomes automatable again and this section shrinks to a footnote; check which way it went
before writing the parameter list.
---
## 4. The sequencing assertion — the one thing this doc claims
**The knob-taper work (Phase Γ item D) must land before the parameter system. Not
alongside; before.**
- Re-tapering is **free today** — normalization is a UI layer, the payload stores raw engine
doubles, and a re-taper moves the needle angle and nothing else (saved projects reload
bit-identical).
- Re-tapering is **permanently expensive after parameters ship** — the taper becomes the
host-facing normalization, and changing it re-interprets recorded automation in project
files we do not own and cannot migrate (§3.2).
The same argument, weaker but real, applies to `DeckParam` additions and reordering (§3.1):
Phase Γ adds Rate and Pitch to that enum, and doing it now costs one exhaustive-switch
update; doing it after parameters ship costs a frozen-id decision.
**So the tension the brief flags is real, and it resolves in Γ's favour.** Both item D and
the parameter system touch `deck_values.cpp` and `editor_controls.cpp`, and the ordering is
not a coin flip: item D first, by a wide margin.
---
## 5. Open questions, collected
Not one of these is a Phase Γ blocker. They are what a future phase's first wave answers.
1. **Blob vs. parameters as the source of truth** (§3.3) — the architectural decision, made
first, before any wiring.
2. **`ParamID` space** — direct `DeckParam` values, or an independent frozen table (§3.1).
*Lean: independent.*
3. **The discrete/rebuild-tier controls** (§3.4) — for each of voice count, Poly/Mono,
Retrig/Legato, Gate/Trigger, Staged/Spline, pitch engine, filter law: gain a live path,
expose as non-automatable, or omit from the parameter list entirely.
4. **Per-parameter live/latched classification** (§3.5) — the three-state predicate Phase Γ
opens must be filled in for every exposed parameter, including whether Rate is lifted to
live and at what cost.
5. **Unit tree shape** (§3.6) — mirror the deck's visual grouping, or the signal flow.
6. **Sample-accurate vs. block-boundary** (§3.7). *Lean: block boundary, stated explicitly.*
7. **Does the extension's `instrument_drop` need to know?** It writes the same
`ComponentState` bytes. If parameters become the source of truth for automatable values
(option (a)), a blob the extension writes would no longer fully determine the instrument's
sound. That is a cross-artifact contract change and it is the sharpest hidden cost of
option (a).
8. **Migration.** Every saved project predates parameters. On first load a parameter's value
comes from the blob; after that the host owns it. Confirm that path is lossless, and
confirm what happens to a project saved *by* the new build and opened by an older one
(the blob still carries everything, so it should be fine — verify, do not assume).