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
+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."*