diff --git a/docs/PLAN.md b/docs/PLAN.md index b0a1cba..636aa29 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -843,604 +843,98 @@ exact interim layout; do not "fix" it in a track that does not own it. ### Γ-W1 — Foundations -**Depends on:** nothing in this phase. **Six tracks, disjoint by surface** — re-verified -against this membership rather than carried over from the four-wave shape. The phase's track -numbering runs to T7; **T6 landed within this wave but has no entry in this document**: +**Depends on:** nothing in this phase. -| Track | Owns | -|---|---| -| **T1** `knob-interaction-law` | a **new pure taper module** under `core/instrument/ui/`, `ui/deck_values`, `ui/envelope_overlay` + `ui/envelope_edit` (the AHDSR schematic axis and its drag inverse), `ui/param_slider`, the three `shell/instrument/editor_input_*` drag paths, `editor_controls.cpp`'s `envClampBounds` only, the shared modifier helper in `editor_internal.h` | -| **T2** `master-bus-audio` | new pure `engine/limiter` + `engine/meter_ballistics`, `shell/instrument/reasampler_processor` + `processor_state`, `map/component_state_io` + `params_payload` (**the wave's payload rung**) | -| **T3** `contour-trace-curves` | `shell/instrument/editor_paint_waveform.cpp`'s staged trace + a **new pure** tessellation module | -| **T4** `editor-floor-and-row-law` | `ui/sample_bands.h` (the floor), `ui/knob_deck.h` (budget constants + two invalidated header notes), `ui/deck_groups` (the row predicate **only**), five test fixtures | -| **T5** `preserve-time-stretch` | `engine/pitch_shift` + a new pure stretcher module, `engine/voice.{h,cpp}`'s Preserve read path | -| **T7** `psola-preserve` | a **new pure module** `engine/period_detect` (two-pass YIN, with its own `period_detect_tests` target), `pitch_shift`'s jump geometry, the load-time hook in `map/sample_map`'s `buildSampleData`, `voice`'s note-on | - -**Two shared files in the wave, named rather than discovered at merge.** -`src/core/instrument/engine/CMakeLists.txt` — T2 declares two new pure libraries and their -test targets there, T5 declares one. **And, newly, `src/core/instrument/ui/CMakeLists.txt`** — -T1 declares the taper module and its test target, T3 declares the tessellation module and -its. All four are append-only additions in separate blocks — **textual merge adjacency, not -semantic contention.** Whichever lands second rebases. - -**Three near-misses that are avoided by construction, and must stay avoided.** - -(a) **T3's tessellation helper lands in a NEW pure module — explicitly NOT -`ui/envelope_overlay`, which T1 now owns**, and not in `editor_internal.h`, which T1 is also -editing. The prior wording offered `envelope_overlay` as an option; Ruling 2 removed it, -because T1's schematic-axis work rewrites that module's whole time→x map. This still -satisfies the phase's geometry-stays-pure criterion, so it costs nothing. - -(b) **T1 and T3 are disjoint by file but coupled by data, and the coupling has a stated -resolution.** T1 owns where an AHDSR's vertices *land*; T3 owns the stroke *between* -vertices. T3's tessellation is over φ across a segment's pixel span, so the tapered axis -changes nothing about the curve it draws — **but T3's tests must assert against the returned -vertices, not against absolute pixel literals**, or they break when T1 lands. Whichever -track lands second rebases; expressing T3's assertions relatively makes that rebase free. - -(c) **T4 touches `deck_groups` but adds only the new row predicate**; it does not touch -`sampleDeckGroups`, which W2-T1 and W3-T1 own in later waves, and it does not touch -`deck_values`, which is T1's. - -**Two consumption boundaries worth stating, because they look like collisions and are not.** -T1 **consumes** `engine/master_gain`'s dB taper for its whole-dB snap and does not edit it; -T2 does not touch it either. And T2's payload rung is the wave's only format change — -T1's taper and ceiling changes are persistence-neutral by construction (the payload stores -raw engine doubles). - -**Both of the phase's DSP unknowns are in this wave** — T2's limiter and T5's stretcher. That -is deliberate: they are the two tracks whose gate can fail, and failing in wave 1 is -recoverable in a way that failing in the last wave is not. +**All seven tracks have landed** — Γ-W1-T1 (`knob-interaction-law`), Γ-W1-T2 +(`master-bus-audio`), Γ-W1-T3 (`contour-trace-curves`), Γ-W1-T4 (`editor-floor-and-row-law`), +Γ-W1-T5 (`preserve-time-stretch`), Γ-W1-T6 (`exhaustive-switch gate on pure libraries`), and +Γ-W1-T7 (`psola-preserve`) — see `docs/COMPLETED.md` for the full narrative of each. **T6 has +no subsection below**, matching this plan's original choice not to give it one; its record in +`docs/COMPLETED.md` is reconstructed from the CMake change and its enforcement call site +rather than from a spec section here. #### Γ-W1-T1 — `knob-interaction-law` -**Goal.** One consistent, unit-category-driven interaction and taper rule across every -variable control, **over a stage-time range raised 2 s → 10 s**, landed **before** any new -control is added so the new ones are authored into it rather than retro-fitted — and before -any parameter is declared, so the law is what the host is handed rather than something the -host has to be reconciled with later. - -**Spec:** `docs/product/instrument-control-surface.md` §4, **§4.3.1 (the 10 s ceiling and the -overlay-legibility design — new, read it before scoping this track)**, and -`docs/product/parameter-automation.md` §8 (the one-way-door sweep this track discharges). - -**Surface boundary — owns:** a **new pure taper module** under `core/instrument/ui/` (the -ms/semitone/exponent maps, extracted so they have one home), -`core/instrument/ui/deck_values` (the bindings, the snap-unit table, `resetDeckParam`), -`core/instrument/ui/envelope_overlay` (the ceiling constant **and** the AHDSR schematic -axis) and `core/instrument/ui/envelope_edit` (its drag inverse), -`core/instrument/ui/param_slider` (the drag law), `shell/instrument/editor_input_*` -(modifier read + re-anchor), `shell/instrument/editor_controls.cpp`'s `envClampBounds` -**only** (it reads `kEnvTimeMaxSeconds`), and the modifier-reading helper the three input -paths share. **Does not own** any deck descriptor, any parameter, the waveform painter, or -`engine/master_gain` (consumed, not edited). - -**Why this track does NOT split, asked and answered.** Ruling 2 makes it materially bigger — -tapers, modifiers, re-anchor, reset bypass, the ceiling, and the overlay's schematic scale. -Two splits were considered and both are **serial, not parallel**, so neither buys any -concurrency: an *interaction* half (modifiers, snap, re-anchor) needs the *domain* half's -taper and snap-unit table to exist first; and a standalone *overlay-axis* track needs the -taper module and the final ceiling before it can define a stage's slot width. Splitting -would therefore cost a wave and gain nothing, while putting the single most -identity-critical function in the phase across a wave boundary — the same function the host -will normalize against three waves later. **The seam that matters is internal and is a -deliverable: the taper is extracted into its own pure module**, which is what makes "the -taper IS the host-facing normalization" structurally true rather than a comment. The -~600-line ceiling is a per-file bar, and the extraction is what keeps every file under it. - -**Behavior.** -- **Shift snaps to whole numbers in the control's displayed unit**; **Ctrl scales the drag by - 0.05**; **Shift+Ctrl = Shift wins** (Ctrl is ignored — with an integer-quantized output a - finer drag yields the same sequence, so this is identity, not a compromise). -- **Snap unit by category:** ms knobs → whole ms; semitone knobs (incl. Rate, when it - arrives) → whole semitones; percent/fraction knobs → whole percent; the 12 curve-exponent - inner dials → whole numbers (which puts 1.0, the linear neutral, one snap away); master - gain → whole dB; already-integer controls unchanged. Full table in the spec §4.2. -- **Mid-drag modifier transitions re-anchor** — on every press *and* release during an active - drag, the current value becomes the anchor value and the current cursor position the anchor - position. The value is continuous across the transition; only the rate changes. Without - this the grab-anchored absolute drag (`kKnobDragRangePixels = 128`) jumps by - `(1 − 0.05) ×` the accumulated delta. -- **Millisecond knobs become log-scaled.** Exactly 0 s at norm 0 and exactly - `kEnvTimeMaxSeconds` at norm 1, monotone throughout; **10 ms lands within 0.12–0.20 of - travel and 100 ms within 0.42–0.52**. -- **The stage-time ceiling moves 2.0 s → 10.0 s** (Daniel, reversing Γ-F3): - `kGateStageMaxSeconds` (`envelope_overlay.h:85`) and, through it, `kEnvTimeMaxSeconds` - (`deck_values.h:22`). **The two move together or not at all** — `deck_values.h` reads the - overlay's constant rather than restating it precisely so they cannot drift - (`deck_values.h:19-22`). The taper's landmarks above are fit against the **new** ceiling, - which is why the ceiling cannot be a follow-up: fitting the taper twice is the only other - way to get there. -- **`resetDeckParam`'s bypass becomes MANDATORY rather than merely required-anyway.** - `deck_values.h:42-46` records that exact default recovery depends on the ceiling being a - power of two; **2.0 is, 10.0 is not**, and the log taper compounds it. Nothing here may be - "simplified" back into a norm round-trip under any circumstance. -- **NEW, and the sharpest requirement in the track: every default must have an EXACT - normalized preimage under its taper.** `ParameterInfo::defaultNormalizedValue` (Γ-W4-T1) - is normalized, so a host's reset-to-default arrives as `toPlain(defaultNorm)` — and **the - host has no `resetDeckParam` bypass to use**. The bypass fixes the editor's reset and - cannot fix the host's; only exactness in the map itself makes the two land on the same - value. This binds the taper's *shape*, so it belongs here and cannot be handed forward. - Master gain's unity (≈ 0.714 norm) is the case where a hair off is audible. -- **The AHDSR overlay's schematic axis becomes the taper — the ceiling's real cost, and it - is design work, not a constant change.** Each of the four timed stages gets an equal slot - and today maps seconds across it linearly (`gatePxPerSecond`, - `envelope_overlay.cpp:33-34`). At 2 s a 30 ms attack is 1.5 % of its stage's domain; **at - 10 s it is 0.3 %, under a pixel at the floor width.** The fix: a stage's slot width becomes - `slotPx × taperNorm(seconds)` instead of `slotPx × seconds / ceiling`, so a node's position - within its slot **is** its knob's needle position. Legibility becomes ceiling-independent by - construction; the one-model invariant gets stronger rather than strained; and **the drawn - curve is unaffected**, because the taper decides only where a stage's end node lands while φ - still runs linearly across the stage's pixel span — so Γ-W1-T3's φ^p trace composes with it - rather than fighting it. **The AHD policy is untouched**: an AHD maps 1:1 onto the - waveform's own PCM-aligned time axis and stays linear in seconds. Two alternatives - (content-fit auto-scale; a minimum drawn stage width) were considered and rejected — spec - §4.3.1 names why, and neither is to be reintroduced as a "simplification." -- **`envelope_edit`'s drag inverse must remain the EXACT inverse of the draw.** Both read the - same taper module; a node dragged to a pixel and the knob's value at that pixel are one - number, not two that agree. -- **The taper is EXTRACTED into its own pure module**, with its own `_tests` target, - because it now has three consumers in two different dependency layers: `deck_values` (which - sits above `envelope_overlay`), `envelope_overlay`/`envelope_edit` (which sit below it), - and — from Γ-W4-T1 — the host. Leaving it inside `deck_values` would force an inverted - include edge. **Do not solve that by copying the map.** -- **Semitone knobs become log2/centre-expanded.** Symmetric, exactly 0 at centre, exactly - ±`kPitchDepthMaxSemis` at the ends, monotone; **±7 st reached at 50–58 % of each - half-travel**. -- **`resetDeckParam` bypasses the taper** — it writes the default value directly instead of - round-tripping through `norm → value`. This *removes* the power-of-two dependency the - header currently documents rather than working around it; that comment - (`deck_values.h:42-46`) becomes wrong and must be rewritten. -- **Scope is the parameter, not the widget.** Deck knobs (outer ring and inner dial), - envelope stage nodes and curve knots all honour it — they are surfaces onto one model, and - a snap on one but not the others is a divergence. **Waveform markers are explicitly - excluded**: they carry a shipped zero-crossing snap on the same modifier space and their - domain is frames. - -**Acceptance criteria.** -- Every taper change is verified **persistence-neutral**: a project saved before the change - reopens with bit-identical stored values and identical audio; only needle angles move. -- Holding Shift mid-drag on each unit category lands the documented whole unit; releasing it - does not jump the value. -- Holding and releasing Ctrl mid-drag is continuous — no step at either transition. -- Double-clicking any knob (outer ring and inner dial independently) lands **exactly** on its - default at every taper, verified against a default-constructed `PlaySeconds` rather than a - round trip. -- The log/log2 landmark positions above are asserted in the **taper module's** own tests, and - hold at the **10 s** ceiling — the fit is against the new ceiling, not the old one. -- **Every default round-trips exactly through `norm → value`**, asserted per unit category - against a default-constructed `PlaySeconds` and against `master_gain`'s unity. This is the - criterion Γ-W4-T1 will declare `defaultNormalizedValue` from; it fails here, not there. -- **A stage time of several seconds is reachable by hand with no loss of resolution below - 100 ms**, and `kEnvTimeMaxSeconds == kGateStageMaxSeconds` is asserted, not assumed. -- **A project saved at the 2 s ceiling reloads with identical stored seconds and identical - audio** — the ceiling change is persistence-neutral for the same reason the taper is. -- **The AHDSR overlay reads legibly at both ends of the new range**: a default 3 ms attack is - a visible, grabbable node at the floor width, and a 10 s decay still lands its end node at - its slot's edge. Assert the node separation, then judge the result by eye in the DAW. -- **The overlay's drag inverse is the exact inverse of its draw** at the tapered axis — - `nodeAtPoint` / `resolveNodeDrag` and `buildEnvelopePolyline` round-trip. -- **The AHD 1:1 policy is unchanged**, asserted: a sustain-less envelope's x-axis stays - wall-clock over the waveform. -- **The taper module is pure, CTest-covered, and is the ONLY definition of each map** — a - grep finds no second copy in `deck_values`, `envelope_overlay`, or the shell. -- **The filter's four `*Norm` controls are untouched by the taper pass** — cutoff, Q, morph - and drive are already wire-frozen in payload v9; a regression baseline proves their audio - is unchanged. -- One shared modifier-read helper serves all drag surfaces; no second modifier grammar exists. - -**Open questions.** -- **No [Daniel] questions.** Fork **Γ-F3 is REVERSED**: the ceiling moves to **10.0 s, in this - track.** Daniel's *"a horrifically long decay with tight exp"* is the case it serves, and - the `docs/TODO.md` entry that carried it is discharged rather than deferred again. **The - reversal's cause is Ruling 1** — a range endpoint is host-facing normalization, free to - move now and permanently expensive after Γ-W4-T1. Both of the prerequisites the deferred - entry named are in this track anyway: the log taper (which is what makes a higher ceiling - usable at the low end rather than unusable) and the reset bypass (which retires the - power-of-two dependency — 2.0 is a power of two, 10.0 is not). The engineer should know the - reset bypass is now doing triple duty and must not be "simplified" back into a norm - round-trip under any circumstance. -- **[propose at review]** the exact shape of the taper, subject to the landmark bounds **and** - the exact-default-preimage requirement. Those two together are tighter than either alone, - and the second is easy to satisfy by accident and easy to lose in a refactor — **assert it, - do not observe it.** -- **[propose at review]** whether the tapered schematic axis wants a visible tick or - gradation cue, now that it is no longer linear in time. The plan's lean is **no** — the ms - labels carry the number and the editor's no-decoration policy stands — but a reader who - finds the axis illegible in the DAW should say so rather than silently adding one. +**Landed** — see `docs/COMPLETED.md` for the full narrative. One consistent interaction and +taper law across every variable control, landed before Rate/Pitch or any VST3 parameter +existed so both are authored into it rather than retrofitted. The taper is extracted into +its own pure module, `core/instrument/ui/param_taper` — the one home three consumers read +(the knob's needle, the AHDSR overlay's schematic axis and its drag inverse, and — from a +later wave — the VST3 host's `toPlain`/`toNormalized`). Shift snaps to whole units in the +control's displayed category; Ctrl scales the drag by 0.05; Shift+Ctrl resolves to Shift; a +mid-drag modifier transition re-anchors value and cursor position. **The stage-time ceiling +moves 2.0 s → 10.0 s** (`kGateStageMaxSeconds`/`kEnvTimeMaxSeconds`, moved together so they +cannot drift), reversing Γ-F3 on Daniel's later ruling, and every default now has an exact +normalized preimage under its own taper — the requirement Γ-W4-T1's `defaultNormalizedValue` +depends on, since a host's reset-to-default has no `resetDeckParam` bypass to fall back on. +The filter's four `*Norm` controls stay untouched (wire-frozen in payload v9); the change is +persistence-neutral throughout. #### Γ-W1-T2 — `master-bus-audio` -**Goal.** The master limiter and the meter's **audio and publication halves**, plus **the -plugin's first latency reporting** — no editor drawing. Landing the audio ahead of the deck is -what lets Γ-W3 draw against real published state instead of a stub. - -**Spec:** `docs/product/instrument-control-surface.md` §3.1, **§3.1.1 (latency — read this -first; it was rewritten when Γ-F6 closed, so an older reading of it is wrong)**, §3.2–3.3, -§3.5, §7.10, §7.11, §8.2. - -**Surface boundary — owns:** a new pure limiter module and a new pure meter-ballistics -module under `core/instrument/engine/` (each with its own `_tests` target), -`shell/instrument/reasampler_processor` (the chain, the published block state, **and the -`getLatencySamples` / `restartComponent(kLatencyChanged)` path**), and **the phase's FIRST -params-payload rung** (the limiter enable flag). **Does not own** MASTER's deck geometry or -any drawing — that is Γ-W3-T1. **This track spends that rung: `kParamsPayloadVersion == 15`** -(`map/component_state_io.h:170`), confirmed on this branch as of 2026-08-01 — the full ladder -(spent / next-free / reserved) is stated in the phase summary's resequencing note near the end -of this phase's ASCII block. - -**Behavior.** -- **Chain:** `voice mixer → master gain (existing ramped multiply) → limiter (bypassable) → - output bus`, with the meter tapped at the **bus output, post-limiter**. -- **Limiter: a single toggle, no configurable controls.** Baked ceiling **−0.3 dBTP**. - Default **off**. **No makeup gain, ever, of any kind** — transparent at rest. - Stereo-linked detection (max |L|,|R| drives one gain) so the image is not moved. -- **True-peak detection is sidechain-only** — an oversampled detector in the sidechain, never - oversampling the signal path. Factor is the engineer's call under the measure gate. -- **Lookahead, with DYNAMIC reported latency (Γ-F2, ruled).** `getLatencySamples()` returns - **0** when the limiter is off and **the lookahead in samples** when it is on; the toggle - calls `IComponentHandler::restartComponent(kLatencyChanged)`. **None of this exists today** — - there is no `getLatencySamples` override, no `kLatencyChanged`, and no `restartComponent` - call site anywhere in `src/`; the plugin ships the SDK default of 0. This track introduces - the plugin's first latency reporting. -- **The restart is routine; the fencing is against a standing scar, not against the flag.** - Dynamic latency reporting is ordinary VST3-instrument behaviour and REAPER handles it as a - matter of course. The SDK's deactivate/reactivate requirement - (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) is the normal contract. **What makes the - cycle expensive here is this plugin's own `setActive`** — reactivate calls - `reloadInstrument()`, a bridge read plus a full WAV re-decode - (`reasampler_processor.cpp:89-97`), where a typical plugin only allocates buffers; deactivate - frees `live_`/`draining_`/graveyard (`:98-107`) for a documented reason (ghost sustained - voices). **Γ-F6 is ruled: ship it — the toggle is a patch-design gesture, not a - during-playback one.** Do **not** build a constant-reported-latency fallback and do **not** - gate the deliverable on a measurement. The reduction of that self-inflicted cost is filed in - `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") with its trigger - condition; it is out of scope here. The four requirements below survive as engineering - hygiene against the `kIoChanged` scar, and all four are acceptance criteria: - 1. **Verify the whole call sequence against the vendored Steinberg SDK** before writing it, - including the ordering rule that the new latency is what `getLatencySamples` returns - *after* `setActive(true)` — so **the reported value must derive from persisted state, not - from a transient the deactivate clears.** - 2. **Prove the restart does not disturb the output bus arrangement.** The output stays one - permanently-stereo bus, never renegotiated. - 3. **Ship a regression test in the spirit of `testDualMonoStereoSampleRendersCentered`** — - a dual-mono capture rendered across a limiter toggle stays centered, L ≡ R. - 4. **`restartComponent` is never called from `process()`.** Main/UI thread only, and - coalesced so repeated clicks produce one restart per settled state. - **This is NOT the change `reasampler_processor.cpp:66-68` forbids.** That warning is against - reintroducing per-mode **bus** renegotiation (`kIoChanged` class), which panned a dual-mono - capture hard right in the host's pin re-routing; `kLatencyChanged` is a different flag and the - bus is untouched. But the precedent — mid-session `restartComponent` in this plugin has - already shipped one real regression — is exactly why (2) and (3) are non-negotiable. -- **Flipping the toggle during playback: apply immediately, do NOT defer to a transport - boundary** (product ruling, spec §3.1.1). A deferred restart leaves the plugin misaligned by - the lookahead with no visible cue, which is worse than a visible interruption; and the host, - not the plugin, schedules the deactivate/reactivate anyway. **Daniel has accepted the - interruption outright** (Γ-F6) — it is not a case to design for. Two things remain in scope, - and neither is a mitigation for it: - - **A short (≤ 10 ms) equal-gain crossfade over the engage/disengage.** Kept as a *quality* - measure, not a mitigation: a limiter engaging is a gain-path change, and this codebase - already ramps every gain-path change (`kGainRampSeconds`, `ValueRamp`). It also earns its - keep independently of the restart, because **we do not control when the host acts on the - request** — our own transition must be clean in the window before it does. - - **The limiter enable is classified NOT automatable** - (`docs/product/parameter-automation.md` §3.8) so nothing can flip it at rate. It is also - **not** the plugin's `kIsBypass` parameter. -- **Per block the processor publishes, 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 converts and runs ballistics from - block peaks and elapsed time. This widens the existing advisory-peak pattern - (`reasampler_processor.h:109-113`), which is not reusable as-is. -- **Meter ballistics (pure, unit-tested):** instantaneous rise; **fall 20 dB/s**; peak-hold - latched at the running max, **held 1.5 s**, then falling at the same rate; scale **linear in - dB over −60…+6 dBFS**; clip latches at block peak ≥ 0 dBFS and is cleared on request. -- **The `ComponentState` payload rung** appends the limiter flag as a strict suffix on the - existing discipline; the preceding version's blob is a strict prefix and lifts to bypassed. - -**Acceptance criteria.** -- **With the limiter bypassed the rendered output is byte-identical to the pre-change build**, - asserted by a regression baseline, not by ear. -- With the limiter engaged, no output sample exceeds the ceiling on program material that - exceeds it by up to +12 dB; with it bypassed and gain driven, the output does exceed - 0 dBFS (proving the toggle is doing the work). -- **Nothing is louder at rest with the limiter on.** A signal that never reaches the threshold - is bit-identical engaged and bypassed. -- No allocation, no lock, no transcendental on the per-sample path; the measure-and-report - gate reports per-voice-block CPU with the limiter engaged at 32 voices. -- The meter-ballistics module is pure and CTest-covered: rise, 20 dB/s fall, 1.5 s hold, clip - latch/clear, and the dB↔pixel map are all asserted without a host. -- A project saved before this change reopens with the limiter bypassed and sounding identical. -- **`getLatencySamples()` returns exactly 0 with the limiter off**, and the lookahead in - samples with it on — asserted against the persisted flag, and correct across a - deactivate/reactivate cycle. -- **A dual-mono capture rendered across a limiter toggle stays centered** (L ≡ R), and the - output bus arrangement after a latency-change restart is identical to before it. -- **The plugin emits no hard step at the toggle** — the engage/disengage crossfade is asserted - on a rendered signal, not judged by ear. - -**Open questions.** -- **No [Daniel] questions. Fork Γ-F6 is ruled** — dynamic latency ships as specced, the - deactivate/reactivate is accepted, and there is no fallback design and no measurement gate. - Do not reintroduce either; the constant-reported-latency option is closed, not shelved. -- **[verify]** `temp_cortex/` has already been assessed and **rejected** (spec §3.5) — do not - re-litigate it, and do not transplant from it. -- **[record, not a gate]** While the limiter is in REAPER under your hand, note what the - restart actually costs — do notes cut, is the re-decode perceptible, does transport hiccup — - and record it in this track's review. It is **not** a gate on shipping and no outcome changes - the design; it is the trigger-condition evidence for the `docs/TODO.md` entry "Decouple the - instrument reload from VST3 activation," which is where that cost gets reduced if it ever - matters. Do **not** restructure `setActive` here: its destructive shape is deliberate and its - reasoning (ghost sustained voices on reactivate) is documented at the call site. +**Landed** — see `docs/COMPLETED.md` for the full narrative. The master bus: a bypassable +true-peak limiter (baked −0.3 dBTP ceiling, default off, no makeup gain, stereo-linked, +sidechain-only oversampled detection), the meter's audio and publication half, and the +plugin's first latency report — `getLatencySamples()` returns 0 with the limiter off and the +lookahead in samples with it on, driving `restartComponent(kLatencyChanged)` on toggle. New +pure modules `core/instrument/engine/limiter` and `engine/meter_ballistics`. **Spent the +phase's first payload rung: `kParamsPayloadVersion` reaches 15**, appending the limiter +enable flag as a strict suffix. #### Γ-W1-T3 — `contour-trace-curves` -**Goal.** Staged envelope segments draw as the curve their exponent defines, so the -mid-segment knot stops floating off its own trace. - -**Spec:** `docs/product/instrument-control-surface.md` §5. - -**Surface boundary — owns:** `shell/instrument/editor_paint_waveform.cpp`'s staged-envelope -trace and a **new pure tessellation module** for it. **Does not own** the loop/crossfade -marks (Γ-W2-T2), `envelope_overlay`'s vertex model, or the drawn-EG (spline) trace. - -**The helper's home is now constrained, not a choice.** `ui/envelope_overlay` was previously -offered as a candidate home for the tessellation helper; **Γ-W1-T1 now owns that module** -(the AHDSR schematic axis, per Ruling 2), so the helper lands in a **new** pure module under -`core/instrument/ui/`. T1 also owns where an AHDSR's vertices land — this track owns only the -stroke *between* vertices, and tessellates over φ across a segment's pixel span, so the -tapered axis changes nothing about the curve drawn. **Express this track's assertions against -the returned vertices, not against absolute pixel literals**, and the rebase onto T1 is free. - -**Behavior.** The defect is verified: `editor_paint_waveform.cpp:218` drops knots -(`if (v.knot) continue;`) and joins the remaining vertices with straight strokes, and -`curveMap` is never called in the paint path even though the exponent is in scope at `:211`. -Knot *positioning* already honours the exponent via `curveMidLevel` -(`envelope_overlay.cpp:94-105`) — that divergence is the visible symptom. The fix draws each -sloped stage through **the same `curveMap` the audio uses**, so trace and sound cannot -diverge; tessellation approach is the engineer's call. - -**Acceptance criteria.** -- **At every exponent the knot's centre lies on the trace, within 1 px** — the reported defect, - stated as the gate. -- **At exponent 1.0 the segment is visually identical to today's straight line.** -- No visible faceting at the widest segment the canvas can produce; a fixed low tessellation - count is not acceptable at full width. -- All three envelopes, both play modes, all sloped stages (attack/decay/release) — one paint - path, one fix. -- The established trace grammar is unchanged: one weight, `kEnvTracePx = 2.0`, through the - analytic stroker. Both overlay layout policies (AHDSR right-anchored schematic, AHD 1:1) - are honoured unchanged. The spline overlay's own trace is untouched. -- **Audio is unchanged** — this is a drawing defect only; a regression baseline proves it. +**Landed** — see `docs/COMPLETED.md` for the full narrative. Staged envelope segments now +draw as the curve their exponent defines, closing the defect where the mid-segment knot +floated off its own trace. A new pure module, `curve_tessellate`, draws every sloped stage +through the same curve the audio's evaluators use, so the drawn stage and the sound it makes +cannot diverge. All three envelopes, both play modes, every sloped stage, share the one fix; +audio is unchanged. #### Γ-W1-T4 — `editor-floor-and-row-law` -**Goal.** Commit the **canvas** — the window floor, the width budget it is derived from, and -the row every deck group belongs to — so every other UI track in the phase is drawn, tested and -judged at the final window size. The **arrangement** inside that canvas is Γ-W3-T1's. - -**Spec:** `docs/product/instrument-control-surface.md` §1.1 (the two categories), §1.2 (the -floor arithmetic block), §1.6 (the headroom ledger), §7.1 and §7.4 (the two invalidated -`knob_deck.h` notes). - -**Surface boundary — owns:** `core/instrument/ui/sample_bands.h` (`kEditorMinWidth`), -`core/instrument/ui/knob_deck.h` (the declared budget constants and the two invalidated header -notes), `core/instrument/ui/deck_groups.{h,cpp}` (**the new row predicate only**), and the five -test fixtures that read the floor — `test_sample_bands.cpp`, `test_deck_groups.cpp`, -`test_knob_deck.cpp`, `test_sample_chrome.cpp`, `test_keyboard_strip.cpp`. **Does not own** -`layoutDeck` / `deckRowCount` / `deckHeight` behaviour, the justification law, any descriptor, -MASTER's inventory or interior, any painter, or any parameter. It changes **no drawing code at -all.** - -**Behavior — what it commits.** -- **`kEditorMinWidth` 980 → 1190. `kEditorMinHeight` stays 680** (Γ-F1). -- **Three declared budget constants in `knob_deck.h`:** the row block both rows will justify - inside (**1020**), the right-anchored spanning deck's reserved width (**MASTER 142**), and - the ceiling (**1280**). These are *declarations of budget*, not measurements — nothing - computes them from a descriptor, and Γ-W3-T1's job is to prove its content fits inside them. -- **The floor is derived, not asserted as a literal.** `1020 + kDeckGroupGap(12) + 142 + - 2·kPad(8) = 1190`. `kEditorMinWidth` stays a literal in `sample_bands.h` — **do not add an - include edge from `sample_bands` to `knob_deck`**, which would invert the allocator's - deliberate independence from the deck (it takes `deckHeight` as a *parameter* for exactly - that reason). The identity is asserted in `test_deck_groups.cpp`, which already includes - both headers. This is the Θ-W6-T1 derived-floor precedent, landed once and never rewritten. -- **Row membership becomes a property of the group id:** `DeckRow { Sound, Contour, Spanning }` - + `deckRowFor(DeckGroupId)` in `deck_groups`, an **exhaustive switch** on the - `isLiveDeckParam` discipline, so a future group is a compile error rather than a silent - default. Partition: **Sound** = PITCH/RATE, FILTER, VELOCITY, VOICE; **Contour** = PITCH ENV, - FILTER ENV, AMP ENVELOPE; **Spanning** = MASTER. **Nothing consumes it until Γ-W3-T1** — that - is the seam, and it is why the predicate is safe to land now: **membership is a property of - the group, width is a property of the descriptor**, and only the widths are still moving. -- **Γ adds no new deck group**, so no later track amends this predicate. - -**The seam, stated as what this track can and cannot assert.** - -*Can assert today:* -- The derived floor identity above, and `kEditorMinWidth ≤ 1280` with **90 px** of headroom. -- `kEditorMinHeight == 680`, asserted so no later track drifts Γ-F1's ruling. -- `deckRowFor` is total over `DeckGroupId` and yields exactly the partition above. -- **Row 2's natural width already fits the block, in both play modes:** - 252 + 312 + 312 = **876 ≤ 1020**, leaving both its gutters ≥ `kDeckGroupGap`. Mode-stable - because FILTER ENV's and AMP's reserve slots hold them at 312 in Gate and Trigger alike. -- **MASTER's reserve is not yet spent:** `deckGroupWidth(MASTER) == 72 ≤ 142`. -- At the floor, deck band **216** and waveform band **358** — the reflow's 112 px arrives here, - two waves early (see the interim layout below). - -*Cannot assert yet, and must not force:* -- **Row 1's natural width does not fit the block.** Today it is PITCH 150 + FILTER 524 + - VELOCITY 192 + VOICE 164 = **1030**, against the 1020 block. The 50 px deficit is exactly - what the two descriptor changes buy: PITCH → PITCH/RATE **+42** (Γ-W2-T1) and FILTER's - `Band|Notch` moving to the caption corner **−92** (Γ-W3-T1), netting **980**. Record the - target and the two contributions as a test comment; **assert the fit in Γ-W3-T1, and do not - pre-empt either descriptor change to close it early.** -- Gutter distribution, the filter tie-line at x = 636, flush outer edges, MASTER's interior and - its meter — all Γ-W3-T1. Every one of them measures a descriptor that does not exist yet. - -**The interim editor, stated exactly so it is not filed as a defect.** At the new floor, -`availWidth = 1190 − 2·kPad = 1174`, and the **unchanged** greedy whole-group wrap packs: - -``` -row 1 PITCH 150 · PITCH ENV 252 · FILTER 524 = 950 used, 224 px ragged right -row 2 FILTER ENV 312 · AMP 312 · VELOCITY 192 · - VOICE 164 · MASTER 72 = 1100 used, 74 px ragged right -``` - -**Two rows, not three** — so the deck band is already 216 and the waveform already draws at its -final 358 px, in both Gate and Trigger. After Γ-W2-T1 lands PITCH/RATE the pack is row 1 = 992, -row 2 unchanged; still two rows. The composition is wrong in exactly the way the reflow exists -to fix — PITCH ENV sits up with the sound decks, VOICE and MASTER sit down with the envelopes, -MASTER is still a single-height 72 px box, and both rows are left-packed with dead space at the -right. **Worse than today in composition, better in proportion.** That is the accepted -transitional state for the rest of the phase. - -**Do not convert the two-row interim into a claim.** It is a coincidence of the greedy wrap at -exactly this width, not a guarantee — which is precisely why Γ-W3-T1's criterion is "two rows -**by construction**, asserted against the group inventory, not observed as a wrap outcome." -`testDeckFitsInsideTheEnforcedMinimumWindow` currently asserts `deckRowCount == 3`; relax it to -an **upper bound** (`<= 2`), which is a real regression canary throughout the interim and is -subsumed by Γ-W3-T1's exact claim. An exact `== 2` here is acceptable only with a comment -naming it as a wrap outcome the reflow replaces. - -**Acceptance criteria.** -- **The floor is 1190 × 680, reached by a derived test over the three budget constants**, not - by a literal — and the derivation is the one Γ-W3-T1 later reads rather than a second copy. -- **Headroom is exactly 90 px** against the 1280 ceiling, asserted. -- `deckRowFor` is exhaustive over `DeckGroupId`; adding a group without classifying it fails to - compile. -- Row 2's natural width and MASTER's unspent reserve are asserted, in **both** play modes. -- **All five floor-reading test fixtures pass at the new floor** — including the chrome row, - whose title slot gets *more* room at 1190, not less. -- **No drawing code changes, no descriptor changes, no parameter changes, no audio change.** - A regression baseline proves the last of those trivially. -- The two invalidated `knob_deck.h` notes (§7.1's fourteen-pixel headroom figure, §7.4's - cells-and-floor pairing) are **re-derived against the new floor, not deleted** — §7.4's - restatement is *the deck's cell metrics AND its group/row composition both drive - `kEditorMinWidth`; none of the three may move alone.* - -**Open questions.** **No [Daniel] questions.** **[propose at review]** whether the three budget -constants belong in `knob_deck.h` (the deck owns the row block and the spanning-deck reserve) -or in `sample_bands.h` (the allocator owns the floor they derive). The plan's lean is -`knob_deck.h` with the identity in the test, because it adds no include edge; either is -defensible, but the *derivation must live in exactly one place*. +**Landed** — see `docs/COMPLETED.md` for the full narrative. Commits the editor's canvas +ahead of the rest of the phase's UI work: `kEditorMinWidth` moves 980 → 1190 +(`kEditorMinHeight` stays 680, Γ-F1), derived from three budget constants — the row block +(1020), MASTER's reserved width (142), and the 1280 ceiling (`kEditorCeilingWidth`, relocated +into `sample_bands.h`) — leaving 90 px of headroom. Row membership becomes a property of the +group id via an exhaustive `deckRowFor(DeckGroupId)` switch (Sound / Contour / Spanning), +consumed by no one yet — **that consumption, and the fit inside the 1020 block, is +Γ-W3-T1's** to assert. No drawing code, descriptor, parameter, or audio changed in this +track. #### Γ-W1-T5 — `preserve-time-stretch` -**Goal.** A real pitch-preserving time-stretcher for Preserve mode, written from established -state-of-the-art literature — landed **before** the control that drives it, so Rate ships onto a -finished engine rather than onto a disposable stand-in. - -**Spec:** `docs/product/instrument-control-surface.md` §2.5. - -**Moved from Γ-W4-T1 (Daniel, 2026-08-01).** It is the longest pole in the phase and has zero -dependency on any UI work. **The consequence is the interesting one: it inverts the -relationship with Rate.** Under the old order the stretcher was Rate's quality upgrade and -Γ-W2-T1 shipped an interim resample-and-cancel path to make Rate complete on day one; under -this order the stretcher is Rate's **prerequisite** and **the interim path is not built at -all.** Skipping a stand-in that was only ever going to be deleted is the win; see Γ-W2-T1's -named contingency for what happens if this track's gate slips. - -**Precedent for landing a DSP module ahead of its consumer:** Θ-W1-T3 (`filter-dsp-port`) -landed the filter DSP as a standalone pure module a wave before Θ-W2-T1 wired it into the voice -path, for the same reason — the unknown is the DSP, not the wiring. - -**Surface boundary — owns:** `core/instrument/engine/pitch_shift` and whatever new pure module -the stretcher needs (each with its own `_tests` target), plus `voice.{h,cpp}`'s Preserve -read path. **Does not own** any parameter, any UI, the varispeed path, or the deck. It adds no -`ComponentState` field and takes **no rung of the payload ladder**. - -**Behavior and constraints.** The algorithm is **the engineer's call under a -measure-and-report gate — this plan deliberately names none.** The constraints: -- **The stretch ratio is an argument, not a parameter.** Nothing publishes a non-unity ratio - until Γ-W2-T1's Rate knob does. Until then the Preserve read path runs at ratio 1.0 and must - be **bit-identical to the shipped Preserve read** — a stronger and cheaper regression gate - than the old plan's A/B-against-an-interim-path, because the baseline is a build that exists. -- **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 I/O, no lock in `process()`; buffers sized at voice - allocation or at the off-audio-thread reload, on `pitch_shift`'s existing pre-warm - precedent. -- **Per-voice state, holding up at the 32-voice ceiling.** The gate is 32 simultaneous - Preserve voices at 50 % and 200 %, not one voice at 100 %. -- **No new third-party dependency** (`pitch_shift`'s standing property). -- **No dispatch on the per-sample path** — 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.** - -**Acceptance criteria.** -- **Ratio 1.0 with no shift is bit-identical to the shipped Preserve read**, asserted by a - regression baseline — the null case, and the criterion that makes landing this ahead of Rate - safe. -- Preserve speaks on frame 0 — no added onset latency, no first-frame smear, in any - ratio/shift combination. -- No audible metallic or phasey artefacting on sustained tonal material at ±6 st and - 75–133 % ratio; transient material at 50 % / 200 % is no worse smeared than **varispeed - playback at the equivalent ratio** — the honest "what does preserving pitch cost" reference, - and the one that needs **no disposable implementation built to serve the comparison.** -- The Gate sustain-loop contract is unchanged: **loop the source, shift the output** — loop - points remain source-frame facts. -- **Measure and report before the algorithm is final:** per-voice CPU at 32 voices, added - latency (must be zero at the onset), and A/B recordings on three material classes (one-shot, - tonal sustain, full-mix bounce). Report to Daniel; the choice is not final until he has heard - the A/Bs. - -**Open questions.** **[propose, with a measurement step]** the algorithm family itself. -**[verify]** that `core/instrument/CLAUDE.md`'s *"`WDL_Resampler` is not a Preserve engine — -never wire it as the duration-preserving path"* is honoured: under Preserve, Rate legitimately -changes duration, so a resampled read is an explicit duration control — but the -*pitch-preserving* mechanism must not be a resampler. **No [Daniel] questions.** +**Landed** — see `docs/COMPLETED.md` for the full narrative. A real pitch-preserving +time-stretcher for Preserve mode, moved up from a later wave (Daniel, 2026-08-01) so Rate +ships onto a finished engine instead of a disposable stand-in — the interim +resample-and-cancel path that had been planned for Γ-W2-T1 was not built at all. New +header-only pure module `time_stretch` alongside `pitch_shift`'s existing shift-ratio +control; rate 1.0 is exactly one source frame per output frame with no residue, keeping the +unity-ratio Preserve read bit-identical to the pre-stretch engine. No new third-party +dependency, no allocation/lock in `process()`, no per-sample dispatch. --- #### Γ-W1-T7 — `psola-preserve` -**Goal.** Preserve's splices become pitch-synchronous: the source's fundamental period is -detected offline at sample load, cached, and the splice jump becomes a whole number of that -period, so an aligned landing point exists by construction rather than being searched for. +**Landed** — see `docs/COMPLETED.md` for the full narrative, including the corrected closure +status below. Preserve's splices become pitch-synchronous: a new pure module, +`core/instrument/engine/period_detect` (two-pass YIN), estimates the source's fundamental +period once at load; `pitch_shift`'s splice jump becomes the multiple of that period nearest +the fixed window, so an aligned landing point exists by construction. Detection runs off the +audio thread by link graph — `sampler_core` does not link `period_detect` — and an unknown +period restores the fixed-window geometry byte for byte. A period is derived from the audio +at load, so it is cache, not state: no `ComponentState` field, no payload rung. It gates the +Rate control (Γ-W2-T1) on the plan's own stated principle that Rate must not ship before its +Preserve engine. -**Origin — Daniel's ruling, 2026-08-01, after Γ-W1-T5 landed.** T5's measurement pass showed -the shipped fixed 50 ms OLA window with a ±window/4 correlation search could only reach -landings spanning `[¾w, 1¼w]` — a 1.5:1 span that cannot contain a whole number of periods for -low material. A 30 Hz tone (1470 frames at 44.1 kHz) had no phase-aligned landing at all. A -second, distinct failure mode: splices recur every `window/|rate − shift|` frames and fail -when that interval is shorter than the output period. Three cheaper options were offered and -declined: widening `maxLag` to `window/2` (fixes the geometry, not the cadence); sizing the -window from the note's known fundamental at note-on (fixes both, but trusts root-note -tagging); enlarging the window to ~200 ms (fixes both, smears transients — the OLA crossfade -is `window/4`). - -**Why this is a new track and not an amendment to Γ-W1-T5.** It touches the sample-load / -analysis path, which is outside T5's stated surface boundary (`pitch_shift`, the stretcher -module, `voice`'s Preserve read path). - -**Why PSOLA is affordable here.** ReaSampler is a sampler, so the source is fixed and fully -known at load. Detection runs once during the reload that already happens, entirely off the -audio thread — the usual real-time objection to PSOLA does not apply. - -**Surface boundary — owns:** a new pure module `core/instrument/engine/period_detect` -(two-pass YIN, with its own `period_detect_tests` target), `pitch_shift`'s jump geometry, the -load-time hook in `map/sample_map`'s `buildSampleData`, and `voice`'s note-on. Adds no -`ComponentState` field and takes **no rung of the payload ladder** — a detected period is -derived from the audio, so it is cache, not state. - -**Behavior and constraints.** -- **Enforcement is by link graph, not by convention.** The detector runs off the audio thread - because `sampler_core` does not link `period_detect` — no translation unit on the render path - can name `detectPeriod`. An unknown period restores the fixed-window geometry byte for byte. -- **Dependency it discharges:** it gates the Rate control (Γ-W2-T1) on the plan's own stated - principle that Rate must not ship before its Preserve engine. - -**Status — landed, with open findings.** -- The **geometry** failure mode is closed and asserted. -- The **cadence** failure mode is **not** closed. It was re-characterized rather than fixed: - the previously-headlined 7–21 % artifact-energy readings turned out to be ~95 % the - measurement's own spectral leakage, leaving a real excess of 0.23–0.51 %. The track asserted - no-regression there rather than claiming an improvement. - -**Open questions.** -- **Unresolved review findings, not a design fork.** A later review of the follow-up fold - returned unresolved Major findings; remediation has not yet been dispatched. The - findings themselves live in the review, not here. +**Both failure modes this track set out to close are now closed.** The **geometry** failure +mode (no phase-aligned landing existing inside the search window for low material) closed at +the original merge. The **cadence** failure mode — splices recurring faster than the output +period — was left open at that point, with unresolved review findings from a later review of +a follow-up fold; three remediation commits have since landed and a re-review confirmed the +earlier findings closed. The closing measurement (one-machine, Debug-build) is recorded in +`docs/COMPLETED.md`, not restated here. --- @@ -4354,13 +3848,17 @@ Phase Ξ — The resample loop (W1 concurrency-safe with Θ from Θ-W T1 capture-signal-popup .................... 15 (popup abandoned; window derives) Phase Γ — The instrument's control surface (none of the seventeen; ends with VST3 params) - W1 Foundations [5 tracks, disjoint by surface] + W1 Foundations — landed [seven tracks, disjoint by surface] T1 knob-interaction-law ....... modifiers + ONE taper module + reset bypass + 10 s ceiling + AHDSR schematic axis [Ruling 2] T2 master-bus-audio ........... limiter + meter ballistics + dynamic PDC [rung 1] T3 contour-trace-curves ....... staged traces draw curved, knot on its trace T4 editor-floor-and-row-law ... floor 1190x680 + budget constants + row predicate T5 preserve-time-stretch ...... real stretcher [measure-and-report gate] + T6 exhaustive-switch gate on pure libraries ... /we4062, -Werror=switch on + pure libraries [no PLAN entry — see COMPLETED.md] + T7 psola-preserve ............. PSOLA-aligned splice jump; period_detect + [cadence closure — see COMPLETED.md] W2 New controls, and the overlay's marks [2 tracks] T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [rung 2] T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard