Files
reasampler/docs/PLAN.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

95 KiB
Raw Blame History

PLAN

The post-1.0 roadmap. Seventeen queued items consolidated into overlapping areas and sequenced into a Phase → Wave → Track hierarchy that implementation specialists can be dispatched against directly — plus Phase Γ, which did not come from those seventeen (it came from a direct interview, 2026-08-01) and is scoped in docs/product/instrument-control-surface.md.

What this doc is, and how it relates to the others

  • docs/PLAN.md (this file) — the active on-deck specification list. Each track is written so a specialist brief is writable from this file alone: goal, consolidated source items, surface boundary, behavior, acceptance criteria, open questions, prerequisites.
  • docs/TODO-1.0.md — retained as the verbatim-provenance appendix. It holds Daniel's raw asks and every answer round, unedited; where this plan compresses a behavior bullet, that file is the backing record. Items are cited here by number (e.g. "consolidates items 1, 8, 14"). It is not a work queue any more; this file is.
  • docs/TODO.md — deferred follow-ups with recorded rationale, unrelated to the seventeen (with one flagged intersection: see "Flagged for awareness" below).
  • docs/COMPLETED.md / docs/ARCHIVE.md — doc-keeper's. When a track here finishes, its point is removed from this file and appended to COMPLETED.md with any deviation between landed code and spec noted.
  • docs/product/ — the product-design reasoning behind prior phases. Grep for a cited section rather than reading a file whole.

Worktree slug convention: p<phase>-w<wave>-t<track>-<slug>. Greek phase letters transliterate: Θ → th, Ξ → xi, Γ → g. So Θ-W1-T1 dispatches into pth-w1-t1-zone-retirement and Γ-W1-T1 into pg-w1-t1-knob-interaction-law.

Decision state

Everything carried forward from TODO-1.0.md is classified [verify] (answerable by reading code or running the DAW) or [propose] (a design call made at implementation review with a proposal, not a Daniel call); that classification is preserved per question, attached to the track that will answer it.

Θ-W3-T1's two genuine [Daniel] questions — which no amount of code-reading could answer — are both ruled on and the track has landed; see docs/COMPLETED.md for the full narrative. Reload tier = Grouping B (continuous knobs live: filter cutoff/Q/ morph/drive/mod amount/key-track, every envelope stage time and level; root note, loop span, and start frame still trigger a full reload). Mid-stage rule = candidate (iv), hold normalized stage position (φ = elapsed/duration held fixed across a duration change, then advancing at 1/newDuration). Phase Γ opened five [Daniel]-class forks (Γ-F1…Γ-F5) and all five are ruled (Daniel, 2026-08-01) — the rulings are folded into the tracks below and indexed in docs/product/instrument-control-surface.md §8.1. One new fork, Γ-F6, is open: it was surfaced by the Γ-F2 ruling out of the vendored VST3 SDK, and Daniel has not seen it. It sits on Γ-W1-T2, is not blocking — it is gated on a DAW measurement that track performs first — and it has a pre-agreed fallback. No other track in this plan carries an unanswered [Daniel]-class question.

Flagged for awareness — not blocking, but decision-grade

  1. Item 15 has an unresolved cross-artifact seam, and it is the phase's largest unknown. The instrument is a read-only bank consumer by invariant (src/shell/instrument/CLAUDE.md), and the one previous attempt at an instrument→extension relay (S13) closed with a DEGRADED spike verdict and was deferred (docs/TODO.md). Resample requires that crossing. Ξ-W2-T1 opens with the architecture decision and this plan names the candidates rather than assuming the deferred relay shape; see that track. Consequence to hold: if every candidate fails verification, item 15's "one click from inside the VST" framing is what gives, not the read-only invariant — the fallback is a bindable extension-side action.

  2. Phase Γ must land before Ξ-W2, and this is a correctness point, not a preference. Ξ-W2's settled reset scope enumerates parameters by name; Γ adds rate, pitch offset and the limiter flag, so shipping the bake first means its reset list is incomplete on the day it lands. Γ's product doc pre-classifies all three against the ratified rule (all reset), so this costs no Daniel decision — only ordering. Second, weaker reason: Γ owns params-payload v14 and v15, and Ξ-W3's programmed-signal persistence will want the next rung; two phases contending for the ladder is the fight Θ's organizing constraint exists to avoid.

  3. The taper work inside Γ-W1-T1 is a one-way door with respect to automation. Once VST3 parameters exist, the taper is the host-facing normalization, and re-tapering re-interprets every recorded automation point in project files we do not own and cannot migrate. Re-tapering is free today and permanently expensive afterwards. See docs/product/parameter-automation.md §4 — that doc is scoping only, nothing in it is scheduled here. The same door applies to the stage-time ceiling, which Γ-F3 left at 2.0 s with a 10 s ambition recorded in docs/TODO.md: if that ceiling is ever raised, it wants to happen before the parameter system, not after.

Phase-wide acceptance criteria

These bind every track in all three phases and are stated once here rather than repeated per track. Phase Γ adds five of its own, stated in its phase header.

Structural (root CLAUDE.md, Daniel 2026-07-28)

  • More directories is a must; more files is good; ~600-line file ceiling. The ceiling is the bar; a responsibility seam is the method. Bisection-to-hit-the- number is rejected. sampler_core.cpp (956 lines) is the standing documented hot-path exception — Θ-W1-T1 re-seams it, and any surviving over-ceiling TU must carry the same explicit justification.
  • Templates where earned — compile-time dedup with zero runtime cost, off the hot paths. Not for types that differ in name only.
  • SOLID is great, but saved CPU is better. No dispatch-stack blowouts anywhere; prefer static polymorphism where the types are compile-time-known.

Performance guardrails

Root CLAUDE.md's five extension-side guardrails (peaks envelope compute, audition, realtime-capture tick, JSON, FxBypassGuard) are unchanged by this plan; no track here touches them. The instrument adds a sixth surface that binds every Θ track:

  • process() — the per-voice-per-sample path — takes no new indirection. The filter tick, the envelope evaluation (staged and spline), the pitch-shift read, and the loop read all sit on it. Concrete, inlineable types only: no IEnvelope, no IFilter, no virtual per-voice tick(). A filter with two modes is a branch-predictable switch or a compile-time-known dispatch, never a vtable. Spline evaluation is a binary search over a point array, not a polymorphic curve object.
  • No allocation, no file I/O, no bridge call in process(). The off-audio-thread reloadInstrument + atomic pointer swap stays the only way new state reaches the audio thread. Every new parameter this plan adds follows that path.
  • A split that would add a hot-path indirection is out of scope — rework it or drop it.

Product invariants

  • Capture and placement are separate acts. No track here may place a timeline item. Item 15's bake explicitly must not, and one of its candidate architectures uses a temporary arrange mutation — that candidate must leave the arrange byte-identical.
  • Prune is the single, exclusive file-deletion authority. Item 15's "replace" never deletes bytes; item 17's consolidation may not weaken any protection prune has today.
  • The instrument never writes the bank. Item 15 is the first feature that needs to, and it resolves that by asking the extension, not by breaching the invariant.
  • Migration bar: a project saved before a change reopens sounding identical. Holds everywhere except item 16's genuinely-multi-zone case, where Daniel deliberately relaxed it.
  • Every pure module gets a <module>_tests target that runs without REAPER or a DAW. New pure modules in this plan are not optional-test.

Phase Θ — ReaSampler 9000: one parameter set, a filter, shapeable envelopes, a legible editor

Ships: the instrument with the zone system retired, a resonant HP/LP filter stage in the voice path, curve-shapeable and spline-drawable envelopes on all three EGs, Gate-mode loop sustain, and a re-laid, high-DPI-clean editor — plus the two extension drag/drop defects that block getting captures into it.

Consolidates items 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16.

All seven waves have landed — Phase Θ is complete. W1 through W7 each carry their own landed note above; see docs/COMPLETED.md for every track's full narrative. Θ-W7 was opened after Θ-W6-T1 shipped, to fix two rendering defects Daniel found by eye; it did not exist in the plan when this phase was originally scoped.

The organizing constraint. Three surfaces in the instrument are single-writer by nature and dictate the wave shape:

  1. The parameter model (zone_params.h + component_state_io) — every parameter addition touches both. Two tracks adding parameters concurrently is a merge fight and two competing ComponentState version bumps.
  2. The voice render path (sampler_core.cpp) — filter insertion, envelope evaluation, loop read, and the Trigger tail all live there.
  3. The Sample face (editor_paint_sample.cpp 516 lines / editor_input_sample.cpp 583 lines) — every UI item repaints it, and both are already at the ceiling.

Parallelism in this phase therefore comes from splitting the Sample face into bands (done once, in Θ-W1-T1) and from extension-side work being genuinely disjoint — not from running two parameter-model tracks at once. Where a wave has one track, the collision is real and the serialization is the correct answer.


Θ-W1 — Collapse and re-seam

All three tracks have landed — Θ-W1-T1 (zone-retirement), Θ-W1-T2 (capture-handoff-bugs), and Θ-W1-T3 (filter-dsp-port) — see docs/COMPLETED.md for the full narrative of each. Between them: the zone subsystem is retired, the wave's two responsibility seams (the sampler_core split, the Sample-face band split) are in place, the two extension-side capture-handoff defects are fixed, and the filter DSP has landed as a standalone pure module — Θ-W2-T1 has since wired it into the voice path (see below).


Θ-W2 — Filter in the voice path; waveform and chrome bands

Depends on W1 for: the one-parameter-set model and the sampler_core seam that T1 writes the filter into; the filter module T1 wires up; the Sample-face band split and band-stack allocator that T2 and T3 fill; and W1-T1's key-range answer, which decides what T3's piano strip displays. Authoring any of this against the per-zone model means writing storage plumbing W1 deletes.

All three tracks have landed — Θ-W2-T1 (filter-voice-path), Θ-W2-T2 (stereo-waveform-lanes), and Θ-W2-T3 (toolbar-and-piano-strip) — see docs/COMPLETED.md for the full narrative of each. Between them: the filter sits in the per-voice signal path with its own deck, the waveform band shows both channels in stereo mode behind a type-enforced full-height overlay contract, and the chrome band's toolbar and piano strip are cleaned up per spec. The three tracks were disjoint by band — T1 owned the parameter model and the deck band, T2 owned the waveform band, T3 the chrome band — and none re-allocated the band stack.


Θ-W3 — Live parameters, then the staged envelope system

Depends on W2 for: the filter envelope's existence — items 1, 8, and 14 govern three envelopes, and the filter is the third; and for the Filter deck, which must exist before it can receive a corner radio switch and inner curve dials. T1 additionally depends on W2-T1 for the filter itself: the filter is the first control set where the latched-at-note-on delivery model fails audibly, and it is what made the defect visible.

Both tracks have landed — Θ-W3-T1 (live-parameter-delivery) and Θ-W3-T2 (staged-envelope-curves) — see docs/COMPLETED.md for the full narrative of each. Between them: every continuous playback control (filter cutoff/Q/morph/drive/mod amount/key-track, every stage time and level on all three envelopes) now reaches a sounding voice live instead of latching at note-on, and the envelope-overlay editor grew from an amp-only fixture into the shared graphical surface for all three envelopes — a corner radio switch per deck (none active by default), curve-shapeable segments on every sloped stage (0.110 exponent, an inner dial paired with an overlay knot), the release-right-anchored AHDSR layout against the pitch envelope's 1:1 AHD, and the Trigger amp/filter fade pair folded into a Trigger AHD, consolidating what were two staged-shape mechanisms into one. The Trigger × Preserve end-of-sample click is fixed; the landed fix is wider than scoped, also ringing out Gate × Preserve × source-exhaustion, previously a hard cut. The ordering was deliberately serial — T1's live-delivery mechanism landed first so T2's new curve exponents and Trigger AHD fields were authored directly into it rather than backfilled afterward.


Θ-W4 — Loop sustain and the velocity deck

Depends on W3 for: the envelope system both tracks compose with — T1's loop-sustain is the Gate-mode sustain the AHDSR releases out of, and T2's bipolar pitch/filter curves modulate targets whose envelopes W3 just reshaped. T2 additionally depends on W2-T1 for the filter's existence and on W2-T3 for the preview button's toolbar position.

Both tracks have landed — Θ-W4-T1 (gate-loop-sustain) and Θ-W4-T2 (velocity-deck-and-bipolar-curves) — see docs/COMPLETED.md for the full narrative of each. Between them: loop points are now a usable feature, with a Gate-mode loop acting as the sustain and a parameterized crossfade at the seam; and the three velocity-curve popups (amp, pitch, filter) now live together in a new VELOCITY deck group, with the pitch and filter curves bipolar and flat-by-default so their modulation is off until drawn, while the amp curve stays unipolar and unchanged, and the preview button's text is replaced by a drawn play-triangle glyph. Params payload reached v11 with T1's loop block and v12 with T2's velocity→pitch curve appended after it.


Θ-W5 — Spline EGs

Depends on W4 for: the bipolar velocity-curve domain (W4-T2) — the spline algorithm is singly implemented and multi-referenced, so its enhancement must land against the final consumer set, and the last consumer to change domain is the pitch/filter velocity curve; and for the Gate-mode loop (W4-T1), since "Gate is unavailable in Spline mode" is only a real, testable rule once Gate has something to be unavailable for. It also depends on W3's radio switch, which is how a spline contour reaches the overlay at all.

Θ-W5-T1 has landedspline-egs — see docs/COMPLETED.md for the full narrative. It shipped a free-drawn alternative to every staged envelope: pitch, filter, and amp EGs can switch Staged → Spline and have their contour drawn directly on the waveform overlay. The one shared monotone-spline implementation gained hard points — sharp corners, no smoothing on either adjacent segment — flowing to every consumer including the existing velocity→amp transfer curve, no fork. Both Staged and Spline state persist simultaneously (saved-but-inactive, lossless round-trip); params payload reached v13, and v12 projects still load. Gate is unavailable while a Spline EG is active; the contour is a pure time function over the full sample length, normalized and drawn 1:1 with the sample's time axis. Point grammar converged on left-click add / right-click delete / control-click hard-smooth toggle, one grammar across both spline consumers. Staged segment knobs and their inner curve dials render disabled and reject edits while Spline is active. Point-count ceiling: 128, a musical bound rather than a performance one. A follow-on change in the same track reworked deck cell width: -1 in cellIds now means one cell's width, reserved and redistributed, rather than a blank cell holding geometry — a Trigger face that drops Sustain and Release gets wider cells instead of dead slots; group widths, row packing, deck height, and Gate-mode cell widths are unchanged.


Θ-W6 — Editor legibility pass

Depends on W5 for: the last change to a drawn surface. Item 13 is an audit whose output is a disposition list over "every class of drawn surface," and item 10's sizing pass is judged by eye over the finished layout — running either while the spline contour, the disabled-knob state, or the deck inventory was still moving would have meant auditing and then re-auditing. Θ-W5-T1 has landed (see docs/COMPLETED.md), so that surface has stopped moving and this dependency is satisfied. The source doc names this sequencing as an observation (item 13 after the layout/knob work); this plan adopts it as the boundary.

One track. Both items repaint essentially every surface in the editor; concurrent tracks would collide everywhere.

Θ-W6-T1 has landedlegibility-and-antialiasing — see docs/COMPLETED.md for the full narrative. It shipped both halves together: knobs grew 28→40 px (inner curve dial 14→20), the deck cell 48×58→60×74, and the label band 12→16 px, now drawn in Font::Label rather than Font::Micro — group captions and toggle segments deliberately stayed Font::Micro, since bumping them would outgrow row 1's headroom at the floor width. The editor's default/minimum size grew 840×620→980×680 to fit the wider deck at floor width; an existing saved instance's window grows on open, and the floor is validated by a derived test rather than literals. All fourteen time-constant labels now read in ms (display-only; internal representation untouched) — holdFraction knobs, Len %, and the bank panel's duration readout stayed out of scope. Double-click resets each ring independently — outer ring resets the value, inner dial resets the exponent to 1.0 — reaching the chrome's preview-velocity knob too via a shared inKnobFace rule.

The antialiasing audit fixed knob arcs and needle, the inner dial arc and needle, staged and spline envelope slopes, the spline contour, the velocity-popup trace, the waveform outline, and the preview triangle; node handles, curve knots, knob discs, buttons, piano keys, loop markers, borders, gradients, and text were already clean. The disposition table is a standing artifact in docs/product/visual-design-language.md §8. The piano-key open question is answered: not aliasing — every key is an axis-aligned LICE_FillRect, so the earlier width defect was integer-division residue in the tiling, not a sloped edge. High-DPI host scaling itself is unverified (deferred, see docs/TODO.md).

All visual outcomes remain pending Daniel's by-eye sign-off on dev.


Θ-W7 — Arc-and-spline antialiasing fix

Depends on W6 for: a drawn surface to find a defect on — this wave did not exist in the plan; it was opened after Daniel found two rendering defects by eye once Θ-W6-T1 shipped, so the audit's own output is what surfaced them.

One track.

Θ-W7-T1 has landedarc-and-spline-aa — see docs/COMPLETED.md for the full narrative. The stacked-LICE_Arc knob and dial rings never reached an opaque core (peak alpha measured 138/255), and the staged/spline envelope and velocity-curve traces were fully aliased rather than gapped, from integer cy quantizing the slope. Both defects, plus the two needles, now route through one pure analytic thick-stroke rasterizer — coverage in a new core/ui/stroke_aa, the single LICE blend in a new shell/instrument/editor_stroke — replacing LICE_Arc and LICE_ThickFLine outright. Measured: arc peak alpha 138/255 → 255/255, arc perpendicular-weight ripple 67% → 5%, spline weight ripple 29% → 3%, at a cost of +0.09 ms per full editor repaint. Daniel then ruled every sub-2 px stroker width up to 2 px, since the stroker only guarantees an opaque core at width ≥ 2 px; the knob track arc, the inner-dial needle, and the deck's mini velocity trace each moved 1.0 → 2.0 px. docs/product/visual-design-language.md §8 is corrected — a false "keeps every ring antialiased" claim is deleted, the rows are re-dispositioned with measurements, and the audit's methodological lesson (verifying which primitive was called is not verifying what it rasterized) is recorded as a standing blockquote. All visual outcomes remain pending Daniel's by-eye sign-off on dev.


Phase Ξ — The resample loop

Ships: one consolidated, fully robust provenance/usage tracking system, and on top of it the one-click in-sampler resample — dial → bake → dial again, with the bank as the medium each iteration passes through.

Consolidates items 15, 17.

Why these two and not more. Item 17 is a prerequisite of meaning for item 15's bank-side half: the replace-vs-add rule is "does provenance-tied usage exist," which denotes nothing until the consolidated lineage records exist. Item 17 is otherwise independent of the editor chain — which is what makes Ξ-W1 concurrency-safe with Θ.

Ξ-W2 onward requires Phase Θ complete. Item 15 presupposes the processing surface it bakes — "filtering, pitching, amp all set up nice" is the instrument items 1, 2, 3, and 14 build — and its settled reset scope enumerates the filter parameters, the spline contours, and the loop points by name. Baking a processing chain that does not exist yet is not a schedule preference; the feature is not expressible. Phase Θ landed as of Θ-W7-T1 (see docs/COMPLETED.md); this gate is satisfied.


Ξ-W1 — Consolidated tracking, and the programmed-note model

Ξ-W1-T1 — tracking-consolidation

Landed — see docs/COMPLETED.md for the full narrative. The provenance/usage territory is now one system: a new src/core/tracking/ directory holds origin_ledger (the record family — OriginRecord/OriginKind, the insertion-ordered OriginLedger, its JSON codec, and the Fresh/Loaded/Unreadable/FutureVersion load classification) and tracking_authority (the one decision surface: pruneProtection and tiedUsageExists), retiring core/model/owned_manifest. Both prune's protected set and the resample's replace-vs-add decision are computed from one borrowed TrackingState, so the two safety-critical consumers cannot drift apart. sample_usage deliberately stays in core/wire — the consolidation is of the decisions, not the codecs. The deferred persisted-instance-identity fix was not folded in — the deferral is restated in docs/TODO.md, its one home. This track landed OriginRecord's birth-time parentSampleId chain as the lineage mechanism, but Ξ-W2-T1's "naming and lineage" open question (jointly held with this track) is unaffected and still theirs to close — display naming and user-readable iteration lineage were not decided here.

Ξ-W1-T2 — note-program-model

Landed — see docs/COMPLETED.md for the full narrative. The programmed-capture- signal model is a new pure module directory, src/core/instrument/note/ — a fourth peer of engine//map//ui/ under core/instrument/ — holding musical_division, tempo, and note_program (Velocity, the denominated OffsetAmount, the anchored StartOffset/EndOffset, NoteProgram, resolveNote). Both open questions below are answered, for Ξ-W3-T1: negative offsets are legal in both directions (sign uniform, positive is later in time; only an inverted window is refused, reported via ResolvedNote::windowCollapsed), and the denomination seam is confirmed — note length stays musical-division-only, and an offset stores the denomination it was entered in.


Ξ-W2 — The bake chain

Depends on Ξ-W1 for: the consolidated lineage records that make replace-vs-add computable (T1) and the programmed-note record the offline pass renders (T2). Also depends on all of Phase Θ — see the phase note above.

One track. The bake is one gesture and one chain; the architecture decision at its head governs every step after it.

Ξ-W2-T1 — resample-bake-chain

Goal. One click bakes the dialed sound into a bank capture, re-points the instance at it, and hands the instrument back at neutral — the dial → bake → dial-again loop, run without leaving the sampler.

Consolidates item 15 (the chain; the popup UI is Ξ-W3-T1).

Surface boundary — owns: whichever seam the architecture decision selects (see below), plus shell/capture/capture_orchestrator (a resample entry point alongside captureAndIndexOne), core/model/bank_model + owned_manifest at the add boundary, shell/actions (a new action, if the decision needs one), and the instrument-side bake trigger. Does not own the capture-signal popup's UI — that is Ξ-W3-T1.

The architecture decision — this track's first deliverable, before any chain work

Item 15 requires a click inside the VST3 editor to cause a bank write, and the instrument is a read-only bank consumer by invariant. The one prior attempt at this crossing (S13) closed DEGRADED and was deferred. Resolve this before building the chain. Two orthogonal choices:

Decision 1 — how the click crosses to the extension.

  • (1a) Ext-state request key + extension-side timer poller, with a claim/clear nonce — the shape S13 spiked and deferred. It needs a new instrument WRITE seam into ext-state, though the rsusage_-prefixed guarded write already establishes the precedent that such a seam can exist without weakening the read-only-bank invariant. Known cost: the cross-process handshake race the S17 spec rejected.
  • (1b) Direct action invocation over the VST-host bridge — the lead candidate. A REAPER-hosted VST3 already resolves REAPER API functions by name over the host callback (that is how the instrument reads GetProjExtState), and can fetch its own host context — the track/take/project it was instantiated in. If NamedCommandLookup and Main_OnCommand resolve the same way, the editor can invoke the extension's bake action synchronously, with no request key, no poller, and no nonce — which dissolves the S13 problem for this case rather than re-litigating it. [verify] against vendor/reaper-sdk/sdk/ (reaper_plugin.h, video_processor.h, reaper_plugin_functions.h) that both resolve over the callback, and that calling them from the editor's UI thread is safe. This is a verification task, not an assertion — do not build on it until confirmed.
  • (1c) No crossing — a bindable extension-side action only. Fallback. It abandons Daniel's "from directly inside the ReaSampler 9000" framing, so it is the answer only if (1a) and (1b) both fail. If it is taken, say so explicitly rather than quietly shipping a panel button.

Decision 2 — what actually renders the audio.

  • (2a) Headless voice-engine render, extension-side. The extension reads the instance's component state, reconstructs the engine from the shared pure core, renders the programmed note, and banks the result through the normal capture add-path. Exact and fully deterministic. Trade-off: the extension currently links component_state_io but deliberately not sampler_core/pitch_shiftcomponent_state_io was split out precisely to avoid pulling the voice engine into the extension. This reverses that.
  • (2b) REAPER offline render of the hosting track, with the programmed note. Reuses OfflineRenderBackend and with it the whole precision-invariant apparatus — exact bounds, bit-identical repeats, relative paths, tail control — for free. Trade-offs: it needs a temporary arrange mutation to program the note (the realtime backend's temp-track-created-and-removed-cleanly is the precedent), and the existing scope model has no "this one FX only" scope, so post-instrument track FX would be baked in unless a new bypass scope is added. The arrange must be byte-identical afterward.
  • (2c) The instrument renders, the extension banks it. The instrument produces the audio (it already owns the engine) into a temp file or shared buffer; the extension moves it into the bank and indexes it. Splits the work along the existing ownership line — but adds a handoff artifact and its cleanup.

Recommended sequence for the decision: verify (1b) first — if it holds, the crossing is nearly free. Then pick between (2a) and (2b) on the strength of the "only-the-instrument, not the track chain" requirement: Daniel's ask says "send a trigger or gate through the sampler offline," which favours (2a) or (2c) unless (2b) gains a single-FX bypass scope. Record the verdict in this track's review, and if the verdict is DEGRADED, escalate rather than improvise — the fallback is (1c) + (2a).

Behavior — the chain.

  • One gesture, whole chain. A single click performs: offline pass → capture → bank update (replace or add-distinct) → instance re-point → parameter reset. One action from the user's side, not a wizard.
  • Offline pass through the instrument's own processing. The audio is produced by sending a trigger or gate through the sampler offline — the instrument's own voice path, with filtering, pitching, and amp exactly as dialed. The recapture is of that processed output, not of the raw source.
  • The recapture is a bank capture like any other — project-relative, indexed, browsable from any surface that browses the bank, recorded in the owned-file manifest, and governed by the same safety rules as every file the system creates.
  • The note is the capture's root. The rendered note is the capture's root note — which is exactly why the root-note parameter is not reset by resampling: capturing at root is what makes root survivable, and resetting it would detune every subsequent iteration.
  • Gate's hold and tail come from the programmed window. The programmed note length is the Gate hold bound — the gate holds for the note length, then releases; a Gate loop cycles within the held span and the render still terminates. The end offset is the home of the tail policy: captured time past the note's end is where the release rings.
  • Replace, or add distinct. When nothing else references the source capture, the recapture replaces it as the bank entry; when other references exist, the original entry stays and the recapture is added as a new distinct capture. "Other reference" means any usage of the source capture tied to it by the provenance/recapture system — Ξ-W1-T1's records. Read plainly: the reference universe is the resample system's own lineage records, not the prune-protection universe. Bank multi-membership, items placed in the arrange, and a plain hold by another instance outside any recapture lineage do not force add-distinct, and need not for safety — the superseded file survives until prune, and prune's protection universe is unchanged and broader.
  • Replacement never destroys audio bytes. "Replace" means the bank entry now denotes the recapture; the superseded file is not deleted. Prune remains the system's only file-deletion authority — resample writes a new file and retires the old one to reclaimable-by-prune status; it never overwrites or deletes it. Until a prune reclaims it, the pre-bake audio survives on disk — the iterate loop's built-in recovery floor.
  • The instance re-points at the new/corrected bank capture and plays the baked sound.
  • Parameters reinitialize to default — destructive to the dialed settings by design: the processing now lives in the recaptured audio, and neutral controls are the starting point for the next iteration.
  • Reset scope: only what the bake baked in. Ratified by Daniel, not derived:
    • Reset (their effect is in the audio): the envelope contours — staged and spline alike — the filter parameters, master gain, the pitch envelope/engine settings, the velocity transfer curves (their effect at the programmed velocity is in the audio), and the loop points (they shaped the render, and old positions are meaningless against new audio).
    • Survive (mapping facts, not present in the audio): the root note, whatever remains of key mapping (key-tracking; any key-range concept Θ-W1-T1 settled), and the VOICE group (polyphony behavior leaves no trace in a single rendered note).
  • No timeline item, ever. Resampling is a capture act: it writes a file to the bank and updates the index; nothing is placed in the arrange view. Any framing that auto-inserts the recapture is invalid — and if architecture (2b) is chosen, its temporary programming item must leave the arrange byte-identical.
  • Undo/recovery: a plus, not a requirement. Welcome if it falls out cheaply; the feature ships without it. The guaranteed recovery path is the superseded file surviving on disk until a prune reclaims it.

Acceptance criteria.

  • On a dialed-in instrument, one click yields all of: a recapture in the bank, the instance holding that recapture, and the baked-in audio parameters at their defaults — with the root note and the other surviving mapping parameters untouched.
  • The bake is audible and faithful: after the click, playing the programmed capture note (root, at the programmed length, offsets, and velocity, in the active mode) through the now-neutral controls sounds as the dialed instrument sounded just before the click — the processing has moved from the controls into the audio.
  • A Gate-mode bake terminates on its own: the gate holds for the programmed note length, then releases — even with loop-sustain active, the render ends. No indefinite capture.
  • After the bake the root note is unchanged — iteration never detunes; the next bake plays the same root.
  • Sole-reference case: the bank afterwards shows the recapture where the source capture's entry was; no other bank entry is disturbed. Other-references case (provenance-tied usage of the original exists, computed by Ξ-W1-T1): the original entry is untouched, a distinct new entry appears, and every other holder of the original sounds exactly as before.
  • The click deletes no file: the superseded audio file still exists on disk afterwards, and only a later prune — under the settled orphan rules, only when nothing references it — can reclaim it.
  • The arrange timeline is untouched: no item appears anywhere, on any track, and if a temporary programming item was used it is gone and the arrange is byte-identical.
  • Iteration composes: dial → click → dial → click bakes the second pass onto the first's result, repeatable indefinitely.
  • Save/reload: an instance holding a recapture reloads and plays it exactly like any other loaded capture.
  • The bake adds nothing to process() — the offline pass runs off the audio thread.

Open questions.

  • The architecture decision [propose, with a verification step]. See above. The (1b) bridge verification is [verify]; the choice among the candidates is [propose].
  • Extension presence [propose]. The instrument plays self-contained with the extension absent, but the bank is the extension's surface and resampling mutates the bank. The natural answer is that resample requires the extension present and is cleanly unavailable — not silently lossy — without it; propose the exact behavior at review.
  • Reset-scope edge cases [verify]. The rule is settled and the per-parameter classification is ratified. Only a genuinely new parameter — one arriving with a Θ track and absent from the ratified lists — is classified against the rule and surfaced at review. Not a new Daniel call. (Θ adds: curve exponents → reset; spline contours → reset, already named; the filter's velocity/key-tracking mod → reset with the filter; loop crossfade → reset with the loop points; the Staged/Spline mode flag → classify.)
  • Naming and lineage [propose, jointly with Ξ-W1-T1's lineage-record question]. When add-distinct fires, the new capture needs a display name (derived from the original?), and the bank some way to read iteration lineage across repeated bakes. One proposal, both tracks.
  • Provenance of the recapture — homed in item 17, i.e. Ξ-W1-T1. A resample's recipe is the instrument's own settings, not a track's chain. Nothing to decide here beyond consuming that answer.

Ξ-W3 — The capture-signal popup

Depends on Ξ-W2 for: the bake chain that the popup programs and its preview must not diverge from. The acceptance criterion "preview and bake cannot diverge" requires one render path, which W2 owns — building the preview against a second path is exactly the defect the criterion exists to catch.

One track.

Ξ-W3-T1 — capture-signal-popup

Goal. Let the user program the capture signal — note length, offsets, velocity — and hear it before committing the bake.

Consolidates item 15 (the popup sub-feature).

Surface boundary — owns: core/instrument/ui/curve_popup's sibling — a new pure popup geometry module for the capture-signal sheet (mirror the curve_popup precedent: centered sheet, width/height clamps, title row, Close button rect, outside-sheet dismissal test) — plus the editor's popup paint/input wiring in the deck or chrome band, and the preview trigger's call into W2's render path. Reads Ξ-W1-T2's note-program record; does not re-own it.

Behavior.

  • A popup menu programs the capture signal: note length, start and end offsets — in ms AND in beats — and velocity.
  • A preview trigger button auditions the capture note exactly as currently programmed — the user hears the bake before committing it — and the offline pass renders that same programmed performance.
  • Note length is a musical-division picker spanning 1/64th to 64/1 with dotted and triplet multipliers.
  • Offsets are anchored — start to note-on, end to note-off — and each is readable and editable in both ms and beats, the two views of one stored value.
  • Beat-denominated values resolve against the project tempo under the cursor. The shell reads the tempo; the arithmetic is Ξ-W1-T2's.
  • The programmed velocity is the render velocity — material because the velocity transfer curves modulate amp, pitch, and filter at that velocity.
  • House-consistent: drawn through the shared kit by palette role, geometry pure and CTest-covered, no decorative animation.

Acceptance criteria.

  • The popup exposes: note length as a musical-division picker spanning 1/64th to 64/1 with dotted and triplet multipliers; start and end offsets, each readable and editable in both ms and beats, anchored to note-on and note-off respectively; and velocity.
  • Its preview trigger auditions the capture note exactly as programmed, and the bake renders that same programmed performance — preview and bake cannot diverge. Verify structurally (one render path), not just by ear.
  • Beat-denominated values resolve against the project tempo under the cursor: the same programmed division yields a correspondingly different rendered duration when the tempo at the cursor differs.
  • The programmed signal persists with the instance and round-trips save/reload.
  • The popup's geometry and dismissal test are pure and unit-tested; no hit-test math lands in shell code.

Open questions. Both of Ξ-W1-T2's residuals (negative offsets; the note-length denomination seam) surface here as UI consequences — if T2 answered them, this track implements the answer; if T2 deferred either, this track is where it becomes visible and must be closed.


Phase Γ — The instrument's control surface

Ships: the deck reflowed into two categorical rows with a double-height MASTER bus deck, a PITCH/RATE deck with playback-rate and baseline-pitch controls, a master limiter with dynamic reported latency and a real output meter, one consistent knob interaction/taper law across every variable control, a fix for staged contour traces drawing straight, and a re-approached loop/crossfade marker UX under an explicit chrome-row loop enable.

Consolidates: none of the seventeen. Phase Γ came from a direct interview with Daniel (2026-08-01); the product reasoning, the measured layout table, the invariant collisions and the fork rulings are in docs/product/instrument-control-surface.md. Read §1.2 (the layout table) and §7 (collisions) before dispatching any track here — every number in this phase is derived there, and docs/TODO.md's old deck-rework geometry is superseded.

Fork state. The phase's five original forks are all ruled (Daniel, 2026-08-01) — indexed at spec §8.1, folded into the tracks below:

  • Γ-F1kEditorMinHeight stays 680.
  • Γ-F2 — the limiter has lookahead with DYNAMIC reported latency (zero when off, the lookahead when on, reported to host PDC). This inverted the product recommendation; W1-T2's scope grows accordingly — spec §3.1.1.
  • Γ-F3 — the stage-time ceiling stays 2.0 s in this phase. The 10 s ambition is carried in docs/TODO.md with its rationale and its prerequisites.
  • Γ-F4 — there is an explicit loop enable, and it lives on the chrome row, not in a deck. W2-T2's scope grows accordingly — spec §6.4.
  • Γ-F5 — MASTER's reserved slot is one cell. The 90 px headroom argument behind that is spec §1.6 and governs every future control addition.

One fork remains open — Γ-F6, on W1-T2 (spec §8.2): the VST3 SDK defines restartComponent(kLatencyChanged) as a host deactivate/reactivate, and this plugin's setActive is destructive in both directions. Not blocking; gated on a DAW measurement W1-T2 performs first, with constant reported latency as the pre-agreed fallback.

Sequencing against Phase Ξ — Γ runs BEFORE Ξ-W2. Two reasons, both the same shape as Ξ's own stated gate:

  1. The bake bakes the control surface. Ξ-W2's settled reset scope enumerates parameters by name; Γ adds rate, pitch offset and the limiter flag. Shipping Ξ-W2 first means its reset list is incomplete on the day it lands. (Γ's doc §3.4 pre-classifies all three against the ratified rule — all reset — so this is a sequencing point, not a new Daniel question.)
  2. One params-payload ladder. Γ takes v14 and v15. Ξ-W2 does not currently bump the payload, but Ξ-W3's programmed-signal persistence will, and two phases contending for the ladder is exactly the fight Θ's organizing constraint calls out.

The organizing constraint. Four surfaces are single-writer and dictate the wave shape: ui/deck_values.cpp (the taper law, then the two new controls), editor_paint_waveform.cpp (the contour trace, then the loop marks), the deck descriptors in ui/deck_groups.cpp + ui/knob_deck.cpp (the new group, then the reflow), and the params-payload ladder. Every wave boundary below is one of those collisions, not a preference. Where a wave has more than one track, the tracks are disjoint by surface.

Phase-wide acceptance criteria (in addition to the ones stated at the top of this file):

  • Bypassed means byte-identical. With the limiter off, the per-sample output path is 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.
  • No ComponentState sound change. A project saved before this phase reopens sounding identical: absent rate lifts to 100 %, absent pitch offset to 0 st, absent limiter flag to bypassed. Re-tapering a knob (Γ-W1-T1) changes needle angles only — the payload stores raw engine doubles, so saved values reload bit-identical.
  • kVelocityPitchRangeSemitones / kPitchDepthMaxSemis (24.0) does not move. It is load-bearing in the v12 wire format. The new Pitch knob reads it; it does not mint a second ±24 constant.
  • The window floor is 1190 × 680 and must not exceed 1280 × 720. A track that pushes the floor past 1280 has failed, not overrun. kEditorMinHeight stays 680 (Γ-F1). The remaining 90 px of width headroom is the budget for the life of this layout — one deck cell is 60 px, so there is room for exactly one more, once. Spec §1.6 states the ledger; read it before adding any control. Chrome-row additions are a separate purse (they are paid for out of the title slot, not the floor) and must not be charged against this one.
  • Reported latency is zero unless the limiter is on. getLatencySamples() returns 0 with the limiter bypassed, in every track and at every point in the phase. Only W1-T2 may introduce a non-zero value, and only under the limiter-on condition.
  • Geometry stays pure. Every new layout, cap, label and hit-test rule lands in a pure CTest-covered module (knob_deck, sample_bands, waveform_view), never in a painter.

Γ-W1 — Foundations

Depends on: nothing in this phase. All three tracks are disjoint by surface — T1 owns editor input and the value/taper layer, T2 owns the engine and processor, T3 owns the waveform painter's envelope trace. None touches the deck descriptors.

Γ-W1-T1 — knob-interaction-law

Goal. One consistent, unit-category-driven interaction and taper rule across every variable control, landed before any new control is added so the new ones are authored into it rather than retro-fitted.

Spec: docs/product/instrument-control-surface.md §4.

Surface boundary — owns: core/instrument/ui/deck_values (the taper maps, the snap-unit table, resetDeckParam), core/instrument/ui/param_slider (the drag law), shell/instrument/editor_input_* (modifier read + re-anchor), and the modifier-reading helper the three input paths share. Does not own any deck descriptor, any parameter, or the waveform painter.

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.120.20 of travel and 100 ms within 0.420.52. The ceiling stays 2.0 s — it reads kGateStageMaxSeconds, which the AHDSR overlay's schematic scale is derived from, and the two must agree.
  • Semitone knobs become log2/centre-expanded. Symmetric, exactly 0 at centre, exactly ±kPitchDepthMaxSemis at the ends, monotone; ±7 st reached at 5058 % 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 deck_values' own tests.
  • One shared modifier-read helper serves all drag surfaces; no second modifier grammar exists.

Open questions. None [Daniel] — fork Γ-F3 is ruled: the ceiling stays 2.0 s. The 10 s ambition Daniel described ("a horrifically long decay with tight exp") is carried as a docs/TODO.md entry, and this track lands both of its prerequisites: 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). Neither is optional on that basis alone — they are already required by this track — but the engineer should know the reset bypass is doing double duty, and should not "simplify" it back into a norm round-trip.

Γ-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), §3.23.3, §3.5, §7.10, §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 <module>_tests target), shell/instrument/reasampler_processor (the chain, the published block state, and the getLatencySamples / restartComponent(kLatencyChanged) path), and params payload v14 (the limiter enable flag). Does not own MASTER's deck geometry or any drawing — that is Γ-W3-T1.

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 the risky part and it is fenced. The SDK defines kLatencyChanged as a host deactivate/reactivate (pluginterfaces/vst/ivsteditcontroller.h:105-108), and this plugin's setActive is destructive in both directions (reasampler_processor.cpp:85-109) — deactivate frees every sounding voice, reactivate re-decodes the WAV. Four requirements, all 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. Two mitigations are in scope: the engage/disengage is covered by a short (≤ 10 ms) equal-gain crossfade so the plugin emits no discontinuity of its own, and 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.
  • ComponentState payload v14 appends the limiter flag as a strict suffix on the existing discipline; a v13 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.

  • [Daniel] fork Γ-F6 — is the deactivate/reactivate cost acceptable? (spec §8.2.) Not blocking, and this is the track's FIRST deliverable: a verification spike in REAPER — flip the limiter with notes held, during playback and while stopped, and record what actually happens (do notes cut? is the re-decode perceptible? does transport hiccup?). The SDK mandates the deactivate/reactivate; what REAPER does with it is DAW-verifiable only. Ship the ruled dynamic-latency design regardless; if the observed behaviour is as ugly as the SDK's worst case allows, the pre-agreed fallback is constant reported latency (the delay line engaged whether or not the limiter is on), which needs one word from Daniel and no DSP change — only the latency-reporting predicate moves. Report the measurement to Daniel with a recommendation; do not choose the fallback unilaterally.
  • [verify] temp_cortex/ has already been assessed and rejected (spec §3.5) — do not re-litigate it, and do not transplant from it.
  • [verify] whether setActive(true)'s reloadInstrument() can reuse the already-decoded SampleData on a latency-restart reactivate rather than re-reading the bridge and re-decoding the WAV. If it cannot cheaply, that is an accepted cost to be recorded, not a reason to restructure setActive — its destructive shape is deliberate and its reasoning (ghost sustained voices on reactivate) is documented at the call site.

Γ-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 any pure tessellation helper it needs. Does not own the loop/crossfade marks (Γ-W2-T2), envelope_overlay's vertex model, or the drawn-EG (spline) trace.

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.

Γ-W2 — New controls, and the overlay's marks

Depends on Γ-W1 for: T1 depends on W1-T1 — Rate and Pitch must be authored into the finished taper/modifier law, not retro-fitted into it (and the semitone taper must exist before a second semitone knob does). T2 depends on W1-T3 — both write editor_paint_waveform.cpp, and running them together is a merge fight in one file.

Disjointness — restated after the Γ-F4 ruling, because it changed. T1 owns the parameter model, the engine and the deck descriptors; T2 owns the waveform band's marks and their pure geometry and now also the chrome row's loop enable. The two are disjoint at the module level with one named exception: shell/instrument/editor_session.cpp. T1 may touch it for the third commit tier's routing; T2 owns pickedMarkers and applyMarkers there and nothing else. The partition is by function and the two do not overlap — 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, so it is stated rather than discovered at merge. Whichever track lands second rebases onto the first.

The format ladder stays clean. The loop enable maps onto the existing SampleLoop::hasLoop, which is already persisted and whose start/end are already written unconditionally — no new field, no version bump — so T1 keeps sole ownership of payload v15 exactly as specced.

Γ-W2-T1 — pitch-rate-deck

Goal. PITCH becomes PITCH/RATE: three knobs (Key Trk | Rate | Pitch) under the existing Varisp|Presrv toggle, with both new controls wired through the engine.

Spec: docs/product/instrument-control-surface.md §2.

Surface boundary — owns: core/instrument/engine/play_params.h + core/instrument/map/play_seconds.h (the two new fields), core/instrument/map/component_state_io + params_payload (payload v15), core/instrument/engine/voice.{h,cpp} (the compounding and the note-on latch), core/instrument/ui/deck_groups (the PITCH/RATE descriptor and the three-state live predicate), core/instrument/ui/deck_values (the two new bindings). Does not own the deck's row layout — that is Γ-W3-T1 — nor the real time-stretcher (Γ-W4-T1).

Behavior.

  • Rate: 50 %200 %, default 100 % at true knob centre, exponential taper — 50 % = 12 st, 200 % = +12 st, musically symmetric. This is linear in semitones over ±12 and is the stated exception to W1-T1's centre-expansion law (which applies to semitone knobs whose throw exceeds ±12).
  • Pitch: a baseline pitch offset, ±24 semitones, centred, on W1-T1's centre-expanded semitone taper. Reads kPitchDepthMaxSemis; does not mint a second constant.
  • Varispeed: keytrack ratio × rate ratio × pitch-offset ratio compound into a single read-increment multiply; the rate offset applies to the varispeed pitch. Composes with the pitch envelope's existing per-frame ratio_ multiply — no new per-sample stage.
  • Preserve: rate is an absolute value driving duration only; keytrack and pitch offset drive the pitch shifter. Interim implementation: a resampled read with the resulting pitch change cancelled in the existing SOLA shifter. This is an explicit duration control, not a covert Preserve path — core/instrument/CLAUDE.md's "never wire WDL_Resampler as the duration-preserving path" is untouched. Quality is Γ-W4-T1's.
  • Rate is latched at note-on, delivered by a third commit class: published into the live block like any live parameter, read only by snapLive, never by applyLive. isLiveDeckParam/liveCommitFor widens from two states to three (Live / NoteOnLatched / Reload) in that one predicate — not a second table, and not the reload tier (a swept knob must never trigger a WAV re-decode). Record the reason in the header: loop resolution and contour mapping are note-on folds, so live rate means re-folding a resolved loop and re-mapping a contour mid-note.
  • Pitch is live — under Varispeed one more factor in a multiply the pitch envelope already performs; under Preserve an addend to a shift the pitch envelope already modulates.
  • Loop points scale with rate; contours scale with rate. Neither rewrites stored values: the loop is source-frame facts traversed at the new increment (Varispeed) or the new read rate (Preserve), and a contour is a function of normalized position. Staged envelope stage times do NOT scale — 30 ms is 30 ms at any rate. That asymmetry is deliberate: a contour is of the sample, a staged envelope is of the performance.
  • Deck descriptor: three cells; captionWidth 70, hard ceiling 80 (above that the caption row overtakes the 180 px knob row and the group exceeds 192). If the text will not fit at 80, narrow the Varisp|Presrv segments 48 → 44 (ceiling becomes 88) — do not widen the group.
  • Payload v15 appends both fields as a strict suffix; a v14 blob lifts to rate 100 % / pitch 0 st, bit-identical playback.

Acceptance criteria.

  • Rate at 50 % plays an octave down and half speed under Varispeed; at 200 %, an octave up and double speed. Under Preserve the same settings change duration only — pitch is unchanged within the shifter's tolerance.
  • Rate at exactly 100 % and Pitch at exactly 0 st render bit-identical to the pre-change build, in both engines.
  • Shift-drag on Rate lands on whole semitones (so an octave and a fifth are reachable by hand); Shift-drag on Pitch lands on whole semitones; Ctrl gives cents on both.
  • A Rate change while a note sounds does not alter that note; the next note-on takes it. It does not trigger a reload or an engine rebuild — assert the tier, not just the sound.
  • A Pitch change does move a sounding note, in both engines.
  • With a loop set, changing Rate changes the loop's audible period without moving either waveform marker.
  • The PITCH/RATE group measures exactly 192 px; adding the two DeckParams produces a compile error in isLiveDeckParam's exhaustive switch until they are classified.
  • A v14 project reopens at rate 100 % / pitch 0 st and sounds identical.

Open questions. None [Daniel]. [propose at review] the exact interim resample+cancel composition and its clamp behaviour at the range extremes, which Γ-W4-T1 then replaces.

Γ-W2-T2 — loop-crossfade-ux

Goal. Give the loop an explicit enable, make the loop and crossfade marks legible, and paint the crossfade where it is actually heard.

Spec: docs/product/instrument-control-surface.md §6 — read §6.1 (the diagnosis), §6.4 (the enable) and §6.5 (trade-offs) in full before starting. This is the phase's one genuinely designed surface; the sections are the brief.

Surface boundary — owns: shell/instrument/editor_paint_waveform.cpp's marker/loop draw, shell/instrument/editor_input_waveform.cpp's marker hit-test routing, core/instrument/ui/waveform_view (cap rects, label boxes, the label-suppression rule — all pure, all CTest-covered), and — new, from the Γ-F4 rulingcore/instrument/ui/sample_chrome (the enable's rect in the toolbar control run), shell/instrument/editor_paint_chrome + editor_input_chrome (its draw and hit-test), and shell/instrument/editor_session.cpp's pickedMarkers / applyMarkers only (the retention rule — see the wave header for the shared-file partition). Does not own loop_span, the crossfade model, any parameter, or any ComponentState version. This track changes drawing, hit-testing and one editor-state retention rule — no format change.

Behavior.

  • An explicit loop enable on the CHROME ROW (Γ-F4, ruled). A two-segment Loop Off|On toggle joins the toolbar row's right-anchored control run, immediately left of the Mono|Stereo toggle, with Browse still rightmost. Loop is a waveform-overlay concept and has no deck — a deck cell was never the right home. Because the run is right-anchored and the title slot absorbs it, this costs zero window width and none of the 90 px headroom; if the title will not hold its text at the 1190 floor, the enable's segments narrow — the floor does not move.
  • The enable IS SampleLoop::hasLoop. No new field, no version bump. The field already exists (play_params.h:210), is already what resolveLoop refuses on (loop_span.cpp:12), and is already persisted in the payload's loopOverride block — where start/end are written unconditionally whatever hasLoop says (params_payload.cpp:31-36), so the wire can already carry "off, with a span remembered." What changes is the field's provenance: today it is derived from the marker gesture, and after this track it is user-owned, with the gestures as shortcuts onto it.
  • Collapse-to-off survives as a shortcut, not as a second state machine. hasLoop is the single authority; four gestures reach it:
    Gesture Enable Span Crossfade
    Enable → On on retained retained
    Enable → Off off retained retained
    Collapse the span onto itself off destroyed, re-parked at defaultLoopBounds zeroed
    Drag either loop mark while off on takes the drag retained, re-clamped
  • Two consequent behaviour changes, each with its reason. (a) pickedMarkers' re-park (editor_session.cpp:221-226) currently triggers on !hasLoop; it must become conditional on the span being invalid (collapsed / inverted / out of range) rather than on the enable being off — a toggle whose off→on does not restore what was there is a delete button, not a toggle. (b) applyMarkers' crossfade zeroing (:240-243) moves from "the enable is off" to "the span was destroyed." The original reasoning is preserved, not overruled: it zeroes so a stale length cannot silently re-apply against a span that no longer exists; with the span retained, its clamp bound is retained too and there is nothing stale.
  • The "drag me" affordance splits into two off-states. Off with no span ever set — pair parked at defaultLoopBounds, Disabled, caption DRAG TO SET LOOP. Off with a span retained — pair Disabled at its own positions, caption LOOP OFF (there is nothing to "set"). In both, dragging a mark turns the enable on — the shipped drag-to-create gesture survives and now teaches the enable by demonstration.
  • In Trigger the enable draws Disabled and inert, and does NOT clear hasLoop — Disabled-not-hidden, the same grammar as the marks, with its state restored on the return to Gate. This transitively covers the drawn-EG case via enforceGateUnavailableWhileDrawn (play_params.h:198-205), which forces Trigger whenever an envelope is drawn — one predicate, not a second rule. Disabled-but-grabbable (the off marks) vs. Disabled-and-inert (Trigger) is deliberate: the user's own off is reversible by the very gesture on offer; Trigger's refusal comes from the engine and no drag can talk it out of it.
  • One mark grammar: line + shaped cap + label. The cap IS the grip. Four marks: START (accent/primary, solid right-pointing triangle cap, solid line — the only primary-ink mark, because it is the only one always in effect); LOOP (accent/secondary, L-cap opening right); END (accent/secondary, L-cap opening left); XFADE (accent/secondary reduced alpha, ramp cap, dashed line — a soft boundary). This replaces the bare 10 px orphan tab that today marks the crossfade with no line of its own.
  • Labels in Font::Micro/TextDim, drawn beneath the trace and handles in z-order. A mark's label re-draws on top on hover or drag of that mark. A label is suppressed if its box would overlap one already placed; placement order is grabbed/hovered first, then START, LOOP, END, XFADE. Occlusion by an envelope node is accepted and named — the cap shape carries the identity permanently, the label is for learning.
  • The crossfade moves to [loopEnd crossfade, loopEnd) — where it is audible. The handle moves to the loop-end side; drag direction is unchanged (left lengthens), so the muscle memory survives.
  • The crossfade region draws as a top-and-bottom edge wedge, NEVER as a second fill. A triangular band at the overlay's top and bottom edges growing from zero at loopEnd crossfade to ~10 px at loopEnd. This is a hard constraint: the region is now inside the loop span, where a translucent fill would stack on the 0.20 loop fill, and the envelope trace crossing that fill is a known, accepted under-floor pair at 2.25:1 (editor_paint_waveform.cpp:28-34), whose own note says the FILL is what changes if it is ever resolved. The loop fill's peak alpha must stay exactly 0.20.
  • The ingredient draws as a ghost. [loopStart crossfade, loopStart) draws the mirror wedge at half alpha, no handle — a hairline dashed outline at rest, filling in on hover or drag of the crossfade handle. This makes the crossfade ≤ min(start, loopLength) clamp self-explanatory: the fade stops growing exactly when the ghost's left edge reaches START or LOOP, so the user sees the reason instead of hitting an invisible wall.
  • Trigger mode: the loop pair and the crossfade mark draw Disabled and are not grabbable, with a dim LOOP — GATE ONLY caption — Disabled rather than hidden, matching the editor's existing Gate-segment grammar, and because hiding a set loop on a mode flip destroys information the user put there. START stays fully live.

Acceptance criteria.

  • The four marks are distinguishable by ink and cap shape with the labels suppressed, and named when they are not.
  • The shaded crossfade region sits over the frames where the fade is audible — verify against a rendered loop, not by reading the code.
  • Every mark is grabbable by its cap; grabbing a mark shows its label.
  • The crossfade at its clamp shows the ghost's left edge coincident with the bounding mark.
  • The loop fill's peak alpha is unchanged at 0.20 and the accepted 2.25:1 trace pair is neither improved nor worsened.
  • In Trigger, no loop mark accepts a grab, the chrome enable is Disabled and inert, and the reason is on screen. Returning to Gate restores the enable's prior state.
  • Turning the enable off and on again restores the loop exactly — same span, same crossfade, no re-park. Collapsing the span instead turns it off, re-parks at defaultLoopBounds and zeroes the crossfade. Both paths asserted.
  • Dragging a loop mark while the enable is off turns it on, in both off-states.
  • The enable costs no window width: kEditorMinWidth is unchanged by this track, asserted by the same derived test that guards the floor.
  • No ComponentState version moves; no new persisted field; resolveLoop is untouched; audio is unchanged. The enable round-trips save/reload through the existing loopOverride block, in both states, with the span retained across an off.
  • All cap/label/suppression geometry is pure and unit-tested; no hit-test math in the painter. The enable's rect lands in sample_chrome alongside the rest of the control run.

Open questions.

  • [propose at review, then verify by hand] The claim-arbitration inputs change: markerHandleRect today gives a tab to the crossfade only, and resolveWaveformClaim breaks ties by smallest nominal target area. Giving every mark a cap-grip changes the candidate set and every nominal area in it. The arbitration must be re-derived, and docs/TODO.md's open entry "Pre-existing staged-envelope-node shadow at zero-attack" must be re-evaluated against the new cap geometry and its outcome recorded — resolved or worsened, either is acceptable, silence is not.
  • No [Daniel] questions. Fork Γ-F4 is ruled — there is an explicit enable and it is on the chrome row, in this track. The prior framing ("an enable needs a cell, so it is a Γ-W3 layout decision") was wrong and is retired: loop has no deck, so it never needed one.
  • [propose at review] every site that currently infers hasLoop — two in editor_input_waveform (:255, :258), two in editor_session (:222, :236) — is now writing to a user-visible control rather than to an internal flag. Re-read each in that light; "it still compiles" is not a disposition.
  • Named escalation, not a fallback to take silently: if the top strip reads crowded in the DAW, the pre-designed answer is the marker rail (spec §6.2, Direction 2) — a larger build that would also dissolve the arbitration problem structurally. Escalate; do not improvise a half-rail.

Γ-W3 — The reflow

Depends on Γ-W2 for: the PITCH/RATE descriptor (W2-T1) — the reflow measures the real three-cell group, and laying it out against a forecast of that group means re-measuring afterward. Depends on Γ-W1 for: W1-T2's published meter/GR/clip state, which MASTER's deck draws; drawing against a stub would mean building the meter twice.

One track. The row law, the group inventory, the double-height deck and the window floor are one geometry decision spread over knob_deck, deck_groups, sample_bands and the deck painter. Splitting it would put two tracks in the same three pure modules.

Γ-W3-T1 — deck-reflow

Goal. Two categorical rows plus a double-height MASTER bus deck, inside a 1280 × 720 ceiling, returning 112 px to the waveform.

Spec: docs/product/instrument-control-surface.md §1 (the whole section, incl. the §1.2 measured table and §1.6, the headroom ledger) and §3.23.3 (what MASTER draws). §7 lists the invariants this track invalidates or widens — read it before touching knob_deck.h.

Surface boundary — owns: core/instrument/ui/knob_deck (the row law, the double-height group, the justification), core/instrument/ui/deck_groups (row assignment, FILTER's toggle move, MASTER's inventory), core/instrument/ui/sample_bands (kEditorMinWidth), and shell/instrument/editor_paint_deck (the MASTER meter/limiter/bubble draw). Does not own any parameter, the limiter DSP, or the waveform band.

Behavior.

  • Row 1 (sound), one row, non-negotiable: PITCH/RATE 192 · FILTER 432 · VELOCITY 192 · VOICE 164 = 980 natural.
  • Row 2 (contour): PITCH ENV 252 · FILTER ENV 312 · AMP ENVELOPE 312 = 876 natural.
  • MASTER is double-height (216 px) and right-anchored, outside both rows, 142 px wide.
  • FILTER's Band|Notch moves from its row-toggle position to the caption corner, taking the group 524 → 432 (92 px). It occupies FILTER's currently-unused captionToggle2 slot — no new geometry is required.
  • VOICE keeps its Retrig|Legato row toggle. Moving it to the caption makes VOICE wider (226, not narrower), because its caption row is the binding side. Verified; do not "fix" it.
  • Justification law, applied to BOTH rows: space-between within the row block; slack divided equally among the row's (n1) gutters, integer residue to the leftmost; no gutter narrower than kDeckGroupGap (12). Decks are never stretched. MASTER is not part of either row's justification.
  • Row block = 1020 px at the floor, giving row 1 gutters 12/14/14 and row 2 gutters 72/72, at which width FILTER's right edge and FILTER ENV's right edge both land on x = 636. That tie-line, row 2's equal gutters, and row 1's minimum gutter being exactly kDeckGroupGap all hold at 1020 and only at 1020 — this is why the floor is 1190 and not 1186. Above the floor the tie-line drifts and that is accepted (spec §1.3).
  • kEditorMinWidth 980 → 1190; kEditorMinHeight stays 680 (Γ-F1, ruled — the reflow's 112 px goes entirely to the waveform); deck band height 328 → 216; waveform band at the floor 246 → 358.
  • The 90 px of remaining headroom is the budget for the life of this layout, and one deck cell is 60 px. This is why MASTER's reserved slot is ONE cell (Γ-F5, ruled): two would spend 60 of the 90 up front on a control nobody has named, leaving 30 — which would freeze row 1 forever, since any later row-1 addition needs 60. Widening MASTER later costs the same 60 it would cost now, and by then the trade is against a real control instead of a guess. State this ledger where a future reader will hit it — spec §1.6 is its home, and a reader proposing a new knob needs to see it before they propose.
  • MASTER's interior (spec §1.4, exact to the pixel): caption row with the limiter toggle and a round 12 px warn GR bubble in the far corner (non-interactive — the same slot the envelope decks' radio uses; round so it reads as a lamp, not a control); gain knob in the upper-left cell at box-relative y = 26 and a reserved empty slot at y = 138 — i.e. the two cells land on row 1's and row 2's knob baselines exactly, which is what stitches the spanning deck to both rows; meter column 62 px wide × 186 px tall on the right.
  • Three rules not to generalise wrongly: MASTER's left column uses fixed cell slots at the two baselines, NOT the horizontal run-division law (that law would stretch one knob over 186 px); the reserved slot draws nothing (blank reads as breathing room, a dashed placeholder reads as unfinished); the meter is one rect spanning both baselines, not two per-row meters.
  • The meter draws W1-T2's published state, with the ballistics run on the UI timer. Bar count follows the same LaneSplit decision waveformSurface already folds (channel mode ∧ source channel count) — one wide bar when the waveform draws one lane, two skinnier bars when it draws two. Not a second rule: a mono source in stereo mode is dual-mono, and two identical bars would be a lie.
  • Meter appearance: bar in accent/primary; peak-hold tick 2 px in text/primary; clip cap in warn, latched, click-to-clear; scale linear in dB over 60…+6 with ticks every 6 dB and numerals at 0/12/24/36/48/60, the 0 dB tick heavier. No green/yellow/red segmentationwarn stays reserved for clip states.

Acceptance criteria.

  • At the floor width the deck lays out in exactly two rows plus the spanning MASTER, by construction — asserted against the group inventory, not observed as a wrap outcome.
  • Every group's width matches the §1.2 table exactly, in both Gate and Trigger (row 2's natural width is mode-stable at 876 because the reserve slots hold FILTER ENV and AMP at 312 in both modes — assert it).
  • Row 1 and row 2 are flush left and flush right; at the floor width the filter tie-line is exact (both edges at x = 636) and row 2's two gutters are equal.
  • kEditorMinWidth is 1190 and the floor is ≤ 1280 × 720, asserted by a derived test rather than a literal (the Θ-W6-T1 precedent).
  • The waveform band is 358 px at the floor, and the deck band is 216.
  • MASTER's gain knob shares a knob baseline with FILTER's knobs; its reserved slot shares one with AMP ENVELOPE's.
  • The meter reads correctly in mono and stereo, the peak-hold tick holds 1.5 s, the clip cap latches and clears, and the GR bubble lights only while the limiter reduces gain.
  • With the limiter engaged the clip cap never latches on material the limiter is catching; if it does, that is a defect report against W1-T2, not a user error.
  • knob_deck's and sample_bands' tests are updated to the new law, and the invalidated notes in knob_deck.h (the fourteen-pixel headroom figure; the cells-and-floor pairing) are re-derived, not deleted — spec §7.1, §7.4.

Open questions.

  • [propose at review] Whether the greedy whole-group wrap survives at all as a sub-floor degrade, or is replaced outright by explicit row assignment. What is not optional: at and above the floor width the layout is the specified arrangement, reached by construction. DeckLayout::rowCount/::height change meaning either way (spec §7.3).
  • No [Daniel] questions. Forks Γ-F5 (one cell) and Γ-F1 (680 stays) are both ruled; they are stated in Behavior above, not carried here as options.
  • [verify] deck_groups.cpp's kEnvModeSegW = 23 ceiling rises to 47 once PITCH ENV is on row 2 (AMP binds at 55). No change is required; the comment stating the old ceiling stops being true and must be corrected (spec §7.2).

Γ-W4 — The Preserve time-stretcher

Depends on Γ-W2 for: Rate existing as a shipped, working control on the interim resample+cancel path. Landing the stretcher against a working feature makes it a measurable quality upgrade with an A/B; landing them together would make one track responsible for both a feature and an algorithm.

One track. This is the phase's largest unknown — flagged the way Ξ-W2-T1's crossing was. If the measure-and-report gate fails, what gives is the algorithm choice, not Rate: the interim path already ships in W2-T1, so the fallback is "keep the interim path and record why," not "cut the feature."

Γ-W4-T1 — preserve-time-stretch

Goal. Replace the interim resample+cancel composition with a real pitch-preserving time-stretcher written from established state-of-the-art literature.

Spec: docs/product/instrument-control-surface.md §2.5.

Surface boundary — owns: core/instrument/engine/pitch_shift and whatever new pure module the stretcher needs, plus voice.cpp's Preserve read path. Does not own any parameter, any UI, or the varispeed path.

Behavior and constraints. The algorithm is the engineer's call under a measure-and-report gate — this plan deliberately names none. 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 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.

  • Rate 100 % with no shift is bit-identical to the un-stretched read.
  • Preserve speaks on frame 0 — no added onset latency, no first-frame smear, in any rate/shift combination.
  • No audible metallic or phasey artefacting on sustained tonal material at ±6 st and 75133 % rate; transient material at 50 % / 200 % is no worse smeared than the interim path.
  • 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 against the interim path 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: Rate legitimately changes duration under Preserve, so a resampled read is an explicit duration control, but the pitch-preserving mechanism must not be a resampler.


Traceability — all seventeen items

The check that nothing was dropped. Every row points at a track that exists above.

# Item (short) Phase-Wave-Track Worktree slug
1 Envelope editor: radio switch, curve dials, overlay recolor Θ-W3-T2 pth-w3-t2-staged-envelope-curves
2 MM preamp Filter — resonant HP/LP stage Θ-W1-T3 (DSP) + Θ-W2-T1 (integration) pth-w1-t3-filter-dsp-port, pth-w2-t1-filter-voice-path
3 Alternative Spline EGs Θ-W5-T1 pth-w5-t1-spline-egs
4 Bug: end-of-sample click, Trigger × Preserve Θ-W3-T2 pth-w3-t2-staged-envelope-curves
5 Bug: drag-out sometimes lands without audio Θ-W1-T2 pth-w1-t2-capture-handoff-bugs
6 Bug: FX-container drop loses the capture Θ-W1-T2 pth-w1-t2-capture-handoff-bugs
7 Stereo waveform shows both channels Θ-W2-T2 pth-w2-t2-stereo-waveform-lanes
8 Release anchoring; the Pitch AD becomes AHD Θ-W3-T2 pth-w3-t2-staged-envelope-curves
9 Loop points — regression + Gate loop-sustain Θ-W4-T1 pth-w4-t1-gate-loop-sustain
10 Knob/label sizing, ms units, double-click reset Θ-W6-T1 pth-w6-t1-legibility-and-antialiasing
11 Preview glyph; VELOCITY deck; bipolar curves Θ-W4-T2 pth-w4-t2-velocity-deck-and-bipolar-curves
12 Toolbar cleanup; full-width piano strip; tooltips Θ-W2-T3 pth-w2-t3-toolbar-and-piano-strip
13 Antialiased rendering audit for high-DPI Θ-W6-T1 pth-w6-t1-legibility-and-antialiasing
14 Trigger amp/filter fade → AHD consolidation Θ-W3-T2 pth-w3-t2-staged-envelope-curves
15 One-click in-sampler resample Ξ-W1-T2 (note model) + Ξ-W2-T1 (bake chain) + Ξ-W3-T1 (popup) pxi-w1-t2-note-program-model, pxi-w2-t1-resample-bake-chain, pxi-w3-t1-capture-signal-popup
16 Retire the zone mapping system Θ-W1-T1 pth-w1-t1-zone-retirement
17 Consolidate provenance/usage tracking Ξ-W1-T1 pxi-w1-t1-tracking-consolidation

Work in this plan that is not one of the seventeen

The table above is a completeness proof over TODO-1.0.md — every row points at a track, so nothing from the source was dropped. It is deliberately not an index of the plan: work that did not come from the source doc has no row, and inventing one would weaken the proof it exists to give.

  • Θ-W3-T1 — live-parameter-delivery (pth-w3-t1-live-parameter-delivery). Arose from Θ-W2-T1's implementation review, not from TODO-1.0.md. The first such track in this plan; see Θ-W7-T1 below for the second.
  • Θ-W7-T1 — arc-and-spline-aa (pth-w7-t1-arc-and-spline-aa). Opened after Θ-W6-T1 shipped, when Daniel found two rendering defects by eye in the editor — not from TODO-1.0.md, and not a track this plan originally scoped. The second such track in this plan today; if others appear, they belong on this list rather than in the table.
  • All of Phase Γ (pg-*). Seven tracks from a direct interview with Daniel (2026-08-01), not from TODO-1.0.md. Listed here as a block rather than per track, because the whole phase is outside the source doc; the product reasoning lives in docs/product/instrument-control-surface.md and the automation scoping it defers in docs/product/parameter-automation.md. Γ-W3-T1 additionally discharges the docs/TODO.md deck-rework entry, whose original "one row of taller decks with within-deck stacking" shape Daniel explicitly superseded.

Deliberate compressions

Recorded so a reader of TODO-1.0.md can see what this plan did to the source, rather than discovering it later:

  • Item 2 is split across two waves. The DSP port (Θ-W1-T3) is deliberately separated from the integration (Θ-W2-T1) so the external-input dependency on Daniel's Cortex-M4 code sits on a standalone, disjoint track instead of blocking a wave. Item 2's acceptance criteria are split accordingly — the range/resonance assertions land in W1-T3's tests, the audible/pipeline/deck criteria in W2-T1.
  • Item 2's filter envelope ships twice. W2-T1 ships it in the existing staged AHDSR shape; W3-T2 gives it the curve treatment and the mode-driven Gate→AHDSR / Trigger→AHD shape. This is deliberate: waiting would put the filter behind the whole envelope system.
  • Item 4 lands in Θ-W3, not Θ-W1. Its fix region is the region item 14 retires, and the only earlier instrument-side track owns the entire engine. Its acceptance gate is stated as the post-consolidation gate. It is not gated behind the whole editor chain — three waves of six — and the source doc itself requires re-verification under the surviving mechanism either way.
  • Item 11's filter velocity curve is split from item 2's filter velocity modulation. W2-T1 wires the modulation path (following the amp/pitch precedents); W4-T2 sets the curve's bipolar domain and default and homes its button. Neither track can do the other's half.
  • Item 15's undo/recovery is carried as a note, not a criterion — Daniel set it at exactly "a plus." The guaranteed recovery floor (the superseded file surviving until prune) is a criterion.
  • Nothing else was compressed. Every other item's behavior bullets and acceptance criteria are carried at full strength into the track that owns it.

Outline at a glance

Phase Θ — ReaSampler 9000: one parameter set, filter, shapeable envelopes, legible editor
  W1  Collapse and re-seam
      T1 zone-retirement ......................... 16
      T2 capture-handoff-bugs .................... 5, 6
      T3 filter-dsp-port ......................... 2 (DSP)      [needs Daniel's M4 code]
  W2  Filter in the voice path; waveform and chrome bands
      T1 filter-voice-path ....................... 2 (integration)
      T2 stereo-waveform-lanes ................... 7
      T3 toolbar-and-piano-strip ................. 12
  W3  Live parameters, then the staged envelope system      [T1 before T2 — serial]
      T1 live-parameter-delivery ................. (not one of the seventeen)
      T2 staged-envelope-curves .................. 1, 8, 14, 4
  W4  Loop sustain and the velocity deck
      T1 gate-loop-sustain ....................... 9
      T2 velocity-deck-and-bipolar-curves ........ 11
  W5  Spline EGs
      T1 spline-egs .............................. 3
  W6  Editor legibility pass
      T1 legibility-and-antialiasing ............. 10, 13
  W7  Arc-and-spline antialiasing fix
      T1 arc-and-spline-aa ....................... (not one of the seventeen)

Phase Ξ — The resample loop            (W1 concurrency-safe with Θ from Θ-W2 onward)
  W1  Consolidated tracking, and the programmed-note model
      T1 tracking-consolidation .................. 17
      T2 note-program-model ...................... 15 (model)
  W2  The bake chain                      [requires all of Phase Θ, and Phase Γ before it]
      T1 resample-bake-chain ..................... 15 (chain)
  W3  The capture-signal popup
      T1 capture-signal-popup .................... 15 (popup)

Phase Γ — The instrument's control surface        (none of the seventeen; runs before Ξ-W2)
  W1  Foundations                            [3 tracks, disjoint by surface]
      T1 knob-interaction-law ....... modifiers + ms/semitone tapers + reset bypass
      T2 master-bus-audio ........... limiter + meter ballistics + processor   [payload v14]
      T3 contour-trace-curves ....... staged traces draw curved, knot on its trace
  W2  New controls, and the overlay's marks  [2 tracks]
      T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding  [payload v15]
      T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard
  W3  The reflow                             [1 track]
      T1 deck-reflow ................ two rows + double-height MASTER; floor 1190x680
  W4  Preserve time-stretch                  [1 track]
      T1 preserve-time-stretch ...... real stretcher      [measure-and-report gate]