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
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;
- **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
`Live NoteOnLatched`;
- **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.
- **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
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
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.
@@ -940,280 +940,59 @@ earlier findings closed. The closing measurement (one-machine, Debug-build) is r
### Γ-W2 — New controls, and the overlay's marks
**Depends on Γ-W1 for — four dependencies, two of them new:**
1. **T1 ← W1-T1 (taper law).** 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.
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.
**Depended on Γ-W1** for the taper law and the Preserve stretcher (T1), and the contour trace
and the floor/row law (T2) — see `docs/COMPLETED.md` for the full narrative.
T1 additionally inherits `engine/voice.{h,cpp}` from W1-T5 — a **hand-off, not a conflict**:
W1-T5 defines the Preserve ratio seam, and T1 feeds it. Serial across waves by construction.
**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).
**Both tracks have landed** — Γ-W2-T1 (`pitch-rate-deck`) and Γ-W2-T2 (`loop-crossfade-ux`) —
see `docs/COMPLETED.md` for the full narrative of each.
#### Γ-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.
**Landed** — see `docs/COMPLETED.md` for the full narrative. PITCH became PITCH/RATE: three
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` +
`core/instrument/map/play_seconds.h` (the two new fields),
`core/instrument/map/component_state_io` + `params_payload` (**the phase's SECOND payload
rung** — v16 on this branch as of 2026-08-01, which has already integrated Ξ; read the ladder,
do not assume the number),
`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 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.
Code review found one Critical (the resample bake read a stale frame window at non-unity Rate;
fixed by deriving the window from the rate the voice actually reads at, proved non-vacuous by
reverting the fix) and folded in a ruling that Pitch, unlike key-track and velocity, is now
compensated against Varispeed's stage-time coupling. A Varispeed golden hash was added,
honestly witnessed from the remediation commit rather than pre-track, so it stands as a
witness for the next track rather than proof of this one.
#### Γ-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.
**Landed** — see `docs/COMPLETED.md` for the full narrative. An explicit loop enable, a
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),
§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.
**START draws in `overlay/trace`, a deliberate deviation from
`docs/product/instrument-control-surface.md` §6.3's table**, because the spec's
`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,
`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.
Code review found three Majors, all fixed before merge. `docs/TODO.md`'s "Pre-existing
staged-envelope-node shadow at zero-attack" entry was resolved incidentally — giving START a
cap is what closed it — and rewritten in place with the recorded outcome by the track itself.
**One thing is deliberately left open, not accepted:** the audible crossfade wedge draws at
1.60:1 against the envelope trace, under the 3:1 floor, with no alpha fix available; Daniel is
judging it visually in the DAW and has not yet ruled. **Neither track has been verified in a
running DAW; both are asserted in CTest only** — 99/99, Debug config, on one machine.
---
@@ -1437,7 +1216,7 @@ courtesy:**
2. **← W1-T2 and W2-T1.** Every control that could be a parameter must exist before the list
is declared. The list is derived from the control inventory; an inventory still growing
produces a list that has to be re-frozen, and it cannot be.
3. **← W2-T1 specifically.** `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`.
4. **← W3-T1.** MASTER's inventory (limiter toggle, GR bubble, reserved cell) is the last
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
`cellIds`; **§6.3 for the freeze invariant, which is to be stated in the table's header with
the same force as the command-id strings, the class UIDs and the payload field order.**
- **The exposed set is DERIVED from `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`.
**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
@@ -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
in this plan today; if others appear, they belong on this list rather than in the
table.
- **All of Phase Γ** (`pg-*`). **Ten tracks across four waves**, from a direct interview with
Daniel (2026-08-01) and his four later rulings the same day, not from `TODO-1.0.md`. Listed
- **All of Phase Γ** (`pg-*`). **Twelve tracks across four waves** (W1 seven, W2 two, W3 two,
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;
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`
@@ -3859,7 +3639,7 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends
pure libraries [no PLAN entry — see COMPLETED.md]
T7 psola-preserve ............. PSOLA-aligned splice jump; period_detect
[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]
T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard
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).**
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
**It is a latch, not a reload.** `deckParamCommit` was a binary predicate whose
`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
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.
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
> 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
> (`Live` / `NoteOnLatched` / `Reload`) rather than adding a second predicate elsewhere.
> 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
- **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.
- **Loop points scale with rate under Varispeed; under Preserve, the loop's *traversal*
scales and its audible period does not.** 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, the stored frames are untouched, and the audible period
scales by `1/rate` along with everything else the voice plays. **Under Preserve this is
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
position (`core/instrument/CLAUDE.md`: "Normalized is what makes a contour
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 |
|---|---|---|---|---|
| **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 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 `[ … ]`
Start draws in `overlay/trace`, not `accent/primary`: `accent/primary` **is** the waveform
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
(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
> 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.
actually being mixed in — draws the **same ramp** the audible wedge draws (growing
left-to-right, peaking at `loopStart`) at half alpha, outside the loop fill — not a
mirror of it: the incoming tap's weight at ingredient frame `loopStart crossfade + k` is
the same `crossfadeWeight` as audible frame `loopEnd crossfade + k`, so both spans carry
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
`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.
`crossfade ≤ min(loopStart, loopLength)` (`loop_span.h:19``maxCrossfade(loopStart,
loopEnd loopStart)`; `start` there names `loopStart`, not the START mark — and its "no
material ahead of the loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn,
**each half of the clamp is now visible, on a different mark:** the ghost's left edge
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.**
@@ -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
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
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
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,"
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
@@ -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
> `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
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.
@@ -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
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
already struck."* That sentence describes `NoteOnLatched`, not `Reload`.
- **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."*