Eight tracks across two waves, from Daniel's seven-item list of 2026-08-03; all six forks ruled, none open. Amends instrument-control-surface.md 6.3(d) and 6.4, both reversed by Omega-F2.
107 KiB
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 oursetActivere-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|Notchcaption 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.mdat Γ-W1-T4 states the seam in full.
Three corrections to the arithmetic in the brief, all small and all in our favour:
- 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.
- 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.
- VOICE keeps its row toggle — confirmed. Moving
Retrig|Legatoto the caption gives38 + 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:
-
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.
-
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.kDeckGroupGapis 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. -
Shared horizontal baselines. Every group is
kDeckGroupHwith 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. -
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|Notchmove 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.mdstates that "which controls are live is ONE decision, recorded in ONE place" —deckParamCommit/liveCommitForinui/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 — seedocs/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/ratealong 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 istestPreserveStretchLoopsTheSourceSpan) — 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 thepitch_shiftpre-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|Oncaption toggle, the same primitivekFilterEnableuses). Persisted inComponentState. - 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 == nullptrbyte-identical to the pre-live core and the filter's exact skip atmodAmount == 0hold 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 usingIComponentHandler::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)calledreloadInstrument()— 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)freeslive_,draining_and the graveyard, so every sounding voice dies. That half is correct and must not be softened casually: a survivinglive_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↔kStereoviarestartComponentpreviously panned a dual-mono capture hard right in the host's pin re-routing (seetestDualMonoStereoSampleRendersCentered)."
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:
- Verify the whole call sequence against the vendored Steinberg SDK before writing it
—
IAudioProcessor::getLatencySamples,IComponentHandler::restartComponent, theRestartFlagsvalue, and the SDK's stated ordering (the new latency is whatgetLatencySamplesreturns aftersetActive(true), perivsteditcontroller.h:106— so the reported value must be derived from persisted state, not from a transient the deactivate clears). - 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.
- 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. - Never call
restartComponentfromprocess(). 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:
- 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.
- 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.
- 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'skIsBypassparameter 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
captionRadiogeometry with hit-test suppressed, or a passive-indicator slot inknob_deck— an implementation call, but the slot is the existing one and no new geometry is invented. - Ink:
warn. Legal under the palette's "warn is reserved for clip states" rule because gain reduction reports an averted clip — the same state class the clip cap reports, one stage earlier. - Lit whenever the block's maximum gain reduction exceeds a small floor (the intent is "the limiter is working," not "a sample touched the threshold"); it follows the same 20 dB/s-style decay as the meter so a transient catch is visible rather than a single-frame flicker.
3.4 Where the master controls sit in the reset scope
Phase Ξ-W2's resample reset scope is settled by rule ("reset what the bake baked in"). Derived against that rule — no new Daniel call: rate → reset, pitch offset → reset, limiter enabled → reset.
The limiter clause's original reasoning was false, and the code was changed to make its
conclusion true. It read "master gain is already on the reset list, so the bake includes
the master stage, so the limiter's effect is in the audio" — but the bake printed a flat
gain multiply and nothing else; the limiter ran in the processor's block, off the bake path,
so a capture baked with it engaged came back unlimited and resetting the enable was
resetting a control whose effect was NOT in the file. Daniel ruled the goal rather than the
premise: renderBake now prints the whole master stage, gain then limiter, so the
classification stands on the rule it always claimed to. The lookahead is compensated inside
the render, and a bypassed bake is the pre-limiter render frame for frame —
src/core/instrument/bake/CLAUDE.md owns both, plus the double-limiting boundary a baked
capture inherits.
This is now a CORRECTION, not a sequencing note. The original plan required Phase Γ to land before Ξ-W2 so the bake's reset list would be complete on the day it shipped. That ordering was never Daniel's choice and it is already violated — Ξ-W2-T1 was underway before this phase was scoped (Daniel: "xi was started before I spun you up, we'll have to correct phase xi inside gamma. wasn't a choice."). So the three classifications above are an amendment Phase Γ owes to a shipped bake, and Γ-W3-T2 is the track that pays it. The amendment is verified against what Ξ-W2-T1 actually shipped, never against what this document predicted it would ship.
A second correction of the same shape arrives with Ruling 1, and it is Γ-W4-T1's, not
this one's: once these values are exposed as VST3 parameters, the bake's reset must notify
the host, and a host automation lane on a reset-class parameter re-imposes its curve onto
already-baked audio. Full analysis and the disposition:
docs/product/parameter-automation.md §9.
3.5 Assessment: the temp_cortex/ limiter reference
Read in full (temp_cortex/limiter_base.{hpp,cpp}, temp_cortex/fast_limiter.{hpp,cpp},
commit 3ad3094). Standing project rule: cortex code is a reference, not a transplant —
if there is character in it worth having, it gets rebuilt as an explicit parameter rather
than inherited as a side effect.
Verdict: it does not clear that bar. There is no character in it worth having. Read it as a reminder of the shape, take nothing.
What is structurally right: detection on the un-delayed signal with the gain applied to a delayed copy (that is correct lookahead), and stereo-linked max detection. Both are textbook and need no reference.
Everything else is disqualifying:
| Finding | Why it disqualifies the code |
|---|---|
Unconditional makeup gain — makeupGain = ceilingLinear / thresholdLinear, applied on every sample whether or not anything is limiting (fast_limiter.cpp:121,129). At the defaults that is a permanent +2.9 dB. |
Directly contradicts "a safety device": the toggle would change loudness at rest. This is exactly the inherited-side-effect the standing rule exists to catch. Our limiter has no makeup gain at all. |
uint8_t lookaheadSamples (fast_limiter.hpp:14, computed at .cpp:12). 5 ms at 96 kHz = 480 → silently wraps to 224. |
An embedded-platform assumption (fixed low rate) that does not survive a DAW. |
uint8_t peakHoldSamples, with dead guard code — if (peakHoldSamples > 255) (.cpp:50) can never be true; the cast already truncated. |
Same class of defect, plus the guard reads as protection that isn't there. |
Two powf calls per sample to convert constant dB values to linear (.cpp:94-95). |
A transcendental on the per-sample path. The house rule (engine/loop/CLAUDE.md) explicitly forbids one there. |
Cascaded double smoothing — envelope is attack/release smoothed, then currentGain is smoothed again with the same coefficients (.cpp:85-91, 112-118). |
The realized timing is not the stated timing; the timing constants mean nothing. |
A near-instant attack (0.01 ms) behind a 5 ms lookahead (limiter_base.cpp:6). |
The lookahead's whole purpose is to let the gain reach its target before the peak arrives. With an instantaneous attack the lookahead only delays audio. |
virtual void process(float[2], …) called per frame (limiter_base.hpp:16). |
A vtable dispatch on the per-sample path — the phase-wide "no dispatch-stack blowouts anywhere" guardrail. |
| Sample-peak only, no ISP detection. | Cannot meet the settled −0.3 dBTP ceiling as written. |
Startup mute — outputs silence until the delay line fills (.cpp:132-135); raw new[]/delete[]; depends on unvendored CircularBuffer.h and basicmaths.h. |
Embedded idiom, not house idiom. |
Recommendation: write ours from the literature against §3.1's constraints, and delete
temp_cortex/ once the limiter lands (its removal is staff-engineer's, not mine).
4. The knob interaction law (item D)
Daniel scoped this as its own work item: a consistent, unit-category-driven interaction and taper rule across every variable control. The load-bearing property is that the rule is derived from the control's unit, so a control added later inherits it without anyone maintaining a list.
4.1 Modifiers
| Gesture | Effect |
|---|---|
| Shift | Snap to whole numbers in the control's displayed unit (§4.2). |
| Ctrl | Scale the drag by 0.05 (1/20 sensitivity) — fine grain. |
| Shift + Ctrl | Shift wins; Ctrl is ignored. Not a compromise: when the output is quantized to integers, a finer drag produces the same sequence of values. Stated so nobody "fixes" it later. |
Mid-drag modifier changes re-anchor. The knob drag is grab-anchored absolute
(param_slider.cpp:138-143, kKnobDragRangePixels = 128), so flipping a modifier mid-drag
without re-anchoring makes the value jump by (1 − 0.05) × the accumulated delta.
On every modifier transition — press or release — during an active drag, the drag re-anchors: the control's current value becomes the new anchor value and the cursor's current position becomes the new anchor position. The value is continuous across the transition; only the rate changes. This holds for Shift too: releasing Shift re-anchors from the snapped value, so a snapped knob does not jump back.
Plumbing. wndProc currently discards wParam for WM_MOUSEMOVE. The codebase already
reads modifiers via GetKeyState in two other input paths
(editor_input_curve.cpp:50, editor_input_waveform.cpp:38) — the precedent exists; the
requirement is that all drag surfaces read it through one shared helper so they cannot
drift into two modifier grammars.
Scope — and one explicit exclusion. The law is a property of the parameter, not the widget. Every surface that edits a unit-valued parameter honours it: deck knobs (outer ring and inner curve dial), envelope stage nodes, and curve knots. This follows from the standing invariant that node drags, knot drags and knob edits are "surfaces onto ONE model" — a snap available on one and not the others would be a divergence.
Waveform markers (start, loop start, loop end, crossfade) are excluded. They carry an existing zero-crossing snap gesture, and their domain is frames, which has no meaningful "whole number" above the frame. Overloading Shift there would collide with a shipped gesture. Recorded as a deliberate exclusion.
4.2 The snap unit, by category
| Unit category | Controls | Shift snaps to |
|---|---|---|
| milliseconds | the 14 stage-time knobs (amp/filter Gate A,H,D,R; amp/filter Trigger A,D; pitch env A,D) | whole ms |
| semitones | pitch env depth, the new Pitch, and Rate | whole semitones — on Rate this means the 25 semitone steps between 50 % and 200 %, which is what makes an octave or a fifth reachable by hand |
| percent / fraction | sustain level, hold fraction, Trigger length, key-track (both), filter mod amt / vel amt / morph / cutoff / Q / drive | whole percent |
| exponent | the 12 inner curve dials (0.1 … 10) | whole numbers — which puts 1.0, the linear neutral, one snap away |
| decibels | master gain | whole dB |
| already integer | voice count, preview velocity | no change |
This table is a DRAG rule and must never become
ParameterInfo::stepCount(Ruling 3, §8.4).stepCountquantizes 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 shipstepCount = 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
kEnvTimeMaxSecondsat norm 1. Monotone and continuous throughout. - 10 ms lands within 0.12 … 0.20 of travel; 100 ms within 0.42 … 0.52.
kEnvTimeMaxSecondsmoves 2.0 s → 10.0 s IN THIS PHASE, in this track (deck_values.h:22, which readskGateStageMaxSecondsatenvelope_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.
kEnvTimeMaxSecondsandkGateStageMaxSecondsmove together.deck_values.hreads the overlay's constant rather than restating it precisely so the two cannot drift, and the agreement requirement is documented atdeck_values.h:19-22.- 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. - A new, harder correctness case arrives with the parameters, and it is NOT solved by the
bypass.
ParameterInfo::defaultNormalizedValueis normalized; a host's reset-to-default arrives back astoPlain(defaultNorm), and the host has no bypass to offer. The taper must therefore be designed so every default has an exact normalized preimage — seedocs/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.
- 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.
- 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. - The schematic axis BECOMES the taper — recommended. A stage's slot width is
slotPx × taperNorm(seconds)instead ofslotPx × 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 ±
kPitchDepthMaxSemisat 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:
resetDeckParammust bypass the taper. It currently round-trips the default throughnorm → value(deck_values.cpp:200-203), anddeck_values.h:42-46documents 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-constructedPlaySeconds, so there is no second table to drift. This removes the power-of-two dependency rather than working around it; the comment atdeck_values.h:42-46becomes 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
curveMapthe 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 − crossfadeto ~10 px atloopEnd, inaccent/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 atloopStart) at half alpha, outside the loop fill — not a mirror of it: the incoming tap's weight at ingredient frameloopStart − crossfade + kis the samecrossfadeWeightas audible frameloopEnd − crossfade + k, so both spans carry the identical ramp, which is exactly why onecrossfadeWedgeHeightfunction 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);startthere namesloopStart, not the START mark — and its "no material ahead of the loop" reasoning inengine/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 theloopStartbound, and the audible wedge's left edge reaches the LOOP mark exactly at theloopLengthbound. 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.
AMENDED by Ω-F2 (Daniel, 2026-08-03). The Trigger bullet below is REVERSED and the
LOOP — GATE ONLYcaption is retired; the Gate off-state bullet survives with its styling corrected. The amended text is what ships — seedocs/PLAN.md§Phase Ω, Ω-W1-T5.
- Loop off (Gate). The pair stays drawn and grabbable, styled grey disabled — not
the dim teal that shipped, which read as decoration rather than as an off state
("that was not intuitive to me"). The centred dim caption in the span survives and still
says which off state you are in:
DRAG TO SET LOOPwhen no span has ever been set (the pair is parked at the last quarter,defaultLoopBounds),LOOP OFFwhen 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 (
resolveLooprefuses in Trigger). In Trigger the loop pair and the crossfade mark are HIDDEN OUTRIGHT — not Disabled, not dim: absent, along with their labels and the caption. "Trigger mode doesn't work with loop anyway." The stored span is untouched and returns with the mode, so nothing the user put there is destroyed — only its drawing is suppressed where it can mean nothing. The superseded rule, recorded so it is not reinstated: the marks once drew Disabled-rather-than-hidden in Trigger, with a dimLOOP — GATE ONLYcaption in the span. 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:
- It is the same class of control as the one next to it.
Mono|Stereois a playback mode of the loaded capture; so is loop-on. They belong adjacent, in the same two-segment primitive. - Browse stays rightmost. It is navigation, not a mode — moving it would break the established right-edge reading.
- It costs zero window width. The run is right-anchored and the title slot absorbs it,
so
kEditorMinWidthdoes 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 wheneverhasLoopis 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-243zeroes 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 boundmin(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 grey disabled (Ω-F2; the shipped dim teal was not readable as an off state), captionDRAG 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 grey 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. This rule SURVIVES Ω-F2 and is the reason the Gate off-state marks stay grabbable rather than going grey-and-inert. It is what keeps the enable from being a gate the user has to remember to open. It has nothing to say about Trigger, where there is no mark to grab.
The Trigger case — the enable disables itself, it does not clear itself
AMENDED by Ω-F2 (Daniel, 2026-08-03). The MARKS are now hidden in Trigger (§6.3(d) as amended); the chrome-row ENABLE is not. The two halves of the old "same message at two scales" argument no longer travel together, and the paragraph below says which one survives.
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 — a control
that vanishes from the chrome row costs the user the map of what the instrument has. The
span's LOOP — GATE ONLY caption is retired along with the marks it was centred in, so the
chrome row now carries the message alone: this control is unavailable here. The why is the
mode segment sitting beside it.
This transitively covers the drawn-EG case: enforceGateUnavailableWhileDrawn
(play_params.h) 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.
Three states, not two, and collapsing any pair of them is a defect: grey-disabled but grabbable (the Gate off marks — the user's own off, reversible by the very gesture on offer), Disabled and inert (the chrome enable in Trigger — the engine's refusal, which no drag can talk it out of), and absent (the marks in Trigger — a position that can mean nothing in this mode). The discriminator between the first two is who said no; the discriminator for the third is whether the thing being drawn could be acted on at all.
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_chromeandeditor_input_chromecome into scope, and so doeditor_session'spickedMarkers/applyMarkersfor the retention rule. §7.9 records what that does to the wave's disjointness claim. hasLoopchanges provenance from derived to user-owned. No format change, but every existing site that infers it (two ineditor_input_waveform, two ineditor_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
markerHandleRectgives a tab to the crossfade only, andresolveWaveformClaimbreaks 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 opendocs/TODO.mdentry "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 ownsmarkerHandleRect) as pure, CTest-covered geometry. No hit-test math moves into the painter. - The model does not change.
crossfaderemains a stored frame count with the same clamp,resolveLoopis untouched, and noComponentStateversion 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 DeckParams 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:
- §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).
- 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.
- 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.
- The verification requirements survive unchanged, because they were always about the
kIoChangedscar (a dual-mono capture panned hard right by a prior mid-sessionrestartComponent), not about this flag. - 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
getParameterInfopresentation 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 [3 tracks]
T1 deck-reflow ..................... item B's ARRANGEMENT half + C's UI half
T2 bake-reset-amendment ............ the Phase Ξ correction Γ owns (§3.4)
T3 bake-prints-limiter ............. prints the limiter through the bake's master stage (§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:
- 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.mdat Γ-W1-T4. - 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.