docs: collapse Phase Gamma Wave 2 to its landed record, and correct five spec claims the implementation disproved

This commit is contained in:
2026-08-02 06:51:04 -04:00
parent bf7840020e
commit f60c05c042
3 changed files with 101 additions and 297 deletions
+50 -270
View File
@@ -715,7 +715,7 @@ caution:
- **after W1-T2 and W2-T1**, because every control that could be a parameter must exist - **after W1-T2 and W2-T1**, because every control that could be a parameter must exist
before the list is declared — the list is derived from the control inventory, and an before the list is declared — the list is derived from the control inventory, and an
inventory that is still growing produces a list that has to be re-frozen; inventory that is still growing produces a list that has to be re-frozen;
- **after W2-T1 specifically**, because `isLiveDeckParam` becoming three-valued is the - **after W2-T1 specifically**, because `deckParamCommit` becoming three-valued is the
*prerequisite* of the classification, not an incidental of it: the exposed set is exactly *prerequisite* of the classification, not an incidental of it: the exposed set is exactly
`Live NoteOnLatched`; `Live NoteOnLatched`;
- **after W3-T1**, because MASTER's inventory (the limiter toggle, the GR bubble, the - **after W3-T1**, because MASTER's inventory (the limiter toggle, the GR bubble, the
@@ -824,7 +824,7 @@ exact interim layout; do not "fix" it in a track that does not own it.
Full wording: `docs/product/parameter-automation.md` §6.3. Full wording: `docs/product/parameter-automation.md` §6.3.
- **The exposed parameter set is DERIVED, never hand-maintained.** A control is a parameter - **The exposed parameter set is DERIVED, never hand-maintained.** A control is a parameter
if and only if its commit class is `Live` or `NoteOnLatched`. There is no second table if and only if its commit class is `Live` or `NoteOnLatched`. There is no second table
beside `isLiveDeckParam` / `liveCommitFor`, and no list that can drift from it. beside `deckParamCommit` / `liveCommitFor`, and no list that can drift from it.
- **The window floor is 1190 × 680 and must not exceed 1280 × 720.** **Γ-W1-T4 sets it, in - **The window floor is 1190 × 680 and must not exceed 1280 × 720.** **Γ-W1-T4 sets it, in
wave 1; no other track in the phase may move it**, and from that point every track is wave 1; no other track in the phase may move it**, and from that point every track is
authored and judged at it. A track that pushes the floor past 1280 has failed, not overrun. authored and judged at it. A track that pushes the floor past 1280 has failed, not overrun.
@@ -940,280 +940,59 @@ earlier findings closed. The closing measurement (one-machine, Debug-build) is r
### Γ-W2 — New controls, and the overlay's marks ### Γ-W2 — New controls, and the overlay's marks
**Depends on Γ-W1 for — four dependencies, two of them new:** **Depended on Γ-W1** for the taper law and the Preserve stretcher (T1), and the contour trace
1. **T1 ← W1-T1 (taper law).** Rate and Pitch must be authored into the finished and the floor/row law (T2) — see `docs/COMPLETED.md` for the full narrative.
taper/modifier law, not retro-fitted into it, and the semitone taper must exist before a
second semitone knob does.
2. **T1 ← W1-T5 (the stretcher) — NEW, and the reason the interim path is gone.** Preserve
Rate has no engine without it. Under the prior four-wave order this dependency ran the other
way and was paid for with a disposable resample-and-cancel stand-in; the resequencing
inverts it. **Rate must not ship before its Preserve engine.**
3. **T2 ← W1-T3 (the contour trace).** Both write `editor_paint_waveform.cpp`; running them
together is a merge fight in one file.
4. **T2 ← W1-T1 and W1-T4, weakly.** W1-T1 also edits `editor_input_waveform.cpp` (the
modifier read), which T2 rewrites for marker hit-test routing — serial, so not a conflict,
but T2 rebases onto it. And T2's "the enable costs no window width" criterion is now
asserted against **W1-T4's** derived floor test rather than one this track has to write.
T1 additionally inherits `engine/voice.{h,cpp}` from W1-T5 — a **hand-off, not a conflict**: **Both tracks have landed** — Γ-W2-T1 (`pitch-rate-deck`) and Γ-W2-T2 (`loop-crossfade-ux`) —
W1-T5 defines the Preserve ratio seam, and T1 feeds it. Serial across waves by construction. see `docs/COMPLETED.md` for the full narrative of each.
**Disjointness — re-verified against this wave's membership, not carried over.** Both tracks
stayed in W2 and nothing entered or left it, so the prior finding is re-checked and stands. T1
owns the parameter model, the engine and the deck descriptors; T2 owns the waveform band's
marks and their pure geometry **and 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 — **textual merge
adjacency, not 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. No *new* in-wave adjacency was created by the
resequencing: T2 touches neither `deck_values` nor `deck_groups` nor `voice`.
**One cross-wave hand-off, new with Ruling 2 and NOT a contention.** Rate's taper — linear in
semitones over ±12, the stated exception to the centre-expansion law — belongs in the **taper
module Γ-W1-T1 extracts**, since that module is the one home of every map. T1 therefore
appends a law to a module a previous wave created. Serial across waves by construction, the
same shape as its `engine/voice.{h,cpp}` hand-off from W1-T5. **What would be wrong is a
second taper defined inside `deck_values`' binding** — one home, appended to, not forked.
**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 **the
phase's second payload rung** exactly as specced (v16 on this branch as of 2026-08-01, which
has already integrated Ξ; read the ladder rather than assuming the number).
#### Γ-W2-T1 — `pitch-rate-deck` #### Γ-W2-T1 — `pitch-rate-deck`
**Goal.** PITCH becomes **PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the **Landed** — see `docs/COMPLETED.md` for the full narrative. PITCH became PITCH/RATE: three
existing Varisp|Presrv toggle, with both new controls wired through the engine. knobs (`Key Trk | Rate | Pitch`) under the existing Varisp|Presrv toggle, both new controls
wired through the engine. Rate is 50200 % on a taper linear in semitones over ±12 (the stated
exception to the centre-expansion law), note-on latched; Pitch is a ±24 st baseline offset,
live. Keytrack × rate × pitch-offset compound into a single read-increment multiply — the
per-sample path gained nothing. Merged as `9dbb8b8`; spent the phase's second payload rung,
v16, as a strict suffix, a v15 blob lifting to rate 100 % / pitch 0 st.
**Spec:** `docs/product/instrument-control-surface.md` §2. **`isLiveDeckParam` was renamed `deckParamCommit`** and now returns a three-state `LiveCommit`
(`Live` / `NoteOnLatched` / `Reload`) rather than a bool — one predicate widened, not a second
mechanism. **Rate classifies `NoteOnLatched`, Pitch classifies `Live`; Γ-W4-T1 derives the
VST3 exposed parameter set from this predicate**, so the classification is load-bearing two
waves out.
**Surface boundary — owns:** `core/instrument/engine/play_params.h` + Code review found one Critical (the resample bake read a stale frame window at non-unity Rate;
`core/instrument/map/play_seconds.h` (the two new fields), fixed by deriving the window from the rate the voice actually reads at, proved non-vacuous by
`core/instrument/map/component_state_io` + `params_payload` (**the phase's SECOND payload reverting the fix) and folded in a ruling that Pitch, unlike key-track and velocity, is now
rung** — v16 on this branch as of 2026-08-01, which has already integrated Ξ; read the ladder, compensated against Varispeed's stage-time coupling. A Varispeed golden hash was added,
do not assume the number), honestly witnessed from the remediation commit rather than pre-track, so it stands as a
`core/instrument/engine/voice.{h,cpp}` (the compounding and the note-on latch), witness for the next track rather than proof of this one.
`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 time-stretcher itself (Γ-W1-T5, already landed
by the time this track runs) — nor the pitch-synchronous splice geometry that gates Preserve
Rate (Γ-W1-T7, also already landed by the time this track runs).
**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. **Rate drives the stretch ratio Γ-W1-T5's stretcher already
consumes — there is no interim path.** The stretcher is this track's prerequisite, not its
successor; the resample-and-cancel stand-in the prior plan carried is retired unbuilt (see
Open questions for the contingency). `core/instrument/CLAUDE.md`'s "never wire
`WDL_Resampler` as the duration-preserving path" is honoured by construction.
- **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**.
- **The payload rung** appends both fields as a strict suffix; the preceding version's blob
lifts to rate 100 % / pitch 0 st, bit-identical playback.
- **Both new `DeckParam`s are classified in the three-state predicate, and that classification
is what puts them in the VST3 parameter list three waves later** — Rate `NoteOnLatched`,
Pitch `Live`. Γ-W4-T1 derives the exposed set from this predicate rather than from a list
of its own, so a mis-classification here is a mis-declared parameter there.
**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 stretcher's tolerance. **Preserve Rate is a finished feature the day this lands**,
because Γ-W1-T5 already shipped its engine; a degraded or inert Preserve Rate is a failed
track, not an acceptable interim.
- 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 project saved at the preceding payload version reopens at rate 100 % / pitch 0 st and
sounds identical.
**Open questions.**
- **None [Daniel].**
- **[propose at review]** Rate's clamp behaviour at the range extremes as it meets the
stretcher's own ratio bounds — one clamp, resolved where the two meet, not two that can
disagree.
- **Named contingency, not a plan item, and not to be taken silently.** If Γ-W1-T5's
measure-and-report gate has not passed when this track is ready to dispatch, the pre-agreed
fallback is the **resample-and-cancel composition** spec §2.5 records — a resampled read with
the resulting pitch change cancelled in the existing SOLA shifter — shipped as an interim
Preserve path with the stretcher as its later quality upgrade, i.e. a return to the prior
four-wave order. **Escalate to Daniel rather than taking it:** it revives a disposable
implementation and re-opens the `WDL_Resampler` guardrail conversation, and the whole point of
the resequencing was to avoid building it.
#### Γ-W2-T2 — `loop-crossfade-ux` #### Γ-W2-T2 — `loop-crossfade-ux`
**Goal.** Give the loop an explicit enable, make the loop and crossfade marks legible, and **Landed** — see `docs/COMPLETED.md` for the full narrative. An explicit loop enable, a
paint the crossfade where it is actually heard. legible mark grammar, and the crossfade painted where it is heard. **No format change** — no
`ComponentState` version moved, no new persisted field, `resolveLoop` untouched, audio
unchanged. The enable **is** `SampleLoop::hasLoop`, whose provenance changes from
marker-gesture-derived to user-owned, with the gestures as shortcuts onto it. Merged as
`a8e30a9`. A new pure module, `core/instrument/ui/loop_marks`, holds the state machine
(`resolveLoopMarks`/`applyLoopMarks`); the four marks (START/LOOP/END/XFADE) get one grammar —
line + shaped cap + label, the cap being the grip — with cap/label/suppression geometry pure
and unit-tested. The crossfade moved to `[loopEnd crossfade, loopEnd)`, drawing as a
top-and-bottom edge wedge, never a second fill; the loop fill's peak alpha stays exactly 0.20.
**Spec:** `docs/product/instrument-control-surface.md` §6 — **read §6.1 (the diagnosis), **START draws in `overlay/trace`, a deliberate deviation from
§6.4 (the enable) and §6.5 (trade-offs) in full before starting.** This is the phase's one `docs/product/instrument-control-surface.md` §6.3's table**, because the spec's
genuinely designed surface; the sections are the brief. `accent/primary` choice would measure 1:1 against the waveform fill it marks.
**Surface boundary — owns:** `shell/instrument/editor_paint_waveform.cpp`'s marker/loop draw, Code review found three Majors, all fixed before merge. `docs/TODO.md`'s "Pre-existing
`shell/instrument/editor_input_waveform.cpp`'s marker hit-test routing, staged-envelope-node shadow at zero-attack" entry was resolved incidentally — giving START a
`core/instrument/ui/waveform_view` (cap rects, label boxes, the label-suppression rule — all cap is what closed it — and rewritten in place with the recorded outcome by the track itself.
pure, all CTest-covered), and — **new, from the Γ-F4 ruling** **One thing is deliberately left open, not accepted:** the audible crossfade wedge draws at
`core/instrument/ui/sample_chrome` (the enable's rect in the toolbar control run), 1.60:1 against the envelope trace, under the 3:1 floor, with no alpha fix available; Daniel is
`shell/instrument/editor_paint_chrome` + `editor_input_chrome` (its draw and hit-test), and judging it visually in the DAW and has not yet ruled. **Neither track has been verified in a
`shell/instrument/editor_session.cpp`'s **`pickedMarkers` / `applyMarkers` only** (the running DAW; both are asserted in CTest only** — 99/99, Debug config, on one machine.
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.
--- ---
@@ -1437,7 +1216,7 @@ courtesy:**
2. **← W1-T2 and W2-T1.** Every control that could be a parameter must exist before the list 2. **← W1-T2 and W2-T1.** Every control that could be a parameter must exist before the list
is declared. The list is derived from the control inventory; an inventory still growing is declared. The list is derived from the control inventory; an inventory still growing
produces a list that has to be re-frozen, and it cannot be. produces a list that has to be re-frozen, and it cannot be.
3. **← W2-T1 specifically.** `isLiveDeckParam` becoming three-valued is the *prerequisite* of 3. **← W2-T1 specifically.** `deckParamCommit` becoming three-valued is the *prerequisite* of
the classification, not an incidental: the exposed set is exactly `Live NoteOnLatched`. the classification, not an incidental: the exposed set is exactly `Live NoteOnLatched`.
4. **← W3-T1.** MASTER's inventory (limiter toggle, GR bubble, reserved cell) is the last 4. **← W3-T1.** MASTER's inventory (limiter toggle, GR bubble, reserved cell) is the last
change to what controls exist at all. change to what controls exist at all.
@@ -1493,7 +1272,7 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
derivation and why the within-block order is seeded ONCE rather than tracked against derivation and why the within-block order is seeded ONCE rather than tracked against
`cellIds`; **§6.3 for the freeze invariant, which is to be stated in the table's header with `cellIds`; **§6.3 for the freeze invariant, which is to be stated in the table's header with
the same force as the command-id strings, the class UIDs and the payload field order.** the same force as the command-id strings, the class UIDs and the payload field order.**
- **The exposed set is DERIVED from `isLiveDeckParam` / `liveCommitFor`, never - **The exposed set is DERIVED from `deckParamCommit` / `liveCommitFor`, never
hand-maintained** — a control is a parameter iff its class is `Live` or `NoteOnLatched`. hand-maintained** — a control is a parameter iff its class is `Live` or `NoteOnLatched`.
**44 parameters** at the end of Γ-W3, enumerated by group in §7.1. **44 parameters** at the end of Γ-W3, enumerated by group in §7.1.
- **Everything else is OMITTED from the list entirely**, not exposed-and-flagged: the reload - **Everything else is OMITTED from the list entirely**, not exposed-and-flagged: the reload
@@ -3731,8 +3510,9 @@ proof it exists to give.
from `TODO-1.0.md`, and not a track this plan originally scoped. The second such track from `TODO-1.0.md`, and not a track this plan originally scoped. The second such track
in this plan today; if others appear, they belong on this list rather than in the in this plan today; if others appear, they belong on this list rather than in the
table. table.
- **All of Phase Γ** (`pg-*`). **Ten tracks across four waves**, from a direct interview with - **All of Phase Γ** (`pg-*`). **Twelve tracks across four waves** (W1 seven, W2 two, W3 two,
Daniel (2026-08-01) and his four later rulings the same day, not from `TODO-1.0.md`. Listed W4 one), from a direct interview with Daniel (2026-08-01) and his four later rulings the
same day, not from `TODO-1.0.md`. Listed
here as a block rather than per track, because the whole phase is outside the source doc; 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 the product reasoning lives in `docs/product/instrument-control-surface.md` and the
parameter system's in `docs/product/parameter-automation.md` §§610. **Two `docs/TODO.md` parameter system's in `docs/product/parameter-automation.md` §§610. **Two `docs/TODO.md`
@@ -3859,7 +3639,7 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends
pure libraries [no PLAN entry — see COMPLETED.md] pure libraries [no PLAN entry — see COMPLETED.md]
T7 psola-preserve ............. PSOLA-aligned splice jump; period_detect T7 psola-preserve ............. PSOLA-aligned splice jump; period_detect
[cadence closure — see COMPLETED.md] [cadence closure — see COMPLETED.md]
W2 New controls, and the overlay's marks [2 tracks] W2 New controls, and the overlay's marks — landed [2 tracks]
T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [rung 2] T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [rung 2]
T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard
W3 The reflow, and the bake correction [2 tracks] W3 The reflow, and the bake correction [2 tracks]
+48 -24
View File
@@ -344,16 +344,16 @@ shift unless that contingency is taken.
**Settled: Rate is latched at note-on for this phase (not live on sustaining voices).** **Settled: Rate is latched at note-on for this phase (not live on sustaining voices).**
Two consequences the implementation must get right: Two consequences the implementation must get right:
**It is a latch, not a reload.** `isLiveDeckParam` is currently a binary predicate whose **It is a latch, not a reload.** `deckParamCommit` was a binary predicate whose
`false` branch routes an edit to a **full reload** (bridge read, WAV re-decode, fresh `false` branch routed 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 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, 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 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 mechanism already exists — the invariant "A fresh note SNAPS, a sounding one holds φ" is
exactly this split — but the *classification* does not. exactly this split — but the *classification* did not, until this phase widened it.
> **Where this is recorded.** `core/instrument/CLAUDE.md` states that *"which controls are > **Where this is recorded.** `core/instrument/CLAUDE.md` states that *"which controls are
> live is ONE decision, recorded in ONE place"* — `isLiveDeckParam` / `liveCommitFor` in > live is ONE decision, recorded in ONE place"* — `deckParamCommit` / `liveCommitFor` in
> `ui/deck_groups`. Phase Γ widens that one decision from two states to three > `ui/deck_groups`. Phase Γ widens that one decision from two states to three
> (`Live` / `NoteOnLatched` / `Reload`) rather than adding a second predicate elsewhere. > (`Live` / `NoteOnLatched` / `Reload`) rather than adding a second predicate elsewhere.
> This is also precisely the seam the automation work needs — see > This is also precisely the seam the automation work needs — see
@@ -374,12 +374,23 @@ Preserve it is an addend to a shift amount the pitch envelope already modulates.
### 2.4 Rate scaling — what "scales with rate" means, concretely ### 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 - **Loop points scale with rate under Varispeed; under Preserve, the loop's *traversal*
Varispeed the read increment changes and the loop is traversed proportionally faster — scales and its audible period does not.** The loop is a pair of *source-frame* facts.
scaling is automatic and the stored frames are untouched. Under Preserve the read Under Varispeed the read increment changes and the loop is traversed proportionally
advances at `rate ×` the source rate, so the loop's wall-clock period scales by `1/rate` faster — scaling is automatic, the stored frames are untouched, and the audible period
while its source-frame span is unchanged. **In neither mode are the stored loop frames scales by `1/rate` along with everything else the voice plays. **Under Preserve this is
rewritten**; the marks on the waveform do not move when Rate moves. the opposite of what the Varispeed case suggests, and the obvious extension of it is
wrong** — which is why an engineer measured this before writing code against it rather
than inferring it from the Varispeed case above. What scales with rate under Preserve is
the loop's *traversal* — how fast the source is consumed (the feed-side witness is
`testPreserveStretchLoopsTheSourceSpan`) — not its audible period: holding the source's
period constant while its duration changes is what Preserve *is*. **Measured** (Debug
build, one machine): with a ring long enough to hold the whole loop, the rendered
sawtooth period is ~3999.9 output frames at rate 0.5, 1.0, and 2.0 alike; at shorter
rings, where splice cadence intrudes instead of the design property being isolated, the
same fixture measured 3064 and 4130 frames at rate 0.5 — never the 8000 a scaling period
would give either. **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 - **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 position (`core/instrument/CLAUDE.md`: "Normalized is what makes a contour
length-independent"), so it follows the read head by construction. **The staged length-independent"), so it follows the read head by construction. **The staged
@@ -1042,13 +1053,20 @@ being a bare orphan rectangle and becomes the same kind of object as every other
| Mark | Ink | Cap | Line | Label | | 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 | | **Start** | `overlay/trace` | 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 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 | | **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 | | **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 Start draws in `overlay/trace`, not `accent/primary`: `accent/primary` **is** the waveform
always in effect (both Gate and Trigger). The loop pair's opposed L-caps read as `[ … ]` fill, so a primary START would measure 1:1 against the material it marks. `overlay/trace`
measures 3.071:1 against the fill and 3.065:1 against `bg/base`, clearing the 3:1 non-text
floor on both — provably optimal, since `core/ui/CLAUDE.md`'s two-neighbour rule derives
`sqrt(9.41) ≈ 3.07` as the ceiling any single value can hold against both neighbours at
once. Start is still the only mark always in effect (both Gate and Trigger), but that is no
longer what its ink says, now that `overlay/trace` is shared with the envelope trace: the
distinction is carried by shape instead — a straight full-height column under a solid
triangle cap, never a curve. The loop pair's opposed L-caps read as `[ … ]`
without needing to be explained. All four caps use primitives already in the kit 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). (axis-aligned fills, AA-restroked triangles per `visual-design-language.md` §8).
@@ -1088,17 +1106,23 @@ cosmetic gain.
> already-accepted failure worse.** The edge wedge leaves the loop fill's peak alpha at > 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. > 0.20 exactly as today, so the pair is untouched.
- **The ingredient draws as a ghost.** `[loopStart crossfade, loopStart)` — the material - **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 actually being mixed in — draws the **same ramp** the audible wedge draws (growing
`loopStart`) at half alpha, outside the loop fill. It carries no handle. **At rest it is a left-to-right, peaking at `loopStart`) at half alpha, outside the loop fill — not a
hairline dashed outline; it fills in on hover or drag of the crossfade handle** — a hover mirror of it: the incoming tap's weight at ingredient frame `loopStart crossfade + k` is
state in the sense §3.3 of the visual language means, revealing the relationship only when the same `crossfadeWeight` as audible frame `loopEnd crossfade + k`, so both spans carry
the user is asking about it. the identical ramp, which is exactly why one `crossfadeWedgeHeight` function draws both.
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 - **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 `crossfade ≤ min(loopStart, loopLength)` (`loop_span.h:19``maxCrossfade(loopStart,
loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn, **the fade stops loopEnd loopStart)`; `start` there names `loopStart`, not the START mark — and its "no
growing exactly when the ghost's left edge reaches the START mark or the LOOP mark** — the material ahead of the loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn,
user sees the reason instead of hitting an invisible wall. That is the single best payoff **each half of the clamp is now visible, on a different mark:** the ghost's left edge
in this design and it costs nothing extra. reaches frame 0 — the overlay's own left edge, not a mark — exactly at the `loopStart`
bound, and the audible wedge's left edge reaches the LOOP mark exactly at the `loopLength`
bound. The user sees why the fade stopped growing 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.** **(d) The off-state and the Trigger state get words, not just alpha.**
@@ -1309,7 +1333,7 @@ the floor without touching cell metrics, because the **group inventory and its r
assignment** now also drive it. Restate as: *the deck's cell metrics AND its group/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.* composition both drive `kEditorMinWidth`; none of the three may move alone.*
**7.5 — `isLiveDeckParam` becomes three-valued.** See §2.3. The exhaustive switch must **7.5 — `deckParamCommit` 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 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*. to do, and which is why the two new parameters are cheap to add *now*.
+3 -3
View File
@@ -124,7 +124,7 @@ than balancing it — a fork with a dominated option in it is not a fork.
### 3.4 Which controls can be parameters at all — three classes ### 3.4 Which controls can be parameters at all — three classes
The good news: **this analysis is already done once, in one place.** `isLiveDeckParam` / The good news: **this analysis is already done once, in one place.** `deckParamCommit` /
`liveCommitFor` (`ui/deck_groups`) is exactly "which controls can change without a rebuild," `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 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 (§3.5). The parameter work should widen the *same* decision point again rather than start a
@@ -825,7 +825,7 @@ already names that door from the other side.
> **A control is an exposed VST3 parameter if and only if its commit class is `Live` or > **A control is an exposed VST3 parameter if and only if its commit class is `Live` or
> `NoteOnLatched`.** Everything else is omitted from the parameter list entirely. > `NoteOnLatched`.** Everything else is omitted from the parameter list entirely.
That makes `isLiveDeckParam` / `liveCommitFor` — already *"THE home for why each excluded That makes `deckParamCommit` / `liveCommitFor` — already *"THE home for why each excluded
control is excluded"* — the single source for the parameter list too, which is the standing control is excluded"* — the single source for the parameter list too, which is the standing
rule (`core/instrument/CLAUDE.md`: *"which controls are live is ONE decision, recorded in ONE rule (`core/instrument/CLAUDE.md`: *"which controls are live is ONE decision, recorded in ONE
place"*) applied once more rather than a second table opened beside it. place"*) applied once more rather than a second table opened beside it.
@@ -890,7 +890,7 @@ blob, which is exactly what §6.1's split is for.
set **for the note-on-latch reason** currently route through the reload tier, and the new set **for the note-on-latch reason** currently route through the reload tier, and the new
state fits them exactly: state fits them exactly:
- **Key-track**`isLiveDeckParam`'s header already says it *"feed[s] values a voice - **Key-track**`deckParamCommit`'s header already says it *"feed[s] values a voice
latches at note-on by design (the pitch ratio…), so live delivery would retune… a note latches at note-on by design (the pitch ratio…), so live delivery would retune… a note
already struck."* That sentence describes `NoteOnLatched`, not `Reload`. already struck."* That sentence describes `NoteOnLatched`, not `Reload`.
- **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."* - **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."*