docs: close Γ-F6 and resequence Phase Γ into three waves

Dynamic latency ships as ruled; the restart cost is re-attributed to our own
setActive and filed in TODO. The reflow splits canvas (W1-T4) from arrangement
(W3-T1); preserve-time-stretch moves to W1-T5.
This commit is contained in:
2026-08-01 16:51:04 -04:00
parent eb777f55e1
commit 256216d670
4 changed files with 658 additions and 255 deletions
+401 -156
View File
@@ -41,13 +41,14 @@ full narrative. **Reload tier = Grouping B** (continuous knobs live: filter cuto
morph/drive/mod amount/key-track, every envelope stage time and level; root note, loop 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), 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 hold normalized stage position** (φ = elapsed/duration held fixed across a duration
change, then advancing at 1/newDuration). **Phase Γ opened five [Daniel]-class forks (Γ-F1…Γ-F5) and all five are ruled** (Daniel, change, then advancing at 1/newDuration). **Phase Γ opened six [Daniel]-class forks
2026-08-01) — the rulings are folded into the tracks below and indexed in (Γ-F1…Γ-F6) and all six are ruled** (Daniel, 2026-08-01) — the rulings are folded into the
`docs/product/instrument-control-surface.md` §8.1. **One new fork, Γ-F6, is open**: it was tracks below and indexed in `docs/product/instrument-control-surface.md` §8. **Γ-F6 closed
surfaced by the Γ-F2 ruling out of the vendored VST3 SDK, and Daniel has not seen it. It sits with a correction to the analysis, not merely a ruling**: dynamic reported latency is routine
on Γ-W1-T2, is **not blocking** it is gated on a DAW measurement that track performs first for VST3 instruments and REAPER handles it as a matter of course; what makes the mandated
— and it has a pre-agreed fallback. No other track in this plan carries an unanswered restart expensive *here* is self-inflicted (`setActive(true)` calls `reloadInstrument`), so
**[Daniel]**-class question. the cost is ours to reduce and the reduction is filed in `docs/TODO.md` rather than designed
around. **No track in this plan carries an unanswered [Daniel]-class question.**
### Flagged for awareness — not blocking, but decision-grade ### Flagged for awareness — not blocking, but decision-grade
@@ -652,8 +653,8 @@ the fork rulings are in **`docs/product/instrument-control-surface.md`**. Read
layout table) and §7 (collisions) before dispatching any track here — every number in this 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. phase is derived there, and `docs/TODO.md`'s old deck-rework geometry is superseded.
**Fork state.** The phase's five original forks are **all ruled** (Daniel, 2026-08-01) — **Fork state — all six forks are ruled; nothing in this phase awaits Daniel.** Indexed at
indexed at spec §8.1, folded into the tracks below: spec §8, folded into the tracks below:
- **Γ-F1** — `kEditorMinHeight` stays **680**. - **Γ-F1** — `kEditorMinHeight` stays **680**.
- **Γ-F2** — the limiter has **lookahead with DYNAMIC reported latency** (zero when off, - **Γ-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;* the lookahead when on, reported to host PDC). *This inverted the product recommendation;*
@@ -664,11 +665,22 @@ indexed at spec §8.1, folded into the tracks below:
in a deck. W2-T2's scope grows accordingly — spec §6.4. 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 - **Γ-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. 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.
**One fork remains open — Γ-F6, on W1-T2** (spec §8.2): the VST3 SDK defines **What the Γ-F6 ruling changed in the analysis, not just in the plan.** Dynamic latency
`restartComponent(kLatencyChanged)` as a host **deactivate/reactivate**, and this plugin's reporting is **routine** for VST3 instruments and REAPER handles it as a matter of course;
`setActive` is destructive in both directions. Not blocking; gated on a DAW measurement W1-T2 the SDK's deactivate/reactivate requirement (`pluginterfaces/vst/ivsteditcontroller.h:105-108`)
performs first, with constant reported latency as the pre-agreed fallback. 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 Ξ — Γ runs BEFORE Ξ-W2.** Two reasons, both the same shape as **Sequencing against Phase Ξ — Γ runs BEFORE Ξ-W2.** Two reasons, both the same shape as
Ξ's own stated gate: Ξ's own stated gate:
@@ -681,12 +693,43 @@ performs first, with constant reported latency as the pre-agreed fallback.
payload, but Ξ-W3's programmed-signal persistence will, and two phases contending for the payload, but Ξ-W3's programmed-signal persistence will, and two phases contending for the
ladder is exactly the fight Θ's organizing constraint calls out. ladder is exactly the fight Θ's organizing constraint calls out.
**The organizing constraint.** Four surfaces are single-writer and dictate the wave shape: **The organizing constraint.** Five surfaces are single-writer and dictate the wave shape:
`ui/deck_values.cpp` (the taper law, then the two new controls), `editor_paint_waveform.cpp` `ui/deck_values.cpp` (the taper law, then the two new controls), `editor_paint_waveform.cpp`
(the contour trace, then the loop marks), the deck descriptors in `ui/deck_groups.cpp` + (the contour trace, then the loop marks), `ui/deck_groups.cpp` (the row predicate, then the
`ui/knob_deck.cpp` (the new group, then the reflow), and the params-payload ladder. **Every PITCH/RATE descriptor, then the reflow's row consumption — three tracks, three waves),
wave boundary below is one of those collisions**, not a preference. Where a wave has more `engine/voice.cpp` (the Preserve read path, then the rate compounding into it), and the
than one track, the tracks are disjoint by surface. 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), two changes.** The prior four-wave shape put the reflow at
W3 and the Preserve stretcher at W4; both moved.
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 params-payload ladder is unchanged by the resequencing, and re-checked against the new
membership.** Exactly one bump per wave, owned by exactly one track: **W1-T2 owns v14** (the
limiter flag), **W2-T1 owns v15** (rate + pitch offset), and **every other track in the phase
owns no rung** — W1-T4 changes no persisted field, W1-T5 adds no parameter, W2-T2's loop
enable maps onto the already-persisted `SampleLoop::hasLoop`, and W3-T1 is layout only. The
ordering still works because v14 lands a whole wave before v15, and neither of the two tracks
that moved touches the codec.
**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): **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 - **Bypassed means byte-identical.** With the limiter off, the per-sample output path is
@@ -700,12 +743,14 @@ than one track, the tracks are disjoint by surface.
- **`kVelocityPitchRangeSemitones` / `kPitchDepthMaxSemis` (24.0) does not move.** It is - **`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 load-bearing in the v12 wire format. The new Pitch knob **reads** it; it does not mint a
second ±24 constant. second ±24 constant.
- **The window floor is 1190 × 680 and must not exceed 1280 × 720.** A track that pushes the - **The window floor is 1190 × 680 and must not exceed 1280 × 720.** **Γ-W1-T4 sets it, in
floor past 1280 has failed, not overrun. **`kEditorMinHeight` stays 680** (Γ-F1). The wave 1; no other track in the phase may move it**, and from that point every track is
remaining **90 px of width headroom is the budget for the life of this layout** — one deck authored and judged at it. A track that pushes the floor past 1280 has failed, not overrun.
cell is 60 px, so there is room for exactly one more, once. Spec §1.6 states the ledger; **`kEditorMinHeight` stays 680** (Γ-F1). The remaining **90 px of width headroom is the
read it before adding any control. Chrome-row additions are a **separate purse** (they are budget for the life of this layout** — one deck cell is 60 px, so there is room for exactly
paid for out of the title slot, not the floor) and must not be charged against this one. 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 - **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 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. introduce a non-zero value, and only under the limiter-on condition.
@@ -716,9 +761,32 @@ than one track, the tracks are disjoint by surface.
### Γ-W1 — Foundations ### Γ-W1 — Foundations
**Depends on:** nothing in this phase. All three tracks are disjoint by surface — T1 owns **Depends on:** nothing in this phase. **Five tracks, disjoint by surface** — re-verified
editor input and the value/taper layer, T2 owns the engine and processor, T3 owns the against this membership rather than carried over from the four-wave shape:
waveform painter's envelope trace. None touches the deck descriptors.
| Track | Owns |
|---|---|
| **T1** `knob-interaction-law` | `ui/deck_values`, `ui/param_slider`, the three `shell/instrument/editor_input_*` drag paths, 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` (**v14**) |
| **T3** `contour-trace-curves` | `shell/instrument/editor_paint_waveform.cpp`'s staged trace + a **pure** tessellation helper |
| **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 |
**One shared file 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. Both are append-only additions in separate blocks —
**textual merge adjacency, not semantic contention.** Whichever lands second rebases.
**Two near-misses that are avoided by construction, and must stay avoided.** (a) T3's
tessellation helper **lands pure** (`ui/envelope_overlay` or a new pure module), *not* in
`editor_internal.h`, which T1 is editing — this also satisfies the phase's geometry-stays-pure
criterion, so it costs nothing. (b) 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.
**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` #### Γ-W1-T1 — `knob-interaction-law`
@@ -793,7 +861,8 @@ plugin's first latency reporting** — no editor drawing. Landing the audio ahea
what lets Γ-W3 draw against real published state instead of a stub. 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 **Spec:** `docs/product/instrument-control-surface.md` §3.1, **§3.1.1 (latency — read this
first)**, §3.23.3, §3.5, §7.10, §8.2. first; it was rewritten when Γ-F6 closed, so an older reading of it is wrong)**, §3.23.3,
§3.5, §7.10, §7.11, §8.2.
**Surface boundary — owns:** a new pure limiter module and a new pure meter-ballistics **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), module under `core/instrument/engine/` (each with its own `<module>_tests` target),
@@ -816,11 +885,20 @@ module under `core/instrument/engine/` (each with its own `<module>_tests` targe
there is no `getLatencySamples` override, no `kLatencyChanged`, and no `restartComponent` 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 call site anywhere in `src/`; the plugin ships the SDK default of 0. This track introduces
the plugin's first latency reporting. the plugin's first latency reporting.
- **The restart is the risky part and it is fenced.** The SDK defines `kLatencyChanged` as a - **The restart is routine; the fencing is against a standing scar, not against the flag.**
host **deactivate/reactivate** (`pluginterfaces/vst/ivsteditcontroller.h:105-108`), and this Dynamic latency reporting is ordinary VST3-instrument behaviour and REAPER handles it as a
plugin's `setActive` is destructive in both directions (`reasampler_processor.cpp:85-109`) — matter of course. The SDK's deactivate/reactivate requirement
deactivate frees every sounding voice, reactivate re-decodes the WAV. Four requirements, all (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) is the normal contract. **What makes the
acceptance criteria: 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, 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 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 *after* `setActive(true)` — so **the reported value must derive from persisted state, not
@@ -839,11 +917,17 @@ module under `core/instrument/engine/` (each with its own `<module>_tests` targe
- **Flipping the toggle during playback: apply immediately, do NOT defer to a transport - **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 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, the lookahead with no visible cue, which is worse than a visible interruption; and the host,
not the plugin, schedules the deactivate/reactivate anyway. Two mitigations are in scope: not the plugin, schedules the deactivate/reactivate anyway. **Daniel has accepted the
the engage/disengage is covered by a short (≤ 10 ms) equal-gain crossfade so **the plugin interruption outright** (Γ-F6) — it is not a case to design for. Two things remain in scope,
emits no discontinuity of its own**, and the limiter enable is classified **not automatable** and neither is a mitigation for it:
(`docs/product/parameter-automation.md` §3.8) so nothing can flip it at rate. It is also - **A short (≤ 10 ms) equal-gain crossfade over the engage/disengage.** Kept as a *quality*
**not** the plugin's `kIsBypass` parameter. 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 - **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 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 ballistics, no hold timers on the audio thread** — the UI converts and runs ballistics from
@@ -877,23 +961,18 @@ module under `core/instrument/engine/` (each with its own `<module>_tests` targe
on a rendered signal, not judged by ear. on a rendered signal, not judged by ear.
**Open questions.** **Open questions.**
- **[Daniel] fork Γ-F6 is the deactivate/reactivate cost acceptable?** (spec §8.2.) **Not - **No [Daniel] questions. Fork Γ-F6 is ruled** — dynamic latency ships as specced, the
blocking, and this is the track's FIRST deliverable:** a verification spike in REAPER — deactivate/reactivate is accepted, and there is no fallback design and no measurement gate.
flip the limiter with notes held, during playback and while stopped, and **record what Do not reintroduce either; the constant-reported-latency option is closed, not shelved.
actually happens** (do notes cut? is the re-decode perceptible? does transport hiccup?).
The SDK mandates the deactivate/reactivate; what REAPER does with it is DAW-verifiable only.
Ship the ruled dynamic-latency design regardless; if the observed behaviour is as ugly as
the SDK's worst case allows, the **pre-agreed fallback is constant reported latency** (the
delay line engaged whether or not the limiter is on), which needs one word from Daniel and
**no DSP change** — only the latency-reporting predicate moves. Report the measurement to
Daniel with a recommendation; do not choose the fallback unilaterally.
- **[verify]** `temp_cortex/` has already been assessed and **rejected** (spec §3.5) — do not - **[verify]** `temp_cortex/` has already been assessed and **rejected** (spec §3.5) — do not
re-litigate it, and do not transplant from it. re-litigate it, and do not transplant from it.
- **[verify]** whether `setActive(true)`'s `reloadInstrument()` can reuse the already-decoded - **[record, not a gate]** While the limiter is in REAPER under your hand, note what the
`SampleData` on a latency-restart reactivate rather than re-reading the bridge and restart actually costs — do notes cut, is the re-decode perceptible, does transport hiccup —
re-decoding the WAV. If it cannot cheaply, that is an **accepted cost to be recorded**, not and record it in this track's review. It is **not** a gate on shipping and no outcome changes
a reason to restructure `setActive` — its destructive shape is deliberate and its reasoning the design; it is the trigger-condition evidence for the `docs/TODO.md` entry "Decouple the
(ghost sustained voices on reactivate) is documented at the call site. 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` #### Γ-W1-T3 — `contour-trace-curves`
@@ -927,24 +1006,217 @@ diverge; tessellation approach is the engineer's call.
are honoured unchanged. The spline overlay's own trace is untouched. 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. - **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
75133 % 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 ### Γ-W2 — New controls, and the overlay's marks
**Depends on Γ-W1 for:** T1 depends on W1-T1 — Rate and Pitch must be authored into the **Depends on Γ-W1 for — four dependencies, two of them new:**
finished taper/modifier law, not retro-fitted into it (and the semitone taper must exist 1. **T1 ← W1-T1 (taper law).** Rate and Pitch must be authored into the finished
before a second semitone knob does). T2 depends on W1-T3 — both write taper/modifier law, not retro-fitted into it, and the semitone taper must exist before a
`editor_paint_waveform.cpp`, and running them together is a merge fight in one file. 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.
**Disjointness — restated after the Γ-F4 ruling, because it changed.** T1 owns the parameter T1 additionally inherits `engine/voice.{h,cpp}` from W1-T5 — a **hand-off, not a conflict**:
model, the engine and the deck descriptors; T2 owns the waveform band's marks and their pure W1-T5 defines the Preserve ratio seam, and T1 feeds it. Serial across waves by construction.
geometry **and now also the chrome row's loop enable**. The two are disjoint at the module
level with **one named exception: `shell/instrument/editor_session.cpp`.** T1 may touch it for **Disjointness — re-verified against this wave's membership, not carried over.** Both tracks
the third commit tier's routing; **T2 owns `pickedMarkers` and `applyMarkers` there and nothing stayed in W2 and nothing entered or left it, so the prior finding is re-checked and stands. T1
else.** The partition is by function and the two do not overlap — this is a textual merge owns the parameter model, the engine and the deck descriptors; T2 owns the waveform band's
adjacency, not a semantic contention — but it is a shared file in a phase whose wave boundaries 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 are otherwise single-writer surfaces, so it is stated rather than discovered at merge. Whichever
track lands second rebases onto the first. 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`.
**The format ladder stays clean.** The loop enable maps onto the existing **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 `SampleLoop::hasLoop`, which is already persisted and whose `start`/`end` are already written
@@ -964,7 +1236,8 @@ existing Varisp|Presrv toggle, with both new controls wired through the engine.
`core/instrument/engine/voice.{h,cpp}` (the compounding and the note-on latch), `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 `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 predicate), `core/instrument/ui/deck_values` (the two new bindings). **Does not own** the
deck's row layout — that is Γ-W3-T1 — nor the real time-stretcher (Γ-W4-T1). deck's row layout — that is Γ-W3-T1 — nor the time-stretcher itself (Γ-W1-T5, already landed
by the time this track runs).
**Behavior.** **Behavior.**
- **Rate: 50 %200 %, default 100 % at true knob centre, exponential taper** — 50 % = 12 st, - **Rate: 50 %200 %, default 100 % at true knob centre, exponential taper** — 50 % = 12 st,
@@ -977,10 +1250,11 @@ deck's row layout — that is Γ-W3-T1 — nor the real time-stretcher (Γ-W4-T1
read-increment multiply**; the rate offset applies to the varispeed pitch. Composes with 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**. 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 - **Preserve:** rate is an **absolute** value driving **duration only**; keytrack and pitch
offset drive the pitch shifter. **Interim implementation:** a resampled read with the offset drive the pitch shifter. **Rate drives the stretch ratio Γ-W1-T5's stretcher already
resulting pitch change cancelled in the existing SOLA shifter. This is an explicit duration consumes — there is no interim path.** The stretcher is this track's prerequisite, not its
control, **not** a covert Preserve path — `core/instrument/CLAUDE.md`'s "never wire successor; the resample-and-cancel stand-in the prior plan carried is retired unbuilt (see
`WDL_Resampler` as the duration-preserving path" is untouched. Quality is Γ-W4-T1's. 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 - **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`. live block like any live parameter, read only by `snapLive`, never by `applyLive`.
`isLiveDeckParam`/`liveCommitFor` widens from two states to three `isLiveDeckParam`/`liveCommitFor` widens from two states to three
@@ -1005,7 +1279,9 @@ deck's row layout — that is Γ-W3-T1 — nor the real time-stretcher (Γ-W4-T1
**Acceptance criteria.** **Acceptance criteria.**
- Rate at 50 % plays an octave down and half speed under Varispeed; at 200 %, an octave up and - 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 double speed. Under Preserve the same settings change duration only — pitch is unchanged
within the shifter's tolerance. 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 - Rate at exactly 100 % and Pitch at exactly 0 st render **bit-identical** to the
pre-change build, in both engines. pre-change build, in both engines.
- Shift-drag on Rate lands on whole semitones (so an octave and a fifth are reachable by - Shift-drag on Rate lands on whole semitones (so an octave and a fifth are reachable by
@@ -1019,9 +1295,19 @@ deck's row layout — that is Γ-W3-T1 — nor the real time-stretcher (Γ-W4-T1
compile error in `isLiveDeckParam`'s exhaustive switch until they are classified. compile error in `isLiveDeckParam`'s exhaustive switch until they are classified.
- A v14 project reopens at rate 100 % / pitch 0 st and sounds identical. - A v14 project reopens at rate 100 % / pitch 0 st and sounds identical.
**Open questions.** None **[Daniel]**. **[propose at review]** the exact interim **Open questions.**
resample+cancel composition and its clamp behaviour at the range extremes, which Γ-W4-T1 then - **None [Daniel].**
replaces. - **[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` #### Γ-W2-T2 — `loop-crossfade-ux`
@@ -1167,12 +1453,17 @@ track changes drawing, hit-testing and one editor-state retention rule — no fo
**Depends on Γ-W2 for:** the PITCH/RATE descriptor (W2-T1) — the reflow measures the real **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 three-cell group, and laying it out against a forecast of that group means re-measuring
afterward. **Depends on Γ-W1 for:** W1-T2's published meter/GR/clip state, which MASTER's afterward. **This is the whole reason the arrangement is late**, and it is why the canvas was
deck draws; drawing against a stub would mean building the meter twice. split out of it into W1-T4.
**One track.** The row law, the group inventory, the double-height deck and the window floor **Depends on Γ-W1 for:** W1-T2's published meter/GR/clip state, which MASTER's deck draws
are one geometry decision spread over `knob_deck`, `deck_groups`, `sample_bands` and the deck (drawing against a stub would mean building the meter twice), and W1-T4's floor, budget
painter. Splitting it would put two tracks in the same three pure modules. constants and row predicate, which this track **consumes rather than re-derives**.
**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.
#### Γ-W3-T1 — `deck-reflow` #### Γ-W3-T1 — `deck-reflow`
@@ -1184,10 +1475,12 @@ measured table **and §1.6, the headroom ledger**) and §3.23.3 (what MASTER
the invariants this track invalidates or widens — read it before touching `knob_deck.h`.** 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 **Surface boundary — owns:** `core/instrument/ui/knob_deck` (the row law, the double-height
group, the justification), `core/instrument/ui/deck_groups` (row assignment, FILTER's toggle group, the justification — **consuming** W1-T4's budget constants, not restating them),
move, MASTER's inventory), `core/instrument/ui/sample_bands` (`kEditorMinWidth`), and `core/instrument/ui/deck_groups` (consumption of W1-T4's row predicate, FILTER's `Band|Notch`
`shell/instrument/editor_paint_deck` (the MASTER meter/limiter/bubble draw). **Does not own** caption move, MASTER's inventory), and `shell/instrument/editor_paint_deck` (the MASTER
any parameter, the limiter DSP, or the waveform band. 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.** **Behavior.**
- **Row 1 (sound), one row, non-negotiable:** PITCH/RATE 192 · FILTER 432 · VELOCITY 192 · - **Row 1 (sound), one row, non-negotiable:** PITCH/RATE 192 · FILTER 432 · VELOCITY 192 ·
@@ -1209,9 +1502,12 @@ any parameter, the limiter DSP, or the waveform band.
That tie-line, row 2's equal gutters, and row 1's minimum gutter being exactly 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 `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). 1186.** Above the floor the tie-line drifts and that is accepted (spec §1.3).
- **`kEditorMinWidth` 980 → 1190**; `kEditorMinHeight` **stays 680** (Γ-F1, ruled — the - **The floor is already 1190 × 680 and the bands are already 216 / 358** — Γ-W1-T4 landed all
reflow's 112 px goes entirely to the waveform); deck band height 328 → **216**; waveform four in wave 1, and the greedy wrap happened to reach two rows at that width. **This track
band at the floor 246 → **358**. 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 - **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 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 spend 60 of the 90 up front on a control nobody has named, leaving 30 — which would freeze
@@ -1248,9 +1544,13 @@ any parameter, the limiter DSP, or the waveform band.
312 in both modes — assert it). 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 - 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. is exact (both edges at x = 636) and row 2's two gutters are equal.
- **`kEditorMinWidth` is 1190 and the floor is ≤ 1280 × 720**, asserted by a derived test - **Row 1's natural width is 980 and fits the 1020 block** — the fit Γ-W1-T4 could not yet
rather than a literal (the Θ-W6-T1 precedent). assert, closed here by the `Band|Notch` move.
- The waveform band is **358 px at the floor**, and the deck band is 216. - **`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 - MASTER's gain knob shares a knob baseline with FILTER's knobs; its reserved slot shares one
with AMP ENVELOPE's. with AMP ENVELOPE's.
- The meter reads correctly in mono and stereo, the peak-hold tick holds 1.5 s, the clip cap - The meter reads correctly in mono and stereo, the peak-hold tick holds 1.5 s, the clip cap
@@ -1274,66 +1574,6 @@ any parameter, the limiter DSP, or the waveform band.
--- ---
### Γ-W4 — The Preserve time-stretcher
**Depends on Γ-W2 for:** Rate existing as a shipped, working control on the interim
resample+cancel path. Landing the stretcher against a working feature makes it a measurable
quality upgrade with an A/B; landing them together would make one track responsible for both
a feature and an algorithm.
**One track.** **This is the phase's largest unknown** — flagged the way Ξ-W2-T1's crossing
was. If the measure-and-report gate fails, what gives is the *algorithm choice*, not Rate:
the interim path already ships in W2-T1, so the fallback is "keep the interim path and record
why," not "cut the feature."
#### Γ-W4-T1 — `preserve-time-stretch`
**Goal.** Replace the interim resample+cancel composition with a real pitch-preserving
time-stretcher written from established state-of-the-art literature.
**Spec:** `docs/product/instrument-control-surface.md` §2.5.
**Surface boundary — owns:** `core/instrument/engine/pitch_shift` and whatever new pure
module the stretcher needs, plus `voice.cpp`'s Preserve read path. **Does not own** any
parameter, any UI, or the varispeed path.
**Behavior and constraints.** The algorithm is **the engineer's call under a
measure-and-report gate — this plan deliberately names none.** The constraints:
- **CPU stance (Daniel, verbatim intent):** *"we should be efficient but accept the cost of
high-quality algorithm choices. It's 2026, most people's computers can handle audio with
ease. Just don't be wasteful."*
- **RT-safe:** no allocation, no I/O, no lock in `process()`; buffers sized at voice
allocation or at the off-audio-thread reload, on `pitch_shift`'s existing pre-warm
precedent.
- **Per-voice state, holding up at the 32-voice ceiling.** The gate is 32 simultaneous
Preserve voices at 50 % and 200 %, not one voice at 100 %.
- **No new third-party dependency** (`pitch_shift`'s standing property).
- **No dispatch on the per-sample path** — concrete, inlineable types; no `IStretcher`.
- **Onset behaviour is a regression surface.** GA2 eliminated Preserve's ~25 ms onset latency
by priming the ring with the actual upcoming source. **A stretcher that reintroduces an
onset delay or a first-frame smear is a regression, not a trade-off.**
**Acceptance criteria.**
- **Rate 100 % with no shift is bit-identical to the un-stretched read.**
- Preserve speaks on frame 0 — no added onset latency, no first-frame smear, in any
rate/shift combination.
- No audible metallic or phasey artefacting on sustained tonal material at ±6 st and
75133 % rate; transient material at 50 % / 200 % is no worse smeared than the interim path.
- The Gate sustain-loop contract is unchanged: **loop the source, shift the output** — loop
points remain source-frame facts.
- **Measure and report before the algorithm is final:** per-voice CPU at 32 voices, added
latency (must be zero at the onset), and A/B recordings against the interim path on three
material classes (one-shot, tonal sustain, full-mix bounce). Report to Daniel; the choice
is not final until he has heard the A/Bs.
**Open questions.** **[propose, with a measurement step]** the algorithm family itself.
**[verify]** that `core/instrument/CLAUDE.md`'s *"`WDL_Resampler` is not a Preserve engine —
never wire it as the duration-preserving path"* is honoured: Rate legitimately changes
duration under Preserve, so a resampled read is an explicit duration control, but the
*pitch-preserving* mechanism must not be a resampler.
---
## Traceability — all seventeen items ## Traceability — all seventeen items
The check that nothing was dropped. Every row points at a track that exists above. The check that nothing was dropped. Every row points at a track that exists above.
@@ -1373,8 +1613,8 @@ proof it exists to give.
from `TODO-1.0.md`, and not a track this plan originally scoped. The second such track 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 in this plan today; if others appear, they belong on this list rather than in the
table. table.
- **All of Phase Γ** (`pg-*`). Seven tracks from a direct interview with Daniel - **All of Phase Γ** (`pg-*`). Eight tracks across three waves, from a direct interview with
(2026-08-01), not from `TODO-1.0.md`. Listed here as a block rather than per track, Daniel (2026-08-01), not from `TODO-1.0.md`. Listed here as a block rather than per track,
because the whole phase is outside the source doc; the product reasoning lives in because the whole phase is outside the source doc; the product reasoning lives in
`docs/product/instrument-control-surface.md` and the automation scoping it defers in `docs/product/instrument-control-surface.md` and the automation scoping it defers in
`docs/product/parameter-automation.md`. Γ-W3-T1 additionally **discharges** the `docs/product/parameter-automation.md`. Γ-W3-T1 additionally **discharges** the
@@ -1447,15 +1687,20 @@ Phase Ξ — The resample loop (W1 concurrency-safe with Θ from Θ-W
T1 capture-signal-popup .................... 15 (popup) T1 capture-signal-popup .................... 15 (popup)
Phase Γ — The instrument's control surface (none of the seventeen; runs before Ξ-W2) Phase Γ — The instrument's control surface (none of the seventeen; runs before Ξ-W2)
W1 Foundations [3 tracks, disjoint by surface] W1 Foundations [5 tracks, disjoint by surface]
T1 knob-interaction-law ....... modifiers + ms/semitone tapers + reset bypass T1 knob-interaction-law ....... modifiers + ms/semitone tapers + reset bypass
T2 master-bus-audio ........... limiter + meter ballistics + processor [payload v14] T2 master-bus-audio ........... limiter + meter ballistics + dynamic PDC [payload v14]
T3 contour-trace-curves ....... staged traces draw curved, knot on its trace 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] W2 New controls, and the overlay's marks [2 tracks]
T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [payload v15] T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [payload v15]
T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard
W3 The reflow [1 track] W3 The reflow [1 track]
T1 deck-reflow ................ two rows + double-height MASTER; floor 1190x680 T1 deck-reflow ................ two rows + double-height MASTER, by construction
W4 Preserve time-stretch [1 track]
T1 preserve-time-stretch ...... real stretcher [measure-and-report gate] Resequenced 2026-08-01: the reflow split canvas (W1-T4) from arrangement (W3-T1), and
preserve-time-stretch moved W4 -> W1-T5, which retires Rate's interim stand-in.
Shared files, named: engine/CMakeLists.txt (W1-T2 | W1-T5) and editor_session.cpp
(W2-T1 | W2-T2) — both textual adjacency, not semantic contention.
``` ```
+52
View File
@@ -224,6 +224,58 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
**Done looks like.** A stage time of several seconds is reachable by hand with no loss of resolution below 100 ms; the AHDSR overlay still reads legibly at both ends of the range; `kEnvTimeMaxSeconds` and `kGateStageMaxSeconds` still agree; and a project saved at the old ceiling reloads with identical stored seconds and identical audio. **Done looks like.** A stage time of several seconds is reachable by hand with no loss of resolution below 100 ms; the AHDSR overlay still reads legibly at both ends of the range; `kEnvTimeMaxSeconds` and `kGateStageMaxSeconds` still agree; and a project saved at the old ceiling reloads with identical stored seconds and identical audio.
## Decouple the instrument reload from VST3 activation
**Context (Daniel, 2026-08-01 — Phase Γ fork Γ-F6, ruled closed).** Γ-W1-T2 ships the plugin's
first latency reporting: `getLatencySamples()` returns 0 with the limiter off and the lookahead
with it on, and the toggle calls `IComponentHandler::restartComponent(kLatencyChanged)`. The
vendored SDK defines that flag as a host **deactivate/reactivate**
(`pluginterfaces/vst/ivsteditcontroller.h:105-108`). **Dynamic latency reporting is routine for
VST3 instruments and REAPER handles it as a matter of course** — the deactivate/reactivate is
the normal contract, and for a typical plugin `setActive` only allocates and frees buffers.
Γ-F6 was originally posed as "is this SDK cost acceptable?"; Daniel's answer relocated it:
*"you have to have missed something, I used plenty of VST3s inside of REAPER that report PDC
dynamically... Toggling the limiter killing the voices isn't a deal breaker though, the limiter
will either be on or off on its instance, toggling during playback is not a use case."*
**The wart — and it is ours, not the SDK's.** `ReaSamplerProcessor::setActive(true)` calls
`reloadInstrument()` (`src/shell/instrument/reasampler_processor.cpp:89-97`) — a bridge read
plus a **full WAV re-decode** plus a fresh engine. `setActive(false)` frees `live_`,
`draining_` and the graveyard (`:98-107`). So every host-driven activation cycle — a
latency-change restart, an offline-render bracket, any host that deactivates around transport
state — pays a disk read and a decode that nothing about activation requires. **Activation
currently means two things at once**: "the audio thread may run" and "the decoded `SampleData`
is (re)built." Dynamic latency is simply the first feature that makes the cycle
user-triggerable.
**Intended fix.** Separate the two lifetimes: keep the decoded `SampleData` alive across a
deactivate and rebuild only the voice state on reactivate. The mechanism already exists in this
file — `rebuildVoiceEngine` performs exactly that shape (drain-slot swap around the
already-decoded `SampleData`, no bank re-read, no WAV re-decode) for voice-count and voice-mode
edits. This is a lifetime split, not a new mechanism.
**The constraint the fix MUST handle.** The deactivate's destruction is deliberate and its
reason is documented at the call site: a surviving `live_` would be displaced into the drain
slot on reactivate and *"resurrect stale sustained voices as ghosts."* **Voice state must still
die across the cycle** — only the decoded PCM survives, and those are two different lifetimes
currently collapsed into one. Second constraint: `setActive(true)` is also the non-editor
legacy-lift trigger for a pre-v10 blob (its opportunistic `refreshRefsFromBank` copies refs in
once the bank blob is readable), so a path that skips the bridge read must keep that lift
reachable — the comment at `:90-96` records the residual load-order race it exists to cover.
**Priority / risk.** Low; deferred by ruling. Nothing is incorrect today, only wasteful, and
Daniel has explicitly accepted the user-visible consequence (held notes cut on a limiter
toggle). **Trigger conditions — revisit when any one of these holds:** (a) a second
latency-changing control appears, so the cycle stops being a once-per-patch event; (b) the
limiter enable is ever wanted automatable, which `docs/product/parameter-automation.md` §3.8
currently forbids *because* of this cost; or (c) the re-decode is observed to be perceptible in
REAPER — Γ-W1-T2's review records that observation for exactly this purpose.
**Done looks like.** A host-driven deactivate/reactivate cycle costs no disk I/O and no WAV
decode; sounding voices are still destroyed across it, with no ghost-resurrection regression;
a pre-v10 blob still lifts; and `getLatencySamples()` still derives from persisted state rather
than from a transient the deactivate cleared.
## The deck layout rework — SPECCED, and the original shape SUPERSEDED ## The deck layout rework — SPECCED, and the original shape SUPERSEDED
**Status (2026-08-01): no longer a deferral. The design notes Daniel owed this entry have **Status (2026-08-01): no longer a deferral. The design notes Daniel owed this entry have
+193 -94
View File
@@ -6,12 +6,16 @@ contour-trace defect, and a re-approach of the loop/crossfade markers.
**Status:** items AF below are **SETTLED (Daniel, 2026-08-01)** from a direct interview; **Status:** items AF below are **SETTLED (Daniel, 2026-08-01)** from a direct interview;
this doc records them, works out the design detail they imply, and states the arithmetic. this doc records them, works out the design detail they imply, and states the arithmetic.
**The five forks this doc opened (Γ-F1…Γ-F5) were all ruled on by Daniel on 2026-08-01**; **All six forks this doc opened (Γ-F1…Γ-F6) are ruled (Daniel, 2026-08-01)**; their rulings
their rulings are folded into the sections they affect and the rulings themselves are are folded into the sections they affect and indexed in §8. **Nothing in this phase awaits a
recorded in §8. §8 also carries **one new fork, Γ-F6**, which the Γ-F2 ruling surfaced from Daniel answer.** **The forward-looking VST3 automation parameter system is deliberately NOT
the vendored SDK and which Daniel did not have in front of him. **The forward-looking VST3 in this phase** — it has its own doc, `docs/product/parameter-automation.md`.
automation parameter system is deliberately NOT in this phase** — it has its own doc,
`docs/product/parameter-automation.md`. > **Γ-F6's ruling corrected this document's analysis, not merely its recommendation.** §3.1.1
> previously framed dynamic latency reporting as exotic and expensive. It is neither: it is
> routine for VST3 instruments and REAPER handles it as a matter of course. What is expensive
> here is **self-inflicted** — this plugin's own `setActive` — and therefore ours to reduce.
> §3.1.1 has been rewritten accordingly, not merely annotated with the ruling.
Every geometry number below was re-derived from `src/core/instrument/ui/knob_deck.cpp`'s Every geometry number below was re-derived from `src/core/instrument/ui/knob_deck.cpp`'s
own width formula, not carried over from a prior measurement. The stale geometry block in own width formula, not carried over from a prior measurement. The stale geometry block in
@@ -41,11 +45,13 @@ own width formula, not carried over from a prior measurement. The stale geometry
toggle in the caption corner, gain knob upper-left, a full-double-height stereo peak toggle in the caption corner, gain knob upper-left, a full-double-height stereo peak
meter down the right, an averted-clip bubble, and a **one-cell** reserved lower-left meter down the right, an averted-clip bubble, and a **one-cell** reserved lower-left
slot (Γ-F5). slot (Γ-F5).
- **The limiter is a lookahead design with DYNAMIC reported latency** (Γ-F2): zero when - **The limiter is a lookahead design with DYNAMIC reported latency** (Γ-F2, Γ-F6): zero
off, the lookahead when on, reported to the host's PDC. That buys a transparent when off, the lookahead when on, reported to the host's PDC. This is **routine VST3
true-peak limiter and costs a `restartComponent(kLatencyChanged)` on the toggle — which behaviour**; the `restartComponent(kLatencyChanged)` it costs is the normal contract, and
the SDK defines as a host **deactivate/reactivate**, not a re-tap. §3.1.1 is the whole the deactivate/reactivate the flag mandates is **accepted** — the toggle is a patch-design
of that cost and how it is contained. gesture. The only reason the cycle is expensive at all is that **our** `setActive` re-decodes
the WAV, which is a latent improvement filed in `docs/TODO.md`, not a design constraint.
§3.1.1.
- **The cortex limiter does not clear the bar** — §3.5. Read it, take nothing. - **The cortex limiter does not clear the bar** — §3.5. Read it, take nothing.
- **Loop gets an explicit enable on the chrome row** (Γ-F4), and the four-mark grammar - **Loop gets an explicit enable on the chrome row** (Γ-F4), and the four-mark grammar
sits under it. The core finding behind the re-approach: three identical bars draw a sits under it. The core finding behind the re-approach: three identical bars draw a
@@ -112,6 +118,16 @@ waveform band at the floor = 680 90 (chrome) 4 4 8 216
**1190 × 680, against a 1280 × 720 ceiling — 90 px of width headroom, 40 px of height.** **1190 × 680, against a 1280 × 720 ceiling — 90 px of width headroom, 40 px of height.**
> **Who lands which half.** The floor, the three budget constants it is derived from
> (row block 1020 · MASTER 142 · ceiling 1280) and each group's row membership land in
> **Γ-W1-T4**, in wave 1, so the rest of the phase is authored at the final window. The
> arrangement *inside* that budget — the justification law, the gutters, the tie-line,
> MASTER's interior — is **Γ-W3-T1**, because every one of those measures a descriptor that
> does not exist until Γ-W2-T1 and Γ-W3-T1 create it. **Row 1's natural width does not fit
> the 1020 block until Γ-W3-T1**: it is 1030 today, +42 from PITCH/RATE, 92 from FILTER's
> `Band|Notch` caption move, = 980. Row 2's 876 already fits. `docs/PLAN.md` at Γ-W1-T4
> states the seam and the interim layout in full.
Three corrections to the arithmetic in the brief, all small and all in our favour: Three corrections to the arithmetic in the brief, all small and all in our favour:
1. **MASTER at 142, not ~236.** A 236-wide MASTER puts the floor at exactly 1280 — the 1. **MASTER at 142, not ~236.** A 236-wide MASTER puts the floor at exactly 1280 — the
@@ -304,7 +320,10 @@ resampler that couples duration) — never wire it as the duration-preserving pa
Preserve, Rate is *supposed* to change duration; a Preserve implementation that resamples Preserve, Rate is *supposed* to change duration; a Preserve implementation that resamples
the read rate and cancels the resulting pitch shift in the shifter is an explicit the read rate and cancels the resulting pitch shift in the shifter is an explicit
duration control, not a covert Preserve path. The invariant forbids using a resampler *as* duration control, not a covert Preserve path. The invariant forbids using a resampler *as*
the pitch-preserving mechanism, and that prohibition stands. the pitch-preserving mechanism, and that prohibition stands. **As of the 2026-08-01
resequencing this paragraph describes a contingency, not the shipping path** — the real
stretcher lands ahead of Rate (§2.5), so nothing composes a resampled read with a cancelling
shift unless that contingency is taken.
### 2.3 Rate is latched at note-on — and the reason matters more than the rule ### 2.3 Rate is latched at note-on — and the reason matters more than the rule
@@ -357,10 +376,25 @@ Preserve it is an addend to a shift amount the pitch envelope already modulates.
### 2.5 The Preserve time-stretcher — quality bar, not algorithm ### 2.5 The Preserve time-stretcher — quality bar, not algorithm
**Preserve mode has no pitch-preserving time-stretch DSP today.** `pitch_shift` is a **Preserve mode has no pitch-preserving time-stretch DSP today.** `pitch_shift` is a
correlation-aligned SOLA *pitch* shifter; composing it with a resampled read yields a correlation-aligned SOLA *pitch* shifter. A **real stretcher, written from established
working stretch, and that composition is the **interim path** Γ-W2-T1 ships so Rate is a state-of-the-art literature**, is its own track.
complete feature the day it lands. A **real stretcher, written from established
state-of-the-art literature**, follows as its own track. **Sequencing — changed 2026-08-01 (Daniel), and it retires an implementation.** This doc
originally put the stretcher *after* Rate and had Γ-W2-T1 ship an **interim** composition —
a resampled read with the resulting pitch change cancelled in the SOLA shifter — so Rate
would be complete the day it landed. **That is reversed.** The stretcher has zero dependency
on any UI work and is the phase's longest pole, so it runs from the start of the phase
(Γ-W1-T5) and Rate lands onto it (Γ-W2-T1). Two consequences:
- **The interim composition is not built.** It only ever existed to be deleted; skipping a
disposable implementation is the win. It survives in this document as the **named
contingency** if the stretcher's gate slips past the point Rate is ready to dispatch — see
Γ-W2-T1's open questions. Taking it is an escalation to Daniel, not an engineer's call.
- **The quality reference changes.** There is no interim path to A/B against. The honest
reference is **varispeed playback at the equivalent ratio** — same duration, pitch shifted —
which answers "what does preserving pitch cost" and needs nothing built to serve it. The
null case is unchanged and is now stronger: ratio 1.0 with no shift must be **bit-identical
to the shipped Preserve read**, a baseline that exists rather than one that was invented.
**Do not pick an algorithm in this document.** The constraints: **Do not pick an algorithm in this document.** The constraints:
@@ -380,12 +414,12 @@ state-of-the-art literature**, follows as its own track.
latency by priming the ring with the actual upcoming source. **A stretcher that 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.** reintroduces an onset delay or a first-frame smear is a regression, not a trade-off.**
- **Quality bar.** No audible metallic/phasey artefacting on sustained tonal material at - **Quality bar.** No audible metallic/phasey artefacting on sustained tonal material at
±6 st and 75133 % rate; no smearing of transient material at 50 %/200 % worse than the ±6 st and 75133 % ratio; no smearing of transient material at 50 %/200 % worse than
interim resample+SOLA path; the null case (rate 100 %, no shift) must be **bit-identical varispeed at the equivalent ratio; the null case (ratio 1.0, no shift) must be
to the un-stretched read**. **bit-identical to the shipped Preserve read**.
- **Gate.** Measure and report before the algorithm is final: per-voice CPU at 32 voices, - **Gate.** Measure and report before the algorithm is final: per-voice CPU at 32 voices,
added latency (must be zero at the onset), and A/B recordings against the interim path on added latency (must be zero at the onset), and A/B recordings on three material classes
three material classes (one-shot, tonal sustain, full-mix bounce). (one-shot, tonal sustain, full-mix bounce).
--- ---
@@ -433,7 +467,7 @@ Behaviour:
- **Gain reduction is published per block** for the bubble indicator (§3.3). - **Gain reduction is published per block** for the bubble indicator (§3.3).
- **Lookahead, with DYNAMIC reported latency** — §3.1.1. - **Lookahead, with DYNAMIC reported latency** — §3.1.1.
### 3.1.1 Lookahead and dynamic latency (Γ-F2, ruled by Daniel 2026-08-01) ### 3.1.1 Lookahead and dynamic latency (Γ-F2 and Γ-F6, both ruled by Daniel 2026-08-01)
**Settled: the limiter has lookahead, and the plugin reports latency dynamically.** **Settled: the limiter has lookahead, and the plugin reports latency dynamically.**
Daniel's reasoning, verbatim intent: true-peak detection needs oversampling and a Daniel's reasoning, verbatim intent: true-peak detection needs oversampling and a
@@ -452,9 +486,15 @@ This overrides the zero-lookahead recommendation this doc previously carried.
SDK default of **0**. This track is the first latency reporting the instrument has ever SDK default of **0**. This track is the first latency reporting the instrument has ever
done, so there is no existing behaviour to preserve, only a new contract to get right. done, so there is no existing behaviour to preserve, only a new contract to get right.
#### What the vendored SDK actually says, and why it is more expensive than it looks #### What the vendored SDK says and where the cost actually comes from
Two facts read directly out of `vendor/vst3sdk`, both load-bearing: > **This section previously argued that the SDK's requirements made dynamic latency
> expensive. That framing was wrong and Daniel corrected it** (Γ-F6): *"you have to have
> missed something, I used plenty of VST3s inside of REAPER that report PDC
> dynamically."* He is right. The corrected analysis follows; the SDK quotes are unchanged
> because the quotes were never the problem — the attribution of the cost was.
Two facts read directly out of `vendor/vst3sdk`:
> `pluginterfaces/vst/ivstaudioprocessor.h:293-299` — *"If during the use of the plug-in > `pluginterfaces/vst/ivstaudioprocessor.h:293-299` — *"If during the use of the plug-in
> this latency change, the plug-in has to inform the host by using > this latency change, the plug-in has to inform the host by using
@@ -466,21 +506,36 @@ Two facts read directly out of `vendor/vst3sdk`, both load-bearing:
> to deactivate and reactivate the plug-in**, then afterwards the host could ask for the > to deactivate and reactivate the plug-in**, then afterwards the host could ask for the
> current latency."* > current latency."*
The second is the sharp one. `kLatencyChanged` is not a "re-read the number" flag — the **Both describe the ordinary contract.** Dynamic latency reporting is routine for VST3
SDK defines it as a **deactivate/reactivate cycle**. And in *this* plugin, plugins — lookahead limiters, linear-phase EQs and oversampling processors all do it — and
REAPER handles it as a matter of course. The deactivate/reactivate is the *normal* cost of
the flag, and for a typical plugin it is cheap: `setActive` allocates and frees buffers.
**What makes it expensive here is entirely our own design, in one line.**
`ReaSamplerProcessor::setActive` is deliberately destructive in both directions `ReaSamplerProcessor::setActive` is deliberately destructive in both directions
(`reasampler_processor.cpp:85-109`): (`reasampler_processor.cpp:85-109`):
- `setActive(false)` frees `live_`, `draining_`, **and** the graveyard — *every sounding - `setActive(true)` calls `reloadInstrument()` (`:89-97`) — **a bridge read and a full WAV
voice dies*. The comment there explains why that is correct and must not be softened re-decode**, plus a fresh engine. This is the expensive half, and no part of it is required
casually: a surviving `live_` would be displaced into the drain slot on reactivate and by the SDK: it is there because activation was the convenient trigger for a reload, not
*"resurrect stale sustained voices as ghosts."* because activation implies one.
- `setActive(true)` calls `reloadInstrument()` — a bridge read, a **WAV re-decode**, and a - `setActive(false)` frees `live_`, `draining_` **and** the graveyard (`:98-107`), so every
fresh engine. sounding voice dies. The comment there explains why that is correct and must not be
softened casually: a surviving `live_` would be displaced into the drain slot on reactivate
and *"resurrect stale sustained voices as ghosts."*
**So the honest cost of the toggle is: every sounding note stops, and the sample is **So the cost is ours, and it is ours to reduce.** The reduction is **decoupling the reload
re-decoded from disk.** That is a materially heavier consequence than "a brief click," and from activation** — keeping the decoded `SampleData` alive across a deactivate while still
it is the reason §8's new fork Γ-F6 exists rather than this being fully closed. destroying voice state, which is exactly the shape `rebuildVoiceEngine`'s drain-slot swap
already implements for voice-count edits. **That is a latent improvement with a clear trigger
condition, filed in `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") —
not a reason to abandon dynamic latency, and not scheduled in this phase.**
**The honest cost of the toggle today, stated plainly:** every sounding note stops and the
sample is re-decoded from disk. **Daniel has accepted it** (Γ-F6): *"Toggling the limiter
killing the voices isn't a deal breaker though, the limiter will either be on or off on its
instance, toggling during playback is not a use case."* There is no fallback design and no
measurement gate.
#### The standing scar, and why this is nonetheless not the forbidden change #### The standing scar, and why this is nonetheless not the forbidden change
@@ -516,11 +571,16 @@ reason the following are acceptance criteria and not suggestions:
user clicking the toggle repeatedly produces one restart per settled state, not one per user clicking the toggle repeatedly produces one restart per settled state, not one per
click. click.
#### Flipping the toggle during playback — the product decision #### Flipping the toggle during playback — settled twice over
**Ruling (mine, not deferred): the toggle applies immediately, the restart is requested **Daniel's ruling (Γ-F6) is the outer one: this is not a use case, and it is not to be
immediately, and the resulting interruption is accepted and documented. It is NOT deferred designed for.** *"The limiter will either be on or off on its instance, toggling during
to a transport boundary.** Three reasons, in order of weight: playback is not a use case."* Nothing below is a mitigation for an accepted cost; what
survives is either ordinary hygiene or an ordinary quality measure.
**The inner ruling stands unchanged: the toggle applies immediately, the restart is requested
immediately, and it is NOT deferred to a transport boundary.** Three reasons, in order of
weight:
1. **A deferred restart is a silent lie.** If the limiter's audio engages now but the 1. **A deferred restart is a silent lie.** If the limiter's audio engages now but the
reported latency lands at the next transport stop, the plugin is misaligned by the reported latency lands at the next transport stop, the plugin is misaligned by the
@@ -533,25 +593,28 @@ to a transport boundary.** Three reasons, in order of weight:
transport stopped is the common editing case; a transport-boundary deferral would mean transport stopped is the common editing case; a transport-boundary deferral would mean
the restart never lands at all in that case, which is the worst outcome of the three. the restart never lands at all in that case, which is the worst outcome of the three.
The mitigations that make this acceptable rather than merely defensible: What is in scope alongside it — and what each is actually for:
- **The limiter's own output has no hard step.** Within the plugin, the engage/disengage is - **A short (≤ 10 ms) equal-gain crossfade over the engage/disengage. Kept as a QUALITY
covered by a short (≤ 10 ms) equal-gain crossfade between the pre- and post-toggle paths, measure, not as a mitigation.** A limiter engaging is a gain-path change, and this codebase
so whatever the host does around it, we do not emit a discontinuity of our own making. already ramps every gain-path change (`kGainRampSeconds`, `ValueRamp`); a plugin that steps
its gain path clicks whether or not a restart is pending. It also earns its keep for a
reason that has nothing to do with the restart: **the host, not the plugin, decides when to
act on the request**, so our own transition must be clean in the window before it does.
- **The toggle is framed as a patch-design control, not a performance control.** It is set - **The toggle is framed as a patch-design control, not a performance control.** It is set
once while building a sound. The editor should not encourage flipping it while playing, once while building a sound. The editor should not encourage flipping it while playing,
and nothing in the UI should make it a per-take gesture. and nothing in the UI should make it a per-take gesture. Γ-F6's ruling *is* this framing.
- **The limiter enable is explicitly NOT automatable.** This is the load-bearing - **The limiter enable is explicitly NOT automatable.** This is the load-bearing
consequence and it must be recorded where the parameter work will read it: an automation consequence and it must be recorded where the parameter work will read it: an automation
lane toggling a latency-changing parameter would request a host deactivate/reactivate on lane toggling a latency-changing parameter would request a host deactivate/reactivate on
every flip. See `docs/product/parameter-automation.md` §3.8 — the limiter enable belongs every flip. See `docs/product/parameter-automation.md` §3.8 — the limiter enable belongs
in the **not-automatable** class, and it is emphatically not the plugin's `kIsBypass` in the **not-automatable** class, and it is emphatically not the plugin's `kIsBypass`
parameter either. parameter either.
- **The interruption is verified in REAPER, and its severity recorded.** The SDK mandates - **Observe what REAPER does, and record it — as evidence, not as a gate.** Whether notes
the deactivate/reactivate; *what REAPER actually does with it* — whether sounding notes cut, whether the re-decode is perceptible, whether transport hiccups, is DAW-observable
cut, whether the re-decode is perceptible, whether transport hiccups — is DAW-verifiable only. Record it in Γ-W1-T2's review because it is the trigger-condition evidence for the
only. That verification is W1-T2's first deliverable, and its outcome is what closes `docs/TODO.md` decoupling entry. **No outcome changes the design**; Γ-F6 is closed either
Γ-F6. way.
### 3.2 The meter ### 3.2 The meter
@@ -1175,15 +1238,24 @@ override, no `kLatencyChanged`, and no `restartComponent` call site exists anywh
latency reporting. There is therefore no existing behaviour to preserve — but §3.1.1's four latency reporting. There is therefore no existing behaviour to preserve — but §3.1.1's four
verification requirements bind, because the deactivate/reactivate the flag mandates lands verification requirements bind, because the deactivate/reactivate the flag mandates lands
squarely on `ReaSamplerProcessor::setActive`, which is deliberately destructive in both squarely on `ReaSamplerProcessor::setActive`, which is deliberately destructive in both
directions. directions. **Those four are hygiene against the `kIoChanged` scar (§3.1.1), not a hedge
against the flag itself** — Γ-F6 is ruled and the restart ships.
**7.11 — `setActive` conflates two lifetimes, and dynamic latency is the first feature that
makes a user notice.** Activation currently means both "the audio thread may run" and "the
decoded `SampleData` is (re)built" (`reasampler_processor.cpp:89-97`). Phase Γ does **not**
separate them — Γ-F6 accepts the cost — but the conflation is now a named, filed improvement
(`docs/TODO.md`, "Decouple the instrument reload from VST3 activation") rather than an
unremarked property. **Do not restructure `setActive` inside this phase**; its destructive
shape is deliberate and its reasoning is documented at the call site.
--- ---
## 8. Forks — five ruled, one open ## 8. Forks — all six ruled, none open
### 8.1 Ruled by Daniel, 2026-08-01 ### 8.1 Ruled by Daniel, 2026-08-01
All five forks this doc opened are closed. The rulings are folded into the sections that Every fork this doc opened is closed. The rulings are folded into the sections that
depend on them; this table is the index, not a second copy of the reasoning. depend on them; this table is the index, not a second copy of the reasoning.
| Fork | Question | Ruling | Where it landed | | Fork | Question | Ruling | Where it landed |
@@ -1193,9 +1265,10 @@ depend on them; this table is the index, not a second copy of the reasoning.
| **Γ-F3** | Does the log taper raise the 2 s stage-time ceiling? | **Not in this phase — stays 2.0 s.** The 10 s ambition is preserved as a `docs/TODO.md` entry with its rationale. | §4.3, `docs/TODO.md` | | **Γ-F3** | Does the log taper raise the 2 s stage-time ceiling? | **Not in this phase — stays 2.0 s.** The 10 s ambition is preserved as a `docs/TODO.md` entry with its rationale. | §4.3, `docs/TODO.md` |
| **Γ-F4** | Explicit loop enable? | **Yes — on the CHROME ROW.** Not a deck cell; loop is a waveform-overlay concept and has no deck. | **§6.4** (new), §6.5, §7.9 | | **Γ-F4** | Explicit loop enable? | **Yes — on the CHROME ROW.** Not a deck cell; loop is a waveform-overlay concept and has no deck. | **§6.4** (new), §6.5, §7.9 |
| **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 90 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 | | **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 90 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 |
| **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11, `docs/TODO.md` |
Two of these corrected this doc rather than confirming it, and both corrections are worth Three of these corrected this doc rather than confirming it, and all three corrections are
remembering as pattern: worth remembering as pattern:
- **Γ-F2 inverted the recommendation.** The zero-lookahead pitch weighed "monitoring latency - **Γ-F2 inverted the recommendation.** The zero-lookahead pitch weighed "monitoring latency
on every instance" against limiter transparency — but that trade only existed under the on every instance" against limiter transparency — but that trade only existed under the
@@ -1206,68 +1279,94 @@ remembering as pattern:
belonged to a deck. It does not — nothing about loop belongs to a deck — and once that is belonged to a deck. It does not — nothing about loop belongs to a deck — and once that is
seen, the chrome row is obvious and free. **The reframe was the answer; the fork as posed seen, the chrome row is obvious and free. **The reframe was the answer; the fork as posed
had no good option in it.** had no good option in it.**
- **Γ-F6 was a mis-attributed cost.** The fork was posed as "the SDK mandates an expensive
cycle — is that acceptable?", with a constant-latency fallback and a measurement gate
attached. Daniel's answer — *"you have to have missed something, I used plenty of VST3s
inside of REAPER that report PDC dynamically"* — located the error correctly: the SDK
mandates an *ordinary* cycle, and everything expensive about it is in **our** `setActive`.
The right move was never a fallback; it was to name the self-inflicted cost, accept it now,
and file its reduction. **Before presenting a platform constraint as a fork, check whether
the constraint is the platform's or ours** — the two want completely different answers, one
a product decision and the other a deferred improvement.
### 8.2 Open — one fork, surfaced by the Γ-F2 ruling ### 8.2 Closed — Γ-F6, and what its closure changed
**Fork Γ-F6 `kLatencyChanged` mandates a host deactivate/reactivate. Is that acceptable **Fork Γ-F6 asked: `kLatencyChanged` mandates a host deactivate/reactivate. Is that
as the cost of the limiter toggle?** acceptable as the cost of the limiter toggle?**
This was not visible when Γ-F2 was posed and Daniel did not have it in front of him. The **Ruled: yes — ship it (Daniel, 2026-08-01).** *"Toggling the limiter killing the voices
vendored SDK (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) defines the flag as: *"The isn't a deal breaker though, the limiter will either be on or off on its instance, toggling
host has to deactivate and reactivate the plug-in."* In this plugin, during playback is not a use case."*
`ReaSamplerProcessor::setActive` is destructive in both directions
(`reasampler_processor.cpp:85-109`) — deactivate frees every sounding voice, reactivate
re-decodes the WAV from disk. **So flipping the limiter cuts held notes and reloads the
sample.** Full detail in §3.1.1.
- **(a) Ship it, accept the cut.** The limiter is a patch-design control set once while The two alternatives the fork carried are **closed, not shelved**, and neither is to be
building a sound, not a per-take gesture; the enable is classified **not automatable** so reintroduced:
nothing can flip it at rate; the plugin's own output is crossfaded so *we* emit no step.
Cost: a user who flips it mid-audition loses the note they were holding.
- **(b) Constant reported latency** — the delay line engaged whenever the limiter design
ships, on or off, so the toggle never changes latency and never restarts. Cost: exactly
the thing Daniel's ruling rejected — every instance pays the lookahead in live monitoring
whether or not the limiter is used. Named here as the pre-agreed fallback, not as a
re-litigation.
- **(c) Zero-lookahead** — the original §8 recommendation. Closed; do not reopen it here.
**Recommendation: (a), gated on a DAW measurement.** Ship the dynamic-latency design as - **Constant reported latency** (the delay line engaged whether or not the limiter is on) —
ruled, and make **W1-T2's first deliverable a verification spike in REAPER**: flip the rejected. It is exactly what Γ-F2's ruling refused: every instance paying the lookahead in
limiter with notes held, during playback and while stopped, and record what actually live monitoring whether or not the limiter is used.
happens — whether notes cut, whether the re-decode is perceptible, whether transport - **Zero lookahead** — closed at Γ-F2. Do not reopen it here.
hiccups. If the observed behaviour is as ugly as the SDK's worst case allows, **(b) is the
pre-agreed fallback and needs one word from Daniel, not a redesign** — the DSP is identical
either way and only the latency-reporting predicate changes.
*This is the only open fork in the phase.* Nothing in §§17 is awaiting a Daniel answer. **What the closure changed beyond the ruling**, and why this fork is worth reading rather
than just counting:
1. **§3.1.1 was rewritten, not annotated.** Its prior framing — dynamic latency as exotic and
expensive — was wrong. Dynamic PDC is routine; the expense is our reload-on-activate.
2. **The measurement gate was dropped.** Γ-W1-T2's first deliverable is the limiter, not a
spike. What remains is an *observation* recorded in review as evidence for the deferred
improvement — it gates nothing.
3. **The ≤ 10 ms crossfade survives, reclassified.** It is a quality measure on a gain-path
change, in line with every other ramp in this codebase, not a mitigation for an accepted
interruption.
4. **The verification requirements survive unchanged**, because they were always about the
`kIoChanged` scar (a dual-mono capture panned hard right by a prior mid-session
`restartComponent`), not about this flag.
5. **The reduction is filed**, with a trigger condition, in `docs/TODO.md`.
*No fork in this phase is open. Nothing in §§17 awaits a Daniel answer.*
--- ---
## 9. Build shape ## 9. Build shape
Sequenced into `docs/PLAN.md` as **Phase Γ** (worktree slug prefix `pg-`), four waves: Sequenced into `docs/PLAN.md` as **Phase Γ** (worktree slug prefix `pg-`), **three waves**
(resequenced by Daniel, 2026-08-01 — see below):
``` ```
Γ-W1 Foundations [3 tracks, disjoint by surface] Γ-W1 Foundations [5 tracks, disjoint by surface]
T1 knob-interaction-law ............ item D (editor input + deck_values tapers) T1 knob-interaction-law ............ item D (editor input + deck_values tapers)
T2 master-bus-audio ................ item C (pure limiter + meter ballistics + T2 master-bus-audio ................ item C (pure limiter + meter ballistics +
processor + LATENCY REPORTING) processor + LATENCY REPORTING)
T3 contour-trace-curves ............ item E (waveform painter) T3 contour-trace-curves ............ item E (waveform painter)
T4 editor-floor-and-row-law ........ item B's CANVAS half
(floor + budget constants + row predicate)
T5 preserve-time-stretch ........... item A's engine half [measure-and-report gate]
Γ-W2 New controls, and the overlay's marks [2 tracks] Γ-W2 New controls, and the overlay's marks [2 tracks]
T1 pitch-rate-deck ................. item A (params + engine + deck descriptor) T1 pitch-rate-deck ................. item A (params + engine + deck descriptor)
T2 loop-crossfade-ux ............... item F (waveform painter + pure marker geometry T2 loop-crossfade-ux ............... item F (waveform painter + pure marker geometry
+ the chrome-row loop enable) + the chrome-row loop enable)
Γ-W3 The reflow [1 track] Γ-W3 The reflow [1 track]
T1 deck-reflow ..................... item B + C's UI half T1 deck-reflow ..................... item B's ARRANGEMENT half + C's UI half
Γ-W4 Preserve time-stretch [1 track]
T1 preserve-time-stretch ........... item A's quality half [measure-and-report gate]
``` ```
**Two resequencing decisions, both Daniel's (2026-08-01).**
1. **Item B splits: canvas early, arrangement late.** The window floor, the width budget it
derives from, and each group's row membership land in W1-T4 so every other UI track is
drawn, tested and judged at the final 1190 × 680 window. The two-row layout itself stays in
W3-T1, because it can only be measured once the final PITCH/RATE and MASTER descriptors
exist. The exact seam — what W1-T4 can assert, what it cannot, and what the editor looks
like in between — is in `docs/PLAN.md` at Γ-W1-T4.
2. **The stretcher moved last → first** (W4-T1 → W1-T5). Longest pole, zero UI dependency.
It inverts its relationship with Rate: prerequisite, not successor, which retires the
interim resample-and-cancel path unbuilt (§2.5).
The wave boundaries are collision boundaries, not preferences: `deck_values.cpp` is written The wave boundaries are collision boundaries, not preferences: `deck_values.cpp` is written
by W1-T1 then W2-T1; `editor_paint_waveform.cpp` by W1-T3 then W2-T2; the deck descriptors by W1-T1 then W2-T1; `editor_paint_waveform.cpp` by W1-T3 then W2-T2; `deck_groups.cpp` by
by W2-T1 then W3-T1; and **one params-payload version bump per wave, owned by one track** W1-T4 (the row predicate) then W2-T1 (the descriptor) then W3-T1 (the row consumption);
(W1-T2 takes v14 for the limiter flag, W2-T1 takes v15 for rate + pitch offset) so no two `voice.cpp` by W1-T5 then W2-T1; and **one params-payload version bump per wave, owned by one
tracks contend for the format ladder. The Γ-F4 ruling adds a chrome surface to W2-T2 and one track** (W1-T2 takes v14 for the limiter flag, W2-T1 takes v15 for rate + pitch offset) — the
named shared file inside W2 — **§7.9**, which restates the wave's disjointness claim rather two new W1 tracks take **no rung at all**, so the ladder is unchanged by the resequencing.
than repeating it. Full track specs, dependencies and acceptance criteria are in Two shared files are named rather than discovered at merge:
`docs/PLAN.md`. `core/instrument/engine/CMakeLists.txt` inside W1 (T2 | T5) and `editor_session.cpp` inside
W2 (T1 | T2, **§7.9**) — both textual adjacency, not semantic contention. Full track specs,
dependencies and acceptance criteria are in `docs/PLAN.md`.
+12 -5
View File
@@ -197,11 +197,18 @@ Two corollaries the parameter work must carry rather than rediscover:
flag must survive that cycle. flag must survive that cycle.
Full reasoning, the SDK quotes, and the required verification steps are in Full reasoning, the SDK quotes, and the required verification steps are in
`docs/product/instrument-control-surface.md` §3.1.1. **One fork remains open there `docs/product/instrument-control-surface.md` §3.1.1. **Fork Γ-F6 is now RULED (Daniel,
(Γ-F6)** — whether the deactivate/reactivate cost is acceptable in REAPER, with constant 2026-08-01): dynamic latency ships as specced and the deactivate/reactivate is accepted.**
reported latency as the pre-agreed fallback. If that fallback is taken, the limiter enable There is no constant-reported-latency fallback that option is closed, not shelved — so
becomes automatable again and this section shrinks to a footnote; check which way it went **this section does not shrink to a footnote and the limiter enable does not become
before writing the parameter list. automatable.** Plan against the not-automatable classification; it is settled.
**One future condition could reopen it, and it is worth knowing about.** The restart is only
expensive because *this plugin's* `setActive(true)` re-decodes the WAV — not because the SDK
requires it. `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") files that
reduction, and **"the limiter enable is wanted automatable" is one of its named trigger
conditions.** If the parameter work genuinely needs that lane, the answer is to do the
decoupling first, not to re-litigate the classification.
--- ---