From 256216d6702f066e493fe7abfcfee2332098c169 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 16:51:04 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20close=20=CE=93-F6=20and=20resequence=20?= =?UTF-8?q?Phase=20=CE=93=20into=20three=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/PLAN.md | 557 +++++++++++++++------ docs/TODO.md | 52 ++ docs/product/instrument-control-surface.md | 287 +++++++---- docs/product/parameter-automation.md | 17 +- 4 files changed, 658 insertions(+), 255 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 8ad4cbd..741d325 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 span, and start frame still trigger a full reload). **Mid-stage rule = candidate (iv), hold normalized stage position** (φ = elapsed/duration held fixed across a duration -change, then advancing at 1/newDuration). **Phase Γ opened five [Daniel]-class forks (Γ-F1…Γ-F5) and all five are ruled** (Daniel, -2026-08-01) — the rulings are folded into the tracks below and indexed in -`docs/product/instrument-control-surface.md` §8.1. **One new fork, Γ-F6, is open**: it was -surfaced by the Γ-F2 ruling out of the vendored VST3 SDK, and Daniel has not seen it. It sits -on Γ-W1-T2, is **not blocking** — it is gated on a DAW measurement that track performs first -— and it has a pre-agreed fallback. No other track in this plan carries an unanswered -**[Daniel]**-class question. +change, then advancing at 1/newDuration). **Phase Γ opened six [Daniel]-class forks +(Γ-F1…Γ-F6) and all six are ruled** (Daniel, 2026-08-01) — the rulings are folded into the +tracks below and indexed in `docs/product/instrument-control-surface.md` §8. **Γ-F6 closed +with a correction to the analysis, not merely a ruling**: dynamic reported latency is routine +for VST3 instruments and REAPER handles it as a matter of course; what makes the mandated +restart expensive *here* is self-inflicted (`setActive(true)` calls `reloadInstrument`), so +the cost is ours to reduce and the reduction is filed in `docs/TODO.md` rather than designed +around. **No track in this plan carries an unanswered [Daniel]-class question.** ### 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 phase is derived there, and `docs/TODO.md`'s old deck-rework geometry is superseded. -**Fork state.** The phase's five original forks are **all ruled** (Daniel, 2026-08-01) — -indexed at spec §8.1, folded into the tracks below: +**Fork state — all six forks are ruled; nothing in this phase awaits Daniel.** Indexed at +spec §8, folded into the tracks below: - **Γ-F1** — `kEditorMinHeight` stays **680**. - **Γ-F2** — the limiter has **lookahead with DYNAMIC reported latency** (zero when off, the lookahead when on, reported to host PDC). *This inverted the product recommendation;* @@ -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. - **Γ-F5** — MASTER's reserved slot is **one** cell. The 90 px headroom argument behind that is spec §1.6 and governs every future control addition. +- **Γ-F6** — **ship dynamic latency as ruled.** The `restartComponent(kLatencyChanged)` + deactivate/reactivate the SDK mandates is accepted: *"the limiter will either be on or off + on its instance, toggling during playback is not a use case."* No constant-latency + fallback, no measurement gate. *This ruling also corrected the analysis* — spec §3.1.1. -**One fork remains open — Γ-F6, on W1-T2** (spec §8.2): the VST3 SDK defines -`restartComponent(kLatencyChanged)` as a host **deactivate/reactivate**, and this plugin's -`setActive` is destructive in both directions. Not blocking; gated on a DAW measurement W1-T2 -performs first, with constant reported latency as the pre-agreed fallback. +**What the Γ-F6 ruling changed in the analysis, not just in the plan.** Dynamic latency +reporting is **routine** for VST3 instruments and REAPER handles it as a matter of course; +the SDK's deactivate/reactivate requirement (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) +is the normal contract, not an exotic one. What makes the cycle expensive **here** is entirely +our own doing: `ReaSamplerProcessor::setActive(true)` calls `reloadInstrument()` — a bridge +read plus a full WAV re-decode (`reasampler_processor.cpp:89-97`) — where a typical plugin's +`setActive` only allocates and frees buffers, and the deactivate side's freeing of +`live_`/`draining_`/graveyard (`:98-107`) is likewise our own design. **The cost is therefore +ours to reduce if it ever matters, and the reduction is decoupling reload from activation — +not abandoning dynamic latency.** That improvement is filed as a `docs/TODO.md` entry with its +trigger condition; it is not scheduled in this phase. **Sequencing against Phase Ξ — Γ runs BEFORE Ξ-W2.** Two reasons, both the same shape as Ξ'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 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` -(the contour trace, then the loop marks), the deck descriptors in `ui/deck_groups.cpp` + -`ui/knob_deck.cpp` (the new group, then the reflow), and the params-payload ladder. **Every -wave boundary below is one of those collisions**, not a preference. Where a wave has more -than one track, the tracks are disjoint by surface. +(the contour trace, then the loop marks), `ui/deck_groups.cpp` (the row predicate, then the +PITCH/RATE descriptor, then the reflow's row consumption — three tracks, three waves), +`engine/voice.cpp` (the Preserve read path, then the rate compounding into it), and the +params-payload ladder. **Every wave boundary below is one of those collisions**, not a +preference. Where a wave has more than one track, the tracks are disjoint by surface. + +**Resequenced 2026-08-01 (Daniel), 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): - **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 load-bearing in the v12 wire format. The new Pitch knob **reads** it; it does not mint a second ±24 constant. -- **The window floor is 1190 × 680 and must not exceed 1280 × 720.** A track that pushes the - floor past 1280 has failed, not overrun. **`kEditorMinHeight` stays 680** (Γ-F1). The - remaining **90 px of width headroom is the budget for the life of this layout** — one deck - cell is 60 px, so there is room for exactly one more, once. Spec §1.6 states the ledger; - read it before adding any control. Chrome-row additions are a **separate purse** (they are - paid for out of the title slot, not the floor) and must not be charged against this one. +- **The window floor is 1190 × 680 and must not exceed 1280 × 720.** **Γ-W1-T4 sets it, in + wave 1; no other track in the phase may move it**, and from that point every track is + authored and judged at it. A track that pushes the floor past 1280 has failed, not overrun. + **`kEditorMinHeight` stays 680** (Γ-F1). The remaining **90 px of width headroom is the + budget for the life of this layout** — one deck cell is 60 px, so there is room for exactly + one more, once. Spec §1.6 states the ledger; read it before adding any control. Chrome-row + additions are a **separate purse** (they are paid for out of the title slot, not the floor) + and must not be charged against this one. - **Reported latency is zero unless the limiter is on.** `getLatencySamples()` returns 0 with the limiter bypassed, in every track and at every point in the phase. Only W1-T2 may introduce a non-zero value, and only under the limiter-on condition. @@ -716,9 +761,32 @@ than one track, the tracks are disjoint by surface. ### Γ-W1 — Foundations -**Depends on:** nothing in this phase. All three tracks are disjoint by surface — T1 owns -editor input and the value/taper layer, T2 owns the engine and processor, T3 owns the -waveform painter's envelope trace. None touches the deck descriptors. +**Depends on:** nothing in this phase. **Five tracks, disjoint by surface** — re-verified +against this membership rather than carried over from the four-wave shape: + +| Track | Owns | +|---|---| +| **T1** `knob-interaction-law` | `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` @@ -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. **Spec:** `docs/product/instrument-control-surface.md` §3.1, **§3.1.1 (latency — read this -first)**, §3.2–3.3, §3.5, §7.10, §8.2. +first; it was rewritten when Γ-F6 closed, so an older reading of it is wrong)**, §3.2–3.3, +§3.5, §7.10, §7.11, §8.2. **Surface boundary — owns:** a new pure limiter module and a new pure meter-ballistics module under `core/instrument/engine/` (each with its own `_tests` target), @@ -816,11 +885,20 @@ module under `core/instrument/engine/` (each with its own `_tests` targe there is no `getLatencySamples` override, no `kLatencyChanged`, and no `restartComponent` call site anywhere in `src/`; the plugin ships the SDK default of 0. This track introduces the plugin's first latency reporting. -- **The restart is the risky part and it is fenced.** The SDK defines `kLatencyChanged` as a - host **deactivate/reactivate** (`pluginterfaces/vst/ivsteditcontroller.h:105-108`), and this - plugin's `setActive` is destructive in both directions (`reasampler_processor.cpp:85-109`) — - deactivate frees every sounding voice, reactivate re-decodes the WAV. Four requirements, all - acceptance criteria: +- **The restart is routine; the fencing is against a standing scar, not against the flag.** + Dynamic latency reporting is ordinary VST3-instrument behaviour and REAPER handles it as a + matter of course. The SDK's deactivate/reactivate requirement + (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) is the normal contract. **What makes the + cycle expensive here is this plugin's own `setActive`** — reactivate calls + `reloadInstrument()`, a bridge read plus a full WAV re-decode + (`reasampler_processor.cpp:89-97`), where a typical plugin only allocates buffers; deactivate + frees `live_`/`draining_`/graveyard (`:98-107`) for a documented reason (ghost sustained + voices). **Γ-F6 is ruled: ship it — the toggle is a patch-design gesture, not a + during-playback one.** Do **not** build a constant-reported-latency fallback and do **not** + gate the deliverable on a measurement. The reduction of that self-inflicted cost is filed in + `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") with its trigger + condition; it is out of scope here. The four requirements below survive as engineering + hygiene against the `kIoChanged` scar, and all four are acceptance criteria: 1. **Verify the whole call sequence against the vendored Steinberg SDK** before writing it, including the ordering rule that the new latency is what `getLatencySamples` returns *after* `setActive(true)` — so **the reported value must derive from persisted state, not @@ -839,11 +917,17 @@ module under `core/instrument/engine/` (each with its own `_tests` targe - **Flipping the toggle during playback: apply immediately, do NOT defer to a transport boundary** (product ruling, spec §3.1.1). A deferred restart leaves the plugin misaligned by the lookahead with no visible cue, which is worse than a visible interruption; and the host, - not the plugin, schedules the deactivate/reactivate anyway. Two mitigations are in scope: - the engage/disengage is covered by a short (≤ 10 ms) equal-gain crossfade so **the plugin - emits no discontinuity of its own**, and the limiter enable is classified **not automatable** - (`docs/product/parameter-automation.md` §3.8) so nothing can flip it at rate. It is also - **not** the plugin's `kIsBypass` parameter. + not the plugin, schedules the deactivate/reactivate anyway. **Daniel has accepted the + interruption outright** (Γ-F6) — it is not a case to design for. Two things remain in scope, + and neither is a mitigation for it: + - **A short (≤ 10 ms) equal-gain crossfade over the engage/disengage.** Kept as a *quality* + measure, not a mitigation: a limiter engaging is a gain-path change, and this codebase + already ramps every gain-path change (`kGainRampSeconds`, `ValueRamp`). It also earns its + keep independently of the restart, because **we do not control when the host acts on the + request** — our own transition must be clean in the window before it does. + - **The limiter enable is classified NOT automatable** + (`docs/product/parameter-automation.md` §3.8) so nothing can flip it at rate. It is also + **not** the plugin's `kIsBypass` parameter. - **Per block the processor publishes, as relaxed atomics:** per-channel peak `max|x|`, a latched clip flag, and the block's maximum gain reduction. **No dB conversion, no ballistics, no hold timers on the audio thread** — the UI converts and runs ballistics from @@ -877,23 +961,18 @@ module under `core/instrument/engine/` (each with its own `_tests` targe on a rendered signal, not judged by ear. **Open questions.** -- **[Daniel] fork Γ-F6 — is the deactivate/reactivate cost acceptable?** (spec §8.2.) **Not - blocking, and this is the track's FIRST deliverable:** a verification spike in REAPER — - flip the limiter with notes held, during playback and while stopped, and **record what - actually happens** (do notes cut? is the re-decode perceptible? does transport hiccup?). - The SDK mandates the deactivate/reactivate; what REAPER does with it is DAW-verifiable only. - Ship the ruled dynamic-latency design regardless; if the observed behaviour is as ugly as - the SDK's worst case allows, the **pre-agreed fallback is constant reported latency** (the - delay line engaged whether or not the limiter is on), which needs one word from Daniel and - **no DSP change** — only the latency-reporting predicate moves. Report the measurement to - Daniel with a recommendation; do not choose the fallback unilaterally. +- **No [Daniel] questions. Fork Γ-F6 is ruled** — dynamic latency ships as specced, the + deactivate/reactivate is accepted, and there is no fallback design and no measurement gate. + Do not reintroduce either; the constant-reported-latency option is closed, not shelved. - **[verify]** `temp_cortex/` has already been assessed and **rejected** (spec §3.5) — do not re-litigate it, and do not transplant from it. -- **[verify]** whether `setActive(true)`'s `reloadInstrument()` can reuse the already-decoded - `SampleData` on a latency-restart reactivate rather than re-reading the bridge and - re-decoding the WAV. If it cannot cheaply, that is an **accepted cost to be recorded**, not - a reason to restructure `setActive` — its destructive shape is deliberate and its reasoning - (ghost sustained voices on reactivate) is documented at the call site. +- **[record, not a gate]** While the limiter is in REAPER under your hand, note what the + restart actually costs — do notes cut, is the re-decode perceptible, does transport hiccup — + and record it in this track's review. It is **not** a gate on shipping and no outcome changes + the design; it is the trigger-condition evidence for the `docs/TODO.md` entry "Decouple the + instrument reload from VST3 activation," which is where that cost gets reduced if it ever + matters. Do **not** restructure `setActive` here: its destructive shape is deliberate and its + reasoning (ghost sustained voices on reactivate) is documented at the call site. #### Γ-W1-T3 — `contour-trace-curves` @@ -927,24 +1006,217 @@ diverge; tessellation approach is the engineer's call. are honoured unchanged. The spline overlay's own trace is untouched. - **Audio is unchanged** — this is a drawing defect only; a regression baseline proves it. +#### Γ-W1-T4 — `editor-floor-and-row-law` + +**Goal.** Commit the **canvas** — the window floor, the width budget it is derived from, and +the row every deck group belongs to — so every other UI track in the phase is drawn, tested and +judged at the final window size. The **arrangement** inside that canvas is Γ-W3-T1's. + +**Spec:** `docs/product/instrument-control-surface.md` §1.1 (the two categories), §1.2 (the +floor arithmetic block), §1.6 (the headroom ledger), §7.1 and §7.4 (the two invalidated +`knob_deck.h` notes). + +**Surface boundary — owns:** `core/instrument/ui/sample_bands.h` (`kEditorMinWidth`), +`core/instrument/ui/knob_deck.h` (the declared budget constants and the two invalidated header +notes), `core/instrument/ui/deck_groups.{h,cpp}` (**the new row predicate only**), and the five +test fixtures that read the floor — `test_sample_bands.cpp`, `test_deck_groups.cpp`, +`test_knob_deck.cpp`, `test_sample_chrome.cpp`, `test_keyboard_strip.cpp`. **Does not own** +`layoutDeck` / `deckRowCount` / `deckHeight` behaviour, the justification law, any descriptor, +MASTER's inventory or interior, any painter, or any parameter. It changes **no drawing code at +all.** + +**Behavior — what it commits.** +- **`kEditorMinWidth` 980 → 1190. `kEditorMinHeight` stays 680** (Γ-F1). +- **Three declared budget constants in `knob_deck.h`:** the row block both rows will justify + inside (**1020**), the right-anchored spanning deck's reserved width (**MASTER 142**), and + the ceiling (**1280**). These are *declarations of budget*, not measurements — nothing + computes them from a descriptor, and Γ-W3-T1's job is to prove its content fits inside them. +- **The floor is derived, not asserted as a literal.** `1020 + kDeckGroupGap(12) + 142 + + 2·kPad(8) = 1190`. `kEditorMinWidth` stays a literal in `sample_bands.h` — **do not add an + include edge from `sample_bands` to `knob_deck`**, which would invert the allocator's + deliberate independence from the deck (it takes `deckHeight` as a *parameter* for exactly + that reason). The identity is asserted in `test_deck_groups.cpp`, which already includes + both headers. This is the Θ-W6-T1 derived-floor precedent, landed once and never rewritten. +- **Row membership becomes a property of the group id:** `DeckRow { Sound, Contour, Spanning }` + + `deckRowFor(DeckGroupId)` in `deck_groups`, an **exhaustive switch** on the + `isLiveDeckParam` discipline, so a future group is a compile error rather than a silent + default. Partition: **Sound** = PITCH/RATE, FILTER, VELOCITY, VOICE; **Contour** = PITCH ENV, + FILTER ENV, AMP ENVELOPE; **Spanning** = MASTER. **Nothing consumes it until Γ-W3-T1** — that + is the seam, and it is why the predicate is safe to land now: **membership is a property of + the group, width is a property of the descriptor**, and only the widths are still moving. +- **Γ adds no new deck group**, so no later track amends this predicate. + +**The seam, stated as what this track can and cannot assert.** + +*Can assert today:* +- The derived floor identity above, and `kEditorMinWidth ≤ 1280` with **90 px** of headroom. +- `kEditorMinHeight == 680`, asserted so no later track drifts Γ-F1's ruling. +- `deckRowFor` is total over `DeckGroupId` and yields exactly the partition above. +- **Row 2's natural width already fits the block, in both play modes:** + 252 + 312 + 312 = **876 ≤ 1020**, leaving both its gutters ≥ `kDeckGroupGap`. Mode-stable + because FILTER ENV's and AMP's reserve slots hold them at 312 in Gate and Trigger alike. +- **MASTER's reserve is not yet spent:** `deckGroupWidth(MASTER) == 72 ≤ 142`. +- At the floor, deck band **216** and waveform band **358** — the reflow's 112 px arrives here, + two waves early (see the interim layout below). + +*Cannot assert yet, and must not force:* +- **Row 1's natural width does not fit the block.** Today it is PITCH 150 + FILTER 524 + + VELOCITY 192 + VOICE 164 = **1030**, against the 1020 block. The 50 px deficit is exactly + what the two descriptor changes buy: PITCH → PITCH/RATE **+42** (Γ-W2-T1) and FILTER's + `Band|Notch` moving to the caption corner **−92** (Γ-W3-T1), netting **980**. Record the + target and the two contributions as a test comment; **assert the fit in Γ-W3-T1, and do not + pre-empt either descriptor change to close it early.** +- Gutter distribution, the filter tie-line at x = 636, flush outer edges, MASTER's interior and + its meter — all Γ-W3-T1. Every one of them measures a descriptor that does not exist yet. + +**The interim editor, stated exactly so it is not filed as a defect.** At the new floor, +`availWidth = 1190 − 2·kPad = 1174`, and the **unchanged** greedy whole-group wrap packs: + +``` +row 1 PITCH 150 · PITCH ENV 252 · FILTER 524 = 950 used, 224 px ragged right +row 2 FILTER ENV 312 · AMP 312 · VELOCITY 192 · + VOICE 164 · MASTER 72 = 1100 used, 74 px ragged right +``` + +**Two rows, not three** — so the deck band is already 216 and the waveform already draws at its +final 358 px, in both Gate and Trigger. After Γ-W2-T1 lands PITCH/RATE the pack is row 1 = 992, +row 2 unchanged; still two rows. The composition is wrong in exactly the way the reflow exists +to fix — PITCH ENV sits up with the sound decks, VOICE and MASTER sit down with the envelopes, +MASTER is still a single-height 72 px box, and both rows are left-packed with dead space at the +right. **Worse than today in composition, better in proportion.** That is the accepted +transitional state for the rest of the phase. + +**Do not convert the two-row interim into a claim.** It is a coincidence of the greedy wrap at +exactly this width, not a guarantee — which is precisely why Γ-W3-T1's criterion is "two rows +**by construction**, asserted against the group inventory, not observed as a wrap outcome." +`testDeckFitsInsideTheEnforcedMinimumWindow` currently asserts `deckRowCount == 3`; relax it to +an **upper bound** (`<= 2`), which is a real regression canary throughout the interim and is +subsumed by Γ-W3-T1's exact claim. An exact `== 2` here is acceptable only with a comment +naming it as a wrap outcome the reflow replaces. + +**Acceptance criteria.** +- **The floor is 1190 × 680, reached by a derived test over the three budget constants**, not + by a literal — and the derivation is the one Γ-W3-T1 later reads rather than a second copy. +- **Headroom is exactly 90 px** against the 1280 ceiling, asserted. +- `deckRowFor` is exhaustive over `DeckGroupId`; adding a group without classifying it fails to + compile. +- Row 2's natural width and MASTER's unspent reserve are asserted, in **both** play modes. +- **All five floor-reading test fixtures pass at the new floor** — including the chrome row, + whose title slot gets *more* room at 1190, not less. +- **No drawing code changes, no descriptor changes, no parameter changes, no audio change.** + A regression baseline proves the last of those trivially. +- The two invalidated `knob_deck.h` notes (§7.1's fourteen-pixel headroom figure, §7.4's + cells-and-floor pairing) are **re-derived against the new floor, not deleted** — §7.4's + restatement is *the deck's cell metrics AND its group/row composition both drive + `kEditorMinWidth`; none of the three may move alone.* + +**Open questions.** **No [Daniel] questions.** **[propose at review]** whether the three budget +constants belong in `knob_deck.h` (the deck owns the row block and the spanning-deck reserve) +or in `sample_bands.h` (the allocator owns the floor they derive). The plan's lean is +`knob_deck.h` with the identity in the test, because it adds no include edge; either is +defensible, but the *derivation must live in exactly one place*. + +#### Γ-W1-T5 — `preserve-time-stretch` + +**Goal.** A real pitch-preserving time-stretcher for Preserve mode, written from established +state-of-the-art literature — landed **before** the control that drives it, so Rate ships onto a +finished engine rather than onto a disposable stand-in. + +**Spec:** `docs/product/instrument-control-surface.md` §2.5. + +**Moved from Γ-W4-T1 (Daniel, 2026-08-01).** It is the longest pole in the phase and has zero +dependency on any UI work. **The consequence is the interesting one: it inverts the +relationship with Rate.** Under the old order the stretcher was Rate's quality upgrade and +Γ-W2-T1 shipped an interim resample-and-cancel path to make Rate complete on day one; under +this order the stretcher is Rate's **prerequisite** and **the interim path is not built at +all.** Skipping a stand-in that was only ever going to be deleted is the win; see Γ-W2-T1's +named contingency for what happens if this track's gate slips. + +**Precedent for landing a DSP module ahead of its consumer:** Θ-W1-T3 (`filter-dsp-port`) +landed the filter DSP as a standalone pure module a wave before Θ-W2-T1 wired it into the voice +path, for the same reason — the unknown is the DSP, not the wiring. + +**Surface boundary — owns:** `core/instrument/engine/pitch_shift` and whatever new pure module +the stretcher needs (each with its own `_tests` target), plus `voice.{h,cpp}`'s Preserve +read path. **Does not own** any parameter, any UI, the varispeed path, or the deck. It adds no +`ComponentState` field and takes **no rung of the payload ladder**. + +**Behavior and constraints.** The algorithm is **the engineer's call under a +measure-and-report gate — this plan deliberately names none.** The constraints: +- **The stretch ratio is an argument, not a parameter.** Nothing publishes a non-unity ratio + until Γ-W2-T1's Rate knob does. Until then the Preserve read path runs at ratio 1.0 and must + be **bit-identical to the shipped Preserve read** — a stronger and cheaper regression gate + than the old plan's A/B-against-an-interim-path, because the baseline is a build that exists. +- **CPU stance (Daniel, verbatim intent):** *"we should be efficient but accept the cost of + high-quality algorithm choices. It's 2026, most people's computers can handle audio with + ease. Just don't be wasteful."* +- **RT-safe:** no allocation, no I/O, no lock in `process()`; buffers sized at voice + allocation or at the off-audio-thread reload, on `pitch_shift`'s existing pre-warm + precedent. +- **Per-voice state, holding up at the 32-voice ceiling.** The gate is 32 simultaneous + Preserve voices at 50 % and 200 %, not one voice at 100 %. +- **No new third-party dependency** (`pitch_shift`'s standing property). +- **No dispatch on the per-sample path** — concrete, inlineable types; no `IStretcher`. +- **Onset behaviour is a regression surface.** GA2 eliminated Preserve's ~25 ms onset latency + by priming the ring with the actual upcoming source. **A stretcher that reintroduces an + onset delay or a first-frame smear is a regression, not a trade-off.** + +**Acceptance criteria.** +- **Ratio 1.0 with no shift is bit-identical to the shipped Preserve read**, asserted by a + regression baseline — the null case, and the criterion that makes landing this ahead of Rate + safe. +- Preserve speaks on frame 0 — no added onset latency, no first-frame smear, in any + ratio/shift combination. +- No audible metallic or phasey artefacting on sustained tonal material at ±6 st and + 75–133 % ratio; transient material at 50 % / 200 % is no worse smeared than **varispeed + playback at the equivalent ratio** — the honest "what does preserving pitch cost" reference, + and the one that needs **no disposable implementation built to serve the comparison.** +- The Gate sustain-loop contract is unchanged: **loop the source, shift the output** — loop + points remain source-frame facts. +- **Measure and report before the algorithm is final:** per-voice CPU at 32 voices, added + latency (must be zero at the onset), and A/B recordings on three material classes (one-shot, + tonal sustain, full-mix bounce). Report to Daniel; the choice is not final until he has heard + the A/Bs. + +**Open questions.** **[propose, with a measurement step]** the algorithm family itself. +**[verify]** that `core/instrument/CLAUDE.md`'s *"`WDL_Resampler` is not a Preserve engine — +never wire it as the duration-preserving path"* is honoured: under Preserve, Rate legitimately +changes duration, so a resampled read is an explicit duration control — but the +*pitch-preserving* mechanism must not be a resampler. **No [Daniel] questions.** + --- ### Γ-W2 — New controls, and the overlay's marks -**Depends on Γ-W1 for:** T1 depends on W1-T1 — Rate and Pitch must be authored into the -finished taper/modifier law, not retro-fitted into it (and the semitone taper must exist -before a second semitone knob does). T2 depends on W1-T3 — both write -`editor_paint_waveform.cpp`, and running them together is a merge fight in one file. +**Depends on Γ-W1 for — four dependencies, two of them new:** +1. **T1 ← W1-T1 (taper law).** Rate and Pitch must be authored into the finished + taper/modifier law, not retro-fitted into it, and the semitone taper must exist before a + second semitone knob does. +2. **T1 ← W1-T5 (the stretcher) — NEW, and the reason the interim path is gone.** Preserve + Rate has no engine without it. Under the prior four-wave order this dependency ran the other + way and was paid for with a disposable resample-and-cancel stand-in; the resequencing + inverts it. **Rate must not ship before its Preserve engine.** +3. **T2 ← W1-T3 (the contour trace).** Both write `editor_paint_waveform.cpp`; running them + together is a merge fight in one file. +4. **T2 ← W1-T1 and W1-T4, weakly.** W1-T1 also edits `editor_input_waveform.cpp` (the + modifier read), which T2 rewrites for marker hit-test routing — serial, so not a conflict, + but T2 rebases onto it. And T2's "the enable costs no window width" criterion is now + asserted against **W1-T4's** derived floor test rather than one this track has to write. -**Disjointness — restated after the Γ-F4 ruling, because it changed.** T1 owns the parameter -model, the engine and the deck descriptors; T2 owns the waveform band's marks and their pure -geometry **and now also the chrome row's loop enable**. The two are disjoint at the module -level with **one named exception: `shell/instrument/editor_session.cpp`.** T1 may touch it for -the third commit tier's routing; **T2 owns `pickedMarkers` and `applyMarkers` there and nothing -else.** The partition is by function and the two do not overlap — this is a textual merge -adjacency, not a semantic contention — but it is a shared file in a phase whose wave boundaries +T1 additionally inherits `engine/voice.{h,cpp}` from W1-T5 — a **hand-off, not a conflict**: +W1-T5 defines the Preserve ratio seam, and T1 feeds it. Serial across waves by construction. + +**Disjointness — re-verified against this wave's membership, not carried over.** Both tracks +stayed in W2 and nothing entered or left it, so the prior finding is re-checked and stands. T1 +owns the parameter model, the engine and the deck descriptors; T2 owns the waveform band's +marks and their pure geometry **and the chrome row's loop enable**. The two are disjoint at the +module level with **one named exception: `shell/instrument/editor_session.cpp`.** T1 may touch +it for the third commit tier's routing; **T2 owns `pickedMarkers` and `applyMarkers` there and +nothing else.** The partition is by function and the two do not overlap — **textual merge +adjacency, not semantic contention** — but it is a shared file in a phase whose wave boundaries are otherwise single-writer surfaces, so it is stated rather than discovered at merge. Whichever -track lands second rebases onto the first. +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 `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/ui/deck_groups` (the PITCH/RATE descriptor **and** the three-state live predicate), `core/instrument/ui/deck_values` (the two new bindings). **Does not own** the -deck's row layout — that is Γ-W3-T1 — nor the real time-stretcher (Γ-W4-T1). +deck's row layout — that is Γ-W3-T1 — nor the time-stretcher itself (Γ-W1-T5, already landed +by the time this track runs). **Behavior.** - **Rate: 50 %–200 %, default 100 % at true knob centre, exponential taper** — 50 % = −12 st, @@ -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 the pitch envelope's existing per-frame `ratio_` multiply — **no new per-sample stage**. - **Preserve:** rate is an **absolute** value driving **duration only**; keytrack and pitch - offset drive the pitch shifter. **Interim implementation:** a resampled read with the - resulting pitch change cancelled in the existing SOLA shifter. This is an explicit duration - control, **not** a covert Preserve path — `core/instrument/CLAUDE.md`'s "never wire - `WDL_Resampler` as the duration-preserving path" is untouched. Quality is Γ-W4-T1's. + offset drive the pitch shifter. **Rate drives the stretch ratio Γ-W1-T5's stretcher already + consumes — there is no interim path.** The stretcher is this track's prerequisite, not its + successor; the resample-and-cancel stand-in the prior plan carried is retired unbuilt (see + Open questions for the contingency). `core/instrument/CLAUDE.md`'s "never wire + `WDL_Resampler` as the duration-preserving path" is honoured by construction. - **Rate is latched at note-on**, delivered by a **third commit class**: published into the live block like any live parameter, read only by `snapLive`, never by `applyLive`. `isLiveDeckParam`/`liveCommitFor` widens from two states to three @@ -1005,7 +1279,9 @@ deck's row layout — that is Γ-W3-T1 — nor the real time-stretcher (Γ-W4-T1 **Acceptance criteria.** - Rate at 50 % plays an octave down and half speed under Varispeed; at 200 %, an octave up and double speed. Under Preserve the same settings change duration only — pitch is unchanged - within the shifter's tolerance. + within the stretcher's tolerance. **Preserve Rate is a finished feature the day this lands**, + because Γ-W1-T5 already shipped its engine; a degraded or inert Preserve Rate is a failed + track, not an acceptable interim. - Rate at exactly 100 % and Pitch at exactly 0 st render **bit-identical** to the pre-change build, in both engines. - Shift-drag on Rate lands on whole semitones (so an octave and a fifth are reachable by @@ -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. - A v14 project reopens at rate 100 % / pitch 0 st and sounds identical. -**Open questions.** None **[Daniel]**. **[propose at review]** the exact interim -resample+cancel composition and its clamp behaviour at the range extremes, which Γ-W4-T1 then -replaces. +**Open questions.** +- **None [Daniel].** +- **[propose at review]** Rate's clamp behaviour at the range extremes as it meets the + stretcher's own ratio bounds — one clamp, resolved where the two meet, not two that can + disagree. +- **Named contingency, not a plan item, and not to be taken silently.** If Γ-W1-T5's + measure-and-report gate has not passed when this track is ready to dispatch, the pre-agreed + fallback is the **resample-and-cancel composition** spec §2.5 records — a resampled read with + the resulting pitch change cancelled in the existing SOLA shifter — shipped as an interim + Preserve path with the stretcher as its later quality upgrade, i.e. a return to the prior + four-wave order. **Escalate to Daniel rather than taking it:** it revives a disposable + implementation and re-opens the `WDL_Resampler` guardrail conversation, and the whole point of + the resequencing was to avoid building it. #### Γ-W2-T2 — `loop-crossfade-ux` @@ -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 three-cell group, and laying it out against a forecast of that group means re-measuring -afterward. **Depends on Γ-W1 for:** W1-T2's published meter/GR/clip state, which MASTER's -deck draws; drawing against a stub would mean building the meter twice. +afterward. **This is the whole reason the arrangement is late**, and it is why the canvas was +split out of it into W1-T4. -**One track.** The row law, the group inventory, the double-height deck and the window floor -are one geometry decision spread over `knob_deck`, `deck_groups`, `sample_bands` and the deck -painter. Splitting it would put two tracks in the same three pure modules. +**Depends on Γ-W1 for:** W1-T2's published meter/GR/clip state, which MASTER's deck draws +(drawing against a stub would mean building the meter twice), and W1-T4's floor, budget +constants and row predicate, which 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` @@ -1184,10 +1475,12 @@ measured table **and §1.6, the headroom ledger**) and §3.2–3.3 (what MASTER the invariants this track invalidates or widens — read it before touching `knob_deck.h`.** **Surface boundary — owns:** `core/instrument/ui/knob_deck` (the row law, the double-height -group, the justification), `core/instrument/ui/deck_groups` (row assignment, FILTER's toggle -move, MASTER's inventory), `core/instrument/ui/sample_bands` (`kEditorMinWidth`), and -`shell/instrument/editor_paint_deck` (the MASTER meter/limiter/bubble draw). **Does not own** -any parameter, the limiter DSP, or the waveform band. +group, the justification — **consuming** W1-T4's budget constants, not restating them), +`core/instrument/ui/deck_groups` (consumption of W1-T4's row predicate, FILTER's `Band|Notch` +caption move, MASTER's inventory), and `shell/instrument/editor_paint_deck` (the MASTER +meter/limiter/bubble draw). **Does not own** `kEditorMinWidth` or any budget constant — those +are W1-T4's and are **read**, never moved — nor any parameter, the limiter DSP, or the +waveform band. **Behavior.** - **Row 1 (sound), one row, non-negotiable:** PITCH/RATE 192 · FILTER 432 · VELOCITY 192 · @@ -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 `kDeckGroupGap` all hold at 1020 and only at 1020 — **this is why the floor is 1190 and not 1186.** Above the floor the tie-line drifts and that is accepted (spec §1.3). -- **`kEditorMinWidth` 980 → 1190**; `kEditorMinHeight` **stays 680** (Γ-F1, ruled — the - reflow's 112 px goes entirely to the waveform); deck band height 328 → **216**; waveform - band at the floor 246 → **358**. +- **The floor is already 1190 × 680 and the bands are already 216 / 358** — Γ-W1-T4 landed all + four in wave 1, and the greedy wrap happened to reach two rows at that width. **This track + changes none of those numbers; it makes them true by construction instead of by coincidence.** + Row 1's natural width fits the block **only after this track's `Band|Notch` move**: 1030 + today, +42 from W2-T1's PITCH/RATE, −92 here, = **980**. That is this track's fit assertion + and W1-T4 deliberately left it open. - **The 90 px of remaining headroom is the budget for the life of this layout**, and one deck cell is 60 px. **This is why MASTER's reserved slot is ONE cell** (Γ-F5, ruled): two would spend 60 of the 90 up front on a control nobody has named, leaving 30 — which would freeze @@ -1248,9 +1544,13 @@ any parameter, the limiter DSP, or the waveform band. 312 in both modes — assert it). - Row 1 and row 2 are **flush left and flush right**; at the floor width the filter tie-line is exact (both edges at x = 636) and row 2's two gutters are equal. -- **`kEditorMinWidth` is 1190 and the floor is ≤ 1280 × 720**, asserted by a derived test - rather than a literal (the Θ-W6-T1 precedent). -- The waveform band is **358 px at the floor**, and the deck band is 216. +- **Row 1's natural width is 980 and fits the 1020 block** — the fit Γ-W1-T4 could not yet + assert, closed here by the `Band|Notch` move. +- **`kEditorMinWidth` is still 1190 and the floor is still ≤ 1280 × 720** — unchanged by this + track, verified against Γ-W1-T4's derived test rather than a second copy of it. +- The waveform band is **358 px at the floor**, and the deck band is 216 — **unchanged from the + interim, now reached by construction**: `deckRowCount` at and above the floor is 2 because the + row predicate says so, not because a wrap landed there. Assert against the group inventory. - MASTER's gain knob shares a knob baseline with FILTER's knobs; its reserved slot shares one with AMP ENVELOPE's. - The meter reads correctly in mono and stereo, the peak-hold tick holds 1.5 s, the clip cap @@ -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 - 75–133 % rate; transient material at 50 % / 200 % is no worse smeared than the interim path. -- The Gate sustain-loop contract is unchanged: **loop the source, shift the output** — loop - points remain source-frame facts. -- **Measure and report before the algorithm is final:** per-voice CPU at 32 voices, added - latency (must be zero at the onset), and A/B recordings against the interim path on three - material classes (one-shot, tonal sustain, full-mix bounce). Report to Daniel; the choice - is not final until he has heard the A/Bs. - -**Open questions.** **[propose, with a measurement step]** the algorithm family itself. -**[verify]** that `core/instrument/CLAUDE.md`'s *"`WDL_Resampler` is not a Preserve engine — -never wire it as the duration-preserving path"* is honoured: Rate legitimately changes -duration under Preserve, so a resampled read is an explicit duration control, but the -*pitch-preserving* mechanism must not be a resampler. - ---- - ## Traceability — all seventeen items The check that nothing was dropped. Every row points at a track that exists above. @@ -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 in this plan today; if others appear, they belong on this list rather than in the table. -- **All of Phase Γ** (`pg-*`). Seven tracks from a direct interview with Daniel - (2026-08-01), not from `TODO-1.0.md`. Listed here as a block rather than per track, +- **All of Phase Γ** (`pg-*`). Eight tracks across three waves, from a direct interview with + Daniel (2026-08-01), not from `TODO-1.0.md`. Listed here as a block rather than per track, because the whole phase is outside the source doc; the product reasoning lives in `docs/product/instrument-control-surface.md` and the automation scoping it defers in `docs/product/parameter-automation.md`. Γ-W3-T1 additionally **discharges** the @@ -1447,15 +1687,20 @@ Phase Ξ — The resample loop (W1 concurrency-safe with Θ from Θ-W T1 capture-signal-popup .................... 15 (popup) Phase Γ — The instrument's control surface (none of the seventeen; runs before Ξ-W2) - W1 Foundations [3 tracks, disjoint by surface] + W1 Foundations [5 tracks, disjoint by surface] 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 + T4 editor-floor-and-row-law ... floor 1190x680 + budget constants + row predicate + T5 preserve-time-stretch ...... real stretcher [measure-and-report gate] W2 New controls, and the overlay's marks [2 tracks] T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [payload v15] T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard W3 The reflow [1 track] - T1 deck-reflow ................ two rows + double-height MASTER; floor 1190x680 - W4 Preserve time-stretch [1 track] - T1 preserve-time-stretch ...... real stretcher [measure-and-report gate] + T1 deck-reflow ................ two rows + double-height MASTER, by construction + + 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. ``` diff --git a/docs/TODO.md b/docs/TODO.md index 2e3d2fa..c27411e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. +## 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 **Status (2026-08-01): no longer a deferral. The design notes Daniel owed this entry have diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index d5e5843..892eae9 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -6,12 +6,16 @@ contour-trace defect, and a re-approach of the loop/crossfade markers. **Status:** items A–F 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. -**The five forks this doc opened (Γ-F1…Γ-F5) were all ruled on by Daniel on 2026-08-01**; -their rulings are folded into the sections they affect and the rulings themselves are -recorded in §8. §8 also carries **one new fork, Γ-F6**, which the Γ-F2 ruling surfaced from -the vendored SDK and which Daniel did not have in front of him. **The forward-looking VST3 -automation parameter system is deliberately NOT in this phase** — it has its own doc, -`docs/product/parameter-automation.md`. +**All six forks this doc opened (Γ-F1…Γ-F6) are ruled (Daniel, 2026-08-01)**; their rulings +are folded into the sections they affect and indexed in §8. **Nothing in this phase awaits a +Daniel answer.** **The forward-looking VST3 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 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 meter down the right, an averted-clip bubble, and a **one-cell** reserved lower-left slot (Γ-F5). -- **The limiter is a lookahead design with DYNAMIC reported latency** (Γ-F2): zero when - off, the lookahead when on, reported to the host's PDC. That buys a transparent - true-peak limiter and costs a `restartComponent(kLatencyChanged)` on the toggle — which - the SDK defines as a host **deactivate/reactivate**, not a re-tap. §3.1.1 is the whole - of that cost and how it is contained. +- **The limiter is a lookahead design with DYNAMIC reported latency** (Γ-F2, Γ-F6): zero + when off, the lookahead when on, reported to the host's PDC. This is **routine VST3 + behaviour**; the `restartComponent(kLatencyChanged)` it costs is the normal contract, and + the deactivate/reactivate the flag mandates is **accepted** — the toggle is a patch-design + 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. - **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 @@ -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.** +> **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: 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 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* -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 @@ -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 **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 -working stretch, and that composition is the **interim path** Γ-W2-T1 ships so Rate is a -complete feature the day it lands. A **real stretcher, written from established -state-of-the-art literature**, follows as its own track. +correlation-aligned SOLA *pitch* shifter. A **real stretcher, written from established +state-of-the-art literature**, is 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: @@ -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 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 - ±6 st and 75–133 % rate; no smearing of transient material at 50 %/200 % worse than the - interim resample+SOLA path; the null case (rate 100 %, no shift) must be **bit-identical - to the un-stretched read**. + ±6 st and 75–133 % ratio; no smearing of transient material at 50 %/200 % worse than + varispeed at the equivalent ratio; the null case (ratio 1.0, no shift) must be + **bit-identical to the shipped Preserve read**. - **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 - three material classes (one-shot, tonal sustain, full-mix bounce). + added latency (must be zero at the onset), and A/B recordings on three material classes + (one-shot, tonal sustain, full-mix bounce). --- @@ -433,7 +467,7 @@ Behaviour: - **Gain reduction is published per block** for the bubble indicator (§3.3). - **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.** 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 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 > 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 > current latency."* -The second is the sharp one. `kLatencyChanged` is not a "re-read the number" flag — the -SDK defines it as a **deactivate/reactivate cycle**. And in *this* plugin, +**Both describe the ordinary contract.** Dynamic latency reporting is routine for VST3 +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 (`reasampler_processor.cpp:85-109`): -- `setActive(false)` frees `live_`, `draining_`, **and** the graveyard — *every 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."* -- `setActive(true)` calls `reloadInstrument()` — a bridge read, a **WAV re-decode**, and a - fresh engine. +- `setActive(true)` calls `reloadInstrument()` (`:89-97`) — **a bridge read and a full WAV + re-decode**, plus a fresh engine. This is the expensive half, and no part of it is required + by the SDK: it is there because activation was the convenient trigger for a reload, not + because activation implies one. +- `setActive(false)` frees `live_`, `draining_` **and** the graveyard (`:98-107`), so every + 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 -re-decoded from disk.** That is a materially heavier consequence than "a brief click," and -it is the reason §8's new fork Γ-F6 exists rather than this being fully closed. +**So the cost is ours, and it is ours to reduce.** The reduction is **decoupling the reload +from activation** — keeping the decoded `SampleData` alive across a deactivate while still +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 @@ -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 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 -immediately, and the resulting interruption is accepted and documented. It is NOT deferred -to a transport boundary.** Three reasons, in order of weight: +**Daniel's ruling (Γ-F6) is the outer one: this is not a use case, and it is not to be +designed for.** *"The limiter will either be on or off on its instance, toggling during +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 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 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 - covered by a short (≤ 10 ms) equal-gain crossfade between the pre- and post-toggle paths, - so whatever the host does around it, we do not emit a discontinuity of our own making. +- **A short (≤ 10 ms) equal-gain crossfade over the engage/disengage. Kept as a QUALITY + measure, not as a mitigation.** A limiter engaging is a gain-path change, and this codebase + 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 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 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 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` parameter either. -- **The interruption is verified in REAPER, and its severity recorded.** The SDK mandates - the deactivate/reactivate; *what REAPER actually does with it* — whether sounding notes - cut, whether the re-decode is perceptible, whether transport hiccups — is DAW-verifiable - only. That verification is W1-T2's first deliverable, and its outcome is what closes - Γ-F6. +- **Observe what REAPER does, and record it — as evidence, not as a gate.** Whether notes + cut, whether the re-decode is perceptible, whether transport hiccups, is DAW-observable + only. Record it in Γ-W1-T2's review because it is the trigger-condition evidence for the + `docs/TODO.md` decoupling entry. **No outcome changes the design**; Γ-F6 is closed either + way. ### 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 verification requirements bind, because the deactivate/reactivate the flag mandates lands 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 -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. | 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` | | **Γ-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 | +| **Γ-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 -remembering as pattern: +Three of these corrected this doc rather than confirming it, and all three corrections are +worth remembering as pattern: - **Γ-F2 inverted the recommendation.** The zero-lookahead pitch weighed "monitoring latency 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 seen, the chrome row is obvious and free. **The reframe was the answer; the fork as posed 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 -as the cost of the limiter toggle?** +**Fork Γ-F6 asked: `kLatencyChanged` mandates a host deactivate/reactivate. Is that +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 -vendored SDK (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) defines the flag as: *"The -host has to deactivate and reactivate the plug-in."* In this plugin, -`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. +**Ruled: yes — ship it (Daniel, 2026-08-01).** *"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."* -- **(a) Ship it, accept the cut.** The limiter is a patch-design control set once while - building a sound, not a per-take gesture; the enable is classified **not automatable** so - 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. +The two alternatives the fork carried are **closed, not shelved**, and neither is to be +reintroduced: -**Recommendation: (a), gated on a DAW measurement.** Ship the dynamic-latency design as -ruled, and make **W1-T2's first deliverable a verification spike in REAPER**: flip the -limiter with notes held, during playback and while stopped, and record what actually -happens — whether notes cut, whether the re-decode is perceptible, whether transport -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. +- **Constant reported latency** (the delay line engaged whether or not the limiter is on) — + rejected. It is exactly what Γ-F2's ruling refused: every instance paying the lookahead in + live monitoring whether or not the limiter is used. +- **Zero lookahead** — closed at Γ-F2. Do not reopen it here. -*This is the only open fork in the phase.* Nothing in §§1–7 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 §§1–7 awaits a Daniel answer.* --- ## 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) T2 master-bus-audio ................ item C (pure limiter + meter ballistics + processor + LATENCY REPORTING) 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] T1 pitch-rate-deck ................. item A (params + engine + deck descriptor) T2 loop-crossfade-ux ............... item F (waveform painter + pure marker geometry + the chrome-row loop enable) Γ-W3 The reflow [1 track] - T1 deck-reflow ..................... item B + C's UI half -Γ-W4 Preserve time-stretch [1 track] - T1 preserve-time-stretch ........... item A's quality half [measure-and-report gate] + T1 deck-reflow ..................... item B's ARRANGEMENT half + C's UI half ``` +**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 -by W1-T1 then W2-T1; `editor_paint_waveform.cpp` by W1-T3 then W2-T2; the deck descriptors -by W2-T1 then W3-T1; and **one params-payload version bump per wave, owned by one track** -(W1-T2 takes v14 for the limiter flag, W2-T1 takes v15 for rate + pitch offset) so no two -tracks contend for the format ladder. The Γ-F4 ruling adds a chrome surface to W2-T2 and one -named shared file inside W2 — **§7.9**, which restates the wave's disjointness claim rather -than repeating it. Full track specs, dependencies and acceptance criteria are in -`docs/PLAN.md`. +by W1-T1 then W2-T1; `editor_paint_waveform.cpp` by W1-T3 then W2-T2; `deck_groups.cpp` by +W1-T4 (the row predicate) then W2-T1 (the descriptor) then W3-T1 (the row consumption); +`voice.cpp` by W1-T5 then W2-T1; and **one params-payload version bump per wave, owned by one +track** (W1-T2 takes v14 for the limiter flag, W2-T1 takes v15 for rate + pitch offset) — the +two new W1 tracks take **no rung at all**, so the ladder is unchanged by the resequencing. +Two shared files are named rather than discovered at merge: +`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`. diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md index b4c214c..8627119 100644 --- a/docs/product/parameter-automation.md +++ b/docs/product/parameter-automation.md @@ -197,11 +197,18 @@ Two corollaries the parameter work must carry rather than rediscover: flag must survive that cycle. 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 -(Γ-F6)** — whether the deactivate/reactivate cost is acceptable in REAPER, with constant -reported latency as the pre-agreed fallback. If that fallback is taken, the limiter enable -becomes automatable again and this section shrinks to a footnote; check which way it went -before writing the parameter list. +`docs/product/instrument-control-surface.md` §3.1.1. **Fork Γ-F6 is now RULED (Daniel, +2026-08-01): dynamic latency ships as specced and the deactivate/reactivate is accepted.** +There is no constant-reported-latency fallback — that option is closed, not shelved — so +**this section does not shrink to a footnote and the limiter enable does not become +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. ---