4422 lines
303 KiB
Markdown
4422 lines
303 KiB
Markdown
# 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`, **and Phase Ρ**, likewise a direct request (Daniel,
|
||
2026-08-02), scoped in `docs/product/render-in-place.md`, **and Phase Λ** — the Linux port
|
||
of both artifacts — likewise a direct request (Daniel, 2026-08-02), scoped in
|
||
`docs/product/linux-readiness.md`.
|
||
|
||
## What this doc is, and how it relates to the others
|
||
|
||
- **`docs/PLAN.md`** (this file) — the active on-deck specification list. Each track is
|
||
written so a specialist brief is writable from this file alone: goal, consolidated
|
||
source items, surface boundary, behavior, acceptance criteria, open questions,
|
||
prerequisites.
|
||
- **`docs/TODO-1.0.md`** — retained as the **verbatim-provenance appendix**. It holds
|
||
Daniel's raw asks and every answer round, unedited; where this plan compresses a
|
||
behavior bullet, that file is the backing record. Items are cited here by number
|
||
(e.g. "consolidates items 1, 8, 14"). It is not a work queue any more; this file is.
|
||
- **`docs/TODO.md`** — deferred follow-ups with recorded rationale, unrelated to the
|
||
seventeen (with one flagged intersection: see "Flagged for awareness" below).
|
||
- **`docs/COMPLETED.md`** / **`docs/ARCHIVE.md`** — doc-keeper's. When a track here
|
||
finishes, its point is removed from this file and appended to `COMPLETED.md` with any
|
||
deviation between landed code and spec noted.
|
||
- **`docs/product/`** — the product-design reasoning behind prior phases. Grep for a
|
||
cited section rather than reading a file whole.
|
||
|
||
**Worktree slug convention:** `p<phase>-w<wave>-t<track>-<slug>`. Greek phase letters
|
||
transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**, **Ψ → `psi`**, **Ε → `e`**, **Ρ → `r`**,
|
||
**Λ → `l`**. So Θ-W1-T1 dispatches into `pth-w1-t1-zone-retirement`, Γ-W1-T1 into
|
||
`pg-w1-t1-knob-interaction-law`, Ψ-W1-T1 into `ppsi-w1-t1-capture-range-exactness`, and
|
||
Λ-W2-T1 into `pl-w2-t1-linux-compile-blockers` (Phase Λ's two audit tracks already ran
|
||
under `pl-w1-t1-build-toolchain-audit` and `pl-w1-t2-source-runtime-audit`).
|
||
|
||
## 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.
|
||
|
||
**Read the Phase Λ paragraph below before relying on the plan-wide "nothing is unanswered"
|
||
claim the Γ, Ε and Ρ paragraphs make.** Λ (added 2026-08-02) carries **four open
|
||
[Daniel]-class forks**, so that claim is no longer true of the plan as a whole; it is true
|
||
of every phase except Λ, and Λ's paragraph states exactly which of its four forks gate
|
||
anything.
|
||
|
||
Θ-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.**
|
||
|
||
**Phase Ε (added 2026-08-02) opened three more [Daniel]-class forks and ALL THREE ARE
|
||
RULED**, same day (Daniel, 2026-08-02): **Ε-F1** container format — *"proprietary
|
||
container"*, the hand-rolled `RSBK`; **Ε-F2** import target — *"always lands as a new bank,
|
||
with an auto suffix if name collision"*; **Ε-F3** import under a degraded tracking ledger —
|
||
*"refuse mismatched import."* The rulings are folded into the tracks below and indexed at
|
||
`docs/product/bank-package.md` §"Rulings". **Two of the three landed somewhere other than
|
||
the framing recommendation:** Ε-F2 dropped the proposed rename prompt in favour of a
|
||
deterministic suffix, and Ε-F3 reversed allow-with-confirm to refuse — carried by the
|
||
framing's own counter-argument, that the accepted tracking residual contemplates *one*
|
||
untracked capture while a bulk import strands hundreds in a single gesture. **The plan-wide
|
||
claim above therefore holds unqualified — no unanswered [Daniel]-class question remains
|
||
anywhere in this plan, Phase Ε included — and no track in this plan is gated on a
|
||
decision.**
|
||
|
||
**Phase Ρ (added 2026-08-02) opened three more [Daniel]-class forks and ALL THREE ARE
|
||
RULED**, same day (Daniel, 2026-08-02): **Ρ-F1** multi-track — *"refuse"*, one track per
|
||
fire, a settled non-goal rather than a deferral; **Ρ-F2** the result track's mode —
|
||
*"for this action which is not a capture, the result track should always go to
|
||
arrange"*; **Ρ-F3** tail — *"follow panel tail settings."* The rulings are folded into
|
||
the track below and indexed at `docs/product/render-in-place.md` §"Rulings". **Ρ-F2
|
||
overrode the request's own original wording** ("stays in whatever mode was active") and
|
||
is the only one of the three that changed the spec: the result track is now an Arrange
|
||
member unconditionally, the A/B-on-the-bench behaviour mode-following would have enabled
|
||
is gone, and the ruling pulls in a two-line fix to the panel's auto-tag detector that
|
||
would otherwise reverse it on the next timer tick. **The plan-wide claim above therefore
|
||
still holds unqualified — no unanswered [Daniel]-class question remains anywhere in this
|
||
plan, Phase Ρ included — and no track in this plan is gated on a decision.**
|
||
|
||
**Phase Λ (added 2026-08-02) breaks that unqualified claim, and it is corrected here rather
|
||
than left to be discovered.** The claim as written in the three paragraphs above — *"no
|
||
unanswered [Daniel]-class question remains anywhere in this plan"* and *"no track in this
|
||
plan is gated on a decision"* — is now **scoped to Θ, Ξ, Γ, Ψ, Ε and Ρ.** It is still true
|
||
of all six. It is **not** true of Λ. **Λ opened four [Daniel]-class forks and NONE of them
|
||
is ruled** (Λ-F1 CI; Λ-F2 the dialog-resource route; Λ-F3 the copy-only drag-out invariant's
|
||
wording; Λ-F4 the declared support floor). Each is stated with the evidence for both sides
|
||
at `docs/product/linux-readiness.md` §"Open forks — Daniel's", and the Phase Λ section below
|
||
carries them as a table. What they gate is narrow and is not a matter of judgement:
|
||
|
||
- **Λ-F2 is the only one that gates a dispatch** — Λ-W2-T3 `panel-dialog-resource` cannot be
|
||
briefed until the route is chosen, because the two routes own different files. Leaving it
|
||
open does not stall the phase; it slips that one track to Λ-W4 and **splits Λ-W3's
|
||
verification sweep into two Linux sessions**, which is the fork's actual price.
|
||
- **Λ-F4 gates the ship wave, not a dispatch.** Λ-W2 can record a floor and widen it; Λ-W5
|
||
cannot ship an artifact that does not say what it runs on. Answerable any time before
|
||
Λ-W5-T1 is briefed.
|
||
- **Λ-F1 and Λ-F3 gate nothing at all.** Λ-F1 (CI) decides only which paragraph Λ-W5-T1
|
||
writes; Λ-F3 decides one sentence's wording inside an edit Λ-W4-T3 makes either way.
|
||
- **The other eleven Λ tracks are dispatchable against open forks**, in the sense that none
|
||
of them waits on a decision. What every Λ track waits on instead is a Linux box — a
|
||
different kind of unknown, and the reason Λ-W3 exists.
|
||
|
||
**Λ's six [Daniel] rulings of 2026-08-02 are settled** and are recorded in the phase section
|
||
below in the same shape as Γ's and Ε's: the instrument is in scope; SWELL is reached by
|
||
`dlopen`ing REAPER's own `libSwell.so` rather than vendoring it; the editor is REAPER-only
|
||
but every other host must degrade safely; macOS is out; the Linux artifact is shipped rather
|
||
than developer-only; and a hard `unlink` prune is acceptable with a platform-aware
|
||
confirmation. **Do not re-litigate them.**
|
||
|
||
**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
|
||
|
||
1. **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. See `docs/COMPLETED.md` for the full narrative.
|
||
|
||
2. **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.
|
||
|
||
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
|
||
`resetDeckParam` bypass to use), and the filter's four `*Norm` controls **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: **no `IEnvelope`, no
|
||
`IFilter`, no virtual per-voice `tick()`**. A filter with two modes is a
|
||
branch-predictable switch or a compile-time-known dispatch, never a vtable. Spline
|
||
evaluation is a binary search over a point array, not a polymorphic curve object.
|
||
- **No allocation, no file I/O, no bridge call in `process()`.** The off-audio-thread
|
||
`reloadInstrument` + atomic pointer swap stays the only way new state reaches the
|
||
audio thread. Every new parameter this plan adds follows that path.
|
||
- **A split that would add a hot-path indirection is out of scope — rework it or drop
|
||
it.**
|
||
|
||
### Product invariants
|
||
|
||
- **Capture and placement are separate acts.** No track here may place a timeline item.
|
||
Item 15's bake explicitly must not, and one of its candidate architectures uses a
|
||
*temporary* arrange mutation — that candidate must leave the arrange byte-identical.
|
||
- **Prune is the single, exclusive file-deletion authority.** Item 15's "replace" never
|
||
deletes bytes; item 17's consolidation may not weaken any protection prune has today.
|
||
- **The instrument never writes the bank.** Item 15 is the first feature that needs to,
|
||
and it resolves that by *asking the extension*, not by breaching the invariant.
|
||
- **Migration bar: a project saved before a change reopens sounding identical.** Holds
|
||
everywhere except item 16's genuinely-multi-zone case, where Daniel deliberately
|
||
relaxed it.
|
||
- **Every pure module gets a `<module>_tests` target** that runs without REAPER or a
|
||
DAW. New pure modules in this plan are not optional-test.
|
||
|
||
---
|
||
|
||
## Phase Θ — ReaSampler 9000: one parameter set, a filter, shapeable envelopes, a legible editor
|
||
|
||
**Ships:** the instrument with the zone system retired, a resonant HP/LP filter stage in
|
||
the voice path, curve-shapeable and spline-drawable envelopes on all three EGs,
|
||
Gate-mode loop sustain, and a re-laid, high-DPI-clean editor — plus the two extension
|
||
drag/drop defects that block getting captures into it.
|
||
|
||
**Consolidates items** 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16.
|
||
|
||
**All seven waves have landed — Phase Θ is complete.** W1 through W7 each carry their own
|
||
landed note above; see `docs/COMPLETED.md` for every track's full narrative. Θ-W7 was
|
||
opened after Θ-W6-T1 shipped, to fix two rendering defects Daniel found by eye; it did not
|
||
exist in the plan when this phase was originally scoped.
|
||
|
||
**The organizing constraint.** Three surfaces in the instrument are single-writer by
|
||
nature and dictate the wave shape:
|
||
|
||
1. **The parameter model** (`zone_params.h` + `component_state_io`) — every parameter
|
||
addition touches both. Two tracks adding parameters concurrently is a merge fight
|
||
and two competing `ComponentState` version bumps.
|
||
2. **The voice render path** (`sampler_core.cpp`) — filter insertion, envelope
|
||
evaluation, loop read, and the Trigger tail all live there.
|
||
3. **The Sample face** (`editor_paint_sample.cpp` 516 lines / `editor_input_sample.cpp`
|
||
583 lines) — every UI item repaints it, and both are already at the ceiling.
|
||
|
||
Parallelism in this phase therefore comes from **splitting the Sample face into bands**
|
||
(done once, in Θ-W1-T1) and from **extension-side work being genuinely disjoint** — not
|
||
from running two parameter-model tracks at once. Where a wave has one track, the
|
||
collision is real and the serialization is the correct answer.
|
||
|
||
---
|
||
|
||
### Θ-W1 — Collapse and re-seam
|
||
|
||
**All three tracks have landed** — Θ-W1-T1 (`zone-retirement`), Θ-W1-T2
|
||
(`capture-handoff-bugs`), and Θ-W1-T3 (`filter-dsp-port`) — see `docs/COMPLETED.md` for
|
||
the full narrative of each. Between them: the zone subsystem is retired, the wave's two
|
||
responsibility seams (the `sampler_core` split, the Sample-face band split) are in
|
||
place, the two extension-side capture-handoff defects are fixed, and the filter DSP has
|
||
landed as a standalone pure module — Θ-W2-T1 has since wired it into the voice path (see
|
||
below).
|
||
|
||
---
|
||
|
||
### Θ-W2 — Filter in the voice path; waveform and chrome bands
|
||
|
||
**Depends on W1 for:** the one-parameter-set model and the `sampler_core` seam that T1
|
||
writes the filter into; the `filter` module T1 wires up; the Sample-face band split and
|
||
band-stack allocator that T2 and T3 fill; and W1-T1's key-range answer, which decides
|
||
what T3's piano strip displays. Authoring any of this against the per-zone model means
|
||
writing storage plumbing W1 deletes.
|
||
|
||
**All three tracks have landed** — Θ-W2-T1 (`filter-voice-path`), Θ-W2-T2
|
||
(`stereo-waveform-lanes`), and Θ-W2-T3 (`toolbar-and-piano-strip`) — see
|
||
`docs/COMPLETED.md` for the full narrative of each. Between them: the filter sits in the
|
||
per-voice signal path with its own deck, the waveform band shows both channels in
|
||
stereo mode behind a type-enforced full-height overlay contract, and the chrome band's
|
||
toolbar and piano strip are cleaned up per spec. The three tracks were disjoint by
|
||
band — T1 owned the parameter model and the deck band, T2 owned the waveform band, T3
|
||
the chrome band — and none re-allocated the band stack.
|
||
|
||
---
|
||
|
||
### Θ-W3 — Live parameters, then the staged envelope system
|
||
|
||
**Depends on W2 for:** the filter envelope's existence — items 1, 8, and 14 govern
|
||
*three* envelopes, and the filter is the third; and for the Filter deck, which must
|
||
exist before it can receive a corner radio switch and inner curve dials. T1 additionally
|
||
depends on W2-T1 for the filter itself: the filter is the first control set where the
|
||
latched-at-note-on delivery model fails audibly, and it is what made the defect visible.
|
||
|
||
**Both tracks have landed** — Θ-W3-T1 (`live-parameter-delivery`) and Θ-W3-T2
|
||
(`staged-envelope-curves`) — see `docs/COMPLETED.md` for the full narrative of each.
|
||
Between them: every continuous playback control (filter cutoff/Q/morph/drive/mod
|
||
amount/key-track, every stage time and level on all three envelopes) now reaches a
|
||
sounding voice live instead of latching at note-on, and the envelope-overlay editor
|
||
grew from an amp-only fixture into the shared graphical surface for all three
|
||
envelopes — a corner radio switch per deck (none active by default), curve-shapeable
|
||
segments on every sloped stage (0.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** — `kEditorMinHeight` stays **680**.
|
||
- **Γ-F2** — the limiter has **lookahead with DYNAMIC reported latency** (zero when off,
|
||
the lookahead when on, reported to host PDC). *This inverted the product recommendation;*
|
||
W1-T2's scope grows accordingly — spec §3.1.1.
|
||
- **Γ-F3 — 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` /
|
||
`kGateStageMaxSeconds` move **2.0 → 10.0 in Γ-W1-T1**, and the `docs/TODO.md` entry 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 `getParameterInfo` presentation index — is
|
||
**PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE → MASTER**, the
|
||
deck's own `sampleDeckGroups` rule, 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:
|
||
|
||
1. **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.**
|
||
2. **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).
|
||
3. **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.
|
||
|
||
1. **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.
|
||
2. **`preserve-time-stretch` moved W4 → W1-T5.** It is the longest pole in the phase and has
|
||
**zero dependency on any UI work** — a pure `core/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 `isLiveDeckParam` becoming three-valued is the
|
||
*prerequisite* of the classification, not an incidental of it: the exposed set is exactly
|
||
`Live ∪ 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 `dev` carries 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 `dev` as 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 == nullptr` byte-identical to the pre-live core and the filter's exact skip at
|
||
`modAmount == 0` hold the at-rest path unchanged.
|
||
- **No `ComponentState` sound change.** A project saved before this phase reopens sounding
|
||
identical: absent rate lifts to 100 %, absent pitch offset to 0 st, absent limiter flag to
|
||
bypassed. Re-tapering a knob **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's `normalizedParamToPlain` / `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: `toNormalized` is
|
||
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
|
||
`units` string — no embedded unit, no magnitude-switched unit, no width-conditional
|
||
abbreviation, no caller-side branch. The editor's knob label and
|
||
`getParamStringByValue` read 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's `k` abbreviation 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`). `resetDeckParam` bypasses 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): `defaultNormalizedValue` is **computed** as
|
||
`toNormalized(default)`, never written as a normalized literal; the assertion is made on
|
||
`toPlain(defaultNormalizedValue)`, the pair the host actually calls; and
|
||
`toNormalized(toPlain(n)) == n` at **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 `*Norm` doubles 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: `toPlain` is a pure read-side mapping that never touches the stored value, so
|
||
reporting Hz / Q / drive depth means **calling** `filterCutoffHzFromNorm` and 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 and `filterNormFromDriveDepth` must 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; `stepCount` is a parameter property; they are independent.**
|
||
The editor's snap grid must never be exposed as `ParameterInfo::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 ship `stepCount = 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 `Live` or `NoteOnLatched`. There is no second table
|
||
beside `isLiveDeckParam` / `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.
|
||
**`kEditorMinHeight` stays 680** (Γ-F1). The remaining **90 px of width headroom is the
|
||
budget for the life of this layout** — one deck cell is 60 px, so there is room for exactly
|
||
one more, once. Spec §1.6 states the ledger; read it before adding any control. Chrome-row
|
||
additions are a **separate purse** (they are paid for out of the title slot, not the floor)
|
||
and must not be charged against this one.
|
||
- **Reported latency is zero unless the limiter is on.** `getLatencySamples()` returns 0 with
|
||
the limiter bypassed, in every track and at every point in the phase. Only W1-T2 may
|
||
introduce a non-zero value, and only under the limiter-on condition.
|
||
- **Geometry stays pure.** Every new layout, cap, label and hit-test rule lands in a pure
|
||
CTest-covered module (`knob_deck`, `sample_bands`, `waveform_view`), never in a painter.
|
||
|
||
---
|
||
|
||
### Γ-W1 — Foundations
|
||
|
||
**Depends on:** nothing in this phase. **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
|
||
`kEnvTimeMaxSeconds` at norm 1, monotone throughout; **10 ms lands within 0.12–0.20 of
|
||
travel and 100 ms within 0.42–0.52**.
|
||
- **The stage-time ceiling moves 2.0 s → 10.0 s** (Daniel, reversing Γ-F3):
|
||
`kGateStageMaxSeconds` (`envelope_overlay.h:85`) and, through it, `kEnvTimeMaxSeconds`
|
||
(`deck_values.h:22`). **The two move together or not at all** — `deck_values.h` reads the
|
||
overlay's constant rather than restating it precisely so they cannot drift
|
||
(`deck_values.h:19-22`). The taper's landmarks above are fit against the **new** ceiling,
|
||
which is why the ceiling cannot be a follow-up: fitting the taper twice is the only other
|
||
way to get there.
|
||
- **`resetDeckParam`'s bypass becomes MANDATORY rather than merely required-anyway.**
|
||
`deck_values.h:42-46` records that exact default recovery depends on the ceiling being a
|
||
power of two; **2.0 is, 10.0 is not**, and the log taper compounds it. Nothing here may be
|
||
"simplified" back into a norm round-trip under any circumstance.
|
||
- **NEW, and the sharpest requirement in the track: every default must have an EXACT
|
||
normalized preimage under its taper.** `ParameterInfo::defaultNormalizedValue` (Γ-W4-T1)
|
||
is normalized, so a host's reset-to-default arrives as `toPlain(defaultNorm)` — and **the
|
||
host has no `resetDeckParam` bypass to use**. The bypass fixes the editor's reset and
|
||
cannot fix the host's; only exactness in the map itself makes the two land on the same
|
||
value. This binds the taper's *shape*, so it belongs here and cannot be handed forward.
|
||
Master gain's unity (≈ 0.714 norm) is the case where a hair off is audible.
|
||
- **The AHDSR overlay's schematic axis becomes the taper — the ceiling's real cost, and it
|
||
is design work, not a constant change.** Each of the four timed stages gets an equal slot
|
||
and today maps seconds across it linearly (`gatePxPerSecond`,
|
||
`envelope_overlay.cpp:33-34`). At 2 s a 30 ms attack is 1.5 % of its stage's domain; **at
|
||
10 s it is 0.3 %, under a pixel at the floor width.** The fix: a stage's slot width becomes
|
||
`slotPx × taperNorm(seconds)` instead of `slotPx × seconds / ceiling`, so a node's position
|
||
within its slot **is** its knob's needle position. Legibility becomes ceiling-independent by
|
||
construction; the one-model invariant gets stronger rather than strained; and **the drawn
|
||
curve is unaffected**, because the taper decides only where a stage's end node lands while φ
|
||
still runs linearly across the stage's pixel span — so Γ-W1-T3's φ^p trace composes with it
|
||
rather than fighting it. **The AHD policy is untouched**: an AHD maps 1:1 onto the
|
||
waveform's own PCM-aligned time axis and stays linear in seconds. Two alternatives
|
||
(content-fit auto-scale; a minimum drawn stage width) were considered and rejected — spec
|
||
§4.3.1 names why, and neither is to be reintroduced as a "simplification."
|
||
- **`envelope_edit`'s drag inverse must remain the EXACT inverse of the draw.** Both read the
|
||
same taper module; a node dragged to a pixel and the knob's value at that pixel are one
|
||
number, not two that agree.
|
||
- **The taper is EXTRACTED into its own pure module**, with its own `<module>_tests` target,
|
||
because it now has three consumers in two different dependency layers: `deck_values` (which
|
||
sits above `envelope_overlay`), `envelope_overlay`/`envelope_edit` (which sit below it),
|
||
and — from Γ-W4-T1 — the host. Leaving it inside `deck_values` would force an inverted
|
||
include edge. **Do not solve that by copying the map.**
|
||
- **Semitone knobs become log2/centre-expanded.** Symmetric, exactly 0 at centre, exactly
|
||
±`kPitchDepthMaxSemis` at the ends, monotone; **±7 st reached at 50–58 % of each
|
||
half-travel**.
|
||
- **`resetDeckParam` bypasses the taper** — it writes the default value directly instead of
|
||
round-tripping through `norm → value`. This *removes* the power-of-two dependency the
|
||
header currently documents rather than working around it; that comment
|
||
(`deck_values.h:42-46`) becomes wrong and must be rewritten.
|
||
- **Scope is the parameter, not the widget.** Deck knobs (outer ring and inner dial),
|
||
envelope stage nodes and curve knots all honour it — they are surfaces onto one model, and
|
||
a snap on one but not the others is a divergence. **Waveform markers are explicitly
|
||
excluded**: they carry a shipped zero-crossing snap on the same modifier space and their
|
||
domain is frames.
|
||
|
||
**Acceptance criteria.**
|
||
- Every taper change is verified **persistence-neutral**: a project saved before the change
|
||
reopens with bit-identical stored values and identical audio; only needle angles move.
|
||
- Holding Shift mid-drag on each unit category lands the documented whole unit; releasing it
|
||
does not jump the value.
|
||
- Holding and releasing Ctrl mid-drag is continuous — no step at either transition.
|
||
- Double-clicking any knob (outer ring and inner dial independently) lands **exactly** on its
|
||
default at every taper, verified against a default-constructed `PlaySeconds` rather than a
|
||
round trip.
|
||
- The log/log2 landmark positions above are asserted in the **taper module's** own tests, and
|
||
hold at the **10 s** ceiling — the fit is against the new ceiling, not the old one.
|
||
- **Every default round-trips exactly through `norm → value`**, asserted per unit category
|
||
against a default-constructed `PlaySeconds` and against `master_gain`'s unity. This is the
|
||
criterion Γ-W4-T1 will declare `defaultNormalizedValue` from; it fails here, not there.
|
||
- **A stage time of several seconds is reachable by hand with no loss of resolution below
|
||
100 ms**, and `kEnvTimeMaxSeconds == kGateStageMaxSeconds` is asserted, not assumed.
|
||
- **A project saved at the 2 s ceiling reloads with identical stored seconds and identical
|
||
audio** — the ceiling change is persistence-neutral for the same reason the taper is.
|
||
- **The AHDSR overlay reads legibly at both ends of the new range**: a default 3 ms attack is
|
||
a visible, grabbable node at the floor width, and a 10 s decay still lands its end node at
|
||
its slot's edge. Assert the node separation, then judge the result by eye in the DAW.
|
||
- **The overlay's drag inverse is the exact inverse of its draw** at the tapered axis —
|
||
`nodeAtPoint` / `resolveNodeDrag` and `buildEnvelopePolyline` round-trip.
|
||
- **The AHD 1:1 policy is unchanged**, asserted: a sustain-less envelope's x-axis stays
|
||
wall-clock over the waveform.
|
||
- **The taper module is pure, CTest-covered, and is the ONLY definition of each map** — a
|
||
grep finds no second copy in `deck_values`, `envelope_overlay`, or the shell.
|
||
- **The filter's four `*Norm` controls are untouched by the taper pass** — cutoff, Q, morph
|
||
and drive are already wire-frozen in payload v9; a regression baseline proves their audio
|
||
is unchanged.
|
||
- One shared modifier-read helper serves all drag surfaces; no second modifier grammar exists.
|
||
|
||
**Open questions.**
|
||
- **No [Daniel] questions.** Fork **Γ-F3 is REVERSED**: the ceiling moves to **10.0 s, in this
|
||
track.** Daniel's *"a horrifically long decay with tight exp"* is the case it serves, and
|
||
the `docs/TODO.md` entry that carried it is discharged rather than deferred again. **The
|
||
reversal's cause is Ruling 1** — a range endpoint is host-facing normalization, free to
|
||
move now and permanently expensive after Γ-W4-T1. Both of the prerequisites the deferred
|
||
entry named are in this track anyway: the log taper (which is what makes a higher ceiling
|
||
usable at the low end rather than unusable) and the reset bypass (which retires the
|
||
power-of-two dependency — 2.0 is a power of two, 10.0 is not). The engineer should know the
|
||
reset bypass is now doing triple duty and must not be "simplified" back into a norm
|
||
round-trip under any circumstance.
|
||
- **[propose at review]** the exact shape of the taper, subject to the landmark bounds **and**
|
||
the exact-default-preimage requirement. Those two together are tighter than either alone,
|
||
and the second is easy to satisfy by accident and easy to lose in a refactor — **assert it,
|
||
do not observe it.**
|
||
- **[propose at review]** whether the tapered schematic axis wants a visible tick or
|
||
gradation cue, now that it is no longer linear in time. The plan's lean is **no** — the ms
|
||
labels carry the number and the editor's no-decoration policy stands — but a reader who
|
||
finds the axis illegible in the DAW should say so rather than silently adding one.
|
||
|
||
#### Γ-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
|
||
calls `IComponentHandler::restartComponent(kLatencyChanged)`. **None of this exists today** —
|
||
there is no `getLatencySamples` override, no `kLatencyChanged`, and no `restartComponent`
|
||
call site anywhere in `src/`; the plugin ships the SDK default of 0. This track introduces
|
||
the plugin's first latency reporting.
|
||
- **The restart is routine; the fencing is against a standing scar, not against the flag.**
|
||
Dynamic latency reporting is ordinary VST3-instrument behaviour and REAPER handles it as a
|
||
matter of course. The SDK's deactivate/reactivate requirement
|
||
(`pluginterfaces/vst/ivsteditcontroller.h:105-108`) is the normal contract. **What makes the
|
||
cycle expensive here is this plugin's own `setActive`** — reactivate calls
|
||
`reloadInstrument()`, a bridge read plus a full WAV re-decode
|
||
(`reasampler_processor.cpp:89-97`), where a typical plugin only allocates buffers; deactivate
|
||
frees `live_`/`draining_`/graveyard (`:98-107`) for a documented reason (ghost sustained
|
||
voices). **Γ-F6 is ruled: ship it — the toggle is a patch-design gesture, not a
|
||
during-playback one.** Do **not** build a constant-reported-latency fallback and do **not**
|
||
gate the deliverable on a measurement. The reduction of that self-inflicted cost is filed in
|
||
`docs/TODO.md` ("Decouple the instrument reload from VST3 activation") with its trigger
|
||
condition; it is out of scope here. The four requirements below survive as engineering
|
||
hygiene against the `kIoChanged` scar, and all four are acceptance criteria:
|
||
1. **Verify the whole call sequence against the vendored Steinberg SDK** before writing it,
|
||
including the ordering rule that the new latency is what `getLatencySamples` returns
|
||
*after* `setActive(true)` — so **the reported value must derive from persisted state, not
|
||
from a transient the deactivate clears.**
|
||
2. **Prove the restart does not disturb the output bus arrangement.** The output stays one
|
||
permanently-stereo bus, never renegotiated.
|
||
3. **Ship a regression test in the spirit of `testDualMonoStereoSampleRendersCentered`** —
|
||
a dual-mono capture rendered across a limiter toggle stays centered, L ≡ R.
|
||
4. **`restartComponent` is never called from `process()`.** Main/UI thread only, and
|
||
coalesced so repeated clicks produce one restart per settled state.
|
||
**This is NOT the change `reasampler_processor.cpp:66-68` forbids.** That warning is against
|
||
reintroducing per-mode **bus** renegotiation (`kIoChanged` class), which panned a dual-mono
|
||
capture hard right in the host's pin re-routing; `kLatencyChanged` is a different flag and the
|
||
bus is untouched. But the precedent — mid-session `restartComponent` in this plugin has
|
||
already shipped one real regression — is exactly why (2) and (3) are non-negotiable.
|
||
- **Flipping the toggle during playback: apply immediately, do NOT defer to a transport
|
||
boundary** (product ruling, spec §3.1.1). A deferred restart leaves the plugin misaligned by
|
||
the lookahead with no visible cue, which is worse than a visible interruption; and the host,
|
||
not the plugin, schedules the deactivate/reactivate anyway. **Daniel has accepted the
|
||
interruption outright** (Γ-F6) — it is not a case to design for. Two things remain in scope,
|
||
and neither is a mitigation for it:
|
||
- **A short (≤ 10 ms) equal-gain crossfade over the engage/disengage.** Kept as a *quality*
|
||
measure, not a mitigation: a limiter engaging is a gain-path change, and this codebase
|
||
already ramps every gain-path change (`kGainRampSeconds`, `ValueRamp`). It also earns its
|
||
keep independently of the restart, because **we do not control when the host acts on the
|
||
request** — our own transition must be clean in the window before it does.
|
||
- **The limiter enable is classified NOT automatable**
|
||
(`docs/product/parameter-automation.md` §3.8) so nothing can flip it at rate. It is also
|
||
**not** the plugin's `kIsBypass` parameter.
|
||
- **Per block the processor publishes, as relaxed atomics:** per-channel peak `max|x|`, a
|
||
latched clip flag, and the block's maximum gain reduction. **No dB conversion, no
|
||
ballistics, no hold timers on the audio thread** — the UI converts and runs ballistics from
|
||
block peaks and elapsed time. This widens the existing advisory-peak pattern
|
||
(`reasampler_processor.h:109-113`), which is not reusable as-is.
|
||
- **Meter ballistics (pure, unit-tested):** instantaneous rise; **fall 20 dB/s**; peak-hold
|
||
latched at the running max, **held 1.5 s**, then falling at the same rate; scale **linear in
|
||
dB over −60…+6 dBFS**; clip latches at block peak ≥ 0 dBFS and is cleared on request.
|
||
- **The `ComponentState` payload rung** appends the limiter flag as a strict suffix on the
|
||
existing discipline; the preceding version's blob is a strict prefix and lifts to bypassed.
|
||
|
||
**Acceptance criteria.**
|
||
- **With the limiter bypassed the rendered output is byte-identical to the pre-change build**,
|
||
asserted by a regression baseline, not by ear.
|
||
- With the limiter engaged, no output sample exceeds the ceiling on program material that
|
||
exceeds it by up to +12 dB; with it bypassed and gain driven, the output does exceed
|
||
0 dBFS (proving the toggle is doing the work).
|
||
- **Nothing is louder at rest with the limiter on.** A signal that never reaches the threshold
|
||
is bit-identical engaged and bypassed.
|
||
- No allocation, no lock, no transcendental on the per-sample path; the measure-and-report
|
||
gate reports per-voice-block CPU with the limiter engaged at 32 voices.
|
||
- The meter-ballistics module is pure and CTest-covered: rise, 20 dB/s fall, 1.5 s hold, clip
|
||
latch/clear, and the dB↔pixel map are all asserted without a host.
|
||
- A project saved before this change reopens with the limiter bypassed and sounding identical.
|
||
- **`getLatencySamples()` returns exactly 0 with the limiter off**, and the lookahead in
|
||
samples with it on — asserted against the persisted flag, and correct across a
|
||
deactivate/reactivate cycle.
|
||
- **A dual-mono capture rendered across a limiter toggle stays centered** (L ≡ R), and the
|
||
output bus arrangement after a latency-change restart is identical to before it.
|
||
- **The plugin emits no hard step at the toggle** — the engage/disengage crossfade is asserted
|
||
on a rendered signal, not judged by ear.
|
||
|
||
**Open questions.**
|
||
- **No [Daniel] questions. Fork Γ-F6 is ruled** — dynamic latency ships as specced, the
|
||
deactivate/reactivate is accepted, and there is no fallback design and no measurement gate.
|
||
Do not reintroduce either; the constant-reported-latency option is closed, not shelved.
|
||
- **[verify]** `temp_cortex/` has already been assessed and **rejected** (spec §3.5) — do not
|
||
re-litigate it, and do not transplant from it.
|
||
- **[record, not a gate]** While the limiter is in REAPER under your hand, note what the
|
||
restart actually costs — do notes cut, is the re-decode perceptible, does transport hiccup —
|
||
and record it in this track's review. It is **not** a gate on shipping and no outcome changes
|
||
the design; it is the trigger-condition evidence for the `docs/TODO.md` entry "Decouple the
|
||
instrument reload from VST3 activation," which is where that cost gets reduced if it ever
|
||
matters. Do **not** restructure `setActive` here: its destructive shape is deliberate and its
|
||
reasoning (ghost sustained voices on reactivate) is documented at the call site.
|
||
|
||
#### Γ-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.**
|
||
- **`kEditorMinWidth` 980 → 1190. `kEditorMinHeight` stays 680** (Γ-F1).
|
||
- **Three declared budget constants in `knob_deck.h`:** the row block both rows will justify
|
||
inside (**1020**), the right-anchored spanning deck's reserved width (**MASTER 142**), and
|
||
the ceiling (**1280**). These are *declarations of budget*, not measurements — nothing
|
||
computes them from a descriptor, and Γ-W3-T1's job is to prove its content fits inside them.
|
||
- **The floor is derived, not asserted as a literal.** `1020 + kDeckGroupGap(12) + 142 +
|
||
2·kPad(8) = 1190`. `kEditorMinWidth` stays a literal in `sample_bands.h` — **do not add an
|
||
include edge from `sample_bands` to `knob_deck`**, which would invert the allocator's
|
||
deliberate independence from the deck (it takes `deckHeight` as a *parameter* for exactly
|
||
that reason). The identity is asserted in `test_deck_groups.cpp`, which already includes
|
||
both headers. This is the Θ-W6-T1 derived-floor precedent, landed once and never rewritten.
|
||
- **Row membership becomes a property of the group id:** `DeckRow { Sound, Contour, Spanning }`
|
||
+ `deckRowFor(DeckGroupId)` in `deck_groups`, an **exhaustive switch** on the
|
||
`isLiveDeckParam` discipline, so a future group is a compile error rather than a silent
|
||
default. Partition: **Sound** = PITCH/RATE, FILTER, VELOCITY, VOICE; **Contour** = PITCH ENV,
|
||
FILTER ENV, AMP ENVELOPE; **Spanning** = MASTER. **Nothing consumes it until Γ-W3-T1** — that
|
||
is the seam, and it is why the predicate is safe to land now: **membership is a property of
|
||
the group, width is a property of the descriptor**, and only the widths are still moving.
|
||
- **Γ adds no new deck group**, so no later track amends this predicate.
|
||
|
||
**The seam, stated as what this track can and cannot assert.**
|
||
|
||
*Can assert today:*
|
||
- The derived floor identity above, and `kEditorMinWidth ≤ 1280` with **90 px** of headroom.
|
||
- `kEditorMinHeight == 680`, asserted so no later track drifts Γ-F1's ruling.
|
||
- `deckRowFor` is total over `DeckGroupId` and yields exactly the partition above.
|
||
- **Row 2's natural width already fits the block, in both play modes:**
|
||
252 + 312 + 312 = **876 ≤ 1020**, leaving both its gutters ≥ `kDeckGroupGap`. Mode-stable
|
||
because FILTER ENV's and AMP's reserve slots hold them at 312 in Gate and Trigger alike.
|
||
- **MASTER's reserve is not yet spent:** `deckGroupWidth(MASTER) == 72 ≤ 142`.
|
||
- At the floor, deck band **216** and waveform band **358** — the reflow's 112 px arrives here,
|
||
two waves early (see the interim layout below).
|
||
|
||
*Cannot assert yet, and must not force:*
|
||
- **Row 1's natural width does not fit the block.** Today it is PITCH 150 + FILTER 524 +
|
||
VELOCITY 192 + VOICE 164 = **1030**, against the 1020 block. The 50 px deficit is exactly
|
||
what the two descriptor changes buy: PITCH → PITCH/RATE **+42** (Γ-W2-T1) and FILTER's
|
||
`Band|Notch` moving to the caption corner **−92** (Γ-W3-T1), netting **980**. Record the
|
||
target and the two contributions as a test comment; **assert the fit in Γ-W3-T1, and do not
|
||
pre-empt either descriptor change to close it early.**
|
||
- Gutter distribution, the filter tie-line at x = 636, flush outer edges, MASTER's interior and
|
||
its meter — all Γ-W3-T1. Every one of them measures a descriptor that does not exist yet.
|
||
|
||
**The interim editor, stated exactly so it is not filed as a defect.** At the new floor,
|
||
`availWidth = 1190 − 2·kPad = 1174`, and the **unchanged** greedy whole-group wrap packs:
|
||
|
||
```
|
||
row 1 PITCH 150 · PITCH ENV 252 · FILTER 524 = 950 used, 224 px ragged right
|
||
row 2 FILTER ENV 312 · AMP 312 · VELOCITY 192 ·
|
||
VOICE 164 · MASTER 72 = 1100 used, 74 px ragged right
|
||
```
|
||
|
||
**Two rows, not three** — so the deck band is already 216 and the waveform already draws at its
|
||
final 358 px, in both Gate and Trigger. After Γ-W2-T1 lands PITCH/RATE the pack is row 1 = 992,
|
||
row 2 unchanged; still two rows. The composition is wrong in exactly the way the reflow exists
|
||
to fix — PITCH ENV sits up with the sound decks, VOICE and MASTER sit down with the envelopes,
|
||
MASTER is still a single-height 72 px box, and both rows are left-packed with dead space at the
|
||
right. **Worse than today in composition, better in proportion.** That is the accepted
|
||
transitional state for the rest of the phase.
|
||
|
||
**Do not convert the two-row interim into a claim.** It is a coincidence of the greedy wrap at
|
||
exactly this width, not a guarantee — which is precisely why Γ-W3-T1's criterion is "two rows
|
||
**by construction**, asserted against the group inventory, not observed as a wrap outcome."
|
||
`testDeckFitsInsideTheEnforcedMinimumWindow` currently asserts `deckRowCount == 3`; relax it to
|
||
an **upper bound** (`<= 2`), which is a real regression canary throughout the interim and is
|
||
subsumed by Γ-W3-T1's exact claim. An exact `== 2` here is acceptable only with a comment
|
||
naming it as a wrap outcome the reflow replaces.
|
||
|
||
**Acceptance criteria.**
|
||
- **The floor is 1190 × 680, reached by a derived test over the three budget constants**, not
|
||
by a literal — and the derivation is the one Γ-W3-T1 later reads rather than a second copy.
|
||
- **Headroom is exactly 90 px** against the 1280 ceiling, asserted.
|
||
- `deckRowFor` is exhaustive over `DeckGroupId`; adding a group without classifying it fails to
|
||
compile.
|
||
- Row 2's natural width and MASTER's unspent reserve are asserted, in **both** play modes.
|
||
- **All five floor-reading test fixtures pass at the new floor** — including the chrome row,
|
||
whose title slot gets *more* room at 1190, not less.
|
||
- **No drawing code changes, no descriptor changes, no parameter changes, no audio change.**
|
||
A regression baseline proves the last of those trivially.
|
||
- The two invalidated `knob_deck.h` notes (§7.1's fourteen-pixel headroom figure, §7.4's
|
||
cells-and-floor pairing) are **re-derived against the new floor, not deleted** — §7.4's
|
||
restatement is *the deck's cell metrics AND its group/row composition both drive
|
||
`kEditorMinWidth`; none of the three may move alone.*
|
||
|
||
**Open questions.** **No [Daniel] questions.** **[propose at review]** whether the three budget
|
||
constants belong in `knob_deck.h` (the deck owns the row block and the spanning-deck reserve)
|
||
or in `sample_bands.h` (the allocator owns the floor they derive). The plan's lean is
|
||
`knob_deck.h` with the identity in the test, because it adds no include edge; either is
|
||
defensible, but the *derivation must live in exactly one place*.
|
||
|
||
#### Γ-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, on `pitch_shift`'s existing pre-warm
|
||
precedent.
|
||
- **Per-voice state, holding up at the 32-voice ceiling.** The gate is 32 simultaneous
|
||
Preserve voices at 50 % and 200 %, not one voice at 100 %.
|
||
- **No new third-party dependency** (`pitch_shift`'s standing property).
|
||
- **No dispatch on the per-sample path** — concrete, inlineable types; no `IStretcher`.
|
||
- **Onset behaviour is a regression surface.** GA2 eliminated Preserve's ~25 ms onset latency
|
||
by priming the ring with the actual upcoming source. **A stretcher that reintroduces an
|
||
onset delay or a first-frame smear is a regression, not a trade-off.**
|
||
|
||
**Acceptance criteria.**
|
||
- **Ratio 1.0 with no shift is bit-identical to the shipped Preserve read**, asserted by a
|
||
regression baseline — the null case, and the criterion that makes landing this ahead of Rate
|
||
safe.
|
||
- Preserve speaks on frame 0 — no added onset latency, no first-frame smear, in any
|
||
ratio/shift combination.
|
||
- No audible metallic or phasey artefacting on sustained tonal material at ±6 st and
|
||
75–133 % ratio; transient material at 50 % / 200 % is no worse smeared than **varispeed
|
||
playback at the equivalent ratio** — the honest "what does preserving pitch cost" reference,
|
||
and the one that needs **no disposable implementation built to serve the comparison.**
|
||
- The Gate sustain-loop contract is unchanged: **loop the source, shift the output** — loop
|
||
points remain source-frame facts.
|
||
- **Measure and report before the algorithm is final:** per-voice CPU at 32 voices, added
|
||
latency (must be zero at the onset), and A/B recordings on three material classes (one-shot,
|
||
tonal sustain, full-mix bounce). Report to Daniel; the choice is not final until he has heard
|
||
the A/Bs.
|
||
|
||
**Open questions.** **[propose, with a measurement step]** the algorithm family itself.
|
||
**[verify]** that `core/instrument/CLAUDE.md`'s *"`WDL_Resampler` is not a Preserve engine —
|
||
never wire it as the duration-preserving path"* is honoured: under Preserve, Rate legitimately
|
||
changes duration, so a resampled read is an explicit duration control — but the
|
||
*pitch-preserving* mechanism must not be a resampler. **No [Daniel] questions.**
|
||
|
||
---
|
||
|
||
### Γ-W2 — New controls, and the overlay's marks
|
||
|
||
**Depends on Γ-W1 for — four dependencies, two of them new:**
|
||
1. **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.
|
||
2. **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.**
|
||
3. **T2 ← W1-T3 (the contour trace).** Both write `editor_paint_waveform.cpp`; running them
|
||
together is a merge fight in one file.
|
||
4. **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 wire
|
||
`WDL_Resampler` as 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 by `applyLive`.
|
||
`isLiveDeckParam`/`liveCommitFor` widens from two states to three
|
||
(`Live` / `NoteOnLatched` / `Reload`) in that one predicate — **not** a second table, and
|
||
**not** the reload tier (a swept knob must never trigger a WAV re-decode). **Record the
|
||
reason in the header:** loop resolution and contour mapping are note-on folds, so live rate
|
||
means re-folding a resolved loop and re-mapping a contour mid-note.
|
||
- **Pitch is live** — under Varispeed one more factor in a multiply the pitch envelope already
|
||
performs; under Preserve an addend to a shift the pitch envelope already modulates.
|
||
- **Loop points scale with rate; contours scale with rate.** Neither rewrites stored values:
|
||
the loop is source-frame facts traversed at the new increment (Varispeed) or the new read
|
||
rate (Preserve), and a contour is a function of normalized position. **Staged envelope stage
|
||
times do NOT scale** — 30 ms is 30 ms at any rate. That asymmetry is deliberate: a contour is
|
||
of the sample, a staged envelope is of the performance.
|
||
- **Deck descriptor:** three cells; `captionWidth` **70**, hard ceiling **80** (above that the
|
||
caption row overtakes the 180 px knob row and the group exceeds 192). If the text will not
|
||
fit at 80, narrow the `Varisp|Presrv` segments 48 → 44 (ceiling becomes 88) — **do not widen
|
||
the group**.
|
||
- **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 `DeckParam`s are classified in the three-state predicate, and that classification
|
||
is what puts them in the VST3 parameter list three waves later** — Rate `NoteOnLatched`,
|
||
Pitch `Live`. Γ-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 `DeckParam`s produces a
|
||
compile error in `isLiveDeckParam`'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_Resampler` guardrail 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|On`
|
||
toggle joins the toolbar row's right-anchored control run, **immediately left of the
|
||
`Mono|Stereo` toggle**, with Browse still rightmost. Loop is a waveform-overlay concept and
|
||
**has no deck** — a deck cell was never the right home. Because the run is right-anchored
|
||
and the title slot absorbs it, **this costs zero window width and none of the 90 px
|
||
headroom**; if the title will not hold its text at the 1190 floor, **the enable's segments
|
||
narrow — the floor does not move.**
|
||
- **The enable IS `SampleLoop::hasLoop`. No new field, no version bump.** The field already
|
||
exists (`play_params.h:210`), is already what `resolveLoop` refuses on
|
||
(`loop_span.cpp:12`), and is already persisted in the payload's `loopOverride` block —
|
||
where **`start`/`end` are written unconditionally whatever `hasLoop` says**
|
||
(`params_payload.cpp:31-36`), so the wire can already carry "off, with a span remembered."
|
||
What changes is the field's *provenance*: today it is derived from the marker gesture, and
|
||
after this track it is **user-owned**, with the gestures as shortcuts onto it.
|
||
- **Collapse-to-off survives as a shortcut, not as a second state machine.** `hasLoop` is the
|
||
single authority; four gestures reach it:
|
||
| Gesture | Enable | Span | Crossfade |
|
||
|---|---|---|---|
|
||
| Enable → On | on | retained | retained |
|
||
| Enable → Off | off | **retained** | **retained** |
|
||
| Collapse the span onto itself | off | **destroyed**, re-parked at `defaultLoopBounds` | **zeroed** |
|
||
| Drag either loop mark while off | **on** | takes the drag | retained, re-clamped |
|
||
- **Two consequent behaviour changes, each with its reason.** (a) `pickedMarkers`'
|
||
re-park (`editor_session.cpp:221-226`) currently triggers on `!hasLoop`; it must become
|
||
conditional on the span being **invalid** (collapsed / inverted / out of range) rather than
|
||
on the enable being off — a toggle whose off→on does not restore what was there is a delete
|
||
button, not a toggle. (b) `applyMarkers`' crossfade zeroing (`:240-243`) moves from "the
|
||
enable is off" to "the span was destroyed." **The original reasoning is preserved, not
|
||
overruled:** it zeroes so a stale length cannot silently re-apply against a span that no
|
||
longer exists; with the span retained, its clamp bound is retained too and there is nothing
|
||
stale.
|
||
- **The "drag me" affordance splits into two off-states.** Off with **no span ever set** —
|
||
pair parked at `defaultLoopBounds`, Disabled, caption `DRAG TO SET LOOP`. Off with a **span
|
||
retained** — pair Disabled *at its own positions*, caption `LOOP OFF` (there is nothing to
|
||
"set"). In both, **dragging a mark turns the enable on** — the shipped drag-to-create
|
||
gesture survives and now teaches the enable by demonstration.
|
||
- **In Trigger the enable draws Disabled and inert, and does NOT clear `hasLoop`** —
|
||
Disabled-not-hidden, the same grammar as the marks, with its state restored on the return to
|
||
Gate. This transitively covers the drawn-EG case via `enforceGateUnavailableWhileDrawn`
|
||
(`play_params.h:198-205`), which forces Trigger whenever an envelope is drawn — one
|
||
predicate, not a second rule. **Disabled-but-grabbable (the off marks) vs.
|
||
Disabled-and-inert (Trigger) is deliberate:** the user's own off is reversible by the very
|
||
gesture on offer; Trigger's refusal comes from the engine and no drag can talk it out of it.
|
||
- **One mark grammar: line + shaped cap + label. The cap IS the grip.** Four marks:
|
||
**START** (`accent/primary`, solid right-pointing triangle cap, solid line — the only
|
||
primary-ink mark, because it is the only one always in effect); **LOOP** (`accent/secondary`,
|
||
L-cap opening right); **END** (`accent/secondary`, L-cap opening left); **XFADE**
|
||
(`accent/secondary` reduced alpha, ramp cap, **dashed** line — a soft boundary). This
|
||
replaces the bare 10 px orphan tab that today marks the crossfade with no line of its own.
|
||
- **Labels** in `Font::Micro`/`TextDim`, drawn **beneath** the trace and handles in z-order.
|
||
A mark's label **re-draws on top on hover or drag** of that mark. **A label is suppressed if
|
||
its box would overlap one already placed**; placement order is grabbed/hovered first, then
|
||
START, LOOP, END, XFADE. Occlusion by an envelope node is **accepted and named** — the cap
|
||
shape carries the identity permanently, the label is for learning.
|
||
- **The crossfade moves to `[loopEnd − crossfade, loopEnd)`** — where it is audible. The
|
||
handle moves to the loop-end side; **drag direction is unchanged** (left lengthens), so the
|
||
muscle memory survives.
|
||
- **The crossfade region draws as a top-and-bottom edge wedge, NEVER as a second fill.** A
|
||
triangular band at the overlay's top and bottom edges growing from zero at
|
||
`loopEnd − crossfade` to ~10 px at `loopEnd`. **This is a hard constraint:** the region is
|
||
now *inside* the loop span, where a translucent fill would stack on the 0.20 loop fill, and
|
||
the envelope trace crossing that fill is a known, accepted under-floor pair at 2.25:1
|
||
(`editor_paint_waveform.cpp:28-34`), whose own note says the FILL is what changes if it is
|
||
ever resolved. **The loop fill's peak alpha must stay exactly 0.20.**
|
||
- **The ingredient draws as a ghost.** `[loopStart − crossfade, loopStart)` draws the mirror
|
||
wedge at half alpha, no handle — **a hairline dashed outline at rest, filling in on hover or
|
||
drag of the crossfade handle**. This makes the `crossfade ≤ min(start, loopLength)` clamp
|
||
self-explanatory: the fade stops growing exactly when the ghost's left edge reaches START or
|
||
LOOP, so the user sees the reason instead of hitting an invisible wall.
|
||
- **Trigger mode:** the loop pair and the crossfade mark draw **Disabled and are not
|
||
grabbable**, with a dim `LOOP — GATE ONLY` caption — Disabled rather than hidden, matching
|
||
the editor's existing Gate-segment grammar, and because hiding a set loop on a mode flip
|
||
destroys information the user put there. START stays fully live.
|
||
|
||
**Acceptance criteria.**
|
||
- The four marks are distinguishable by ink and cap shape with the labels suppressed, and
|
||
named when they are not.
|
||
- **The shaded crossfade region sits over the frames where the fade is audible** — verify
|
||
against a rendered loop, not by reading the code.
|
||
- Every mark is grabbable by its cap; grabbing a mark shows its label.
|
||
- The crossfade at its clamp shows the ghost's left edge coincident with the bounding mark.
|
||
- **The loop fill's peak alpha is unchanged at 0.20** and the accepted 2.25:1 trace pair is
|
||
neither improved nor worsened.
|
||
- In Trigger, no loop mark accepts a grab, the chrome enable is Disabled and inert, and the
|
||
reason is on screen. Returning to Gate restores the enable's prior state.
|
||
- **Turning the enable off and on again restores the loop exactly** — same span, same
|
||
crossfade, no re-park. Collapsing the span instead turns it off, re-parks at
|
||
`defaultLoopBounds` and zeroes the crossfade. Both paths asserted.
|
||
- **Dragging a loop mark while the enable is off turns it on**, in both off-states.
|
||
- **The enable costs no window width:** `kEditorMinWidth` is unchanged by this track, asserted
|
||
by the same derived test that guards the floor.
|
||
- **No `ComponentState` version moves; no new persisted field; `resolveLoop` is untouched;
|
||
audio is unchanged.** The enable round-trips save/reload through the existing
|
||
`loopOverride` block, in both states, with the span retained across an off.
|
||
- All cap/label/suppression geometry is pure and unit-tested; no hit-test math in the painter.
|
||
The enable's rect lands in `sample_chrome` alongside the rest of the control run.
|
||
|
||
**Open questions.**
|
||
- **[propose at review, then verify by hand]** The claim-arbitration inputs change:
|
||
`markerHandleRect` today gives a tab to the crossfade only, and `resolveWaveformClaim`
|
||
breaks ties by smallest nominal target area. Giving every mark a cap-grip changes the
|
||
candidate set **and every nominal area in it**. The arbitration must be re-derived, and
|
||
`docs/TODO.md`'s open entry *"Pre-existing staged-envelope-node shadow at zero-attack"*
|
||
must be **re-evaluated against the new cap geometry and its outcome recorded** — resolved or
|
||
worsened, either is acceptable, silence is not.
|
||
- **No [Daniel] questions.** Fork Γ-F4 is ruled — there **is** an explicit enable and it is on
|
||
the chrome row, in this track. The prior framing ("an enable needs a cell, so it is a Γ-W3
|
||
layout decision") was wrong and is retired: loop has no deck, so it never needed one.
|
||
- **[propose at review]** every site that currently *infers* `hasLoop` — two in
|
||
`editor_input_waveform` (`:255`, `:258`), two in `editor_session` (`:222`, `:236`) — is now
|
||
writing to a user-visible control rather than to an internal flag. Re-read each in that
|
||
light; "it still compiles" is not a disposition.
|
||
- **Named escalation, not a fallback to take silently:** if the top strip reads crowded in the
|
||
DAW, the pre-designed answer is the marker rail (spec §6.2, Direction 2) — a larger build
|
||
that would also dissolve the arbitration problem structurally. Escalate; do not improvise a
|
||
half-rail.
|
||
|
||
---
|
||
|
||
### Γ-W3 — The reflow, 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|Notch` moves from its row-toggle position to the caption corner**, taking
|
||
the group 524 → **432** (−92 px). It occupies FILTER's currently-unused `captionToggle2`
|
||
slot — **no new geometry is required**.
|
||
- **VOICE keeps its `Retrig|Legato` row toggle.** Moving it to the caption makes VOICE
|
||
*wider* (226, not narrower), because its caption row is the binding side. Verified; do not
|
||
"fix" it.
|
||
- **Justification law, applied to BOTH rows:** space-between within the row block; slack
|
||
divided equally among the row's (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
|
||
`kDeckGroupGap` all hold at 1020 and only at 1020 — **this is why the floor is 1190 and not
|
||
1186.** Above the floor the tie-line drifts and that is accepted (spec §1.3).
|
||
- **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|Notch` move**: 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 `warn` GR bubble in the far corner (non-interactive — the same slot the
|
||
envelope decks' radio uses; round so it reads as a lamp, not a control); **gain knob in the
|
||
upper-left cell at box-relative y = 26** and a **reserved empty slot at y = 138** — i.e. the
|
||
two cells land on row 1's and row 2's knob baselines exactly, which is what stitches the
|
||
spanning deck to both rows; **meter column 62 px wide × 186 px tall** on the right.
|
||
- **Three rules not to generalise wrongly:** MASTER's left column uses **fixed cell slots at
|
||
the two baselines, NOT the horizontal run-division law** (that law would stretch one knob
|
||
over 186 px); the reserved slot **draws nothing** (blank reads as breathing room, a dashed
|
||
placeholder reads as unfinished); the meter is **one rect spanning both baselines**, not two
|
||
per-row meters.
|
||
- **The meter draws W1-T2's published state**, with the ballistics run on the UI timer.
|
||
**Bar count follows the same `LaneSplit` decision `waveformSurface` already folds** (channel
|
||
mode ∧ source channel count) — one wide bar when the waveform draws one lane, two skinnier
|
||
bars when it draws two. Not a second rule: a mono source in stereo mode is dual-mono, and
|
||
two identical bars would be a lie.
|
||
- **Meter appearance:** bar in `accent/primary`; peak-hold tick 2 px in `text/primary`; clip
|
||
cap in `warn`, latched, click-to-clear; scale linear in dB over −60…+6 with ticks every
|
||
6 dB and numerals at 0/−12/−24/−36/−48/−60, the 0 dB tick heavier. **No green/yellow/red
|
||
segmentation** — `warn` stays reserved for clip states.
|
||
|
||
**Acceptance criteria.**
|
||
- At the floor width the deck lays out in **exactly two rows plus the spanning MASTER**,
|
||
**by construction** — asserted against the group inventory, not observed as a wrap outcome.
|
||
- Every group's width matches the §1.2 table exactly, **in both Gate and Trigger** (row 2's
|
||
natural width is mode-stable at 876 because the reserve slots hold FILTER ENV and AMP at
|
||
312 in both modes — assert it).
|
||
- Row 1 and row 2 are **flush left and flush right**; at the floor width the filter tie-line
|
||
is exact (both edges at x = 636) and row 2's two gutters are equal.
|
||
- **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|Notch` move.
|
||
- **`kEditorMinWidth` is 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**: `deckRowCount` at 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 and `sample_bands`' tests are updated to the new law, and the invalidated
|
||
notes in `knob_deck.h` (the fourteen-pixel headroom figure; the cells-and-floor pairing) are
|
||
**re-derived, not deleted** — spec §7.1, §7.4.
|
||
|
||
**Open questions.**
|
||
- **[propose at review]** Whether the greedy whole-group wrap survives at all as a sub-floor
|
||
degrade, or is replaced outright by explicit row assignment. What is **not** optional: at
|
||
and above the floor width the layout is the specified arrangement, reached by construction.
|
||
`DeckLayout::rowCount`/`::height` change meaning either way (spec §7.3).
|
||
- **No [Daniel] questions.** Forks Γ-F5 (**one cell**) and Γ-F1 (**680 stays**) are both
|
||
ruled; they are stated in Behavior above, not carried here as options.
|
||
- **[verify]** `deck_groups.cpp`'s `kEnvModeSegW = 23` ceiling rises to **47** once PITCH ENV
|
||
is on row 2 (AMP binds at 55). No change is required; the comment stating the old ceiling
|
||
stops being true and must be corrected (spec §7.2).
|
||
|
||
#### Γ-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:**
|
||
|
||
1. **← 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.
|
||
2. **← 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.
|
||
3. **← W2-T1 specifically.** `isLiveDeckParam` becoming three-valued is the *prerequisite* of
|
||
the classification, not an incidental: the exposed set is exactly `Live ∪ NoteOnLatched`.
|
||
4. **← W3-T1.** MASTER's inventory (limiter toggle, GR bubble, reserved cell) is the last
|
||
change to what controls exist at all.
|
||
5. **← 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 a
|
||
`SingleComponentEffect`, where the SDK itself collapses `IComponent::setState` and
|
||
`IEditController::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's `instrument_drop` writes, which
|
||
parameters-as-truth would silently make partial.
|
||
- **`ParamID` is 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 against
|
||
`cellIds`; **§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 is `Live` or `NoteOnLatched`.
|
||
**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 **`units` string**, and a **display precision**; the complete
|
||
eight-category table covering all 44 — ranges, units, precision, and which taper each
|
||
category carries — is `docs/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 is `public.sdk/samples/vst/common/logscale.h:221-229`
|
||
overriding `toPlain`/`toNormalized` for a log law — the exact shape our log ms and log2
|
||
semitone knobs need.
|
||
- **`normalizedParamToPlain` / `plainParamToNormalized` / `getParamStringByValue` /
|
||
`getParamValueByString` route 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. **`toNormalized` IS 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 = 0` on all 44, and the editor's shift-snap is never exposed as `stepCount`.**
|
||
Snapped drag and parameter continuity are independent axes; `stepCount` quantizes 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.
|
||
- **`IParameterChanges` is 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 `reloadInstrument` or `rebuildVoiceEngine` — 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.** `toPlain` is read-side
|
||
only; reporting Hz / Q / drive depth means calling `filterCutoffHzFromNorm`,
|
||
`filterQFromNorm` and `filterDriveDepthFromNorm` — the filter module's own frozen laws,
|
||
which `deckValueLabel` already calls today — not restating them. **The one additive piece:
|
||
`filterNormFromDriveDepth` does not exist and must be added in `filter_params`**, beside the
|
||
two inverses that do; the analytic inverse of a frozen law is not a change to it. §6.7.5.
|
||
- **No `kIsBypass` on 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`/`endEdit`
|
||
path, 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:**
|
||
`IMidiMapping` is **in scope and nearly free** (a CC → `ParamID` map, one function);
|
||
`IParameterFunctionName` is **not implemented** (its vocabulary is compressor/panner
|
||
semantics that name nothing here); `IAutomationState` is **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 `Live` or `NoteOnLatched`** — 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 `cellIds` would defeat the freeze it exists
|
||
to hold.
|
||
- **`toPlain(info.defaultNormalizedValue)` compares EXACTLY equal to the default**, per
|
||
parameter, against a default-constructed `PlaySeconds` (and against `master_gain`'s unity),
|
||
so a host's reset-to-default and the editor's double-click land on the same value.
|
||
**`defaultNormalizedValue` is computed as `toNormalized(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.
|
||
- **`getParamStringByValue` prints 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 no `snprintf` of a
|
||
parameter value outside it.
|
||
- **Every exposed parameter's `units` and plain range match §6.7.1**, asserted per parameter;
|
||
`stepCount` is 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 `reloadInstrument` or `rebuildVoiceEngine`.**
|
||
- **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 `*Norm` values 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` (not `setComponentState`) on a
|
||
single-component plug-in, and the ordering of `setState` against the first
|
||
`IParameterChanges` block 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::units` beside the string
|
||
`getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own
|
||
convention** — digits in the string, unit carried separately, which is what
|
||
`RangeParameter::toString` and the `Parameter` constructor'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 `Reload` to
|
||
`NoteOnLatched` (§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 `IMidiMapping` CC 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 `setState` verification 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.md`
|
||
reload/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:
|
||
|
||
1. **The never-touch-solo rule** (Ψ-W1-T2): `src/shell/view/CLAUDE.md:14-17`,
|
||
`src/core/view/CLAUDE.md:10`, and `docs/product/design-view.md:160-165` + `:588-592`.
|
||
Ψ.2 requires writing `I_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.
|
||
2. **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_action`
|
||
mirror) must be added beside it.
|
||
3. **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 at `RENDER_CHANNELS=2` today). 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 a widened
|
||
render and retains it outside the bank for diagnosis rather than deleting it. 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 were opened at framing and all three are RULED** (Daniel, 2026-08-02). The
|
||
rationale for each — including why two went against the framing recommendation — is
|
||
recorded at `docs/product/bank-package.md` §"Rulings"; the binding specification for each
|
||
is in the section of that doc named below.
|
||
|
||
| Fork | Ruling | Specified in | Bound into |
|
||
|---|---|---|---|
|
||
| **Ε-F1** | **Proprietary container** — the hand-rolled `RSBK`. ZIP via the vendored MiniZip64 (`vendor/WDL/WDL/zlib/`) and a hand-written stored-only ZIP shape are both rejected and are not to be revisited in this phase | §"The container" | Ε-W1-T1 |
|
||
| **Ε-F2** | **Import always lands as a new bank**, with an automatic numeric suffix on a display-name collision — no prompt, no overwrite. Merge-into-existing is **out of scope for Phase Ε**, not deferred | §"Identity and collision on import", incl. the auto-suffix rule | Ε-W2-T2 |
|
||
| **Ε-F3** | **Refuse** the import when the tracking ledger is degraded. No confirm-and-proceed path, no opt-out | §"Import under a degraded tracking ledger" | Ε-W2-T2 |
|
||
|
||
**No Ε track is gated on a decision.** Every track in this phase is dispatchable as
|
||
written.
|
||
|
||
### 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 `hashBytes` digest 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_codec` may 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 (root `CLAUDE.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 through `persistBankOp`.
|
||
- **All-or-nothing on both sides.** No partial export, no partial import. A truncated
|
||
`.rsbank` must 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. No `ReaSamplerSession&`, no service container, no "pass the thing that has
|
||
everything" reaches `core/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>_tests` target** 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), and
|
||
`core/model/bank_book.{h,cpp}` (W2-T2 only — **one additive public `const` member**, required
|
||
by the Ε-F2 auto-suffix rule so the name fold keeps its single home). **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.
|
||
|
||
**All three tracks are dispatchable now.** T1 carries the Ε-F1 ruling — the container is the
|
||
hand-rolled `RSBK`, decided, not a candidate T1 chooses among. T2 and T3 never depended on
|
||
that ruling anyway: 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.**
|
||
- **The container is the hand-rolled `RSBK` (Ε-F1, ruled).** Magic `RSBK`, a fixed
|
||
little-endian header carrying the two version fields, a length-prefixed JSON manifest, then
|
||
each entry's payload concatenated in manifest order. Framing is built on `core/wire/bytes.h`
|
||
(`putLE` / `ByteReader`) and the manifest on `core/json` — both already owned and tested
|
||
here. **No ZIP, no compressor, no new third-party source in the build**; a link edge to
|
||
`vendor/WDL/WDL/zlib/` means the ruling was misread.
|
||
- **Two version integers, not one.** `formatVersion` = what this writer emitted;
|
||
`minReaderVersion` = the oldest reader that can read it safely. The reader's whole rule is
|
||
`minReaderVersion <= kPackageFormatVersion`. An **additive** change (a new optional
|
||
manifest key, a new enum value with a defined degrade) bumps `formatVersion` only; 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-21` documents 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:182` behaviour), and
|
||
**unknown persisted enum integers degrade to their defined `Unknown` equivalent**, never to
|
||
the numeric default and never to a parse failure (`core/wire/CLAUDE.md`'s `BakeStatus` rule,
|
||
verbatim). Both are pinned by tests, not left to inheritance.
|
||
- **The manifest nests `BankModel`'s own serialization verbatim**, exactly as
|
||
`bank_book_json.cpp:15-20` nests it, so per-sample shape has one owner and a future
|
||
`Sample` field reaches packages for free. Per entry the manifest adds only: the bare file
|
||
name, the byte length, and a `hashBytes` digest (`core/capture/wav_codec.h:143`) —
|
||
`hashBytes`, **not** `hashWavContent`, because the latter deliberately skips chunks
|
||
(`wav_codec.h:145-151`) and so cannot answer "did these bytes survive."
|
||
- **The bank's `slot_map` rides along** — display positions are part of what the user built.
|
||
- **Framing only, never a payload.** `bank_package` produces 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)) == x` over a manifest fixture exercising every field,
|
||
including every `Sample` optional in both present and absent states.
|
||
- A synthetic header with `minReaderVersion` above this build classifies `TooNew` and **no
|
||
manifest is produced** — the decode does not half-succeed.
|
||
- A synthetic header with `formatVersion` above this build but `minReaderVersion` at or below
|
||
it classifies `Readable`, 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 the
|
||
`bank_model.h:204-206` standard.
|
||
- 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_tests` all run without
|
||
REAPER or a DAW.
|
||
|
||
**Open questions.** **No [Daniel] questions — Ε-F1 is RULED** (proprietary `RSBK`), so this
|
||
track is dispatchable as written. **[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 `.rsbank` behind. 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-11` and 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 for `FileNameFor|SaveFile|Browse`
|
||
returns only the read picker — so export uses Win32 `GetSaveFileNameW` on Windows and
|
||
SWELL's `BrowseForSaveFile` elsewhere (`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:167` exactly. `[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_win` precedent.
|
||
|
||
**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::PackageImport` as value 5.** Append only — `Capture`=1, `Ingest`=2,
|
||
`Recapture`=3, `Resample`=4 keep their integers, per `core/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 existing
|
||
`kindFromInt` behaviour 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.
|
||
- **`recordCreated` needs no signature change** — it already takes an `OriginKind`
|
||
(`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 `Unknown` and
|
||
the ledger loads `Loaded`, not `Unreadable` — the vocabulary gap does not halt prune.
|
||
- `kLedgerVersion` is **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_codec` is not asked to
|
||
rebuild anything.
|
||
- **A new FOREVER-STABLE command id** is minted through `version::channelCommandId(suffix)`
|
||
per the root `CLAUDE.md` action contract, with its per-channel display name through
|
||
`channelActionName`.
|
||
|
||
**Acceptance criteria.**
|
||
- `planExport` is 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 `hashBytes` digest
|
||
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 `.rsbank` at 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 **new** 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" (including the
|
||
auto-suffix rule), §"Failure modes" (import rows), §"Import under a degraded tracking
|
||
ledger", §"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, and — the **only** `core/model/`
|
||
edit in the phase — **one additive public `const` member on `BankBook`** (recommended
|
||
`std::string uniqueDisplayName(const std::string& seed) const`), so the auto-suffix probe runs
|
||
behind the model's own name fold. **Does not own:** anything on the export side,
|
||
`bank_book`'s *rules* (consumed, never re-implemented — the new member exposes the existing
|
||
fold, it does not add a second one), `origin_ledger` (Ε-W1-T3's).
|
||
|
||
**Behavior.**
|
||
- **The ledger guard runs FIRST — before the file picker opens (Ε-F3, ruled: refuse).** If
|
||
`tracking::ledgerDegraded(status)` holds for the project's loaded ledger status
|
||
(`Unreadable` or `FutureVersion`; `core/tracking/origin_ledger.h:94`, `:100-101`), the
|
||
import **refuses outright** — no picker, no bytes read, no confirm-and-proceed path, no
|
||
opt-out. `Fresh` and `Loaded` both proceed. **Do not key this on
|
||
`PruneReport::blockedByTracking`**: that flag also fires on undecodable `rsusage_*` keys,
|
||
which govern deletion-time protection and have nothing to do with writing birth records.
|
||
The refusal is a `ShowConsoleMsg` block mirroring `prune_action.cpp:30-69` in structure and
|
||
tone, with two variants (malformed / newer-build) and every recovery line naming **this
|
||
build's** namespace through `version::extStateNamespace()`. Exact wording in the spec doc.
|
||
**Export is deliberately not gated this way** — that is Ε-W2-T1's, and it stays ungated.
|
||
- **Version gate second, before any byte is written.** `minReaderVersion` above this build
|
||
refuses the whole package and reports through `ShowMessageBox`
|
||
(**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 why `origin_ledger.cpp:178-185` separates 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 through
|
||
`capture_paths::deriveBankPaths`, silently, counted in the summary. (3) **Content hash** —
|
||
consult `BankModel::findByHash` **before 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** — **auto-suffix, no prompt** (Ε-F2, ruled). Seed = the package's recorded
|
||
source bank name **verbatim** (or the literal `Imported bank` if absent/blank); take the
|
||
first of `seed`, `seed + " 2"`, `seed + " 3"`, … whose fold is free in the destination
|
||
book, ascending from 2. Four points that decide the behaviour and must not be re-invented:
|
||
the seed is **never re-parsed** (`"Drums 2"` colliding lands as `"Drums 2 2"`, not
|
||
`"Drums 3"` — a bare trailing integer is indistinguishable from `"Kit 808"`); the probe
|
||
**fills gaps** (first-free, not highest-plus-one, so it is a pure function of the current
|
||
name set); the probe **terminates** by pigeonhole within `B + 1` candidates for `B` banks,
|
||
so **no arbitrary cap**; and the fold is `BankBook`'s own (`bank_book.h:252-258`), reached
|
||
through the new public member, never re-implemented in `import_plan`. Sample display names
|
||
are **not** suffixed, and `slot_map` positions are untouched.
|
||
- **Always a new bank; never a merge (Ε-F2, ruled).** The import creates a bank — it never
|
||
merges into an existing one, never lands into the pool, and offers no target picker. A
|
||
pool export therefore lands as a **named** bank `"Pool 2"`, which is correct, not a glitch.
|
||
This track ships **one** action, not two; merge-into-existing is out of scope for the
|
||
phase, and move/copy already cover the after-the-fact case.
|
||
- **Birth records at landing.** Every landed file goes through
|
||
`ReaSamplerSession::recordCreated(sample, OriginKind::PackageImport)` at the same point the
|
||
`Sample` is added, in the same straight-line block, per `core/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-11` carve-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 at
|
||
`reaper_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.**
|
||
- `planImport` is 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 back into the project that already contains B lands
|
||
a **new** bank named `"B 2"`, with every id reminted, no entry lost, and B itself
|
||
unmutated. Importing it a third time lands `"B 3"`.
|
||
- The suffix probe is pinned by pure tests over a name set, covering at minimum: a free seed
|
||
(no suffix applied), a case/whitespace-folded collision (`"drums"` blocks `"Drums"`), a gap
|
||
(`"Drums"` + `"Drums 3"` present ⇒ `"Drums 2"`), a seed that already ends in a number
|
||
(`"Drums 2"` colliding ⇒ `"Drums 2 2"`), an absent/blank recorded name (⇒ `Imported bank`),
|
||
and a package whose source bank was the pool (⇒ `"Pool 2"`, a named bank).
|
||
- A degraded ledger (`Unreadable` and `FutureVersion`, both asserted) refuses the import with
|
||
**no picker shown, zero files written, zero index mutation**, and the message names the
|
||
channel-correct ext-state namespace. An undecodable `rsusage_*` key with an otherwise
|
||
`Loaded` ledger **does not** block — asserted, because the tempting reuse of
|
||
`blockedByTracking` would silently make it.
|
||
- An entry whose payload fails its `hashBytes` check 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.
|
||
- `minReaderVersion` above the build: nothing written, message names all three facts.
|
||
`formatVersion` above the build with `minReaderVersion` at 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.** **No [Daniel] questions — Ε-F2 and Ε-F3 are both RULED** (new bank
|
||
always with an auto suffix; refuse on a degraded ledger), so this track is dispatchable as
|
||
written and ships one action. **[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. Note the refusal path is
|
||
already fixed at a console block by the Ε-F3 spec, so this call is about the *success*
|
||
summary only.
|
||
|
||
---
|
||
|
||
### Ε-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 `.rsbank` bytes committed to
|
||
the repo: a v1 package written by the shipping build, a synthetic
|
||
`formatVersion` = N+1 / `minReaderVersion` = current package (the additive-forward case),
|
||
and a synthetic `formatVersion` = 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
|
||
`Malformed` rather than `TooNew`, 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 the
|
||
`docs/verify-track-scope-multitrack.md` precedent — 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 classifies `TooNew`, `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.
|
||
|
||
---
|
||
|
||
## Phase Ρ — Render in place: a track's output to a new sibling, source to the bench
|
||
|
||
**Ships:** one bindable action that renders the selected track's output over the current
|
||
range to a file outside the bank, drops that file as an item on a brand-new sibling track
|
||
at the exact position it was rendered from, clones the source's colour and its name with an
|
||
idempotent `Capture ` prefix, moves the source track into Design mode — where Design
|
||
View's existing park hides it, takes it out of the mix, and puts its FX offline — and puts
|
||
the result track into Arrange, unconditionally. The bank is never read, never written, and
|
||
never notified.
|
||
|
||
**Consolidates: none of the seventeen.** Phase Ρ came from a direct request (Daniel,
|
||
2026-08-02) and is scoped in `docs/product/render-in-place.md`. Daniel's framing, verbatim
|
||
in substance: *similar to REAPER's "Render selected track time selection to new track
|
||
(stereo) and mute original", except instead of muting the original, the source track
|
||
stays/goes to design mode, and the resulting new sibling track — which gets the rendered
|
||
audio item placed correctly in the timeline — stays in whatever mode was active when the
|
||
action was run; the new track clones the source track colour and name with a Capture
|
||
prefix; this must NOT put the rendered audio into the ReaSampler banks/pool.* **The
|
||
mode-following clause in that framing was superseded by Daniel's own Ρ-F2 ruling the same
|
||
day** — *"for this action which is not a capture, the result track should always go to
|
||
arrange"* — and the quote is kept verbatim only as the record of the request. It
|
||
**supersedes nothing** — a sweep of `docs/TODO.md` and `docs/TODO-1.0.md` for
|
||
`render.in.place|render to new track|preserve.source` returns nothing.
|
||
|
||
### The framing answer — why this does not breach the load-bearing principle
|
||
|
||
Root `CLAUDE.md`'s rule — *capture and placement are separate acts; any code path that
|
||
auto-inserts a capture into the timeline must be rejected in review* — is a rule **about
|
||
the bank**, and Phase Ρ does not put the bank on either side of its verb. The full argument
|
||
is `docs/product/render-in-place.md` §"The third verb"; the operative summary, which every
|
||
review of this phase must apply:
|
||
|
||
| Verb | Source | Sink | Touches the bank |
|
||
|---|---|---|---|
|
||
| **Capture** (`RunCapture`, batch, realtime, bake, ingest) | arrange / instrument | bank | writes it |
|
||
| **Placement** (`RunInsertSelected`, `performArrangeDrop`) | bank | arrange | reads it |
|
||
| **Render in place** (this phase) | arrange | arrange | never |
|
||
|
||
What Ρ shares with capture is the **render** — `renderOffline`, `FxBypassGuard`, exact
|
||
custom time bounds, `RENDER_ADDTOPROJ = 0`, the multi-track refusal — not the capture. A
|
||
capture is a render *plus* a bank landing; Ρ takes the mechanism and declines the landing.
|
||
|
||
**Four boundary conditions, three of them structural, all review-rejectable:**
|
||
|
||
1. Ρ's shell never calls `session.bank()`, `session.book()`, `session.recordCreated()`, or
|
||
`session.bumpBankGeneration()`. The `Sample` the backend returns is discarded, and on the
|
||
`ProjectMedia` destination its `relativePath` is left **empty** — a Ρ `Sample` is inert
|
||
by construction.
|
||
2. **Ρ cannot express "write into the bank folder."** The destination reaches the backend as
|
||
a two-valued enum, never a caller-supplied path string. A `renderDir` string on
|
||
`CaptureRequest` instead of the enum **is** the drift, and is rejected on sight.
|
||
3. Ρ's file is never recorded as owned, so prune (`(owned ∩ present) − referenced`) cannot
|
||
reach it — and it lives outside the bank folder, so prune's enumeration never sees it
|
||
either. Two independent layers. The tool deletes only what it owns; a render-in-place
|
||
file belongs to the project.
|
||
4. **Traffic is one-way.** Ρ may borrow capture's render; **capture may never borrow Ρ's
|
||
placement.** No `place` flag on `CaptureActionDef`, no "capture and also place" action,
|
||
ever.
|
||
|
||
### Phase-Ρ acceptance criteria
|
||
|
||
These bind the track in this phase, in addition to the plan-wide set above.
|
||
|
||
- **Placement is sample-exact and unsnapped.** The item lands at the render window's
|
||
`startSeconds`, unrounded, with `SnapToGrid` deliberately **not** applied (unlike
|
||
`performArrangeDrop`). Ρ's placement is the null test performed automatically — a render
|
||
of a range re-inserted at its source position nulls to silence against the source — so a
|
||
snapped or rounded placement is a phase failure, not a rough edge.
|
||
- **No tempo conform, ever.** `computeInsertMode(InsertOptions{})` only; `insert_plan`
|
||
already guarantees the &4 stretch-to-time-selection bit is never set. No conform variant
|
||
of this action is offered.
|
||
- **The bank path is byte-identical to today.** The `CaptureDestination` enum defaults to
|
||
`Bank`; every existing capture entry point must produce exactly the file, path, hash, and
|
||
index entry it produces now. If any capture test changes expectation, the seam is wrong.
|
||
- **The new track is bare.** `InsertTrackInProject(proj, p, /*flags=*/0)` — flags&1 adds
|
||
default envelopes/FX (SDK header 3954) and a default chain would process the render a
|
||
second time.
|
||
- **Both `I_FOLDERDEPTH` writes, or none.** The sibling-placement arithmetic is pure and
|
||
unit-tested before any DAW work; a render that lands the new track at the wrong nesting
|
||
level is audibly wrong in both directions (double-processed through a folder it re-enters,
|
||
or bypassing the folder bus entirely).
|
||
- **The Ρ-F2 ruling survives the auto-tag detector.** The result track and its placed
|
||
item are tagged `kArrangeModeId` **explicitly** (a membership record, not an `untag()`
|
||
to the Arrange default), and `panel_input::detectNewContent` drops added GUIDs that
|
||
already carry a record. Without that filter the detector tags every new track to the
|
||
active mode on its next tick and a Design-fired render silently becomes a Design
|
||
member — the ruling reversed inside a second. This criterion **replaces** the
|
||
tag-before-reapply ordering criterion the phase carried under mode-following: with the
|
||
result track an Arrange member unconditionally, a Design reapply parking it is the
|
||
correct outcome and the ordering is state hygiene, not behaviour.
|
||
- **One undo block, `UNDO_STATE_ALL`,** opened before the track is created and closed after
|
||
the mode reapply; the render sits outside it. `persistViewState` runs **after** the block
|
||
closes — it may raise a Save-As dialog, which must not sit inside an open undo block
|
||
(`design_view_actions::doMoveItems`' documented ordering).
|
||
- **Every pure module gets a `<module>_tests` target.** All three pure additions land in
|
||
existing `core/capture` modules that already have one.
|
||
|
||
**Performance posture.** Every surface is cold — one gesture, once. None of the named hot
|
||
paths (peaks envelope compute, audition, the realtime-capture tick's single-pointer-test
|
||
idle fast path, the instrument's `process()`) is touched.
|
||
|
||
**Concurrency.** Phase Ρ is extension-side. Γ lives in `core/instrument/` +
|
||
`shell/instrument/`; Ε lands in the new `core/package/` + `shell/package/`. The pre-existing
|
||
files Ρ edits are named in its track's surface boundary and intersect neither — including
|
||
`shell/panel/panel_input.cpp`, which the Ρ-F2 ruling adds: no in-flight track in this plan
|
||
touches that file (Phase Ψ's two named regions in it, the footer block and the drag-arm
|
||
block, are both landed, and both are functions other than `detectNewContent`).
|
||
|
||
### Rulings — Daniel's, 2026-08-02. Nothing open.
|
||
|
||
All three forks this phase opened were ruled the day it was framed. Full statements, the
|
||
counter-arguments that made each a fork, and the one unexercised alternative:
|
||
`docs/product/render-in-place.md` §"Rulings".
|
||
|
||
- **Ρ-F1 — multi-track. RULED: refuse.** One selected track per fire, inherited from
|
||
`isMultiTrackStemRender`. A **settled non-goal**, in the same register as the other
|
||
entries under §"What Phase Ρ explicitly is NOT" — not deferred-with-a-plan. There is no
|
||
per-track loop planned, no second wave holding one, and no seam to leave half-open for
|
||
it. Matches the framing recommendation; nothing in the track changed.
|
||
- **Ρ-F2 — the result track's mode. RULED: always Arrange.** *"For this action which is
|
||
not a capture, the result track should always go to arrange."* **This overrode the
|
||
framing and the request's own original wording** — the result track no longer follows
|
||
the active mode. The source still goes to Design. Three consequences, all specced
|
||
below: the result track and its item are tagged `kArrangeModeId` explicitly; firing
|
||
from Design produces **no visible change** (the A/B-on-the-bench behaviour
|
||
mode-following would have enabled **does not exist** and must not be cited as a
|
||
benefit anywhere); and the panel's auto-tag detector needs a two-line
|
||
explicit-tag-wins filter, or it re-tags the result track to Design on its next timer
|
||
tick and silently reverses the ruling.
|
||
- **Ρ-F3 — tail. RULED: follow the panel tail settings.** Matches the framing
|
||
recommendation; nothing in the track changed. **Two consequences, accepted rather than
|
||
caveated:** under Auto/Manual the placed item is **longer than the window it replaces**
|
||
(correct for a decaying chain, wrong for a butt-joined section — the user's lever is
|
||
the panel's own tail setting), and the exact-bounds gate is **inactive** in those two
|
||
modes because it runs only under `TailMode::None`. Both are inherited from every other
|
||
capture path, not introduced here. The item's **start** is exact in all three modes, so
|
||
the null test holds in all three. The third option floated at framing (run the gate's
|
||
start-alignment check regardless of tail mode) is **not ruled in** and is recorded in
|
||
the product doc as an unexercised alternative.
|
||
|
||
---
|
||
|
||
### Ρ-W1 — The verb
|
||
|
||
**Depends on:** nothing.
|
||
|
||
**One track, deliberately.** The whole phase is roughly 350 lines: three small pure
|
||
additions to existing `core/capture` modules, one bounded edit to the offline backend, one
|
||
new shell TU, a two-line filter in `panel_input.cpp`, one `ActionTableRow`, four invariant
|
||
amendments. Splitting it would create a
|
||
merge dance across `core/capture` for no gain, and the pure half cannot be reviewed
|
||
meaningfully apart from the caller that gives it meaning. **Named contingency:** if the
|
||
sibling-placement arithmetic balloons in implementation, that function is the natural split
|
||
point — it is the only piece with zero dependency on anything else in the track.
|
||
|
||
#### Ρ-W1-T1 — `render-in-place`
|
||
|
||
**Goal.** One bindable action: render the selected track's output over the current range to
|
||
the project's recording path, place it on a new sibling track at the exact render position,
|
||
clone colour and name, move the source to Design and the result track to Arrange —
|
||
**always Arrange, whatever mode was active** (Ρ-F2).
|
||
|
||
**Spec:** `docs/product/render-in-place.md` — §"The render", §"Where the file goes",
|
||
§"Placement", §"The new track", §"Mode transitions", §"Undo", §"The action".
|
||
|
||
**Surface boundary — owns:**
|
||
- **New:** `src/shell/capture/render_in_place.{h,cpp}` (the action body) and its
|
||
`CMakeLists.txt` entry. It lives in `shell/capture/` rather than `shell/actions/` because
|
||
it composes `renderOffline` and `ResolveScopeSource` — that directory's `CLAUDE.md` places
|
||
action *bodies* here and reserves `shell/actions` for skins over mutation logic owned
|
||
elsewhere.
|
||
- **Extends (existing modules, existing test targets):** `core/capture/track_topology`
|
||
(`siblingPlacement`), `core/capture/capture_name` (`captureTrackName`),
|
||
`core/capture/capture_paths` (`RenderPaths` + `deriveRenderPaths`, with `deriveBankPaths`
|
||
re-expressed over it so the file-stem spelling keeps one owner —
|
||
`bankRelativeForName` already depends on that).
|
||
- **Edits, bounded:** `src/shell/capture/capture.{h,cpp}` — the `CaptureDestination` enum on
|
||
`CaptureRequest`, the ~6-line destination branch at path derivation, and
|
||
`CaptureResult::absolutePath`. `src/shell/panel/panel_input.cpp` — two lines inside
|
||
`detectNewContent` only, dropping added GUIDs that already carry a membership record
|
||
(`MembershipIndex::query(guid) != nullptr`) before the `autoTagNewContent` call. That
|
||
edit exists **only because of the Ρ-F2 ruling**. `src/app/main.cpp` — one
|
||
`ActionTableRow`.
|
||
- **Does not own:** anything under `core/instrument/`, `shell/instrument/`, `core/package/`,
|
||
`shell/package/`, `core/model/`, `core/tracking/`, `core/reclaim/`, or `shell/persist/`.
|
||
No new directory. No new persisted state, no new ext-state key, no new wire version rung.
|
||
|
||
**Behavior.**
|
||
- **Resolve** via `ResolveScopeSource(CaptureScope::Track, …)` — razor-else-time range,
|
||
selected tracks, canonical GUIDs, source track name. No range → refuse with the reason
|
||
`resolveRange` produced. **Item extent is not a fallback and must not become one.**
|
||
- **Refuse multi-track by inheritance.** `renderOffline` fires `isMultiTrackStemRender`
|
||
before touching anything, keyed on the render *source* (`SelectedTracks`), so Ρ adds no
|
||
check of its own and gets `multiTrackRefusalMessage(CaptureScope::Track)` for free.
|
||
- **Render** through `renderOffline(CaptureScope::Track, src.sourceTracks, req)` with
|
||
`req.destination = ProjectMedia`, tail from `bankPanelTailSetting()`, `channelCount = 2`,
|
||
`Float32`, `sampleRate = 0`, and the name from `captureNameFor(src.trackNames, 0,
|
||
"capture")`. Inherits the FX-bypass guard, the render-selection guard, the `RENDER_*`
|
||
snapshot/restore, `RENDER_ADDTOPROJ = 0`, the exact-bounds gate, the unsaved-project
|
||
Save-As gate, and the lossless mono collapse — **all unchanged**.
|
||
- **Destination resolves in the backend, after its own save gate**, so an unsaved project is
|
||
still prompted before any path arithmetic runs. `ProjectMedia` → `GetProjectPathEx(proj,
|
||
…)` (SDK header 2550; header 3102 names it as the way to get the *effective* recording
|
||
path when `RECORD_PATH` is blank or relative). The relative-paths-only invariant is
|
||
untouched — it binds the `BankIndex`, and Ρ writes to no index.
|
||
- **Sibling placement** (pure, in `track_topology`): prefix-sum `I_FOLDERDEPTH` to absolute
|
||
levels; `L = level[srcIdx]`; if `depth[srcIdx] >= 1` the insert position `p` is the first
|
||
`j > srcIdx` with `level[j] == L` (else `count`), otherwise `p = srcIdx + 1`; then exactly
|
||
two writes — `depth[p-1] = L - level[p-1]` and the new track's `depth = level[p] - L`
|
||
(with `level[count] = 0`). Total delta sum preserved, so nothing downstream shifts. The
|
||
five cases and their expected outcomes are tabulated in the product doc; a malformed
|
||
project whose deltas do not sum to zero clamps rather than asserts.
|
||
- **Create + dress:** `InsertTrackInProject(proj, p, 0)`, `GetTrack(proj, p)`;
|
||
`SetMediaTrackInfo_Value(new, "I_CUSTOMCOLOR", (double)GetTrackColor(source))` — one line
|
||
clones a colour and the absence of one, since `GetTrackColor` returns the value already
|
||
OR'd with `0x1000000` and `0` means unset; `GetSetMediaTrackInfo_String(new, "P_NAME",
|
||
buf, true)` with `captureTrackName(trackName(source))`.
|
||
- **Name rule (pure, tested):** `"Capture " + sourceName`, **idempotent** — if the source
|
||
name already begins with the prefix, the new name is the source name verbatim, so a second
|
||
run yields `Capture MONEY`, never `Capture Capture MONEY`. An unnamed source yields
|
||
`Capture Track N` (`trackName` uses `GetTrackName`, which already answers REAPER's own
|
||
convention — the Ψ-W2-T1 precedent). A counter suffix is rejected: REAPER does not
|
||
uniquify track names either, and it is a treadmill.
|
||
- **Place:** snapshot the edit cursor → `SetOnlyTrackSelected(new)` →
|
||
`SetEditCurPos(src.startSeconds, false, false)` → `InsertMedia(absolutePath,
|
||
computeInsertMode(InsertOptions{}))` → restore the cursor. **Leave the new track selected,
|
||
alone** — a deliberate divergence from the restore-the-selection convention every other
|
||
placing path follows, because in the headline case the source is being parked out of sight
|
||
in the same gesture and restoring the selection would leave the user selecting an
|
||
invisible track.
|
||
- **Modes (Ρ-F2, RULED — absolute, not mode-following):**
|
||
`membership().tag(sourceGuid, kDesignModeId)` (covers stays *and* goes — `tag` replaces
|
||
prior single-mode membership); `membership().tag(newTrackGuid, kArrangeModeId)` —
|
||
**`kArrangeModeId` unconditionally, never `view.activeModeId()`**; `membership().tag(…,
|
||
kArrangeModeId)` for **each item on the new track** (enumerate after `InsertMedia`; the
|
||
track is brand new so those are exactly the items just placed — `item_read::itemGuid` is
|
||
the existing GUID seam); then `mintManagedLanes(view, nullptr)`; then `applyMode(view,
|
||
view.activeModeId(), nullptr)` — a reapply, never a switch, so it is not transport-gated
|
||
and does not touch solo.
|
||
- **Why explicit tags and not `untag()`:** an untagged GUID is an Arrange member by
|
||
behaviour but carries no membership record, and the record is what the auto-tag
|
||
detector's new filter keys on. Tag, don't untag. **This is the first explicit
|
||
`kArrangeModeId` record in the tree** — the shipped *tag selected tracks → Arrange*
|
||
action dispatches to `doUntag()`, i.e. Arrange-by-absence. The record is well-formed
|
||
and behaviourally identical (`isMember` answers the same for both states, `untag()`
|
||
still clears it); it costs one persisted entry. Unit-test the JSON round-trip;
|
||
`[verify — DAW]` that such a project shows no view-behaviour difference.
|
||
- **Fired from Design, the result track is parked and nothing is visible.** That is the
|
||
ruled behaviour, not a bug: the result track is an Arrange member, so a Design reapply
|
||
parks it, and it appears in the source's place on the next switch to Arrange. The
|
||
tag-before-reapply ordering that mode-following made load-bearing is now state hygiene
|
||
only.
|
||
- **Selection stays absolute too.** The result track is left selected, alone, in both
|
||
cases — even fired from Design, where that selects a parked track. Making selection
|
||
conditional on the active mode would reintroduce exactly the mode-relative behaviour
|
||
Ρ-F2 removed.
|
||
- **Inherited and deliberately not fought:** show-both on the source is not cleared (it is
|
||
the user's pin); a folder-parent source is not hidden by tagging, because parents are
|
||
derived (`core/view/CLAUDE.md`) — the existing tag action behaves identically. **Do not
|
||
invent a cascade that tags the children.**
|
||
- **Feedback:** silent on success (the new track is the feedback), `ShowConsoleMsg` on every
|
||
refusal — matching `RunInsertSelected`. **[propose at review]** whether the Design-fired
|
||
path breaks that silence with a one-line `ShowConsoleMsg` naming the track it created:
|
||
under Ρ-F2 that path produces no visible change, so a silent success is
|
||
indistinguishable from a no-op. Recommendation: yes, one line and one string.
|
||
- **Action:** suffix **`RENDER_TRACK_IN_PLACE`** — FOREVER-STABLE per channel, minted as a
|
||
new `RENDER_*` verb family rather than a `CAPTURE_*` member, deliberately: the id is
|
||
permanent and is the most durable statement the codebase makes about which pillar a
|
||
feature belongs to. Phrase: **`"render selected track to a new track (source moves to
|
||
Design)"`**. Main section only; one `ActionTableRow`; no `custom_action`/`hookcommand2`.
|
||
|
||
**Invariant amendments — deliverables of this track, not follow-ups** (the Phase Ψ
|
||
precedent, where three such amendments were acceptance criteria of the tracks that broke
|
||
them). A track that lands Ρ without these reads as an invariant breach in review.
|
||
|
||
1. `src/shell/capture/CLAUDE.md` §Invariants — *"`RunInsertSelected` is the one deliberate
|
||
exception to capture-never-places."* Amend to state that this directory now hosts two
|
||
placing paths and give the discriminator: `RunInsertSelected` places a *bank sample*;
|
||
`render_in_place` places a render that never entered the bank. Neither is a capture
|
||
placing itself.
|
||
2. `src/shell/actions/CLAUDE.md` §Invariants — *"`arrange_drop_win` is the only
|
||
timeline-placing shell in this directory."* Scope the sentence explicitly to that
|
||
directory and cross-reference the third verb.
|
||
3. Root `CLAUDE.md` §"The load-bearing principle" — **one sentence, not a rewrite**: a
|
||
render that never enters the bank and never leaves it is a third verb outside the rule,
|
||
with the two-way boundary named. The prohibition must not be softened; the exception must
|
||
be named precisely.
|
||
4. `src/core/view/CLAUDE.md` §Invariants — *"New tracks are tagged to the active mode at
|
||
creation."* **Added by the Ρ-F2 ruling.** That rule now applies only to a GUID carrying
|
||
no membership record: an explicit tag wins over the detector. Amend the sentence and
|
||
state the reason in one clause — the detector classifies content the *user* made, not
|
||
content the tool made and already classified. `src/shell/panel/CLAUDE.md` describes
|
||
`panel_input` only as "the new-content auto-tag timer" and does not restate the rule, so
|
||
it needs no amendment.
|
||
|
||
**Acceptance criteria.**
|
||
- `siblingPlacement` is unit-tested over all five cases in the product doc's table (normal
|
||
mid-folder, last-in-folder `-1`, last-in-two-folders `-2`, folder parent, last track in
|
||
the project) plus a malformed non-zero-sum delta list, with no DAW.
|
||
- `captureTrackName` is unit-tested for plain, already-prefixed (idempotence), empty, and
|
||
`Track N` sources.
|
||
- `deriveRenderPaths` round-trips the same stem spelling `deriveBankPaths` produces for the
|
||
same inputs, and the existing `capture_paths` tests pass unchanged.
|
||
- Every existing capture test passes with **no expectation change** — the proof that the
|
||
destination seam is inert on the `Bank` path.
|
||
- `render_in_place.cpp` contains no reference to `session.bank()`, `session.book()`,
|
||
`recordCreated`, or `bumpBankGeneration` — checkable by grep, and the review gate for
|
||
boundary condition 1.
|
||
- The action registers, dispatches, and mirror-unregisters through the single
|
||
`ActionTableRow` — no separate registration mechanism.
|
||
- `render_in_place.cpp` contains no reference to `activeModeId` in the tagging path —
|
||
checkable by grep, and the review gate for the Ρ-F2 ruling. The result track and its
|
||
item are tagged `kArrangeModeId`; `activeModeId()` appears only in the `applyMode`
|
||
reapply.
|
||
- `detectNewContent` no longer auto-tags a GUID that already carries a membership record,
|
||
and the existing `view_mode_model` / `guid_diff` tests pass unchanged (the filter is
|
||
shell-side; `autoTagNewContent`'s pure contract does not move).
|
||
- A membership index carrying an explicit `kArrangeModeId` record JSON-round-trips
|
||
unchanged, and `isMember` answers identically for that record and for an absent one —
|
||
one added `view_mode_model` test, no DAW.
|
||
- All four invariant amendments are in the diff.
|
||
|
||
**DAW-verification obligation** (stated up front; nothing past the pure functions is
|
||
unit-testable):
|
||
- **The null test on Ρ's own output** — render a track over a range, polarity-invert the
|
||
source against the new track, confirm silence. The phase's trust anchor.
|
||
- **The three folder cases**, each confirming the new track's nesting level and that the
|
||
render feeds (or correctly bypasses) the folder bus. `[verify — DAW]` whether
|
||
`InsertTrackInProject` plus the two `I_FOLDERDEPTH` writes settle without an intermediate
|
||
`TrackList_AdjustWindows(false)` (SDK header 7735; header 2721 notes some attribute writes
|
||
need a manual panel update, and the `isMinor` semantics are undocumented).
|
||
- **The collapsed-mono placement** — render a dead-centre source, confirm a mono item, and
|
||
confirm it sums at the same level the stereo source did. This is root `CLAUDE.md`'s
|
||
existing `[verify — DAW]` on mono-item-on-stereo-track summing, **promoted to
|
||
load-bearing by this phase**: Ρ is the first path where a collapsed render is placed into
|
||
the mix by the tool itself.
|
||
- **Both mode transitions under the Ρ-F2 ruling** — fired from Arrange (source parks;
|
||
result track visible and in the mix) and fired from Design (source stays on the bench;
|
||
result track parked, then present in the source's place after a switch to Arrange).
|
||
**In each case wait out at least one panel timer tick and re-check the membership** —
|
||
that is the auto-tag-detector regression, and it is what catches a missing
|
||
explicit-tag-wins filter or an untagged item, either of which silently reverses the
|
||
ruling. The old ordering check (that the new track is never momentarily parked) no
|
||
longer applies.
|
||
- **Undo** — one Ctrl-Z removes track and item and reverts the folder-depth write; the file
|
||
survives (REAPER's undo deletes no files, and prune cannot reach this one); the source
|
||
stays tagged Design, whose way out is the existing *tag selected tracks → Arrange* action.
|
||
All three residuals are inherited from the documented model-vs-undo split
|
||
(`src/shell/view/CLAUDE.md` §Gotchas), not introduced here.
|
||
- **Name and colour clone**, including a second run over an already-prefixed track (must not
|
||
stack) and an unnamed source (must read `Capture Track N`).
|
||
- **`GetProjectPathEx` against a project with a non-default recording path**, confirming the
|
||
render lands where the project's media lives.
|
||
|
||
**Open questions.** **No [Daniel] questions — Ρ-F1, Ρ-F2 and Ρ-F3 are all RULED**
|
||
(2026-08-02), so nothing here is gated. Two **[propose at review]** items, both
|
||
recommendation-carrying. First, whether the Design-fired path emits a one-line
|
||
`ShowConsoleMsg` (see the Feedback bullet) — recommendation: yes. Second, **[propose at
|
||
review]** whether `render_in_place` should refuse when the source track is the master —
|
||
`ResolveScopeSource` collects via `CountSelectedTracks`/`GetSelectedTrack`, which skip the
|
||
master (SDK header), so a master-only selection already resolves to zero tracks and refuses
|
||
with "nothing selected"; the recommendation is to leave that inherited behaviour alone
|
||
rather than add a message for a case the existing path already handles correctly.
|
||
|
||
---
|
||
|
||
## Phase Λ — ReaSampler on Linux: both artifacts, shipped
|
||
|
||
**Ships:** `reaper_reasampler.so` and `reasampler_9000.vst3` built, installed and documented
|
||
on Linux. The extension compiles under GCC/Clang, builds optimized by default, docks its
|
||
panel, writes a bank index that survives a comma-decimal locale, and prunes without lying
|
||
about what it reclaimed. The instrument loads, scans, instantiates and processes audio in
|
||
**any** Linux host, and opens its editor under **REAPER only** — a stated contract, not a
|
||
shortfall. macOS is out.
|
||
|
||
**Four named exceptions to platform parity**, stated up front rather than discovered:
|
||
prune deletes permanently (no trash); the OS drag-out's copy-only guarantee is conventional
|
||
rather than structural, because SWELL's file-list drag takes no effect mask; the type faces
|
||
are DejaVu rather than Segoe UI and Consolas; and the ReaSampler 9000 editor comes up under
|
||
REAPER only, with every other host getting its generic parameter UI.
|
||
|
||
**Consolidates: none of the seventeen.** Phase Λ came from a direct request (Daniel,
|
||
2026-08-02) and is scoped in `docs/product/linux-readiness.md`, which is itself downstream of
|
||
two landed audits: `docs/product/audit-notes/lambda-w1-t1-build-toolchain.md` (findings
|
||
Λ-01…Λ-10, verify items V1…V10, forks D1…D7 — build system, toolchain, vendored deps,
|
||
resources, test harness, packaging) and
|
||
`docs/product/audit-notes/lambda-w1-t2-source-runtime.md` (L2-01…L2-12 — source portability
|
||
and runtime behaviour). **Grep those for a cited finding number; do not read either whole.**
|
||
This phase **supersedes nothing**, and corrects one standing promise rather than inheriting
|
||
it: `docs/product/versioning-and-release.md` already commits in writing to "three platform
|
||
artifacts per channel per release" (T1 §1e), which Λ-D4 makes **two** for now, with macOS
|
||
named as deferred rather than silently dropped.
|
||
|
||
**Nothing in this phase has been verified on a Linux machine.** Every `[verify — Linux]`
|
||
mark below is load-bearing and none may be laundered into settled voice. The structural
|
||
consequence is stated once here because it shapes the whole wave order: **Λ-W2's edits are
|
||
authored blind from the audits' citations, and Λ-W3 is the session that discharges Λ-W2's
|
||
acceptance criteria.** Λ-W2 is not "done" in the usual sense until Λ-W3 runs, and a track
|
||
that reports otherwise has laundered unverified work into landed work — which is exactly
|
||
what the audits' `[verify]` discipline exists to prevent. Both audits state their own effort
|
||
bands as provisional until the sweep runs; this plan does not restate a band the audits did
|
||
not give.
|
||
|
||
### Rulings — Daniel's, 2026-08-02. Six, settled.
|
||
|
||
Full statements with the reasoning Daniel gave: `docs/product/linux-readiness.md`
|
||
§"Settled decisions (Daniel, 2026-08-02)". **Do not re-litigate these.**
|
||
|
||
| Ruling | What it settled | Specified in | Bound into |
|
||
|---|---|---|---|
|
||
| **Λ-D1** | **The VST3 instrument is in scope** (audit fork D1 = **Fork B**). Both artifacts ship, not just the extension. **Reverses D5's platform clause in writing** — "Windows-only, VST3-only, REAPER-only" becomes "Windows and Linux; the editor is REAPER-hosted"; the VST3-only and REAPER-only clauses survive verbatim | §Λ-D1 | Λ-W6…Λ-W8; the D5 rewrite is Λ-W6-T1's first act |
|
||
| **Λ-D2** | **SWELL is reached by `dlopen`ing REAPER's own `libSwell.so`** (route **B3a**). Route B3b — building SWELL into the module, and with it GDK/GTK3, FreeType, Fontconfig, OpenGL — is **rejected**, not deferred | §Λ-D2 | Λ-W7-T1 |
|
||
| **Λ-D3** | **Non-REAPER hosts must be protected** — *"REAPER-only is good for now, but we need to protext other hosts."* A first-class acceptance criterion of two tracks, not a footnote on one | §"The non-REAPER-host safety criterion" | Λ-W6-T2 (contract), Λ-W7-T1 (runtime), Λ-W8-T1 (regression) |
|
||
| **Λ-D4** | **macOS is out** (D3 = **out**) — *"I don't have a mac to compile on."* Shared macOS/Linux edits are still made **in their shared form and noted as shared**; what is out is macOS as a deliverable, any macOS verification, signing/notarization, and L2-11's APFS half. **Do not "fix macOS while you're in there"** — an unverifiable edit to the APPLE branch is worse than none, because it looks tested | §Λ-D4 | phase-wide |
|
||
| **Λ-D5** | **The Linux artifact is shipped, not developer-only** (D6 = **shipped**), which promotes Λ-02 (a real optimized build) and Λ-08 (a documented install path) to must-fix. **And a hard `unlink` prune is acceptable**, with a **platform-aware confirmation string** saying so | §Λ-D5 | Λ-W2-T2, Λ-W4-T1, Λ-W5-T1 |
|
||
| **Λ-D6** | **The X11 editor is real work and is sequenced last.** Not a separate ruling — the direct consequence of D1+D2+D3. Everything before it ships something; halting Λ-W8 still leaves a shipped extension and a loading, processing, generic-UI instrument | §Λ-D6 | the wave order itself |
|
||
|
||
### Open forks — four, NONE ruled
|
||
|
||
Stated with the evidence for both sides at `docs/product/linux-readiness.md` §"Open forks —
|
||
Daniel's". This is the one phase in this plan with unanswered [Daniel]-class questions; see
|
||
"Decision state" above for how that reconciles against the plan-wide claim.
|
||
|
||
| Fork | Question | Recommendation | What it blocks |
|
||
|---|---|---|---|
|
||
| **Λ-F1** | Does CI get built in this phase, and on what runner? (audit D4) | **No recommendation — genuinely a resourcing call.** For: two toolchains and one is not on the developer's machine, so every Windows-only commit becomes a latent Linux regression. Against: CI is infrastructure, its value is highest *after* the first Linux build works, and a runner forces Λ-F4 immediately | **Nothing.** Decides only whether Λ-W5-T1 writes a pipeline paragraph or a "deferred to dev-ops" one |
|
||
| **Λ-F2** | The dialog-resource route: **resource-id-0** (SWELL's documented escape hatch, `swell-functions.h:606–608`; deletes the whole resgen pipeline from non-Windows builds) or **wire up resgen properly** (PHP, `add_custom_command`, an include-shim TU — the M end of Λ-01's band)? (audit D7) | **No clean recommendation; the asymmetry neither audit stated is the reason it is worth ruling.** Route A is cheaper and structurally simpler and stakes the panel's file-drop ingest on an *unverified* `ChildWindowFromPoint` descent, because with no resource there is no `WS_EX_ACCEPTFILES` bit to set (L2-07). Route B can set that bit explicitly. **A third option, if the cheap route is wanted without the exposure:** take Route A and make the file-drop check a hard gate in Λ-W3's sweep, with Route B as an additive follow-up track rather than a rewrite — it spends a second Linux session in the bad case | **Λ-W2-T3's dispatch — the ONLY fork here that gates a track.** Unruled, that track slips to Λ-W4 and Λ-W3's sweep splits into two Linux sessions |
|
||
| **Λ-F3** | Does "copy-only is structural" survive as a shipped invariant? (T2 [Daniel] 4) | **Keep the invariant, scope the *enforcement* claim to Windows** — "structurally enforced on Windows via `DoDragDrop`'s copy-only mask; advertised, not enforced, on SWELL, which takes no effect mask." The real counter: an invariant one platform cannot enforce is arguably not an invariant, and weakening the global wording would also correctly warn a Windows reader off relying on it in shared code | **Nothing.** Λ-W4-T3 makes the edit either way; only the sentence changes. Answerable at implementation review |
|
||
| **Λ-F4** | What is the declared support floor? (new — the audits raised the inputs Λ-07, V8, V10, not the decision) | **Declare a narrow floor — current-stable-distro glibc, GCC ≥ 9, `x86_64-linux` only — and widen it on request.** Counter: REAPER's Linux reputation is partly built on modest and non-x86 hardware, `aarch64` is no longer exotic, and adding it later means a second bundle directory and a second verification pass on every release | **The ship wave, not a dispatch.** Non-gating for Λ-W2 (a recorded floor can be widened); **gating for Λ-W5-T1** — a shipped artifact has to say what it runs on. Also feeds Λ-W6-T1's bundle directory set and Λ-F1's runner image |
|
||
|
||
### Phase-Λ acceptance criteria
|
||
|
||
These bind every track in this phase, in addition to the plan-wide set above.
|
||
|
||
- **Windows behaviour does not change. Every track carries that criterion explicitly.** This
|
||
is a port; a port that improves Windows by accident has also changed Windows by accident.
|
||
- **`[verify — Linux]` is a status, not a decoration.** A criterion carrying it is discharged
|
||
by a recorded observation in Λ-W3's verification record — never by inspection, never by
|
||
"should work". A track whose Linux criteria are all unrun is not landed; it is authored.
|
||
- **No vendored file is patched.** `git status` under `vendor/` stays clean through the whole
|
||
phase, including the SWELL bootstrap — which is precisely the constraint that shapes it.
|
||
- **No new third-party dependency surface.** Λ-D2 rejected route B3b for this reason: no
|
||
vendored SWELL build, no GDK/GTK3, no FreeType, no Fontconfig, no OpenGL, no `pkg-config`
|
||
in this tree. **A track that finds itself reaching for one of those has drifted and stops.**
|
||
- **Shared macOS/Linux edits are made in shared form and labelled shared** (Λ-D4). Λ-01,
|
||
Λ-03, Λ-04, Λ-07, Λ-09 and L2-09 are all shared by the audits' own marking; an
|
||
`#ifdef _WIN32` / `#else` that is right for both costs nothing and needs no mac. No macOS
|
||
verification is claimed for any of them.
|
||
- **`core/` stays pure and stays platform-neutral.** Every `#include` under `src/core/**` is
|
||
a `core/` sibling, one of 26 standard headers, or the generated `version_generated.h` (T2
|
||
§1.1) — the audits verified this rather than assuming it, and no Λ track may be the one
|
||
that breaks it. The one platform fork in the whole directory
|
||
(`capture_paths.cpp:18–20`, the Windows case-fold) is already correct for Linux with both
|
||
branches asserted by `tests/test_capture_paths.cpp`.
|
||
- **Every pure module gets a `<module>_tests` target** that runs without REAPER or a DAW.
|
||
Λ-W2-T4 is the phase's proof that this is not ceremonial: it is a `core/`-only fix, fully
|
||
testable on the current Windows box, and it lands before any Linux session.
|
||
- **The ~600-line ceiling and the structural heuristics bind unchanged.** Λ-W8-T1 is the
|
||
track most likely to strain them; its own criteria name the seam vocabulary to reuse.
|
||
|
||
**Performance posture.** No named hot path is touched by any track in this phase — not the
|
||
`peaks` envelope compute, not audition, not the realtime-capture tick's single-pointer-test
|
||
idle fast path, not the instrument's `process()`. Two consequences are stated as criteria
|
||
rather than left implicit: **nothing is added to `process()`** (no `dlopen`, no probe, no
|
||
platform branch on the per-voice-per-sample path — the SWELL availability probe is computed
|
||
once, lazily, off the audio thread), and **Λ-W2-T4's locale fix stays on the JSON/persist
|
||
path**, which root `CLAUDE.md` already declares off all hot paths. Λ-02 is the phase's one
|
||
genuine performance item and it runs the other way: today a Linux build carries **no `-O`
|
||
flag at all**, on a tree whose `peaks` path is documented as presuming an optimizing build.
|
||
|
||
### The non-REAPER-host safety contract
|
||
|
||
Λ-D3 stated as something a person can check. **This is an observable contract, and it is the
|
||
specification the two instrument waves are graded against** — the full argument is
|
||
`docs/product/linux-readiness.md` §"The non-REAPER-host safety criterion", which a brief for
|
||
Λ-W6-T2, Λ-W7-T1 or Λ-W8-T1 must be written against.
|
||
|
||
**The finding that makes it urgent, and it is a fact rather than a risk.** The vendored
|
||
`vendor/WDL/WDL/swell/swell-modstub-generic.cpp` declares a **file-scope static** at `:125`,
|
||
so its constructor runs when our `.so` is `dlopen`ed — i.e. **during the host's plugin
|
||
scan** — and `:102` calls **`exit(2)`** when `dlopen` of `libSwell.so` fails, `:117`
|
||
**`exit(1)`** on an incomplete API table. Compiling the vendored `SWELL_LOAD_SWELL_DYLIB`
|
||
path unmodified therefore means **a Bitwig or Ardour plugin scan on a machine without
|
||
`libSwell.so` terminates the host process** — a killed DAW mid-scan, with the user's
|
||
session. The mitigating detail that shapes the fix: `doinit` substitutes a zero-returning
|
||
`dummyFunc` for each unresolved name, so a *partial* table degrades rather than crashes. A
|
||
partial load is survivable; `exit()` is not.
|
||
|
||
**In any Linux host, with or without `libSwell.so`:** the module scans and enumerates its one
|
||
class with no crash, hang, process exit or blacklist entry; it instantiates, produces audio,
|
||
plays MIDI, and round-trips component state byte-identically with the Windows build; when no
|
||
usable platform surface exists `isPlatformTypeSupported` returns `kResultFalse` for **every**
|
||
type — including `kPlatformTypeX11EmbedWindowID` — and `createView(kEditor)` returns
|
||
**`nullptr`**, never a view that then fails to attach and never one that draws nothing; the
|
||
host falls back to its generic parameter UI; exactly **one** diagnostic line per process
|
||
names why the editor is unavailable, through the SDK's logging or stderr, never a modal;
|
||
teardown crashes nothing and leaves no partially-initialised SWELL table reachable.
|
||
|
||
**Verified with a harness that is already on disk and that neither audit named**, because
|
||
neither swept the SDK's `samples/` tree:
|
||
`vendor/vst3sdk/public.sdk/samples/vst-hosting/validator/` is a scriptable, headless,
|
||
REAPER-free host, under `public.sdk/` and therefore pulled by the documented narrow submodule
|
||
init. The four checks, all `[verify — Linux]`: `validator` completes with **no `libSwell.so`
|
||
on the filesystem** (the direct negation of the `exit(2)` finding, and the load-bearing one);
|
||
a real Ardour or Bitwig scan reaching browser, instantiation and generic UI; a REAPER-on-Linux
|
||
load where the editor opens; and a **negative control** — rename `libSwell.so` beside a Linux
|
||
REAPER and confirm the generic-UI fallback plus the single diagnostic line.
|
||
|
||
### Cross-phase boundary — Γ-W4-T1, and the frozen parameter table
|
||
|
||
**Λ must never register a VST3 parameter.** Γ-W4-T1's `ParamID` table is FOREVER-FROZEN from
|
||
the moment it ships, on the same footing as the command-id strings and the class UIDs. A
|
||
parameter minted in Λ to make a Linux fallback look better would collide with a table Λ does
|
||
not own. **This is a hard boundary and it is the only Γ↔Λ interaction that could actually go
|
||
wrong.**
|
||
|
||
Neither phase blocks the other. The host's fallback for a plug-in reporting no usable editor
|
||
is its generic parameter UI, and today the plugin registers zero parameters — so "no editor"
|
||
currently degrades to *nothing* rather than to *controls*. **Λ-W6-T2's acceptance criteria are
|
||
written to pass with zero parameters registered**: an empty generic UI is a pass. Once
|
||
Γ-W4-T1 lands its 44 derived parameters the identical Λ code path degrades to a usable
|
||
generic UI instead — a better result, not a different criterion. **Λ assumes no schedule for
|
||
Γ**; a Λ track needing a Γ fact reads `dev` at dispatch time.
|
||
|
||
**Concurrency.** Λ is the widest phase in this plan by file surface, and it is disjoint from
|
||
the others by *kind* rather than by directory: its edits are platform guards, CMake, docs and
|
||
one new `shell/instrument/` TU. Γ owns `core/instrument/` + `shell/instrument/` **sources**
|
||
(Λ-W6…Λ-W8 own that directory's CMake, its `CLAUDE.md` files, and `editor_platform`'s
|
||
non-Windows branch); Ε lands in the new `core/package/` + `shell/package/`; Ρ touches
|
||
`core/capture`, one new `shell/capture` TU, `panel_input.cpp` and `main.cpp`. **The genuine
|
||
adjacency to watch is CMake**: Ε appends two `add_subdirectory` lines to the root
|
||
`CMakeLists.txt` while Λ-W2-T2 owns that file's language and target settings — append-only
|
||
lines against property blocks, textual adjacency rather than semantic contention. Λ's own
|
||
shared files are named in the wave sections below.
|
||
|
||
---
|
||
|
||
### Λ-W1 — The audits *(complete)*
|
||
|
||
Two tracks, both landed: **T1 `build-toolchain-audit`** (Λ-01…Λ-10, V1…V10, D1…D7) and
|
||
**T2 `source-runtime-audit`** (L2-01…L2-12). Recorded so the wave numbering matches the audit
|
||
filenames and branch names. Nothing to re-spec; nothing to dispatch.
|
||
|
||
---
|
||
|
||
### Λ-W2 — Make it buildable, and make it honest
|
||
|
||
**Depends on:** nothing. **Four tracks, disjoint at the file level.** Three are authored
|
||
blind against the audits and verified in Λ-W3; **T4 is the one track fully verifiable on the
|
||
current Windows box.**
|
||
|
||
| Track | Owns |
|
||
|---|---|
|
||
| **T1** `linux-compile-blockers` | two source blockers + `main.cpp`'s API-load failure branch |
|
||
| **T2** `toolchain-floor` | the CMake files' language, property and platform blocks |
|
||
| **T3** `panel-dialog-resource` | `panel_window.cpp`'s dialog creation, `resource.rc` / `resource.h` — **gated on Λ-F2** |
|
||
| **T4** `locale-independent-numerics` | the number codec in four `core/` TUs |
|
||
|
||
**Shared file in the wave, named rather than discovered at merge:**
|
||
`src/app/CMakeLists.txt` — T2 takes the property and platform blocks, T3 takes **one
|
||
`target_sources` line under the resgen route only**. Disjoint regions; whichever lands second
|
||
rebases. The wave's severity question is dissolved rather than adjudicated: T2 grades L2-03 a
|
||
Blocker on failure-mode quality with no direct evidence and L2-04 a Major with a confirmed
|
||
mechanism, and flags its own inconsistency — both land here, in different tracks, so the
|
||
relative grade never has to be settled.
|
||
|
||
#### Λ-W2-T1 — `linux-compile-blockers`
|
||
|
||
**Goal.** The extension compiles and links under GCC/Clang, and when it refuses to load it
|
||
says why instead of vanishing.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W2-T1; findings L2-01, L2-02, L2-03.
|
||
|
||
**Surface boundary — owns:** `src/shell/panel/draw_kit.cpp` (`loadFont`, `:70–77`),
|
||
`src/shell/actions/instrument_drop_win.cpp` (`writeTempPreset`, `:50–61`), `src/app/main.cpp`
|
||
(**the `REAPERAPI_LoadAPI` failure branch only**). **Does not own:** any `CMakeLists.txt`,
|
||
`panel_window.cpp`, or any `core/` file.
|
||
|
||
**Behavior.**
|
||
- **`FF_DONTCARE` (L2-01).** `draw_kit.cpp:73` passes `DEFAULT_PITCH | FF_DONTCARE` to
|
||
`CreateFont`; the symbol has **zero occurrences anywhere in `vendor/WDL/`**, and the file is
|
||
not platform-guarded, only its include is. `draw_kit` links into **both** modules, so
|
||
nothing builds until this is fixed. Drop the term or define it locally in the non-Windows
|
||
include branch. **Do not add `windows.h`** — L2-01's stated direction. The family bits are
|
||
advisory to Windows' font mapper and meaningless to fontconfig.
|
||
- **`GetCurrentProcessId()` (L2-02).** `instrument_drop_win.cpp:59` calls it with no platform
|
||
branch anywhere in the TU; SWELL exports `GetCurrentThreadId` and not this. The PID exists
|
||
only to keep two concurrent REAPER instances from colliding in the shared temp dir, and the
|
||
atomic counter at `:52` already carries the intra-process half. Replace with a
|
||
platform-neutral uniqueness source behind a guard.
|
||
- **The silent load failure (L2-03).** Either switch `main.cpp` to `REAPERAPI_MINIMAL` plus an
|
||
explicit `WANT` list — the pattern `panel_window.cpp` and `panel_audition.cpp` already use,
|
||
and the honest inventory of what this extension actually needs — or keep the full load and
|
||
print the failure count via `rec->GetFunc("ShowConsoleMsg")` before returning 0.
|
||
|
||
**Acceptance criteria.**
|
||
- `cmake -B build -S . -G Ninja && cmake --build build` produces `build/reaper_reasampler.so`
|
||
with no errors. `[verify — Linux]` = **V1**.
|
||
- `ctest --test-dir build --output-on-failure` passes all 91 test targets, **no `-C` flag
|
||
needed** on a single-config generator. `[verify — Linux]` = **V2**.
|
||
- A deliberately misspelled `WANT` entry (or a forced non-zero `failcnt`) produces a visible
|
||
REAPER console line naming the count, not a silent refusal.
|
||
- Windows build and `ctest` unchanged.
|
||
|
||
**Prerequisites.** None. **Discharges:** L2-01, L2-02, L2-03; enables V1, V2, V3.
|
||
|
||
#### Λ-W2-T2 — `toolchain-floor`
|
||
|
||
**Goal.** The Linux build is optimized when asked, links what it uses, hides what it does not
|
||
export, and reports diagnostics no one has seen yet.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W2-T2; findings Λ-02, Λ-03, Λ-04, Λ-05, Λ-07,
|
||
Λ-09, Λ-10.
|
||
|
||
**Surface boundary — owns:** root `CMakeLists.txt`, `src/app/CMakeLists.txt` (**the target
|
||
property and platform blocks; NOT the source list — that is Λ-W4-T3's**),
|
||
`cmake/reasampler_targets.cmake`, `src/shell/instrument/CMakeLists.txt` (**thread linkage
|
||
only; the `WIN32` gate is Λ-W6-T1's**), `README.md`, and root `CLAUDE.md` §"Build and test" /
|
||
§"Install / reload" / §"One-time submodule setup". **Does not own:** any `.cpp` or `.h`.
|
||
|
||
**Behavior.**
|
||
- **Λ-02** — root `CMakeLists.txt:28–30` is the *complete* list of language settings: no
|
||
`CMAKE_BUILD_TYPE`, no `CMAKE_CXX_FLAGS`, no IPO/LTO, no `target_compile_options` anywhere
|
||
in the tree. Default `CMAKE_BUILD_TYPE` when neither it nor `CMAKE_CONFIGURATION_TYPES` is
|
||
set, and correct the docs' ship instruction: `--config Release` is *accepted and ignored*
|
||
by Ninja and Make, and the README currently sends a Linux user to `build/Release/`, which
|
||
does not exist there — the module lands at `build/reaper_reasampler.so`.
|
||
- **Λ-03** — `CXX_VISIBILITY_PRESET hidden` + `VISIBILITY_INLINES_HIDDEN` on both module
|
||
targets. SWELL's own build already uses `-fvisibility=hidden`, and the symbols that must
|
||
stay exported carry their own `visibility("default")` attributes
|
||
(`reaper_plugin.h`'s `REAPER_PLUGIN_DLL_EXPORT`, `fplatform.h`'s `SMTG_EXPORT_SYMBOL`,
|
||
`swell-modstub-generic.cpp:135`'s `SWELL_dllMain`).
|
||
- **Λ-04** — `find_package(Threads REQUIRED)` + `Threads::Threads`. Correct on all three
|
||
platforms, costs nothing on Windows.
|
||
- **Λ-09** — `-Wall -Wextra` and `CMAKE_CXX_EXTENSIONS OFF`. **No `-Werror` in this change**
|
||
(the audit is explicit): the diagnostic-set size over this tree is not estimable from
|
||
Windows. Separately `-Wl,--no-undefined` on the module targets, restoring the
|
||
fail-at-link-time behaviour MSVC gives and GNU `ld` does not — **or** a recorded reason why
|
||
`SWELL_PROVIDED_BY_APP`'s function-pointer design makes the gap moot.
|
||
- **Λ-05** — pin the `reaper_plugin.h` → `../WDL/swell/swell.h` include coincidence with a
|
||
comment or an `INTERFACE` target carrying both include dirs as one unit. Invisible on
|
||
Windows, load-bearing off it.
|
||
- **Λ-07** — record the compiler floor (**Λ-F4's input**), or add `-lstdc++fs` and document
|
||
why.
|
||
- **Λ-10** — one sentence in the platform-support docs: on Linux `vendor/vst3sdk` is optional
|
||
until Λ-W6, so `git submodule update --init vendor/reaper-sdk vendor/WDL` is the complete
|
||
extension-only prerequisite.
|
||
|
||
**Acceptance criteria.**
|
||
- `compile_commands.json` or a verbose build log shows an explicit `-O` flag on a bare
|
||
`cmake --build build`. `[verify — Linux]`.
|
||
- `nm -D --defined-only reaper_reasampler.so | grep -E 'ReaperPluginEntry|SWELL_dllMain'`
|
||
finds **both** after the visibility preset. `[verify — Linux]` = **V6**.
|
||
- The extension links with `Threads::Threads` **removed** — proving the include-only pthread
|
||
dependency needs no flag — or the symbol forcing it is named. `[verify — Linux]` = **V5**.
|
||
- `capture_paths_tests` links without an explicit `-lstdc++fs` on the declared floor, or the
|
||
flag is added and the floor documented. `[verify — Linux]` = **V8**.
|
||
- **The warning count from the first `-Wall -Wextra` build is recorded, not fixed**, and
|
||
handed to Λ-W4 as an input.
|
||
- Windows build unchanged; `--config Release` still behaves as documented there.
|
||
|
||
**Prerequisites.** None; concurrent with T1, T3, T4. **Discharges:** Λ-02, Λ-03, Λ-04, Λ-05,
|
||
Λ-07, Λ-09, Λ-10; enables V5, V6, V8.
|
||
|
||
#### Λ-W2-T3 — `panel-dialog-resource` *(GATED on Λ-F2 — do not dispatch until it is ruled)*
|
||
|
||
**Goal.** The docked bank panel opens on Linux, and if it ever fails to, it says so.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W2-T3 and §Λ-F2; findings Λ-01, L2-06, L2-07.
|
||
|
||
**The defect.** `panel_window.cpp:135` is `CreateDialogParam(g_hInst,
|
||
MAKEINTRESOURCE(IDD_BANK_PANEL), …)`, which SWELL resolves out of a per-module registry
|
||
populated by a **resgen-generated source file that is not in the Linux target**:
|
||
`src/app/CMakeLists.txt:97` has the `target_sources` line commented out (`:86` for macOS).
|
||
The registry head stays null, `SWELL_CreateDialog` returns null, `:137` returns, and the
|
||
toggle action is a **silent no-op** — no console line, no Actions-list checkmark. Three
|
||
defects stack inside the commented-out instructions themselves: the script named at `:96`
|
||
(`mac_resgen.php`) does not exist, the output filename is wrong, and the output is an
|
||
`#include`-only artifact that cannot be a `target_sources` entry at all.
|
||
|
||
**Surface boundary — owns:** `src/shell/panel/panel_window.cpp` (the `CreateDialogParam` call
|
||
at `:135–137`, the dialog proc's platform contract, the drop-accept opt-in at `:145–150`),
|
||
`src/resource.rc`, `src/resource.h`, and — **under the resgen route only** — one
|
||
`target_sources` line in `src/app/CMakeLists.txt`'s `else()` branch plus a new include-shim
|
||
TU. **Does not own:** any other panel TU, `draw_kit`, or any CMake target property.
|
||
`panel_window.cpp` is **deliberately not split** across tracks: the L2-06 diagnostic and the
|
||
Λ-01 resource route are the same function, and under the id-0 route the same *line*.
|
||
|
||
**Behavior.** Whichever route Λ-F2 picks, plus — **unconditionally, and on both platforms
|
||
rather than behind a guard** — a one-line `ShowConsoleMsg` on the `!g_panel.hwnd` path naming
|
||
the missing dialog resource (L2-06). That single line converts a mystery into a two-minute
|
||
diagnosis and is worth having on Windows too. The fix is shared macOS/Linux either way
|
||
(Λ-D4: made in shared form, verified on Linux only).
|
||
|
||
**Acceptance criteria.**
|
||
- The panel toggle action docks a visible, LICE-drawn bank panel in a Linux REAPER.
|
||
`[verify — Linux]` = **V7**.
|
||
- With the resource deliberately unavailable, the toggle prints one console line rather than
|
||
doing nothing. **Verifiable on Windows by forcing the branch.**
|
||
- Dragging a WAV from the file manager onto the docked panel ingests it — or, if it does not,
|
||
the failure is understood rather than mysterious. `[verify — Linux]`, T2 §5 item 3.
|
||
**This criterion's difficulty depends on the Λ-F2 route** and is the fork's substance.
|
||
- Windows panel behaviour byte-for-byte unchanged.
|
||
|
||
**Prerequisites.** **Λ-F2 must be ruled before dispatch.** If it is not, this track slips to
|
||
Λ-W4 and Λ-W3's sweep splits into a panel-independent half (run early) and a panel-dependent
|
||
half (run after this lands) — a second Linux session, which is the cost of leaving the fork
|
||
open. **Discharges:** Λ-01, L2-06, L2-07; enables V7.
|
||
|
||
#### Λ-W2-T4 — `locale-independent-numerics`
|
||
|
||
**Goal.** A persisted float round-trips identically regardless of process locale, so a bank
|
||
index written on a comma-decimal machine is not written unparseable.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W2-T4; finding L2-04.
|
||
|
||
**Why it is pulled forward, ahead of everything it looks like it should follow:** it is the
|
||
phase's one substantial fix that **needs no Linux box at all** — two writers and three
|
||
readers, all in `core/`, all unit-testable on Windows today — and its failure mode is that
|
||
the bank index is written unparseable and **the project's whole bank is lost on reload.** It
|
||
should land before any Linux user saves a project.
|
||
|
||
**Surface boundary — owns:** `src/core/json/json.cpp` (the `%.17g` writer and the `strtod`
|
||
reader), `src/core/model/provenance.cpp` (its own `%.17g`), `src/core/wire/wire.cpp`
|
||
(`Cursor::fieldDouble`), `src/core/capture/render_settings.cpp` (the `std::stod` over
|
||
REAPER's `P_RAZOREDITS`), and the corresponding `tests/`. **Does not own:** any shell TU, any
|
||
CMake file, `wav_codec` (its byte-order handling is already explicit and correct).
|
||
|
||
**Behavior.** Make the number codec locale-independent at its two writers and three readers —
|
||
`std::to_chars`/`std::from_chars`, or a `std::locale::classic()`-bound stream. **Do not "fix"
|
||
this by calling `setlocale`**; an extension must not mutate the host's locale. Why it is not
|
||
paranoia: on Windows the CRT starts in the `"C"` locale and nothing here calls `setlocale`,
|
||
which is why it has never fired; on Linux SWELL's GDK backend calls
|
||
`gtk_init_check`/`gdk_init_check` and never calls `gtk_disable_setlocale`, and any GTK or Qt
|
||
plugin in the same process can do the same. The readers are honestly fail-closed — they
|
||
require whole-token consumption — so the failure is "the field disappears", not "the field is
|
||
silently wrong".
|
||
|
||
**Acceptance criteria.**
|
||
- New pure tests pass **on Windows today**, under a forced comma-decimal `LC_NUMERIC` set
|
||
inside the test. **This is the one Λ-W2 track that does not wait for Λ-W3.**
|
||
- The bank index, view model, tracking ledger, provenance blob and tail setting all round-trip
|
||
a fractional value under that forced locale.
|
||
- **Byte-for-byte identical output to today's writer under the `"C"` locale** — this is a
|
||
persisted format, and a changed representation is a compatibility event.
|
||
- Nothing on a hot path is touched; the JSON/persist path is declared off all hot paths in
|
||
root `CLAUDE.md` and stays there.
|
||
|
||
**Prerequisites.** None. **Discharges:** L2-04.
|
||
**Note:** T2 §5 item 1 (read `LC_NUMERIC` inside a running REAPER-Linux process) stays in the
|
||
Λ-W3 sweep, but only to record how urgent this *was* — the work is not gated on it.
|
||
|
||
---
|
||
|
||
### Λ-W3 — First light, and the verification sweep
|
||
|
||
**Depends on:** Λ-W2. **One track, deliberately** — this is a person at a Linux box working a
|
||
checklist where each answer reprices the next; splitting it across specialists buys no
|
||
concurrency and loses the thread. Precedent: Ρ-W1 and Γ-W4 are both single-track waves for
|
||
the same reason.
|
||
|
||
#### Λ-W3-T1 — `linux-verification-sweep`
|
||
|
||
**Goal.** Discharge every acceptance criterion Λ-W2 could not check from Windows, answer every
|
||
`[verify — Linux]` item in both audits, and reprice the remaining waves against what is
|
||
actually true.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W3-T1 and §"Why this sequence".
|
||
|
||
**Surface boundary — owns: no source file and no CMake file.** Its deliverable is a
|
||
**verification record** at `docs/product/audit-notes/lambda-w3-verification.md`, one entry per
|
||
item: the exact check run, the observed result, and what it changes. **Any fix the sweep
|
||
motivates is filed to Λ-W4, not made here.** The one exception: a defect that blocks further
|
||
sweeping (the build does not link at all) is fixed in place and recorded as a deviation,
|
||
because the alternative is a wasted session.
|
||
|
||
**Behavior — the sweep set.**
|
||
|
||
| From | Items |
|
||
|---|---|
|
||
| T1 | **V1** compile, **V2** ctest, **V3** does REAPER's Linux build call `SWELL_dllMain` and populate the API table, **V4** where `UserPlugins/` actually is and whether `reaper_*.so` is the right glob, **V5** pthread link flag, **V6** visibility vs. the two exported symbols, **V7** the docked panel, **V8** `-lstdc++fs`, **V9** does `libSwell.so` sit beside REAPER's executable, **V10** which `uname -m` values the VST3 bundle must carry |
|
||
| T2 | process `LC_NUMERIC`; `REAPERAPI_LoadAPI`'s actual return value and the name of any gap; panel file-drop routing; which SWELL GDI/locale build REAPER ships; fontconfig's substitution for "Consolas"; `SWELL_InitiateDragDropOfFileList` acceptance by common targets, and whether its 500 ms no-motion timeout cancels a slow gesture; prune against an in-use file |
|
||
| New | the `-Wall -Wextra` diagnostic set, counted and categorised; whether `-Wl,--no-undefined` links clean or names an undefined set; whether the four-TU LICE slice links without `lice_colorspace.cpp` (T1 §3 leaves this an unreconciled inference) |
|
||
|
||
**Three items are load-bearing beyond their own answer and must be run FIRST:** **V1**
|
||
(nothing else is observable until it passes); **V9** (a negative answer **voids Λ-D2's route
|
||
and needs a Daniel re-ruling** before Λ-W6 is dispatched — the entire `dlopen`-REAPER's-SWELL
|
||
design rests on it, and it is a one-line `ls`); and the `REAPERAPI_LoadAPI` count (a non-zero
|
||
result promotes L2-03 from a diagnostic to a real Blocker and names the gap).
|
||
|
||
**Acceptance criteria.**
|
||
- Every item above has a recorded answer or an explicit "could not determine, because X".
|
||
**A blank is a failure of this track, not a deferral.**
|
||
- Λ-W2's four tracks each have their `[verify — Linux]` criteria marked discharged or failed,
|
||
**by name**.
|
||
- Λ-W4 and Λ-W5's scope is restated against the answers, with any effort band that moved
|
||
called out. **The audits' bands are provisional by their own statement; this is where they
|
||
stop being.**
|
||
- Λ-W6's prerequisites (V9, V10) are answered, or Λ-W6 is explicitly blocked pending a Daniel
|
||
re-ruling on Λ-D2.
|
||
|
||
**Prerequisites.** Λ-W2-T1, T2, T4. **T3 if Λ-F2 was ruled** — otherwise the panel-dependent
|
||
items (V7, file-drop routing, any font check needing a rendered panel) defer to a second
|
||
session and that deferral is recorded. **Discharges:** V1–V10 and T2 §5's seven items, **as
|
||
answers rather than as fixes**.
|
||
|
||
---
|
||
|
||
### Λ-W4 — Correctness and safety, repriced
|
||
|
||
**Depends on:** Λ-W3-T1. **Three tracks, disjoint by directory.** Everything here is known
|
||
work whose *size* the sweep may have moved.
|
||
|
||
#### Λ-W4-T1 — `prune-deletion-safety`
|
||
|
||
**Goal.** Prune on Linux deletes only what it means to, tells the truth about what it
|
||
reclaimed, and tells the user the deletion is permanent.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W4-T1; finding L2-05 (both halves) plus the
|
||
symlink row of T2 §4.
|
||
|
||
**Surface boundary — owns:** `src/shell/persist/prune_fs.cpp` (the deletion authority's
|
||
non-Windows branch and the reclaim scan) and the prune confirmation text wherever it is
|
||
composed (`shell/actions/prune_action` / `shell/panel/panel_bank_ops`). **Does not own:**
|
||
`core/reclaim/`'s orphan computation (pure, portable, unaffected), or any other persist TU.
|
||
|
||
**Behavior.**
|
||
- **The confirmation string becomes platform-aware** (Λ-D5). On Linux it states the deletion
|
||
is permanent and there is no Recycle Bin; on Windows it says what it says today. **Same code
|
||
path, one platform-dependent phrase — not a second dialog.**
|
||
- **The dead "locked" branch is reckoned with.** `if (ec) return false; // real failure
|
||
(locked/permission) -> skip` encodes Windows file-sharing semantics; on Linux `unlink` of an
|
||
open file **succeeds**, the audio keeps playing from the open fd, and the bytes are gone
|
||
when it closes — so the branch never fires. Either it is documented as Windows-only in
|
||
place, or the Linux path acquires an equivalent guard. **What it must not do is stay
|
||
silently asymmetric**: the recovery floor root `CLAUDE.md` §"The resample bake" relies on
|
||
("the superseded file survives on disk until a prune reclaims it") otherwise has nothing
|
||
under it on Linux. **This half is not covered by Λ-D5's trash ruling** — it is not a trash
|
||
question.
|
||
- **The symlink hazard is fixed.** `fs::directory_iterator` + `is_regular_file()` follows
|
||
symlinks under C++17; size is read from the target via `file_size()` but `fs::remove`
|
||
deletes the **link**, not the target — so prune reports N bytes reclaimed and reclaims
|
||
zero. Symlinked media folders are far more idiomatic on Linux than on Windows. **This is a
|
||
reporting lie, not a cosmetic issue.**
|
||
|
||
**Acceptance criteria.**
|
||
- A Linux prune of a bank file currently playing behaves as recorded in Λ-W3's sweep, and the
|
||
behaviour matches what the confirmation promised. `[verify — Linux]`.
|
||
- A symlinked bank file is either skipped or deleted with its target, and the reclaimed-byte
|
||
figure matches what actually left the disk in **both** cases. Unit-testable for the
|
||
computation; `[verify — Linux]` for the filesystem half.
|
||
- The Windows path — `SHFileOperationW` + `FOF_ALLOWUNDO`, Recycle-Bin recoverable — is
|
||
bit-for-bit unchanged.
|
||
- **Prune remains the only file-deletion path in the system** (plan-wide product invariant).
|
||
|
||
**Prerequisites.** Λ-W3-T1 (the in-use-file check). **Discharges:** L2-05 both halves, and the
|
||
symlink row of T2 §4.
|
||
|
||
#### Λ-W4-T2 — `linux-font-faces`
|
||
|
||
**Goal.** The kit asks for faces that exist on a stock Linux distro, so type is **chosen**
|
||
rather than substituted.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W4-T2 and §"Calls made here"; finding L2-09.
|
||
**This is a product-designer call, not a fork:** silent fontconfig substitution is not
|
||
acceptable because the kit's type is part of a deliberate visual identity
|
||
(`docs/product/visual-design-language.md`), and the palette-role discipline exists precisely
|
||
so a visual direction is a single-file change rather than an emergent property of the host.
|
||
The cost is five string literals. Contradict it in review with an argument.
|
||
|
||
**Surface boundary — owns:** `src/shell/panel/draw_kit.cpp`'s five `loadFont` call sites
|
||
(`:154–158`) and the face constants in `draw_kit.h`. **Does not own:** `loadFont` itself
|
||
beyond the literals, the palette, any geometry, or the two WCAG `static_assert`s — which are
|
||
on pixel height and weight, not on the face, and hold regardless.
|
||
|
||
**Behavior.** A platform face list at the five call sites: **DejaVu Sans / DejaVu Sans Mono**
|
||
as the Linux defaults, following SWELL's own no-fontconfig fallback list
|
||
(LiberationSans/DejaVuSans, LiberationMono/DejaVuSansMono) as precedent. **One code path**,
|
||
shared with macOS's eventual San Francisco/Menlo (Λ-D4: made in shared form, not verified).
|
||
The subtlety to carry into the work: `draw_kit.cpp:74`'s `if (!hf) return` guard does **not**
|
||
catch this failure mode — SWELL's `CreateFont` always returns a non-null handle even when the
|
||
face never resolved, recording the failure as a null `typedata` internally. **A wrong or
|
||
missing face is not observable at the call site, only in the rendering.**
|
||
|
||
**Acceptance criteria.**
|
||
- Every kit string renders in the intended face on a stock distro, and the numeric readouts
|
||
are tabular. `[verify — Linux]`.
|
||
- Windows renders Segoe UI and Consolas exactly as today.
|
||
- Row heights, ellipsis points and label truncation are unchanged on Windows; on Linux they
|
||
are **measured** rather than assumed correct.
|
||
|
||
**Prerequisites.** Λ-W3-T1's fontconfig answer (T2 §5 item 5), which decides whether this is
|
||
cosmetic or a readability regression. **Discharges:** L2-09.
|
||
|
||
#### Λ-W4-T3 — `source-partition-and-invariants`
|
||
|
||
**Goal.** The build's source list stops relying on every TU's own `#ifdef` discipline, and the
|
||
invariants Linux weakens are stated where a reviewer will read them.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W4-T3 and §Λ-F3; findings Λ-06, L2-10, L2-11.
|
||
|
||
**Surface boundary — owns:** the `target_sources` list in `src/app/CMakeLists.txt:8–51` (**the
|
||
list; the property blocks are Λ-W2-T2's**), any new platform-sibling TU the sweep showed was
|
||
needed, `src/shell/actions/drag_out_win.h`'s invariant comment, and the corresponding
|
||
`src/shell/**/CLAUDE.md` invariant passages. **Does not own:** any behaviour change in a
|
||
shipped code path.
|
||
|
||
**Behavior.**
|
||
- **Λ-06** — partition the source list where the Λ-W3 diagnostic set says a TU needs it.
|
||
`drag_out_win.cpp` is the model (a real `#ifdef _WIN32` / `#else` split);
|
||
`arrange_drop_win.cpp` and `instrument_drop_win.cpp` are `_win`-suffixed for the **surface**
|
||
they serve, not for a platform dependency, and the audits found them portable by inspection
|
||
— **confirm against the actual compile rather than re-inspecting.**
|
||
- **L2-10 — the copy-only invariant. Λ-F3 rules the wording; this track makes the edit either
|
||
way.** Make `drag_out_win.h:7–11` the doc a Linux reviewer is pointed at, and treat "MOVE is
|
||
structurally impossible" as a Windows-scoped claim: SWELL's file-list drag takes no effect
|
||
mask, so nothing at the API level forbids a target from treating the drag as a move.
|
||
- **L2-11 — no Linux action.** If the predicate is touched at all it becomes
|
||
"case-insensitive filesystem", not "Windows" — but Λ-D4 puts macOS out, so the right move is
|
||
**a comment recording the known macOS defect, not a speculative fix.**
|
||
|
||
**Acceptance criteria.**
|
||
- No TU compiles on Linux only because of an `#ifdef` that happens to be complete; every
|
||
platform-specific TU is either partitioned in CMake or carries a deliberate, commented
|
||
guard.
|
||
- The copy-only invariant's text says **the same thing** in `drag_out_win.h`, the owning
|
||
`CLAUDE.md`, and any spec text that cites it.
|
||
- Zero behaviour change on Windows.
|
||
|
||
**Prerequisites.** Λ-W3-T1. **Λ-F3 for the L2-10 wording only — non-gating:** the track can
|
||
land the partition and leave the sentence for a follow-up. **Discharges:** Λ-06, L2-10,
|
||
L2-11's Linux half.
|
||
|
||
---
|
||
|
||
### Λ-W5 — Ship the extension
|
||
|
||
**Depends on:** Λ-W3-T1 (V4) and **all three Λ-W4 tracks** — shipping means the correctness
|
||
fixes are in. **One track. Gated on Λ-F4** (a shipped artifact has to say what it runs on).
|
||
|
||
#### Λ-W5-T1 — `linux-packaging-and-install`
|
||
|
||
**Goal.** A Linux user can install a correctly-built `reaper_reasampler.so` by following a
|
||
document, and a pipeline can install it by following a rule.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W5-T1; finding Λ-08 plus Λ-02's shipped half.
|
||
|
||
**Surface boundary — owns:** an `install()` rule in `src/app/CMakeLists.txt` — **the first in
|
||
the tree** — `README.md`'s platform-support and install sections, root `CLAUDE.md`
|
||
§"Install / reload", and `docs/product/versioning-and-release.md`'s artifact and pipeline
|
||
paragraphs. **Does not own:** any source file, the toolchain properties (Λ-W2-T2's), or CI
|
||
(Λ-F1).
|
||
|
||
**Behavior.**
|
||
- Document the Linux `UserPlugins/` root **as V4 actually found it**, and the
|
||
single-config-generator output path (`build/reaper_reasampler.so`) that no doc currently
|
||
names.
|
||
- Add the `install()` rule for **both channels**. The channel fork is platform-independent by
|
||
construction — `REASAMPLER_CHANNEL` threads through `configure_file` into names only — so
|
||
`reaper_reasampler_beta.so` needs no separate mechanism, only a separate destination check.
|
||
- **Amend `versioning-and-release.md`**, which already promises three platform artifacts per
|
||
channel per release and mentions no Linux install path, no signing and no CI. Λ-D4 removes
|
||
macOS from the near-term promise: that document should say **two** platform artifacts per
|
||
channel for now, **with macOS named as deferred rather than silently dropped.**
|
||
- **The beta channel on Linux is uncosted in both audits.** Almost certainly free — the fork
|
||
is name-only — but "almost certainly" is not a ship criterion. Build and install both
|
||
channels side by side once.
|
||
- **Λ-F1 decides only which paragraph gets written here** — a pipeline paragraph, or an
|
||
explicit "deferred to dev-ops". Until it is ruled, **Λ ships nothing that presumes a
|
||
runner.**
|
||
|
||
**Acceptance criteria.**
|
||
- A clean-machine walkthrough **following only the README**: clone, narrow submodule init,
|
||
configure, build, install, restart REAPER, actions present, panel docks. `[verify — Linux]`.
|
||
- `reaper_reasampler.so` and `reaper_reasampler_beta.so` coexist in one REAPER with separate
|
||
ext-state namespaces, command ids and dock idents. `[verify — Linux]`.
|
||
- The **installed** binary shows an explicit `-O` flag in its build log — Λ-02's criterion,
|
||
re-checked on the artifact that actually ships.
|
||
- `versioning-and-release.md` no longer promises an artifact this phase does not produce.
|
||
- The declared support floor (Λ-F4) appears in the README and matches what was built.
|
||
|
||
**Prerequisites.** Λ-W3-T1 (V4), Λ-W4 (all three tracks), **Λ-F4 ruled**. **Discharges:**
|
||
Λ-08, and Λ-02's shipped half.
|
||
|
||
---
|
||
|
||
### Λ-W6 — The instrument module, and the host-safety contract
|
||
|
||
**Depends on:** Λ-W2-T2 (thread linkage, visibility, warnings) and Λ-W3-T1 (V10, plus V9 if
|
||
Λ-D2 is to survive). **Λ-W6 may run CONCURRENTLY with Λ-W4 and Λ-W5** — it touches
|
||
`src/shell/instrument/` and that directory's CMake and `CLAUDE.md` files plus
|
||
`src/core/instrument/CLAUDE.md`, none of which Λ-W4 or Λ-W5 opens; the only reason to
|
||
serialise is attention, not contention.
|
||
|
||
**Two tracks — one build + docs, one source — and they are `T1 before T2 — serial`**, the
|
||
same shape this plan already records for Γ-W3. They are file-disjoint but not order-free:
|
||
there must be a module before it can refuse to show a view.
|
||
|
||
**At the end of this wave the Linux instrument loads in any host, processes audio, and has NO
|
||
EDITOR AT ALL** — a defined, verifiable, shippable state, not a half-done one.
|
||
|
||
#### Λ-W6-T1 — `vst-linux-module`
|
||
|
||
**Goal.** `reasampler_9000.vst3` configures, builds and installs on Linux as the directory
|
||
bundle a Linux host expects, and the decision that forbade it is reversed in writing.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W6-T1; audit findings B1, B2, B5.
|
||
|
||
**Surface boundary — owns:** `src/shell/instrument/CMakeLists.txt` (the gate, the entry-point
|
||
source selection, the bundle POST_BUILD and install rule, `SWELL_PROVIDED_BY_APP`, the modstub
|
||
TU) and the three D5 passages in `src/core/instrument/CLAUDE.md`,
|
||
`src/shell/instrument/CLAUDE.md` and `src/shell/panel/CLAUDE.md`. **Does not own:** any `.cpp`
|
||
or `.h` under `shell/instrument/`.
|
||
|
||
**Behavior.**
|
||
- **B5 first, as a documentation act.** Rewrite the three D5 passages: the platform clause
|
||
becomes "Windows and Linux; the editor is REAPER-hosted"; **VST3-only and REAPER-only
|
||
survive verbatim.** `src/core/instrument/CLAUDE.md` carries it twice (Invariants and
|
||
Non-goals), `src/shell/instrument/CLAUDE.md` once (Non-goals), and
|
||
`src/shell/panel/CLAUDE.md` once (the font/GDI clause). **Nothing else in this wave may land
|
||
before this does** — the invariant files are what a future implementer reads.
|
||
- **B1** — swap `public.sdk/source/main/dllmain.cpp` for `linuxmain.cpp` on Linux. `dllmain`
|
||
includes `<windows.h>` with no `SMTG_OS_*` guard; `linuxmain.cpp` exports `ModuleEntry` and
|
||
`ModuleExit`, **both mandatory** — the SDK's own loader refuses the module without either.
|
||
Both files are already vendored; this is a source swap plus a platform `if()`.
|
||
- **Split the `WIN32 AND EXISTS` conjunction** at `src/shell/instrument/CMakeLists.txt:9`.
|
||
Today it is a conjunction, so a Linux configure silently omits `reasampler_vst` **even with
|
||
the submodule slice fully initialised.** The `EXISTS` half stays — a fresh clone with no
|
||
VST3 slice must still configure — and the `WIN32` half becomes a Windows-or-Linux predicate.
|
||
- **B2** — the artifact becomes a **directory bundle**:
|
||
`reasampler_9000.vst3/Contents/<uname -m>-linux/reasampler_9000.so`, per V10's answer and
|
||
Λ-F4's floor. `Contents/Resources/moduleinfo.json` is **optional** — the SDK's
|
||
`getModuleInfoPath` returns empty when absent rather than failing — **so do not author
|
||
one.** Install roots: `$HOME/.vst3/`, `/usr/lib/vst3/`, `/usr/local/lib/vst3/`,
|
||
`$APPFOLDER/vst3/`.
|
||
- **`SWELL_PROVIDED_BY_APP` + the modstub TU** are added to this target in the **default
|
||
(non-`SWELL_LOAD_SWELL_DYLIB`) branch** — the same branch the extension already uses
|
||
(`src/app/CMakeLists.txt:91–92`). The whole modstub file is inside
|
||
`#ifdef SWELL_PROVIDED_BY_APP`, so the VST3 target must define that symbol; today it does
|
||
not. **The `dlopen` itself is Λ-W7's**; what this track lands is the compiled-in, inert
|
||
table plus the exported `SWELL_dllMain`.
|
||
- **The channel fork applies:** `reasampler_9000_beta.vst3` with its own class UID. **The UID
|
||
pair is FOREVER-FROZEN and must not change** — a Linux build is a new platform, not a new
|
||
identity, and a saved project rebinds by UID.
|
||
|
||
**Acceptance criteria.**
|
||
- A Linux configure produces the `reasampler_vst` target; a Windows configure is unchanged.
|
||
- The built bundle's directory shape matches what the SDK's `module_linux.cpp` opens, and the
|
||
SDK's own `validator` loads it. `[verify — Linux]`.
|
||
- Both channels build and install side by side, and **the two class UIDs are byte-identical to
|
||
the Windows build's.**
|
||
- The three `CLAUDE.md` files no longer claim Windows-only, and **still** claim VST3-only and
|
||
REAPER-only.
|
||
- **No VST3 parameter is registered by this track or any other in this phase** — see the
|
||
Γ-W4-T1 boundary above.
|
||
|
||
**Prerequisites.** Λ-W2-T2, Λ-W3-T1 (V10; V9 if Λ-D2 is to survive). **Discharges:** B1, B2,
|
||
B5; and Λ-10's "vst3sdk is optional on Linux" caveat becomes conditional.
|
||
|
||
#### Λ-W6-T2 — `vst-host-safety-contract`
|
||
|
||
**Goal.** The plugin's editor surface refuses **cleanly** on every Linux host, so that adding
|
||
a real editor later is a change of branch taken and not a change of contract.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W6-T2 and §"The non-REAPER-host safety
|
||
criterion"; Λ-D3, L2-08's fallback half.
|
||
|
||
**Surface boundary — owns:** `src/shell/instrument/editor_platform.cpp` (the
|
||
`isPlatformTypeSupported` / `createView` decision and the non-Windows stubs),
|
||
`reasampler_processor.cpp`'s `createView` site, `reasampler_embed.cpp`'s
|
||
`REAPER_FXEMBED_WM_IS_SUPPORTED`, and a new availability-probe seam under
|
||
`shell/instrument/`. **Does not own:** any CMake file, the paint or input families (untouched
|
||
— they stay whole-region-guarded), or any pure `core/instrument/` module.
|
||
|
||
**Behavior.**
|
||
- The editor's availability becomes a **tri-state probe** — untried / available / unavailable
|
||
— computed **once, lazily, off the audio thread**. In this wave the Linux answer is
|
||
unconditionally *unavailable*; **Λ-W7 gives it a real computation without changing a single
|
||
call site.**
|
||
- `isPlatformTypeSupported` returns `kResultFalse` for **every** type when unavailable;
|
||
`createView(kEditor)` returns `nullptr`. **Never a view that fails to attach.**
|
||
- **One** diagnostic line per process, naming the reason. Never a modal.
|
||
- `reasampler_embed`'s `REAPER_FXEMBED_WM_IS_SUPPORTED` continues to return 0 off Windows.
|
||
That is correct and stays correct — the TCP/MCP embed strip on Linux is an explicit
|
||
non-goal of this phase.
|
||
- **The Windows path is not restructured to accommodate this.** The probe is a Linux branch on
|
||
an existing decision, **not a new abstraction over both.**
|
||
|
||
**Acceptance criteria.** The full observable contract above, verified by **all four** of its
|
||
checks: `validator` with no `libSwell.so` present; a real Ardour or Bitwig scan; a
|
||
REAPER-on-Linux load; and the renamed-`libSwell.so` negative control. `[verify — Linux]`.
|
||
Plus:
|
||
- Windows editor behaviour bit-for-bit unchanged — same window class, same `wndProc`, same
|
||
`CS_DBLCLKS` fall-through.
|
||
- **Nothing added to `process()`** — no `dlopen`, no probe, no branch on the
|
||
per-voice-per-sample path.
|
||
- **The criteria pass with ZERO VST3 parameters registered.** An empty generic UI is a pass;
|
||
Γ-W4-T1 improves it and Λ must not.
|
||
|
||
**Prerequisites.** Λ-W6-T1 (there must be a module to load). **Discharges:** Λ-D3's Λ-W6 half;
|
||
L2-08's fallback half.
|
||
|
||
---
|
||
|
||
### Λ-W7 — The SWELL bootstrap
|
||
|
||
**Depends on:** Λ-W6-T1, Λ-W6-T2, and **V9 answered affirmatively.** If V9 is negative, **this
|
||
track does not exist and Λ-D2 needs a re-ruling** — which is why V9 runs first in Λ-W3 rather
|
||
than being discovered here.
|
||
|
||
#### Λ-W7-T1 — `swell-dylib-bootstrap`
|
||
|
||
**Goal.** The plugin acquires a working SWELL function table under REAPER on Linux, without
|
||
the vendored stub's `exit()` behaviour and without vendoring SWELL.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W7-T1 and §"The implementation shape this
|
||
obliges"; Λ-D2 (route B3a), audit finding B3.
|
||
|
||
**Surface boundary — owns:** a new `src/shell/instrument/swell_bootstrap.{h,cpp}`, the
|
||
availability-probe computation Λ-W6-T2 left stubbed, and the `SWELLAppMain`-shaped callback.
|
||
**Does not own:** any vendored file (**nothing under `vendor/` is patched**), the CMake source
|
||
list beyond adding one TU, or the editor.
|
||
|
||
**Behavior — exactly the four-point shape in the product doc, and it is not a re-litigation of
|
||
Λ-D2 but the specification of it.**
|
||
1. **Never define `SWELL_LOAD_SWELL_DYLIB`.** Compile `swell-modstub-generic.cpp` in its
|
||
default branch, which exports `SWELL_dllMain(hInst, callMode, GetFunc)` and calls `doinit`
|
||
on the pointer it is handed.
|
||
2. **Own the load.** Resolve the host executable's directory, `dlopen` `libSwell.so`, `dlsym`
|
||
`SWELLAPI_GetFunc`, `dlsym` and call `SWELL_set_app_main`, then call our own exported
|
||
`SWELL_dllMain(hinst, DLL_PROCESS_ATTACH, getfunc)` — **using only exported surface, with
|
||
every failure returning a recorded state instead of exiting.** This also dissolves the
|
||
`SWELLAppMain`-as-a-link-requirement noted in T1 B3a: we pass an app-main because we choose
|
||
to, not because the linker demands one. `[verify — Linux]` whether a minimal app-main
|
||
suffices for a plugin that only ever creates child windows inside a host-supplied X11
|
||
window.
|
||
3. **Probe the host, not just the library.** A non-REAPER host has no SWELL message loop —
|
||
REAPER's own is what pumps SWELL windows — so even a successful `dlopen` in Ardour would
|
||
produce a window nothing drives. **Gate on the host name via `IHostApplication::getName()`
|
||
AND on the `dlopen` succeeding**; either failing means no editor.
|
||
4. **Probe once, lazily, off the audio thread.** Tri-state, computed on first `createView` and
|
||
never recomputed. No `dlopen` from `process()`, no per-`createView` retry, **no
|
||
static-constructor work.**
|
||
|
||
**The failure taxonomy the probe must distinguish**, because they need different messages:
|
||
host is not REAPER / executable path unresolvable / `libSwell.so` not present at the resolved
|
||
path / `SWELLAPI_GetFunc` missing or version-mismatched (the stub checks
|
||
`SWELLAPI_GetFunc(NULL)==(void*)0x100`) / API table incomplete. **The last is not fatal** —
|
||
`doinit` substitutes a zero-returning `dummyFunc` per miss — and whether to accept a partial
|
||
table or refuse is **[propose at review]**, with *"refuse if any name the editor actually
|
||
calls is missing"* as the starting proposal.
|
||
|
||
**Acceptance criteria.**
|
||
- Under REAPER on Linux the probe reports *available* and the SWELL table resolves with **zero
|
||
misses**. `[verify — Linux]`.
|
||
- Under `validator` with **no `libSwell.so` anywhere**, the probe reports *unavailable*, the
|
||
process **exits normally**, and one diagnostic line is printed. **This is the direct
|
||
negation of the `exit(2)` finding and is the track's headline criterion.**
|
||
`[verify — Linux]`.
|
||
- Under Ardour or Bitwig **with** a `libSwell.so` reachable, the host-name gate **still
|
||
refuses** — proving the gate is on the host, not only on the library. `[verify — Linux]`.
|
||
- No vendored file is modified; `git status` under `vendor/` is clean.
|
||
- Windows build unaffected — the whole TU is behind a platform guard.
|
||
|
||
**Prerequisites.** Λ-W6-T1, Λ-W6-T2, V9 affirmative. **Discharges:** B3 via route B3a; Λ-D3's
|
||
runtime half.
|
||
|
||
---
|
||
|
||
### Λ-W8 — The X11 editor
|
||
|
||
**Depends on:** Λ-W7-T1. **One track, and it is the phase's only L-band source item** (L2-08;
|
||
T1 B4 costs it "build S, source L"). Λ-D6 sequences it last on purpose: **halting this wave
|
||
still leaves a shipped extension and a loading, processing, generic-UI instrument** — a real
|
||
product, not a stub.
|
||
|
||
#### Λ-W8-T1 — `x11-embed-view`
|
||
|
||
**Goal.** The ReaSampler 9000 editor opens, draws and responds under REAPER on Linux.
|
||
|
||
**Spec:** `docs/product/linux-readiness.md` §Λ-W8-T1; audit findings B4, L2-08.
|
||
|
||
**Surface boundary — owns:** a **Linux sibling** to `src/shell/instrument/editor_platform.cpp`
|
||
(window creation and parenting, the run-loop timer, the event-driven input path), and the
|
||
`#ifdef _WIN32` region **boundaries** in `reasampler_editor.h` and the eight
|
||
`editor_input_*` / `editor_paint*` TUs — **boundaries only, not their contents.**
|
||
**Does not own:** `draw_kit`, any pure `core/instrument/` module, any painter's drawing logic,
|
||
`instrument_bake`, or the processor.
|
||
|
||
**Behavior.** The audits call this **a new competence rather than a port.** The build-side cost
|
||
is nil — the Linux IIDs are already in the vendored slice (`commoniids.cpp` defines
|
||
`Linux::IEventHandler`, `Linux::ITimerHandler`, `Linux::IRunLoop` under `#if SMTG_OS_LINUX`,
|
||
and that file is already in the `vst3_sdk` source list). What must be written:
|
||
- `kPlatformTypeX11EmbedWindowID` instead of `kPlatformTypeHWND`; `attachedToParent` receiving
|
||
an X11 window id.
|
||
- A `Linux::IRunLoop`-driven timer replacing `SetTimer`/`WM_TIMER` — **including the editor's
|
||
sync tick, which the bake's arm-then-run discipline rides on.**
|
||
- An **event-driven input path** replacing the `wndProc` switch. `RegisterClassW` /
|
||
`CreateWindowExW` / `DefWindowProcW` have **no SWELL analogue at all** — SWELL has no
|
||
window-class model, only `SWELL_CreateDialog` and raw `HWND__` construction.
|
||
- Substitutions the audit already resolved by name: `MoveWindow` → `SetWindowPos`;
|
||
`GetWindowLongPtr`/`SetWindowLongPtr` → the non-`Ptr` forms returning `LONG_PTR`;
|
||
`GetKeyState` → `GetAsyncKeyState`; `TrackMouseEvent`/`WM_MOUSELEAVE` → **nothing**, so
|
||
hover-leave needs its own derivation (the panel layer already documents the same gap);
|
||
`DragAcceptFiles`/`DragQueryFileW` → the drop path Λ-W2-T3 settled for the panel.
|
||
|
||
**A worked reference implementation is already on disk and neither audit named it.**
|
||
`vendor/vst3sdk/public.sdk/samples/vst-hosting/editorhost/source/platform/linux/` contains
|
||
`window.cpp` (returning `{kPlatformTypeX11EmbedWindowID, …}` and answering
|
||
`Linux::IRunLoop::iid` from `queryInterface`), `runloop.cpp`, and `irunloopimpl.h` (a
|
||
`RunLoopImpl` implementing `registerEventHandler` / `registerTimer` and their unregisters). It
|
||
is the **host** side of the contract rather than the plug-in side, which makes it a precise
|
||
specification of what our plug-in side must satisfy, and the documented narrow submodule init
|
||
already pulls it. **This does not shrink the L band** — it is a reading input, not a library;
|
||
it removes the "we are guessing at the contract" risk from the estimate.
|
||
|
||
**Acceptance criteria.**
|
||
- The editor opens, draws every face identically to the Windows build (compare screenshots at
|
||
the same window size), and every drag, click, wheel and keyboard interaction behaves the
|
||
same. `[verify — Linux]`.
|
||
- Hover-leave is correct on every hover surface, without `TrackMouseEvent`.
|
||
- **The bake's arm-then-run tick fires under `IRunLoop`, and a bake completes end-to-end on
|
||
Linux:** staged file, extension action invoked over the VST3 host bridge, outcome read back,
|
||
adopt and reset. `[verify — Linux]`.
|
||
- **Λ-W6-T2's contract still holds** — a non-REAPER host still gets `nullptr` from
|
||
`createView`. **Re-run all four checks; this is a regression criterion, not a new one.**
|
||
- **Nothing added to `process()`**; no dispatch added to any per-sample path.
|
||
- Every new file lands under the ~600-line ceiling **with a responsibility seam, not a
|
||
bisection.** The editor's existing band-axis split (`_chrome` / `_waveform` / `_deck` /
|
||
`_browse` / `_curve`) is the seam vocabulary to reuse — the Linux platform TU is a **sibling
|
||
to `editor_platform`**, per L2-08's own direction: *"it needs a sibling, not a rewrite."*
|
||
- Windows editor behaviour bit-for-bit unchanged.
|
||
|
||
**Prerequisites.** Λ-W7-T1. **Discharges:** B4, L2-08.
|
||
|
||
---
|
||
|
||
### Shared files across Λ's waves, named rather than discovered at merge
|
||
|
||
All textual adjacency, not semantic contention, unless marked otherwise.
|
||
|
||
| File | Tracks | Nature |
|
||
|---|---|---|
|
||
| `src/app/CMakeLists.txt` | Λ-W2-T2 (property + platform blocks), Λ-W2-T3 (one `target_sources` line, **resgen route only**), Λ-W4-T3 (the source list), Λ-W5-T1 (the `install()` rule) | Four disjoint regions of one file. Λ-W2-T2 and Λ-W2-T3 are the only pair in the same wave; one line each |
|
||
| `src/shell/panel/draw_kit.cpp` | Λ-W2-T1 (`loadFont`'s `CreateFont` args, `:73`), Λ-W4-T2 (the five call sites, `:154–158`) | Different waves |
|
||
| `src/shell/instrument/CMakeLists.txt` | Λ-W2-T2 (thread linkage), Λ-W6-T1 (gate, entry point, bundle, install), Λ-W7-T1 (one added TU) | Different waves |
|
||
| `src/shell/panel/panel_window.cpp` | Λ-W2-T3 alone | **Deliberately not split.** The L2-06 diagnostic and the Λ-01 resource route are the same function — under the id-0 route the same *line*. Splitting them would be semantic contention |
|
||
| `src/shell/instrument/editor_platform.cpp` | Λ-W6-T2 (the refusal branch), Λ-W8-T1 (the real branch) | Different waves; the second replaces the first's computation **without touching its call sites** |
|
||
|
||
---
|
||
|
||
## 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 from `TODO-1.0.md`. The first such track in
|
||
this plan; see Θ-W7-T1 below for the second.
|
||
- **Θ-W7-T1 — `arc-and-spline-aa`** (`pth-w7-t1-arc-and-spline-aa`). Opened after
|
||
Θ-W6-T1 shipped, when Daniel found two rendering defects by eye in the editor — not
|
||
from `TODO-1.0.md`, and not a track this plan originally scoped. The second such track
|
||
in this plan today; if others appear, they belong on this list rather than in the
|
||
table.
|
||
- **All of Phase Γ** (`pg-*`). **Ten tracks across four waves**, from a direct interview with
|
||
Daniel (2026-08-01) and his four later rulings the same day, not from `TODO-1.0.md`. Listed
|
||
here as a block rather than per track, because the whole phase is outside the source doc;
|
||
the product reasoning lives in `docs/product/instrument-control-surface.md` and the
|
||
parameter system's in `docs/product/parameter-automation.md` §§6–10. **Two `docs/TODO.md`
|
||
entries 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 from `TODO-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-amendment` is 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 over `TODO-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 from `TODO-1.0.md`. Listed here as a block, like Γ and Ψ; the
|
||
product reasoning lives in `docs/product/bank-package.md`. It **supersedes nothing** —
|
||
neither `docs/TODO.md` nor `docs/TODO-1.0.md` records export, import, or a package format,
|
||
so there is no deferred entry to absorb or contradict. Its three [Daniel]-class forks
|
||
(Ε-F1/F2/F3) were opened and ruled the same day it was framed, so no track here is gated on
|
||
a decision; see "Decision state" above.
|
||
- **All of Phase Ρ** (`pr-*`). **One track in one wave**, from a direct request (Daniel,
|
||
2026-08-02), not from `TODO-1.0.md`; the product reasoning lives in
|
||
`docs/product/render-in-place.md`. It **supersedes nothing** — a sweep of `docs/TODO.md`
|
||
and `docs/TODO-1.0.md` for `render.in.place|render to new track|preserve.source` returns
|
||
nothing. The smallest phase in this plan, deliberately: it is a thin third verb composed
|
||
out of machinery that already exists, and the burden was on any new machinery to prove it
|
||
unavoidable. Its three [Daniel]-class forks (Ρ-F1/F2/F3) were opened and ruled the same
|
||
day it was framed, so the track here is not gated on a decision; see "Decision state"
|
||
above. **Ρ-F2 was the one that moved the spec** — the result track goes to Arrange
|
||
unconditionally rather than following the active mode, which is also the only reason the
|
||
phase touches `panel_input.cpp` at all.
|
||
- **All of Phase Λ** (`pl-*`). **Thirteen pending tracks across seven waves** (Λ-W2…Λ-W8),
|
||
plus Λ-W1's two audit tracks, which are landed. From a direct request (Daniel, 2026-08-02),
|
||
not from `TODO-1.0.md`; the product reasoning lives in `docs/product/linux-readiness.md`
|
||
and the evidence in the two audit notes it cites. It **supersedes nothing** and **corrects
|
||
one standing promise** rather than inheriting it: `docs/product/versioning-and-release.md`
|
||
commits to three platform artifacts per channel per release, and Λ-D4 (macOS out) makes
|
||
that two for now, with macOS named as deferred. **Λ is the one phase in this plan with
|
||
unanswered [Daniel]-class questions** — four forks, none ruled, of which only Λ-F2 gates a
|
||
dispatch and only Λ-F4 gates a wave; see "Decision state" above. It is also the one phase
|
||
whose acceptance criteria cannot be checked from the current box at all, which is why
|
||
Λ-W3 exists as a wave rather than as a checklist at the end.
|
||
|
||
### 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
|
||
[E-F1 RULED: proprietary RSBK. No ZIP, no zlib]
|
||
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
|
||
[E-F2 RULED: always a NEW bank, auto-suffix, no
|
||
merge. E-F3 RULED: REFUSE on 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.
|
||
All three E-forks were ruled 2026-08-02, the day the phase was framed: NO track here is
|
||
gated on a decision. Import name collision = first free of seed, "seed 2", "seed 3", ...
|
||
seed never re-parsed, fold is BankBook's own.
|
||
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); bank_book.{h,cpp} is W2-T2's alone (one additive public const
|
||
member). W1's three tracks are unconditionally disjoint; W2's two are
|
||
textually adjacent only — serialize T2 behind T1 if zero contention is wanted.
|
||
|
||
Phase Rho — Render in place (none of the seventeen; a direct request 2026-08-02)
|
||
W1 The verb [ONE track, deliberately]
|
||
T1 render-in-place ............. render selected track -> new sibling track,
|
||
item placed at the exact render position,
|
||
colour + "Capture " name cloned, source -> Design,
|
||
result track -> ARRANGE always. Never the bank.
|
||
[R-F1 RULED: refuse multi-track, settled non-goal.
|
||
R-F2 RULED: result track ALWAYS Arrange, never
|
||
mode-following. R-F3 RULED: follow panel tail]
|
||
|
||
The THIRD VERB. Capture = arrange -> bank (writes it). Placement = bank -> arrange
|
||
(reads it). Render in place = arrange -> arrange (bank on neither side). That is why the
|
||
load-bearing capture/placement rule survives it; the full argument is in the phase header
|
||
and in docs/product/render-in-place.md.
|
||
Structural boundary, not a convention: the render destination reaches the backend as a
|
||
TWO-VALUED ENUM (Bank | ProjectMedia), never a caller-supplied path — so no Rho caller can
|
||
name the bank folder. A renderDir string on CaptureRequest IS the drift.
|
||
Genuinely new: ONE pure sibling-placement function (folder-parent and last-in-folder are
|
||
both audibly wrong if inserted at srcIdx+1), an idempotent "Capture " prefix, a
|
||
deriveRenderPaths sibling, and CaptureResult::absolutePath. Everything else composes.
|
||
R-F2 fallout, the ruling's only cost: the result track AND its item are tagged
|
||
kArrangeModeId explicitly, and detectNewContent must drop added GUIDs that already carry
|
||
a membership record — the auto-tag detector otherwise re-tags a Design-fired result to
|
||
Design on its next tick and reverses the ruling. Firing from Design is therefore
|
||
deliberately invisible: the render waits in Arrange. No A/B-on-the-bench behaviour exists.
|
||
Four invariant amendments are track deliverables (the Psi precedent): shell/capture and
|
||
shell/actions CLAUDE.md placing-path claims, ONE sentence in root CLAUDE.md naming
|
||
the third verb without softening the prohibition, and core/view CLAUDE.md's "new tracks
|
||
are tagged to the active mode" made conditional on carrying no membership record.
|
||
Shared files, named: capture.{h,cpp} (destination enum + result field), main.cpp (one
|
||
ActionTableRow), and panel_input.cpp (detectNewContent only; Psi's two named regions in
|
||
that file are landed and are other functions) — the only pre-existing shell files
|
||
touched. Disjoint from Gamma (core+shell/instrument) and Epsilon (core+shell/package).
|
||
|
||
Phase Lambda — ReaSampler on Linux (none of the seventeen; a direct request 2026-08-02)
|
||
W1 The audits [COMPLETE]
|
||
T1 build-toolchain-audit ....... L-01..L-10, V1..V10, D1..D7
|
||
T2 source-runtime-audit ........ L2-01..L2-12
|
||
|
||
W2 Make it buildable, and make it honest [4 tracks, file-disjoint]
|
||
T1 linux-compile-blockers ...... L2-01, L2-02, L2-03 -> V1, V2
|
||
T2 toolchain-floor ............. L-02..L-05, L-07, L-09, L-10 -> V5, V6, V8
|
||
T3 panel-dialog-resource ....... L-01, L2-06, L2-07 -> V7 [GATED: L-F2]
|
||
T4 locale-independent-numerics . L2-04 [the one track verifiable on Windows]
|
||
W3 First light, and the verification sweep [1 track, deliberately]
|
||
T1 linux-verification-sweep .... V1..V10 + T2 section 5's seven; the deliverable
|
||
is a RECORD, not a fix. V1 / V9 / the API-load
|
||
count run FIRST — each reprices what follows.
|
||
W4 Correctness and safety, repriced [3 tracks, disjoint by dir]
|
||
T1 prune-deletion-safety ....... L2-05 both halves + the symlink reclaim lie
|
||
T2 linux-font-faces ............ L2-09
|
||
T3 source-partition-and-invariants . L-06, L2-10, L2-11 (Linux half)
|
||
W5 Ship the extension [1 track; GATED: L-F4]
|
||
T1 linux-packaging-and-install . L-08 + L-02's shipped half + both channels
|
||
W6 The instrument module + the host-safety contract [T1 before T2 — serial]
|
||
T1 vst-linux-module ............ B5 (the D5 reversal, FIRST), B1, B2
|
||
T2 vst-host-safety-contract .... D3's observable contract; passes with ZERO params
|
||
W7 The SWELL bootstrap [1 track]
|
||
T1 swell-dylib-bootstrap ....... B3 via route B3a, WITHOUT the stub's exit(2)
|
||
[prereq: V9 affirmative, else L-D2 re-rules]
|
||
W8 The X11 editor [1 track]
|
||
T1 x11-embed-view .............. B4, L2-08. The phase's one L-band source item.
|
||
|
||
NOTHING here has been verified on a Linux machine. W2's edits are authored BLIND from the
|
||
audits' citations and their acceptance criteria are discharged in W3 — W2 is not "done"
|
||
until W3 runs, and this plan says so rather than pretending otherwise.
|
||
W6 may run concurrently with W4 and W5 — file-disjoint; the only reason to serialise is
|
||
attention. Six D-rulings are SETTLED (instrument in scope; SWELL via dlopen of REAPER's
|
||
libSwell.so; REAPER-only editor but other hosts must degrade SAFELY; macOS out; shipped
|
||
not developer-only; hard unlink acceptable with a platform-aware confirmation).
|
||
OPEN, none ruled: L-F1 (CI — gates nothing), L-F2 (dialog-resource route — the ONLY fork
|
||
gating a dispatch, W2-T3), L-F3 (copy-only invariant wording — gates nothing),
|
||
L-F4 (declared support floor — gates W5, not a dispatch).
|
||
Lambda registers NO VST3 parameter, ever — that table is Gamma-W4-T1's and is frozen.
|
||
```
|