Files
reasampler/docs/product/instrument-control-surface.md
T

1600 lines
106 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 AF 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 →
1198 × 680**, inside the settled 1280 × 720 ceiling with **82 px of headroom**. The deck
band drops **328 → 216 px**, returning **112 px to the waveform** (246 → 358 px at the
floor). **That 82 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 1028 px,
and at that width BOTH rows' gutters are equal (16/16/16 and 76/76) *and* FILTER's right
edge lands exactly on FILTER ENV's right edge (both at x = 640). That is the aesthetic tie
between the rows and it falls out of the arithmetic — §1.3, which also records the three
properties the originally-specified 1020 block was claimed to deliver and did not.
- **PITCH becomes PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the existing
Varisp|Presrv toggle. Rate 50200 % 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 cycle used to be expensive only because **our** `setActive` re-decoded the WAV;
Γ-W3 decoupled the two lifetimes, so it no longer does. §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` §§610: 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 **in signal-flow order**
(Γ-F7, ruled), and the blob left authoritative with parameters as a third surface onto the
one model. **They report REAL UNITS to the host** (Ruling 3) through VST3's plain-value
layer — a plain range, a unit string and a stated display precision per parameter, formatted
by the same pure function the knob label uses. §6.7 there; §8.4 here for what it binds in
this doc.
- **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** | 16 + 16 + 16 = 48 | **1028** |
| Row 2 | 252 + 312 + 312 = **876** | 76 + 76 = 152 | **1028** |
**Window floor.**
```
deck band width = 1028 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1182
kEditorMinWidth = 1182 + 2·kPad(8) = 1198
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)
```
**1198 × 680, against a 1280 × 720 ceiling — 82 px of width headroom, 40 px of height.**
> **Who lands which half.** The floor, the three budget constants it is derived from
> (row block · 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 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. Γ-W1-T4 set the block at 1020
> and the floor at 1190; the widen recorded below moved both, and it is the ONLY number of
> W1-T4's that this phase reopened. `docs/PLAN.md` at Γ-W1-T4 states the seam 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 1028, not 1016 and not the 1020 originally specified.** 1020 was
chosen to make the two rows align exactly; it does not — 1020 leaves row 1 a 40 px slack
that three gutters cannot divide evenly, so the justification law produces 14/13/13 and
leaves FILTER's right edge 2 px past FILTER ENV's. **1028 is the width at which the law
itself makes the tie-line exact**, with no residue in either row (§1.3). The 8 px is the
single cheapest aesthetic purchase in the phase, and it is spent from the headroom
ledger in §1.6.
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 + 16 + 432 = 640` · `row 2: 252 + 76 + 312 = 640`.
That is not a coincidence preserved by a special rule — it is what row-block width
**1028** buys, and at 1028 both rows' gutters are *also* exactly equal (16/16/16 and
76/76), because 1028 leaves each row a slack its gutter count divides with no residue.
**This is why the floor is 1198 and not 1190.**
> **Corrected 2026-08-02 — this paragraph previously claimed THREE properties at 1020,
> and none of the three held there.** It said the tie-line landed at 636, that row 2's
> gutters were equal, and that row 1's *smallest gutter was exactly* `kDeckGroupGap` (12).
> What 1020 actually produced: row 1's slack is 40 over three gutters, so the law's
> equal-division-plus-leftmost-residue rule gives **14/13/13** — not 12/14/14 as §1.2's
> table stated, and not a smallest gutter of 12 — and FILTER's right edge lands on **638**
> against row 2's 636. Only row 2's equal gutters held. The three were never
> simultaneously satisfiable: the tie-line needs 1028, an exactly-12 smallest gutter needs
> 1016, and 1020 delivered neither. **`kDeckGroupGap` is a FLOOR — "no gutter narrower
> than 12" — never a target**, so the 16 px gutters at 1028 satisfy the real rule and the
> third property is withdrawn rather than traded away. Two properties hold at 1028, both
> exactly, and the law is what makes them hold.
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 | **1198 × 680** |
| Ceiling headroom | — | **82 px wide, 40 px tall** |
**Costs, named.** The floor width grows by 218 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 82 px headroom is the budget, and it governs every future control
**Read this before proposing any new knob.** The floor is **1198** against Daniel's hard
**1280** ceiling. That is **82 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 | 22 |
| One more caption toggle on a group whose caption row is the binding side | 048 | 3482 |
| Widening MASTER to a two-cell left column | 60 | 22 |
| A second cell *and* a wider MASTER | 120 | **over ceiling** |
**The ledger was 90 until the row block widened 1020 → 1028** (§1.2 correction 2, §1.3). Its
*purchasing power* is unchanged: one more 60 px deck cell remains affordable (82 60 = 22),
which is the only purchase this ledger has ever promised, and the second one was already over
the ceiling at 90. The 8 px came out of the spare change, not out of the budget's one slot.
**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 82 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 22 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 82. 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.** `deckParamCommit` was a binary predicate whose
`false` branch routed an edit to a **full reload** (bridge read, WAV re-decode, fresh
engine) or an engine rebuild. Routing a swept knob down that path is unacceptable. Rate is
therefore a **third commit class**: *published into the live block like any live parameter,
but read only by `snapLive` at note-on and never by `applyLive` on a sounding voice.* The
mechanism already exists — the invariant "A fresh note SNAPS, a sounding one holds φ" is
exactly this split — but the *classification* did not, until this phase widened it.
> **Where this is recorded.** `core/instrument/CLAUDE.md` states that *"which controls are
> live is ONE decision, recorded in ONE place"* — `deckParamCommit` / `liveCommitFor` in
> `ui/deck_groups`. Phase Γ widens that one decision from two states to three
> (`Live` / `NoteOnLatched` / `Reload`) rather than adding a second predicate elsewhere.
> This is also precisely the seam the automation work needs — see
> `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 under Varispeed; under Preserve, the loop's *traversal*
scales and its audible period does not.** The loop is a pair of *source-frame* facts.
Under Varispeed the read increment changes and the loop is traversed proportionally
faster — scaling is automatic, the stored frames are untouched, and the audible period
scales by `1/rate` along with everything else the voice plays. **Under Preserve this is
the opposite of what the Varispeed case suggests, and the obvious extension of it is
wrong** — which is why an engineer measured this before writing code against it rather
than inferring it from the Varispeed case above. What scales with rate under Preserve is
the loop's *traversal* — how fast the source is consumed (the feed-side witness is
`testPreserveStretchLoopsTheSourceSpan`) — not its audible period: holding the source's
period constant while its duration changes is what Preserve *is*. **Measured** (Debug
build, one machine): with a ring long enough to hold the whole loop, the rendered
sawtooth period is ~3999.9 output frames at rate 0.5, 1.0, and 2.0 alike; at shorter
rings, where splice cadence intrudes instead of the design property being isolated, the
same fixture measured 3064 and 4130 frames at rate 0.5 — never the 8000 a scaling period
would give either. **In neither mode are the stored loop frames rewritten**; the marks on
the waveform do not move when Rate moves.
- **Contours scale with rate.** A drawn contour is a pure function of *normalized* sample
position (`core/instrument/CLAUDE.md`: "Normalized is what makes a contour
length-independent"), so it follows the read head by construction. **The staged
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 75133 % 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 made it expensive here was entirely our own design, in one line** — and Γ-W3 removed
that line. `ReaSamplerProcessor::setActive` was deliberately destructive in both directions:
- `setActive(true)` called `reloadInstrument()`**a bridge read and a full WAV re-decode**,
plus a fresh engine. That was the expensive half, and no part of it was required by the SDK:
it was there because activation was the convenient trigger for a reload, not because
activation implies one.
- `setActive(false)` frees `live_`, `draining_` **and** the graveyard, so every sounding voice
dies. That half 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."*
**The cost was ours, and it has been reduced (Γ-W3 — see §7.11).** The deactivate now parks the
decoded `SampleData` and the reactivate rebuilds only the voice state around it, through the
same drain-slot swap `rebuildVoiceEngine` uses for voice-count edits. An activation cycle costs
no disk read and no decode; an instance with nothing decoded still takes the full reload, which
is where the pre-v10 legacy lift lives.
**The honest cost of the toggle, stated plainly:** every sounding note stops. **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 and whether transport hiccups is DAW-observable only. The re-decode half of that
question is gone (§7.11), so what remains to observe is the voice cut alone. **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**.
**The limiter clause's original reasoning was false, and the code was changed to make its
conclusion true.** It read "master gain is already on the reset list, so the bake includes
the master stage, so the limiter's effect is in the audio" — but the bake printed a flat
gain multiply and nothing else; the limiter ran in the processor's block, off the bake path,
so a capture baked with it engaged came back unlimited and resetting the enable was
resetting a control whose effect was NOT in the file. Daniel ruled the goal rather than the
premise: `renderBake` now prints the whole master stage, gain then limiter, so the
classification stands on the rule it always claimed to. The lookahead is compensated inside
the render, and a bypassed bake is the pre-limiter render frame for frame —
`src/core/instrument/bake/CLAUDE.md` owns both, plus the double-limiting boundary a baked
capture inherits.
**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 |
> **This table is a DRAG rule and must never become `ParameterInfo::stepCount`** (Ruling 3,
> §8.4). `stepCount` quantizes the parameter itself — permanently, and for the host's
> automation too — which would put continuous cents out of reach from a lane and freeze the
> grid into the forever contract. The two axes are already visibly independent here: filter
> cutoff *displays* in Hz, *snaps* in whole percent of its normalized position, and is
> *automated* continuously. All 44 exposed parameters ship `stepCount = 0`.
### 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 1100 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.120.20 of travel, 100 ms within 0.420.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 1416 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** | `overlay/trace` | solid **right-pointing triangle** (a play flag — it points into the material that will play) | solid | `START`, right of the line |
| **Loop start** | `accent/secondary` | **L-cap opening right** | solid | `LOOP`, right of the line |
| **Loop end** | `accent/secondary` | **L-cap opening left** | solid | `END`, left of the line |
| **Crossfade** | `accent/secondary`, reduced alpha | **ramp cap** — a small right triangle whose hypotenuse rises left→right, drawing the fade-in shape | **dashed** — a soft boundary, not a hard one | `XFADE`, left of the line |
Start draws in `overlay/trace`, not `accent/primary`: `accent/primary` **is** the waveform
fill, so a primary START would measure 1:1 against the material it marks. `overlay/trace`
measures 3.071:1 against the fill and 3.065:1 against `bg/base`, clearing the 3:1 non-text
floor on both — provably optimal, since `core/ui/CLAUDE.md`'s two-neighbour rule derives
`sqrt(9.41) ≈ 3.07` as the ceiling any single value can hold against both neighbours at
once. Start is still the only mark always in effect (both Gate and Trigger), but that is no
longer what its ink says, now that `overlay/trace` is shared with the envelope trace: the
distinction is carried by shape instead — a straight full-height column under a solid
triangle cap, never a curve. The loop pair's opposed L-caps read as `[ … ]`
without needing to be explained. All four caps use primitives already in the kit
(axis-aligned fills, AA-restroked triangles per `visual-design-language.md` §8).
**(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 **same ramp** the audible wedge draws (growing
left-to-right, peaking at `loopStart`) at half alpha, outside the loop fill — not a
mirror of it: the incoming tap's weight at ingredient frame `loopStart crossfade + k` is
the same `crossfadeWeight` as audible frame `loopEnd crossfade + k`, so both spans carry
the identical ramp, which is exactly why one `crossfadeWedgeHeight` function draws both.
It carries no handle. **At rest it is a hairline dashed outline; it fills in on hover or
drag of the crossfade handle** — a hover state in the sense §3.3 of the visual language
means, revealing the relationship only when the user is asking about it.
- **This makes the clamp self-explanatory.** The hard clamp is
`crossfade ≤ min(loopStart, loopLength)` (`loop_span.h:19``maxCrossfade(loopStart,
loopEnd loopStart)`; `start` there names `loopStart`, not the START mark — and its "no
material ahead of the loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn,
**each half of the clamp is now visible, on a different mark:** the ghost's left edge
reaches frame 0 — the overlay's own left edge, not a mark — exactly at the `loopStart`
bound, and the audible wedge's left edge reaches the LOOP mark exactly at the `loopLength`
bound. The user sees why the fade stopped growing instead of hitting an invisible wall.
That is the single best payoff in this design and it costs nothing extra.
**(d) The off-state and the Trigger state get words, not just alpha.**
- **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 82 px headroom is spent.**
*Constraint:* the title slot must still hold its text at the 1198 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 — `deckParamCommit` becomes three-valued.** See §2.3. The exhaustive switch must
classify the two new `DeckParam`s or fail to compile — which is exactly what it is designed
to do, and which is why the two new parameters are cheap to add *now*.
**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` conflated two lifetimes; it no longer does (LANDED, Γ-W3).** Activation
used to mean both "the audio thread may run" and "the decoded `SampleData` is (re)built", so
every host-driven cycle paid a bridge read and a full WAV decode. The two are now separate:
`setActive(false)` parks the decoded sample and destroys the voice state, `setActive(true)`
rebuilds the voices around the parked sample through the drain-slot swap `rebuildVoiceEngine`
already used. **This section's earlier instruction — "do not restructure `setActive` inside
this phase" — was superseded by Daniel's ruling that this track does it**; the deactivate's
destruction of voice state is still deliberate (a surviving `live_` would resurrect stale
sustained voices as ghosts) and only the PCM survives. Nothing parked routes the activation
back through the full reload, which is what keeps the pre-v10 legacy lift reachable. Γ-F6 is
untouched: dynamic latency ships and the deactivate/reactivate is still the accepted cost —
it is simply a much cheaper one.
**What "cheaper" does NOT mean: skipping the bank fold.** `reloadInstrument` also runs the
recapture sync and the `rsusage_` prune-protection publish, and with no editor open the
activation is the only place either happens (`pollBankSync` runs off the editor's sync tick and
nothing else). Both are a `GetProjExtState` plus a parse — neither disk nor decode — so both run
on the resume path too, and a fold that moves the loaded capture's decode source hands back to
the full reload rather than resuming PCM the bank has superseded. A resume that refreshed the
refs without re-decoding would be the worst of the three: the table would name a recapture's new
file while the voices played the old one.
---
## 8. Forks — SEVEN ruled (one later reversed), NONE 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 82 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` decoupling entry discharged in Γ-W3 |
| **Γ-F7** | VST3 parameter ORDER: signal flow, or the editor's visual rows? | **Signal flow***"signal flow order."* The frozen id numbering and the presentation index both follow the deck's own rule; the visual layout is too mobile to freeze against. | **§8.3**; `parameter-automation.md` §6.4 (argument) and §6.2 (the 44-id table) |
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 was our reload-on-activate, and
Γ-W3 removed it (§7.11).
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 was filed with a trigger condition and has since LANDED** (Γ-W3 — §7.11).
### 8.3 Γ-F7 — RULED: signal flow. The parameter order
**Opened 2026-08-01 by Ruling 1** (*"Make the parameter order logical"*), because "logical"
resolved two ways and the choice freezes forever the day parameters ship. **Closed the same
day by Daniel: *"signal flow order."***
> **PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE → MASTER**, the
> deck's own documented ordering rule, layout-independent. Both the frozen id numbering and
> the `getParameterInfo` presentation index follow it; each group's cells take the semantic
> order the id table freezes.
**The rejected alternative** was the editor's visual row order after the Γ-W3 reflow — row 1,
then row 2, then MASTER, matching what the user's eye scans. **The reason it lost, recorded
because a reader will ask why the numbering does not match the screen:** the editor's layout
has already moved twice and this phase moves it again, and within-row order is settled by
width fitting rather than by meaning — so binding a permanently-frozen id order to a
demonstrably mobile layout guarantees the two drift apart, after which the order is neither
logical nor matching. Signal flow is the axis that does not move.
The full argument, the accepted residual cost (a user scanning REAPER's flat list), and the
resulting 44-id table are in `docs/product/parameter-automation.md` §6.4 and §6.2.
*Every fork in this phase is now ruled, and nothing in this doc awaits a Daniel answer.*
### 8.4 Ruling 3 — real units at the host boundary
**Daniel, 2026-08-01:** *"The parameter values exposed to the VST host should be in real
units, such that the host automation lanes report usable values."* Not a fork — it arrived as
a requirement and is specified rather than decided. It lands entirely in Γ-W4-T1 and moves no
wave boundary.
It reaches back into this doc in exactly one place: **§4's interaction law and §4.2's
snap-unit table are DRAG rules and must never become `ParameterInfo::stepCount`.** Snapped
drag and parameter continuity are independent axes — this doc's own table already proves it,
since filter cutoff displays in Hz, snaps in whole percent of normalized position, and is
automated continuously. Full spec, including the unit/precision table, the one-formatter
invariant and the `stepCount` sweep: `docs/product/parameter-automation.md` §6.7.
---
## 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 1198 × 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 v15 for the limiter flag, W2-T1 takes v16 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`.