Two-row deck reflow (sound/contour), double-height MASTER with limiter and meter, PITCH/RATE deck, unit-driven knob law, contour-trace fix, and a re-approached loop/crossfade UX. Folds rulings Γ-F1..Γ-F5; opens Γ-F6.
81 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.
The five forks this doc opened (Γ-F1…Γ-F5) were all ruled on by Daniel on 2026-08-01;
their rulings are folded into the sections they affect and the rulings themselves are
recorded in §8. §8 also carries one new fork, Γ-F6, which the Γ-F2 ruling surfaced from
the vendored SDK and which Daniel did not have in front of him. The forward-looking VST3
automation parameter system is deliberately NOT in this phase — it has its own doc,
docs/product/parameter-automation.md.
Every geometry number below was re-derived from src/core/instrument/ui/knob_deck.cpp's
own width formula, not carried over from a prior measurement. The stale geometry block in
docs/TODO.md ("The deck layout needs a real rework") is superseded by this doc — see
§7.6.
0. TL;DR
- The spine is the reflow. The deck's controls are two categories — sound and contour — and the current three-row greedy wrap expresses neither. Row 1 is PITCH/RATE | FILTER | VELOCITY | VOICE (sound). Row 2 is PITCH ENV | FILTER ENV | AMP ENVELOPE (contour). MASTER spans both rows on the far right.
- The arithmetic closes, with room. Minimum/default window goes 980 × 680 → 1190 × 680, inside the settled 1280 × 720 ceiling with 90 px of headroom. The deck band drops 328 → 216 px, returning 112 px to the waveform (246 → 358 px at the floor). That 90 px is the governing budget for every future control addition — one deck cell is 60 px, so the layout has room for exactly one more, once. §1.6.
- The two rows align exactly, not nearly. At the floor width the row block is 1020 px, and at that width row 2's two gutters are equal (72 px each) and FILTER's right edge lands exactly on FILTER ENV's right edge (both at x = 636). That is the aesthetic tie between the rows and it falls out of the arithmetic — §1.3.
- PITCH becomes PITCH/RATE: three knobs (
Key Trk | Rate | Pitch) under the existing Varisp|Presrv toggle. Rate 50–200 % exponential, Pitch ±24 st. - MASTER becomes the post-voice-mixer deck it was always reserved to be: limiter toggle in the caption corner, gain knob upper-left, a full-double-height stereo peak meter down the right, an averted-clip bubble, and a one-cell reserved lower-left slot (Γ-F5).
- The limiter is a lookahead design with DYNAMIC reported latency (Γ-F2): zero when
off, the lookahead when on, reported to the host's PDC. That buys a transparent
true-peak limiter and costs a
restartComponent(kLatencyChanged)on the toggle — which the SDK defines as a host deactivate/reactivate, not a re-tap. §3.1.1 is the whole of that cost and how it is contained. - The cortex limiter does not clear the bar — §3.5. Read it, take nothing.
- Loop gets an explicit enable on the chrome row (Γ-F4), and the four-mark grammar sits under it. The core finding behind the re-approach: three identical bars draw a point and the two ends of a span in the same ink, and the crossfade is painted where its ingredient lives rather than where the event is heard. §6.
1. The reflow — the spine of the phase
Daniel's framing, verbatim intent: the deck controls are two categories — sound controls and contour controls — and the current three-row wrap is organizationally bad.
1.1 The two categories
| Row | Category | Groups (left → right) |
|---|---|---|
| 1 | Sound — what the voice is | PITCH/RATE, FILTER, VELOCITY, VOICE |
| 2 | Contour — how it moves over time | PITCH ENV, FILTER ENV, AMP ENVELOPE |
| both | Bus — what happens after the mixer | MASTER (double-height, far right) |
Row 1 is non-negotiably one row. Row 2 is the three envelope decks. deck_groups'
existing signal-flow order (pitch → filter → amp) survives within each row, so the
two rows read down the same axis: row 1's first two groups are the sound stages whose
contours are row 2's first two groups.
1.2 The measured layout
deckGroupWidth(g) = max(captionRowWidth, knobRowWidth) + 2·kDeckGroupPadX, with
captionRowWidth = captionWidth + Σ(kDeckToggleGap + 2·segWidth) + (radio ? 4 + 12 : 0)
and knobRowWidth = |cellIds|·kDeckCellW (+ 4 + 2·segWidth for a rowToggle). Metrics:
kDeckCellW 60, kDeckCellH 74, kDeckKnobSize 40, kDeckCellLabelH 16,
kDeckCaptionH 20, kDeckToggleH 18, kDeckGroupPadX 6, kDeckGroupPadY 4,
kDeckCaptionGap 2, kDeckToggleGap 4, kDeckGroupGap 12, kDeckRowGap 8,
kDeckRadioSize 12, kDeckGroupH 104.
| Group | Row | Caption run | Knob run | Width | Δ | Control inventory |
|---|---|---|---|---|---|---|
| PITCH/RATE | 1 | 70 + 4 + 2·48 = 170 | 3 × 60 = 180 | 192 | +42 | 3 cells Key Trk / Rate / Pitch; caption toggle Varisp|Presrv (48) |
| FILTER | 1 | 46 + 4 + 2·32 + 4 + 2·44 = 206 | 7 × 60 = 420 | 432 | −92 | 7 cells (morph, cutoff, Q, drive, mod amt, vel, key trk); caption toggle Off|On (32); caption toggle 2 Band|Notch (44) — moved from the knob row |
| VELOCITY | 1 | 54 | 3 × 60 = 180 | 192 | 0 | 3 curve-popup cells (amp, pitch, filter) |
| VOICE | 1 | 38 + 4 + 2·40 = 122 | 60 + 4 + 2·44 = 152 | 164 | 0 | 1 cell (voice count); caption toggle Poly|Mono (40); row toggle Retrig|Legato (44) stays — see note |
| PITCH ENV | 2 | 58 + 4 + 64 + 4 + 46 + 4 + 12 = 192 | 4 × 60 = 240 | 252 | 0 | 4 cells (A, H, D, Depth); caption toggle Off|On; caption toggle 2 Staged|Spline; corner radio |
| FILTER ENV | 2 | 66 + 4 + 46 + 4 + 12 = 132 | 5 × 60 = 300 | 312 | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: A,H,D + 2 reserves); caption toggle 2; corner radio |
| AMP ENVELOPE | 2 | 78 + 4 + 88 + 4 + 46 + 4 + 12 = 236 | 5 × 60 = 300 | 312 | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: Len,A,H,D + 1 reserve); caption toggle Gate|Trig (44); caption toggle 2; corner radio |
| MASTER | 1+2 | 46 + 4 + 2·32 + 4 + 12 = 130 | 60 + 8 + 62 = 130 | 142 | +70 | 1 cell (gain, upper-left); 1 reserved lower-left slot; caption toggle Limiter Off|On (32); corner bubble (12, passive); meter column 62 px, full double height |
Row totals.
| Natural content | Gutters at floor | Row width | |
|---|---|---|---|
| Row 1 | 192 + 432 + 192 + 164 = 980 | 12 + 14 + 14 = 40 | 1020 |
| Row 2 | 252 + 312 + 312 = 876 | 72 + 72 = 144 | 1020 |
Window floor.
deck band width = 1020 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1174
kEditorMinWidth = 1174 + 2·kPad(8) = 1190
kEditorMinHeight = 680 (unchanged)
deck band height = 2·kDeckGroupH(104) + kDeckRowGap(8) = 216 (was 328)
waveform band at the floor = 680 − 90 (chrome) − 4 − 4 − 8 − 216 = 358 (was 246)
1190 × 680, against a 1280 × 720 ceiling — 90 px of width headroom, 40 px of height.
Three corrections to the arithmetic in the brief, all small and all in our favour:
- 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 1020, not 1016. The extra 4 px is deliberate and is what makes the two rows align exactly rather than 2 px apart — §1.3. It is the single cheapest aesthetic purchase in the phase.
- 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 + 12 + 432 = 636·row 2: 252 + 72 + 312 = 636. That is not a coincidence to be preserved by a special rule — it is what row-block width 1020 buys, and at 1020 row 2's two gutters are also exactly equal (72/72) and row 1's smallest gutter is exactlykDeckGroupGap. Three good properties at one width. This is why the floor is 1190 and not 1186. - 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 | 1190 × 680 |
| Ceiling headroom | — | 90 px wide, 40 px tall |
Costs, named. The floor width grows by 210 px — an existing saved instance's window grows on open (the same one-time effect Θ-W6-T1 already shipped at 840 → 980, so the behaviour is precedented, not new). The deck's wrap mechanism stops being the thing that decides row membership at the floor width (§7.3). And the phase spends its ceiling headroom budget — §1.6.
1.6 The 90 px headroom is the budget, and it governs every future control
Read this before proposing any new knob. The floor is 1190 against Daniel's hard 1280 ceiling. That is 90 px of width headroom for the life of this layout, and it is the single constraint every later addition spends from:
| Purchase | Cost | Headroom after |
|---|---|---|
| One more 60 px deck cell on row 1 | 60 | 30 |
| One more caption toggle on a group whose caption row is the binding side | 0–48 | 42–90 |
| Widening MASTER to a two-cell left column | 60 | 30 |
| A second cell and a wider MASTER | 120 | over ceiling |
This is why MASTER's reserved lower-left slot is ONE cell and not two (Γ-F5, ruled by Daniel 2026-08-01). A two-cell reserve would spend 60 of the 90 up front, on a control nobody has named yet, and would effectively freeze row 1 forever: any later row-1 addition would then need the remaining 30 px and would not have it. One cell keeps the spare. If the future master-bus control turns out to be two knobs, widening MASTER then costs the same 60 px it would cost now, and by then the trade is being made against a real control instead of a guess. Reserving capacity you have not designed a use for is not free here — it is the whole budget.
Two corollaries for a reader who wants to add something:
- A caption toggle is the cheap slot; a cell is the expensive one. A group whose caption
row is narrower than its knob row absorbs a toggle for nothing (that is exactly what
FILTER's
Band|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 90. That is a genuinely different purse and must not be confused with this one.
2. The PITCH/RATE deck
Settled. The PITCH deck becomes PITCH/RATE and carries three knobs plus the
existing mode toggle. Left to right: Key Trk | Rate | Pitch. Key Trk is the
existing control, unmoved.
2.1 The two new controls
| Rate | Pitch | |
|---|---|---|
| Range | 50 % … 200 % | −24 … +24 semitones |
| Default | 100 %, at true knob centre | 0 st, at true knob centre |
| Taper | exponential — linear in semitones over ±12 (50 % = −12 st, 200 % = +12 st) | log2 / centre-expanded (§4.3) |
| Display | %, one decimal below 100 % |
st, signed, one decimal |
| Unit category (§4) | semitone | semitone |
| Commit tier | live-published, note-on-latched (§2.3) | live |
| Range constant | its own ±12 st | reads kPitchDepthMaxSemis — do not mint a second ±24 |
Pitch's ±24 is deliberately the same throw the pitch envelope's depth and the
velocity→pitch curve already speak (kPitchDepthMaxSemis = kVelocityPitchRangeSemitones = 24.0, deck_values.h:26). That constant is load-bearing in the v12 wire format
(component_state_io.h:93-95) and must not change — reusing it is the point; retuning it
is forbidden.
2.2 What the existing pitch-engine toggle now governs
The Varisp|Presrv toggle stays in the caption corner and now governs both new knobs:
- Varispeed. Keytrack ratio × rate ratio × pitch-offset ratio all compound into a
single read-increment multiply. The rate offset applies to the varispeed pitch — i.e.
under Varispeed, Rate is a pitch control that happens to be labelled in %, and the two
knobs are two views of one multiply. This composes with the pitch envelope's existing
per-frame multiply of
ratio_; it adds no new per-sample stage. - Preserve. Rate is an absolute value driving duration only; keytrack and the pitch offset drive the pitch shifter. This is the mode where Rate is a genuine time-stretch.
Guardrail — do not misread this as reopening a settled invariant.
core/instrument/CLAUDE.md says "WDL_Resampler is not a Preserve engine (it is a
resampler that couples duration) — never wire it as the duration-preserving path." Under
Preserve, Rate is supposed to change duration; a Preserve implementation that resamples
the read rate and cancels the resulting pitch shift in the shifter is an explicit
duration control, not a covert Preserve path. The invariant forbids using a resampler as
the pitch-preserving mechanism, and that prohibition stands.
2.3 Rate is latched at note-on — and the reason matters more than the rule
Settled: Rate is latched at note-on for this phase (not live on sustaining voices). Two consequences the implementation must get right:
It is a latch, not a reload. isLiveDeckParam is currently a binary predicate whose
false branch routes an edit to a full reload (bridge read, WAV re-decode, fresh
engine) or an engine rebuild. Routing a swept knob down that path is unacceptable. Rate is
therefore a third commit class: published into the live block like any live parameter,
but read only by snapLive at note-on and never by applyLive on a sounding voice. The
mechanism already exists — the invariant "A fresh note SNAPS, a sounding one holds φ" is
exactly this split — but the classification does not.
Where this is recorded.
core/instrument/CLAUDE.mdstates that "which controls are live is ONE decision, recorded in ONE place" —isLiveDeckParam/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. The loop is a pair of source-frame facts. Under
Varispeed the read increment changes and the loop is traversed proportionally faster —
scaling is automatic and the stored frames are untouched. Under Preserve the read
advances at
rate ×the source rate, so the loop's wall-clock period scales by1/ratewhile its source-frame span is unchanged. In neither mode are the stored loop frames rewritten; the marks on the waveform do not move when Rate moves. - Contours scale with rate. A drawn contour is a pure function of normalized sample
position (
core/instrument/CLAUDE.md: "Normalized is what makes a contour length-independent"), so it follows the read head by construction. The staged envelopes' stage times are wall-clock seconds and do NOT scale with rate — an attack of 30 ms is 30 ms at any rate. That asymmetry is correct and deliberate: a contour is of the sample, a staged envelope is of the performance.
2.5 The Preserve time-stretcher — quality bar, not algorithm
Preserve mode has no pitch-preserving time-stretch DSP today. pitch_shift is a
correlation-aligned SOLA pitch shifter; composing it with a resampled read yields a
working stretch, and that composition is the interim path Γ-W2-T1 ships so Rate is a
complete feature the day it lands. A real stretcher, written from established
state-of-the-art literature, follows as its own track.
Do not pick an algorithm in this document. The constraints:
- CPU stance (Daniel, verbatim intent): "we should be efficient but accept the cost of high-quality algorithm choices. It's 2026, most people's computers can handle audio with ease. Just don't be wasteful."
- RT-safe. No allocation, no file I/O, no lock in
process(). Any window/FFT/analysis buffer is sized and allocated at voice allocation or at the off-audio-thread reload, on 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 % rate; no smearing of transient material at 50 %/200 % worse than the interim resample+SOLA path; the null case (rate 100 %, no shift) must be bit-identical to the un-stretched read.
- Gate. Measure and report before the algorithm is final: per-voice CPU at 32 voices, added latency (must be zero at the onset), and A/B recordings against the interim path on three material classes (one-shot, tonal sustain, full-mix bounce).
3. MASTER — the post-voice-mixer deck
deck_groups.cpp already records the reservation: "MASTER is reserved for
post-voice-mixer concerns, which is why the curves sit in their own group immediately left
of VOICE rather than there." Phase Γ fulfils that reservation rather than contradicting
it.
The signal chain, stated once:
voice mixer → master gain (existing ramped multiply) → LIMITER (bypassable) → output bus
└── METER TAP
The meter is tapped at the audio bus output, post-limiter (settled).
3.1 The limiter
Settled: a single toggle, no configurable controls. Baked ceiling at −0.3 dBTP. Daniel's framing: "this is a safety device with potential for musical abuse, not a whole configurable limiter."
Behaviour:
- Toggle only, in MASTER's caption corner (a two-segment
Off|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, ruled by Daniel 2026-08-01)
Settled: the limiter has lookahead, and the plugin reports latency dynamically. Daniel's reasoning, verbatim intent: true-peak detection needs oversampling and a transparent limiter wants lookahead, and he is willing to pay the latency provided it is latent only when the limiter is ON and the latency is reported to the host's PDC system. This overrides the zero-lookahead recommendation this doc previously carried.
The behaviour, stated as the contract:
- Limiter off →
getLatencySamples()returns 0. - Limiter on →
getLatencySamples()returns the lookahead in samples. - The toggle calls
IComponentHandler::restartComponent(kLatencyChanged).
None of that exists today. There is no getLatencySamples override anywhere in
src/, no kLatencyChanged, and no restartComponent call site — the plugin ships the
SDK default of 0. This track is the first latency reporting the instrument has ever
done, so there is no existing behaviour to preserve, only a new contract to get right.
What the vendored SDK actually says, and why it is more expensive than it looks
Two facts read directly out of vendor/vst3sdk, both load-bearing:
pluginterfaces/vst/ivstaudioprocessor.h:293-299— "If during the use of the plug-in this latency change, the plug-in has to inform the host by 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."
The second is the sharp one. kLatencyChanged is not a "re-read the number" flag — the
SDK defines it as a deactivate/reactivate cycle. And in this plugin,
ReaSamplerProcessor::setActive is deliberately destructive in both directions
(reasampler_processor.cpp:85-109):
setActive(false)freeslive_,draining_, and the graveyard — every sounding voice dies. The comment there explains why that 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."setActive(true)callsreloadInstrument()— a bridge read, a WAV re-decode, and a fresh engine.
So the honest cost of the toggle is: every sounding note stops, and the sample is re-decoded from disk. That is a materially heavier consequence than "a brief click," and it is the reason §8's new fork Γ-F6 exists rather than this being fully closed.
The standing scar, and why this is nonetheless not the forbidden change
reasampler_processor.cpp:66-68 carries a warning in the codebase's own words:
"Do not reintroduce per-mode bus renegotiation: flipping
kMono↔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 — the product decision
Ruling (mine, not deferred): the toggle applies immediately, the restart is requested immediately, and the resulting interruption is accepted and documented. It is NOT deferred to a transport boundary. Three reasons, in order of weight:
- 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.
The mitigations that make this acceptable rather than merely defensible:
- The limiter's own output has no hard step. Within the plugin, the engage/disengage is covered by a short (≤ 10 ms) equal-gain crossfade between the pre- and post-toggle paths, so whatever the host does around it, we do not emit a discontinuity of our own making.
- The toggle is framed as a patch-design control, not a performance control. It is set once while building a sound. The editor should not encourage flipping it while playing, and nothing in the UI should make it a per-take gesture.
- The limiter enable is explicitly NOT automatable. This is the load-bearing
consequence and it must be recorded where the parameter work will read it: an automation
lane toggling a latency-changing parameter would request a host deactivate/reactivate on
every flip. See
docs/product/parameter-automation.md§3.8 — the limiter enable belongs in the not-automatable class, and it is emphatically not the plugin'skIsBypassparameter either. - The interruption is verified in REAPER, and its severity recorded. The SDK mandates the deactivate/reactivate; what REAPER actually does with it — whether sounding notes cut, whether the re-decode is perceptible, whether transport hiccups — is DAW-verifiable only. That verification is W1-T2's first deliverable, and its outcome is what closes Γ-F6.
3.2 The meter
Vertical, 62 px wide, 186 px tall, down MASTER's right side, spanning both row baselines.
| Property | Decision | Why |
|---|---|---|
| Bar count | One wide bar when the waveform draws one lane; two skinnier bars when it draws two | The bar count is resolved by the same LaneSplit decision waveformSurface already folds (channel mode ∧ source channel count) — not a second rule. A mono source in stereo mode is dual-mono: L ≡ R, and two identical bars would be a lie. One source, two views. |
| Bar geometry | 22 px label gutter · 4 px gap · 36 px bar field. Mono: one 36 px bar. Stereo: two 17 px bars, 2 px apart. | |
| Scale | Linear in dB, −60 … +6 dBFS. Ticks every 6 dB; numerals at 0, −12, −24, −36, −48, −60; the 0 dB tick drawn heavier. | 66 dB over 186 px = 2.8 px/dB; 34 px between numerals at Font::Micro. Honest and simple; an expanded-top scale was considered and rejected as harder to read against a numeric label. Above 0 is shown because it is exactly what the limiter-off case needs to make visible. |
| Ballistics | Rise: instantaneous (a peak displays on the first UI frame after it occurs). Fall: 20 dB/second. | A peak meter must not smooth its attack or it under-reports. 20 dB/s is close to the IEC 60268-18 PPM fallback (20 dB in 1.7 s) and reads as responsive without flicker. |
| Peak hold | A 2 px horizontal tick at the running max, in text/primary. Holds 1.5 s after its last update, then falls at the same 20 dB/s. |
A neutral bright tick reads cleanly over the bar's accent ink; a second accent would compete. |
| Clip | A cap at the top of the meter, latched warn when any block peak ≥ 0 dBFS. Click to clear. |
|
| Bar ink | accent/primary — it is the live signal. |
warn stays reserved for clip states (visual-design-language.md §2.1). No green/yellow/red segmentation. |
The clip indicator earns its keep precisely because the meter is post-limiter. With the limiter engaged, a post-limiter clip is essentially impossible; with it bypassed and gain driven up (the knob reaches +24 dB), clipping is easy. So the indicator quietly teaches what the toggle does: drive the gain, see red; engage the limiter, red stops. If the clip cap ever latches while the limiter is on, that is a defect report, not a user error.
RT discipline. The audio thread publishes, per block, as relaxed atomics: per-channel
peak max|x|, a latched clip flag, and the block's maximum gain reduction. No dB
conversion, no ballistics, no hold timers on the audio thread — the UI timer converts and
runs the ballistics from the published block peaks and elapsed time. This matches the
existing advisory-peak shape (reasampler_processor.h:109-113) and the standing rule that
observation happens at block boundaries, never per frame. The existing advisory peak is
not reusable as-is — no dB, no ballistics, no hold, no clip, mono only — but it is the
right pattern to widen.
3.3 The gain-reduction bubble
Settled: the limiter shows a red bubble when the threshold is crossed and any gain reduction is applied.
- A round 12 px lamp in the caption row's far corner — the slot the three envelope decks use for their overlay radio. Round, not square, so it reads as a lamp rather than a control; the limiter toggle sits one slot to its left, which is the existing right-to-left caption grammar unchanged.
- Non-interactive. Either the existing
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, surfaced for Ξ-W2's review rather than as a Daniel call: rate → reset, pitch offset → reset, limiter enabled → reset (master gain is already on the reset list, so the bake includes the master stage, so the limiter's effect is in the audio).
3.5 Assessment: the temp_cortex/ limiter reference
Read in full (temp_cortex/limiter_base.{hpp,cpp}, temp_cortex/fast_limiter.{hpp,cpp},
commit 3ad3094). Standing project rule: cortex code is a reference, not a transplant —
if there is character in it worth having, it gets rebuilt as an explicit parameter rather
than inherited as a side effect.
Verdict: it does not clear that bar. There is no character in it worth having. Read it as a reminder of the shape, take nothing.
What is structurally right: detection on the un-delayed signal with the gain applied to a delayed copy (that is correct lookahead), and stereo-linked max detection. Both are textbook and need no reference.
Everything else is disqualifying:
| Finding | Why it disqualifies the code |
|---|---|
Unconditional makeup gain — makeupGain = ceilingLinear / thresholdLinear, applied on every sample whether or not anything is limiting (fast_limiter.cpp:121,129). At the defaults that is a permanent +2.9 dB. |
Directly contradicts "a safety device": the toggle would change loudness at rest. This is exactly the inherited-side-effect the standing rule exists to catch. Our limiter has no makeup gain at all. |
uint8_t lookaheadSamples (fast_limiter.hpp:14, computed at .cpp:12). 5 ms at 96 kHz = 480 → silently wraps to 224. |
An embedded-platform assumption (fixed low rate) that does not survive a DAW. |
uint8_t peakHoldSamples, with dead guard code — if (peakHoldSamples > 255) (.cpp:50) can never be true; the cast already truncated. |
Same class of defect, plus the guard reads as protection that isn't there. |
Two powf calls per sample to convert constant dB values to linear (.cpp:94-95). |
A transcendental on the per-sample path. The house rule (engine/loop/CLAUDE.md) explicitly forbids one there. |
Cascaded double smoothing — envelope is attack/release smoothed, then currentGain is smoothed again with the same coefficients (.cpp:85-91, 112-118). |
The realized timing is not the stated timing; the timing constants mean nothing. |
A near-instant attack (0.01 ms) behind a 5 ms lookahead (limiter_base.cpp:6). |
The lookahead's whole purpose is to let the gain reach its target before the peak arrives. With an instantaneous attack the lookahead only delays audio. |
virtual void process(float[2], …) called per frame (limiter_base.hpp:16). |
A vtable dispatch on the per-sample path — the phase-wide "no dispatch-stack blowouts anywhere" guardrail. |
| Sample-peak only, no ISP detection. | Cannot meet the settled −0.3 dBTP ceiling as written. |
Startup mute — outputs silence until the delay line fills (.cpp:132-135); raw new[]/delete[]; depends on unvendored CircularBuffer.h and basicmaths.h. |
Embedded idiom, not house idiom. |
Recommendation: write ours from the literature against §3.1's constraints, and delete
temp_cortex/ once the limiter lands (its removal is staff-engineer's, not mine).
4. The knob interaction law (item D)
Daniel scoped this as its own work item: a consistent, unit-category-driven interaction and taper rule across every variable control. The load-bearing property is that the rule is derived from the control's unit, so a control added later inherits it without anyone maintaining a list.
4.1 Modifiers
| Gesture | Effect |
|---|---|
| Shift | Snap to whole numbers in the control's displayed unit (§4.2). |
| Ctrl | Scale the drag by 0.05 (1/20 sensitivity) — fine grain. |
| Shift + Ctrl | Shift wins; Ctrl is ignored. Not a compromise: when the output is quantized to integers, a finer drag produces the same sequence of values. Stated so nobody "fixes" it later. |
Mid-drag modifier changes re-anchor. The knob drag is grab-anchored absolute
(param_slider.cpp:138-143, kKnobDragRangePixels = 128), so flipping a modifier mid-drag
without re-anchoring makes the value jump by (1 − 0.05) × the accumulated delta.
On every modifier transition — press or release — during an active drag, the drag re-anchors: the control's current value becomes the new anchor value and the cursor's current position becomes the new anchor position. The value is continuous across the transition; only the rate changes. This holds for Shift too: releasing Shift re-anchors from the snapped value, so a snapped knob does not jump back.
Plumbing. wndProc currently discards wParam for WM_MOUSEMOVE. The codebase already
reads modifiers via GetKeyState in two other input paths
(editor_input_curve.cpp:50, editor_input_waveform.cpp:38) — the precedent exists; the
requirement is that all drag surfaces read it through one shared helper so they cannot
drift into two modifier grammars.
Scope — and one explicit exclusion. The law is a property of the parameter, not the widget. Every surface that edits a unit-valued parameter honours it: deck knobs (outer ring and inner curve dial), envelope stage nodes, and curve knots. This follows from the standing invariant that node drags, knot drags and knob edits are "surfaces onto ONE model" — a snap available on one and not the others would be a divergence.
Waveform markers (start, loop start, loop end, crossfade) are excluded. They carry an existing zero-crossing snap gesture, and their domain is frames, which has no meaningful "whole number" above the frame. Overloading Shift there would collide with a shipped gesture. Recorded as a deliberate exclusion.
4.2 The snap unit, by category
| Unit category | Controls | Shift snaps to |
|---|---|---|
| milliseconds | the 14 stage-time knobs (amp/filter Gate A,H,D,R; amp/filter Trigger A,D; pitch env A,D) | whole ms |
| semitones | pitch env depth, the new Pitch, and Rate | whole semitones — on Rate this means the 25 semitone steps between 50 % and 200 %, which is what makes an octave or a fifth reachable by hand |
| percent / fraction | sustain level, hold fraction, Trigger length, key-track (both), filter mod amt / vel amt / morph / cutoff / Q / drive | whole percent |
| exponent | the 12 inner curve dials (0.1 … 10) | whole numbers — which puts 1.0, the linear neutral, one snap away |
| decibels | master gain | whole dB |
| already integer | voice count, preview velocity | no change |
4.3 The tapers
Both taper changes are SAFE for persistence and require no format bump. The
ComponentState payload stores raw engine values as doubles — seconds, semitones, curve
exponents (params_payload.cpp:358-408). Normalization exists only in
ui/deck_values.cpp as a UI display/interaction layer. Re-tapering moves the needle
angle and nothing else; saved projects reload bit-identical and sound identical.
Millisecond knobs become log-scaled. More resolution across 1–100 ms while still reaching the ceiling. A pure log map cannot include zero, and zero is a required value, so the taper is stated as acceptance criteria rather than a formula (the engineer picks the shape):
- Exactly 0 s at norm 0 and exactly
kEnvTimeMaxSecondsat norm 1. Monotone and continuous throughout. - 10 ms lands within 0.12 … 0.20 of travel; 100 ms within 0.42 … 0.52.
kEnvTimeMaxSecondsis 2.0 s (deck_values.h:22, readingkGateStageMaxSecondsatenvelope_overlay.h:85) and does not move in this phase — settled, Γ-F3. The AHDSR overlay's schematic scale is derived from it and the two must agree so a maxed knob lands exactly at the canvas edge; the agreement requirement is documented atdeck_values.h:19-22. Daniel foresees wanting a 10 s ceiling for sound-design cases — his example, "a horrifically long decay with tight exp" — and that ambition is carried as adocs/TODO.mdentry rather than dropped. Two things make it cheap later and both land in this phase: the log taper is precisely what makes a higher ceiling usable rather than unusable at the low end, and the reset-bypasses-the-taper change below removes the power-of-two dependency that would otherwise block a 10.0 s ceiling outright.
Semitone knobs become log2-scaled. More resolution across −7 … +7 st while still allowing the extremes:
- Symmetric about the centre; exactly 0 at norm 0.5; exactly ±
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 well before any VST3
parameter work, for a much sharper reason: once parameters are exposed, the taper is the
host-facing normalization, and re-tapering silently re-interprets every recorded automation
point in every saved project. Taper changes are free now and permanently expensive
later. See docs/product/parameter-automation.md §4.
5. Staged contour traces draw straight, not curved (item E)
Daniel's report. Changing a curve exponent from 1.0 moves the knot on the overlay, but the stage segment still draws as a straight line. Audio is correct; only the drawing is wrong.
Root cause, verified. editor_paint_waveform.cpp:218 does if (v.knot) continue; —
knots are dropped from the trace and the remaining vertices are joined with straight
strokes (:222). curveMap is never called in the paint path. The exponent is in scope
(env.attackCurve / .decayCurve / .releaseCurve at :211) and simply never read.
Knot positioning does honour the exponent via curveMidLevel
(envelope_overlay.cpp:94-105) — which is the divergence: at any non-neutral exponent the
knot visibly floats off its own trace.
The intended visual result (the tessellation approach is the engineer's call):
- Every sloped stage draws as the curve its exponent defines, evaluated through the same
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 | accent/primary |
solid right-pointing triangle (a play flag — it points into the material that will play) | solid | START, right of the line |
| Loop start | accent/secondary |
L-cap opening right | solid | LOOP, right of the line |
| Loop end | accent/secondary |
L-cap opening left | solid | END, left of the line |
| Crossfade | accent/secondary, reduced alpha |
ramp cap — a small right triangle whose hypotenuse rises left→right, drawing the fade-in shape | dashed — a soft boundary, not a hard one | XFADE, left of the line |
Start is the only accent/primary mark in the band, because it is the only one that is
always in effect (both Gate and Trigger). The loop pair's opposed L-caps read as [ … ]
without needing to be explained. All four caps use primitives already in the kit
(axis-aligned fills, AA-restroked triangles per visual-design-language.md §8).
(b) Labels, with an accepted overlap.
Labels draw in the top strip beneath the trace and handles in z-order. Where an envelope node overlaps a label, the node wins visually and the label is occluded — accepted, and named here so it is not filed as a defect. Labels are for learning; the cap shape carries the identity permanently. Two rules keep them honest:
- On hover or drag of a mark, that mark's label re-draws on top, so you always see what you grabbed.
- A label is suppressed if its box would overlap one already placed. Placement order is the grabbed/hovered mark first, then START, LOOP, END, XFADE.
A vertical inset of the overlay canvas to make room for labels was considered and rejected: it would change the envelope's level mapping (level 1.0 would no longer reach the canvas top), which moves both the forward and inverse overlay maps and their tests, for a cosmetic gain.
(c) The crossfade moves to where it is heard, and its ingredient becomes a ghost.
-
The crossfade mark and its region move to
[loopEnd − crossfade, loopEnd). The handle now lives on the loop-end side. Drag direction is unchanged — left lengthens the fade — so the muscle memory survives; only the anchor moves. -
The region draws as a top-and-bottom edge wedge, never as a second fill. A triangular band along the top and bottom edges of the overlay, growing from zero height at
loopEnd − 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 mirror wedge (growing right-to-left, peaking atloopStart) at half alpha, outside the loop fill. It carries no handle. At rest it is a hairline dashed outline; it fills in on hover or drag of the crossfade handle — a hover state in the sense §3.3 of the visual language means, revealing the relationship only when the user is asking about it. -
This makes the clamp self-explanatory. The hard clamp is
crossfade ≤ min(start, loopLength)(loop_span.h:19, and its "no material ahead of the loop" reasoning inengine/loop/CLAUDE.md). With the ghost drawn, the fade stops growing exactly when the ghost's left edge reaches the START mark or the LOOP mark — the user sees the reason instead of hitting an invisible wall. That is the single best payoff in this design and it costs nothing extra.
(d) The off-state and the Trigger state get words, not just alpha.
- Loop off. The pair draws in the kit's Disabled state with a centred dim caption in
the span —
DRAG TO SET 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) but the markers still draw at full strength today, which is marks that do nothing. In Trigger the loop pair and the crossfade mark draw Disabled and are not grabbable, with a dimLOOP — GATE ONLYcaption in the span. Disabled rather than hidden, because that is the established grammar — the editor's Gate segment already refuses and paints Disabled off thesplineActivepredicate — and because hiding a set loop on a mode flip destroys information the user put there. The START mark stays fully live in both modes.
6.4 The explicit loop enable (Γ-F4, ruled by Daniel 2026-08-01)
Settled: there is an explicit loop enable, and it lives on the CHROME ROW.
The framing this doc previously carried — "an enable costs a cell, so it is a §1.2 layout decision" — was wrong, and Daniel corrected it: loop is a waveform-overlay concept and has no deck. There was never a right deck cell for it. It is not on the overlay either (an overlay control that governs the overlay is circular), and it is not in a deck group.
Where it sits, and why that is free
The chrome band's toolbar row carries a right-anchored control run — preview · velocity
cell · Mono|Stereo · Browse — with the title taking whatever the run leaves
(sample_chrome.h). The enable joins that run as a two-segment Loop Off|On toggle,
immediately left of the channel toggle, giving a two-toggle mode cluster with Browse
still rightmost:
[▶ preview] [VEL knob] [Loop Off|On] [Mono|Stereo] [Browse]
Three reasons for that exact slot:
- 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 90 px headroom is spent. Constraint: the title slot must still hold its text at the 1190 floor. If it will not, the enable's segments narrow — the floor does not move. That is a hard rule, because the floor is a phase-wide acceptance criterion.
What it is, in the model — and it needs no new persisted field
The enable IS SampleLoop::hasLoop. That field already exists
(play_params.h:210), is already the predicate resolveLoop refuses on
(loop_span.cpp:12), and is already persisted inside the params payload's loopOverride
block — where start and end are written unconditionally, whatever hasLoop says
(params_payload.cpp:31-36). So the wire can already carry "off, with a span remembered."
No ComponentState field, no version bump, no format change. This track stays off the
payload ladder entirely.
What does change is the field's provenance. Today hasLoop is derived: the editor
sets it true whenever a marker is dragged (editor_input_waveform.cpp:255,258) and false
whenever the span collapses (editor_session.cpp:236). After Γ it is user-owned, with
the gestures as shortcuts onto it.
The interaction with collapse-to-off — one authority, two shortcuts
Collapse-to-off is not retired, and it does not become a second state machine. It becomes
a shortcut that flips the enable. hasLoop is the single authority; three gestures reach
it:
| Gesture | Effect on the enable | Effect on the span | Effect on the crossfade |
|---|---|---|---|
| Click the enable → On | on | retained as-is | retained |
| Click the enable → Off | off | retained | retained |
| Collapse the span (drag one loop mark onto the other) | off | destroyed → re-parked at defaultLoopBounds |
zeroed |
| Drag either loop mark while off | on | takes the drag | retained (re-clamped) |
Two of those rows are behaviour changes and each has a reason:
- Toggle-off retains the span. This is what makes an enable worth having at all: a
toggle whose off→on does not restore what was there is not a toggle, it is a delete
button. Today
pickedMarkers(editor_session.cpp:221-226) discards the stored span and re-parks at the default 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 Disabled, 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 Disabled at its own positions, caption
LOOP OFF. There is nothing to "set," so the drag-me copy would be wrong. Dragging still turns the enable on, by the same rule. - On — full four-mark grammar of §6.3, unchanged.
A grab implies intent to loop. That is the one rule behind both off-states, and it is what keeps the enable from being a gate the user has to remember to open.
The Trigger case — the enable disables itself, it does not clear itself
Per the Disabled-not-hidden principle already established for the marks: in Trigger the
chrome-row enable draws Disabled and inert, with its state preserved and restored on the
return to Gate. It does not clear hasLoop, and it does not hide. The enable's Disabled
state and the span's LOOP — GATE ONLY caption are the same message delivered at two
scales — the chrome row says this control is unavailable here, the span says why.
This transitively covers the drawn-EG case: enforceGateUnavailableWhileDrawn
(play_params.h:198-205) forces Trigger whenever any envelope is drawn, so a spline EG
disables the loop enable through the same predicate rather than through a second rule.
Disabled-but-grabbable (the off marks) vs. Disabled-and-inert (Trigger) is a deliberate distinction, not an inconsistency, and the discriminator is who said no: the user's own off is reversible by the very gesture being offered, while Trigger's refusal comes from the engine and no marker drag can talk it out of it.
6.5 Trade-offs and consequences, named
- The crossfade handle moves sides. A real muscle-memory break for the only current user. Mitigated by the unchanged drag direction and by the mark finally being labelled.
- The enable puts a waveform-band control on the chrome row, one band away from what it governs. That distance is the price of it having no deck; it is mitigated by the marks themselves changing state visibly when it flips, so the two surfaces are never ambiguous about which one won. The alternative — a control on the overlay governing the overlay — is circular and was not seriously considered.
- The track gains a chrome surface it did not previously have.
sample_chrome(pure),editor_paint_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 — isLiveDeckParam 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.
8. Forks — five ruled, one open
8.1 Ruled by Daniel, 2026-08-01
All five forks this doc opened are closed. The rulings are folded into the sections that depend on them; this table is the index, not a second copy of the reasoning.
| Fork | Question | Ruling | Where it landed |
|---|---|---|---|
| Γ-F1 | Does kEditorMinHeight move 680 → 720? |
No — stays 680. The reflow's 112 px goes entirely to the waveform. | §1.2 / §1.5, unchanged |
| Γ-F2 | Limiter lookahead, or zero-latency? | Lookahead with DYNAMIC reported latency — zero when off, the lookahead when on, reported to the host's PDC. Overrides this doc's zero-lookahead recommendation. | §3.1.1 (new), §7.10 |
| Γ-F3 | Does the log taper raise the 2 s stage-time ceiling? | Not in this phase — stays 2.0 s. The 10 s ambition is preserved as a docs/TODO.md entry with its rationale. |
§4.3, docs/TODO.md |
| Γ-F4 | Explicit loop enable? | Yes — on the CHROME ROW. Not a deck cell; loop is a waveform-overlay concept and has no deck. | §6.4 (new), §6.5, §7.9 |
| Γ-F5 | MASTER's reserved slot: one cell or two? | One cell. Two would spend 60 of the 90 px headroom on an unnamed control and freeze row 1 forever. | §1.6 (new), §1.4 |
Two of these corrected this doc rather than confirming it, and both corrections are worth remembering as pattern:
- Γ-F2 inverted the recommendation. The zero-lookahead pitch weighed "monitoring latency on every instance" against limiter transparency — but that trade only existed under the always-active delay line framing. Daniel's condition (latent only when on, and reported) dissolves it, at the cost of a dynamic-latency restart. The alternative I proposed was answering a constraint he did not accept.
- Γ-F4 was mis-framed as a layout question. "An enable costs a cell" presumed the enable belonged to a deck. It does not — nothing about loop belongs to a deck — and once that is seen, the chrome row is obvious and free. The reframe was the answer; the fork as posed had no good option in it.
8.2 Open — one fork, surfaced by the Γ-F2 ruling
Fork Γ-F6 — kLatencyChanged mandates a host deactivate/reactivate. Is that acceptable
as the cost of the limiter toggle?
This was not visible when Γ-F2 was posed and Daniel did not have it in front of him. The
vendored SDK (pluginterfaces/vst/ivsteditcontroller.h:105-108) defines the flag as: "The
host has to deactivate and reactivate the plug-in." In this plugin,
ReaSamplerProcessor::setActive is destructive in both directions
(reasampler_processor.cpp:85-109) — deactivate frees every sounding voice, reactivate
re-decodes the WAV from disk. So flipping the limiter cuts held notes and reloads the
sample. Full detail in §3.1.1.
- (a) Ship it, accept the cut. The limiter is a patch-design control set once while building a sound, not a per-take gesture; the enable is classified not automatable so nothing can flip it at rate; the plugin's own output is crossfaded so we emit no step. Cost: a user who flips it mid-audition loses the note they were holding.
- (b) Constant reported latency — the delay line engaged whenever the limiter design ships, on or off, so the toggle never changes latency and never restarts. Cost: exactly the thing Daniel's ruling rejected — every instance pays the lookahead in live monitoring whether or not the limiter is used. Named here as the pre-agreed fallback, not as a re-litigation.
- (c) Zero-lookahead — the original §8 recommendation. Closed; do not reopen it here.
Recommendation: (a), gated on a DAW measurement. Ship the dynamic-latency design as ruled, and make W1-T2's first deliverable a verification spike in REAPER: flip the limiter with notes held, during playback and while stopped, and record what actually happens — whether notes cut, whether the re-decode is perceptible, whether transport hiccups. If the observed behaviour is as ugly as the SDK's worst case allows, (b) is the pre-agreed fallback and needs one word from Daniel, not a redesign — the DSP is identical either way and only the latency-reporting predicate changes.
This is the only open fork in the phase. Nothing in §§1–7 is awaiting a Daniel answer.
9. Build shape
Sequenced into docs/PLAN.md as Phase Γ (worktree slug prefix pg-), four waves:
Γ-W1 Foundations [3 tracks, disjoint by surface]
T1 knob-interaction-law ............ item D (editor input + deck_values tapers)
T2 master-bus-audio ................ item C (pure limiter + meter ballistics +
processor + LATENCY REPORTING)
T3 contour-trace-curves ............ item E (waveform painter)
Γ-W2 New controls, and the overlay's marks [2 tracks]
T1 pitch-rate-deck ................. item A (params + engine + deck descriptor)
T2 loop-crossfade-ux ............... item F (waveform painter + pure marker geometry
+ the chrome-row loop enable)
Γ-W3 The reflow [1 track]
T1 deck-reflow ..................... item B + C's UI half
Γ-W4 Preserve time-stretch [1 track]
T1 preserve-time-stretch ........... item A's quality half [measure-and-report gate]
The wave boundaries are collision boundaries, not preferences: deck_values.cpp is written
by W1-T1 then W2-T1; editor_paint_waveform.cpp by W1-T3 then W2-T2; the deck descriptors
by W2-T1 then W3-T1; and one params-payload version bump per wave, owned by one track
(W1-T2 takes v14 for the limiter flag, W2-T1 takes v15 for rate + pitch offset) so no two
tracks contend for the format ladder. The Γ-F4 ruling adds a chrome surface to W2-T2 and one
named shared file inside W2 — §7.9, which restates the wave's disjointness claim rather
than repeating it. Full track specs, dependencies and acceptance criteria are in
docs/PLAN.md.