Adds docs/product/bank-package.md and the Phase Ε spec in docs/PLAN.md: three waves, six tracks. Three forks open; Ε-F1 blocks Ε-W1-T1.
201 KiB
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, and Phase Ψ, which likewise did not
come from the seventeen: it came from a direct list of seven defects and refinements
(Daniel, 2026-08-01) and is specified inline in its own section below — there is no
backing product doc for it, and Phase Ε, which likewise did not come from the
seventeen: it came from a direct request (Daniel, 2026-08-02) and is scoped in
docs/product/bank-package.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 toCOMPLETED.mdwith 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, Ψ → psi, Ε → e. So Θ-W1-T1
dispatches into pth-w1-t1-zone-retirement, Γ-W1-T1 into pg-w1-t1-knob-interaction-law,
and Ψ-W1-T1 into ppsi-w1-t1-capture-range-exactness.
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 six [Daniel]-class forks
(Γ-F1…Γ-F6) and all six are ruled (Daniel, 2026-08-01) — the rulings are folded into the
tracks below and indexed in docs/product/instrument-control-surface.md §8. Γ-F6 closed
with a correction to the analysis, not merely a ruling: dynamic reported latency is routine
for VST3 instruments and REAPER handles it as a matter of course; what makes the mandated
restart expensive here is self-inflicted (setActive(true) calls reloadInstrument), so
the cost is ours to reduce and the reduction is filed in docs/TODO.md rather than designed
around.
Γ-F3 was subsequently REVERSED and a seventh fork opened AND CLOSED, all by Daniel's later rulings of 2026-08-01. Γ-F3 ("the stage-time ceiling stays 2.0 s") is replaced by "extend the stage lengths to 10s" — the ceiling moves in Γ-W1-T1. Γ-F7 (the VST3 parameter order) is RULED: signal flow — "signal flow order." There is now NO unanswered [Daniel]-class question anywhere in this plan.
That claim is scoped to Phases Θ / Ξ / Γ / Ψ, and Phase Ε reopens the class. Phase Ε
(added 2026-08-02) carries three [Daniel]-class forks — Ε-F1 (container format),
Ε-F2 (import target), Ε-F3 (import under a degraded ledger) — each stated with a
recommendation and its counter-argument in the phase header below and in
docs/product/bank-package.md §"Open forks". Only Ε-F1 blocks a dispatch (Ε-W1-T1);
the other two are answerable at Ε-W2 and could be ruled at implementation review if Daniel
prefers, but both are user-visible policy rather than implementation detail.
Ruling 3 (Daniel, 2026-08-01) — real units at the host boundary. "The parameter values
exposed to the VST host should be in real units, such that the host automation lanes report
usable values." Satisfied through VST3's plain-value layer, not its wire format (which is
normalized and cannot be otherwise): toPlain/toNormalized, getParamStringByValue and
ParameterInfo::units. Specified at docs/product/parameter-automation.md §6.7 — the
per-category unit/precision table, the one-formatter invariant, the stepCount sweep, and the
resolution of the apparent conflict with the filter's re-taper prohibition. It lands entirely
in Γ-W4-T1 and changes no wave boundary.
Flagged for awareness — not blocking, but decision-grade
-
Item 15's cross-artifact seam is RESOLVED — this is no longer an 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 required that crossing. Ξ-W2-T1 ratified Decision 1 = (1b): the editor invokes the extension's bake action directly over the VST-host bridge (NamedCommandLookup/Main_OnCommandEx), no poller, no nonce — dissolving the S13 DEGRADED verdict rather than re-litigating it. The read-only bank invariant held: the crossing is a bridge call, not a shell-side bank write. Seedocs/COMPLETED.mdfor the full narrative. -
The "Γ before Ξ-W2" ordering is VIOLATED, it was never Daniel's choice, and Γ now owns the correction. Daniel, 2026-08-01: "xi was started before I spun you up, we'll have to correct phase xi inside gamma. wasn't a choice." Ξ-W2-T1 (
resample-bake-chain) ran ahead of this plan's sequencing claim, so the bake's settled reset scope — which enumerates parameters by name — ships incomplete: it cannot name rate, pitch offset or the limiter flag, none of which existed when it was written.This is no longer a scheduling constraint to honour. It is a correction obligation with a named owner: Γ-W3-T2
bake-reset-amendment. The classification costs no Daniel decision —docs/product/instrument-control-surface.md§3.4 pre-classifies all three against Ξ-W2's own ratified rule (all reset) — but the amendment must be written against what Ξ-W2-T1 actually shipped, not against what this plan predicted it would ship. Ruling 1 adds a second correction of the same shape, homed on Γ-W4-T1 rather than here: see item 3. -
The one-way doors are now IN-PHASE, and the sweep for them is a delivered artifact. Ruling 1 (Daniel, 2026-08-01) schedules VST3 parameter reporting inside Phase Γ, as Γ-W4-T1. Everything that participates in a parameter's normalization therefore freezes at the end of this phase rather than at the start of some later one, and anything that ought to move must move first.
- The taper (Γ-W1-T1) — known, and the reason this phase was ordered as it was.
- The stage-time ceiling 2.0 → 10.0 s (Γ-W1-T1) — Γ-F3 reversed by Daniel's "extend the stage lengths to 10s." A range endpoint is normalization exactly as much as the curve between the endpoints is.
- Two further doors that need action, both new, both landing on Γ-W1-T1: every
default must have an exact normalized preimage (a host's reset-to-default has no
resetDeckParambypass to use), and the filter's four*Normcontrols must not be re-tapered (their laws are already wire-frozen in payload v9). - Six more constants freeze without needing to change, and three are already frozen
for unrelated reasons; the complete sweep, with dispositions and with what was checked,
is
docs/product/parameter-automation.md§8. That doc is no longer scoping-only — §§6–10 are the specification Γ-W4-T1 is built from.
Phase-wide acceptance criteria
These bind every track in all four phases and are stated once here rather than repeated per track. Phase Γ adds a set of its own, stated in its phase header. Phase Ψ adds none — the structural heuristics, performance guardrails, and product invariants below bind it exactly as written (its phase header states its performance posture against the named hot paths).
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: noIEnvelope, noIFilter, no virtual per-voicetick(). 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-threadreloadInstrument+ 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>_teststarget 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:
- 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 competingComponentStateversion bumps. - The voice render path (
sampler_core.cpp) — filter insertion, envelope evaluation, loop read, and the Trigger tail all live there. - The Sample face (
editor_paint_sample.cpp516 lines /editor_input_sample.cpp583 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.1–10 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 landed — spline-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 landed — legibility-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 landed — arc-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.
All three waves have landed — Phase Ξ is complete. W1 through W3 each carry their own
landed note below; see docs/COMPLETED.md for every track's full narrative.
Ξ-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 as of this
landing — note length stays musical-division-only, and an offset stores the denomination
it was entered in. Superseded by Ξ-W3-T1's landed work (see docs/COMPLETED.md):
note/CLAUDE.md's musical-division-only rule is amended — a note length now carries
EITHER an exact derived duration or a musical division, not division-only. The offset
denomination rule is unaffected.
Ξ-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.
This wave ran AHEAD of Phase Γ, and that was not a choice. The plan asserted Γ must land
first so the bake's reset list would be complete on the day it shipped; Ξ-W2-T1 was already
live. The consequence is owned, not absorbed: Γ-W3-T2 bake-reset-amendment completes
the list afterwards, and Γ-W4-T1 adds the host-notification obligation once parameters
exist. Neither is this wave's work, and neither is a defect report against it.
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
Landed — see docs/COMPLETED.md for the full narrative. The architecture decision
(this track's first deliverable) is ratified: Decision 1 = (1b), the editor invokes
the extension's bake action directly over the VST-host bridge
(NamedCommandLookup/Main_OnCommandEx), no poller/nonce, dissolving the S13 DEGRADED
verdict; Decision 2 = (2c), the instrument renders in-process and the extension
banks the file, taken over the plan's leaning toward (2a) on an engine-version-skew
argument. Extension presence resolved to cleanly-unavailable, not silently lossy —
the affordance refuses up front when the extension is not loaded. Reset-scope edge
cases classified against the ratified rule: play mode → RESET to Trigger, start point
→ RESET, channel mode and preview velocity → SURVIVE. Phase Γ's additions remain
NOT this track's — Γ-W3-T2 bake-reset-amendment still amends the reset list for
Γ's own new values, unaffected by this landing. Naming and lineage is proposed
(Kick → Kick r2 → Kick r3) but not itself ratified — still open jointly with
Ξ-W1-T1's lineage-record question, which Ξ-W1-T1 answered on its own side
(OriginRecord::parentSampleId). Nothing was verified in a live REAPER session;
Daniel's manual verification is still owed.
Ξ-W3 — The capture-signal popup
Depends on Ξ-W2 for: the bake chain this track was originally scoped to build a preview against, under the acceptance criterion "preview and bake cannot diverge." That motivation is retired, not satisfied — see below.
One track.
Ξ-W3-T1 has landed — capture-signal-popup — see docs/COMPLETED.md for the full
narrative, and it diverges substantially and deliberately from this section's spec. The
popup this track was scoped to build (a musical-division note-length picker, ms/beat-
editable offsets, velocity, and a preview trigger) was built (~1500 lines) and then
abandoned unmerged on Daniel's ruling: "I didn't realize you had already derived a
usable window. The manual stuff for baking a specific midi length was just an idea, if we
have a smarter, fewer-clicks way of doing it, that is ideal. I just don't want to lose
anything when we bake. We can abandon the whole parameterized bake window if we can
safely derive the window in gate and trigger modes." An audit, backed by executable
tests, established the window derives losslessly everywhere except Gate over an active
sustain loop, which has no intrinsic duration to derive. What shipped instead: the bake
window derives itself in Trigger and loop-less Gate; one control, a musical-division
Hold picker, covers the one irreducible case and is shown only for Gate-with-active-
loop (bakeWindowNeedsHold, reading the engine's own loop fold); bake velocity now reads
the instance's persisted preview velocity rather than a hard-coded value; and the
chrome-row play button stays a pure MIDI trigger — Daniel's ruling: "play button is pure
MIDI trigger, Bake parameters are their own thing." This retires this section's
acceptance criterion 2 ("preview and bake cannot diverge") and its preview-trigger
behavior bullet by ruling, not by shortfall — there is no popup and no preview trigger.
Three pre-existing truncation bugs were found and fixed along the way, and
note/CLAUDE.md's musical-division-only invariant is amended (see Ξ-W1-T2 above): a note
length now carries either an exact derived duration or a musical division, not
division-only.
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 over a stage-time range raised 2 s → 10 s, a fix for staged contour traces drawing straight, a re-approached loop/crossfade marker UX under an explicit chrome-row loop enable, the Phase Ξ bake's reset list corrected, and — as the phase's last track — the instrument's first VST3 automatable parameters, reported to the host under a frozen id contract.
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, and the parameter
system's is in docs/product/parameter-automation.md §§6–10. 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 — SEVEN ruled, ONE OF THEM LATER REVERSED, NONE OPEN. Indexed at spec §8, folded into the tracks below:
- Γ-F1 —
kEditorMinHeightstays 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 — RULED, THEN REVERSED THE SAME DAY. First ruled "the ceiling stays 2.0 s in this
phase"; then Daniel: "extend the stage lengths to 10s."
kEnvTimeMaxSeconds/kGateStageMaxSecondsmove 2.0 → 10.0 in Γ-W1-T1, and thedocs/TODO.mdentry that carried the ambition is discharged rather than deferred. The reversal's cause is Ruling 1 — parameters now ship in-phase, so the ceiling is a one-way door that must be walked through before them, not after. Spec §4.3.1. - Γ-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.
- Γ-F6 — ship dynamic latency as ruled. The
restartComponent(kLatencyChanged)deactivate/reactivate the SDK mandates is accepted: "the limiter will either be on or off on its instance, toggling during playback is not a use case." No constant-latency fallback, no measurement gate. This ruling also corrected the analysis — spec §3.1.1. - Γ-F7 — RULED: SIGNAL FLOW. Daniel, 2026-08-01: "signal flow order." The VST3 parameter
order — both the frozen id numbering and the
getParameterInfopresentation index — is PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE → MASTER, the deck's ownsampleDeckGroupsrule, with each group's cells in the semantic order the id table freezes. The editor's visual rows after the reflow were the rejected alternative. The reason, because a future reader will ask why the id order does not match the screen: the editor's layout has already moved twice (Θ-W6-T1 grew the floor 840 → 980; Γ-W3-T1 takes it to 1190 and re-rows every group) and within-row order is settled by width fitting, not by meaning — so binding a permanently-frozen id order to a demonstrably mobile layout guarantees the two drift apart, after which the order is neither logical nor matching. Signal flow is the axis that does not move. Full argument and the accepted residual cost:docs/product/parameter-automation.md§6.4; the resulting 44-id table is stated at §6.2.
Ruling 1 (Daniel, 2026-08-01) — VST3 parameter reporting ships in this phase. Verbatim
intent: "correct the phase gamma plan to account for complying with the VST3 standard for
parameter reporting… by the end of gamma we have the automatable params reported. Make the
parameter order logical." docs/product/parameter-automation.md was written as scoping and
has been promoted in place: §§1–5 are the original analysis, §§6–10 are the
specification Γ-W4-T1 is built from. Four things it decides that the scoping pass left
open: the blob stays authoritative and parameters are a third surface onto the one model
(§6.1); the id space is an independent, hand-assigned, FOREVER-FROZEN table, now stated in
full as 44 numbered rows in signal-flow order (§6.2, §6.3); the exposed list is derived
from the three-state commit predicate, never hand-maintained (§7); and, under Ruling 3,
every parameter's plain unit, range and display precision (§6.7). Today the plugin has
zero parameters — ReaSamplerProcessor::initialize
(reasampler_processor.cpp:56-73) never populates SingleComponentEffect::parameters, so
getParameterCount() returns the SDK default 0.
What the Γ-F6 ruling changed in the analysis, not just in the plan. Dynamic latency
reporting is routine for VST3 instruments and REAPER handles it as a matter of course;
the SDK's deactivate/reactivate requirement (pluginterfaces/vst/ivsteditcontroller.h:105-108)
is the normal contract, not an exotic one. What makes the cycle expensive here is entirely
our own doing: ReaSamplerProcessor::setActive(true) calls reloadInstrument() — a bridge
read plus a full WAV re-decode (reasampler_processor.cpp:89-97) — where a typical plugin's
setActive only allocates and frees buffers, and the deactivate side's freeing of
live_/draining_/graveyard (:98-107) is likewise our own design. The cost is therefore
ours to reduce if it ever matters, and the reduction is decoupling reload from activation —
not abandoning dynamic latency. That improvement is filed as a docs/TODO.md entry with its
trigger condition; it is not scheduled in this phase.
Sequencing against Phase Ξ — the ordering claim is RETIRED and replaced by an owned correction. This plan previously asserted that Γ must run before Ξ-W2 and called it "a correctness point, not a preference." Ξ-W2-T1 ran first. That was not a decision anyone took — the track was live before this phase existed (Daniel: "xi was started before I spun you up, we'll have to correct phase xi inside gamma. wasn't a choice."). So:
- The bake's reset list is incomplete as shipped, and Γ-W3-T2 amends it. Rate, pitch offset and the limiter flag are all reset under Ξ-W2's own ratified rule (spec §3.4), so no Daniel decision is owed — only the edit, and it must be made against what Ξ-W2-T1 actually shipped rather than against what this plan predicted it would ship.
- Ruling 1 adds a second correction of the same shape, and it lands one wave later.
Exposing the reset-class values as VST3 parameters means the bake's reset must notify the
host, and a host automation lane on a reset-class parameter re-imposes its curve onto
already-baked audio. Both are Γ-W4-T1's acceptance criteria — that track creates the
condition, so it carries it (
docs/product/parameter-automation.md§9). - The payload-ladder half of the old claim needs re-checking, not restating — see the ladder block below, which now states rungs relatively rather than by number.
The organizing constraint. Six surfaces are single-writer and dictate the wave shape:
ui/deck_values.cpp and the taper module extracted from it (the taper law and the new
ceiling, then the two new controls, then the host normalization — three tracks, three waves),
editor_paint_waveform.cpp (the contour trace, then the loop marks), ui/deck_groups.cpp
(the row predicate, then the PITCH/RATE descriptor, then the reflow's row consumption — three
tracks, three waves), engine/voice.cpp (the Preserve read path, then the rate compounding
into it), shell/instrument/reasampler_processor (the limiter chain and latency, then the
parameter surface), 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.
Resequenced 2026-08-01 (Daniel), three changes. The prior four-wave shape put the reflow at W3 and the Preserve stretcher at W4; both moved. Ruling 1 then added a fourth wave — see "The wave shape after Ruling 1" below.
- The reflow is split, canvas from arrangement. The window floor and the width budget it is derived from land early (Γ-W1-T4), so every other UI track in the phase is drawn, tested and judged at the final 1190 × 680 window instead of at a size a later wave changes under it. The two-row arrangement stays late (Γ-W3-T1), because it can only be measured once the final PITCH/RATE and MASTER descriptors exist. The seam is stated at Γ-W1-T4.
preserve-time-stretchmoved W4 → W1-T5. It is the longest pole in the phase and has zero dependency on any UI work — a purecore/instrument/engine/module. Scheduling it last was a scheduling error. Consequence: it is no longer Rate's successor but its prerequisite, which retires the interim resample-and-cancel stand-in entirely — see Γ-W2-T1.
Net: four waves become three, and both of the phase's DSP unknowns (the limiter, the stretcher) are exposed in wave 1 rather than one of them landing last.
The wave shape after Ruling 1 — three waves become four. The parameter system cannot be a track inside any existing wave, and the reason is a chain of hard prerequisites, not caution:
- after W1-T1, because the taper and the 10 s ceiling are the host-facing normalization, and Γ-W1-T1 is also what extracts them into the one module the host will read through;
- after W1-T2 and W2-T1, because every control that could be a parameter must exist before the list is declared — the list is derived from the control inventory, and an inventory that is still growing produces a list that has to be re-frozen;
- after W2-T1 specifically, because
isLiveDeckParambecoming three-valued is the prerequisite of the classification, not an incidental of it: the exposed set is exactlyLive ∪ NoteOnLatched; - after W3-T1, because MASTER's inventory (the limiter toggle, the GR bubble, the reserved cell) is the last change to what controls exist at all;
- after W3-T2, so the bake's reset list is already complete when Γ-W4-T1 adds the host-notification obligation over it — one amendment instead of an amendment to an amendment.
The result is a single-track Γ-W4, which is the right shape for it anyway: the storage decision governs every part of the work, exactly as Ξ-W2-T1's crossing decision governs its chain. And it satisfies Daniel's own framing literally — "by the end of gamma we have the automatable params reported."
The params-payload ladder — re-checked, and now stated RELATIVELY. The old block named
v14 and v15 as absolutes. That is no longer safe to assume, because Ξ ran ahead of its
sequencing and this plan is not the record of what Ξ-W2-T1 actually took. On dev today
kParamsPayloadVersion is 14 (map/component_state_io.h:163) and Ξ-W2-T1 was specced to
take no rung — but the plan's prediction is not evidence. So:
Γ owns the next three rungs above whatever
devcarries when Γ-W1-T2 dispatches, and that number is READ, not assumed. In order: the first rung to W1-T2 (the limiter enable flag), the second to W2-T1 (rate + pitch offset), the third RESERVED for W4-T1 — spent only if the storage-architecture verification forces a persisted field, which the specification says it will not (docs/product/parameter-automation.md§6.1, §10). If unspent, that rung falls through to the next phase unclaimed.On
devas of 2026-08-01 that resolves to v15 / v16 / v17-reserved. Ξ-W3-T1 landed and consumed a rung (v14, the bake Hold division) — not Ξ-W2-T1, which took none as specced — so every number shifted by one and nothing else about the ownership changes — which is the whole point of stating it relatively.
Every other track in the phase owns no rung: W1-T1 changes no persisted field (the payload
stores raw engine doubles, so both the taper and the new ceiling are persistence-neutral),
W1-T3, W1-T4 and W1-T5 add no field, W2-T2's loop enable maps onto the already-persisted
SampleLoop::hasLoop, W3-T1 is layout only, and W3-T2 changes a reset list, not a format.
The editor's deck is knowingly mis-composed between Γ-W1-T4 and Γ-W3-T1, and that is not a defect report. Raising the floor without the reflow leaves the greedy whole-group wrap packing two ragged left-aligned rows with categorically wrong membership. Γ-W1-T4 states the exact interim layout; do not "fix" it in a track that does not own it.
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 == nullptrbyte-identical to the pre-live core and the filter's exact skip atmodAmount == 0hold the at-rest path unchanged. - No
ComponentStatesound 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 and raising the stage-time ceiling (both Γ-W1-T1) change needle angles only — the payload stores raw engine doubles, so saved values reload bit-identical, and a 3 s stage saved at the old ceiling is simply unreachable-by-hand rather than altered. 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. From Γ-W4-T1 it is also a frozen host normalization — one more reason, not a new rule.- The taper has exactly ONE home and three consumers, and from Γ-W4-T1 the taper IS the
host's
toPlain/toNormalized. Γ-W1-T1 extracts it into a pure module; the knob's needle (deck_values), the AHDSR overlay's schematic axis (envelope_overlay+envelope_edit), and the host'snormalizedParamToPlain/plainParamToNormalized(Γ-W4-T1) all call the same function. Three functions that agree today is a defect, not an implementation choice — the failure it prevents is a host automation lane that means one value and a needle that draws another. Under Ruling 3 this stops being an analogy:toNormalizedis not like the taper, it is the taper (docs/product/parameter-automation.md§6.7.3). - ONE formatter per unit category, and the editor and the host are both its callers. The
formatter is pure and returns the digits of a plain value in that category's single
unitsstring — no embedded unit, no magnitude-switched unit, no width-conditional abbreviation, no caller-side branch. The editor's knob label andgetParamStringByValueread the same function. The editor and the host printing different text for the same stored value is a defect class, forbidden structurally rather than caught at review — same discipline, same reason, as the taper criterion above. Consequences (existing formatters stop embedding their unit; cutoff'skabbreviation is retired; the curve dial's^is static cell chrome, not value): spec §6.7.2. - Every default value has an EXACT normalized preimage under its own taper. Binds
Γ-W1-T1 (which designs the taper) and Γ-W4-T1 (which declares
ParameterInfo::defaultNormalizedValue).resetDeckParambypasses the taper; a host's reset-to-default cannot, so exactness in the map itself is the only thing that makes the editor's reset and the host's reset land on the same value. Ruling 3 tightens this twice and adds one non-requirement (§6.7.7):defaultNormalizedValueis computed astoNormalized(default), never written as a normalized literal; the assertion is made ontoPlain(defaultNormalizedValue), the pair the host actually calls; andtoNormalized(toPlain(n)) == nat arbitrary n is explicitly NOT required — no log map satisfies it in double, and demanding it would over-constrain the taper for nothing. - Nothing in this phase may re-map the filter's four normalized controls. Cutoff, Q,
morph and drive persist as
*Normdoubles in payload v9 — their laws are already wire-frozen, and re-tapering them would re-tune every saved project independently of automation. The snap-unit table names them; that is display, not law. This does NOT conflict with Ruling 3's real-unit requirement, and the two must not be read as a collision:toPlainis a pure read-side mapping that never touches the stored value, so reporting Hz / Q / drive depth means callingfilterCutoffHzFromNormand its peers, not replacing them — which the editor's own labels already do today. The prohibition forbids editing those laws; the requirement is satisfied by calling them. One additive gap: drive has no published inverse andfilterNormFromDriveDepthmust be added beside the two that exist — the analytic inverse of a frozen law is not a change to it. Spec §6.7.5. - Shift-snap is a drag rule;
stepCountis a parameter property; they are independent. The editor's snap grid must never be exposed asParameterInfo::stepCount— that would quantize the parameter itself, permanently and for the host's automation too, freezing the grid into the forever contract and putting continuous cents out of reach from a lane. All 44 exposed parameters shipstepCount = 0, swept and confirmed, and the coincidence is structural: every discrete control is reload or rebuild tier and therefore omitted by the predicate. Spec §6.7.6. - From Γ-W4-T1, the parameter-id table is FOREVER-FROZEN, on the same footing as the
extension's
"STABLE_FOREVER_STRING"command ids, the two VST3 class UIDs, and the params-payload field order. No id is reassigned, reused or re-pointed; no exposed parameter's normalization ever changes; a retired control's id is retired with it. Full wording:docs/product/parameter-automation.md§6.3. - The exposed parameter set is DERIVED, never hand-maintained. A control is a parameter
if and only if its commit class is
LiveorNoteOnLatched. There is no second table besideisLiveDeckParam/liveCommitFor, and no list that can drift from it. - The window floor is 1190 × 680 and must not exceed 1280 × 720. Γ-W1-T4 sets it, in
wave 1; no other track in the phase may move it, and from that point every track is
authored and judged at it. A track that pushes the floor past 1280 has failed, not overrun.
kEditorMinHeightstays 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. Five tracks, disjoint by surface — re-verified against this membership rather than carried over from the four-wave shape:
| Track | Owns |
|---|---|
T1 knob-interaction-law |
a new pure taper module under core/instrument/ui/, ui/deck_values, ui/envelope_overlay + ui/envelope_edit (the AHDSR schematic axis and its drag inverse), ui/param_slider, the three shell/instrument/editor_input_* drag paths, editor_controls.cpp's envClampBounds only, the shared modifier helper in editor_internal.h |
T2 master-bus-audio |
new pure engine/limiter + engine/meter_ballistics, shell/instrument/reasampler_processor + processor_state, map/component_state_io + params_payload (the wave's payload rung) |
T3 contour-trace-curves |
shell/instrument/editor_paint_waveform.cpp's staged trace + a new pure tessellation module |
T4 editor-floor-and-row-law |
ui/sample_bands.h (the floor), ui/knob_deck.h (budget constants + two invalidated header notes), ui/deck_groups (the row predicate only), five test fixtures |
T5 preserve-time-stretch |
engine/pitch_shift + a new pure stretcher module, engine/voice.{h,cpp}'s Preserve read path |
Two shared files in the wave, named rather than discovered at merge.
src/core/instrument/engine/CMakeLists.txt — T2 declares two new pure libraries and their
test targets there, T5 declares one. And, newly, src/core/instrument/ui/CMakeLists.txt —
T1 declares the taper module and its test target, T3 declares the tessellation module and
its. All four are append-only additions in separate blocks — textual merge adjacency, not
semantic contention. Whichever lands second rebases.
Three near-misses that are avoided by construction, and must stay avoided.
(a) T3's tessellation helper lands in a NEW pure module — explicitly NOT
ui/envelope_overlay, which T1 now owns, and not in editor_internal.h, which T1 is also
editing. The prior wording offered envelope_overlay as an option; Ruling 2 removed it,
because T1's schematic-axis work rewrites that module's whole time→x map. This still
satisfies the phase's geometry-stays-pure criterion, so it costs nothing.
(b) T1 and T3 are disjoint by file but coupled by data, and the coupling has a stated resolution. T1 owns where an AHDSR's vertices land; T3 owns the stroke between vertices. T3's tessellation is over φ across a segment's pixel span, so the tapered axis changes nothing about the curve it draws — but T3's tests must assert against the returned vertices, not against absolute pixel literals, or they break when T1 lands. Whichever track lands second rebases; expressing T3's assertions relatively makes that rebase free.
(c) T4 touches deck_groups but adds only the new row predicate; it does not touch
sampleDeckGroups, which W2-T1 and W3-T1 own in later waves, and it does not touch
deck_values, which is T1's.
Two consumption boundaries worth stating, because they look like collisions and are not.
T1 consumes engine/master_gain's dB taper for its whole-dB snap and does not edit it;
T2 does not touch it either. And T2's payload rung is the wave's only format change —
T1's taper and ceiling changes are persistence-neutral by construction (the payload stores
raw engine doubles).
Both of the phase's DSP unknowns are in this wave — T2's limiter and T5's stretcher. That is deliberate: they are the two tracks whose gate can fail, and failing in wave 1 is recoverable in a way that failing in the last wave is not.
Γ-W1-T1 — knob-interaction-law
Goal. One consistent, unit-category-driven interaction and taper rule across every variable control, over a stage-time range raised 2 s → 10 s, landed before any new control is added so the new ones are authored into it rather than retro-fitted — and before any parameter is declared, so the law is what the host is handed rather than something the host has to be reconciled with later.
Spec: docs/product/instrument-control-surface.md §4, §4.3.1 (the 10 s ceiling and the
overlay-legibility design — new, read it before scoping this track), and
docs/product/parameter-automation.md §8 (the one-way-door sweep this track discharges).
Surface boundary — owns: a new pure taper module under core/instrument/ui/ (the
ms/semitone/exponent maps, extracted so they have one home),
core/instrument/ui/deck_values (the bindings, the snap-unit table, resetDeckParam),
core/instrument/ui/envelope_overlay (the ceiling constant and the AHDSR schematic
axis) and core/instrument/ui/envelope_edit (its drag inverse),
core/instrument/ui/param_slider (the drag law), shell/instrument/editor_input_*
(modifier read + re-anchor), shell/instrument/editor_controls.cpp's envClampBounds
only (it reads kEnvTimeMaxSeconds), and the modifier-reading helper the three input
paths share. Does not own any deck descriptor, any parameter, the waveform painter, or
engine/master_gain (consumed, not edited).
Why this track does NOT split, asked and answered. Ruling 2 makes it materially bigger — tapers, modifiers, re-anchor, reset bypass, the ceiling, and the overlay's schematic scale. Two splits were considered and both are serial, not parallel, so neither buys any concurrency: an interaction half (modifiers, snap, re-anchor) needs the domain half's taper and snap-unit table to exist first; and a standalone overlay-axis track needs the taper module and the final ceiling before it can define a stage's slot width. Splitting would therefore cost a wave and gain nothing, while putting the single most identity-critical function in the phase across a wave boundary — the same function the host will normalize against three waves later. The seam that matters is internal and is a deliverable: the taper is extracted into its own pure module, which is what makes "the taper IS the host-facing normalization" structurally true rather than a comment. The ~600-line ceiling is a per-file bar, and the extraction is what keeps every file under it.
Behavior.
- Shift snaps to whole numbers in the control's displayed unit; Ctrl scales the drag by 0.05; Shift+Ctrl = Shift wins (Ctrl is ignored — with an integer-quantized output a finer drag yields the same sequence, so this is identity, not a compromise).
- Snap unit by category: ms knobs → whole ms; semitone knobs (incl. Rate, when it arrives) → whole semitones; percent/fraction knobs → whole percent; the 12 curve-exponent inner dials → whole numbers (which puts 1.0, the linear neutral, one snap away); master gain → whole dB; already-integer controls unchanged. Full table in the spec §4.2.
- Mid-drag modifier transitions re-anchor — on every press and release during an active
drag, the current value becomes the anchor value and the current cursor position the anchor
position. The value is continuous across the transition; only the rate changes. Without
this the grab-anchored absolute drag (
kKnobDragRangePixels = 128) jumps by(1 − 0.05) ×the accumulated delta. - Millisecond knobs become log-scaled. Exactly 0 s at norm 0 and exactly
kEnvTimeMaxSecondsat norm 1, monotone throughout; 10 ms lands within 0.12–0.20 of travel and 100 ms within 0.42–0.52. - The stage-time ceiling moves 2.0 s → 10.0 s (Daniel, reversing Γ-F3):
kGateStageMaxSeconds(envelope_overlay.h:85) and, through it,kEnvTimeMaxSeconds(deck_values.h:22). The two move together or not at all —deck_values.hreads the overlay's constant rather than restating it precisely so they cannot drift (deck_values.h:19-22). The taper's landmarks above are fit against the new ceiling, which is why the ceiling cannot be a follow-up: fitting the taper twice is the only other way to get there. resetDeckParam's bypass becomes MANDATORY rather than merely required-anyway.deck_values.h:42-46records that exact default recovery depends on the ceiling being a power of two; 2.0 is, 10.0 is not, and the log taper compounds it. Nothing here may be "simplified" back into a norm round-trip under any circumstance.- NEW, and the sharpest requirement in the track: every default must have an EXACT
normalized preimage under its taper.
ParameterInfo::defaultNormalizedValue(Γ-W4-T1) is normalized, so a host's reset-to-default arrives astoPlain(defaultNorm)— and the host has noresetDeckParambypass to use. The bypass fixes the editor's reset and cannot fix the host's; only exactness in the map itself makes the two land on the same value. This binds the taper's shape, so it belongs here and cannot be handed forward. Master gain's unity (≈ 0.714 norm) is the case where a hair off is audible. - The AHDSR overlay's schematic axis becomes the taper — the ceiling's real cost, and it
is design work, not a constant change. Each of the four timed stages gets an equal slot
and today maps seconds across it linearly (
gatePxPerSecond,envelope_overlay.cpp:33-34). At 2 s a 30 ms attack is 1.5 % of its stage's domain; at 10 s it is 0.3 %, under a pixel at the floor width. The fix: a stage's slot width becomesslotPx × taperNorm(seconds)instead ofslotPx × seconds / ceiling, so a node's position within its slot is its knob's needle position. Legibility becomes ceiling-independent by construction; the one-model invariant gets stronger rather than strained; and the drawn curve is unaffected, because the taper decides only where a stage's end node lands while φ still runs linearly across the stage's pixel span — so Γ-W1-T3's φ^p trace composes with it rather than fighting it. The AHD policy is untouched: an AHD maps 1:1 onto the waveform's own PCM-aligned time axis and stays linear in seconds. Two alternatives (content-fit auto-scale; a minimum drawn stage width) were considered and rejected — spec §4.3.1 names why, and neither is to be reintroduced as a "simplification." envelope_edit's drag inverse must remain the EXACT inverse of the draw. Both read the same taper module; a node dragged to a pixel and the knob's value at that pixel are one number, not two that agree.- The taper is EXTRACTED into its own pure module, with its own
<module>_teststarget, because it now has three consumers in two different dependency layers:deck_values(which sits aboveenvelope_overlay),envelope_overlay/envelope_edit(which sit below it), and — from Γ-W4-T1 — the host. Leaving it insidedeck_valueswould force an inverted include edge. Do not solve that by copying the map. - Semitone knobs become log2/centre-expanded. Symmetric, exactly 0 at centre, exactly
±
kPitchDepthMaxSemisat the ends, monotone; ±7 st reached at 50–58 % of each half-travel. resetDeckParambypasses the taper — it writes the default value directly instead of round-tripping throughnorm → 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
PlaySecondsrather than a round trip. - The log/log2 landmark positions above are asserted in the taper module's own tests, and hold at the 10 s ceiling — the fit is against the new ceiling, not the old one.
- Every default round-trips exactly through
norm → value, asserted per unit category against a default-constructedPlaySecondsand againstmaster_gain's unity. This is the criterion Γ-W4-T1 will declaredefaultNormalizedValuefrom; it fails here, not there. - A stage time of several seconds is reachable by hand with no loss of resolution below
100 ms, and
kEnvTimeMaxSeconds == kGateStageMaxSecondsis asserted, not assumed. - A project saved at the 2 s ceiling reloads with identical stored seconds and identical audio — the ceiling change is persistence-neutral for the same reason the taper is.
- The AHDSR overlay reads legibly at both ends of the new range: a default 3 ms attack is a visible, grabbable node at the floor width, and a 10 s decay still lands its end node at its slot's edge. Assert the node separation, then judge the result by eye in the DAW.
- The overlay's drag inverse is the exact inverse of its draw at the tapered axis —
nodeAtPoint/resolveNodeDragandbuildEnvelopePolylineround-trip. - The AHD 1:1 policy is unchanged, asserted: a sustain-less envelope's x-axis stays wall-clock over the waveform.
- The taper module is pure, CTest-covered, and is the ONLY definition of each map — a
grep finds no second copy in
deck_values,envelope_overlay, or the shell. - The filter's four
*Normcontrols are untouched by the taper pass — cutoff, Q, morph and drive are already wire-frozen in payload v9; a regression baseline proves their audio is unchanged. - One shared modifier-read helper serves all drag surfaces; no second modifier grammar exists.
Open questions.
- No [Daniel] questions. Fork Γ-F3 is REVERSED: the ceiling moves to 10.0 s, in this
track. Daniel's "a horrifically long decay with tight exp" is the case it serves, and
the
docs/TODO.mdentry that carried it is discharged rather than deferred again. The reversal's cause is Ruling 1 — a range endpoint is host-facing normalization, free to move now and permanently expensive after Γ-W4-T1. Both of the prerequisites the deferred entry named are in this track anyway: the log taper (which is what makes a higher ceiling usable at the low end rather than unusable) and the reset bypass (which retires the power-of-two dependency — 2.0 is a power of two, 10.0 is not). The engineer should know the reset bypass is now doing triple duty and must not be "simplified" back into a norm round-trip under any circumstance. - [propose at review] the exact shape of the taper, subject to the landmark bounds and the exact-default-preimage requirement. Those two together are tighter than either alone, and the second is easy to satisfy by accident and easy to lose in a refactor — assert it, do not observe it.
- [propose at review] whether the tapered schematic axis wants a visible tick or gradation cue, now that it is no longer linear in time. The plan's lean is no — the ms labels carry the number and the editor's no-decoration policy stands — but a reader who finds the axis illegible in the DAW should say so rather than silently adding one.
Γ-W1-T2 — master-bus-audio
Goal. The master limiter and the meter's audio and publication halves, plus the plugin's first latency reporting — no editor drawing. Landing the audio ahead of the deck is what lets Γ-W3 draw against real published state instead of a stub.
Spec: docs/product/instrument-control-surface.md §3.1, §3.1.1 (latency — read this
first; it was rewritten when Γ-F6 closed, so an older reading of it is wrong), §3.2–3.3,
§3.5, §7.10, §7.11, §8.2.
Surface boundary — owns: a new pure limiter module and a new pure meter-ballistics
module under core/instrument/engine/ (each with its own <module>_tests target),
shell/instrument/reasampler_processor (the chain, the published block state, and the
getLatencySamples / restartComponent(kLatencyChanged) path), and the phase's FIRST
params-payload rung (the limiter enable flag). Does not own MASTER's deck geometry or
any drawing — that is Γ-W3-T1. Read kParamsPayloadVersion on dev and take the next rung
above it rather than assuming the number — Phase Ξ ran ahead of this plan's sequencing, so
the plan is not the record of what the ladder currently carries. On dev as of 2026-08-01
that resolves to v14.
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 callsIComponentHandler::restartComponent(kLatencyChanged). None of this exists today — there is nogetLatencySamplesoverride, nokLatencyChanged, and norestartComponentcall site anywhere insrc/; the plugin ships the SDK default of 0. This track introduces the plugin's first latency reporting. - The restart is routine; the fencing is against a standing scar, not against the flag.
Dynamic latency reporting is ordinary VST3-instrument behaviour and REAPER handles it as a
matter of course. The SDK's deactivate/reactivate requirement
(
pluginterfaces/vst/ivsteditcontroller.h:105-108) is the normal contract. What makes the cycle expensive here is this plugin's ownsetActive— reactivate callsreloadInstrument(), a bridge read plus a full WAV re-decode (reasampler_processor.cpp:89-97), where a typical plugin only allocates buffers; deactivate freeslive_/draining_/graveyard (:98-107) for a documented reason (ghost sustained voices). Γ-F6 is ruled: ship it — the toggle is a patch-design gesture, not a during-playback one. Do not build a constant-reported-latency fallback and do not gate the deliverable on a measurement. The reduction of that self-inflicted cost is filed indocs/TODO.md("Decouple the instrument reload from VST3 activation") with its trigger condition; it is out of scope here. The four requirements below survive as engineering hygiene against thekIoChangedscar, and all four are acceptance criteria:- Verify the whole call sequence against the vendored Steinberg SDK before writing it,
including the ordering rule that the new latency is what
getLatencySamplesreturns aftersetActive(true)— so the reported value must derive from persisted state, not from a transient the deactivate clears. - Prove the restart does not disturb the output bus arrangement. The output stays one permanently-stereo bus, never renegotiated.
- Ship a regression test in the spirit of
testDualMonoStereoSampleRendersCentered— a dual-mono capture rendered across a limiter toggle stays centered, L ≡ R. restartComponentis never called fromprocess(). Main/UI thread only, and coalesced so repeated clicks produce one restart per settled state. This is NOT the changereasampler_processor.cpp:66-68forbids. That warning is against reintroducing per-mode bus renegotiation (kIoChangedclass), which panned a dual-mono capture hard right in the host's pin re-routing;kLatencyChangedis a different flag and the bus is untouched. But the precedent — mid-sessionrestartComponentin this plugin has already shipped one real regression — is exactly why (2) and (3) are non-negotiable.
- Verify the whole call sequence against the vendored Steinberg SDK before writing it,
including the ordering rule that the new latency is what
- Flipping the toggle during playback: apply immediately, do NOT defer to a transport
boundary (product ruling, spec §3.1.1). A deferred restart leaves the plugin misaligned by
the lookahead with no visible cue, which is worse than a visible interruption; and the host,
not the plugin, schedules the deactivate/reactivate anyway. Daniel has accepted the
interruption outright (Γ-F6) — it is not a case to design for. Two things remain in scope,
and neither is a mitigation for it:
- A short (≤ 10 ms) equal-gain crossfade over the engage/disengage. Kept as a quality
measure, not a mitigation: a limiter engaging is a gain-path change, and this codebase
already ramps every gain-path change (
kGainRampSeconds,ValueRamp). It also earns its keep independently of the restart, because we do not control when the host acts on the request — our own transition must be clean in the window before it does. - The limiter enable is classified NOT automatable
(
docs/product/parameter-automation.md§3.8) so nothing can flip it at rate. It is also not the plugin'skIsBypassparameter.
- A short (≤ 10 ms) equal-gain crossfade over the engage/disengage. Kept as a quality
measure, not a mitigation: a limiter engaging is a gain-path change, and this codebase
already ramps every gain-path change (
- Per block the processor publishes, as relaxed atomics: per-channel peak
max|x|, a latched clip flag, and the block's maximum gain reduction. No dB conversion, no ballistics, no hold timers on the audio thread — the UI converts and runs ballistics from block peaks and elapsed time. This widens the existing advisory-peak pattern (reasampler_processor.h:109-113), which is not reusable as-is. - Meter ballistics (pure, unit-tested): instantaneous rise; fall 20 dB/s; peak-hold latched at the running max, held 1.5 s, then falling at the same rate; scale linear in dB over −60…+6 dBFS; clip latches at block peak ≥ 0 dBFS and is cleared on request.
- The
ComponentStatepayload rung appends the limiter flag as a strict suffix on the existing discipline; the preceding version's blob is a strict prefix and lifts to bypassed.
Acceptance criteria.
- With the limiter bypassed the rendered output is byte-identical to the pre-change build, asserted by a regression baseline, not by ear.
- With the limiter engaged, no output sample exceeds the ceiling on program material that exceeds it by up to +12 dB; with it bypassed and gain driven, the output does exceed 0 dBFS (proving the toggle is doing the work).
- Nothing is louder at rest with the limiter on. A signal that never reaches the threshold is bit-identical engaged and bypassed.
- No allocation, no lock, no transcendental on the per-sample path; the measure-and-report gate reports per-voice-block CPU with the limiter engaged at 32 voices.
- The meter-ballistics module is pure and CTest-covered: rise, 20 dB/s fall, 1.5 s hold, clip latch/clear, and the dB↔pixel map are all asserted without a host.
- A project saved before this change reopens with the limiter bypassed and sounding identical.
getLatencySamples()returns exactly 0 with the limiter off, and the lookahead in samples with it on — asserted against the persisted flag, and correct across a deactivate/reactivate cycle.- A dual-mono capture rendered across a limiter toggle stays centered (L ≡ R), and the output bus arrangement after a latency-change restart is identical to before it.
- The plugin emits no hard step at the toggle — the engage/disengage crossfade is asserted on a rendered signal, not judged by ear.
Open questions.
- No [Daniel] questions. Fork Γ-F6 is ruled — dynamic latency ships as specced, the deactivate/reactivate is accepted, and there is no fallback design and no measurement gate. Do not reintroduce either; the constant-reported-latency option is closed, not shelved.
- [verify]
temp_cortex/has already been assessed and rejected (spec §3.5) — do not re-litigate it, and do not transplant from it. - [record, not a gate] While the limiter is in REAPER under your hand, note what the
restart actually costs — do notes cut, is the re-decode perceptible, does transport hiccup —
and record it in this track's review. It is not a gate on shipping and no outcome changes
the design; it is the trigger-condition evidence for the
docs/TODO.mdentry "Decouple the instrument reload from VST3 activation," which is where that cost gets reduced if it ever matters. Do not restructuresetActivehere: 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 a new pure tessellation module for it. Does not own the loop/crossfade
marks (Γ-W2-T2), envelope_overlay's vertex model, or the drawn-EG (spline) trace.
The helper's home is now constrained, not a choice. ui/envelope_overlay was previously
offered as a candidate home for the tessellation helper; Γ-W1-T1 now owns that module
(the AHDSR schematic axis, per Ruling 2), so the helper lands in a new pure module under
core/instrument/ui/. T1 also owns where an AHDSR's vertices land — this track owns only the
stroke between vertices, and tessellates over φ across a segment's pixel span, so the
tapered axis changes nothing about the curve drawn. Express this track's assertions against
the returned vertices, not against absolute pixel literals, and the rebase onto T1 is free.
Behavior. The defect is verified: editor_paint_waveform.cpp:218 drops knots
(if (v.knot) continue;) and joins the remaining vertices with straight strokes, and
curveMap is never called in the paint path even though the exponent is in scope at :211.
Knot positioning already honours the exponent via curveMidLevel
(envelope_overlay.cpp:94-105) — that divergence is the visible symptom. The fix draws each
sloped stage through the same curveMap the audio uses, so trace and sound cannot
diverge; tessellation approach is the engineer's call.
Acceptance criteria.
- At every exponent the knot's centre lies on the trace, within 1 px — the reported defect, stated as the gate.
- At exponent 1.0 the segment is visually identical to today's straight line.
- No visible faceting at the widest segment the canvas can produce; a fixed low tessellation count is not acceptable at full width.
- All three envelopes, both play modes, all sloped stages (attack/decay/release) — one paint path, one fix.
- The established trace grammar is unchanged: one weight,
kEnvTracePx = 2.0, through the analytic stroker. Both overlay layout policies (AHDSR right-anchored schematic, AHD 1:1) are honoured unchanged. The spline overlay's own trace is untouched. - Audio is unchanged — this is a drawing defect only; a regression baseline proves it.
Γ-W1-T4 — editor-floor-and-row-law
Goal. Commit the canvas — the window floor, the width budget it is derived from, and the row every deck group belongs to — so every other UI track in the phase is drawn, tested and judged at the final window size. The arrangement inside that canvas is Γ-W3-T1's.
Spec: docs/product/instrument-control-surface.md §1.1 (the two categories), §1.2 (the
floor arithmetic block), §1.6 (the headroom ledger), §7.1 and §7.4 (the two invalidated
knob_deck.h notes).
Surface boundary — owns: core/instrument/ui/sample_bands.h (kEditorMinWidth),
core/instrument/ui/knob_deck.h (the declared budget constants and the two invalidated header
notes), core/instrument/ui/deck_groups.{h,cpp} (the new row predicate only), and the five
test fixtures that read the floor — test_sample_bands.cpp, test_deck_groups.cpp,
test_knob_deck.cpp, test_sample_chrome.cpp, test_keyboard_strip.cpp. Does not own
layoutDeck / deckRowCount / deckHeight behaviour, the justification law, any descriptor,
MASTER's inventory or interior, any painter, or any parameter. It changes no drawing code at
all.
Behavior — what it commits.
kEditorMinWidth980 → 1190.kEditorMinHeightstays 680 (Γ-F1).- Three declared budget constants in
knob_deck.h: the row block both rows will justify inside (1020), the right-anchored spanning deck's reserved width (MASTER 142), and the ceiling (1280). These are declarations of budget, not measurements — nothing computes them from a descriptor, and Γ-W3-T1's job is to prove its content fits inside them. - The floor is derived, not asserted as a literal.
1020 + kDeckGroupGap(12) + 142 + 2·kPad(8) = 1190.kEditorMinWidthstays a literal insample_bands.h— do not add an include edge fromsample_bandstoknob_deck, which would invert the allocator's deliberate independence from the deck (it takesdeckHeightas a parameter for exactly that reason). The identity is asserted intest_deck_groups.cpp, which already includes both headers. This is the Θ-W6-T1 derived-floor precedent, landed once and never rewritten. - Row membership becomes a property of the group id:
DeckRow { Sound, Contour, Spanning }deckRowFor(DeckGroupId)indeck_groups, an exhaustive switch on theisLiveDeckParamdiscipline, so a future group is a compile error rather than a silent default. Partition: Sound = PITCH/RATE, FILTER, VELOCITY, VOICE; Contour = PITCH ENV, FILTER ENV, AMP ENVELOPE; Spanning = MASTER. Nothing consumes it until Γ-W3-T1 — that is the seam, and it is why the predicate is safe to land now: membership is a property of the group, width is a property of the descriptor, and only the widths are still moving.
- Γ adds no new deck group, so no later track amends this predicate.
The seam, stated as what this track can and cannot assert.
Can assert today:
- The derived floor identity above, and
kEditorMinWidth ≤ 1280with 90 px of headroom. kEditorMinHeight == 680, asserted so no later track drifts Γ-F1's ruling.deckRowForis total overDeckGroupIdand yields exactly the partition above.- Row 2's natural width already fits the block, in both play modes:
252 + 312 + 312 = 876 ≤ 1020, leaving both its gutters ≥
kDeckGroupGap. Mode-stable because FILTER ENV's and AMP's reserve slots hold them at 312 in Gate and Trigger alike. - MASTER's reserve is not yet spent:
deckGroupWidth(MASTER) == 72 ≤ 142. - At the floor, deck band 216 and waveform band 358 — the reflow's 112 px arrives here, two waves early (see the interim layout below).
Cannot assert yet, and must not force:
- Row 1's natural width does not fit the block. Today it is PITCH 150 + FILTER 524 +
VELOCITY 192 + VOICE 164 = 1030, against the 1020 block. The 50 px deficit is exactly
what the two descriptor changes buy: PITCH → PITCH/RATE +42 (Γ-W2-T1) and FILTER's
Band|Notchmoving to the caption corner −92 (Γ-W3-T1), netting 980. Record the target and the two contributions as a test comment; assert the fit in Γ-W3-T1, and do not pre-empt either descriptor change to close it early. - Gutter distribution, the filter tie-line at x = 636, flush outer edges, MASTER's interior and its meter — all Γ-W3-T1. Every one of them measures a descriptor that does not exist yet.
The interim editor, stated exactly so it is not filed as a defect. At the new floor,
availWidth = 1190 − 2·kPad = 1174, and the unchanged greedy whole-group wrap packs:
row 1 PITCH 150 · PITCH ENV 252 · FILTER 524 = 950 used, 224 px ragged right
row 2 FILTER ENV 312 · AMP 312 · VELOCITY 192 ·
VOICE 164 · MASTER 72 = 1100 used, 74 px ragged right
Two rows, not three — so the deck band is already 216 and the waveform already draws at its final 358 px, in both Gate and Trigger. After Γ-W2-T1 lands PITCH/RATE the pack is row 1 = 992, row 2 unchanged; still two rows. The composition is wrong in exactly the way the reflow exists to fix — PITCH ENV sits up with the sound decks, VOICE and MASTER sit down with the envelopes, MASTER is still a single-height 72 px box, and both rows are left-packed with dead space at the right. Worse than today in composition, better in proportion. That is the accepted transitional state for the rest of the phase.
Do not convert the two-row interim into a claim. It is a coincidence of the greedy wrap at
exactly this width, not a guarantee — which is precisely why Γ-W3-T1's criterion is "two rows
by construction, asserted against the group inventory, not observed as a wrap outcome."
testDeckFitsInsideTheEnforcedMinimumWindow currently asserts deckRowCount == 3; relax it to
an upper bound (<= 2), which is a real regression canary throughout the interim and is
subsumed by Γ-W3-T1's exact claim. An exact == 2 here is acceptable only with a comment
naming it as a wrap outcome the reflow replaces.
Acceptance criteria.
- The floor is 1190 × 680, reached by a derived test over the three budget constants, not by a literal — and the derivation is the one Γ-W3-T1 later reads rather than a second copy.
- Headroom is exactly 90 px against the 1280 ceiling, asserted.
deckRowForis exhaustive overDeckGroupId; adding a group without classifying it fails to compile.- Row 2's natural width and MASTER's unspent reserve are asserted, in both play modes.
- All five floor-reading test fixtures pass at the new floor — including the chrome row, whose title slot gets more room at 1190, not less.
- No drawing code changes, no descriptor changes, no parameter changes, no audio change. A regression baseline proves the last of those trivially.
- The two invalidated
knob_deck.hnotes (§7.1's fourteen-pixel headroom figure, §7.4's cells-and-floor pairing) are re-derived against the new floor, not deleted — §7.4's restatement is the deck's cell metrics AND its group/row composition both drivekEditorMinWidth; none of the three may move alone.
Open questions. No [Daniel] questions. [propose at review] whether the three budget
constants belong in knob_deck.h (the deck owns the row block and the spanning-deck reserve)
or in sample_bands.h (the allocator owns the floor they derive). The plan's lean is
knob_deck.h with the identity in the test, because it adds no include edge; either is
defensible, but the derivation must live in exactly one place.
Γ-W1-T5 — preserve-time-stretch
Goal. A real pitch-preserving time-stretcher for Preserve mode, written from established state-of-the-art literature — landed before the control that drives it, so Rate ships onto a finished engine rather than onto a disposable stand-in.
Spec: docs/product/instrument-control-surface.md §2.5.
Moved from Γ-W4-T1 (Daniel, 2026-08-01). It is the longest pole in the phase and has zero dependency on any UI work. The consequence is the interesting one: it inverts the relationship with Rate. Under the old order the stretcher was Rate's quality upgrade and Γ-W2-T1 shipped an interim resample-and-cancel path to make Rate complete on day one; under this order the stretcher is Rate's prerequisite and the interim path is not built at all. Skipping a stand-in that was only ever going to be deleted is the win; see Γ-W2-T1's named contingency for what happens if this track's gate slips.
Precedent for landing a DSP module ahead of its consumer: Θ-W1-T3 (filter-dsp-port)
landed the filter DSP as a standalone pure module a wave before Θ-W2-T1 wired it into the voice
path, for the same reason — the unknown is the DSP, not the wiring.
Surface boundary — owns: core/instrument/engine/pitch_shift and whatever new pure module
the stretcher needs (each with its own <module>_tests target), plus voice.{h,cpp}'s Preserve
read path. Does not own any parameter, any UI, the varispeed path, or the deck. It adds no
ComponentState field and takes no rung of the payload ladder.
Behavior and constraints. The algorithm is the engineer's call under a measure-and-report gate — this plan deliberately names none. The constraints:
- The stretch ratio is an argument, not a parameter. Nothing publishes a non-unity ratio until Γ-W2-T1's Rate knob does. Until then the Preserve read path runs at ratio 1.0 and must be bit-identical to the shipped Preserve read — a stronger and cheaper regression gate than the old plan's A/B-against-an-interim-path, because the baseline is a build that exists.
- CPU stance (Daniel, verbatim intent): "we should be efficient but accept the cost of high-quality algorithm choices. It's 2026, most people's computers can handle audio with ease. Just don't be wasteful."
- RT-safe: no allocation, no I/O, no lock in
process(); buffers sized at voice allocation or at the off-audio-thread reload, onpitch_shift's existing pre-warm precedent. - Per-voice state, holding up at the 32-voice ceiling. The gate is 32 simultaneous Preserve voices at 50 % and 200 %, not one voice at 100 %.
- No new third-party dependency (
pitch_shift's standing property). - No dispatch on the per-sample path — concrete, inlineable types; no
IStretcher. - Onset behaviour is a regression surface. GA2 eliminated Preserve's ~25 ms onset latency by priming the ring with the actual upcoming source. A stretcher that reintroduces an onset delay or a first-frame smear is a regression, not a trade-off.
Acceptance criteria.
- Ratio 1.0 with no shift is bit-identical to the shipped Preserve read, asserted by a regression baseline — the null case, and the criterion that makes landing this ahead of Rate safe.
- Preserve speaks on frame 0 — no added onset latency, no first-frame smear, in any ratio/shift combination.
- No audible metallic or phasey artefacting on sustained tonal material at ±6 st and 75–133 % ratio; transient material at 50 % / 200 % is no worse smeared than varispeed playback at the equivalent ratio — the honest "what does preserving pitch cost" reference, and the one that needs no disposable implementation built to serve the comparison.
- The Gate sustain-loop contract is unchanged: loop the source, shift the output — loop points remain source-frame facts.
- Measure and report before the algorithm is final: per-voice CPU at 32 voices, added latency (must be zero at the onset), and A/B recordings on three material classes (one-shot, tonal sustain, full-mix bounce). Report to Daniel; the choice is not final until he has heard the A/Bs.
Open questions. [propose, with a measurement step] the algorithm family itself.
[verify] that core/instrument/CLAUDE.md's "WDL_Resampler is not a Preserve engine —
never wire it as the duration-preserving path" is honoured: under Preserve, Rate legitimately
changes duration, so a resampled read is an explicit duration control — but the
pitch-preserving mechanism must not be a resampler. No [Daniel] questions.
Γ-W2 — New controls, and the overlay's marks
Depends on Γ-W1 for — four dependencies, two of them new:
- T1 ← W1-T1 (taper law). 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.
- T1 ← W1-T5 (the stretcher) — NEW, and the reason the interim path is gone. Preserve Rate has no engine without it. Under the prior four-wave order this dependency ran the other way and was paid for with a disposable resample-and-cancel stand-in; the resequencing inverts it. Rate must not ship before its Preserve engine.
- T2 ← W1-T3 (the contour trace). Both write
editor_paint_waveform.cpp; running them together is a merge fight in one file. - T2 ← W1-T1 and W1-T4, weakly. W1-T1 also edits
editor_input_waveform.cpp(the modifier read), which T2 rewrites for marker hit-test routing — serial, so not a conflict, but T2 rebases onto it. And T2's "the enable costs no window width" criterion is now asserted against W1-T4's derived floor test rather than one this track has to write.
T1 additionally inherits engine/voice.{h,cpp} from W1-T5 — a hand-off, not a conflict:
W1-T5 defines the Preserve ratio seam, and T1 feeds it. Serial across waves by construction.
Disjointness — re-verified against this wave's membership, not carried over. Both tracks
stayed in W2 and nothing entered or left it, so the prior finding is re-checked and stands. T1
owns the parameter model, the engine and the deck descriptors; T2 owns the waveform band's
marks and their pure geometry and 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 — textual merge
adjacency, not 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. No new in-wave adjacency was created by the
resequencing: T2 touches neither deck_values nor deck_groups nor voice.
One cross-wave hand-off, new with Ruling 2 and NOT a contention. Rate's taper — linear in
semitones over ±12, the stated exception to the centre-expansion law — belongs in the taper
module Γ-W1-T1 extracts, since that module is the one home of every map. T1 therefore
appends a law to a module a previous wave created. Serial across waves by construction, the
same shape as its engine/voice.{h,cpp} hand-off from W1-T5. What would be wrong is a
second taper defined inside deck_values' binding — one home, appended to, not forked.
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 the
phase's second payload rung exactly as specced (v15 on dev as of 2026-08-01; read the
ladder rather than assuming the number).
Γ-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 (the phase's SECOND payload
rung — v15 on dev as of 2026-08-01; read the ladder, do not assume the number),
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 time-stretcher itself (Γ-W1-T5, already landed
by the time this track runs).
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. Rate drives the stretch ratio Γ-W1-T5's stretcher already
consumes — there is no interim path. The stretcher is this track's prerequisite, not its
successor; the resample-and-cancel stand-in the prior plan carried is retired unbuilt (see
Open questions for the contingency).
core/instrument/CLAUDE.md's "never wireWDL_Resampleras the duration-preserving path" is honoured by construction. - 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 byapplyLive.isLiveDeckParam/liveCommitForwidens 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;
captionWidth70, 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 theVarisp|Presrvsegments 48 → 44 (ceiling becomes 88) — do not widen the group. - The payload rung appends both fields as a strict suffix; the preceding version's blob lifts to rate 100 % / pitch 0 st, bit-identical playback.
- Both new
DeckParams are classified in the three-state predicate, and that classification is what puts them in the VST3 parameter list three waves later — RateNoteOnLatched, PitchLive. Γ-W4-T1 derives the exposed set from this predicate rather than from a list of its own, so a mis-classification here is a mis-declared parameter there.
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 stretcher's tolerance. Preserve Rate is a finished feature the day this lands, because Γ-W1-T5 already shipped its engine; a degraded or inert Preserve Rate is a failed track, not an acceptable interim.
- 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 inisLiveDeckParam's exhaustive switch until they are classified. - A project saved at the preceding payload version reopens at rate 100 % / pitch 0 st and sounds identical.
Open questions.
- None [Daniel].
- [propose at review] Rate's clamp behaviour at the range extremes as it meets the stretcher's own ratio bounds — one clamp, resolved where the two meet, not two that can disagree.
- Named contingency, not a plan item, and not to be taken silently. If Γ-W1-T5's
measure-and-report gate has not passed when this track is ready to dispatch, the pre-agreed
fallback is the resample-and-cancel composition spec §2.5 records — a resampled read with
the resulting pitch change cancelled in the existing SOLA shifter — shipped as an interim
Preserve path with the stretcher as its later quality upgrade, i.e. a return to the prior
four-wave order. Escalate to Daniel rather than taking it: it revives a disposable
implementation and re-opens the
WDL_Resamplerguardrail conversation, and the whole point of the resequencing was to avoid building it.
Γ-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 ruling —
core/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|Ontoggle joins the toolbar row's right-anchored control run, immediately left of theMono|Stereotoggle, 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 whatresolveLooprefuses on (loop_span.cpp:12), and is already persisted in the payload'sloopOverrideblock — wherestart/endare written unconditionally whateverhasLoopsays (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.
hasLoopis 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 defaultLoopBoundszeroed 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, captionDRAG TO SET LOOP. Off with a span retained — pair Disabled at its own positions, captionLOOP 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 viaenforceGateUnavailableWhileDrawn(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/secondaryreduced 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 − crossfadeto ~10 px atloopEnd. 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 thecrossfade ≤ 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 ONLYcaption — 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
defaultLoopBoundsand 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:
kEditorMinWidthis unchanged by this track, asserted by the same derived test that guards the floor. - No
ComponentStateversion moves; no new persisted field;resolveLoopis untouched; audio is unchanged. The enable round-trips save/reload through the existingloopOverrideblock, 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_chromealongside the rest of the control run.
Open questions.
- [propose at review, then verify by hand] The claim-arbitration inputs change:
markerHandleRecttoday gives a tab to the crossfade only, andresolveWaveformClaimbreaks 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, anddocs/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 ineditor_input_waveform(:255,:258), two ineditor_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, and the bake correction
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. This is the whole reason the arrangement is late, and it is why the canvas was split out of it into W1-T4. T2 depends on the same wave for a different reason: rate and pitch offset must exist before the bake's reset list can name them.
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), and W1-T4's floor, budget constants and row predicate, which T1 consumes rather than re-derives. T2 depends on W1-T2 for the limiter enable flag, the third of the three values it must add.
Depends on Phase Ξ for T2 — the phase's only EXTERNAL gate. Ξ-W2-T1 (resample-bake-chain) must have landed on dev before T2 dispatches. T2 amends what that
track shipped; it cannot amend a branch.
Two tracks, disjoint by surface — but T2's disjointness is CONDITIONAL and must be
confirmed, not assumed. T1 owns ui/knob_deck, ui/deck_groups (row-predicate
consumption, FILTER's caption move, MASTER's inventory) and shell/instrument/editor_paint_deck.
T2 owns the bake's reset step wherever Ξ-W2-T1 put it. T2's first act is to read what
actually shipped and confirm its reset surface touches none of T1's three modules. If the
shipped reset enumerates controls through deck_groups or deck_values, the two are not
disjoint and T2 serializes behind T1 inside the wave — a named contingency, taken openly,
not discovered at merge. That risk is real precisely because this plan cannot predict the
shipped shape; predicting it is what put the phase in this position.
Why T1 is one track. The row law, the group inventory and the double-height deck are one
geometry decision spread over knob_deck, deck_groups and the deck painter. Splitting it
would put two tracks in the same pure modules. The window floor is no longer part of it —
W1-T4 set it two waves ago, and this track must not move it.
Neither track takes a payload rung. T1 is layout only; T2 changes a reset list, not a format.
Γ-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.2–3.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 — consuming W1-T4's budget constants, not restating them),
core/instrument/ui/deck_groups (consumption of W1-T4's row predicate, FILTER's Band|Notch
caption move, MASTER's inventory), and shell/instrument/editor_paint_deck (the MASTER
meter/limiter/bubble draw). Does not own kEditorMinWidth or any budget constant — those
are W1-T4's and are read, never moved — nor 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|Notchmoves from its row-toggle position to the caption corner, taking the group 524 → 432 (−92 px). It occupies FILTER's currently-unusedcaptionToggle2slot — no new geometry is required. - VOICE keeps its
Retrig|Legatorow 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 (n−1) 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
kDeckGroupGapall 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). - The floor is already 1190 × 680 and the bands are already 216 / 358 — Γ-W1-T4 landed all
four in wave 1, and the greedy wrap happened to reach two rows at that width. This track
changes none of those numbers; it makes them true by construction instead of by coincidence.
Row 1's natural width fits the block only after this track's
Band|Notchmove: 1030 today, +42 from W2-T1's PITCH/RATE, −92 here, = 980. That is this track's fit assertion and W1-T4 deliberately left it open. - 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
warnGR 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
LaneSplitdecisionwaveformSurfacealready 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 intext/primary; clip cap inwarn, 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 segmentation —warnstays 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.
- Row 1's natural width is 980 and fits the 1020 block — the fit Γ-W1-T4 could not yet
assert, closed here by the
Band|Notchmove. kEditorMinWidthis still 1190 and the floor is still ≤ 1280 × 720 — unchanged by this track, verified against Γ-W1-T4's derived test rather than a second copy of it.- The waveform band is 358 px at the floor, and the deck band is 216 — unchanged from the
interim, now reached by construction:
deckRowCountat and above the floor is 2 because the row predicate says so, not because a wrap landed there. Assert against the group inventory. - 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 andsample_bands' tests are updated to the new law, and the invalidated notes inknob_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/::heightchange 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'skEnvModeSegW = 23ceiling 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).
Γ-W3-T2 — bake-reset-amendment
Goal. Complete the resample bake's reset list against the control surface that now exists — the correction Phase Γ owes Phase Ξ because Ξ-W2-T1 shipped ahead of the sequencing this plan asserted.
Consolidates: nothing from the seventeen. It is a correction obligation, not a feature (see "Flagged for awareness" item 2).
Spec: docs/product/instrument-control-surface.md §3.4, and Ξ-W2-T1's own "Reset scope"
block above — which is the ratified rule this track applies, not a rule it may reinterpret.
Surface boundary — owns: the bake's parameter-reset step, wherever Ξ-W2-T1 landed it, and
its tests. Does not own the bake chain, the crossing architecture, the replace-vs-add
decision, the capture path, any deck module, any painter, any parameter, or any
ComponentState version. It changes what a shipped list contains — nothing else.
Behavior.
- Three values join the reset list, all classified against Ξ-W2's own ratified rule ("reset what the bake baked in"), all reset, none of them a new Daniel decision: rate, pitch offset, and limiter enabled. The limiter's reasoning is worth carrying rather than re-deriving: master gain is already on the reset list, so the bake includes the master stage, so the limiter's effect is in the audio.
- Verified against what shipped, not against what was predicted. This plan named three
values before either the bake or the controls existed. Read Ξ-W2-T1's landed reset list
first and reconcile: if it already anticipated any of the three, say so and drop it; if
it classified something differently from
docs/product/instrument-control-surface.md§3.4, the landed code is the fact and this plan is the prediction — escalate the difference, do not silently overwrite either. - Re-run Ξ-W2-T1's own "genuinely new parameter" check over everything Phase Γ added, not just the three named. Γ also ships the loop enable (W2-T2) and raises the stage-time ceiling (W1-T1). Classify each against the rule: the loop enable is a loop fact whose effect is in the rendered audio (reset, with the loop points it travels with); the ceiling is not a parameter at all. State each disposition; silence is not one.
- Root note still survives. The bake's most load-bearing exception is untouched by this track — capturing at root is what makes root survivable, and resetting it would detune every subsequent iteration.
Acceptance criteria.
- After a bake, rate reads 100 %, pitch offset 0 st, and the limiter reads bypassed — and the root note, key-tracking and the VOICE group are still untouched.
- The bake stays audible and faithful with the new controls dialled in: dial rate, pitch offset and the limiter, bake, and the neutral instrument playing the programmed note sounds as the dialled one did — the criterion Ξ-W2-T1 already carries, now actually exercised over Γ's controls.
- A reconciliation note in the track's review stating, per value, whether the landed code already covered it, and recording any difference between what shipped and what §3.4 predicted.
- No format change, no new field, no version bump, no change to the crossing architecture or the replace-vs-add decision. A regression baseline proves the bake's audio is otherwise unchanged.
Open questions.
- No [Daniel] questions. The rule is ratified and §3.4's classification is derived from it.
- [verify, FIRST] the disjointness contingency in the wave header: read the shipped reset step and confirm it touches none of Γ-W3-T1's modules. If it does, serialize behind T1 and say so.
- Explicitly NOT this track's: the two consequences automation adds to the bake — the reset having to notify the host, and a host lane re-imposing its curve onto baked audio. Both are Γ-W4-T1's, because that track creates them. Doing this correction once, before automation, and letting Γ-W4-T1 add its own obligation on top is deliberate: the alternative is an amendment to an amendment.
Γ-W4 — VST3 parameters
Depends on every earlier wave, and each dependency is a hard prerequisite rather than a courtesy:
- ← W1-T1. The taper and the 10 s ceiling are the host-facing normalization, and W1-T1 is also what extracts them into the one pure module the host reads through. Declaring parameters against a taper that is still moving is the one-way door this whole phase is ordered around.
- ← W1-T2 and W2-T1. Every control that could be a parameter must exist before the list is declared. The list is derived from the control inventory; an inventory still growing produces a list that has to be re-frozen, and it cannot be.
- ← W2-T1 specifically.
isLiveDeckParambecoming three-valued is the prerequisite of the classification, not an incidental: the exposed set is exactlyLive ∪ NoteOnLatched. - ← W3-T1. MASTER's inventory (limiter toggle, GR bubble, reserved cell) is the last change to what controls exist at all.
- ← W3-T2. The bake's reset list must already be complete, so this track adds the host-notification obligation once rather than amending an amendment.
One track. The storage decision governs every part of the work — the projection rule, the
migration path, what getParamNormalized returns, and what the bake's reset must do — exactly
as Ξ-W2-T1's crossing decision governs its chain. Every candidate split (a pure
model/classification half and a host-wiring half) is serial, so it buys no concurrency and
puts the decision on one side of a boundary and its consequences on the other.
Γ-W4-T1 — vst3-parameter-set
Goal. The instrument reports its automatable parameters to the host, under a frozen id contract and a logical order — which also hands it REAPER's whole per-parameter modulation block (LFO, envelope follower, MIDI link, parameter linking) for free.
Consolidates: nothing from the seventeen. Ruling 1 (Daniel, 2026-08-01): "correct the phase gamma plan to account for complying with the VST3 standard for parameter reporting… by the end of gamma we have the automatable params reported. Make the parameter order logical."
Spec: docs/product/parameter-automation.md — §§6–10 are the specification; §§1–5 are
the analysis behind it. Read §6.1 (storage), §6.3 (the freeze), §7 (the classification) and
§8 (the one-way-door sweep) before scoping. Today the plugin has zero parameters:
ReaSamplerProcessor::initialize (reasampler_processor.cpp:56-73) never populates
SingleComponentEffect::parameters, so getParameterCount() returns the SDK default 0. This
track introduces the whole surface.
Surface boundary — owns: a new pure parameter-identity module (the frozen id table,
the DeckParam ↔ ParamID mapping, the derived exposed set, the unit assignment — with its
own <module>_tests target), shell/instrument/reasampler_processor + processor_state (the
IEditController parameter surface and the IParameterChanges read),
shell/instrument/editor_controls.cpp and the editor's drag-commit sites (the
beginEdit/performEdit/endEdit bracketing), and the bake's reset step for the
notification path only. Does not own the taper (W1-T1's module, consumed), any control's
value semantics, any deck geometry, or the bake's reset membership (W3-T2's).
Behavior.
- The blob stays authoritative; a parameter is a THIRD SURFACE onto the one model —
a peer of the deck knob and the overlay node, not a second copy of the value.
docs/product/parameter-automation.md§6.1 states the load, host→plugin, plugin→host and save rules, and the two verified findings that closed the fork: this plugin is aSingleComponentEffect, where the SDK itself collapsesIComponent::setStateandIEditController::setState(vstsinglecomponenteffect.h:41-47), so there is one state and §3.3's drift hazard describes a split-component design we do not use; and the blob is a cross-artifact contract the extension'sinstrument_dropwrites, which parameters-as-truth would silently make partial. ParamIDis an independent, hand-assigned, FOREVER-FROZEN table — blocks of 100 per deck group in signal-flow order (Γ-F7), steps of 10 within a block, a curve dial at its outer knob's id + 1, blocks starting at 1000. The full 44-id assignment is stated at §6.2 and is to be transcribed, not re-derived. §6.2 also for why hand-assignment beats derivation and why the within-block order is seeded ONCE rather than tracked againstcellIds; §6.3 for the freeze invariant, which is to be stated in the table's header with the same force as the command-id strings, the class UIDs and the payload field order.- The exposed set is DERIVED from
isLiveDeckParam/liveCommitFor, never hand-maintained — a control is a parameter iff its class isLiveorNoteOnLatched. 44 parameters at the end of Γ-W3, enumerated by group in §7.1. - Everything else is OMITTED from the list entirely, not exposed-and-flagged: the reload
and rebuild tiers, all structural state, and the limiter enable (§3.8, settled). §7.2
states why omission beats
kIsReadOnly, and names the limitation plainly — the user cannot automate filter on/off, play mode, the pitch engine, Staged↔Spline or polyphony, and the unlock is to give the control a live path first. - Every exposed parameter reports REAL UNITS to the host (Ruling 3). Each declares a
plain range, a
unitsstring, and a display precision; the complete eight-category table covering all 44 — ranges, units, precision, and which taper each category carries — isdocs/product/parameter-automation.md§6.7.1, and it is a specification, not a suggestion. VST3's wire format stays normalized (it cannot be otherwise); the requirement is met through the plain-value layer the SDK provides, whose direct precedent in the vendored tree ispublic.sdk/samples/vst/common/logscale.h:221-229overridingtoPlain/toNormalizedfor a log law — the exact shape our log ms and log2 semitone knobs need. normalizedParamToPlain/plainParamToNormalized/getParamStringByValue/getParamValueByStringroute through W1-T1's taper module and the ONE formatter per unit category. Three functions that agree today is a defect; the host's normalization, the needle angle and the overlay node must be the same function.toNormalizedIS the taper — §6.7.3 — which is what makes the phase's one-way-door ordering structurally true rather than a warning someone has to remember.stepCount = 0on all 44, and the editor's shift-snap is never exposed asstepCount. Snapped drag and parameter continuity are independent axes;stepCountquantizes the parameter permanently, including for the host's automation, and freezes into the forever contract. The sweep is done and clean (§6.7.6) — every discrete control is reload or rebuild tier and therefore already omitted, so continuity is structural rather than lucky.IParameterChangesis observed at BLOCK boundaries, stated in the header — the last point in a block wins. Sample-accurate application would put a per-sample "did anything change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids.- A host parameter change takes the control's existing commit tier and no other. Nothing
on the automation path may reach
reloadInstrumentorrebuildVoiceEngine— which §7.2's omissions guarantee structurally rather than by care. IUnitInfo: one unit per deck group, mirroring the group inventory rather than the editor's rows. Order is signal flow — Γ-F7, RULED (§6.4). Presentation index order is ascending id, so identity and presentation agree by construction.- The filter's four report plain units WITHOUT being re-tapered.
toPlainis read-side only; reporting Hz / Q / drive depth means callingfilterCutoffHzFromNorm,filterQFromNormandfilterDriveDepthFromNorm— the filter module's own frozen laws, whichdeckValueLabelalready calls today — not restating them. The one additive piece:filterNormFromDriveDepthdoes not exist and must be added infilter_params, beside the two inverses that do; the analytic inverse of a frozen law is not a change to it. §6.7.5. - No
kIsBypasson anything. The plugin is an instrument and exposes no bypass parameter; the limiter is a safety device, not a bypass, and binding it there would hand the host a control that restarts the component. - The bake's reset gains a notification obligation (§9): every internal writer of a value
that is an exposed parameter must go through the one
beginEdit/performEdit/endEditpath, and the bake's reset is the codebase's first non-gesture writer. Enumerating those sites is part of this track, not a follow-up. - Two adjacent SDK surfaces are assessed, with dispositions, so they are not re-surveyed:
IMidiMappingis in scope and nearly free (a CC →ParamIDmap, one function);IParameterFunctionNameis not implemented (its vocabulary is compressor/panner semantics that name nothing here);IAutomationStateis not implemented (it reports the host's automation mode for the whole plug-in, not per parameter, so it cannot answer the one question §9 would have wanted it for).
Acceptance criteria.
- The host lists exactly the derived set, in the ruled order, with no parameter the
predicate does not classify
LiveorNoteOnLatched— asserted against the predicate, not against a literal count. - Every id in the table is asserted unique, in its group's block, and on its step — and a
test fails if any id changes value, which is what makes the freeze mechanical rather than
cultural. The asserted values are §6.2's table verbatim, including the signal-flow block
sequence; a test that recomputes the ids from
cellIdswould defeat the freeze it exists to hold. toPlain(info.defaultNormalizedValue)compares EXACTLY equal to the default, per parameter, against a default-constructedPlaySeconds(and againstmaster_gain's unity), so a host's reset-to-default and the editor's double-click land on the same value.defaultNormalizedValueis computed astoNormalized(default), not written as a literal — a grep finds no normalized default constant. If the exactness fails, it is a W1-T1 defect surfacing here, not a defect of this track. Round-trip exactness at arbitrary values is NOT asserted — it is not required (§6.7.7) and asserting it would over-constrain the taper.getParamStringByValueprints what the editor's knob label prints, digit for digit, at the same stored value, across every unit category — asserted by calling the same formatter from both sides in one test, not by comparing two independently produced strings. A grep finds exactly one formatter per unit category and nosnprintfof a parameter value outside it.- Every exposed parameter's
unitsand plain range match §6.7.1, asserted per parameter;stepCountis asserted zero on all 44. - A host automation lane moving a Live parameter moves a sounding note; a lane moving a NoteOnLatched parameter takes effect on the next note and does NOT trigger a reload or an engine rebuild — assert the tier, not just the sound.
- No automation path reaches
reloadInstrumentorrebuildVoiceEngine. - A project saved before this change opens with every parameter reading the blob's value and sounds identical; a project saved by this build opens in an older binary with its sound intact; and a project with automation drawn, saved and reopened, replays against the same plain values.
process()takes no new indirection and no new per-sample work — the parameter read is a block-boundary act, on the existing live-publish path.- The bake's reset notifies the host, verified by the host's displayed value following it rather than snapping back on next touch.
- The double-processing limitation is documented, not discovered — a bake whose reset-class parameter carries a host lane is a named boundary of the bake's fidelity claim (§9), stated in the product doc and in this track's review.
- The lane-linearity consequence is stated in the header, not left to be found — under a tapered parameter a straight line drawn in a host automation lane is not linear in the plain unit (exponential in ms, linear in octaves on cutoff, linear in dB on master gain). This is standard and desirable, it follows directly from Ruling 3 plus the taper, and §6.7.4 gives it per category. Writing it down is the acceptance criterion; changing the taper to avoid it is not an option.
- The filter's four are proven untouched: a regression baseline shows their audio
unchanged, and their persisted
*Normvalues are byte-identical across a save/reload that passes through the parameter surface. Reporting Hz/Q/depth changed display only.
Open questions.
- No [Daniel] questions. Γ-F7 is RULED — signal flow (2026-08-01, "signal flow order."), and Ruling 3 (real units) arrived specified rather than forked. There is no unanswered [Daniel]-class question in this track or anywhere in this plan.
- [verify, FIRST] that REAPER calls
setState(notsetComponentState) on a single-component plug-in, and the ordering ofsetStateagainst the firstIParameterChangesblock after a project load. §6.1 is built on the SDK's own name-collapse; verify it in the DAW before wiring, and do not build on the paragraph alone. - [verify] whether REAPER renders
ParameterInfo::unitsbeside the stringgetParamStringByValuereturns, or shows the string alone. We ship the SDK's own convention — digits in the string, unit carried separately, which is whatRangeParameter::toStringand theParameterconstructor's signature both express. If REAPER shows no unit at all, the fallback is to append the unit inside the one formatter: a one-line change in one place, touching neither the frozen id table nor the editor, because display strings are explicitly not frozen (§6.7.1). Do not discover this after shipping. - [propose at review] promoting key-track and Trigger length from
ReloadtoNoteOnLatched(§7.4). Both are excluded from the live set for the note-on-latch reason in the predicate's own words, so the promotion aligns routing with documented semantics — and it is what makes them automatable at all. If either promotion is refused, that control simply drops out of the parameter list. The list follows the predicate; the predicate is never bent to fill the list. Consequence for the frozen table: a refusal drops ids 1000 and 1260 (key-track) or 1450 (Trigger length) and the count falls below 44. Those slots are then simply never issued — not retired, since nothing shipped under them — and remain available to the same control if it is promoted later. No other id moves; that is what the block-and-step scheme buys. - [propose at review] whether to ship a default
IMidiMappingCC table here or leave MIDI control to REAPER's host-side learn. Either is defensible; skipping it silently is not. - [propose at review] whether this track spends the reserved payload rung. §6.1 says
nothing new is persisted and therefore it should not; if the
setStateverification says otherwise, it takes the reserved rung and says so. - Closed, do not reopen: Rate lifted from latched to live (§3.5 records the cost); the
limiter enable made automatable (§3.8 — its one reopening condition is the
docs/TODO.mdreload/activation decoupling, and the answer is to do that first, not to re-litigate the classification).
Phase Ψ — The extension trust pass: exact bounds, disjoint solo surfaces, reachable actions, honest drops, real names, true mono
Ships: capture ranges that mean what was asked — a time selection or razor area over
a longer item captures the selection, both scopes; per-mode solo surfaces that cache,
clear, and restore across the Design/Arrange switch, with the switch itself refused while
the transport runs; the Media-Explorer import action registered into the Media Explorer
action section so it can live on that toolbar; a drag-out gesture law that resolves its
target from what is actually under the cursor, continuously and reversibly, with no
silent no-op release anywhere; captures labeled after their source track instead of the
literal "item"/"track", surfaced on the panel card; and lossless mono collapse for
new captures whose channels are bit-identical.
Consolidates: none of the seventeen. Phase Ψ came from a direct list of seven defects and refinements (Daniel, 2026-08-01). There is no backing product doc — the design content lives inline in the tracks below, and the seven are recorded here as the phase's provenance, cited throughout as Ψ.1–Ψ.7:
Ψ.1 — item and track captures should be named (labeled) after their source track name, plus a discriminator (date, etc.); currently they aren't named anything useful. Ψ.2 — Design vs. Arrange modes: any SOLO state in one mode is cached and removed when switching to another mode, disjoining the solo surfaces. Ψ.3 — the Design/Arrange active-mode toggle is gated if playback is running; only allow the switch when the project is not playing. Ψ.4 — the action that moves a Media Explorer item to a new ReaSampler on the selected track is not in the Media Explorer action category, so it cannot be added to the Media Explorer toolbar; fix this. Ψ.5 — drag-and-drop targets are inexact: sometimes dropping into the arrange doesn't work, sometimes dropping into the FX area doesn't work; dragging between banks is fine. Ψ.6 — capture into mono: if the left and right channels of a new capture are bit-identical, collapse to mono — one channel of data, mono arrange items, ReaSamplers load in mono mode. Ψ.7 — capture item / capture track with a small time selection on a large item captures the entire item, not the selection/razor; make capture regions consistent and correct.
Where the seven land:
| Ψ-item | Track | Worktree slug |
|---|---|---|
| Ψ.7 | Ψ-W1-T1 | ppsi-w1-t1-capture-range-exactness |
| Ψ.2 + Ψ.3 | Ψ-W1-T2 | ppsi-w1-t2-mode-switch-discipline |
| Ψ.4 | Ψ-W1-T3 | ppsi-w1-t3-media-explorer-section |
| Ψ.5 | Ψ-W1-T4 | ppsi-w1-t4-drop-target-resolution |
| Ψ.1 | Ψ-W2-T1 | ppsi-w2-t1-capture-naming |
| Ψ.6 | Ψ-W2-T2 | ppsi-w2-t2-mono-collapse |
Ψ-W3 is not one of the seven — opened mid-phase, after Ψ-W2's review surfaced that
the track scope carried the same multi-track stem-collapse hole Ψ-W1-T1 had just closed
for item scope. It consolidates none of the original seven and carries no Ψ-item row
above; see Ψ-W3 below.
All three waves have landed — Phase Ψ is complete. W1 and W2 each carry their own
landed notes below; W3 carries its own too. See docs/COMPLETED.md for every track's
full narrative. None of the seven tracks is DAW-verified — all are code-complete and
unit-tested, several resting on a shared unverified inference about how REAPER's
selected-tracks render source interacts with custom time bounds, which Ψ-W3-T1's
refusal now also rests on; each track's DAW-verification obligation is restated inline
below.
Ψ.2 and Ψ.3 share one track deliberately: they share one chokepoint — applyMode, the
sole mode mutator (shell/view/view.cpp:380-469) — and one discriminator
(targetModeId != model.activeModeId(), the test that distinguishes a real switch from a
reapply). Splitting them is two tracks fighting over the same function.
Concurrency with Γ and Ξ. Phase Ψ is extension-side. Γ and Ξ-W3 live in
core/instrument/ + shell/instrument/; the surfaces are disjoint with ONE named
exception — Ψ-W2-T2 touches shell/instrument/processor_reload.cpp for a stale comment
and an index/file consistency check, and that touch is bounded to the minimum in its
surface boundary precisely because Γ is live in that directory.
Three invariant amendments were DELIVERABLES of this phase, not asides. Each landed in its owning track, as an acceptance criterion of that track:
- The never-touch-solo rule (Ψ-W1-T2):
src/shell/view/CLAUDE.md:14-17,src/core/view/CLAUDE.md:10, anddocs/product/design-view.md:160-165+:588-592. Ψ.2 requires writingI_SOLO; the amended form is stated in the track. A track that lands solo writes without the amendment reads as an invariant breach in review. - The action-registration contract (Ψ-W1-T3): root
CLAUDE.md§"REAPER extension contract" documents only the main-section 4-step pattern; the second, non-main mechanism (custom_action+hookcommand2+-custom_actionmirror) must be added beside it. - The channel-count-preserved rule (Ψ-W2-T2): root
CLAUDE.md:208— "channel count preserved (no silent stereo fold)" — is contradicted in text (not in spirit) by a lossless bit-identical collapse, and was already untrue in the other direction (a mono source renders atRENDER_CHANNELS=2today). The amended form is stated in the track.
Performance posture. Every Ψ surface is cold — per-capture, per-click,
per-mode-switch, per-mouse-move. None of the named hot paths (peaks envelope compute,
audition, realtime-capture tick, instrument process()) is touched. Two disciplines
carry anyway: the realtime tick's single-pointer-test idle fast path is untouched by
Ψ-W1-T1's render work, and Ψ-W1-T4 keeps the inside-client drag path free of SDK
hit-tests, exactly as today (panel_drag.cpp:273-274 — "costs nothing on the common
internal-drag path").
Ψ-W1 — Exact bounds, disciplined switches, reachable actions, resolved drops
Depends on: nothing in this phase.
Ψ-W1-T1 — capture-range-exactness
Landed — see docs/COMPLETED.md for the full narrative. A ranged item capture now
renders exactly the requested window instead of the whole item, by re-sourcing through
the selected-tracks render when the item extent does not already print the window.
Deviation: the spec named two candidate architectures (re-source vs. render-then-
trim); the engineer shipped a conditional form of the re-source candidate — the
full-extent case runs literally unchanged code, keeping the byte-identity regression
floor structural and the fix cheap to revert if the override inference proves wrong.
Also landed: a transient isolation guard (cutting B_MAINSEND on direct folder
children, muting receives) so an item capture stays true to item scope, and a
post-render frame-count gate (±1 tolerance, tail-None only) that refuses and self-cleans
a widened render. New modules core/capture/render_window, core/capture/track_topology,
shell/capture/render_selection, shell/capture/render_isolation. The whole fix rests
on the unverified inference that REAPER's selected-tracks render source overrides
custom time bounds — Ψ-W3-T1 (below) now also depends on it. DAW-verification
obligation, unmet: the four-cell scope × selection-type matrix over a source item
substantially longer than the selection (tail None), plus one razor-union case — none of
it run in a live REAPER session yet.
Ψ-W1-T2 — mode-switch-discipline
Landed — see docs/COMPLETED.md for the full narrative. Per-mode SOLO surfaces now
cache, clear, and restore across a Design/Arrange switch, with the switch itself
refused, visibly, while the transport is playing or recording. The never-touch-solo
invariant — stated three times (src/shell/view/CLAUDE.md, src/core/view/CLAUDE.md,
docs/product/design-view.md) — is amended, in this track, to the snapshot sense of
non-destructive: solo is cached per mode on a real switch and restored verbatim, not
left untouched absolutely the way B_MUTE and the master track are. Also closed: a
pre-existing bug where a footer mode-segment click never persisted view state. New
core/view/solo_cache, shell/view/view_solo. DAW-verification obligation, unmet:
the disjoint-surface solo matrix, the playback-gated refusal (playing and recording),
and the panel-persist case — none run in a live REAPER session yet.
Ψ-W1-T3 — media-explorer-section
Landed — see docs/COMPLETED.md for the full narrative. The Media Explorer import
action now registers into REAPER's Media Explorer action section (32063) via
custom_action + hookcommand2, alongside its existing Main-section entry so existing
keybindings survive — a second FOREVER-STABLE id, INGEST_IMPORT_MEDIA_EXPLORER_MX,
minted per channel. Root CLAUDE.md's "REAPER extension contract" is amended, in this
track, with the second, non-main registration mechanism beside the original four-step
main-section pattern. DAW-verification obligation, unmet: adding the action to the
Media Explorer toolbar and firing it from there; confirming the Main-section binding
still fires; and confirming unload/reload does not leak a duplicate Media Explorer entry
(the -custom_action unload mirror is unconfirmed against the SDK header) — none run in
a live REAPER session yet.
Ψ-W1-T4 — drop-target-resolution
Landed — see docs/COMPLETED.md for the full narrative. The drag-out gesture is now
a per-move, stateless law: target class resolves from what is under the cursor on every
move, every class transition is reversible until release or until the pointer leaves
REAPER, and the OS hand-off is reserved for leaving REAPER entirely — the whole TCP/MCP
is now an instrument-drop hotspot, and a single-card arrange drop lands a timeline item
at the pointer's track and time. New shell/actions/arrange_drop_win.
DAW-verification obligation, unmet: the full target-class matrix (single- and
multi-card), reversibility across a drag that crosses the arrange en route to an FX
window, drag-speed independence, and the narrow-TCP case — none run in a live REAPER
session yet.
Ψ-W2 — Names and channels, over the settled render block
Depends on Ψ-W1 — specifically Ψ-W1-T1, which rewrites the capture
render-configuration block in shell/capture/capture.cpp and
core/capture/render_settings.cpp; both W2 tracks edit adjacent regions of those same
TUs, so W2 dispatches only after W1-T1 is on dev. (T2–T4 of W1 gate nothing here; the
wave boundary is the file collision, not a semantic dependency.)
Ψ-W2-T1 — capture-naming
Landed — see docs/COMPLETED.md for the full narrative. Captures are now named
after their source track's name plus a discriminator
(<Track> [+N] [#ordinal] MM-DD HHMM) at every interactive mint site, with the name
shown on the panel card over a scrim clearing the 4.5:1 contrast floor. New
core/capture/capture_name. Recapture, ingest, and the bake deliberately keep their own
naming — the bake's naming-and-lineage open question stays Ξ-W1-T1's/Ξ-W2-T1's to close,
untouched here. DAW-verification obligation, unmet: capturing from a named track, an
unnamed track, and a multi-item selection, and confirming the labels show on the card
and in the instrument — none run in a live REAPER session yet.
Ψ-W2-T2 — mono-collapse
Landed — see docs/COMPLETED.md for the full narrative. A capture whose channels
are bit-identical now collapses losslessly to one mono channel, written via temp file
plus atomic rename, with Sample::channelCount measured off the landed file rather than
echoed from the request on every capture path — including realtime, which previously
parsed the layout and echoed the request anyway. Root CLAUDE.md:208's
channel-count-preserved invariant is amended, in this track, to the landed wording:
channel count preserved, except that bit-identical channels may collapse losslessly to
mono; a lossy fold remains forbidden. The shell/instrument/processor_reload.cpp touch
stayed to the minimum named in its surface boundary (Phase Γ was live in that
directory). Open, not closed here: whether bake landings collapse too — [propose],
leaning yes, still deferred to be confirmed against what Ξ-W2-T1 actually shipped.
DAW-verification obligation, unmet: capturing a dead-center mono source and a
true-stereo source, inserting both, loading both into the instrument, and running the
null test on the collapsed one (REAPER's mono-item-on-stereo-track summing at unity is
the specific thing to confirm) — none run in a live REAPER session yet.
Ψ-W3 — Closing the track-scope stem-collapse hole
Depends on Ψ-W2 for: existing at all — this wave did not exist when the phase was scoped. Daniel opened it after Ψ-W2's review surfaced that the track scope carried the same multi-track stem-collapse hole Ψ-W1-T1 had just closed for item scope.
One track. Consolidates none of the seven — it came from a review finding, not from Ψ.1–Ψ.7.
Ψ-W3-T1 — track-scope-range
Landed — see docs/COMPLETED.md for the full narrative. Any multi-track
selected-tracks render now refuses, in both scopes, keyed on the render source rather
than the capture scope — closing the hole Ψ-W1-T1 left open for track scope. Realtime
deliberately diverges and was left untouched, because it sums correctly. The refusal
rests on the same unverified inference Ψ-W1-T1 rests on — that REAPER's selected-tracks
render source overrides custom time bounds — so if that inference is wrong, this refusal
costs a working capture. docs/verify-track-scope-multitrack.md is a new standalone
verification script on this branch, for this track's multi-track refusal specifically.
DAW-verification obligation, unmet: confirmed nowhere in a live REAPER session yet.
Phase Ε — The bank package: one file that carries a bank between projects
Ships: a bank exported to a single version-tagged .rsbank file — audio bytes
byte-exact, index metadata intact — and imported into another project's bank folder and
index, with a compatibility policy that names both directions concretely: an older package
in a newer build always imports, a newer package in an older build refuses whole with an
actionable message, and neither direction is ever a partial landing.
Consolidates: none of the seventeen. Phase Ε came from a direct request (Daniel,
2026-08-02) and is scoped in docs/product/bank-package.md. Nothing in docs/TODO.md or
docs/TODO-1.0.md records export, import, or a package format — a sweep of docs/ for
export|package|portable returns only unrelated matches (prune's portable move-to-trash,
MIDI-playback prose) — so this phase supersedes nothing and absorbs nothing.
Three forks are OPEN and are Daniel's. They are the first unanswered [Daniel]-class
questions in this plan since Γ closed its seven; each is stated with a recommendation and
its counter-argument in docs/product/bank-package.md §"Open forks".
| Fork | Question | Recommendation | Blocks |
|---|---|---|---|
| Ε-F1 | Container format: hand-rolled RSBK vs. ZIP via the vendored minizip (vendor/WDL/WDL/zlib/) |
hand-rolled RSBK — the only option where the whole codec lands pure |
Ε-W1-T1 dispatch. One-way door: packages are in users' hands the day it ships |
| Ε-F2 | Import target: always a new bank, or also offer merge-into-existing | new bank by default, merge as a separate bindable verb if wanted at all | Ε-W2-T2's action count and dialog; not W1 |
| Ε-F3 | Import while the tracking ledger is degraded: allow-with-confirm, or refuse | allow with an up-front confirm naming the consequence — matches the accepted residual rather than inventing a new block | Ε-W2-T2's guard; not W1 |
Ε-F1 is the only one that blocks a dispatch. Ε-F2 and Ε-F3 can be carried into Ε-W2-T2 as ruled-at-review if Daniel prefers, but both are user-visible policy rather than implementation detail, so the plan's default is to ask.
Phase-Ε acceptance criteria
These bind every track in this phase, in addition to the plan-wide set above.
- Byte-exact round-trip is the phase's trust anchor. Export → import → export yields
byte-identical payloads, and the
hashBytesdigest of every landed file equals the digest recorded at export. Frame count, sample rate, bit depth, and channel count are untouched on both sides. No re-encode anywhere:wav_codecmay be called to hash and to read metadata already recorded, never to rebuild, trim, normalize, or collapse. The mono collapse is a capture-path behaviour and must not reach the import path — the same exclusion ingest already carries (rootCLAUDE.md, exact-bounds invariant). - The format cannot express a path. Manifest entries are bare file names — no directory
component, no
.., no drive letter, no leading separator — validated on encode and decode. Relative-paths-only becomes structural rather than remembered, and the archive-traversal bug class closes by construction. - Import places no timeline item. Capture and placement stay separate acts; import is a capture-shaped act, not a placement one.
- Import never overwrites and never deletes an existing bank-folder file. The one
deletion path is the rollback of files this call wrote that no index ever referenced —
the documented carve-out at
src/shell/persist/prune_fs.cpp:5-11, which every track touching it must cite, not restate. - Export is read-only against the project. No ext-state write, no
bumpBankGeneration(), no undo point. Import does the opposite: it bumps the generation (src/shell/persist/session.h:108) so live ReaSampler 9000 instances reload, and batches its index mutation into one Ctrl-Z throughpersistBankOp. - All-or-nothing on both sides. No partial export, no partial import. A truncated
.rsbankmust never exist on disk (temp file + atomic rename, the Ψ-W2-T2 precedent); a half-imported bank must never exist in the index (rollback). - At most one entry's payload in memory at a time, on both paths. The pure codec owns
framing and offset arithmetic; the shell owns the stream. A whole-package
vector<uint8_t>on either side is a rejected shape, not an optimization opportunity. - The pure planners take value inputs, never a handle. The decoded manifest, the
destination
BankBook, and the set of names present in the bank folder cross the seam as values. NoReaSamplerSession&, no service container, no "pass the thing that has everything" reachescore/package/. If a circular dependency appears during the build, the fix is a service split or a thin interface — not parameter propagation, and not a base class gaining a dependency that grows its subclasses' constructors. - Every pure module gets a
<module>_teststarget that runs without REAPER or a DAW. The whole collision/version rule set is expressible as pure functions over strings and hashes; if a rule can only be tested through the shell, the seam is in the wrong place.
Performance posture. Every surface in this phase is cold — per-gesture, once. None of
the named hot paths (peaks envelope compute, audition, realtime-capture tick, instrument
process()) is touched by any track here. The one performance fact that is load-bearing is
the memory criterion above, and it is stated as a structural constraint rather than a
guardrail because exceeding it does not slow the feature down, it makes it fail.
Concurrency with Γ and Λ. Phase Ε is extension-side and lands almost entirely in two
new directories (src/core/package/, src/shell/package/) that no other phase touches.
Γ lives in core/instrument/ + shell/instrument/; Λ is being specced concurrently and is
not read here. The only pre-existing files any Ε track edits are named per track below —
core/tracking/origin_ledger (W1-T3, exclusively), the root CMakeLists.txt
add_subdirectory list (W1-T1 and W1-T2, one line each), src/app/main.cpp and the panel's
bank menu (W2-T1 and W2-T2, one registration line and one menu row each). No Ε track
touches core/instrument/, shell/instrument/, or any capture backend.
Ε-W1 — The contract, the filesystem, and the ledger's new kind
Depends on: nothing in this phase. Three tracks, disjoint by directory — the split is by what each track's inputs are, which is why they genuinely parallelize: T1 knows only bytes and structs, T2 knows only paths and bytes, T3 knows only the ledger.
| Track | Owns |
|---|---|
T1 package-format |
the whole of the new src/core/package/ except export_plan / import_plan (W2's), plus its CMakeLists.txt and CLAUDE.md |
T2 package-fs-shell |
the whole of the new src/shell/package/ except export_bank / import_bank (W2's), plus its CMakeLists.txt and CLAUDE.md |
T3 import-origin-kind |
src/core/tracking/origin_ledger.{h,cpp} and its tests, exclusively |
One shared file in the wave, named rather than discovered at merge: the root
CMakeLists.txt add_subdirectory list — T1 appends src/core/package, T2 appends
src/shell/package. Two append-only lines in one list: textual merge adjacency, not
semantic contention. Whichever lands second rebases.
T1 is the wave's only gated dispatch — it cannot start before Ε-F1 is ruled, because the fork is T1's deliverable. T2 and T3 are unaffected by Ε-F1 and can start immediately: T2's API is bytes-in/bytes-out regardless of what those bytes mean, and T3 touches no package code at all.
Ε-W1-T1 — package-format
Goal. The container and its version ladder, entirely pure — the contract every later track consumes, landed once so nothing downstream re-litigates the shape.
Spec: docs/product/bank-package.md §"The container", §"Version tagging", §"What a
package carries", §"What a package deliberately does NOT carry", §"Memory".
Surface boundary — owns: new src/core/package/package_format (the magic, the header
layout, kPackageFormatVersion, kPackageMinReaderVersion, and
classifyPackageVersion(formatVersion, minReader) -> Readable | TooNew | Malformed), new
src/core/package/package_manifest (the manifest model + its JSON codec), new
src/core/package/bank_package (header encode, prefix decode, entry-layout arithmetic), the
directory's CMakeLists.txt and CLAUDE.md, and one appended add_subdirectory line in the
root CMakeLists.txt. Does not own: export_plan / import_plan (Ε-W2), anything under
shell/, core/model, or core/tracking.
Behavior.
- Two version integers, not one.
formatVersion= what this writer emitted;minReaderVersion= the oldest reader that can read it safely. The reader's whole rule isminReaderVersion <= kPackageFormatVersion. An additive change (a new optional manifest key, a new enum value with a defined degrade) bumpsformatVersiononly; a structural change bumps both. The header carries the writer's semver (version::stampVersion()) alongside them, informational, so a refusal message can name what to install. - The ladder is documented the way
origin_ledger.cpp:8-21documents its own — a header comment listing every shipped version and what changed, with the read-and-validate rule stated, not implied. - Unknown manifest keys are skipped (the
bank_book_json.cpp:182behaviour), and unknown persisted enum integers degrade to their definedUnknownequivalent, never to the numeric default and never to a parse failure (core/wire/CLAUDE.md'sBakeStatusrule, verbatim). Both are pinned by tests, not left to inheritance. - The manifest nests
BankModel's own serialization verbatim, exactly asbank_book_json.cpp:15-20nests it, so per-sample shape has one owner and a futureSamplefield reaches packages for free. Per entry the manifest adds only: the bare file name, the byte length, and ahashBytesdigest (core/capture/wav_codec.h:143) —hashBytes, nothashWavContent, because the latter deliberately skips chunks (wav_codec.h:145-151) and so cannot answer "did these bytes survive." - The bank's
slot_maprides along — display positions are part of what the user built. - Framing only, never a payload.
bank_packageproduces the header bytes and an ordered[{ name, offset, length }]layout; it never holds, copies, or hashes an entry's audio. Decode is symmetric: prefix in, manifest + layout out. - Path expression is structurally impossible. Entry names are validated to contain no
/,\,:, no leading separator, and no..component, on both encode and decode.
Acceptance criteria.
decodePackage(encodePackage(x)) == xover a manifest fixture exercising every field, including everySampleoptional in both present and absent states.- A synthetic header with
minReaderVersionabove this build classifiesTooNewand no manifest is produced — the decode does not half-succeed. - A synthetic header with
formatVersionabove this build butminReaderVersionat or below it classifiesReadable, and its unknown manifest keys are skipped without error. This is the additive-forward-compatibility claim, and it is the reason the two-integer design exists; a test that does not exercise it leaves the design unproven. - Truncated input at every byte offset in a valid package returns
Malformed— never UB, never a partial manifest, never a read past the buffer. Hostile-input hardening at thebank_model.h:204-206standard. - Entry names containing
.., a separator, or an absolute prefix are rejected on encode and rejected on decode. Both directions, because a package can arrive from anywhere. - No file in the new directory exceeds ~600 lines; the three-module split above is the responsibility seam, and a fourth module is preferred over a bisection if one is needed.
package_format_tests,package_manifest_tests,bank_package_testsall run without REAPER or a DAW.
Open questions. [Daniel] Ε-F1 — the container format itself; this track cannot be
dispatched until it is ruled. [propose at review] whether package_format and
bank_package are genuinely two modules or one — the split is proposed on responsibility
grounds (constants and classification vs. offset arithmetic) and may collapse if the
arithmetic turns out to be twenty lines.
Ε-W1-T2 — package-fs-shell
Goal. Every filesystem and dialog act the two verbs need, landed behind an API that knows nothing about what a package contains — so it can be authored, reviewed, and tested in parallel with the format it will carry.
Spec: docs/product/bank-package.md §"Where it lives", §"Failure modes", §"Memory".
Surface boundary — owns: new src/shell/package/package_io (read a file's bytes, write
bytes through temp + atomic rename, read one bank file, write one landed file, enumerate the
bank folder's existing names, and the rollback delete), the platform file-picker seam under
the #ifdef _WIN32 / #else swell/swell.h split this codebase already uses
(src/shell/panel/draw_kit.cpp:11-15, src/shell/persist/prune_fs.cpp:35-38), the
directory's CMakeLists.txt and CLAUDE.md, and one appended add_subdirectory line in the
root CMakeLists.txt. Does not own: export_bank / import_bank (Ε-W2), anything under
core/, and — emphatically — prune_fs, which stays the deletion authority.
Behavior.
- Atomic write. A package is written to a temp path in the destination directory and
renamed on complete success. A failed or interrupted write leaves no
.rsbankbehind. This is the mono-collapse precedent (Ψ-W2-T2, temp file + atomic rename) applied to a much larger file. - Streaming, both ways. Append one payload at a time on write; seek and read one payload at a time on read. The API must make holding the whole package awkward, not merely discouraged.
- The rollback delete is the carve-out, cited. Its TU header cites
src/shell/persist/prune_fs.cpp:5-11and states the discriminator it satisfies — this call created the file, and no index ever referenced it — rather than restating the carve-out's text. Anything that does not satisfy that discriminator is not this function's business. - The two pickers are asymmetric, and the asymmetry is real. Import uses REAPER's own
GetUserFileNameForRead(char* filenameNeed4096, const char* title, const char* defext)— verified,vendor/reaper-sdk/sdk/reaper_plugin_functions.h:3798. There is no save picker in the REAPER API — a sweep of that header forFileNameFor|SaveFile|Browsereturns only the read picker — so export uses Win32GetSaveFileNameWon Windows and SWELL'sBrowseForSaveFileelsewhere (vendor/WDL/WDL/swell/swell-functions.h:167). - No REAPER project state is touched here. No ext-state read or write, no undo block, no generation bump; those belong to the verbs in Ε-W2.
Acceptance criteria.
- A write interrupted before completion leaves the destination path absent or holding its prior contents — never a partial new file. Tested by injecting a failure at the writer seam.
- Reading and writing a multi-entry package never holds more than one entry's payload; the test asserts against a seam counter, not against a memory measurement.
- The rollback deletes exactly the files it was given and nothing else, and is a no-op on a path it did not write.
- The Windows and SWELL picker paths both compile; the SWELL signature matches
swell-functions.h:167exactly.[verify — DAW]— neither picker is exercised in a live REAPER session by this track. - No file exceeds ~600 lines; the platform picker lives in its own TU, following the
drag_out/drag_out_winprecedent.
Open questions. [propose at review] whether the file-picker seam is its own module or
part of package_io — the drag_out_win precedent argues its own TU; whether it also wants
its own header is a judgment call at the size it lands. [verify — DAW] the default
extension and filter strings each platform's picker actually accepts.
Ε-W1-T3 — import-origin-kind
Goal. Give the ledger a birth-record kind for a package import, so an imported file is tracked from the moment it lands rather than becoming a permanently unreclaimable foreign file — landed as its own track, with its own review, because it edits safety-critical territory that nothing else in this phase touches.
Spec: docs/product/bank-package.md §"What a package deliberately does NOT carry" (the
origin-ledger bullet); src/core/tracking/CLAUDE.md for the invariants it must not weaken.
Surface boundary — owns: src/core/tracking/origin_ledger.{h,cpp} and its tests,
exclusively. Does not own: tracking_authority (no decision changes), shell/persist,
or any consumer.
Behavior.
- Append
OriginKind::PackageImportas value 5. Append only —Capture=1,Ingest=2,Recapture=3,Resample=4 keep their integers, percore/tracking/CLAUDE.md's "PERSISTED INTEGERS — never renumber, only append". - A build that does not know value 5 degrades it to
Unknown, which is the existingkindFromIntbehaviour and is the safe direction: the path is still owned, so still protected; only the kind detail is lost. This is the field-vocabulary rule, and it must stay distinct from the document-version rule right beside it, which blocks (origin_ledger.cpp:18-21). - Nothing else changes. No new field, no version bump, no lineage semantics. A new enum
value in an append-only vocabulary is precisely the change that does not need
"v"to move, and demonstrating that is part of the point. recordCreatedneeds no signature change — it already takes anOriginKind(src/shell/persist/session.h:95). Confirm that in the same pass; if it turns out otherwise, that discovery is this track's, not Ε-W2's.
Acceptance criteria.
- A ledger containing a kind-5 record round-trips through serialize/deserialize unchanged.
- A record carrying an unrecognized kind integer (6, 99, negative) loads as
Unknownand the ledger loadsLoaded, notUnreadable— the vocabulary gap does not halt prune. kLedgerVersionis unchanged at 2, and a test asserts it, so the append-vs-bump distinction is pinned rather than assumed.pruneProtection's output is unchanged for every existing kind — this track alters no decision.
Open questions. [propose at review] whether the kind is named PackageImport or
folded onto the existing Ingest. The plan's recommendation is a distinct value: Ingest
means "the user brought in a file," which is close, but losing the distinction makes a future
"where did this bank come from" question unanswerable, and an appended integer costs nothing.
Ε-W2 — The two verbs
Depends on Ε-W1 — all three tracks. T1 for the format the verbs speak, T2 for every filesystem act they perform, T3 for the kind their birth records carry. No part of either verb is authorable against a format that has not settled.
Two tracks, disjoint by direction. They share only the manifest type. The split is real
enough that the plan pre-split the pure planner into two TUs (export_plan /
import_plan) rather than one package_plan — that separation exists specifically so these
two tracks do not fight over a file.
| Track | Owns |
|---|---|
T1 bank-export |
core/package/export_plan, shell/package/export_bank, shell/actions/package_export_action |
T2 bank-import |
core/package/import_plan, shell/package/import_bank, shell/actions/package_import_action, the panel's .rsbank drop route |
Two shared files, named — and the disjointness here is CONDITIONAL, unlike W1's.
src/app/main.cpp (one action-family registration line each) and the panel's bank menu (one
row each). Both are textual adjacency by construction, but Phase Ψ set the precedent of
granting main.cpp to a single track rather than sharing it (Ψ-W1-T3). If the dispatcher
wants zero contention, serialize T2 behind T1 — T2 is the larger track and loses nothing by
starting second. The plan's default is to run them in parallel and rebase whichever lands
second.
Ε-W2-T1 — bank-export
Goal. One bank leaves the project as one file, or the export refuses and says why.
Spec: docs/product/bank-package.md §"Failure modes" (export rows), §"What a package
carries".
Surface boundary — owns: new core/package/export_plan (pure: which entries, what
names, what is missing, and therefore whether the export may proceed), new
shell/package/export_bank (the promptless verb — takes a ReaSamplerSession&, returns an
outcome, no prompts and no message boxes, mirroring src/shell/bank_ops/), new
shell/actions/package_export_action (the bindable-action skin, mirroring prune_action),
one registration line in src/app/main.cpp, one panel menu row. Does not own: anything
on the import side, bank_ops, or persist.
Behavior.
- The exported unit is one bank — the pool included, since the pool is structurally a bank
(
core/model/CLAUDE.md's pool-privileges section). Whole-book export is an explicit non-goal of this phase and is preserved as an additive future by the manifest's shape, not by a promise. - Refuse-if-incomplete, report-before-acting. An index entry whose file is missing or unreadable stops the export by default; the "export the N present entries" path exists only behind an explicit confirm that lists what is absent, distinguishing missing from unreadable. This is prune's dry-run-then-confirm discipline applied to a non-destructive act, and it is deliberate: a silently-incomplete package is discovered on the far side, in another project, weeks later.
- The project is not touched. No ext-state write, no generation bump, no undo point. An export that mutates project state is a defect, and the acceptance criteria name it as one.
- Nothing is re-encoded. Payload bytes are copied and hashed.
wav_codecis not asked to rebuild anything. - A new FOREVER-STABLE command id is minted through
version::channelCommandId(suffix)per the rootCLAUDE.mdaction contract, with its per-channel display name throughchannelActionName.
Acceptance criteria.
planExportis pure and total over its inputs: a bank with a missing file, an unreadable file, zero samples, and one sample all classify without touching a filesystem.- Exporting a bank and re-reading the package yields, for every entry, a
hashBytesdigest equal to the source file's — asserted per entry, not in aggregate. - The exported manifest contains no absolute path and no path separator, asserted by a test that scans the emitted bytes rather than by inspecting the model.
- A failure injected mid-write leaves no
.rsbankat the destination and the prior file, if any, intact. - Project ext state is byte-identical before and after an export, and
bankGeneration()is unchanged — a direct assertion, because "we did not mean to write anything" is not a property that survives without one. - Exporting an empty bank produces a valid, importable package with zero entries rather than refusing. An empty bank is a legitimate thing to carry.
Open questions. [propose at review] whether the export affordance is action-only,
panel-only, or both at ship. [propose at review] whether the default file name is derived
from the bank's display name (recommended, sanitized through
capture_paths::sanitizeStem) or from the project name.
Ε-W2-T2 — bank-import
Goal. A package becomes a bank in this project — completely, or not at all — with every one of the four collision classes answered explicitly rather than by whatever the model happens to do.
Spec: docs/product/bank-package.md §"Identity and collision on import", §"Failure
modes" (import rows), §"Version tagging: both directions".
Surface boundary — owns: new core/package/import_plan (pure: the id remap table, the
parent remap, the per-entry write / skip-already-present / rename disposition, the
destination bank name after uniqueness folding), new shell/package/import_bank (the
promptless verb), new shell/actions/package_import_action, the panel's WM_DROPFILES route
for a .rsbank (routing only — the existing ingest route for audio files is untouched), one
registration line in src/app/main.cpp, one panel menu row. Does not own: anything on the
export side, bank_book's rules (consumed, never re-implemented), origin_ledger
(Ε-W1-T3's).
Behavior.
- Version gate first, before any byte is written.
minReaderVersionabove this build refuses the whole package and reports throughShowMessageBox(verified,reaper_plugin_functions.h:6546) naming three things: the package's requirement, this build's ceiling, and the writer's semver. Two of the three is not enough to act on. A malformed or truncated package reports distinctly — the two failures have opposite recoveries, which is exactly whyorigin_ledger.cpp:178-185separates them. - Four collisions, four answers. (1) Sample id — remint every id and remap
Provenance::parentSampleId(bank_model.h:45-50) through the same map, to the reminted parent when it came in the same package and cleared otherwise; a foreign id never enters the index. (2) File name — never overwrite; mint a fresh unique name throughcapture_paths::deriveBankPaths, silently, counted in the summary. (3) Content hash — consultBankModel::findByHashbefore writing the payload; on a hit, skip the write entirely and let the entry collapse, so a dedup never manufactures an orphan. (4) Bank display name — Ε-F2. - Birth records at landing. Every landed file goes through
ReaSamplerSession::recordCreated(sample, OriginKind::PackageImport)at the same point theSampleis added, in the same straight-line block, percore/tracking/CLAUDE.md's no-silent-gaps invariant. An import that lands a file without a record is the exact failure that section exists to prevent. - All-or-nothing, with rollback. Any failure after the first write deletes the files
this call wrote and abandons the index mutation. The rollback cites the
prune_fs.cpp:5-11carve-out; it does not restate it, and it does not reach outside the set it wrote. - One Ctrl-Z for the index, and the file residue is stated, not implied. The index
mutation batches through
persistBankOp(Undo_BeginBlock2/Undo_EndBlock2, verified atreaper_plugin_functions.h:7758/:7806). Undo does not un-write the files; they remain as orphans until a prune reclaims them — the same designed window a non-empty bank delete already produces (core/model/CLAUDE.md's sample-removal section). The user-facing summary says so. bumpBankGeneration()on success (session.h:108), so live instances reload.- No timeline item is placed. Ever.
- A new FOREVER-STABLE command id, minted the same way T1's is.
Acceptance criteria.
planImportis pure and total, and every one of the four collision classes has a test that exercises it without a filesystem: colliding ids, colliding file names, a hash already present, and a colliding bank name.- Importing a package built from bank B into a project that already contains B produces a correct result under the Ε-F2 ruling, with every id reminted and no entry lost.
- An entry whose payload fails its
hashBytescheck aborts the import with zero files landed and zero index mutation — asserted on both, since either alone would pass a weaker test. - A write failure injected at entry k of n leaves exactly zero files from this import on disk and the index unchanged.
- Every landed file has a ledger birth record with kind
PackageImport, asserted by reading the ledger after the import, not by counting calls. minReaderVersionabove the build: nothing written, message names all three facts.formatVersionabove the build withminReaderVersionat or below it: imports cleanly, unknown keys skipped. Both directions asserted, in this track, against real package bytes.- No timeline item exists after an import; the arrange is byte-identical.
- DAW-verification obligation (to be discharged by Daniel, not by this track): import a package produced on another machine, confirm the panel shows every sample with its metadata, confirm a live ReaSampler 9000 instance picks up the new bank content on the generation bump, and confirm one Ctrl-Z removes the index entries.
Open questions. [Daniel] Ε-F2 — new bank always, or merge offered; decides whether this track ships one action or two. [Daniel] Ε-F3 — behaviour when the ledger is degraded at import time. [propose at review] whether the import summary is a console block, a message box, or both; the recommendation is a console block plus a one-line message box, so the detail is copyable and the outcome is unmissable.
Ε-W3 — The compatibility fixtures
Depends on Ε-W2 for: both verbs existing. A round-trip claim cannot be tested against one half of a round trip, and a "this build refuses a future package" claim cannot be tested against a package this build is incapable of writing.
One track. The whole deliverable is one corpus and the harness over it; splitting it would mean two tracks writing two halves of one fixture set.
Ε-W3-T1 — package-compat-fixtures
Goal. Turn the version-compatibility policy from an assertion in a doc into a property proven against frozen bytes, so a later format change cannot silently break either direction.
Spec: docs/product/bank-package.md §"Version tagging: both directions".
Surface boundary — owns: a new checked-in fixture corpus under the package modules' test
tree, the harness that decodes it, and one docs/ verification script for the DAW half.
Does not own: any production module — if a fixture reveals a defect, the fix is filed
against the owning track's module and this track carries the failing test, not the patch.
Behavior.
- Frozen bytes, not regenerated ones. The corpus holds real
.rsbankbytes committed to the repo: a v1 package written by the shipping build, a syntheticformatVersion= N+1 /minReaderVersion= current package (the additive-forward case), and a syntheticformatVersion= N+1 /minReaderVersion= N+1 package (the refuse case). A test that regenerates its own fixture proves only that the code agrees with itself — which is precisely the failure mode a format ladder exists to catch. - A truncation corpus. The valid package truncated at a spread of offsets, each asserted
Malformedrather thanTooNew, so the two recoveries never get crossed. - A hostile-name corpus. Packages whose entry names carry
.., separators, and absolute prefixes, each refused. - The round-trip anchor. Export → import → export over the v1 fixture yields byte-identical payloads.
- A standalone DAW verification script in
docs/, following thedocs/verify-track-scope-multitrack.mdprecedent — the cross-machine transfer is the one claim no unit test can make. - The corpus is append-only. When a future format version ships, its fixture is added; no existing fixture is ever regenerated or edited. Stated in the corpus's own README so the rule survives the person who wrote it.
Acceptance criteria.
- All three version fixtures classify as specified, and the additive-forward one imports with every known field intact and every unknown key skipped.
- Every truncation offset classifies
Malformed; none classifiesTooNew,Readable, or crashes. - Every hostile-name fixture is refused at decode, before any planner runs.
- The round-trip fixture's payloads are byte-identical after export → import → export.
- The DAW script exists and names its steps concretely enough to run without reading this plan.
Open questions. [propose at review] how large the committed corpus is allowed to be — the recommendation is one-sample packages with a few hundred bytes of payload each, since the properties under test are structural and a large payload proves nothing extra.
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 abandoned by ruling; window derives itself) | 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 fromTODO-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 fromTODO-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-*). Ten tracks across four waves, from a direct interview with Daniel (2026-08-01) and his four later rulings the same day, not fromTODO-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 indocs/product/instrument-control-surface.mdand the parameter system's indocs/product/parameter-automation.md§§6–10. Twodocs/TODO.mdentries are discharged by this phase, not deferred again: Γ-W3-T1 discharges the deck-rework entry (whose original "one row of taller decks with within-deck stacking" shape Daniel explicitly superseded), and Γ-W1-T1 discharges "Raise the stage-time ceiling above 2 s" (Γ-F3 reversed). - All of Phase Ψ (
ppsi-*). Seven tracks across three waves, from a direct list of seven defects and refinements (Daniel, 2026-08-01), not fromTODO-1.0.md. Listed here as a block, like Γ; unlike Γ it has no backing product doc — the seven are recorded verbatim in the phase header as its provenance (Ψ.1–Ψ.7), and the design content lives inline in its tracks. The seventh track, Ψ-W3-T1, is not one of the seven defects/refinements itself — it came from a review finding mid-phase; see the Phase Ψ section for detail. - Γ-W3-T2
bake-reset-amendmentis a CORRECTION, not a feature, and belongs on this list for a different reason from the others: it exists only because Ξ-W2-T1 shipped ahead of this plan's sequencing claim. If more corrections of this shape appear, they belong here rather than in the table — the table is a completeness proof overTODO-1.0.md, and a correction has no source row to point at. - All of Phase Ε (
pe-*). Six tracks across three waves, from a direct request (Daniel, 2026-08-02), not fromTODO-1.0.md. Listed here as a block, like Γ and Ψ; the product reasoning lives indocs/product/bank-package.md. It supersedes nothing — neitherdocs/TODO.mdnordocs/TODO-1.0.mdrecords export, import, or a package format, so there is no deferred entry to absorb or contradict. It is also the only phase in this plan that opens with unanswered [Daniel]-class forks rather than closing them; see "Decision state" above.
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 [ran AHEAD of Γ; its reset list is corrected by Γ-W3-T2]
T1 resample-bake-chain ..................... 15 (chain)
W3 The capture-signal popup [popup abandoned by ruling; bake window derives itself]
T1 capture-signal-popup .................... 15 (popup abandoned; window derives)
Phase Γ — The instrument's control surface (none of the seventeen; ends with VST3 params)
W1 Foundations [5 tracks, disjoint by surface]
T1 knob-interaction-law ....... modifiers + ONE taper module + reset bypass
+ 10 s ceiling + AHDSR schematic axis [Ruling 2]
T2 master-bus-audio ........... limiter + meter ballistics + dynamic PDC [rung 1]
T3 contour-trace-curves ....... staged traces draw curved, knot on its trace
T4 editor-floor-and-row-law ... floor 1190x680 + budget constants + row predicate
T5 preserve-time-stretch ...... real stretcher [measure-and-report gate]
W2 New controls, and the overlay's marks [2 tracks]
T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [rung 2]
T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard
W3 The reflow, and the bake correction [2 tracks]
T1 deck-reflow ................ two rows + double-height MASTER, by construction
T2 bake-reset-amendment ....... the Xi correction Gamma owns [needs Xi-W2-T1 on dev]
W4 VST3 parameters [1 track]
T1 vst3-parameter-set ......... 44 derived params, frozen id table [Ruling 1]
[rung 3 RESERVED, spent only if verify says so]
[OPEN: Gamma-F7, the parameter order — Daniel]
Resequenced 2026-08-01, three times: the reflow split canvas (W1-T4) from arrangement
(W3-T1); preserve-time-stretch moved W4 -> W1-T5, retiring Rate's interim stand-in; then
Ruling 1 added W4 and Ruling 2 grew W1-T1.
Payload rungs are RELATIVE, not absolute — read kParamsPayloadVersion on dev and take the
next three above it. On dev at 2026-08-01 that is v14 / v15 / v16-reserved.
Shared files, named: engine/CMakeLists.txt (W1-T2 | W1-T5), ui/CMakeLists.txt
(W1-T1 | W1-T3), editor_session.cpp (W2-T1 | W2-T2) — all textual adjacency, not
semantic contention. W3-T2's disjointness from W3-T1 is CONDITIONAL: confirm it against
what Xi-W2-T1 shipped, and serialize behind T1 if it does not hold.
Phase Psi — The extension trust pass (none of the seventeen; a direct list of seven)
W1 Exact bounds, disciplined switches, reachable actions, resolved drops [4 tracks]
T1 capture-range-exactness ..... Psi.7 [opens with a DAW repro matrix]
T2 mode-switch-discipline ...... Psi.2 + Psi.3 [one chokepoint: applyMode]
T3 media-explorer-section ...... Psi.4 [BOTH sections; new FOREVER-STABLE id]
T4 drop-target-resolution ...... Psi.5 [gesture law, not a patch]
W2 Names and channels, over the settled render block [gated on W1-T1's render block]
T1 capture-naming .............. Psi.1 [+ the card shows the name]
T2 mono-collapse ............... Psi.6 [lossless only; ingest excluded]
Three invariant amendments are track deliverables: never-touch-solo (W1-T2, three
files), the action-registration contract (W1-T3, root CLAUDE.md), channel-count-
preserved (W2-T2, root CLAUDE.md:208).
Shared files, named: panel_input.cpp (W1-T2 footer block | W1-T4 drag-arm block);
capture.cpp + capture_realtime_finalize.cpp (W2-T1 naming lines | W2-T2 channel
lines) — all textual adjacency, not semantic contention. main.cpp is W1-T3's
exclusively.
Phase Epsilon — The bank package (none of the seventeen; a direct request 2026-08-02)
W1 The contract, the filesystem, and the ledger's new kind [3 tracks, disjoint by dir]
T1 package-format .............. core/package: framing + TWO version ints
[BLOCKED until E-F1 is ruled — Daniel]
T2 package-fs-shell ............ shell/package: atomic write, streaming, pickers
[no REAPER save-picker exists; SWELL/Win32 split]
T3 import-origin-kind .......... OriginKind::PackageImport = 5, append-only
W2 The two verbs [2 tracks; disjointness CONDITIONAL — see below]
T1 bank-export ................. export_plan + verb + action; project untouched
T2 bank-import ................. import_plan + verb + action + .rsbank drop
[OPEN: E-F2 import target, E-F3 degraded ledger]
W3 The compatibility fixtures [1 track]
T1 package-compat-fixtures ..... frozen bytes prove BOTH version directions
Version policy, both directions: older package in newer build ALWAYS imports (additive
fields default, unknown keys skipped); newer package in older build REFUSES WHOLE with a
three-part message (package needs / this build reads / writer semver). The gate is
minReaderVersion <= kPackageFormatVersion — formatVersion is for the message, not the gate.
Shared files, named: root CMakeLists.txt add_subdirectory list (W1-T1 | W1-T2, one
append-only line each); main.cpp + the panel bank menu (W2-T1 | W2-T2, one registration
line and one menu row each). W1's three tracks are unconditionally disjoint; W2's two are
textually adjacent only — serialize T2 behind T1 if zero contention is wanted.