# Instrument control surface — Phase Γ (ReaSampler 9000) The product-design reasoning behind **Phase Γ**: the deck's two-row reflow, the PITCH/RATE deck, the MASTER bus deck (limiter + meter), a consistent knob interaction law, the staged contour-trace defect, and a re-approach of the loop/crossfade markers. **Status:** items A–F below are **SETTLED (Daniel, 2026-08-01)** from a direct interview; this doc records them, works out the design detail they imply, and states the arithmetic. **All six forks this doc opened (Γ-F1…Γ-F6) are ruled (Daniel, 2026-08-01)**; their rulings are folded into the sections they affect and indexed in §8. **Nothing in this phase awaits a Daniel answer.** **The forward-looking VST3 automation parameter system is deliberately NOT in this phase** — it has its own doc, `docs/product/parameter-automation.md`. > **Γ-F6's ruling corrected this document's analysis, not merely its recommendation.** §3.1.1 > previously framed dynamic latency reporting as exotic and expensive. It is neither: it is > routine for VST3 instruments and REAPER handles it as a matter of course. What is expensive > here is **self-inflicted** — this plugin's own `setActive` — and therefore ours to reduce. > §3.1.1 has been rewritten accordingly, not merely annotated with the ruling. Every geometry number below was re-derived from `src/core/instrument/ui/knob_deck.cpp`'s own width formula, not carried over from a prior measurement. The stale geometry block in `docs/TODO.md` ("The deck layout needs a real rework") is **superseded by this doc** — see §7.6. --- ## 0. TL;DR - **The spine is the reflow.** The deck's controls are two categories — *sound* and *contour* — and the current three-row greedy wrap expresses neither. Row 1 is **PITCH/RATE | FILTER | VELOCITY | VOICE** (sound). Row 2 is **PITCH ENV | FILTER ENV | AMP ENVELOPE** (contour). **MASTER spans both rows on the far right.** - **The arithmetic closes, with room.** Minimum/default window goes **980 × 680 → 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 50–200 % exponential, Pitch ±24 st. - **MASTER becomes the post-voice-mixer deck it was always reserved to be**: limiter toggle in the caption corner, gain knob upper-left, a full-double-height stereo peak meter down the right, an averted-clip bubble, and a **one-cell** reserved lower-left slot (Γ-F5). - **The limiter is a lookahead design with DYNAMIC reported latency** (Γ-F2, Γ-F6): zero when off, the lookahead when on, reported to the host's PDC. This is **routine VST3 behaviour**; the `restartComponent(kLatencyChanged)` it costs is the normal contract, and the deactivate/reactivate the flag mandates is **accepted** — the toggle is a patch-design gesture. The 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` §§6–10: 44 exposed parameters derived from the three-state commit predicate, a hand-assigned FOREVER-FROZEN id table in blocks of 100 with steps of 10 **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 | 0–48 | 34–82 | | 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 75–133 % ratio; no smearing of transient material at 50 %/200 % worse than varispeed at the equivalent ratio; the null case (ratio 1.0, no shift) must be **bit-identical to the shipped Preserve read**. - **Gate.** Measure and report before the algorithm is final: per-voice CPU at 32 voices, added latency (must be zero at the onset), and A/B recordings on three material classes (one-shot, tonal sustain, full-mix bounce). --- ## 3. MASTER — the post-voice-mixer deck `deck_groups.cpp` already records the reservation: *"MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there."* Phase Γ **fulfils** that reservation rather than contradicting it. **The signal chain, stated once:** ``` voice mixer → master gain (existing ramped multiply) → LIMITER (bypassable) → output bus └── METER TAP ``` The meter is tapped at the **audio bus output, post-limiter** (settled). ### 3.1 The limiter **Settled: a single toggle, no configurable controls. Baked ceiling at −0.3 dBTP.** Daniel's framing: *"this is a safety device with potential for musical abuse, not a whole configurable limiter."* Behaviour: - **Toggle only**, in MASTER's caption corner (a two-segment `Off|On` caption toggle, the same primitive `kFilterEnable` uses). Persisted in `ComponentState`. - **Default: off.** The migration bar ("a project saved before a change reopens sounding identical") forbids any other default — an absent field must lift to bypassed. - **Transparent at rest.** No makeup gain, ever. No upward gain of any kind. When nothing exceeds the ceiling the output is **bit-identical to the un-limited path**. - **Byte-identical when bypassed.** With the limiter off, the per-sample path must be byte-identical to today's bare ramped multiply — the same discipline that makes `live == nullptr` byte-identical to the pre-live core and the filter's exact skip at `modAmount == 0` hold the at-rest path unchanged. This is an acceptance criterion, not an aspiration. - **Ceiling −0.3 dBTP.** dBTP is a *true-peak* target, so the detector must see inter-sample peaks — the standard route is an oversampled peak detector **in the sidechain only**, never oversampling the signal path. The oversampling factor is the engineer's call under the measure-and-report gate. - **Stereo-linked detection** (max of |L|,|R| drives one gain), so the stereo image is not moved by the limiter. - **Gain reduction is published per block** for the bubble indicator (§3.3). - **Lookahead, with DYNAMIC reported latency** — §3.1.1. ### 3.1.1 Lookahead and dynamic latency (Γ-F2 and Γ-F6, both ruled by Daniel 2026-08-01) **Settled: the limiter has lookahead, and the plugin reports latency dynamically.** Daniel's reasoning, verbatim intent: true-peak detection needs oversampling and a transparent limiter wants lookahead, and he is willing to pay the latency **provided it is latent only when the limiter is ON and the latency is reported to the host's PDC system.** This overrides the zero-lookahead recommendation this doc previously carried. **The behaviour, stated as the contract:** - Limiter **off** → `getLatencySamples()` returns **0**. - Limiter **on** → `getLatencySamples()` returns the lookahead in samples. - The toggle calls `IComponentHandler::restartComponent(kLatencyChanged)`. **None of that exists today.** There is no `getLatencySamples` override anywhere in `src/`, no `kLatencyChanged`, and no `restartComponent` call site — the plugin ships the SDK default of **0**. This track is the first latency reporting the instrument has ever done, so there is no existing behaviour to preserve, only a new contract to get right. #### What the vendored SDK says — and where the cost actually comes from > **This section previously argued that the SDK's requirements made dynamic latency > expensive. That framing was wrong and Daniel corrected it** (Γ-F6): *"you have to have > missed something, I used plenty of VST3s inside of REAPER that report PDC > dynamically."* He is right. The corrected analysis follows; the SDK quotes are unchanged > because the quotes were never the problem — the attribution of the cost was. Two facts read directly out of `vendor/vst3sdk`: > `pluginterfaces/vst/ivstaudioprocessor.h:293-299` — *"If during the use of the plug-in > this latency change, the plug-in has to inform the host by using > `IComponentHandler::restartComponent (kLatencyChanged)`, **this could lead to audio > playback interruption** because the host has to recompute its internal mixer delay > compensation. Note that for player live recording this latency should be zero or small."* > `pluginterfaces/vst/ivsteditcontroller.h:105-108` — *"`kLatencyChanged`: … **The host has > to deactivate and reactivate the plug-in**, then afterwards the host could ask for the > current latency."* **Both describe the ordinary contract.** Dynamic latency reporting is routine for VST3 plugins — lookahead limiters, linear-phase EQs and oversampling processors all do it — and REAPER handles it as a matter of course. The deactivate/reactivate is the *normal* cost of the flag, and for a typical plugin it is cheap: `setActive` allocates and frees buffers. **What 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** (master gain is already on the reset list, so the bake includes the master stage, so the limiter's effect is in the audio). **This is now a CORRECTION, not a sequencing note.** The original plan required Phase Γ to land before Ξ-W2 so the bake's reset list would be complete on the day it shipped. **That ordering was never Daniel's choice and it is already violated** — Ξ-W2-T1 was underway before this phase was scoped (Daniel: *"xi was started before I spun you up, we'll have to correct phase xi inside gamma. wasn't a choice."*). So the three classifications above are an amendment Phase Γ **owes** to a shipped bake, and Γ-W3-T2 is the track that pays it. The amendment is verified **against what Ξ-W2-T1 actually shipped**, never against what this document predicted it would ship. **A second correction of the same shape arrives with Ruling 1**, and it is Γ-W4-T1's, not this one's: once these values are exposed as VST3 parameters, the bake's reset must notify the host, and a host automation lane on a reset-class parameter re-imposes its curve onto already-baked audio. Full analysis and the disposition: `docs/product/parameter-automation.md` §9. ### 3.5 Assessment: the `temp_cortex/` limiter reference Read in full (`temp_cortex/limiter_base.{hpp,cpp}`, `temp_cortex/fast_limiter.{hpp,cpp}`, commit `3ad3094`). Standing project rule: cortex code is a *reference*, not a transplant — if there is character in it worth having, it gets rebuilt as an explicit parameter rather than inherited as a side effect. **Verdict: it does not clear that bar. There is no character in it worth having.** Read it as a reminder of the shape, take nothing. What is structurally right: detection on the un-delayed signal with the gain applied to a delayed copy (that *is* correct lookahead), and stereo-linked max detection. Both are textbook and need no reference. Everything else is disqualifying: | Finding | Why it disqualifies the code | |---|---| | **Unconditional makeup gain** — `makeupGain = ceilingLinear / thresholdLinear`, applied on every sample whether or not anything is limiting (`fast_limiter.cpp:121,129`). At the defaults that is a permanent **+2.9 dB**. | Directly contradicts "a safety device": the toggle would change loudness at rest. This is *exactly* the inherited-side-effect the standing rule exists to catch. Our limiter has **no makeup gain at all**. | | **`uint8_t lookaheadSamples`** (`fast_limiter.hpp:14`, computed at `.cpp:12`). 5 ms at 96 kHz = 480 → silently wraps to 224. | An embedded-platform assumption (fixed low rate) that does not survive a DAW. | | **`uint8_t peakHoldSamples`, with dead guard code** — `if (peakHoldSamples > 255)` (`.cpp:50`) can never be true; the cast already truncated. | Same class of defect, plus the guard reads as protection that isn't there. | | **Two `powf` calls per sample** to convert constant dB values to linear (`.cpp:94-95`). | A transcendental on the per-sample path. The house rule (`engine/loop/CLAUDE.md`) explicitly forbids one there. | | **Cascaded double smoothing** — `envelope` is attack/release smoothed, then `currentGain` is smoothed again with the same coefficients (`.cpp:85-91`, `112-118`). | The realized timing is not the stated timing; the timing constants mean nothing. | | **A near-instant attack (0.01 ms) behind a 5 ms lookahead** (`limiter_base.cpp:6`). | The lookahead's whole purpose is to let the gain reach its target *before* the peak arrives. With an instantaneous attack the lookahead only delays audio. | | **`virtual void process(float[2], …)` called per frame** (`limiter_base.hpp:16`). | A vtable dispatch on the per-sample path — the phase-wide "no dispatch-stack blowouts anywhere" guardrail. | | **Sample-peak only, no ISP detection.** | Cannot meet the settled −0.3 dBTP ceiling as written. | | **Startup mute** — outputs silence until the delay line fills (`.cpp:132-135`); raw `new[]`/`delete[]`; depends on unvendored `CircularBuffer.h` and `basicmaths.h`. | Embedded idiom, not house idiom. | **Recommendation: write ours from the literature against §3.1's constraints, and delete `temp_cortex/` once the limiter lands** (its removal is staff-engineer's, not mine). --- ## 4. The knob interaction law (item D) Daniel scoped this as its own work item: a **consistent, unit-category-driven** interaction and taper rule across **every** variable control. The load-bearing property is that the rule is derived from the control's *unit*, so a control added later inherits it without anyone maintaining a list. ### 4.1 Modifiers | Gesture | Effect | |---|---| | **Shift** | Snap to whole numbers **in the control's displayed unit** (§4.2). | | **Ctrl** | Scale the drag by **0.05** (1/20 sensitivity) — fine grain. | | **Shift + Ctrl** | **Shift wins; Ctrl is ignored.** Not a compromise: when the output is quantized to integers, a finer drag produces the same sequence of values. Stated so nobody "fixes" it later. | **Mid-drag modifier changes re-anchor.** The knob drag is grab-anchored absolute (`param_slider.cpp:138-143`, `kKnobDragRangePixels = 128`), so flipping a modifier mid-drag without re-anchoring makes the value jump by `(1 − 0.05) ×` the accumulated delta. > **On every modifier transition — press or release — during an active drag, the drag > re-anchors: the control's current value becomes the new anchor value and the cursor's > current position becomes the new anchor position.** The value is continuous across the > transition; only the rate changes. This holds for Shift too: releasing Shift re-anchors > from the *snapped* value, so a snapped knob does not jump back. **Plumbing.** `wndProc` currently discards `wParam` for `WM_MOUSEMOVE`. The codebase already reads modifiers via `GetKeyState` in two other input paths (`editor_input_curve.cpp:50`, `editor_input_waveform.cpp:38`) — the precedent exists; the requirement is that **all drag surfaces read it through one shared helper** so they cannot drift into two modifier grammars. **Scope — and one explicit exclusion.** The law is a property of the **parameter**, not the widget. Every surface that edits a unit-valued parameter honours it: deck knobs (outer ring *and* inner curve dial), envelope stage nodes, and curve knots. This follows from the standing invariant that node drags, knot drags and knob edits are *"surfaces onto ONE model"* — a snap available on one and not the others would be a divergence. > **Waveform markers (start, loop start, loop end, crossfade) are excluded.** They carry an > existing zero-crossing snap gesture, and their domain is frames, which has no meaningful > "whole number" above the frame. Overloading Shift there would collide with a shipped > gesture. Recorded as a deliberate exclusion. ### 4.2 The snap unit, by category | Unit category | Controls | Shift snaps to | |---|---|---| | **milliseconds** | the 14 stage-time knobs (amp/filter Gate A,H,D,R; amp/filter Trigger A,D; pitch env A,D) | whole **ms** | | **semitones** | pitch env depth, the new **Pitch**, and **Rate** | whole **semitones** — on Rate this means the 25 semitone steps between 50 % and 200 %, which is what makes an octave or a fifth reachable by hand | | **percent / fraction** | sustain level, hold fraction, Trigger length, key-track (both), filter mod amt / vel amt / morph / cutoff / Q / drive | whole **percent** | | **exponent** | the 12 inner curve dials (0.1 … 10) | whole numbers — which puts **1.0, the linear neutral, one snap away** | | **decibels** | master gain | whole **dB** | | **already integer** | voice count, preview velocity | no change | > **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 1–100 ms while still reaching the ceiling. A pure log map cannot include zero, and zero is a required value, so the taper is stated as acceptance criteria rather than a formula (the engineer picks the shape): - Exactly **0 s at norm 0** and exactly `kEnvTimeMaxSeconds` at norm 1. Monotone and continuous throughout. - **10 ms lands within 0.12 … 0.20 of travel; 100 ms within 0.42 … 0.52.** - `kEnvTimeMaxSeconds` **moves 2.0 s → 10.0 s IN THIS PHASE, in this track** (`deck_values.h:22`, which reads `kGateStageMaxSeconds` at `envelope_overlay.h:85` — the two move together or not at all). **This reverses Γ-F3** (Daniel, 2026-08-01: *"extend the stage lengths to 10s"*), and §4.3.1 below is the design work it pulls in. #### 4.3.1 The 10 s ceiling — why it moved, and what it costs **Γ-F3 was ruled "not in this phase" and is now reversed.** The reversal is not a change of mind about the musical range; it is a consequence of Ruling 1 — VST3 parameters now ship at the end of this same phase (§4.4, `docs/product/parameter-automation.md` §8). The moment parameters exist, a range endpoint is part of the host-facing normalization exactly as much as the curve between the endpoints is: re-ceiling re-interprets every recorded automation point in project files we do not own and cannot migrate. **Raising the ceiling is free this wave and permanently expensive four waves later.** The `docs/TODO.md` entry that carried the ambition is discharged here rather than deferred again. **Three constraints carry forward unchanged.** 1. **`kEnvTimeMaxSeconds` and `kGateStageMaxSeconds` move together.** `deck_values.h` reads the overlay's constant rather than restating it precisely so the two cannot drift, and the agreement requirement is documented at `deck_values.h:19-22`. 2. **The reset bypass is now MANDATORY, not merely required-anyway.** `resetDeckParam`'s exact-default recovery depends on the ceiling being a power of two (`deck_values.h:42-46`); **2.0 is, 10.0 is not**, and the log taper compounds it. The bypass below was already required by this track — it is now also the only thing that makes the new ceiling correct, so it is not a candidate for "simplification" back into a norm round-trip under any circumstance. 3. **A new, harder correctness case arrives with the parameters, and it is NOT solved by the bypass.** `ParameterInfo::defaultNormalizedValue` is normalized; a host's reset-to-default arrives back as `toPlain(defaultNorm)`, and **the host has no bypass to offer**. The taper must therefore be designed so **every default has an exact normalized preimage** — see `docs/product/parameter-automation.md` §8 door 3. This binds the taper's *shape*, so it belongs to this track and cannot be handed forward. **The real design problem is legibility, and it is in scope here.** The AHDSR overlay's schematic gives each of the four timed stages an equal slot and maps seconds across it linearly (`gatePxPerSecond`, `envelope_overlay.cpp:33-34`). At a 2 s ceiling a 30 ms attack occupies 1.5 % of its stage's domain — small but drawn. **At 10 s it occupies 0.3 %, under a pixel at the floor width, and becomes visually indistinguishable from zero.** A 5× ceiling that makes the default attack invisible is not a feature. **Three directions were considered.** 1. **Content-fit auto-scale** — the schematic's domain follows the largest current stage, so short envelopes draw large. Rejected: the axis moves under the hand while you drag, every node shifts when any node moves, and it breaks the documented anchor that a maxed knob lands exactly at the canvas edge. 2. **A minimum drawn stage width** — every stage gets at least *n* px regardless of value. Rejected: it decouples the drawn position from the value, so `envelope_edit`'s drag inverse can no longer be the exact inverse of the draw — which is the one property the node/knot/knob "surfaces onto ONE model" invariant rests on. 3. **The schematic axis BECOMES the taper — recommended.** A stage's slot width is `slotPx × taperNorm(seconds)` instead of `slotPx × seconds / ceiling`. The node's position within its slot then *is* its knob's needle position, drawn a second way. **Recommended: direction 3.** It is the smallest change that is also the most principled one: - **Legibility becomes ceiling-independent by construction.** The taper's own landmarks (10 ms within 0.12–0.20 of travel, 100 ms within 0.42–0.52) are landmarks on the overlay too, at any ceiling this or a future phase picks. - **It strengthens the one-model invariant rather than straining it.** A node and its knob become the same normalized quantity; today they are two maps that happen to agree. - **It costs the drawn curve nothing.** The taper decides only *where a stage's end node lands*. Within a stage, φ still runs linearly across the stage's pixel span, so a φ^p segment draws as φ^p exactly as Γ-W1-T3 specifies — the two tracks compose rather than fight. - **The AHD policy is untouched.** An AHD maps 1:1 onto the waveform's own time axis and is PCM-aligned; it must stay linear in seconds, and nothing here changes it. **Only the AHDSR schematic is tapered**, and it was already documented as schematic-not-time-aligned. **The cost, named:** within-stage horizontal extent stops being proportional to time, so two stages can no longer be compared by eye at a 10× ratio the way they can at 2×. The ms labels Θ-W6-T1 landed carry the actual number, which is what that comparison is actually made against; and the alternative — a 30 ms attack drawn as zero — loses the comparison entirely. **The structural consequence, and it is the important one.** The taper is now read by three consumers: the knob (`deck_values`), the overlay (`envelope_overlay` + `envelope_edit`), and — from Γ-W4-T1 — the host (`normalizedParamToPlain`). **It must be extracted into one pure module** rather than living inside `deck_values`, which sits above `envelope_overlay` in the dependency order. That extraction is what makes "the taper IS the host-facing normalization" structurally true instead of a comment somebody has to remember. **Semitone knobs become log2-scaled.** More resolution across −7 … +7 st while still allowing the extremes: - Symmetric about the centre; **exactly 0 at norm 0.5**; exactly ±`kPitchDepthMaxSemis` at the ends; monotone. - **±7 st reached at 50 % … 58 % of each half-travel** (so the musically useful middle gets more than half the knob on each side). - **Rate is the exception and keeps its settled taper**: linear in semitones over ±12, i.e. exponential in ratio. It needs no centre expansion — ±12 st over the full travel is already 0.19 st per drag pixel — and Daniel settled it explicitly. The two laws coexist on one deck for a stated reason: **centre expansion applies to semitone knobs whose throw exceeds ±12.** **One correctness consequence, and it is required, not optional:** > **`resetDeckParam` must bypass the taper.** It currently round-trips the default through > `norm → value` (`deck_values.cpp:200-203`), and `deck_values.h:42-46` documents that exact > recovery depends on the map being linear over a power-of-two ceiling. A log taper makes > that round-trip inexact. **Reset must write the default value directly** — the defaults > are already read off a default-constructed `PlaySeconds`, so there is no second table to > drift. This *removes* the power-of-two dependency rather than working around it; the > comment at `deck_values.h:42-46` becomes wrong and needs rewriting (source work, > staff-engineer's). > > **This is also the unblocker for a future 10 s ceiling.** 2.0 is a power of two; 10.0 is > not, so under today's round-trip a 10 s ceiling would land every reset a mantissa bit off > its own default. Landing the reset bypass here means the ceiling question later is a > one-constant change plus an overlay-scale re-check, not a correctness problem. ### 4.4 Sequencing — why item D goes first Item D lands **before** the two new PITCH/RATE knobs, so Rate and Pitch are authored into the finished law rather than retro-fitted into it. It lands **three waves before Γ-W4-T1's VST3 parameter work**, for a much sharper reason: once parameters are exposed, the taper *is* the host-facing normalization, and re-tapering silently re-interprets every recorded automation point in every saved project. **Taper changes are free now and permanently expensive later.** See `docs/product/parameter-automation.md` §4. That argument used to justify Γ running ahead of a future phase; since Ruling 1 it governs **wave order inside this one**, which is strictly better — the taper and the parameters that freeze it are now reviewed against each other rather than across a phase boundary. It is also why the ceiling moved (§4.3.1) and why §8's one-way-door sweep in the automation doc is a deliverable rather than a caution. --- ## 5. Staged contour traces draw straight, not curved (item E) **Daniel's report.** Changing a curve exponent from 1.0 moves the knot on the overlay, but the stage segment still draws as a straight line. **Audio is correct; only the drawing is wrong.** **Root cause, verified.** `editor_paint_waveform.cpp:218` does `if (v.knot) continue;` — knots are dropped from the trace and the remaining vertices are joined with straight strokes (`:222`). `curveMap` is never called in the paint path. The exponent is in scope (`env.attackCurve` / `.decayCurve` / `.releaseCurve` at `:211`) and simply never read. Knot *positioning* does honour the exponent via `curveMidLevel` (`envelope_overlay.cpp:94-105`) — which is the divergence: **at any non-neutral exponent the knot visibly floats off its own trace.** **The intended visual result** (the tessellation approach is the engineer's call): - Every sloped stage draws as the curve its exponent defines, evaluated through **the same `curveMap` the audio uses** — one source, so the trace and the sound cannot diverge. - **At exponent 1.0 the segment is visually identical to today's straight line** (the regression guard). - **At every exponent the knot's centre lies on the trace**, within 1 px. This is the observable acceptance criterion, because the knot/trace divergence is the reported defect. - No visible faceting at the widest segment the canvas can produce (per-pixel-column or adaptive sampling; a fixed low tessellation count is not acceptable at full width). - The trace keeps the established grammar: one weight, `kEnvTracePx = 2.0`, through the analytic stroker — Θ-W7's "both envelope traces are one grammar and one weight" holds. - **It is one paint path, so one fix covers all of it**: amp / filter / pitch, Gate AHDSR and Trigger AHD, attack / decay / release. The drawn-EG (spline) overlay's own grammar is untouched — it already traces per column. - Both overlay layout policies are honoured unchanged: the AHDSR's right-anchored schematic and the AHD's 1:1 mapping. --- ## 6. Loop and crossfade — the re-approach (item F) > Daniel: *"the loop indicators and crossfade thingy are unintuitive as fuck. I don't > understand what the three lines represent, so we need to re-approach that UX."* This is the one genuinely open design problem in the phase. What follows is a recommendation, the alternatives that lost, and the trade-offs. ### 6.1 Diagnosis — three root causes, one of them not in the complaint **Cause 1 — a category error in the drawing.** The three bars are not the same *kind* of thing. **Start** is a *point in time* (where the head enters). **Loop start** and **loop end** are the two ends of a *span* (a region the head cycles inside). They are drawn in identical ink (`Role::AccentSecondary` for all three, `editor_paint_waveform.cpp:35-36`), at identical weight, full height, distinguishable only by position. Drawing a point and the ends of a span in one grammar is why the marks cannot be told apart, and it is the deeper answer to "what do the three lines represent." **Cause 2 — nothing is named.** There is no label anywhere in the band. Every audio editor labels these marks; we do not. This is the blunt, unglamorous half of the fix and it is probably worth more than everything else combined. **Cause 3 — the crossfade is painted where its ingredient lives, not where the event is heard.** Verified: the fade is pre-seam and one-tap — it fades material running into `loopEnd` toward material running into `loopStart`, using the read head one loop length earlier (`loop_span.h:26-28`). The audible event occupies the last `crossfade` frames **before `loopEnd`**. But the UI paints the shaded region **before `loopStart`** (`editor_paint_waveform.cpp:108-116`), and hangs the only grab affordance — a 10 px tab in the top strip with no line of its own — at `loopStart − crossfade`. **The paint follows the handle, not the audible event.** Both regions are real things (one is the ingredient, one is the event), but the drawing shows only the ingredient, and the handle is on the wrong side of the loop from the sound it controls. ### 6.2 Three directions considered **Direction 1 — "Two grammars: a point is a caret, a span is a bracket."** Differentiate by category — start gets a distinct ink and a directional flag cap; the loop pair gets bracket caps so it reads as an enclosure; the crossfade moves to the audible location. Cheap, uses only primitives the kit already has, attacks all three causes. **Direction 2 — "Lane it."** Add a dedicated 14–16 px **marker rail** along the top of the waveform band. All handles live in the rail; the waveform proper carries only quiet 1 px guide lines and the span fill. Structurally the strongest option: it would *dissolve* the overlay's claim-arbitration problem (`resolveWaveformClaim`) rather than tie-breaking it, and it would collapse two open `docs/TODO.md` entries. **Rejected for this phase** — it costs waveform height, needs a new pure geometry module, and re-routes every hit-test in the band, which is a much larger build than Daniel's complaint calls for. **Retained as the named fallback** if the recommended direction proves too crowded in the DAW. Precedent: REAPER's own ruler/marker lane; Sound Forge and Audacity's selection/loop handles. **Direction 3 — "Show the loop as a loop."** An arc/ribbon above the waveform running from loop end back to loop start with an arrowhead, the crossfade drawn as the ribbon's taper. Extremely legible for "what is a loop," and there is good precedent (Ableton Simpler's loop arrow, Kontakt's loop-return arc). **Rejected:** it is a lot of ornament for one fact the user learns once, and it sits uncomfortably against the visual language's strict decoration policy (`visual-design-language.md` §3.5). ### 6.3 Recommended: "Name it, class it, and put the fade where it is heard" Direction 1, with labels, in four moves. One grammar, four marks. **(a) One mark grammar: line + cap + label. The cap IS the grip.** Every mark draws as a full-height 2 px column, a **shaped cap** in the overlay's top strip, and a `Font::Micro` / `TextDim` label. The cap is both the mark's identity and its grab handle — which instantly answers "what is that tab?", because the crossfade's cap stops being a bare orphan rectangle and becomes the same kind of object as every other mark's. | Mark | Ink | Cap | Line | Label | |---|---|---|---|---| | **Start** | `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`.