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