eb777f55e1
Two-row deck reflow (sound/contour), double-height MASTER with limiter and meter, PITCH/RATE deck, unit-driven knob law, contour-trace fix, and a re-approached loop/crossfade UX. Folds rulings Γ-F1..Γ-F5; opens Γ-F6.
1274 lines
81 KiB
Markdown
1274 lines
81 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.
|
||
**The five forks this doc opened (Γ-F1…Γ-F5) were all ruled on by Daniel on 2026-08-01**;
|
||
their rulings are folded into the sections they affect and the rulings themselves are
|
||
recorded in §8. §8 also carries **one new fork, Γ-F6**, which the Γ-F2 ruling surfaced from
|
||
the vendored SDK and which Daniel did not have in front of him. **The forward-looking VST3
|
||
automation parameter system is deliberately NOT in this phase** — it has its own doc,
|
||
`docs/product/parameter-automation.md`.
|
||
|
||
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): zero when
|
||
off, the lookahead when on, reported to the host's PDC. That buys a transparent
|
||
true-peak limiter and costs a `restartComponent(kLatencyChanged)` on the toggle — which
|
||
the SDK defines as a host **deactivate/reactivate**, not a re-tap. §3.1.1 is the whole
|
||
of that cost and how it is contained.
|
||
- **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.
|
||
|
||
---
|
||
|
||
## 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.**
|
||
|
||
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.
|
||
|
||
### 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; composing it with a resampled read yields a
|
||
working stretch, and that composition is the **interim path** Γ-W2-T1 ships so Rate is a
|
||
complete feature the day it lands. A **real stretcher, written from established
|
||
state-of-the-art literature**, follows as its own track.
|
||
|
||
**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 % rate; no smearing of transient material at 50 %/200 % worse than the
|
||
interim resample+SOLA path; the null case (rate 100 %, no shift) must be **bit-identical
|
||
to the un-stretched 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 against the interim path 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, 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 actually says, and why it is more expensive than it looks
|
||
|
||
Two facts read directly out of `vendor/vst3sdk`, both load-bearing:
|
||
|
||
> `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."*
|
||
|
||
The second is the sharp one. `kLatencyChanged` is not a "re-read the number" flag — the
|
||
SDK defines it as a **deactivate/reactivate cycle**. And in *this* plugin,
|
||
`ReaSamplerProcessor::setActive` is deliberately destructive in both directions
|
||
(`reasampler_processor.cpp:85-109`):
|
||
|
||
- `setActive(false)` frees `live_`, `draining_`, **and** the graveyard — *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."*
|
||
- `setActive(true)` calls `reloadInstrument()` — a bridge read, a **WAV re-decode**, and a
|
||
fresh engine.
|
||
|
||
**So the honest cost of the toggle is: every sounding note stops, and the sample is
|
||
re-decoded from disk.** That is a materially heavier consequence than "a brief click," and
|
||
it is the reason §8's new fork Γ-F6 exists rather than this being fully closed.
|
||
|
||
#### 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 — the product decision
|
||
|
||
**Ruling (mine, not deferred): the toggle applies immediately, the restart is requested
|
||
immediately, and the resulting interruption is accepted and documented. 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.
|
||
|
||
The mitigations that make this acceptable rather than merely defensible:
|
||
|
||
- **The limiter's own output has no hard step.** Within the plugin, the engage/disengage is
|
||
covered by a short (≤ 10 ms) equal-gain crossfade between the pre- and post-toggle paths,
|
||
so whatever the host does around it, we do not emit a discontinuity of our own making.
|
||
- **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.
|
||
- **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.
|
||
- **The interruption is verified in REAPER, and its severity recorded.** The SDK mandates
|
||
the deactivate/reactivate; *what REAPER actually does with it* — whether sounding notes
|
||
cut, whether the re-decode is perceptible, whether transport hiccups — is DAW-verifiable
|
||
only. That verification is W1-T2's first deliverable, and its outcome is what closes
|
||
Γ-F6.
|
||
|
||
### 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, surfaced for Ξ-W2's review rather than as a 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).
|
||
|
||
### 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` is **2.0 s** (`deck_values.h:22`, reading `kGateStageMaxSeconds` at
|
||
`envelope_overlay.h:85`) and **does not move in this phase** — settled, Γ-F3. The AHDSR
|
||
overlay's schematic scale is derived from it and the two must agree so a maxed knob lands
|
||
exactly at the canvas edge; the agreement requirement is documented at
|
||
`deck_values.h:19-22`. **Daniel foresees wanting a 10 s ceiling** for sound-design cases
|
||
— his example, *"a horrifically long decay with tight exp"* — and that ambition is
|
||
carried as a `docs/TODO.md` entry rather than dropped. Two things make it cheap later and
|
||
both land in this phase: the log taper is precisely what makes a higher ceiling *usable*
|
||
rather than unusable at the low end, and the reset-bypasses-the-taper change below
|
||
removes the power-of-two dependency that would otherwise block a 10.0 s ceiling outright.
|
||
|
||
**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 **well before** any 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.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
## 8. Forks — five ruled, one open
|
||
|
||
### 8.1 Ruled by Daniel, 2026-08-01
|
||
|
||
All five forks this doc opened are closed. The rulings are folded into the sections that
|
||
depend on them; this table is the index, not a second copy of the reasoning.
|
||
|
||
| 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? | **Not in this phase — stays 2.0 s.** The 10 s ambition is preserved as a `docs/TODO.md` entry with its rationale. | §4.3, `docs/TODO.md` |
|
||
| **Γ-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 |
|
||
|
||
Two of these corrected this doc rather than confirming it, and both 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.**
|
||
|
||
### 8.2 Open — one fork, surfaced by the Γ-F2 ruling
|
||
|
||
**Fork Γ-F6 — `kLatencyChanged` mandates a host deactivate/reactivate. Is that acceptable
|
||
as the cost of the limiter toggle?**
|
||
|
||
This was not visible when Γ-F2 was posed and Daniel did not have it in front of him. The
|
||
vendored SDK (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) defines the flag as: *"The
|
||
host has to deactivate and reactivate the plug-in."* In this plugin,
|
||
`ReaSamplerProcessor::setActive` is destructive in both directions
|
||
(`reasampler_processor.cpp:85-109`) — deactivate frees every sounding voice, reactivate
|
||
re-decodes the WAV from disk. **So flipping the limiter cuts held notes and reloads the
|
||
sample.** Full detail in §3.1.1.
|
||
|
||
- **(a) Ship it, accept the cut.** The limiter is a patch-design control set once while
|
||
building a sound, not a per-take gesture; the enable is classified **not automatable** so
|
||
nothing can flip it at rate; the plugin's own output is crossfaded so *we* emit no step.
|
||
Cost: a user who flips it mid-audition loses the note they were holding.
|
||
- **(b) Constant reported latency** — the delay line engaged whenever the limiter design
|
||
ships, on or off, so the toggle never changes latency and never restarts. Cost: exactly
|
||
the thing Daniel's ruling rejected — every instance pays the lookahead in live monitoring
|
||
whether or not the limiter is used. Named here as the pre-agreed fallback, not as a
|
||
re-litigation.
|
||
- **(c) Zero-lookahead** — the original §8 recommendation. Closed; do not reopen it here.
|
||
|
||
**Recommendation: (a), gated on a DAW measurement.** Ship the dynamic-latency design as
|
||
ruled, and make **W1-T2's first deliverable a verification spike in REAPER**: flip the
|
||
limiter with notes held, during playback and while stopped, and record what actually
|
||
happens — whether notes cut, whether the re-decode is perceptible, whether transport
|
||
hiccups. If the observed behaviour is as ugly as the SDK's worst case allows, **(b) is the
|
||
pre-agreed fallback and needs one word from Daniel, not a redesign** — the DSP is identical
|
||
either way and only the latency-reporting predicate changes.
|
||
|
||
*This is the only open fork in the phase.* Nothing in §§1–7 is awaiting a Daniel answer.
|
||
|
||
---
|
||
|
||
## 9. Build shape
|
||
|
||
Sequenced into `docs/PLAN.md` as **Phase Γ** (worktree slug prefix `pg-`), four waves:
|
||
|
||
```
|
||
Γ-W1 Foundations [3 tracks, disjoint by surface]
|
||
T1 knob-interaction-law ............ item D (editor input + deck_values tapers)
|
||
T2 master-bus-audio ................ item C (pure limiter + meter ballistics +
|
||
processor + LATENCY REPORTING)
|
||
T3 contour-trace-curves ............ item E (waveform painter)
|
||
Γ-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 [1 track]
|
||
T1 deck-reflow ..................... item B + C's UI half
|
||
Γ-W4 Preserve time-stretch [1 track]
|
||
T1 preserve-time-stretch ........... item A's quality half [measure-and-report gate]
|
||
```
|
||
|
||
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; the deck descriptors
|
||
by W2-T1 then W3-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) so no two
|
||
tracks contend for the format ladder. The Γ-F4 ruling adds a chrome surface to W2-T2 and one
|
||
named shared file inside W2 — **§7.9**, which restates the wave's disjointness claim rather
|
||
than repeating it. Full track specs, dependencies and acceptance criteria are in
|
||
`docs/PLAN.md`.
|