docs: collapse Phase Gamma Wave 2 to its landed record, and correct five spec claims the implementation disproved
This commit is contained in:
+50
-270
@@ -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 50–200 % 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` §§6–10. **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]
|
||||
|
||||
@@ -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*.
|
||||
|
||||
|
||||
@@ -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."*
|
||||
|
||||
Reference in New Issue
Block a user