Files
reasampler/docs/product/instrument-control-surface.md
T
daniel eb777f55e1 docs: spec Phase Γ — the instrument's control surface
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.
2026-08-01 16:29:32 -04:00

81 KiB
Raw Blame History

Instrument control surface — Phase Γ (ReaSampler 9000)

The product-design reasoning behind Phase Γ: the deck's two-row reflow, the PITCH/RATE deck, the MASTER bus deck (limiter + meter), a consistent knob interaction law, the staged contour-trace defect, and a re-approach of the loop/crossfade markers.

Status: items AF below are SETTLED (Daniel, 2026-08-01) from a direct interview; this doc records them, works out the design detail they imply, and states the arithmetic. 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 50200 % exponential, Pitch ±24 st.
  • MASTER becomes the post-voice-mixer deck it was always reserved to be: limiter toggle in the caption corner, gain knob upper-left, a full-double-height stereo peak meter down the right, an averted-clip bubble, and a one-cell reserved lower-left slot (Γ-F5).
  • The limiter is a lookahead design with DYNAMIC reported latency (Γ-F2): 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 categoriessound controls and contour controls — and the current three-row wrap is organizationally bad.

1.1 The two categories

Row Category Groups (left → right)
1 Sound — what the voice is PITCH/RATE, FILTER, VELOCITY, VOICE
2 Contour — how it moves over time PITCH ENV, FILTER ENV, AMP ENVELOPE
both Bus — what happens after the mixer MASTER (double-height, far right)

Row 1 is non-negotiably one row. Row 2 is the three envelope decks. deck_groups' existing signal-flow order (pitch → filter → amp) survives within each row, so the two rows read down the same axis: row 1's first two groups are the sound stages whose contours are row 2's first two groups.

1.2 The measured layout

deckGroupWidth(g) = max(captionRowWidth, knobRowWidth) + 2·kDeckGroupPadX, with captionRowWidth = captionWidth + Σ(kDeckToggleGap + 2·segWidth) + (radio ? 4 + 12 : 0) and knobRowWidth = |cellIds|·kDeckCellW (+ 4 + 2·segWidth for a rowToggle). Metrics: kDeckCellW 60, kDeckCellH 74, kDeckKnobSize 40, kDeckCellLabelH 16, kDeckCaptionH 20, kDeckToggleH 18, kDeckGroupPadX 6, kDeckGroupPadY 4, kDeckCaptionGap 2, kDeckToggleGap 4, kDeckGroupGap 12, kDeckRowGap 8, kDeckRadioSize 12, kDeckGroupH 104.

Group Row Caption run Knob run Width Δ Control inventory
PITCH/RATE 1 70 + 4 + 2·48 = 170 3 × 60 = 180 192 +42 3 cells Key Trk / Rate / Pitch; caption toggle Varisp|Presrv (48)
FILTER 1 46 + 4 + 2·32 + 4 + 2·44 = 206 7 × 60 = 420 432 92 7 cells (morph, cutoff, Q, drive, mod amt, vel, key trk); caption toggle Off|On (32); caption toggle 2 Band|Notch (44) — moved from the knob row
VELOCITY 1 54 3 × 60 = 180 192 0 3 curve-popup cells (amp, pitch, filter)
VOICE 1 38 + 4 + 2·40 = 122 60 + 4 + 2·44 = 152 164 0 1 cell (voice count); caption toggle Poly|Mono (40); row toggle Retrig|Legato (44) stays — see note
PITCH ENV 2 58 + 4 + 64 + 4 + 46 + 4 + 12 = 192 4 × 60 = 240 252 0 4 cells (A, H, D, Depth); caption toggle Off|On; caption toggle 2 Staged|Spline; corner radio
FILTER ENV 2 66 + 4 + 46 + 4 + 12 = 132 5 × 60 = 300 312 0 5 slots (Gate: A,H,D,S,R / Trigger: A,H,D + 2 reserves); caption toggle 2; corner radio
AMP ENVELOPE 2 78 + 4 + 88 + 4 + 46 + 4 + 12 = 236 5 × 60 = 300 312 0 5 slots (Gate: A,H,D,S,R / Trigger: Len,A,H,D + 1 reserve); caption toggle Gate|Trig (44); caption toggle 2; corner radio
MASTER 1+2 46 + 4 + 2·32 + 4 + 12 = 130 60 + 8 + 62 = 130 142 +70 1 cell (gain, upper-left); 1 reserved lower-left slot; caption toggle Limiter Off|On (32); corner bubble (12, passive); meter column 62 px, full double height

Row totals.

Natural content Gutters at floor Row width
Row 1 192 + 432 + 192 + 164 = 980 12 + 14 + 14 = 40 1020
Row 2 252 + 312 + 312 = 876 72 + 72 = 144 1020

Window floor.

deck band width = 1020 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1174
kEditorMinWidth = 1174 + 2·kPad(8)                                     = 1190
kEditorMinHeight                                                       =  680  (unchanged)
deck band height = 2·kDeckGroupH(104) + kDeckRowGap(8)                 =  216  (was 328)
waveform band at the floor = 680  90 (chrome)  4  4  8  216       =  358  (was 246)

1190 × 680, against a 1280 × 720 ceiling — 90 px of width headroom, 40 px of height.

Three corrections to the arithmetic in the brief, all small and all in our favour:

  1. MASTER at 142, not ~236. A 236-wide MASTER puts the floor at exactly 1280 — the ceiling with zero slack. 142 is what the deck's own content actually needs (§1.4) and it banks 94 px. MASTER may grow to 236 before the ceiling binds; that is the meter's growth room, not a target.
  2. The row block is 1020, not 1016. The extra 4 px is deliberate and is what makes the two rows align exactly rather than 2 px apart — §1.3. It is the single cheapest aesthetic purchase in the phase.
  3. VOICE keeps its row toggle — confirmed. Moving Retrig|Legato to the caption gives 38 + 4 + 80 + 4 + 88 = 214226 px, wider than 164, because VOICE's caption row is the binding side and its knob row is nearly empty. Leave it.

PITCH/RATE's caption reserve is the one tight constant. The group is 192 only while captionWidth ≤ 80 (above that the caption row captionWidth + 100 overtakes the 180 px knob row). "PITCH/RATE" is 10 characters; against the existing reserves (FILTER ENV, also 10 characters incl. a space, reserves 66) 70 is the specified value and 80 is the hard ceiling. If the text does not fit at 80, the fallback is to narrow the Varisp|Presrv segments 48 → 44, which raises the ceiling to 88 — not to widen the group.

1.3 The justification law, and why the two rows read as one surface

Do not stretch the decks. Groups keep their natural widths; slack becomes inter-deck gutters. The law applies to both rows, not just row 2 — that is what makes the outer edges flush, which is the primary alignment signal:

Both rows are justified space-between within the row block. Slack = row block Σ(group widths); it is divided equally among the row's (n 1) gutters, with any integer residue distributed to the leftmost gutters. No gutter is ever narrower than kDeckGroupGap (12). MASTER is right-anchored outside the row block and is not part of either row's justification.

Four things carry the visual consistency, and the first three are exact rather than approximate:

  1. Flush outer edges. Both rows begin at the deck band's left inset and end at the row block's right edge. PITCH/RATE and PITCH ENV share a left edge; VOICE and AMP ENVELOPE share a right edge.
  2. The filter tie-line. At the floor width the two rows' filter groups end on the same pixel: row 1: 192 + 12 + 432 = 636 · row 2: 252 + 72 + 312 = 636. That is not a coincidence to be preserved by a special rule — it is what row-block width 1020 buys, and at 1020 row 2's two gutters are also exactly equal (72/72) and row 1's smallest gutter is exactly kDeckGroupGap. Three good properties at one width. This is why the floor is 1190 and not 1186.
  3. Shared horizontal baselines. Every group is kDeckGroupH with identical interior offsets, so across both rows the caption text, the knob centrelines and the label bands sit on the same four lines. The reflow must not break this — it is free today and becomes load-bearing once two rows are visible at once.
  4. MASTER is stitched to both rows, not parked beside them. Its two left-column cells sit at exactly the two rows' knob-row baselines (box-relative y = 26 and y = 138), so the gain knob is in line with FILTER's knobs and the reserved slot is in line with AMP ENVELOPE's. §1.4 shows the arithmetic is exact to the pixel.

Above the floor width, the tie-line drifts. Both rows gain slack; row 1 divides it over 3 gutters and row 2 over 2, so the filter edges separate. That is accepted and deliberate: pinning the tie-line at every width forces row 1's first gutter to grow at ~2× the rate of its other two, which reads as sloppy at large widths. The default size is the minimum size, so the exact case is the case almost every user sees; a stretched window reads as a stretched window rather than as a near-miss.

The aesthetic reading, stated plainly. Row 2 holds the contour of each of row 1's first two stages — pitch and filter. Its third, AMP ENVELOPE, has no sound-row counterpart because its static control is level, and level lives in MASTER. That is exactly why MASTER is the deck that spans both rows: it is the one axis whose static half and whose contour half sit on different rows. The double-height deck is therefore a statement about signal flow, not a container for a leftover knob.

1.4 MASTER — the double-height geometry

The double-height box is 2·kDeckGroupH + kDeckRowGap = 216, and the interior lands on the row baselines exactly:

box y  +0        top
       +4        padY
       +4..+24   caption row  (kDeckCaptionH 20)  ── caption text · limiter toggle · bubble
       +26..+100 left cell A  (kDeckCellH 74)     ── GAIN knob + label   [row 1 baseline]
       +100..+138 interior seam (38 px)
       +138..+212 left cell B (kDeckCellH 74)     ── RESERVED            [row 2 baseline]
       +212..+216 padY
       +26..+212  meter column (186 px)           ── full double height

Row 1's groups place their cells at box-relative +26; row 2's box top is +112 and its cells at +138. MASTER's two slots land on both, and the bottom padding closes at exactly 4 px.

Horizontally the group is 6 + 60 + 8 + 62 + 6 = 142.

Three rules an engineer must not generalise wrongly:

  • MASTER's left column uses FIXED cell slots at the two baselines. It does NOT use the horizontal run-division law (knob_deck.h: "the cells present divide the whole reserved run"). Applying that law vertically would stretch the single gain knob over the full 186 px. The reserved lower slot reserves height at a fixed position.
  • The reserved slot draws nothing. Blank interior reads as breathing room; a dashed placeholder reads as unfinished. It is reserved in layout only, so adding a control later reflows nothing.
  • The meter column is one rect, spanning both baselines. It is not two per-row meters.

1.5 What the reflow costs, and what it returns

Before After
Deck rows at the floor width 3 (by greedy wrap) 2 (by construction)
Deck band height 328 216
Waveform band at the floor 246 358
Minimum / default window 980 × 680 1190 × 680
Ceiling headroom 90 px wide, 40 px tall

Costs, named. The floor width grows by 210 px — an existing saved instance's window grows on open (the same one-time effect Θ-W6-T1 already shipped at 840 → 980, so the behaviour is precedented, not new). The deck's wrap mechanism stops being the thing that decides row membership at the floor width (§7.3). And the phase spends its ceiling headroom budget — §1.6.

1.6 The 90 px headroom is the budget, and it governs every future control

Read this before proposing any new knob. The floor is 1190 against Daniel's hard 1280 ceiling. That is 90 px of width headroom for the life of this layout, and it is the single constraint every later addition spends from:

Purchase Cost Headroom after
One more 60 px deck cell on row 1 60 30
One more caption toggle on a group whose caption row is the binding side 048 4290
Widening MASTER to a two-cell left column 60 30
A second cell and a wider MASTER 120 over ceiling

This is why MASTER's reserved lower-left slot is ONE cell and not two (Γ-F5, ruled by Daniel 2026-08-01). A two-cell reserve would spend 60 of the 90 up front, on a control nobody has named yet, and would effectively freeze row 1 forever: any later row-1 addition would then need the remaining 30 px and would not have it. One cell keeps the spare. If the future master-bus control turns out to be two knobs, widening MASTER then costs the same 60 px it would cost now, and by then the trade is being made against a real control instead of a guess. Reserving capacity you have not designed a use for is not free here — it is the whole budget.

Two corollaries for a reader who wants to add something:

  • A caption toggle is the cheap slot; a cell is the expensive one. A group whose caption row is narrower than its knob row absorbs a toggle for nothing (that is exactly what FILTER's Band|Notch move exploits). A cell always costs its 60 px.
  • The chrome row is a separate budget. The toolbar row's right-anchored control run is paid for out of the title slot, not out of the window floor — which is why the loop enable (§6.5) costs zero of the 90. That is a genuinely different purse and must not be confused with this one.

2. The PITCH/RATE deck

Settled. The PITCH deck becomes PITCH/RATE and carries three knobs plus the existing mode toggle. Left to right: Key Trk | Rate | Pitch. Key Trk is the existing control, unmoved.

2.1 The two new controls

Rate Pitch
Range 50 % … 200 % 24 … +24 semitones
Default 100 %, at true knob centre 0 st, at true knob centre
Taper exponential — linear in semitones over ±12 (50 % = 12 st, 200 % = +12 st) log2 / centre-expanded (§4.3)
Display %, one decimal below 100 % st, signed, one decimal
Unit category (§4) semitone semitone
Commit tier live-published, note-on-latched (§2.3) live
Range constant its own ±12 st reads kPitchDepthMaxSemisdo not mint a second ±24

Pitch's ±24 is deliberately the same throw the pitch envelope's depth and the velocity→pitch curve already speak (kPitchDepthMaxSemis = kVelocityPitchRangeSemitones = 24.0, deck_values.h:26). That constant is load-bearing in the v12 wire format (component_state_io.h:93-95) and must not change — reusing it is the point; retuning it is forbidden.

2.2 What the existing pitch-engine toggle now governs

The Varisp|Presrv toggle stays in the caption corner and now governs both new knobs:

  • Varispeed. Keytrack ratio × rate ratio × pitch-offset ratio all compound into a single read-increment multiply. The rate offset applies to the varispeed pitch — i.e. under Varispeed, Rate is a pitch control that happens to be labelled in %, and the two knobs are two views of one multiply. This composes with the pitch envelope's existing per-frame multiply of ratio_; it adds no new per-sample stage.
  • Preserve. Rate is an absolute value driving duration only; keytrack and the pitch offset drive the pitch shifter. This is the mode where Rate is a genuine time-stretch.

Guardrail — do not misread this as reopening a settled invariant. core/instrument/CLAUDE.md says "WDL_Resampler is not a Preserve engine (it is a resampler that couples duration) — never wire it as the duration-preserving path." Under Preserve, Rate is supposed to change duration; a Preserve implementation that resamples the read rate and cancels the resulting pitch shift in the shifter is an explicit duration control, not a covert Preserve path. The invariant forbids using a resampler as the pitch-preserving mechanism, and that prohibition stands.

2.3 Rate is latched at note-on — and the reason matters more than the rule

Settled: Rate is latched at note-on for this phase (not live on sustaining voices). Two consequences the implementation must get right:

It is a latch, not a reload. isLiveDeckParam is currently a binary predicate whose false branch routes an edit to a full reload (bridge read, WAV re-decode, fresh engine) or an engine rebuild. Routing a swept knob down that path is unacceptable. Rate is therefore a third commit class: published into the live block like any live parameter, but read only by snapLive at note-on and never by applyLive on a sounding voice. The mechanism already exists — the invariant "A fresh note SNAPS, a sounding one holds φ" is exactly this split — but the classification does not.

Where this is recorded. core/instrument/CLAUDE.md states that "which controls are live is ONE decision, recorded in ONE place"isLiveDeckParam / liveCommitFor in ui/deck_groups. Phase Γ widens that one decision from two states to three (Live / NoteOnLatched / Reload) rather than adding a second predicate elsewhere. This is also precisely the seam the automation work needs — see docs/product/parameter-automation.md §3.

Why Rate specifically. Rate is not latched because live rate would sound bad. It is latched because loop points scale with rate and contours scale with rate (settled), and both are note-on folds: resolveLoop runs once per note-on, and a normalized contour is resolved against the note's own span. Making Rate live means re-folding the resolved loop and re-mapping the contour mid-note, on a sounding voice, without a discontinuity. That is a real feature, not a plumbing detail, and it is out of scope here. Recording the reason is what makes the latch principled and tells the automation work exactly what it would have to build to lift it.

Pitch is live because it is not implicated: under Varispeed a live pitch offset is one more factor in a per-frame ratio_ multiply the pitch envelope already performs, and under Preserve it is an addend to a shift amount the pitch envelope already modulates.

2.4 Rate scaling — what "scales with rate" means, concretely

  • Loop points scale with rate. The loop is a pair of source-frame facts. Under Varispeed the read increment changes and the loop is traversed proportionally faster — scaling is automatic and the stored frames are untouched. Under Preserve the read advances at rate × the source rate, so the loop's wall-clock period scales by 1/rate while its source-frame span is unchanged. In neither mode are the stored loop frames rewritten; the marks on the waveform do not move when Rate moves.
  • Contours scale with rate. A drawn contour is a pure function of normalized sample position (core/instrument/CLAUDE.md: "Normalized is what makes a contour length-independent"), so it follows the read head by construction. The staged envelopes' stage times are wall-clock seconds and do NOT scale with rate — an attack of 30 ms is 30 ms at any rate. That asymmetry is correct and deliberate: a contour is of the sample, a staged envelope is of the performance.

2.5 The Preserve time-stretcher — quality bar, not algorithm

Preserve mode has no pitch-preserving time-stretch DSP today. pitch_shift is a correlation-aligned SOLA pitch shifter; composing it with a resampled read yields a working stretch, and that composition is the interim path Γ-W2-T1 ships so Rate is a complete feature the day it lands. A real stretcher, written from established state-of-the-art literature, follows as its own track.

Do not pick an algorithm in this document. The constraints:

  • CPU stance (Daniel, verbatim intent): "we should be efficient but accept the cost of high-quality algorithm choices. It's 2026, most people's computers can handle audio with ease. Just don't be wasteful."
  • RT-safe. No allocation, no file I/O, no lock in process(). Any window/FFT/analysis buffer is sized and allocated at voice allocation or at the off-audio-thread reload, on the pitch_shift pre-warm precedent.
  • Per-voice state, and it must hold up at the 32-voice polyphony ceiling — the measure-and-report gate is 32 simultaneous Preserve voices at an extreme rate (50 % and 200 %), not one voice at 100 %.
  • No new third-party dependency, matching pitch_shift's standing property.
  • No dispatch on the per-sample path (phase-wide guardrail): concrete, inlineable types; no IStretcher.
  • Onset behaviour is a regression surface. GA2 eliminated Preserve's ~25 ms onset latency by priming the ring with the actual upcoming source. A stretcher that reintroduces an onset delay or a first-frame smear is a regression, not a trade-off.
  • Quality bar. No audible metallic/phasey artefacting on sustained tonal material at ±6 st and 75133 % rate; no smearing of transient material at 50 %/200 % worse than the interim resample+SOLA path; the null case (rate 100 %, no shift) must be bit-identical to the un-stretched read.
  • Gate. Measure and report before the algorithm is final: per-voice CPU at 32 voices, added latency (must be zero at the onset), and A/B recordings against the interim path on three material classes (one-shot, tonal sustain, full-mix bounce).

3. MASTER — the post-voice-mixer deck

deck_groups.cpp already records the reservation: "MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there." Phase Γ fulfils that reservation rather than contradicting it.

The signal chain, stated once:

voice mixer → master gain (existing ramped multiply) → LIMITER (bypassable) → output bus
                                                                                  └── METER TAP

The meter is tapped at the audio bus output, post-limiter (settled).

3.1 The limiter

Settled: a single toggle, no configurable controls. Baked ceiling at 0.3 dBTP. Daniel's framing: "this is a safety device with potential for musical abuse, not a whole configurable limiter."

Behaviour:

  • Toggle only, in MASTER's caption corner (a two-segment Off|On caption toggle, the same primitive kFilterEnable uses). Persisted in ComponentState.
  • Default: off. The migration bar ("a project saved before a change reopens sounding identical") forbids any other default — an absent field must lift to bypassed.
  • Transparent at rest. No makeup gain, ever. No upward gain of any kind. When nothing exceeds the ceiling the output is bit-identical to the un-limited path.
  • Byte-identical when bypassed. With the limiter off, the per-sample path must be byte-identical to today's bare ramped multiply — the same discipline that makes live == nullptr byte-identical to the pre-live core and the filter's exact skip at modAmount == 0 hold the at-rest path unchanged. This is an acceptance criterion, not an aspiration.
  • Ceiling 0.3 dBTP. dBTP is a true-peak target, so the detector must see inter-sample peaks — the standard route is an oversampled peak detector in the sidechain only, never oversampling the signal path. The oversampling factor is the engineer's call under the measure-and-report gate.
  • Stereo-linked detection (max of |L|,|R| drives one gain), so the stereo image is not moved by the limiter.
  • Gain reduction is published per block for the bubble indicator (§3.3).
  • Lookahead, with DYNAMIC reported latency — §3.1.1.

3.1.1 Lookahead and dynamic latency (Γ-F2, ruled by Daniel 2026-08-01)

Settled: the limiter has lookahead, and the plugin reports latency dynamically. Daniel's reasoning, verbatim intent: true-peak detection needs oversampling and a transparent limiter wants lookahead, and he is willing to pay the latency provided it is latent only when the limiter is ON and the latency is reported to the host's PDC system. This overrides the zero-lookahead recommendation this doc previously carried.

The behaviour, stated as the contract:

  • Limiter offgetLatencySamples() returns 0.
  • Limiter ongetLatencySamples() returns the lookahead in samples.
  • The toggle calls IComponentHandler::restartComponent(kLatencyChanged).

None of that exists today. There is no getLatencySamples override anywhere in src/, no kLatencyChanged, and no restartComponent call site — the plugin ships the SDK default of 0. This track is the first latency reporting the instrument has ever done, so there is no existing behaviour to preserve, only a new contract to get right.

What the vendored SDK actually says, and why it is more expensive than it looks

Two facts read directly out of vendor/vst3sdk, both load-bearing:

pluginterfaces/vst/ivstaudioprocessor.h:293-299"If during the use of the plug-in this latency change, the plug-in has to inform the host by using IComponentHandler::restartComponent (kLatencyChanged), this could lead to audio playback interruption because the host has to recompute its internal mixer delay compensation. Note that for player live recording this latency should be zero or small."

pluginterfaces/vst/ivsteditcontroller.h:105-108"kLatencyChanged: … The host has to deactivate and reactivate the plug-in, then afterwards the host could ask for the current latency."

The second is the sharp one. kLatencyChanged is not a "re-read the number" flag — the SDK defines it as a deactivate/reactivate cycle. And in this plugin, ReaSamplerProcessor::setActive is deliberately destructive in both directions (reasampler_processor.cpp:85-109):

  • setActive(false) frees live_, draining_, and the graveyard — every sounding voice dies. The comment there explains why that is correct and must not be softened casually: a surviving live_ would be displaced into the drain slot on reactivate and "resurrect stale sustained voices as ghosts."
  • setActive(true) calls reloadInstrument() — a bridge read, a WAV re-decode, and a fresh engine.

So the honest cost of the toggle is: every sounding note stops, and the sample is re-decoded from disk. That is a materially heavier consequence than "a brief click," and it is the reason §8's new fork Γ-F6 exists rather than this being fully closed.

The standing scar, and why this is nonetheless not the forbidden change

reasampler_processor.cpp:66-68 carries a warning in the codebase's own words:

"Do not reintroduce per-mode bus renegotiation: flipping kMonokStereo via restartComponent previously panned a dual-mono capture hard right in the host's pin re-routing (see testDualMonoStereoSampleRendersCentered)."

and src/shell/instrument/CLAUDE.md elevates that to an invariant.

A kLatencyChanged restart is a different flag from the kIoChanged-class bus renegotiation that caused that regression, and it is NOT forbidden by that invariant — the output bus stays permanently stereo and its arrangement is never renegotiated. But the precedent stands: mid-session restartComponent in this plugin has already shipped one real regression, in the host's re-routing rather than in our code. That history is the reason the following are acceptance criteria and not suggestions:

  1. Verify the whole call sequence against the vendored Steinberg SDK before writing it — IAudioProcessor::getLatencySamples, IComponentHandler::restartComponent, the RestartFlags value, and the SDK's stated ordering (the new latency is what getLatencySamples returns after setActive(true), per ivsteditcontroller.h:106 — so the reported value must be derived from persisted state, not from a transient the deactivate clears).
  2. Prove the restart does not disturb the output bus arrangement. After a latency-change restart the bus is still one stereo output with the same arrangement, and a dual-mono capture still renders centered.
  3. Ship a regression test in the spirit of testDualMonoStereoSampleRendersCentered — a dual-mono capture rendered across a limiter toggle stays centered, with equal L and R. That test is the guard against the exact failure mode the scar records.
  4. Never call restartComponent from process(). It is a main/UI-thread call. The toggle already arrives on the UI thread; the restart is issued there, and coalesced so a user clicking the toggle repeatedly produces one restart per settled state, not one per click.

Flipping the toggle during playback — the product decision

Ruling (mine, not deferred): the toggle applies immediately, the restart is requested immediately, and the resulting interruption is accepted and documented. It is NOT deferred to a transport boundary. Three reasons, in order of weight:

  1. A deferred restart is a silent lie. If the limiter's audio engages now but the reported latency lands at the next transport stop, the plugin is misaligned by the lookahead for however long that takes — and a timing error on an instrument is invisible until it is printed. A visible interruption beats an inaudible misalignment.
  2. We do not actually control the timing. Per the SDK, the plugin requests; the host schedules the deactivate/reactivate. Deferring our request buys uncertainty, not determinism.
  3. The instrument is played live, not only sequenced. Auditioning a patch with the transport stopped is the common editing case; a transport-boundary deferral would mean the restart never lands at all in that case, which is the worst outcome of the three.

The mitigations that make this acceptable rather than merely defensible:

  • The limiter's own output has no hard step. Within the plugin, the engage/disengage is covered by a short (≤ 10 ms) equal-gain crossfade between the pre- and post-toggle paths, so whatever the host does around it, we do not emit a discontinuity of our own making.
  • The toggle is framed as a patch-design control, not a performance control. It is set once while building a sound. The editor should not encourage flipping it while playing, and nothing in the UI should make it a per-take gesture.
  • The limiter enable is explicitly NOT automatable. This is the load-bearing consequence and it must be recorded where the parameter work will read it: an automation lane toggling a latency-changing parameter would request a host deactivate/reactivate on every flip. See docs/product/parameter-automation.md §3.8 — the limiter enable belongs in the not-automatable class, and it is emphatically not the plugin's kIsBypass parameter either.
  • The interruption is verified in REAPER, and its severity recorded. The SDK mandates the deactivate/reactivate; what REAPER actually does with it — whether sounding notes cut, whether the re-decode is perceptible, whether transport hiccups — is DAW-verifiable only. That verification is W1-T2's first deliverable, and its outcome is what closes Γ-F6.

3.2 The meter

Vertical, 62 px wide, 186 px tall, down MASTER's right side, spanning both row baselines.

Property Decision Why
Bar count One wide bar when the waveform draws one lane; two skinnier bars when it draws two The bar count is resolved by the same LaneSplit decision waveformSurface already folds (channel mode ∧ source channel count) — not a second rule. A mono source in stereo mode is dual-mono: L ≡ R, and two identical bars would be a lie. One source, two views.
Bar geometry 22 px label gutter · 4 px gap · 36 px bar field. Mono: one 36 px bar. Stereo: two 17 px bars, 2 px apart.
Scale Linear in dB, 60 … +6 dBFS. Ticks every 6 dB; numerals at 0, 12, 24, 36, 48, 60; the 0 dB tick drawn heavier. 66 dB over 186 px = 2.8 px/dB; 34 px between numerals at Font::Micro. Honest and simple; an expanded-top scale was considered and rejected as harder to read against a numeric label. Above 0 is shown because it is exactly what the limiter-off case needs to make visible.
Ballistics Rise: instantaneous (a peak displays on the first UI frame after it occurs). Fall: 20 dB/second. A peak meter must not smooth its attack or it under-reports. 20 dB/s is close to the IEC 60268-18 PPM fallback (20 dB in 1.7 s) and reads as responsive without flicker.
Peak hold A 2 px horizontal tick at the running max, in text/primary. Holds 1.5 s after its last update, then falls at the same 20 dB/s. A neutral bright tick reads cleanly over the bar's accent ink; a second accent would compete.
Clip A cap at the top of the meter, latched warn when any block peak ≥ 0 dBFS. Click to clear.
Bar ink accent/primary — it is the live signal. warn stays reserved for clip states (visual-design-language.md §2.1). No green/yellow/red segmentation.

The clip indicator earns its keep precisely because the meter is post-limiter. With the limiter engaged, a post-limiter clip is essentially impossible; with it bypassed and gain driven up (the knob reaches +24 dB), clipping is easy. So the indicator quietly teaches what the toggle does: drive the gain, see red; engage the limiter, red stops. If the clip cap ever latches while the limiter is on, that is a defect report, not a user error.

RT discipline. The audio thread publishes, per block, as relaxed atomics: per-channel peak max|x|, a latched clip flag, and the block's maximum gain reduction. No dB conversion, no ballistics, no hold timers on the audio thread — the UI timer converts and runs the ballistics from the published block peaks and elapsed time. This matches the existing advisory-peak shape (reasampler_processor.h:109-113) and the standing rule that observation happens at block boundaries, never per frame. The existing advisory peak is not reusable as-is — no dB, no ballistics, no hold, no clip, mono only — but it is the right pattern to widen.

3.3 The gain-reduction bubble

Settled: the limiter shows a red bubble when the threshold is crossed and any gain reduction is applied.

  • A round 12 px lamp in the caption row's far corner — the slot the three envelope decks use for their overlay radio. Round, not square, so it reads as a lamp rather than a control; the limiter toggle sits one slot to its left, which is the existing right-to-left caption grammar unchanged.
  • Non-interactive. Either the existing captionRadio geometry with hit-test suppressed, or a passive-indicator slot in knob_deck — an implementation call, but the slot is the existing one and no new geometry is invented.
  • Ink: warn. Legal under the palette's "warn is reserved for clip states" rule because gain reduction reports an averted clip — the same state class the clip cap reports, one stage earlier.
  • Lit whenever the block's maximum gain reduction exceeds a small floor (the intent is "the limiter is working," not "a sample touched the threshold"); it follows the same 20 dB/s-style decay as the meter so a transient catch is visible rather than a single-frame flicker.

3.4 Where the master controls sit in the reset scope

Phase Ξ-W2's resample reset scope is settled by rule ("reset what the bake baked in"). Derived against that rule, surfaced for Ξ-W2's review rather than as a Daniel call: rate → reset, pitch offset → reset, limiter enabled → reset (master gain is already on the reset list, so the bake includes the master stage, so the limiter's effect is in the audio).

3.5 Assessment: the temp_cortex/ limiter reference

Read in full (temp_cortex/limiter_base.{hpp,cpp}, temp_cortex/fast_limiter.{hpp,cpp}, commit 3ad3094). Standing project rule: cortex code is a reference, not a transplant — if there is character in it worth having, it gets rebuilt as an explicit parameter rather than inherited as a side effect.

Verdict: it does not clear that bar. There is no character in it worth having. Read it as a reminder of the shape, take nothing.

What is structurally right: detection on the un-delayed signal with the gain applied to a delayed copy (that is correct lookahead), and stereo-linked max detection. Both are textbook and need no reference.

Everything else is disqualifying:

Finding Why it disqualifies the code
Unconditional makeup gainmakeupGain = 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 codeif (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 smoothingenvelope 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 1100 ms while still reaching the ceiling. A pure log map cannot include zero, and zero is a required value, so the taper is stated as acceptance criteria rather than a formula (the engineer picks the shape):

  • Exactly 0 s at norm 0 and exactly kEnvTimeMaxSeconds at norm 1. Monotone and continuous throughout.
  • 10 ms lands within 0.12 … 0.20 of travel; 100 ms within 0.42 … 0.52.
  • kEnvTimeMaxSeconds is 2.0 s (deck_values.h:22, reading kGateStageMaxSeconds at envelope_overlay.h:85) and does not move in this phase — settled, Γ-F3. The AHDSR overlay's schematic scale is derived from it and the two must agree so a maxed knob lands exactly at the canvas edge; the agreement requirement is documented at deck_values.h:19-22. Daniel foresees wanting a 10 s ceiling for sound-design cases — his example, "a horrifically long decay with tight exp" — and that ambition is carried as a docs/TODO.md entry rather than dropped. Two things make it cheap later and both land in this phase: the log taper is precisely what makes a higher ceiling usable rather than unusable at the low end, and the reset-bypasses-the-taper change below removes the power-of-two dependency that would otherwise block a 10.0 s ceiling outright.

Semitone knobs become log2-scaled. More resolution across 7 … +7 st while still allowing the extremes:

  • Symmetric about the centre; exactly 0 at norm 0.5; exactly ±kPitchDepthMaxSemis at the ends; monotone.
  • ±7 st reached at 50 % … 58 % of each half-travel (so the musically useful middle gets more than half the knob on each side).
  • Rate is the exception and keeps its settled taper: linear in semitones over ±12, i.e. exponential in ratio. It needs no centre expansion — ±12 st over the full travel is already 0.19 st per drag pixel — and Daniel settled it explicitly. The two laws coexist on one deck for a stated reason: centre expansion applies to semitone knobs whose throw exceeds ±12.

One correctness consequence, and it is required, not optional:

resetDeckParam must bypass the taper. It currently round-trips the default through norm → value (deck_values.cpp:200-203), and deck_values.h:42-46 documents that exact recovery depends on the map being linear over a power-of-two ceiling. A log taper makes that round-trip inexact. Reset must write the default value directly — the defaults are already read off a default-constructed PlaySeconds, so there is no second table to drift. This removes the power-of-two dependency rather than working around it; the comment at deck_values.h:42-46 becomes wrong and needs rewriting (source work, staff-engineer's).

This is also the unblocker for a future 10 s ceiling. 2.0 is a power of two; 10.0 is not, so under today's round-trip a 10 s ceiling would land every reset a mantissa bit off its own default. Landing the reset bypass here means the ceiling question later is a one-constant change plus an overlay-scale re-check, not a correctness problem.

4.4 Sequencing — why item D goes first

Item D lands before the two new PITCH/RATE knobs, so Rate and Pitch are authored into the finished law rather than retro-fitted into it. It lands well before any VST3 parameter work, for a much sharper reason: once parameters are exposed, the taper is the host-facing normalization, and re-tapering silently re-interprets every recorded automation point in every saved project. Taper changes are free now and permanently expensive later. See docs/product/parameter-automation.md §4.


5. Staged contour traces draw straight, not curved (item E)

Daniel's report. Changing a curve exponent from 1.0 moves the knot on the overlay, but the stage segment still draws as a straight line. Audio is correct; only the drawing is wrong.

Root cause, verified. editor_paint_waveform.cpp:218 does if (v.knot) continue; — knots are dropped from the trace and the remaining vertices are joined with straight strokes (:222). curveMap is never called in the paint path. The exponent is in scope (env.attackCurve / .decayCurve / .releaseCurve at :211) and simply never read. Knot positioning does honour the exponent via curveMidLevel (envelope_overlay.cpp:94-105) — which is the divergence: at any non-neutral exponent the knot visibly floats off its own trace.

The intended visual result (the tessellation approach is the engineer's call):

  • Every sloped stage draws as the curve its exponent defines, evaluated through the same curveMap the audio uses — one source, so the trace and the sound cannot diverge.
  • At exponent 1.0 the segment is visually identical to today's straight line (the regression guard).
  • At every exponent the knot's centre lies on the trace, within 1 px. This is the observable acceptance criterion, because the knot/trace divergence is the reported defect.
  • No visible faceting at the widest segment the canvas can produce (per-pixel-column or adaptive sampling; a fixed low tessellation count is not acceptable at full width).
  • The trace keeps the established grammar: one weight, kEnvTracePx = 2.0, through the analytic stroker — Θ-W7's "both envelope traces are one grammar and one weight" holds.
  • It is one paint path, so one fix covers all of it: amp / filter / pitch, Gate AHDSR and Trigger AHD, attack / decay / release. The drawn-EG (spline) overlay's own grammar is untouched — it already traces per column.
  • Both overlay layout policies are honoured unchanged: the AHDSR's right-anchored schematic and the AHD's 1:1 mapping.

6. Loop and crossfade — the re-approach (item F)

Daniel: "the loop indicators and crossfade thingy are unintuitive as fuck. I don't understand what the three lines represent, so we need to re-approach that UX."

This is the one genuinely open design problem in the phase. What follows is a recommendation, the alternatives that lost, and the trade-offs.

6.1 Diagnosis — three root causes, one of them not in the complaint

Cause 1 — a category error in the drawing. The three bars are not the same kind of thing. Start is a point in time (where the head enters). Loop start and loop end are the two ends of a span (a region the head cycles inside). They are drawn in identical ink (Role::AccentSecondary for all three, editor_paint_waveform.cpp:35-36), at identical weight, full height, distinguishable only by position. Drawing a point and the ends of a span in one grammar is why the marks cannot be told apart, and it is the deeper answer to "what do the three lines represent."

Cause 2 — nothing is named. There is no label anywhere in the band. Every audio editor labels these marks; we do not. This is the blunt, unglamorous half of the fix and it is probably worth more than everything else combined.

Cause 3 — the crossfade is painted where its ingredient lives, not where the event is heard. Verified: the fade is pre-seam and one-tap — it fades material running into loopEnd toward material running into loopStart, using the read head one loop length earlier (loop_span.h:26-28). The audible event occupies the last crossfade frames before loopEnd. But the UI paints the shaded region before loopStart (editor_paint_waveform.cpp:108-116), and hangs the only grab affordance — a 10 px tab in the top strip with no line of its own — at loopStart crossfade. The paint follows the handle, not the audible event. Both regions are real things (one is the ingredient, one is the event), but the drawing shows only the ingredient, and the handle is on the wrong side of the loop from the sound it controls.

6.2 Three directions considered

Direction 1 — "Two grammars: a point is a caret, a span is a bracket." Differentiate by category — start gets a distinct ink and a directional flag cap; the loop pair gets bracket caps so it reads as an enclosure; the crossfade moves to the audible location. Cheap, uses only primitives the kit already has, attacks all three causes.

Direction 2 — "Lane it." Add a dedicated 1416 px marker rail along the top of the waveform band. All handles live in the rail; the waveform proper carries only quiet 1 px guide lines and the span fill. Structurally the strongest option: it would dissolve the overlay's claim-arbitration problem (resolveWaveformClaim) rather than tie-breaking it, and it would collapse two open docs/TODO.md entries. Rejected for this phase — it costs waveform height, needs a new pure geometry module, and re-routes every hit-test in the band, which is a much larger build than Daniel's complaint calls for. Retained as the named fallback if the recommended direction proves too crowded in the DAW. Precedent: REAPER's own ruler/marker lane; Sound Forge and Audacity's selection/loop handles.

Direction 3 — "Show the loop as a loop." An arc/ribbon above the waveform running from loop end back to loop start with an arrowhead, the crossfade drawn as the ribbon's taper. Extremely legible for "what is a loop," and there is good precedent (Ableton Simpler's loop arrow, Kontakt's loop-return arc). Rejected: it is a lot of ornament for one fact the user learns once, and it sits uncomfortably against the visual language's strict decoration policy (visual-design-language.md §3.5).

Direction 1, with labels, in four moves. One grammar, four marks.

(a) One mark grammar: line + cap + label. The cap IS the grip.

Every mark draws as a full-height 2 px column, a shaped cap in the overlay's top strip, and a Font::Micro / TextDim label. The cap is both the mark's identity and its grab handle — which instantly answers "what is that tab?", because the crossfade's cap stops being a bare orphan rectangle and becomes the same kind of object as every other mark's.

Mark Ink Cap Line Label
Start accent/primary solid right-pointing triangle (a play flag — it points into the material that will play) solid START, right of the line
Loop start accent/secondary L-cap opening right solid LOOP, right of the line
Loop end accent/secondary L-cap opening left solid END, left of the line
Crossfade accent/secondary, reduced alpha ramp cap — a small right triangle whose hypotenuse rises left→right, drawing the fade-in shape dashed — a soft boundary, not a hard one XFADE, left of the line

Start is the only accent/primary mark in the band, because it is the only one that is always in effect (both Gate and Trigger). The loop pair's opposed L-caps read as [ … ] without needing to be explained. All four caps use primitives already in the kit (axis-aligned fills, AA-restroked triangles per visual-design-language.md §8).

(b) Labels, with an accepted overlap.

Labels draw in the top strip beneath the trace and handles in z-order. Where an envelope node overlaps a label, the node wins visually and the label is occluded — accepted, and named here so it is not filed as a defect. Labels are for learning; the cap shape carries the identity permanently. Two rules keep them honest:

  • On hover or drag of a mark, that mark's label re-draws on top, so you always see what you grabbed.
  • A label is suppressed if its box would overlap one already placed. Placement order is the grabbed/hovered mark first, then START, LOOP, END, XFADE.

A vertical inset of the overlay canvas to make room for labels was considered and rejected: it would change the envelope's level mapping (level 1.0 would no longer reach the canvas top), which moves both the forward and inverse overlay maps and their tests, for a cosmetic gain.

(c) The crossfade moves to where it is heard, and its ingredient becomes a ghost.

  • The crossfade mark and its region move to [loopEnd crossfade, loopEnd). The handle now lives on the loop-end side. Drag direction is unchanged — left lengthens the fade — so the muscle memory survives; only the anchor moves.

  • The region draws as a top-and-bottom edge wedge, never as a second fill. A triangular band along the top and bottom edges of the overlay, growing from zero height at loopEnd crossfade to ~10 px at loopEnd, in accent/secondary. It reads as the fade closing in on the seam and it leaves the middle of the waveform clean.

    This is a hard constraint, not a stylistic preference. The crossfade region is now inside the loop span, where a translucent fill would stack on the loop fill (0.20 + 0.10). The envelope trace crossing the loop fill is a known, accepted under-floor contrast pair at 2.25:1 against a 3:1 floor (editor_paint_waveform.cpp:28-34), whose own note says "If it is ever resolved, the FILL is what changes; do not nudge a color to chase it." A stacked fill would make an already-accepted failure worse. The edge wedge leaves the loop fill's peak alpha at 0.20 exactly as today, so the pair is untouched.

  • The ingredient draws as a ghost. [loopStart crossfade, loopStart) — the material actually being mixed in — draws the mirror wedge (growing right-to-left, peaking at loopStart) at half alpha, outside the loop fill. It carries no handle. At rest it is a hairline dashed outline; it fills in on hover or drag of the crossfade handle — a hover state in the sense §3.3 of the visual language means, revealing the relationship only when the user is asking about it.

  • This makes the clamp self-explanatory. The hard clamp is crossfade ≤ min(start, loopLength) (loop_span.h:19, and its "no material ahead of the loop" reasoning in engine/loop/CLAUDE.md). With the ghost drawn, the fade stops growing exactly when the ghost's left edge reaches the START mark or the LOOP mark — the user sees the reason instead of hitting an invisible wall. That is the single best payoff in this design and it costs nothing extra.

(d) The off-state and the Trigger state get words, not just alpha.

  • Loop off. The pair draws in the kit's Disabled state with a centred dim caption in the span — DRAG TO SET LOOP when no span has ever been set (the pair is parked at the last quarter, defaultLoopBounds), LOOP OFF when a span is retained. The full off-state machine, and what the explicit enable does to it, is §6.4.
  • Trigger mode. Loop is Gate-only (resolveLoop refuses in Trigger) but the markers still draw at full strength today, which is marks that do nothing. In Trigger the loop pair and the crossfade mark draw Disabled and are not grabbable, with a dim LOOP — GATE ONLY caption in the span. Disabled rather than hidden, because that is the established grammar — the editor's Gate segment already refuses and paints Disabled off the splineActive predicate — and because hiding a set loop on a mode flip destroys information the user put there. The START mark stays fully live in both modes.

6.4 The explicit loop enable (Γ-F4, ruled by Daniel 2026-08-01)

Settled: there is an explicit loop enable, and it lives on the CHROME ROW.

The framing this doc previously carried — "an enable costs a cell, so it is a §1.2 layout decision" — was wrong, and Daniel corrected it: loop is a waveform-overlay concept and has no deck. There was never a right deck cell for it. It is not on the overlay either (an overlay control that governs the overlay is circular), and it is not in a deck group.

Where it sits, and why that is free

The chrome band's toolbar row carries a right-anchored control run — preview · velocity cell · Mono|Stereo · Browse — with the title taking whatever the run leaves (sample_chrome.h). The enable joins that run as a two-segment Loop Off|On toggle, immediately left of the channel toggle, giving a two-toggle mode cluster with Browse still rightmost:

[▶ preview] [VEL knob] [Loop Off|On] [Mono|Stereo] [Browse]

Three reasons for that exact slot:

  1. It is the same class of control as the one next to it. Mono|Stereo is a playback mode of the loaded capture; so is loop-on. They belong adjacent, in the same two-segment primitive.
  2. Browse stays rightmost. It is navigation, not a mode — moving it would break the established right-edge reading.
  3. It costs zero window width. The run is right-anchored and the title slot absorbs it, so kEditorMinWidth does not move and none of §1.6's 90 px headroom is spent. Constraint: the title slot must still hold its text at the 1190 floor. If it will not, the enable's segments narrow — the floor does not move. That is a hard rule, because the floor is a phase-wide acceptance criterion.

What it is, in the model — and it needs no new persisted field

The enable IS SampleLoop::hasLoop. That field already exists (play_params.h:210), is already the predicate resolveLoop refuses on (loop_span.cpp:12), and is already persisted inside the params payload's loopOverride block — where start and end are written unconditionally, whatever hasLoop says (params_payload.cpp:31-36). So the wire can already carry "off, with a span remembered." No ComponentState field, no version bump, no format change. This track stays off the payload ladder entirely.

What does change is the field's provenance. Today hasLoop is derived: the editor sets it true whenever a marker is dragged (editor_input_waveform.cpp:255,258) and false whenever the span collapses (editor_session.cpp:236). After Γ it is user-owned, with the gestures as shortcuts onto it.

The interaction with collapse-to-off — one authority, two shortcuts

Collapse-to-off is not retired, and it does not become a second state machine. It becomes a shortcut that flips the enable. hasLoop is the single authority; three gestures reach it:

Gesture Effect on the enable Effect on the span Effect on the crossfade
Click the enable → On on retained as-is retained
Click the enable → Off off retained retained
Collapse the span (drag one loop mark onto the other) off destroyed → re-parked at defaultLoopBounds zeroed
Drag either loop mark while off on takes the drag retained (re-clamped)

Two of those rows are behaviour changes and each has a reason:

  • Toggle-off retains the span. This is what makes an enable worth having at all: a toggle whose off→on does not restore what was there is not a toggle, it is a delete button. Today pickedMarkers (editor_session.cpp:221-226) discards the stored span and re-parks at the default whenever hasLoop is false. That re-park must become conditional on the span being invalid, not on the enable being off — a collapsed, inverted or out-of-range span still re-parks (that rule exists so two coincident handles cannot become ungrabbable, and it is still right); a valid span under a user-set off keeps its position.
  • Toggle-off retains the crossfade. Same argument, and it does not contradict the existing zeroing rule, which is scoped to a span that no longer exists: editor_session.cpp:240-243 zeroes the crossfade on OFF precisely because "leaving a stale length here would silently re-apply it (clamped) the next time a loop is dragged back in." With the span retained, its clamp bound min(start, loopLength) is retained too, so there is nothing stale to re-apply. The zeroing predicate moves from "the enable is off" to "the span was destroyed." The reasoning behind the original rule is preserved intact, not overruled.

What the enable does to the "drag me" affordance

The parked pair at the last quarter was carrying two messages in one alpha value: there is no loop and drag here to make one. The enable takes the first message; the pair keeps the second.

  • Off, no span ever set — pair parked at defaultLoopBounds, drawn Disabled, caption DRAG TO SET LOOP. Dragging either mark turns the enable on. The shipped drag-to-create gesture survives intact, and it now teaches the enable by demonstration: the user drags and watches the chrome toggle light up.
  • Off, span retained — pair drawn Disabled at its own positions, caption LOOP OFF. There is nothing to "set," so the drag-me copy would be wrong. Dragging still turns the enable on, by the same rule.
  • On — full four-mark grammar of §6.3, unchanged.

A grab implies intent to loop. That is the one rule behind both off-states, and it is what keeps the enable from being a gate the user has to remember to open.

The Trigger case — the enable disables itself, it does not clear itself

Per the Disabled-not-hidden principle already established for the marks: in Trigger the chrome-row enable draws Disabled and inert, with its state preserved and restored on the return to Gate. It does not clear hasLoop, and it does not hide. The enable's Disabled state and the span's LOOP — GATE ONLY caption are the same message delivered at two scales — the chrome row says this control is unavailable here, the span says why.

This transitively covers the drawn-EG case: enforceGateUnavailableWhileDrawn (play_params.h:198-205) forces Trigger whenever any envelope is drawn, so a spline EG disables the loop enable through the same predicate rather than through a second rule.

Disabled-but-grabbable (the off marks) vs. Disabled-and-inert (Trigger) is a deliberate distinction, not an inconsistency, and the discriminator is who said no: the user's own off is reversible by the very gesture being offered, while Trigger's refusal comes from the engine and no marker drag can talk it out of it.

6.5 Trade-offs and consequences, named

  • The crossfade handle moves sides. A real muscle-memory break for the only current user. Mitigated by the unchanged drag direction and by the mark finally being labelled.
  • The enable puts a waveform-band control on the chrome row, one band away from what it governs. That distance is the price of it having no deck; it is mitigated by the marks themselves changing state visibly when it flips, so the two surfaces are never ambiguous about which one won. The alternative — a control on the overlay governing the overlay — is circular and was not seriously considered.
  • The track gains a chrome surface it did not previously have. sample_chrome (pure), editor_paint_chrome and editor_input_chrome come into scope, and so do editor_session's pickedMarkers/applyMarkers for the retention rule. §7.9 records what that does to the wave's disjointness claim.
  • hasLoop changes provenance from derived to user-owned. No format change, but every existing site that infers it (two in editor_input_waveform, two in editor_session) is now writing to a user-visible control rather than to an internal flag, and must be re-read in that light rather than left alone because it still compiles.
  • Top-strip density. Four caps and up to four labels in a strip that also carries envelope nodes. Mitigated by the suppression rule and the hover promote; if it still reads crowded in the DAW, Direction 2's rail (§6.2) is the pre-named escalation.
  • The claim-arbitration inputs change. Today markerHandleRect gives a tab to the crossfade only, and resolveWaveformClaim breaks ties by "smallest nominal target area among the candidates that actually hit." Giving every mark a cap-grip changes the candidate set and every nominal area in it. The arbitration must be re-derived, and the open docs/TODO.md entry "Pre-existing staged-envelope-node shadow at zero-attack" must be re-evaluated against the new cap geometry — it may be resolved by the change or made worse, and either outcome must be recorded rather than discovered.
  • The geometry stays pure. Cap rects, label boxes and the suppression rule belong in waveform_view (which already owns markerHandleRect) as pure, CTest-covered geometry. No hit-test math moves into the painter.
  • The model does not change. crossfade remains a stored frame count with the same clamp, resolveLoop is untouched, and no ComponentState version moves. This is entirely a drawing and hit-test change — which is what makes it a safe track to run in parallel with the parameter work.
  • Not chased: the 2.25:1 trace-over-fill pair stays exactly as accepted (see the constraint in (c)).

7. Collisions with existing invariants

Each of these is a place where Phase Γ contradicts, invalidates, or widens something a CLAUDE.md currently states. Naming them is the point; resolving them in source is staff-engineer's.

7.1 — knob_deck.h's fourteen-pixels-of-headroom note is invalidated. The header records that "the deck has fourteen pixels of headroom on its first row at the editor's floor width — a rowToggle would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds." That measurement is against the 980 px floor and the greedy three-row wrap; the reflow replaces both. The reasoning survives (a caption toggle rides existing slack, a row toggle costs group width); the number does not. It must be re-derived, not deleted.

7.2 — FILTER becomes the first non-envelope group to use captionToggle2. The slot exists and is free on FILTER, so Band|Notch moving to the caption corner needs no new geometry — but deck_groups.cpp's kEnvModeSegW comment describes the second slot as though it belongs to the env decks. Also freed: that comment says "the ceiling is PITCH ENV's, whose caption row lands exactly on its four-cell knob row at 23. Raising it reflows the deck's first row." After the reflow PITCH ENV is on row 2 with 48 px of caption slack, and the binding ceiling on kEnvModeSegW rises from 23 to 47 (PITCH ENV binds at 47, AMP at 55). No change is required; the constraint simply stops being tight, and the comment stops being true.

7.3 — Deterministic whole-group wrap stops deciding row membership. deckRowCount / layoutDeck implement a greedy left-to-right wrap. After the reflow, row membership is a property of the group (sound vs. contour) and MASTER is a right-anchored double-height group outside both rows. DeckLayout::rowCount and ::height change meaning, and deckHeight becomes a constant 2·kDeckGroupH + kDeckRowGap at and above the floor width. Whether the greedy wrap survives at all as a sub-floor degrade is an engineer's call; what is not optional is that at and above the floor width the layout is the specified two-row-plus-spanning-deck arrangement, arrived at by construction and not by a wrap outcome.

7.4 — knob_deck.h's "cell metrics and the editor floor move as a pair" gains a second driver. The invariant is currently directional: wider cells ⇒ wider floor. Phase Γ changes the floor without touching cell metrics, because the group inventory and its row assignment now also drive it. Restate as: the deck's cell metrics AND its group/row composition both drive kEditorMinWidth; none of the three may move alone.

7.5 — isLiveDeckParam becomes three-valued. See §2.3. The exhaustive switch must classify the two new 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 §§17 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.