diff --git a/docs/PLAN.md b/docs/PLAN.md index 788167d..8ad4cbd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -2,7 +2,9 @@ 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 -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 @@ -23,8 +25,8 @@ dispatched against directly. cited section rather than reading a file whole. **Worktree slug convention:** `p-w-t-`. Greek phase letters -transliterate: **Θ → `th`**, **Ξ → `xi`**. So Θ-W1-T1 dispatches into -`pth-w1-t1-zone-retirement`. +transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**. So Θ-W1-T1 dispatches into +`pth-w1-t1-zone-retirement` and Γ-W1-T1 into `pg-w1-t1-knob-interaction-law`. ## 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 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 -change, then advancing at 1/newDuration). No other track in this plan currently carries -an unanswered **[Daniel]**-class question. +change, then advancing at 1/newDuration). **Phase Γ opened five [Daniel]-class forks (Γ-F1…Γ-F5) and all five are ruled** (Daniel, +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 @@ -54,10 +61,28 @@ an unanswered **[Daniel]**-class question. 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. +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 -These bind every track in both phases and are stated once here rather than repeated -per track. +These bind every track in all three phases and are stated once here rather than repeated +per track. **Phase Γ adds five of its own**, stated in its phase header. ### 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.12–0.20 of + travel and 100 ms within 0.42–0.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 50–58 % 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.2–3.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 `_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.2–3.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 (n−1) 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 + 75–133 % 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 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 in this plan today; if others appear, they belong on this list rather than in the 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 @@ -713,8 +1441,21 @@ Phase Ξ — The resample loop (W1 concurrency-safe with Θ from Θ-W W1 Consolidated tracking, and the programmed-note model T1 tracking-consolidation .................. 17 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) W3 The capture-signal 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] ``` diff --git a/docs/TODO.md b/docs/TODO.md index 3276fdb..2e3d2fa 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. -## 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, -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. +**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. -**The wart.** Θ-W4-T2's acceptance criterion "VELOCITY sits immediately to the left of -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. +**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. -**Intended fix.** Not yet specified beyond the shape above (one row, taller decks, -within-deck knob stacking) — Daniel's further design notes are still to come. +**Two things landing in Phase Γ make it cheap afterwards.** +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.12–0.20 of travel, 100 ms within 0.42–0.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 -deferred rather than attempted against the current spec. +**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. -**The measured geometry — the data the rework starts from.** Measured at the 840 px -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. +**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.** -**Unresolved measurement discrepancy.** The VELOCITY↔VOICE adjacency threshold was -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. +**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. -**Priority / risk.** Deliberate deferral, not oversight. Daniel wants to finish his -design notes before this is attempted, to avoid building a layout that gets reworked -piecemeal. +## The deck layout rework — SPECCED, and the original shape SUPERSEDED -**Done looks like.** One row of decks, each much taller than today's, with each deck's -controls stacked internally (e.g. the filter's static control knobs above its envelope -knobs) per Daniel's design notes once they land. The VELOCITY↔VOICE adjacency criterion -is re-evaluated (or explicitly retired) against the new layout rather than the old -per-row one. +**Status (2026-08-01): no longer a deferral. The design notes Daniel owed this entry have +arrived, and they change the shape.** The rework is specced in +`docs/product/instrument-control-surface.md` §1 and sequenced as **Phase Γ** in +`docs/PLAN.md`. This entry is retained only until that work lands, because one loose end +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 diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md new file mode 100644 index 0000000..d5e5843 --- /dev/null +++ b/docs/product/instrument-control-surface.md @@ -0,0 +1,1273 @@ +# Instrument control surface — Phase Γ (ReaSampler 9000) + +The product-design reasoning behind **Phase Γ**: the deck's two-row reflow, the PITCH/RATE +deck, the MASTER bus deck (limiter + meter), a consistent knob interaction law, the staged +contour-trace defect, and a re-approach of the loop/crossfade markers. + +**Status:** items A–F below are **SETTLED (Daniel, 2026-08-01)** from a direct interview; +this doc records them, works out the design detail they imply, and states the arithmetic. +**The five forks this doc opened (Γ-F1…Γ-F5) were all ruled on by Daniel on 2026-08-01**; +their rulings are folded into the sections they affect and the rulings themselves are +recorded in §8. §8 also carries **one new fork, Γ-F6**, which the Γ-F2 ruling surfaced from +the vendored SDK and which Daniel did not have in front of him. **The forward-looking VST3 +automation parameter system is deliberately NOT in this phase** — it has its own doc, +`docs/product/parameter-automation.md`. + +Every geometry number below was re-derived from `src/core/instrument/ui/knob_deck.cpp`'s +own width formula, not carried over from a prior measurement. The stale geometry block in +`docs/TODO.md` ("The deck layout needs a real rework") is **superseded by this doc** — see +§7.6. + +--- + +## 0. TL;DR + +- **The spine is the reflow.** The deck's controls are two categories — *sound* and + *contour* — and the current three-row greedy wrap expresses neither. Row 1 is + **PITCH/RATE | FILTER | VELOCITY | VOICE** (sound). Row 2 is **PITCH ENV | FILTER ENV | + AMP ENVELOPE** (contour). **MASTER spans both rows on the far right.** +- **The arithmetic closes, with room.** Minimum/default window goes **980 × 680 → + 1190 × 680**, inside the settled 1280 × 720 ceiling with **90 px of headroom**. The deck + band drops **328 → 216 px**, returning **112 px to the waveform** (246 → 358 px at the + floor). **That 90 px is the governing budget for every future control addition** — one + deck cell is 60 px, so the layout has room for exactly one more, once. §1.6. +- **The two rows align exactly, not nearly.** At the floor width the row block is 1020 px, + and at that width row 2's two gutters are equal (72 px each) *and* FILTER's right edge + lands exactly on FILTER ENV's right edge (both at x = 636). That is the aesthetic tie + between the rows and it falls out of the arithmetic — §1.3. +- **PITCH becomes PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the existing + Varisp|Presrv toggle. Rate 50–200 % exponential, Pitch ±24 st. +- **MASTER becomes the post-voice-mixer deck it was always reserved to be**: limiter + toggle in the caption corner, gain knob upper-left, a full-double-height stereo peak + meter down the right, an averted-clip bubble, and a **one-cell** reserved lower-left + slot (Γ-F5). +- **The limiter is a lookahead design with DYNAMIC reported latency** (Γ-F2): zero when + off, the lookahead when on, reported to the host's PDC. That buys a transparent + true-peak limiter and costs a `restartComponent(kLatencyChanged)` on the toggle — which + the SDK defines as a host **deactivate/reactivate**, not a re-tap. §3.1.1 is the whole + of that cost and how it is contained. +- **The cortex limiter does not clear the bar** — §3.5. Read it, take nothing. +- **Loop gets an explicit enable on the chrome row** (Γ-F4), and the four-mark grammar + sits under it. The core finding behind the re-approach: three identical bars draw a + *point* and the two ends of a *span* in the same ink, and the crossfade is painted where + its *ingredient* lives rather than where the *event* is heard. §6. + +--- + +## 1. The reflow — the spine of the phase + +Daniel's framing, verbatim intent: the deck controls are **two categories** — *sound +controls* and *contour controls* — and the current three-row wrap is organizationally bad. + +### 1.1 The two categories + +| Row | Category | Groups (left → right) | +|---|---|---| +| **1** | **Sound** — what the voice *is* | PITCH/RATE, FILTER, VELOCITY, VOICE | +| **2** | **Contour** — how it *moves over time* | PITCH ENV, FILTER ENV, AMP ENVELOPE | +| **both** | **Bus** — what happens after the mixer | MASTER (double-height, far right) | + +Row 1 is **non-negotiably one row**. Row 2 is the three envelope decks. `deck_groups`' +existing signal-flow order (**pitch → filter → amp**) survives *within* each row, so the +two rows read down the same axis: row 1's first two groups are the sound stages whose +contours are row 2's first two groups. + +### 1.2 The measured layout + +`deckGroupWidth(g) = max(captionRowWidth, knobRowWidth) + 2·kDeckGroupPadX`, with +`captionRowWidth = captionWidth + Σ(kDeckToggleGap + 2·segWidth) + (radio ? 4 + 12 : 0)` +and `knobRowWidth = |cellIds|·kDeckCellW (+ 4 + 2·segWidth for a rowToggle)`. Metrics: +`kDeckCellW 60`, `kDeckCellH 74`, `kDeckKnobSize 40`, `kDeckCellLabelH 16`, +`kDeckCaptionH 20`, `kDeckToggleH 18`, `kDeckGroupPadX 6`, `kDeckGroupPadY 4`, +`kDeckCaptionGap 2`, `kDeckToggleGap 4`, `kDeckGroupGap 12`, `kDeckRowGap 8`, +`kDeckRadioSize 12`, `kDeckGroupH 104`. + +| Group | Row | Caption run | Knob run | **Width** | Δ | Control inventory | +|---|---|---|---|---|---|---| +| **PITCH/RATE** | 1 | 70 + 4 + 2·48 = 170 | 3 × 60 = **180** | **192** | +42 | 3 cells `Key Trk` / `Rate` / `Pitch`; caption toggle `Varisp\|Presrv` (48) | +| **FILTER** | 1 | 46 + 4 + 2·32 + 4 + 2·44 = 206 | 7 × 60 = **420** | **432** | −92 | 7 cells (morph, cutoff, Q, drive, mod amt, vel, key trk); caption toggle `Off\|On` (32); **caption toggle 2 `Band\|Notch` (44) — moved from the knob row** | +| **VELOCITY** | 1 | 54 | 3 × 60 = **180** | **192** | 0 | 3 curve-popup cells (amp, pitch, filter) | +| **VOICE** | 1 | 38 + 4 + 2·40 = 122 | 60 + 4 + 2·44 = **152** | **164** | 0 | 1 cell (voice count); caption toggle `Poly\|Mono` (40); **row toggle `Retrig\|Legato` (44) stays** — see note | +| **PITCH ENV** | 2 | 58 + 4 + 64 + 4 + 46 + 4 + 12 = 192 | 4 × 60 = **240** | **252** | 0 | 4 cells (A, H, D, Depth); caption toggle `Off\|On`; caption toggle 2 `Staged\|Spline`; corner radio | +| **FILTER ENV** | 2 | 66 + 4 + 46 + 4 + 12 = 132 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: A,H,D + 2 reserves); caption toggle 2; corner radio | +| **AMP ENVELOPE** | 2 | 78 + 4 + 88 + 4 + 46 + 4 + 12 = 236 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: Len,A,H,D + 1 reserve); caption toggle `Gate\|Trig` (44); caption toggle 2; corner radio | +| **MASTER** | **1+2** | 46 + 4 + 2·32 + 4 + 12 = **130** | 60 + 8 + 62 = **130** | **142** | +70 | 1 cell (gain, upper-left); 1 **reserved** lower-left slot; caption toggle `Limiter Off\|On` (32); corner **bubble** (12, passive); **meter column 62 px, full double height** | + +**Row totals.** + +| | Natural content | Gutters at floor | **Row width** | +|---|---|---|---| +| Row 1 | 192 + 432 + 192 + 164 = **980** | 12 + 14 + 14 = 40 | **1020** | +| Row 2 | 252 + 312 + 312 = **876** | 72 + 72 = 144 | **1020** | + +**Window floor.** + +``` +deck band width = 1020 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1174 +kEditorMinWidth = 1174 + 2·kPad(8) = 1190 +kEditorMinHeight = 680 (unchanged) +deck band height = 2·kDeckGroupH(104) + kDeckRowGap(8) = 216 (was 328) +waveform band at the floor = 680 − 90 (chrome) − 4 − 4 − 8 − 216 = 358 (was 246) +``` + +**1190 × 680, against a 1280 × 720 ceiling — 90 px of width headroom, 40 px of height.** + +Three corrections to the arithmetic in the brief, all small and all in our favour: + +1. **MASTER at 142, not ~236.** A 236-wide MASTER puts the floor at exactly 1280 — the + ceiling with zero slack. 142 is what the deck's own content actually needs (§1.4) and + it banks 94 px. MASTER may grow to **236** before the ceiling binds; that is the + meter's growth room, not a target. +2. **The row block is 1020, not 1016.** The extra 4 px is deliberate and is what makes the + two rows align exactly rather than 2 px apart — §1.3. It is the single cheapest + aesthetic purchase in the phase. +3. **VOICE keeps its row toggle** — confirmed. Moving `Retrig|Legato` to the caption gives + `38 + 4 + 80 + 4 + 88 = 214` → **226 px**, wider than 164, because VOICE's caption row is + the binding side and its knob row is nearly empty. Leave it. + +**PITCH/RATE's caption reserve is the one tight constant.** The group is 192 only while +`captionWidth ≤ 80` (above that the caption row `captionWidth + 100` overtakes the 180 px +knob row). "PITCH/RATE" is 10 characters; against the existing reserves (FILTER ENV, also +10 characters incl. a space, reserves 66) **70 is the specified value and 80 is the hard +ceiling**. If the text does not fit at 80, the fallback is to narrow the `Varisp|Presrv` +segments 48 → 44, which raises the ceiling to 88 — **not** to widen the group. + +### 1.3 The justification law, and why the two rows read as one surface + +**Do not stretch the decks.** Groups keep their natural widths; slack becomes inter-deck +gutters. The law applies to **both** rows, not just row 2 — that is what makes the outer +edges flush, which is the primary alignment signal: + +> **Both rows are justified space-between within the row block.** Slack = row block − +> Σ(group widths); it is divided equally among the row's (n − 1) gutters, with any integer +> residue distributed to the leftmost gutters. **No gutter is ever narrower than +> `kDeckGroupGap` (12).** MASTER is right-anchored outside the row block and is not part of +> either row's justification. + +Four things carry the visual consistency, and the first three are exact rather than +approximate: + +1. **Flush outer edges.** Both rows begin at the deck band's left inset and end at the row + block's right edge. PITCH/RATE and PITCH ENV share a left edge; VOICE and AMP ENVELOPE + share a right edge. +2. **The filter tie-line.** At the floor width the two rows' filter groups end on the same + pixel: + `row 1: 192 + 12 + 432 = 636` · `row 2: 252 + 72 + 312 = 636`. + That is not a coincidence to be preserved by a special rule — it is what row-block + width **1020** buys, and at 1020 row 2's two gutters are *also* exactly equal (72/72) + and row 1's smallest gutter is *exactly* `kDeckGroupGap`. Three good properties at one + width. **This is why the floor is 1190 and not 1186.** +3. **Shared horizontal baselines.** Every group is `kDeckGroupH` with identical interior + offsets, so across both rows the caption text, the knob centrelines and the label bands + sit on the same four lines. The reflow must not break this — it is free today and + becomes load-bearing once two rows are visible at once. +4. **MASTER is stitched to both rows, not parked beside them.** Its two left-column cells + sit at *exactly* the two rows' knob-row baselines (box-relative y = 26 and y = 138), so + the gain knob is in line with FILTER's knobs and the reserved slot is in line with AMP + ENVELOPE's. §1.4 shows the arithmetic is exact to the pixel. + +**Above the floor width, the tie-line drifts.** Both rows gain slack; row 1 divides it over +3 gutters and row 2 over 2, so the filter edges separate. That is accepted and deliberate: +pinning the tie-line at every width forces row 1's first gutter to grow at ~2× the rate of +its other two, which reads as sloppy at large widths. The **default size is the minimum +size**, so the exact case is the case almost every user sees; a stretched window reads as a +stretched window rather than as a near-miss. + +**The aesthetic reading, stated plainly.** Row 2 holds the contour of each of row 1's first +two stages — pitch and filter. Its third, AMP ENVELOPE, has no sound-row counterpart +because *its* static control is level, and level lives in MASTER. That is exactly why +MASTER is the deck that spans both rows: it is the one axis whose static half and whose +contour half sit on different rows. The double-height deck is therefore a statement about +signal flow, not a container for a leftover knob. + +### 1.4 MASTER — the double-height geometry + +The double-height box is `2·kDeckGroupH + kDeckRowGap = 216`, and the interior lands on the +row baselines exactly: + +``` +box y +0 top + +4 padY + +4..+24 caption row (kDeckCaptionH 20) ── caption text · limiter toggle · bubble + +26..+100 left cell A (kDeckCellH 74) ── GAIN knob + label [row 1 baseline] + +100..+138 interior seam (38 px) + +138..+212 left cell B (kDeckCellH 74) ── RESERVED [row 2 baseline] + +212..+216 padY + +26..+212 meter column (186 px) ── full double height +``` + +Row 1's groups place their cells at box-relative +26; row 2's box top is +112 and its cells +at +138. MASTER's two slots land on both, and the bottom padding closes at exactly 4 px. + +Horizontally the group is `6 + 60 + 8 + 62 + 6 = 142`. + +**Three rules an engineer must not generalise wrongly:** + +- **MASTER's left column uses FIXED cell slots at the two baselines. It does NOT use the + horizontal run-division law** (`knob_deck.h`: "the cells present divide the whole + reserved run"). Applying that law vertically would stretch the single gain knob over the + full 186 px. The reserved lower slot reserves *height at a fixed position*. +- **The reserved slot draws nothing.** Blank interior reads as breathing room; a dashed + placeholder reads as unfinished. It is reserved in *layout* only, so adding a control + later reflows nothing. +- **The meter column is one rect, spanning both baselines.** It is not two per-row meters. + +### 1.5 What the reflow costs, and what it returns + +| | Before | After | +|---|---|---| +| Deck rows at the floor width | 3 (by greedy wrap) | **2 (by construction)** | +| Deck band height | 328 | **216** | +| Waveform band at the floor | 246 | **358** | +| Minimum / default window | 980 × 680 | **1190 × 680** | +| Ceiling headroom | — | **90 px wide, 40 px tall** | + +**Costs, named.** The floor width grows by 210 px — an existing saved instance's window +grows on open (the same one-time effect Θ-W6-T1 already shipped at 840 → 980, so the +behaviour is precedented, not new). The deck's wrap mechanism stops being the thing that +decides row membership at the floor width (§7.3). And the phase spends its ceiling headroom +budget — §1.6. + +### 1.6 The 90 px headroom is the budget, and it governs every future control + +**Read this before proposing any new knob.** The floor is **1190** against Daniel's hard +**1280** ceiling. That is **90 px of width headroom for the life of this layout**, and it is +the single constraint every later addition spends from: + +| Purchase | Cost | Headroom after | +|---|---|---| +| One more 60 px deck cell on row 1 | 60 | 30 | +| One more caption toggle on a group whose caption row is the binding side | 0–48 | 42–90 | +| Widening MASTER to a two-cell left column | 60 | 30 | +| A second cell *and* a wider MASTER | 120 | **over ceiling** | + +**This is why MASTER's reserved lower-left slot is ONE cell and not two** (Γ-F5, ruled by +Daniel 2026-08-01). A two-cell reserve would spend 60 of the 90 up front, on a control +nobody has named yet, and would effectively freeze row 1 forever: any later row-1 addition +would then need the remaining 30 px and would not have it. One cell keeps the spare. If the +future master-bus control turns out to be two knobs, widening MASTER **then** costs the same +60 px it would cost now, and by then the trade is being made against a real control instead +of a guess. **Reserving capacity you have not designed a use for is not free here — it is +the whole budget.** + +Two corollaries for a reader who wants to add something: + +- **A caption toggle is the cheap slot; a cell is the expensive one.** A group whose caption + row is narrower than its knob row absorbs a toggle for nothing (that is exactly what + FILTER's `Band|Notch` move exploits). A cell always costs its 60 px. +- **The chrome row is a separate budget.** The toolbar row's right-anchored control run is + paid for out of the *title* slot, not out of the window floor — which is why the loop + enable (§6.5) costs zero of the 90. That is a genuinely different purse and must not be + confused with this one. + +--- + +## 2. The PITCH/RATE deck + +**Settled.** The PITCH deck becomes **PITCH/RATE** and carries three knobs plus the +existing mode toggle. Left to right: **`Key Trk | Rate | Pitch`**. `Key Trk` is the +existing control, unmoved. + +### 2.1 The two new controls + +| | **Rate** | **Pitch** | +|---|---|---| +| Range | 50 % … 200 % | −24 … +24 semitones | +| Default | **100 %, at true knob centre** | 0 st, at true knob centre | +| Taper | **exponential — linear in semitones over ±12** (50 % = −12 st, 200 % = +12 st) | **log2 / centre-expanded** (§4.3) | +| Display | `%`, one decimal below 100 % | `st`, signed, one decimal | +| Unit category (§4) | semitone | semitone | +| Commit tier | **live-published, note-on-latched** (§2.3) | **live** | +| Range constant | its own ±12 st | reads `kPitchDepthMaxSemis` — **do not mint a second ±24** | + +Pitch's ±24 is deliberately the same throw the pitch envelope's depth and the +velocity→pitch curve already speak (`kPitchDepthMaxSemis = kVelocityPitchRangeSemitones = +24.0`, `deck_values.h:26`). **That constant is load-bearing in the v12 wire format +(`component_state_io.h:93-95`) and must not change** — reusing it is the point; retuning it +is forbidden. + +### 2.2 What the existing pitch-engine toggle now governs + +The `Varisp|Presrv` toggle stays in the caption corner and now governs both new knobs: + +- **Varispeed.** Keytrack ratio × rate ratio × pitch-offset ratio **all compound into a + single read-increment multiply**. The rate offset applies to the varispeed pitch — i.e. + under Varispeed, Rate is a *pitch* control that happens to be labelled in %, and the two + knobs are two views of one multiply. This composes with the pitch envelope's existing + per-frame multiply of `ratio_`; it adds no new per-sample stage. +- **Preserve.** **Rate is an absolute value driving duration only**; keytrack and the pitch + offset drive the pitch shifter. This is the mode where Rate is a genuine time-stretch. + +**Guardrail — do not misread this as reopening a settled invariant.** +`core/instrument/CLAUDE.md` says *"`WDL_Resampler` is not a Preserve engine (it is a +resampler that couples duration) — never wire it as the duration-preserving path."* Under +Preserve, Rate is *supposed* to change duration; a Preserve implementation that resamples +the read rate and cancels the resulting pitch shift in the shifter is an explicit +duration control, not a covert Preserve path. The invariant forbids using a resampler *as* +the pitch-preserving mechanism, and that prohibition stands. + +### 2.3 Rate is latched at note-on — and the reason matters more than the rule + +**Settled: Rate is latched at note-on for this phase (not live on sustaining voices).** +Two consequences the implementation must get right: + +**It is a latch, not a reload.** `isLiveDeckParam` is currently a binary predicate whose +`false` branch routes an edit to a **full reload** (bridge read, WAV re-decode, fresh +engine) or an engine rebuild. Routing a swept knob down that path is unacceptable. Rate is +therefore a **third commit class**: *published into the live block like any live parameter, +but read only by `snapLive` at note-on and never by `applyLive` on a sounding voice.* The +mechanism already exists — the invariant "A fresh note SNAPS, a sounding one holds φ" is +exactly this split — but the *classification* does not. + +> **Where this is recorded.** `core/instrument/CLAUDE.md` states that *"which controls are +> live is ONE decision, recorded in ONE place"* — `isLiveDeckParam` / `liveCommitFor` in +> `ui/deck_groups`. Phase Γ widens that one decision from two states to three +> (`Live` / `NoteOnLatched` / `Reload`) rather than adding a second predicate elsewhere. +> This is also precisely the seam the automation work needs — see +> `docs/product/parameter-automation.md` §3. + +**Why Rate specifically.** Rate is not latched because live rate would sound bad. It is +latched because **loop points scale with rate and contours scale with rate** (settled), and +both are note-on folds: `resolveLoop` runs once per note-on, and a normalized contour is +resolved against the note's own span. Making Rate live means re-folding the resolved loop +and re-mapping the contour mid-note, on a sounding voice, without a discontinuity. That is +a real feature, not a plumbing detail, and it is out of scope here. **Recording the reason +is what makes the latch principled and tells the automation work exactly what it would have +to build to lift it.** + +Pitch is live because it is not implicated: under Varispeed a live pitch offset is one more +factor in a per-frame `ratio_` multiply the pitch envelope already performs, and under +Preserve it is an addend to a shift amount the pitch envelope already modulates. + +### 2.4 Rate scaling — what "scales with rate" means, concretely + +- **Loop points scale with rate.** The loop is a pair of *source-frame* facts. Under + Varispeed the read increment changes and the loop is traversed proportionally faster — + scaling is automatic and the stored frames are untouched. Under Preserve the read + advances at `rate ×` the source rate, so the loop's wall-clock period scales by `1/rate` + while its source-frame span is unchanged. **In neither mode are the stored loop frames + rewritten**; the marks on the waveform do not move when Rate moves. +- **Contours scale with rate.** A drawn contour is a pure function of *normalized* sample + position (`core/instrument/CLAUDE.md`: "Normalized is what makes a contour + length-independent"), so it follows the read head by construction. **The staged + envelopes' stage times are wall-clock seconds and do NOT scale with rate** — an attack of + 30 ms is 30 ms at any rate. That asymmetry is correct and deliberate: a contour is + *of the sample*, a staged envelope is *of the performance*. + +### 2.5 The Preserve time-stretcher — quality bar, not algorithm + +**Preserve mode has no pitch-preserving time-stretch DSP today.** `pitch_shift` is a +correlation-aligned SOLA *pitch* shifter; composing it with a resampled read yields a +working stretch, and that composition is the **interim path** Γ-W2-T1 ships so Rate is a +complete feature the day it lands. A **real stretcher, written from established +state-of-the-art literature**, follows as its own track. + +**Do not pick an algorithm in this document.** 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 file I/O, no lock in `process()`. Any window/FFT/analysis + buffer is sized and allocated at voice allocation or at the off-audio-thread reload, on + the `pitch_shift` pre-warm precedent. +- **Per-voice state**, and it must hold up at the **32-voice polyphony ceiling** — the + measure-and-report gate is 32 simultaneous Preserve voices at an extreme rate (50 % and + 200 %), not one voice at 100 %. +- **No new third-party dependency**, matching `pitch_shift`'s standing property. +- **No dispatch on the per-sample path** (phase-wide guardrail): 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.** +- **Quality bar.** No audible metallic/phasey artefacting on sustained tonal material at + ±6 st and 75–133 % rate; no smearing of transient material at 50 %/200 % worse than the + interim resample+SOLA path; the null case (rate 100 %, no shift) must be **bit-identical + to the un-stretched read**. +- **Gate.** 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). + +--- + +## 3. MASTER — the post-voice-mixer deck + +`deck_groups.cpp` already records the reservation: *"MASTER is reserved for +post-voice-mixer concerns, which is why the curves sit in their own group immediately left +of VOICE rather than there."* Phase Γ **fulfils** that reservation rather than contradicting +it. + +**The signal chain, stated once:** + +``` +voice mixer → master gain (existing ramped multiply) → LIMITER (bypassable) → output bus + └── METER TAP +``` + +The meter is tapped at the **audio bus output, post-limiter** (settled). + +### 3.1 The limiter + +**Settled: a single toggle, no configurable controls. Baked ceiling at −0.3 dBTP.** Daniel's +framing: *"this is a safety device with potential for musical abuse, not a whole +configurable limiter."* + +Behaviour: + +- **Toggle only**, in MASTER's caption corner (a two-segment `Off|On` caption toggle, the + same primitive `kFilterEnable` uses). Persisted in `ComponentState`. +- **Default: off.** The migration bar ("a project saved before a change reopens sounding + identical") forbids any other default — an absent field must lift to bypassed. +- **Transparent at rest.** No makeup gain, ever. No upward gain of any kind. When nothing + exceeds the ceiling the output is **bit-identical to the un-limited path**. +- **Byte-identical when bypassed.** With the limiter off, the per-sample path must be + 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. This is an acceptance criterion, not + an aspiration. +- **Ceiling −0.3 dBTP.** dBTP is a *true-peak* target, so the detector must see + inter-sample peaks — the standard route is an oversampled peak detector **in the sidechain + only**, never oversampling the signal path. The oversampling factor is the engineer's + call under the measure-and-report gate. +- **Stereo-linked detection** (max of |L|,|R| drives one gain), so the stereo image is not + moved by the limiter. +- **Gain reduction is published per block** for the bubble indicator (§3.3). +- **Lookahead, with DYNAMIC reported latency** — §3.1.1. + +### 3.1.1 Lookahead and dynamic latency (Γ-F2, ruled by Daniel 2026-08-01) + +**Settled: the limiter has lookahead, and the plugin reports latency dynamically.** +Daniel's reasoning, verbatim intent: true-peak detection needs oversampling and a +transparent limiter wants lookahead, and he is willing to pay the latency **provided it is +latent only when the limiter is ON and the latency is reported to the host's PDC system.** +This overrides the zero-lookahead recommendation this doc previously carried. + +**The behaviour, stated as the contract:** + +- Limiter **off** → `getLatencySamples()` returns **0**. +- Limiter **on** → `getLatencySamples()` returns the lookahead in samples. +- The toggle calls `IComponentHandler::restartComponent(kLatencyChanged)`. + +**None of that exists today.** There is no `getLatencySamples` override anywhere in +`src/`, no `kLatencyChanged`, and no `restartComponent` call site — the plugin ships the +SDK default of **0**. This track is the first latency reporting the instrument has ever +done, so there is no existing behaviour to preserve, only a new contract to get right. + +#### What the vendored SDK actually says, and why it is more expensive than it looks + +Two facts read directly out of `vendor/vst3sdk`, both load-bearing: + +> `pluginterfaces/vst/ivstaudioprocessor.h:293-299` — *"If during the use of the plug-in +> this latency change, the plug-in has to inform the host by using +> `IComponentHandler::restartComponent (kLatencyChanged)`, **this could lead to audio +> playback interruption** because the host has to recompute its internal mixer delay +> compensation. Note that for player live recording this latency should be zero or small."* + +> `pluginterfaces/vst/ivsteditcontroller.h:105-108` — *"`kLatencyChanged`: … **The host has +> to deactivate and reactivate the plug-in**, then afterwards the host could ask for the +> current latency."* + +The second is the sharp one. `kLatencyChanged` is not a "re-read the number" flag — the +SDK defines it as a **deactivate/reactivate cycle**. And in *this* plugin, +`ReaSamplerProcessor::setActive` is deliberately destructive in both directions +(`reasampler_processor.cpp:85-109`): + +- `setActive(false)` frees `live_`, `draining_`, **and** the graveyard — *every sounding + voice dies*. The comment there explains why that is correct and must not be softened + casually: a surviving `live_` would be displaced into the drain slot on reactivate and + *"resurrect stale sustained voices as ghosts."* +- `setActive(true)` calls `reloadInstrument()` — a bridge read, a **WAV re-decode**, and a + fresh engine. + +**So the honest cost of the toggle is: every sounding note stops, and the sample is +re-decoded from disk.** That is a materially heavier consequence than "a brief click," and +it is the reason §8's new fork Γ-F6 exists rather than this being fully closed. + +#### The standing scar, and why this is nonetheless not the forbidden change + +`reasampler_processor.cpp:66-68` carries a warning in the codebase's own words: + +> *"Do not reintroduce per-mode bus renegotiation: flipping `kMono`↔`kStereo` via +> `restartComponent` previously panned a dual-mono capture hard right in the host's pin +> re-routing (see `testDualMonoStereoSampleRendersCentered`)."* + +and `src/shell/instrument/CLAUDE.md` elevates that to an invariant. + +**A `kLatencyChanged` restart is a different flag from the `kIoChanged`-class bus +renegotiation that caused that regression, and it is NOT forbidden by that invariant** — +the output bus stays permanently stereo and its arrangement is never renegotiated. But the +precedent stands: **mid-session `restartComponent` in this plugin has already shipped one +real regression**, in the host's re-routing rather than in our code. That history is the +reason the following are acceptance criteria and not suggestions: + +1. **Verify the whole call sequence against the vendored Steinberg SDK** before writing it + — `IAudioProcessor::getLatencySamples`, `IComponentHandler::restartComponent`, the + `RestartFlags` value, and the SDK's stated ordering (*the new latency is what + `getLatencySamples` returns **after** `setActive(true)`*, per `ivsteditcontroller.h:106` + — so the reported value must be derived from persisted state, not from a transient the + deactivate clears). +2. **Prove the restart does not disturb the output bus arrangement.** After a + latency-change restart the bus is still one stereo output with the same arrangement, + and a dual-mono capture still renders centered. +3. **Ship a regression test in the spirit of `testDualMonoStereoSampleRendersCentered`** — + a dual-mono capture rendered across a limiter toggle stays centered, with equal L and R. + That test is the guard against the exact failure mode the scar records. +4. **Never call `restartComponent` from `process()`.** It is a main/UI-thread call. The + toggle already arrives on the UI thread; the restart is issued there, and coalesced so a + user clicking the toggle repeatedly produces one restart per settled state, not one per + click. + +#### Flipping the toggle during playback — the product decision + +**Ruling (mine, not deferred): the toggle applies immediately, the restart is requested +immediately, and the resulting interruption is accepted and documented. It is NOT deferred +to a transport boundary.** Three reasons, in order of weight: + +1. **A deferred restart is a silent lie.** If the limiter's audio engages now but the + reported latency lands at the next transport stop, the plugin is misaligned by the + lookahead for however long that takes — and a *timing* error on an instrument is + invisible until it is printed. A visible interruption beats an inaudible misalignment. +2. **We do not actually control the timing.** Per the SDK, the plugin *requests*; the host + schedules the deactivate/reactivate. Deferring our request buys uncertainty, not + determinism. +3. **The instrument is played live, not only sequenced.** Auditioning a patch with the + transport stopped is the common editing case; a transport-boundary deferral would mean + the restart never lands at all in that case, which is the worst outcome of the three. + +The mitigations that make this acceptable rather than merely defensible: + +- **The limiter's own output has no hard step.** Within the plugin, the engage/disengage is + covered by a short (≤ 10 ms) equal-gain crossfade between the pre- and post-toggle paths, + so whatever the host does around it, we do not emit a discontinuity of our own making. +- **The toggle is framed as a patch-design control, not a performance control.** It is set + once while building a sound. The editor should not encourage flipping it while playing, + and nothing in the UI should make it a per-take gesture. +- **The limiter enable is explicitly NOT automatable.** This is the load-bearing + consequence and it must be recorded where the parameter work will read it: an automation + lane toggling a latency-changing parameter would request a host deactivate/reactivate on + every flip. See `docs/product/parameter-automation.md` §3.8 — the limiter enable belongs + in the **not-automatable** class, and it is emphatically not the plugin's `kIsBypass` + parameter either. +- **The interruption is verified in REAPER, and its severity recorded.** The SDK mandates + the deactivate/reactivate; *what REAPER actually does with it* — whether sounding notes + cut, whether the re-decode is perceptible, whether transport hiccups — is DAW-verifiable + only. That verification is W1-T2's first deliverable, and its outcome is what closes + Γ-F6. + +### 3.2 The meter + +Vertical, 62 px wide, 186 px tall, down MASTER's right side, spanning both row baselines. + +| Property | Decision | Why | +|---|---|---| +| **Bar count** | **One wide bar when the waveform draws one lane; two skinnier bars when it draws two** | The bar count is resolved by the **same `LaneSplit` decision `waveformSurface` already folds** (channel mode ∧ source channel count) — not a second rule. A mono source in stereo mode is dual-mono: L ≡ R, and two identical bars would be a lie. One source, two views. | +| **Bar geometry** | 22 px label gutter · 4 px gap · 36 px bar field. Mono: one 36 px bar. Stereo: two 17 px bars, 2 px apart. | | +| **Scale** | **Linear in dB, −60 … +6 dBFS.** Ticks every 6 dB; numerals at 0, −12, −24, −36, −48, −60; the 0 dB tick drawn heavier. | 66 dB over 186 px = 2.8 px/dB; 34 px between numerals at `Font::Micro`. Honest and simple; an expanded-top scale was considered and rejected as harder to read against a numeric label. Above 0 is shown because it is exactly what the limiter-off case needs to make visible. | +| **Ballistics** | **Rise: instantaneous** (a peak displays on the first UI frame after it occurs). **Fall: 20 dB/second.** | A peak meter must not smooth its attack or it under-reports. 20 dB/s is close to the IEC 60268-18 PPM fallback (20 dB in 1.7 s) and reads as responsive without flicker. | +| **Peak hold** | A 2 px horizontal tick at the running max, in `text/primary`. **Holds 1.5 s** after its last update, then falls at the same 20 dB/s. | A neutral bright tick reads cleanly over the bar's accent ink; a second accent would compete. | +| **Clip** | A cap at the top of the meter, latched `warn` when any block peak ≥ 0 dBFS. **Click to clear.** | | +| **Bar ink** | `accent/primary` — it *is* the live signal. | `warn` stays reserved for clip states (`visual-design-language.md` §2.1). No green/yellow/red segmentation. | + +**The clip indicator earns its keep precisely because the meter is post-limiter.** With the +limiter engaged, a post-limiter clip is essentially impossible; with it bypassed and gain +driven up (the knob reaches +24 dB), clipping is easy. So the indicator quietly teaches +what the toggle does: drive the gain, see red; engage the limiter, red stops. If the clip +cap ever latches *while the limiter is on*, that is a defect report, not a user error. + +**RT discipline.** The audio thread publishes, per block, 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 timer converts and +runs the ballistics from the published block peaks and elapsed time. This matches the +existing advisory-peak shape (`reasampler_processor.h:109-113`) and the standing rule that +observation happens at block boundaries, never per frame. The existing advisory peak is +*not* reusable as-is — no dB, no ballistics, no hold, no clip, mono only — but it is the +right pattern to widen. + +### 3.3 The gain-reduction bubble + +**Settled: the limiter shows a red bubble when the threshold is crossed and any gain +reduction is applied.** + +- A **round** 12 px lamp in the caption row's far corner — the slot the three envelope + decks use for their overlay radio. **Round, not square**, so it reads as a lamp rather + than a control; the limiter toggle sits one slot to its left, which is the existing + right-to-left caption grammar unchanged. +- **Non-interactive.** Either the existing `captionRadio` geometry with hit-test + suppressed, or a passive-indicator slot in `knob_deck` — an implementation call, but the + *slot* is the existing one and no new geometry is invented. +- Ink: **`warn`.** Legal under the palette's "warn is reserved for clip states" rule + because gain reduction reports an **averted clip** — the same state class the clip cap + reports, one stage earlier. +- Lit whenever the block's maximum gain reduction exceeds a small floor (the intent is "the + limiter is working," not "a sample touched the threshold"); it follows the same + 20 dB/s-style decay as the meter so a transient catch is visible rather than a + single-frame flicker. + +### 3.4 Where the master controls sit in the reset scope + +Phase Ξ-W2's resample reset scope is settled by rule ("reset what the bake baked in"). +Derived against that rule, surfaced for Ξ-W2's review rather than as a Daniel call: +**rate → reset**, **pitch offset → reset**, **limiter enabled → reset** (master gain is +already on the reset list, so the bake includes the master stage, so the limiter's effect +is in the audio). + +### 3.5 Assessment: the `temp_cortex/` limiter reference + +Read in full (`temp_cortex/limiter_base.{hpp,cpp}`, `temp_cortex/fast_limiter.{hpp,cpp}`, +commit `3ad3094`). Standing project rule: cortex code is a *reference*, not a transplant — +if there is character in it worth having, it gets rebuilt as an explicit parameter rather +than inherited as a side effect. + +**Verdict: it does not clear that bar. There is no character in it worth having.** Read it +as a reminder of the shape, take nothing. + +What is structurally right: detection on the un-delayed signal with the gain applied to a +delayed copy (that *is* correct lookahead), and stereo-linked max detection. Both are +textbook and need no reference. + +Everything else is disqualifying: + +| Finding | Why it disqualifies the code | +|---|---| +| **Unconditional makeup gain** — `makeupGain = ceilingLinear / thresholdLinear`, applied on every sample whether or not anything is limiting (`fast_limiter.cpp:121,129`). At the defaults that is a permanent **+2.9 dB**. | Directly contradicts "a safety device": the toggle would change loudness at rest. This is *exactly* the inherited-side-effect the standing rule exists to catch. Our limiter has **no makeup gain at all**. | +| **`uint8_t lookaheadSamples`** (`fast_limiter.hpp:14`, computed at `.cpp:12`). 5 ms at 96 kHz = 480 → silently wraps to 224. | An embedded-platform assumption (fixed low rate) that does not survive a DAW. | +| **`uint8_t peakHoldSamples`, with dead guard code** — `if (peakHoldSamples > 255)` (`.cpp:50`) can never be true; the cast already truncated. | Same class of defect, plus the guard reads as protection that isn't there. | +| **Two `powf` calls per sample** to convert constant dB values to linear (`.cpp:94-95`). | A transcendental on the per-sample path. The house rule (`engine/loop/CLAUDE.md`) explicitly forbids one there. | +| **Cascaded double smoothing** — `envelope` is attack/release smoothed, then `currentGain` is smoothed again with the same coefficients (`.cpp:85-91`, `112-118`). | The realized timing is not the stated timing; the timing constants mean nothing. | +| **A near-instant attack (0.01 ms) behind a 5 ms lookahead** (`limiter_base.cpp:6`). | The lookahead's whole purpose is to let the gain reach its target *before* the peak arrives. With an instantaneous attack the lookahead only delays audio. | +| **`virtual void process(float[2], …)` called per frame** (`limiter_base.hpp:16`). | A vtable dispatch on the per-sample path — the phase-wide "no dispatch-stack blowouts anywhere" guardrail. | +| **Sample-peak only, no ISP detection.** | Cannot meet the settled −0.3 dBTP ceiling as written. | +| **Startup mute** — outputs silence until the delay line fills (`.cpp:132-135`); raw `new[]`/`delete[]`; depends on unvendored `CircularBuffer.h` and `basicmaths.h`. | Embedded idiom, not house idiom. | + +**Recommendation: write ours from the literature against §3.1's constraints, and delete +`temp_cortex/` once the limiter lands** (its removal is staff-engineer's, not mine). + +--- + +## 4. The knob interaction law (item D) + +Daniel scoped this as its own work item: a **consistent, unit-category-driven** interaction +and taper rule across **every** variable control. The load-bearing property is that the rule +is derived from the control's *unit*, so a control added later inherits it without anyone +maintaining a list. + +### 4.1 Modifiers + +| Gesture | Effect | +|---|---| +| **Shift** | Snap to whole numbers **in the control's displayed unit** (§4.2). | +| **Ctrl** | Scale the drag by **0.05** (1/20 sensitivity) — fine grain. | +| **Shift + Ctrl** | **Shift wins; Ctrl is ignored.** Not a compromise: when the output is quantized to integers, a finer drag produces the same sequence of values. Stated so nobody "fixes" it later. | + +**Mid-drag modifier changes re-anchor.** The knob drag is grab-anchored absolute +(`param_slider.cpp:138-143`, `kKnobDragRangePixels = 128`), so flipping a modifier mid-drag +without re-anchoring makes the value jump by `(1 − 0.05) ×` the accumulated delta. + +> **On every modifier transition — press or release — during an active drag, the drag +> re-anchors: the control's current value becomes the new anchor value and the cursor's +> current position becomes the new anchor position.** The value is continuous across the +> transition; only the rate changes. This holds for Shift too: releasing Shift re-anchors +> from the *snapped* value, so a snapped knob does not jump back. + +**Plumbing.** `wndProc` currently discards `wParam` for `WM_MOUSEMOVE`. The codebase already +reads modifiers via `GetKeyState` in two other input paths +(`editor_input_curve.cpp:50`, `editor_input_waveform.cpp:38`) — the precedent exists; the +requirement is that **all drag surfaces read it through one shared helper** so they cannot +drift into two modifier grammars. + +**Scope — and one explicit exclusion.** The law is a property of the **parameter**, not the +widget. Every surface that edits a unit-valued parameter honours it: deck knobs (outer ring +*and* inner curve dial), envelope stage nodes, and curve knots. This follows from the +standing invariant that node drags, knot drags and knob edits are *"surfaces onto ONE +model"* — a snap available on one and not the others would be a divergence. + +> **Waveform markers (start, loop start, loop end, crossfade) are excluded.** They carry an +> existing zero-crossing snap gesture, and their domain is frames, which has no meaningful +> "whole number" above the frame. Overloading Shift there would collide with a shipped +> gesture. Recorded as a deliberate exclusion. + +### 4.2 The snap unit, by category + +| Unit category | Controls | Shift snaps to | +|---|---|---| +| **milliseconds** | the 14 stage-time knobs (amp/filter Gate A,H,D,R; amp/filter Trigger A,D; pitch env A,D) | whole **ms** | +| **semitones** | pitch env depth, the new **Pitch**, and **Rate** | whole **semitones** — on Rate this means the 25 semitone steps between 50 % and 200 %, which is what makes an octave or a fifth reachable by hand | +| **percent / fraction** | sustain level, hold fraction, Trigger length, key-track (both), filter mod amt / vel amt / morph / cutoff / Q / drive | whole **percent** | +| **exponent** | the 12 inner curve dials (0.1 … 10) | whole numbers — which puts **1.0, the linear neutral, one snap away** | +| **decibels** | master gain | whole **dB** | +| **already integer** | voice count, preview velocity | no change | + +### 4.3 The tapers + +**Both taper changes are SAFE for persistence and require no format bump.** The +`ComponentState` payload stores raw engine values as doubles — seconds, semitones, curve +exponents (`params_payload.cpp:358-408`). Normalization exists *only* in +`ui/deck_values.cpp` as a UI display/interaction layer. **Re-tapering moves the needle +angle and nothing else; saved projects reload bit-identical and sound identical.** + +**Millisecond knobs become log-scaled.** More resolution across 1–100 ms while still +reaching the ceiling. A pure log map cannot include zero, and zero is a required value, so +the taper is stated as acceptance criteria rather than a formula (the engineer picks the +shape): + +- Exactly **0 s at norm 0** and exactly `kEnvTimeMaxSeconds` at norm 1. Monotone and + continuous throughout. +- **10 ms lands within 0.12 … 0.20 of travel; 100 ms within 0.42 … 0.52.** +- `kEnvTimeMaxSeconds` is **2.0 s** (`deck_values.h:22`, reading `kGateStageMaxSeconds` at + `envelope_overlay.h:85`) and **does not move in this phase** — settled, Γ-F3. The AHDSR + overlay's schematic scale is derived from it and the two must agree so a maxed knob lands + exactly at the canvas edge; the agreement requirement is documented at + `deck_values.h:19-22`. **Daniel foresees wanting a 10 s ceiling** for sound-design cases + — his example, *"a horrifically long decay with tight exp"* — and that ambition is + carried as a `docs/TODO.md` entry rather than dropped. Two things make it cheap later and + both land in this phase: the log taper is precisely what makes a higher ceiling *usable* + rather than unusable at the low end, and the reset-bypasses-the-taper change below + removes the power-of-two dependency that would otherwise block a 10.0 s ceiling outright. + +**Semitone knobs become log2-scaled.** More resolution across −7 … +7 st while still +allowing the extremes: + +- Symmetric about the centre; **exactly 0 at norm 0.5**; exactly ±`kPitchDepthMaxSemis` at + the ends; monotone. +- **±7 st reached at 50 % … 58 % of each half-travel** (so the musically useful middle gets + more than half the knob on each side). +- **Rate is the exception and keeps its settled taper**: linear in semitones over ±12, + i.e. exponential in ratio. It needs no centre expansion — ±12 st over the full travel is + already 0.19 st per drag pixel — and Daniel settled it explicitly. The two laws coexist + on one deck for a stated reason: **centre expansion applies to semitone knobs whose throw + exceeds ±12.** + +**One correctness consequence, and it is required, not optional:** + +> **`resetDeckParam` must bypass the taper.** It currently round-trips the default through +> `norm → value` (`deck_values.cpp:200-203`), and `deck_values.h:42-46` documents that exact +> recovery depends on the map being linear over a power-of-two ceiling. A log taper makes +> that round-trip inexact. **Reset must write the default value directly** — the defaults +> are already read off a default-constructed `PlaySeconds`, so there is no second table to +> drift. This *removes* the power-of-two dependency rather than working around it; the +> comment at `deck_values.h:42-46` becomes wrong and needs rewriting (source work, +> staff-engineer's). +> +> **This is also the unblocker for a future 10 s ceiling.** 2.0 is a power of two; 10.0 is +> not, so under today's round-trip a 10 s ceiling would land every reset a mantissa bit off +> its own default. Landing the reset bypass here means the ceiling question later is a +> one-constant change plus an overlay-scale re-check, not a correctness problem. + +### 4.4 Sequencing — why item D goes first + +Item D lands **before** the two new PITCH/RATE knobs, so Rate and Pitch are authored into +the finished law rather than retro-fitted into it. It lands **well before** any VST3 +parameter work, for a much sharper reason: once parameters are exposed, the taper *is* the +host-facing normalization, and re-tapering silently re-interprets every recorded automation +point in every saved project. **Taper changes are free now and permanently expensive +later.** See `docs/product/parameter-automation.md` §4. + +--- + +## 5. Staged contour traces draw straight, not curved (item E) + +**Daniel's report.** Changing a curve exponent from 1.0 moves the knot on the overlay, but +the stage segment still draws as a straight line. **Audio is correct; only the drawing is +wrong.** + +**Root cause, verified.** `editor_paint_waveform.cpp:218` does `if (v.knot) continue;` — +knots are dropped from the trace and the remaining vertices are joined with straight +strokes (`:222`). `curveMap` is never called in the paint path. The exponent is in scope +(`env.attackCurve` / `.decayCurve` / `.releaseCurve` at `:211`) and simply never read. +Knot *positioning* does honour the exponent via `curveMidLevel` +(`envelope_overlay.cpp:94-105`) — which is the divergence: **at any non-neutral exponent the +knot visibly floats off its own trace.** + +**The intended visual result** (the tessellation approach is the engineer's call): + +- Every sloped stage draws as the curve its exponent defines, evaluated through **the same + `curveMap` the audio uses** — one source, so the trace and the sound cannot diverge. +- **At exponent 1.0 the segment is visually identical to today's straight line** (the + regression guard). +- **At every exponent the knot's centre lies on the trace**, within 1 px. This is the + observable acceptance criterion, because the knot/trace divergence is the reported defect. +- No visible faceting at the widest segment the canvas can produce (per-pixel-column or + adaptive sampling; a fixed low tessellation count is not acceptable at full width). +- The trace keeps the established grammar: one weight, `kEnvTracePx = 2.0`, through the + analytic stroker — Θ-W7's "both envelope traces are one grammar and one weight" holds. +- **It is one paint path, so one fix covers all of it**: amp / filter / pitch, Gate AHDSR + and Trigger AHD, attack / decay / release. The drawn-EG (spline) overlay's own grammar is + untouched — it already traces per column. +- Both overlay layout policies are honoured unchanged: the AHDSR's right-anchored schematic + and the AHD's 1:1 mapping. + +--- + +## 6. Loop and crossfade — the re-approach (item F) + +> Daniel: *"the loop indicators and crossfade thingy are unintuitive as fuck. I don't +> understand what the three lines represent, so we need to re-approach that UX."* + +This is the one genuinely open design problem in the phase. What follows is a +recommendation, the alternatives that lost, and the trade-offs. + +### 6.1 Diagnosis — three root causes, one of them not in the complaint + +**Cause 1 — a category error in the drawing.** The three bars are not the same *kind* of +thing. **Start** is a *point in time* (where the head enters). **Loop start** and **loop +end** are the two ends of a *span* (a region the head cycles inside). They are drawn in +identical ink (`Role::AccentSecondary` for all three, `editor_paint_waveform.cpp:35-36`), at +identical weight, full height, distinguishable only by position. Drawing a point and the +ends of a span in one grammar is why the marks cannot be told apart, and it is the deeper +answer to "what do the three lines represent." + +**Cause 2 — nothing is named.** There is no label anywhere in the band. Every audio editor +labels these marks; we do not. This is the blunt, unglamorous half of the fix and it is +probably worth more than everything else combined. + +**Cause 3 — the crossfade is painted where its ingredient lives, not where the event is +heard.** Verified: the fade is pre-seam and one-tap — it fades material running into +`loopEnd` toward material running into `loopStart`, using the read head one loop length +earlier (`loop_span.h:26-28`). The audible event occupies the last `crossfade` frames +**before `loopEnd`**. But the UI paints the shaded region **before `loopStart`** +(`editor_paint_waveform.cpp:108-116`), and hangs the only grab affordance — a 10 px tab in +the top strip with no line of its own — at `loopStart − crossfade`. **The paint follows the +handle, not the audible event.** Both regions are real things (one is the ingredient, one +is the event), but the drawing shows only the ingredient, and the handle is on the wrong +side of the loop from the sound it controls. + +### 6.2 Three directions considered + +**Direction 1 — "Two grammars: a point is a caret, a span is a bracket."** Differentiate by +category — start gets a distinct ink and a directional flag cap; the loop pair gets bracket +caps so it reads as an enclosure; the crossfade moves to the audible location. Cheap, uses +only primitives the kit already has, attacks all three causes. + +**Direction 2 — "Lane it."** Add a dedicated 14–16 px **marker rail** along the top of the +waveform band. All handles live in the rail; the waveform proper carries only quiet 1 px +guide lines and the span fill. Structurally the strongest option: it would *dissolve* the +overlay's claim-arbitration problem (`resolveWaveformClaim`) rather than tie-breaking it, +and it would collapse two open `docs/TODO.md` entries. **Rejected for this phase** — it +costs waveform height, needs a new pure geometry module, and re-routes every hit-test in +the band, which is a much larger build than Daniel's complaint calls for. **Retained as the +named fallback** if the recommended direction proves too crowded in the DAW. Precedent: +REAPER's own ruler/marker lane; Sound Forge and Audacity's selection/loop handles. + +**Direction 3 — "Show the loop as a loop."** An arc/ribbon above the waveform running from +loop end back to loop start with an arrowhead, the crossfade drawn as the ribbon's taper. +Extremely legible for "what is a loop," and there is good precedent (Ableton Simpler's loop +arrow, Kontakt's loop-return arc). **Rejected:** it is a lot of ornament for one fact the +user learns once, and it sits uncomfortably against the visual language's strict +decoration policy (`visual-design-language.md` §3.5). + +### 6.3 Recommended: "Name it, class it, and put the fade where it is heard" + +Direction 1, with labels, in four moves. One grammar, four marks. + +**(a) One mark grammar: line + cap + label. The cap IS the grip.** + +Every mark draws as a full-height 2 px column, a **shaped cap** in the overlay's top strip, +and a `Font::Micro` / `TextDim` label. The cap is both the mark's identity and its grab +handle — which instantly answers "what is that tab?", because the crossfade's cap stops +being a bare orphan rectangle and becomes the same kind of object as every other mark's. + +| Mark | Ink | Cap | Line | Label | +|---|---|---|---|---| +| **Start** | `accent/primary` | solid **right-pointing triangle** (a play flag — it points into the material that will play) | solid | `START`, right of the line | +| **Loop start** | `accent/secondary` | **L-cap opening right** | solid | `LOOP`, right of the line | +| **Loop end** | `accent/secondary` | **L-cap opening left** | solid | `END`, left of the line | +| **Crossfade** | `accent/secondary`, reduced alpha | **ramp cap** — a small right triangle whose hypotenuse rises left→right, drawing the fade-in shape | **dashed** — a soft boundary, not a hard one | `XFADE`, left of the line | + +Start is the only `accent/primary` mark in the band, because it is the only one that is +always in effect (both Gate and Trigger). The loop pair's opposed L-caps read as `[ … ]` +without needing to be explained. All four caps use primitives already in the kit +(axis-aligned fills, AA-restroked triangles per `visual-design-language.md` §8). + +**(b) Labels, with an accepted overlap.** + +Labels draw in the top strip **beneath the trace and handles in z-order**. Where an envelope +node overlaps a label, the node wins visually and the label is occluded — accepted, and +named here so it is not filed as a defect. Labels are for learning; the cap shape carries +the identity permanently. Two rules keep them honest: + +- **On hover or drag of a mark, that mark's label re-draws on top**, so you always see what + you grabbed. +- **A label is suppressed if its box would overlap one already placed.** Placement order is + the grabbed/hovered mark first, then START, LOOP, END, XFADE. + +*A vertical inset of the overlay canvas to make room for labels was considered and +rejected*: it would change the envelope's level mapping (level 1.0 would no longer reach the +canvas top), which moves both the forward and inverse overlay maps and their tests, for a +cosmetic gain. + +**(c) The crossfade moves to where it is heard, and its ingredient becomes a ghost.** + +- **The crossfade mark and its region move to `[loopEnd − crossfade, loopEnd)`.** The handle + now lives on the loop-end side. **Drag direction is unchanged** — left lengthens the fade + — so the muscle memory survives; only the anchor moves. +- **The region draws as a top-and-bottom edge wedge, never as a second fill.** A triangular + band along the top and bottom edges of the overlay, growing from zero height at + `loopEnd − crossfade` to ~10 px at `loopEnd`, in `accent/secondary`. It reads as the fade + closing in on the seam and it leaves the middle of the waveform clean. + + > **This is a hard constraint, not a stylistic preference.** The crossfade region is now + > *inside* the loop span, where a translucent fill would **stack** on the loop fill + > (0.20 + 0.10). The envelope trace crossing the loop fill is a known, accepted + > under-floor contrast pair at 2.25:1 against a 3:1 floor + > (`editor_paint_waveform.cpp:28-34`), whose own note says *"If it is ever resolved, the + > FILL is what changes; do not nudge a color to chase it."* **A stacked fill would make an + > already-accepted failure worse.** The edge wedge leaves the loop fill's peak alpha at + > 0.20 exactly as today, so the pair is untouched. +- **The ingredient draws as a ghost.** `[loopStart − crossfade, loopStart)` — the material + actually being mixed in — draws the **mirror** wedge (growing right-to-left, peaking at + `loopStart`) at half alpha, outside the loop fill. It carries no handle. **At rest it is a + hairline dashed outline; it fills in on hover or drag of the crossfade handle** — a hover + state in the sense §3.3 of the visual language means, revealing the relationship only when + the user is asking about it. +- **This makes the clamp self-explanatory.** The hard clamp is + `crossfade ≤ min(start, loopLength)` (`loop_span.h:19`, and its "no material ahead of the + loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn, **the fade stops + growing exactly when the ghost's left edge reaches the START mark or the LOOP mark** — the + user sees the reason instead of hitting an invisible wall. That is the single best payoff + in this design and it costs nothing extra. + +**(d) The off-state and the Trigger state get words, not just alpha.** + +- **Loop off.** The pair draws in the kit's **Disabled** state with a centred dim caption in + the span — `DRAG TO SET LOOP` when no span has ever been set (the pair is parked at the + last quarter, `defaultLoopBounds`), `LOOP OFF` when a span is retained. The full off-state + machine, and what the explicit enable does to it, is **§6.4**. +- **Trigger mode.** Loop is Gate-only (`resolveLoop` refuses in Trigger) but the markers + still draw at full strength today, which is marks that do nothing. **In Trigger the loop + pair and the crossfade mark draw Disabled and are not grabbable**, with a dim + `LOOP — GATE ONLY` caption in the span. Disabled rather than hidden, because that is the + established grammar — the editor's Gate segment already refuses and paints Disabled off + the `splineActive` predicate — and because hiding a set loop on a mode flip destroys + information the user put there. The START mark stays fully live in both modes. + +### 6.4 The explicit loop enable (Γ-F4, ruled by Daniel 2026-08-01) + +**Settled: there is an explicit loop enable, and it lives on the CHROME ROW.** + +The framing this doc previously carried — "an enable costs a cell, so it is a §1.2 layout +decision" — was wrong, and Daniel corrected it: **loop is a waveform-overlay concept and has +no deck.** There was never a right deck cell for it. It is not on the overlay either (an +overlay control that governs the overlay is circular), and it is not in a deck group. + +#### Where it sits, and why that is free + +The chrome band's **toolbar row** carries a right-anchored control run — preview · velocity +cell · `Mono|Stereo` · Browse — with **the title taking whatever the run leaves** +(`sample_chrome.h`). The enable joins that run as a two-segment `Loop Off|On` toggle, +**immediately left of the channel toggle**, giving a two-toggle mode cluster with Browse +still rightmost: + +``` +[▶ preview] [VEL knob] [Loop Off|On] [Mono|Stereo] [Browse] +``` + +Three reasons for that exact slot: + +1. **It is the same class of control as the one next to it.** `Mono|Stereo` is a playback + mode of the loaded capture; so is loop-on. They belong adjacent, in the same two-segment + primitive. +2. **Browse stays rightmost.** It is navigation, not a mode — moving it would break the + established right-edge reading. +3. **It costs zero window width.** The run is right-anchored and the title slot absorbs it, + so `kEditorMinWidth` does not move and **none of §1.6's 90 px headroom is spent.** + *Constraint:* the title slot must still hold its text at the 1190 floor. If it will not, + the enable's segments narrow — the floor does not move. That is a hard rule, because the + floor is a phase-wide acceptance criterion. + +#### What it is, in the model — and it needs no new persisted field + +**The enable IS `SampleLoop::hasLoop`.** That field already exists +(`play_params.h:210`), is already the predicate `resolveLoop` refuses on +(`loop_span.cpp:12`), and is already persisted inside the params payload's `loopOverride` +block — where **`start` and `end` are written unconditionally, whatever `hasLoop` says** +(`params_payload.cpp:31-36`). So the wire can already carry "off, with a span remembered." +**No `ComponentState` field, no version bump, no format change.** This track stays off the +payload ladder entirely. + +What *does* change is the field's **provenance**. Today `hasLoop` is *derived*: the editor +sets it true whenever a marker is dragged (`editor_input_waveform.cpp:255,258`) and false +whenever the span collapses (`editor_session.cpp:236`). After Γ it is **user-owned**, with +the gestures as shortcuts onto it. + +#### The interaction with collapse-to-off — one authority, two shortcuts + +**Collapse-to-off is not retired, and it does not become a second state machine. It becomes +a shortcut that flips the enable.** `hasLoop` is the single authority; three gestures reach +it: + +| Gesture | Effect on the enable | Effect on the span | Effect on the crossfade | +|---|---|---|---| +| **Click the enable → On** | on | retained as-is | retained | +| **Click the enable → Off** | off | **retained** | **retained** | +| **Collapse the span** (drag one loop mark onto the other) | off | **destroyed** → re-parked at `defaultLoopBounds` | **zeroed** | +| **Drag either loop mark while off** | **on** | takes the drag | retained (re-clamped) | + +Two of those rows are behaviour changes and each has a reason: + +- **Toggle-off retains the span.** This is what makes an enable worth having at all: a + toggle whose off→on does not restore what was there is not a toggle, it is a delete + button. Today `pickedMarkers` (`editor_session.cpp:221-226`) discards the stored span and + re-parks at the default whenever `hasLoop` is false. **That re-park must become + conditional on the span being invalid, not on the enable being off** — a collapsed, + inverted or out-of-range span still re-parks (that rule exists so two coincident handles + cannot become ungrabbable, and it is still right); a *valid* span under a user-set off + keeps its position. +- **Toggle-off retains the crossfade.** Same argument, and it does **not** contradict the + existing zeroing rule, which is scoped to a span that no longer exists: + `editor_session.cpp:240-243` zeroes the crossfade on OFF precisely because *"leaving a + stale length here would silently re-apply it (clamped) the next time a loop is dragged + back in."* With the span retained, its clamp bound `min(start, loopLength)` is retained + too, so there is nothing stale to re-apply. **The zeroing predicate moves from "the enable + is off" to "the span was destroyed."** The reasoning behind the original rule is preserved + intact, not overruled. + +#### What the enable does to the "drag me" affordance + +The parked pair at the last quarter was carrying two messages in one alpha value: *there is +no loop* **and** *drag here to make one*. The enable takes the first message; the pair keeps +the second. + +- **Off, no span ever set** — pair parked at `defaultLoopBounds`, drawn Disabled, caption + `DRAG TO SET LOOP`. Dragging either mark **turns the enable on.** The shipped + drag-to-create gesture survives intact, and it now teaches the enable by demonstration: + the user drags and watches the chrome toggle light up. +- **Off, span retained** — pair drawn Disabled *at its own positions*, caption `LOOP OFF`. + There is nothing to "set," so the drag-me copy would be wrong. Dragging still turns the + enable on, by the same rule. +- **On** — full four-mark grammar of §6.3, unchanged. + +> **A grab implies intent to loop.** That is the one rule behind both off-states, and it is +> what keeps the enable from being a gate the user has to remember to open. + +#### The Trigger case — the enable disables itself, it does not clear itself + +Per the Disabled-not-hidden principle already established for the marks: **in Trigger the +chrome-row enable draws Disabled and inert, with its state preserved and restored on the +return to Gate.** It does not clear `hasLoop`, and it does not hide. The enable's Disabled +state and the span's `LOOP — GATE ONLY` caption are the same message delivered at two +scales — the chrome row says *this control is unavailable here*, the span says *why*. + +This transitively covers the drawn-EG case: `enforceGateUnavailableWhileDrawn` +(`play_params.h:198-205`) forces Trigger whenever any envelope is drawn, so a spline EG +disables the loop enable through the same predicate rather than through a second rule. + +**Disabled-but-grabbable (the off marks) vs. Disabled-and-inert (Trigger) is a deliberate +distinction, not an inconsistency**, and the discriminator is who said no: the user's own +off is reversible by the very gesture being offered, while Trigger's refusal comes from the +engine and no marker drag can talk it out of it. + +### 6.5 Trade-offs and consequences, named + +- **The crossfade handle moves sides.** A real muscle-memory break for the only current + user. Mitigated by the unchanged drag direction and by the mark finally being labelled. +- **The enable puts a waveform-band control on the chrome row, one band away from what it + governs.** That distance is the price of it having no deck; it is mitigated by the marks + themselves changing state visibly when it flips, so the two surfaces are never + ambiguous about which one won. The alternative — a control on the overlay governing the + overlay — is circular and was not seriously considered. +- **The track gains a chrome surface it did not previously have.** `sample_chrome` (pure), + `editor_paint_chrome` and `editor_input_chrome` come into scope, and so do + `editor_session`'s `pickedMarkers`/`applyMarkers` for the retention rule. §7.9 records + what that does to the wave's disjointness claim. +- **`hasLoop` changes provenance from derived to user-owned.** No format change, but every + existing site that *infers* it (two in `editor_input_waveform`, two in `editor_session`) + is now writing to a user-visible control rather than to an internal flag, and must be + re-read in that light rather than left alone because it still compiles. +- **Top-strip density.** Four caps and up to four labels in a strip that also carries + envelope nodes. Mitigated by the suppression rule and the hover promote; if it still + reads crowded in the DAW, Direction 2's rail (§6.2) is the pre-named escalation. +- **The claim-arbitration inputs change.** Today `markerHandleRect` gives a tab to the + crossfade only, and `resolveWaveformClaim` breaks ties by "smallest nominal target area + among the candidates that actually hit." Giving *every* mark a cap-grip changes the + candidate set and every nominal area in it. **The arbitration must be re-derived, and the + open `docs/TODO.md` entry "Pre-existing staged-envelope-node shadow at zero-attack" must + be re-evaluated against the new cap geometry** — it may be resolved by the change or made + worse, and either outcome must be recorded rather than discovered. +- **The geometry stays pure.** Cap rects, label boxes and the suppression rule belong in + `waveform_view` (which already owns `markerHandleRect`) as pure, CTest-covered geometry. + No hit-test math moves into the painter. +- **The model does not change.** `crossfade` remains a stored frame count with the same + clamp, `resolveLoop` is untouched, and no `ComponentState` version moves. **This is + entirely a drawing and hit-test change** — which is what makes it a safe track to run in + parallel with the parameter work. +- **Not chased:** the 2.25:1 trace-over-fill pair stays exactly as accepted (see the + constraint in (c)). + +--- + +## 7. Collisions with existing invariants + +Each of these is a place where Phase Γ contradicts, invalidates, or widens something a +`CLAUDE.md` currently states. Naming them is the point; resolving them in source is +staff-engineer's. + +**7.1 — `knob_deck.h`'s fourteen-pixels-of-headroom note is invalidated.** The header +records that *"the deck has fourteen pixels of headroom on its first row at the editor's +floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past +what the minimum window holds."* That measurement is against the 980 px floor and the greedy +three-row wrap; the reflow replaces both. **The reasoning survives (a caption toggle rides +existing slack, a row toggle costs group width); the number does not.** It must be +re-derived, not deleted. + +**7.2 — FILTER becomes the first non-envelope group to use `captionToggle2`.** The slot +exists and is free on FILTER, so `Band|Notch` moving to the caption corner needs **no new +geometry** — but `deck_groups.cpp`'s `kEnvModeSegW` comment describes the second slot as +though it belongs to the env decks. Also freed: that comment says *"the ceiling is PITCH +ENV's, whose caption row lands exactly on its four-cell knob row at 23. Raising it reflows +the deck's first row."* After the reflow PITCH ENV is on **row 2** with 48 px of caption +slack, and the binding ceiling on `kEnvModeSegW` rises from **23 to 47** (PITCH ENV binds at +47, AMP at 55). No change is required; the constraint simply stops being tight, and the +comment stops being true. + +**7.3 — Deterministic whole-group wrap stops deciding row membership.** `deckRowCount` / +`layoutDeck` implement a greedy left-to-right wrap. After the reflow, row membership is a +**property of the group** (sound vs. contour) and MASTER is a right-anchored double-height +group outside both rows. `DeckLayout::rowCount` and `::height` change meaning, and +`deckHeight` becomes a constant `2·kDeckGroupH + kDeckRowGap` at and above the floor width. +Whether the greedy wrap survives at all as a sub-floor degrade is an engineer's call; what +is **not** optional is that at and above the floor width the layout is the specified +two-row-plus-spanning-deck arrangement, arrived at by construction and not by a wrap +outcome. + +**7.4 — `knob_deck.h`'s "cell metrics and the editor floor move as a pair" gains a second +driver.** The invariant is currently directional: wider cells ⇒ wider floor. Phase Γ changes +the floor without touching cell metrics, because the **group inventory and its row +assignment** now also drive it. Restate as: *the deck's cell metrics AND its group/row +composition both drive `kEditorMinWidth`; none of the three may move alone.* + +**7.5 — `isLiveDeckParam` becomes three-valued.** See §2.3. The exhaustive switch must +classify the two new `DeckParam`s or fail to compile — which is exactly what it is designed +to do, and which is why the two new parameters are cheap to add *now*. + +**7.6 — `docs/TODO.md`'s deck-rework entry is superseded and its geometry is stale.** The +entry "The deck layout needs a real rework — one row, taller decks, controls stacked within +a deck" records an older directive of Daniel's that **this doc supersedes** (he confirmed +this explicitly): the new shape is **one row of single-height sound decks with knobs +side-by-side**, not taller decks with within-deck stacking. Its measured-geometry block +(840 px floor, `kDeckCellW 48`, group widths PITCH 150 / FILTER 440 / …) predates Θ-W6-T1 +and is wrong. The entry has been rewritten to point here. + +**7.7 — Root `CLAUDE.md`'s "Project docs" list omits `docs/PLAN.md`.** PLAN.md exists and is +the active roadmap (it says so in its own header, and `TODO-1.0.md` defers to it). The root +`CLAUDE.md` list of plan-style docs names only ARCHIVE / COMPLETED / TODO / TODO-1.0. +Flagged for staff-engineer; not mine to edit. + +**7.8 — Not a collision, worth recording as a confirmation.** `deck_groups.cpp` reserves +MASTER for "post-voice-mixer concerns." The limiter and the output meter are exactly that. +Phase Γ discharges the reservation; it does not overrule it. + +**7.9 — The loop enable widens Γ-W2-T2 onto the chrome band, and the wave's disjointness +claim needs restating rather than repeating.** Before Γ-F4, W2-T2 was purely a waveform-band +drawing and hit-test track. It now also owns `core/instrument/ui/sample_chrome` (one rect in +the toolbar's control run), `editor_paint_chrome`/`editor_input_chrome` (draw + hit-test for +it), and `editor_session`'s `pickedMarkers`/`applyMarkers` (the span/crossfade retention +rule). **W2-T1 and W2-T2 remain disjoint at the module level with one named exception: +`editor_session.cpp`.** W2-T1 may touch it for the third commit tier's routing; W2-T2 owns +`pickedMarkers`/`applyMarkers`. The partition is by function and the two do not overlap, so +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, and pretending otherwise +would be the kind of thing that surfaces as a surprise at merge. **Stated, not hidden.** +Everything else stays clean: the enable needs **no** `ComponentState` change, so W2-T1 keeps +sole ownership of the payload ladder (v15) exactly as specced. + +**7.10 — `getLatencySamples` is a new surface, not a changed one.** No `getLatencySamples` +override, no `kLatencyChanged`, and no `restartComponent` call site exists anywhere in +`src/` today; the plugin ships the SDK default of 0. W1-T2 introduces the plugin's first +latency reporting. There is therefore no existing behaviour to preserve — but §3.1.1's four +verification requirements bind, because the deactivate/reactivate the flag mandates lands +squarely on `ReaSamplerProcessor::setActive`, which is deliberately destructive in both +directions. + +--- + +## 8. Forks — five ruled, one open + +### 8.1 Ruled by Daniel, 2026-08-01 + +All five forks this doc opened are closed. The rulings are folded into the sections that +depend on them; this table is the index, not a second copy of the reasoning. + +| Fork | Question | Ruling | Where it landed | +|---|---|---|---| +| **Γ-F1** | Does `kEditorMinHeight` move 680 → 720? | **No — stays 680.** The reflow's 112 px goes entirely to the waveform. | §1.2 / §1.5, unchanged | +| **Γ-F2** | Limiter lookahead, or zero-latency? | **Lookahead with DYNAMIC reported latency** — zero when off, the lookahead when on, reported to the host's PDC. *Overrides this doc's zero-lookahead recommendation.* | **§3.1.1** (new), §7.10 | +| **Γ-F3** | Does the log taper raise the 2 s stage-time ceiling? | **Not in this phase — stays 2.0 s.** The 10 s ambition is preserved as a `docs/TODO.md` entry with its rationale. | §4.3, `docs/TODO.md` | +| **Γ-F4** | Explicit loop enable? | **Yes — on the CHROME ROW.** Not a deck cell; loop is a waveform-overlay concept and has no deck. | **§6.4** (new), §6.5, §7.9 | +| **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 90 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 | + +Two of these corrected this doc rather than confirming it, and both corrections are worth +remembering as pattern: + +- **Γ-F2 inverted the recommendation.** The zero-lookahead pitch weighed "monitoring latency + on every instance" against limiter transparency — but that trade only existed under the + *always-active delay line* framing. Daniel's condition (latent only when on, and reported) + dissolves it, at the cost of a dynamic-latency restart. The alternative I proposed was + answering a constraint he did not accept. +- **Γ-F4 was mis-framed as a layout question.** "An enable costs a cell" presumed the enable + belonged to a deck. It does not — nothing about loop belongs to a deck — and once that is + seen, the chrome row is obvious and free. **The reframe was the answer; the fork as posed + had no good option in it.** + +### 8.2 Open — one fork, surfaced by the Γ-F2 ruling + +**Fork Γ-F6 — `kLatencyChanged` mandates a host deactivate/reactivate. Is that acceptable +as the cost of the limiter toggle?** + +This was not visible when Γ-F2 was posed and Daniel did not have it in front of him. The +vendored SDK (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) defines the flag as: *"The +host has to deactivate and reactivate the plug-in."* In this plugin, +`ReaSamplerProcessor::setActive` is destructive in both directions +(`reasampler_processor.cpp:85-109`) — deactivate frees every sounding voice, reactivate +re-decodes the WAV from disk. **So flipping the limiter cuts held notes and reloads the +sample.** Full detail in §3.1.1. + +- **(a) Ship it, accept the cut.** The limiter is a patch-design control set once while + building a sound, not a per-take gesture; the enable is classified **not automatable** so + nothing can flip it at rate; the plugin's own output is crossfaded so *we* emit no step. + Cost: a user who flips it mid-audition loses the note they were holding. +- **(b) Constant reported latency** — the delay line engaged whenever the limiter design + ships, on or off, so the toggle never changes latency and never restarts. Cost: exactly + the thing Daniel's ruling rejected — every instance pays the lookahead in live monitoring + whether or not the limiter is used. Named here as the pre-agreed fallback, not as a + re-litigation. +- **(c) Zero-lookahead** — the original §8 recommendation. Closed; do not reopen it here. + +**Recommendation: (a), gated on a DAW measurement.** Ship the dynamic-latency design as +ruled, and make **W1-T2's first deliverable a verification spike in REAPER**: flip the +limiter with notes held, during playback and while stopped, and record what actually +happens — whether notes cut, whether the re-decode is perceptible, whether transport +hiccups. If the observed behaviour is as ugly as the SDK's worst case allows, **(b) is the +pre-agreed fallback and needs one word from Daniel, not a redesign** — the DSP is identical +either way and only the latency-reporting predicate changes. + +*This is the only open fork in the phase.* Nothing in §§1–7 is awaiting a Daniel answer. + +--- + +## 9. Build shape + +Sequenced into `docs/PLAN.md` as **Phase Γ** (worktree slug prefix `pg-`), four waves: + +``` +Γ-W1 Foundations [3 tracks, disjoint by surface] + T1 knob-interaction-law ............ item D (editor input + deck_values tapers) + T2 master-bus-audio ................ item C (pure limiter + meter ballistics + + processor + LATENCY REPORTING) + T3 contour-trace-curves ............ item E (waveform painter) +Γ-W2 New controls, and the overlay's marks [2 tracks] + T1 pitch-rate-deck ................. item A (params + engine + deck descriptor) + T2 loop-crossfade-ux ............... item F (waveform painter + pure marker geometry + + the chrome-row loop enable) +Γ-W3 The reflow [1 track] + T1 deck-reflow ..................... item B + C's UI half +Γ-W4 Preserve time-stretch [1 track] + T1 preserve-time-stretch ........... item A's quality half [measure-and-report gate] +``` + +The wave boundaries are collision boundaries, not preferences: `deck_values.cpp` is written +by W1-T1 then W2-T1; `editor_paint_waveform.cpp` by W1-T3 then W2-T2; the deck descriptors +by W2-T1 then W3-T1; and **one params-payload version bump per wave, owned by one track** +(W1-T2 takes v14 for the limiter flag, W2-T1 takes v15 for rate + pitch offset) so no two +tracks contend for the format ladder. The Γ-F4 ruling adds a chrome surface to W2-T2 and one +named shared file inside W2 — **§7.9**, which restates the wave's disjointness claim rather +than repeating it. Full track specs, dependencies and acceptance criteria are in +`docs/PLAN.md`. diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md new file mode 100644 index 0000000..b4c214c --- /dev/null +++ b/docs/product/parameter-automation.md @@ -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).