docs: spec Phase Γ — the instrument's control surface
Two-row deck reflow (sound/contour), double-height MASTER with limiter and meter, PITCH/RATE deck, unit-driven knob law, contour-trace fix, and a re-approached loop/crossfade UX. Folds rulings Γ-F1..Γ-F5; opens Γ-F6.
This commit is contained in:
+749
-8
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
The post-1.0 roadmap. Seventeen queued items consolidated into overlapping areas and
|
The post-1.0 roadmap. Seventeen queued items consolidated into overlapping areas and
|
||||||
sequenced into a Phase → Wave → Track hierarchy that implementation specialists can be
|
sequenced into a Phase → Wave → Track hierarchy that implementation specialists can be
|
||||||
dispatched against directly.
|
dispatched against directly — **plus Phase Γ**, which did not come from those seventeen
|
||||||
|
(it came from a direct interview, 2026-08-01) and is scoped in
|
||||||
|
`docs/product/instrument-control-surface.md`.
|
||||||
|
|
||||||
## What this doc is, and how it relates to the others
|
## What this doc is, and how it relates to the others
|
||||||
|
|
||||||
@@ -23,8 +25,8 @@ dispatched against directly.
|
|||||||
cited section rather than reading a file whole.
|
cited section rather than reading a file whole.
|
||||||
|
|
||||||
**Worktree slug convention:** `p<phase>-w<wave>-t<track>-<slug>`. Greek phase letters
|
**Worktree slug convention:** `p<phase>-w<wave>-t<track>-<slug>`. Greek phase letters
|
||||||
transliterate: **Θ → `th`**, **Ξ → `xi`**. So Θ-W1-T1 dispatches into
|
transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**. So Θ-W1-T1 dispatches into
|
||||||
`pth-w1-t1-zone-retirement`.
|
`pth-w1-t1-zone-retirement` and Γ-W1-T1 into `pg-w1-t1-knob-interaction-law`.
|
||||||
|
|
||||||
## Decision state
|
## Decision state
|
||||||
|
|
||||||
@@ -39,8 +41,13 @@ 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). No other track in this plan currently carries
|
change, then advancing at 1/newDuration). **Phase Γ opened five [Daniel]-class forks (Γ-F1…Γ-F5) and all five are ruled** (Daniel,
|
||||||
an unanswered **[Daniel]**-class question.
|
2026-08-01) — the rulings are folded into the tracks below and indexed in
|
||||||
|
`docs/product/instrument-control-surface.md` §8.1. **One new fork, Γ-F6, is open**: it was
|
||||||
|
surfaced by the Γ-F2 ruling out of the vendored VST3 SDK, and Daniel has not seen it. It sits
|
||||||
|
on Γ-W1-T2, is **not blocking** — it is gated on a DAW measurement that track performs first
|
||||||
|
— and it has a pre-agreed fallback. No other track in this plan carries an unanswered
|
||||||
|
**[Daniel]**-class question.
|
||||||
|
|
||||||
### Flagged for awareness — not blocking, but decision-grade
|
### Flagged for awareness — not blocking, but decision-grade
|
||||||
|
|
||||||
@@ -54,10 +61,28 @@ an unanswered **[Daniel]**-class question.
|
|||||||
verification, item 15's "one click from inside the VST" framing is what gives, not
|
verification, item 15's "one click from inside the VST" framing is what gives, not
|
||||||
the read-only invariant — the fallback is a bindable extension-side action.
|
the read-only invariant — the fallback is a bindable extension-side action.
|
||||||
|
|
||||||
|
2. **Phase Γ must land before Ξ-W2, and this is a correctness point, not a preference.**
|
||||||
|
Ξ-W2's settled reset scope enumerates parameters by name; Γ adds rate, pitch offset and
|
||||||
|
the limiter flag, so shipping the bake first means its reset list is incomplete on the
|
||||||
|
day it lands. Γ's product doc pre-classifies all three against the ratified rule (all
|
||||||
|
**reset**), so this costs no Daniel decision — only ordering. Second, weaker reason: Γ
|
||||||
|
owns params-payload v14 and v15, and Ξ-W3's programmed-signal persistence will want the
|
||||||
|
next rung; two phases contending for the ladder is the fight Θ's organizing constraint
|
||||||
|
exists to avoid.
|
||||||
|
|
||||||
|
3. **The taper work inside Γ-W1-T1 is a one-way door with respect to automation.** Once
|
||||||
|
VST3 parameters exist, the taper *is* the host-facing normalization, and re-tapering
|
||||||
|
re-interprets every recorded automation point in project files we do not own and cannot
|
||||||
|
migrate. Re-tapering is free today and permanently expensive afterwards. See
|
||||||
|
`docs/product/parameter-automation.md` §4 — that doc is scoping only, nothing in it is
|
||||||
|
scheduled here. **The same door applies to the stage-time ceiling**, which Γ-F3 left at
|
||||||
|
2.0 s with a 10 s ambition recorded in `docs/TODO.md`: if that ceiling is ever raised, it
|
||||||
|
wants to happen before the parameter system, not after.
|
||||||
|
|
||||||
## Phase-wide acceptance criteria
|
## Phase-wide acceptance criteria
|
||||||
|
|
||||||
These bind every track in both phases and are stated once here rather than repeated
|
These bind every track in all three phases and are stated once here rather than repeated
|
||||||
per track.
|
per track. **Phase Γ adds five of its own**, stated in its phase header.
|
||||||
|
|
||||||
### Structural (root `CLAUDE.md`, Daniel 2026-07-28)
|
### Structural (root `CLAUDE.md`, Daniel 2026-07-28)
|
||||||
|
|
||||||
@@ -613,6 +638,702 @@ must be closed.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase Γ — The instrument's control surface
|
||||||
|
|
||||||
|
**Ships:** the deck reflowed into two categorical rows with a double-height MASTER bus deck,
|
||||||
|
a PITCH/RATE deck with playback-rate and baseline-pitch controls, a master limiter with
|
||||||
|
dynamic reported latency and a real output meter, one consistent knob interaction/taper law
|
||||||
|
across every variable control, a fix for staged contour traces drawing straight, and a
|
||||||
|
re-approached loop/crossfade marker UX under an explicit chrome-row loop enable.
|
||||||
|
|
||||||
|
**Consolidates:** none of the seventeen. Phase Γ came from a direct interview with Daniel
|
||||||
|
(2026-08-01); the product reasoning, the measured layout table, the invariant collisions and
|
||||||
|
the fork rulings are in **`docs/product/instrument-control-surface.md`**. Read §1.2 (the
|
||||||
|
layout table) and §7 (collisions) before dispatching any track here — every number in this
|
||||||
|
phase is derived there, and `docs/TODO.md`'s old deck-rework geometry is superseded.
|
||||||
|
|
||||||
|
**Fork state.** The phase's five original forks are **all ruled** (Daniel, 2026-08-01) —
|
||||||
|
indexed at spec §8.1, folded into the tracks below:
|
||||||
|
- **Γ-F1** — `kEditorMinHeight` stays **680**.
|
||||||
|
- **Γ-F2** — the limiter has **lookahead with DYNAMIC reported latency** (zero when off,
|
||||||
|
the lookahead when on, reported to host PDC). *This inverted the product recommendation;*
|
||||||
|
W1-T2's scope grows accordingly — spec §3.1.1.
|
||||||
|
- **Γ-F3** — the stage-time ceiling stays **2.0 s** in this phase. The 10 s ambition is
|
||||||
|
carried in `docs/TODO.md` with its rationale and its prerequisites.
|
||||||
|
- **Γ-F4** — there **is** an explicit loop enable, and it lives on the **chrome row**, not
|
||||||
|
in a deck. W2-T2's scope grows accordingly — spec §6.4.
|
||||||
|
- **Γ-F5** — MASTER's reserved slot is **one** cell. The 90 px headroom argument behind
|
||||||
|
that is spec §1.6 and governs every future control addition.
|
||||||
|
|
||||||
|
**One fork remains open — Γ-F6, on W1-T2** (spec §8.2): the VST3 SDK defines
|
||||||
|
`restartComponent(kLatencyChanged)` as a host **deactivate/reactivate**, and this plugin's
|
||||||
|
`setActive` is destructive in both directions. Not blocking; gated on a DAW measurement W1-T2
|
||||||
|
performs first, with constant reported latency as the pre-agreed fallback.
|
||||||
|
|
||||||
|
**Sequencing against Phase Ξ — Γ runs BEFORE Ξ-W2.** Two reasons, both the same shape as
|
||||||
|
Ξ's own stated gate:
|
||||||
|
1. **The bake bakes the control surface.** Ξ-W2's settled reset scope enumerates parameters
|
||||||
|
by name; Γ adds rate, pitch offset and the limiter flag. Shipping Ξ-W2 first means its
|
||||||
|
reset list is incomplete on the day it lands. (Γ's doc §3.4 pre-classifies all three
|
||||||
|
against the ratified rule — all **reset** — so this is a sequencing point, not a new
|
||||||
|
Daniel question.)
|
||||||
|
2. **One params-payload ladder.** Γ takes v14 and v15. Ξ-W2 does not currently bump the
|
||||||
|
payload, but Ξ-W3's programmed-signal persistence will, and two phases contending for the
|
||||||
|
ladder is exactly the fight Θ's organizing constraint calls out.
|
||||||
|
|
||||||
|
**The organizing constraint.** Four surfaces are single-writer and dictate the wave shape:
|
||||||
|
`ui/deck_values.cpp` (the taper law, then the two new controls), `editor_paint_waveform.cpp`
|
||||||
|
(the contour trace, then the loop marks), the deck descriptors in `ui/deck_groups.cpp` +
|
||||||
|
`ui/knob_deck.cpp` (the new group, then the reflow), and the params-payload ladder. **Every
|
||||||
|
wave boundary below is one of those collisions**, not a preference. Where a wave has more
|
||||||
|
than one track, the tracks are disjoint by surface.
|
||||||
|
|
||||||
|
**Phase-wide acceptance criteria** (in addition to the ones stated at the top of this file):
|
||||||
|
- **Bypassed means byte-identical.** With the limiter off, the per-sample output path is
|
||||||
|
byte-identical to today's bare ramped multiply — the same discipline that makes
|
||||||
|
`live == nullptr` byte-identical to the pre-live core and the filter's exact skip at
|
||||||
|
`modAmount == 0` hold the at-rest path unchanged.
|
||||||
|
- **No `ComponentState` sound change.** A project saved before this phase reopens sounding
|
||||||
|
identical: absent rate lifts to 100 %, absent pitch offset to 0 st, absent limiter flag to
|
||||||
|
bypassed. Re-tapering a knob (Γ-W1-T1) changes needle angles only — the payload stores raw
|
||||||
|
engine doubles, so saved values reload bit-identical.
|
||||||
|
- **`kVelocityPitchRangeSemitones` / `kPitchDepthMaxSemis` (24.0) does not move.** It is
|
||||||
|
load-bearing in the v12 wire format. The new Pitch knob **reads** it; it does not mint a
|
||||||
|
second ±24 constant.
|
||||||
|
- **The window floor is 1190 × 680 and must not exceed 1280 × 720.** A track that pushes the
|
||||||
|
floor past 1280 has failed, not overrun. **`kEditorMinHeight` stays 680** (Γ-F1). The
|
||||||
|
remaining **90 px of width headroom is the budget for the life of this layout** — one deck
|
||||||
|
cell is 60 px, so there is room for exactly one more, once. Spec §1.6 states the ledger;
|
||||||
|
read it before adding any control. Chrome-row additions are a **separate purse** (they are
|
||||||
|
paid for out of the title slot, not the floor) and must not be charged against this one.
|
||||||
|
- **Reported latency is zero unless the limiter is on.** `getLatencySamples()` returns 0 with
|
||||||
|
the limiter bypassed, in every track and at every point in the phase. Only W1-T2 may
|
||||||
|
introduce a non-zero value, and only under the limiter-on condition.
|
||||||
|
- **Geometry stays pure.** Every new layout, cap, label and hit-test rule lands in a pure
|
||||||
|
CTest-covered module (`knob_deck`, `sample_bands`, `waveform_view`), never in a painter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Γ-W1 — Foundations
|
||||||
|
|
||||||
|
**Depends on:** nothing in this phase. All three tracks are disjoint by surface — T1 owns
|
||||||
|
editor input and the value/taper layer, T2 owns the engine and processor, T3 owns the
|
||||||
|
waveform painter's envelope trace. None touches the deck descriptors.
|
||||||
|
|
||||||
|
#### Γ-W1-T1 — `knob-interaction-law`
|
||||||
|
|
||||||
|
**Goal.** One consistent, unit-category-driven interaction and taper rule across every
|
||||||
|
variable control, landed **before** any new control is added so the new ones are authored
|
||||||
|
into it rather than retro-fitted.
|
||||||
|
|
||||||
|
**Spec:** `docs/product/instrument-control-surface.md` §4.
|
||||||
|
|
||||||
|
**Surface boundary — owns:** `core/instrument/ui/deck_values` (the taper maps, the
|
||||||
|
snap-unit table, `resetDeckParam`), `core/instrument/ui/param_slider` (the drag law),
|
||||||
|
`shell/instrument/editor_input_*` (modifier read + re-anchor), and the modifier-reading
|
||||||
|
helper the three input paths share. **Does not own** any deck descriptor, any parameter, or
|
||||||
|
the waveform painter.
|
||||||
|
|
||||||
|
**Behavior.**
|
||||||
|
- **Shift snaps to whole numbers in the control's displayed unit**; **Ctrl scales the drag by
|
||||||
|
0.05**; **Shift+Ctrl = Shift wins** (Ctrl is ignored — with an integer-quantized output a
|
||||||
|
finer drag yields the same sequence, so this is identity, not a compromise).
|
||||||
|
- **Snap unit by category:** ms knobs → whole ms; semitone knobs (incl. Rate, when it
|
||||||
|
arrives) → whole semitones; percent/fraction knobs → whole percent; the 12 curve-exponent
|
||||||
|
inner dials → whole numbers (which puts 1.0, the linear neutral, one snap away); master
|
||||||
|
gain → whole dB; already-integer controls unchanged. Full table in the spec §4.2.
|
||||||
|
- **Mid-drag modifier transitions re-anchor** — on every press *and* release during an active
|
||||||
|
drag, the current value becomes the anchor value and the current cursor position the anchor
|
||||||
|
position. The value is continuous across the transition; only the rate changes. Without
|
||||||
|
this the grab-anchored absolute drag (`kKnobDragRangePixels = 128`) jumps by
|
||||||
|
`(1 − 0.05) ×` the accumulated delta.
|
||||||
|
- **Millisecond knobs become log-scaled.** Exactly 0 s at norm 0 and exactly
|
||||||
|
`kEnvTimeMaxSeconds` at norm 1, monotone throughout; **10 ms lands within 0.12–0.20 of
|
||||||
|
travel and 100 ms within 0.42–0.52**. The ceiling stays **2.0 s** — it reads
|
||||||
|
`kGateStageMaxSeconds`, which the AHDSR overlay's schematic scale is derived from, and the
|
||||||
|
two must agree.
|
||||||
|
- **Semitone knobs become log2/centre-expanded.** Symmetric, exactly 0 at centre, exactly
|
||||||
|
±`kPitchDepthMaxSemis` at the ends, monotone; **±7 st reached at 50–58 % of each
|
||||||
|
half-travel**.
|
||||||
|
- **`resetDeckParam` bypasses the taper** — it writes the default value directly instead of
|
||||||
|
round-tripping through `norm → value`. This *removes* the power-of-two dependency the
|
||||||
|
header currently documents rather than working around it; that comment
|
||||||
|
(`deck_values.h:42-46`) becomes wrong and must be rewritten.
|
||||||
|
- **Scope is the parameter, not the widget.** Deck knobs (outer ring and inner dial),
|
||||||
|
envelope stage nodes and curve knots all honour it — they are surfaces onto one model, and
|
||||||
|
a snap on one but not the others is a divergence. **Waveform markers are explicitly
|
||||||
|
excluded**: they carry a shipped zero-crossing snap on the same modifier space and their
|
||||||
|
domain is frames.
|
||||||
|
|
||||||
|
**Acceptance criteria.**
|
||||||
|
- Every taper change is verified **persistence-neutral**: a project saved before the change
|
||||||
|
reopens with bit-identical stored values and identical audio; only needle angles move.
|
||||||
|
- Holding Shift mid-drag on each unit category lands the documented whole unit; releasing it
|
||||||
|
does not jump the value.
|
||||||
|
- Holding and releasing Ctrl mid-drag is continuous — no step at either transition.
|
||||||
|
- Double-clicking any knob (outer ring and inner dial independently) lands **exactly** on its
|
||||||
|
default at every taper, verified against a default-constructed `PlaySeconds` rather than a
|
||||||
|
round trip.
|
||||||
|
- The log/log2 landmark positions above are asserted in `deck_values`' own tests.
|
||||||
|
- One shared modifier-read helper serves all drag surfaces; no second modifier grammar exists.
|
||||||
|
|
||||||
|
**Open questions.** None **[Daniel]** — fork Γ-F3 is ruled: **the ceiling stays 2.0 s.** The
|
||||||
|
10 s ambition Daniel described (*"a horrifically long decay with tight exp"*) is carried as a
|
||||||
|
`docs/TODO.md` entry, and **this track lands both of its prerequisites**: the log taper (which
|
||||||
|
is what makes a higher ceiling usable at the low end rather than unusable) and the reset
|
||||||
|
bypass (which retires the power-of-two dependency — 2.0 is a power of two, 10.0 is not).
|
||||||
|
**Neither is optional on that basis alone** — they are already required by this track — but
|
||||||
|
the engineer should know the reset bypass is doing double duty, and should not "simplify" it
|
||||||
|
back into a norm round-trip.
|
||||||
|
|
||||||
|
#### Γ-W1-T2 — `master-bus-audio`
|
||||||
|
|
||||||
|
**Goal.** The master limiter and the meter's **audio and publication halves**, plus **the
|
||||||
|
plugin's first latency reporting** — no editor drawing. Landing the audio ahead of the deck is
|
||||||
|
what lets Γ-W3 draw against real published state instead of a stub.
|
||||||
|
|
||||||
|
**Spec:** `docs/product/instrument-control-surface.md` §3.1, **§3.1.1 (latency — read this
|
||||||
|
first)**, §3.2–3.3, §3.5, §7.10, §8.2.
|
||||||
|
|
||||||
|
**Surface boundary — owns:** a new pure limiter module and a new pure meter-ballistics
|
||||||
|
module under `core/instrument/engine/` (each with its own `<module>_tests` target),
|
||||||
|
`shell/instrument/reasampler_processor` (the chain, the published block state, **and the
|
||||||
|
`getLatencySamples` / `restartComponent(kLatencyChanged)` path**), and **params payload v14**
|
||||||
|
(the limiter enable flag). **Does not own** MASTER's deck geometry or any drawing — that is
|
||||||
|
Γ-W3-T1.
|
||||||
|
|
||||||
|
**Behavior.**
|
||||||
|
- **Chain:** `voice mixer → master gain (existing ramped multiply) → limiter (bypassable) →
|
||||||
|
output bus`, with the meter tapped at the **bus output, post-limiter**.
|
||||||
|
- **Limiter: a single toggle, no configurable controls.** Baked ceiling **−0.3 dBTP**.
|
||||||
|
Default **off**. **No makeup gain, ever, of any kind** — transparent at rest.
|
||||||
|
Stereo-linked detection (max |L|,|R| drives one gain) so the image is not moved.
|
||||||
|
- **True-peak detection is sidechain-only** — an oversampled detector in the sidechain, never
|
||||||
|
oversampling the signal path. Factor is the engineer's call under the measure gate.
|
||||||
|
- **Lookahead, with DYNAMIC reported latency (Γ-F2, ruled).** `getLatencySamples()` returns
|
||||||
|
**0** when the limiter is off and **the lookahead in samples** when it is on; the toggle
|
||||||
|
calls `IComponentHandler::restartComponent(kLatencyChanged)`. **None of this exists today** —
|
||||||
|
there is no `getLatencySamples` override, no `kLatencyChanged`, and no `restartComponent`
|
||||||
|
call site anywhere in `src/`; the plugin ships the SDK default of 0. This track introduces
|
||||||
|
the plugin's first latency reporting.
|
||||||
|
- **The restart is the risky part and it is fenced.** The SDK defines `kLatencyChanged` as a
|
||||||
|
host **deactivate/reactivate** (`pluginterfaces/vst/ivsteditcontroller.h:105-108`), and this
|
||||||
|
plugin's `setActive` is destructive in both directions (`reasampler_processor.cpp:85-109`) —
|
||||||
|
deactivate frees every sounding voice, reactivate re-decodes the WAV. Four requirements, all
|
||||||
|
acceptance criteria:
|
||||||
|
1. **Verify the whole call sequence against the vendored Steinberg SDK** before writing it,
|
||||||
|
including the ordering rule that the new latency is what `getLatencySamples` returns
|
||||||
|
*after* `setActive(true)` — so **the reported value must derive from persisted state, not
|
||||||
|
from a transient the deactivate clears.**
|
||||||
|
2. **Prove the restart does not disturb the output bus arrangement.** The output stays one
|
||||||
|
permanently-stereo bus, never renegotiated.
|
||||||
|
3. **Ship a regression test in the spirit of `testDualMonoStereoSampleRendersCentered`** —
|
||||||
|
a dual-mono capture rendered across a limiter toggle stays centered, L ≡ R.
|
||||||
|
4. **`restartComponent` is never called from `process()`.** Main/UI thread only, and
|
||||||
|
coalesced so repeated clicks produce one restart per settled state.
|
||||||
|
**This is NOT the change `reasampler_processor.cpp:66-68` forbids.** That warning is against
|
||||||
|
reintroducing per-mode **bus** renegotiation (`kIoChanged` class), which panned a dual-mono
|
||||||
|
capture hard right in the host's pin re-routing; `kLatencyChanged` is a different flag and the
|
||||||
|
bus is untouched. But the precedent — mid-session `restartComponent` in this plugin has
|
||||||
|
already shipped one real regression — is exactly why (2) and (3) are non-negotiable.
|
||||||
|
- **Flipping the toggle during playback: apply immediately, do NOT defer to a transport
|
||||||
|
boundary** (product ruling, spec §3.1.1). A deferred restart leaves the plugin misaligned by
|
||||||
|
the lookahead with no visible cue, which is worse than a visible interruption; and the host,
|
||||||
|
not the plugin, schedules the deactivate/reactivate anyway. Two mitigations are in scope:
|
||||||
|
the engage/disengage is covered by a short (≤ 10 ms) equal-gain crossfade so **the plugin
|
||||||
|
emits no discontinuity of its own**, and the limiter enable is classified **not automatable**
|
||||||
|
(`docs/product/parameter-automation.md` §3.8) so nothing can flip it at rate. It is also
|
||||||
|
**not** the plugin's `kIsBypass` parameter.
|
||||||
|
- **Per block the processor publishes, as relaxed atomics:** per-channel peak `max|x|`, a
|
||||||
|
latched clip flag, and the block's maximum gain reduction. **No dB conversion, no
|
||||||
|
ballistics, no hold timers on the audio thread** — the UI converts and runs ballistics from
|
||||||
|
block peaks and elapsed time. This widens the existing advisory-peak pattern
|
||||||
|
(`reasampler_processor.h:109-113`), which is not reusable as-is.
|
||||||
|
- **Meter ballistics (pure, unit-tested):** instantaneous rise; **fall 20 dB/s**; peak-hold
|
||||||
|
latched at the running max, **held 1.5 s**, then falling at the same rate; scale **linear in
|
||||||
|
dB over −60…+6 dBFS**; clip latches at block peak ≥ 0 dBFS and is cleared on request.
|
||||||
|
- **`ComponentState` payload v14** appends the limiter flag as a strict suffix on the existing
|
||||||
|
discipline; a v13 blob is a strict prefix and lifts to bypassed.
|
||||||
|
|
||||||
|
**Acceptance criteria.**
|
||||||
|
- **With the limiter bypassed the rendered output is byte-identical to the pre-change build**,
|
||||||
|
asserted by a regression baseline, not by ear.
|
||||||
|
- With the limiter engaged, no output sample exceeds the ceiling on program material that
|
||||||
|
exceeds it by up to +12 dB; with it bypassed and gain driven, the output does exceed
|
||||||
|
0 dBFS (proving the toggle is doing the work).
|
||||||
|
- **Nothing is louder at rest with the limiter on.** A signal that never reaches the threshold
|
||||||
|
is bit-identical engaged and bypassed.
|
||||||
|
- No allocation, no lock, no transcendental on the per-sample path; the measure-and-report
|
||||||
|
gate reports per-voice-block CPU with the limiter engaged at 32 voices.
|
||||||
|
- The meter-ballistics module is pure and CTest-covered: rise, 20 dB/s fall, 1.5 s hold, clip
|
||||||
|
latch/clear, and the dB↔pixel map are all asserted without a host.
|
||||||
|
- A project saved before this change reopens with the limiter bypassed and sounding identical.
|
||||||
|
- **`getLatencySamples()` returns exactly 0 with the limiter off**, and the lookahead in
|
||||||
|
samples with it on — asserted against the persisted flag, and correct across a
|
||||||
|
deactivate/reactivate cycle.
|
||||||
|
- **A dual-mono capture rendered across a limiter toggle stays centered** (L ≡ R), and the
|
||||||
|
output bus arrangement after a latency-change restart is identical to before it.
|
||||||
|
- **The plugin emits no hard step at the toggle** — the engage/disengage crossfade is asserted
|
||||||
|
on a rendered signal, not judged by ear.
|
||||||
|
|
||||||
|
**Open questions.**
|
||||||
|
- **[Daniel] fork Γ-F6 — is the deactivate/reactivate cost acceptable?** (spec §8.2.) **Not
|
||||||
|
blocking, and this is the track's FIRST deliverable:** a verification spike in REAPER —
|
||||||
|
flip the limiter with notes held, during playback and while stopped, and **record what
|
||||||
|
actually happens** (do notes cut? is the re-decode perceptible? does transport hiccup?).
|
||||||
|
The SDK mandates the deactivate/reactivate; what REAPER does with it is DAW-verifiable only.
|
||||||
|
Ship the ruled dynamic-latency design regardless; if the observed behaviour is as ugly as
|
||||||
|
the SDK's worst case allows, the **pre-agreed fallback is constant reported latency** (the
|
||||||
|
delay line engaged whether or not the limiter is on), which needs one word from Daniel and
|
||||||
|
**no DSP change** — only the latency-reporting predicate moves. Report the measurement to
|
||||||
|
Daniel with a recommendation; do not choose the fallback unilaterally.
|
||||||
|
- **[verify]** `temp_cortex/` has already been assessed and **rejected** (spec §3.5) — do not
|
||||||
|
re-litigate it, and do not transplant from it.
|
||||||
|
- **[verify]** whether `setActive(true)`'s `reloadInstrument()` can reuse the already-decoded
|
||||||
|
`SampleData` on a latency-restart reactivate rather than re-reading the bridge and
|
||||||
|
re-decoding the WAV. If it cannot cheaply, that is an **accepted cost to be recorded**, not
|
||||||
|
a reason to restructure `setActive` — its destructive shape is deliberate and its reasoning
|
||||||
|
(ghost sustained voices on reactivate) is documented at the call site.
|
||||||
|
|
||||||
|
#### Γ-W1-T3 — `contour-trace-curves`
|
||||||
|
|
||||||
|
**Goal.** Staged envelope segments draw as the curve their exponent defines, so the
|
||||||
|
mid-segment knot stops floating off its own trace.
|
||||||
|
|
||||||
|
**Spec:** `docs/product/instrument-control-surface.md` §5.
|
||||||
|
|
||||||
|
**Surface boundary — owns:** `shell/instrument/editor_paint_waveform.cpp`'s staged-envelope
|
||||||
|
trace and any pure tessellation helper it needs. **Does not own** the loop/crossfade marks
|
||||||
|
(Γ-W2-T2), `envelope_overlay`'s vertex model, or the drawn-EG (spline) trace.
|
||||||
|
|
||||||
|
**Behavior.** The defect is verified: `editor_paint_waveform.cpp:218` drops knots
|
||||||
|
(`if (v.knot) continue;`) and joins the remaining vertices with straight strokes, and
|
||||||
|
`curveMap` is never called in the paint path even though the exponent is in scope at `:211`.
|
||||||
|
Knot *positioning* already honours the exponent via `curveMidLevel`
|
||||||
|
(`envelope_overlay.cpp:94-105`) — that divergence is the visible symptom. The fix draws each
|
||||||
|
sloped stage through **the same `curveMap` the audio uses**, so trace and sound cannot
|
||||||
|
diverge; tessellation approach is the engineer's call.
|
||||||
|
|
||||||
|
**Acceptance criteria.**
|
||||||
|
- **At every exponent the knot's centre lies on the trace, within 1 px** — the reported defect,
|
||||||
|
stated as the gate.
|
||||||
|
- **At exponent 1.0 the segment is visually identical to today's straight line.**
|
||||||
|
- No visible faceting at the widest segment the canvas can produce; a fixed low tessellation
|
||||||
|
count is not acceptable at full width.
|
||||||
|
- All three envelopes, both play modes, all sloped stages (attack/decay/release) — one paint
|
||||||
|
path, one fix.
|
||||||
|
- The established trace grammar is unchanged: one weight, `kEnvTracePx = 2.0`, through the
|
||||||
|
analytic stroker. Both overlay layout policies (AHDSR right-anchored schematic, AHD 1:1)
|
||||||
|
are honoured unchanged. The spline overlay's own trace is untouched.
|
||||||
|
- **Audio is unchanged** — this is a drawing defect only; a regression baseline proves it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Γ-W2 — New controls, and the overlay's marks
|
||||||
|
|
||||||
|
**Depends on Γ-W1 for:** T1 depends on W1-T1 — Rate and Pitch must be authored into the
|
||||||
|
finished taper/modifier law, not retro-fitted into it (and the semitone taper must exist
|
||||||
|
before a second semitone knob does). T2 depends on W1-T3 — both write
|
||||||
|
`editor_paint_waveform.cpp`, and running them together is a merge fight in one file.
|
||||||
|
|
||||||
|
**Disjointness — restated after the Γ-F4 ruling, because it changed.** T1 owns the parameter
|
||||||
|
model, the engine and the deck descriptors; T2 owns the waveform band's marks and their pure
|
||||||
|
geometry **and now also the chrome row's loop enable**. The two are disjoint at the module
|
||||||
|
level with **one named exception: `shell/instrument/editor_session.cpp`.** T1 may touch it for
|
||||||
|
the third commit tier's routing; **T2 owns `pickedMarkers` and `applyMarkers` there and nothing
|
||||||
|
else.** The partition is by function and the two do not overlap — this is a textual merge
|
||||||
|
adjacency, not a semantic contention — but it is a shared file in a phase whose wave boundaries
|
||||||
|
are otherwise single-writer surfaces, so it is stated rather than discovered at merge. Whichever
|
||||||
|
track lands second rebases onto the first.
|
||||||
|
|
||||||
|
**The format ladder stays clean.** The loop enable maps onto the existing
|
||||||
|
`SampleLoop::hasLoop`, which is already persisted and whose `start`/`end` are already written
|
||||||
|
unconditionally — **no new field, no version bump** — so T1 keeps sole ownership of payload v15
|
||||||
|
exactly as specced.
|
||||||
|
|
||||||
|
#### Γ-W2-T1 — `pitch-rate-deck`
|
||||||
|
|
||||||
|
**Goal.** PITCH becomes **PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the
|
||||||
|
existing Varisp|Presrv toggle, with both new controls wired through the engine.
|
||||||
|
|
||||||
|
**Spec:** `docs/product/instrument-control-surface.md` §2.
|
||||||
|
|
||||||
|
**Surface boundary — owns:** `core/instrument/engine/play_params.h` +
|
||||||
|
`core/instrument/map/play_seconds.h` (the two new fields),
|
||||||
|
`core/instrument/map/component_state_io` + `params_payload` (**payload v15**),
|
||||||
|
`core/instrument/engine/voice.{h,cpp}` (the compounding and the note-on latch),
|
||||||
|
`core/instrument/ui/deck_groups` (the PITCH/RATE descriptor **and** the three-state live
|
||||||
|
predicate), `core/instrument/ui/deck_values` (the two new bindings). **Does not own** the
|
||||||
|
deck's row layout — that is Γ-W3-T1 — nor the real time-stretcher (Γ-W4-T1).
|
||||||
|
|
||||||
|
**Behavior.**
|
||||||
|
- **Rate: 50 %–200 %, default 100 % at true knob centre, exponential taper** — 50 % = −12 st,
|
||||||
|
200 % = +12 st, musically symmetric. This is **linear in semitones over ±12** and is the
|
||||||
|
stated exception to W1-T1's centre-expansion law (which applies to semitone knobs whose
|
||||||
|
throw exceeds ±12).
|
||||||
|
- **Pitch: a baseline pitch offset, ±24 semitones**, centred, on W1-T1's centre-expanded
|
||||||
|
semitone taper. **Reads `kPitchDepthMaxSemis`; does not mint a second constant.**
|
||||||
|
- **Varispeed:** keytrack ratio × rate ratio × pitch-offset ratio **compound into a single
|
||||||
|
read-increment multiply**; the rate offset applies to the varispeed pitch. Composes with
|
||||||
|
the pitch envelope's existing per-frame `ratio_` multiply — **no new per-sample stage**.
|
||||||
|
- **Preserve:** rate is an **absolute** value driving **duration only**; keytrack and pitch
|
||||||
|
offset drive the pitch shifter. **Interim implementation:** a resampled read with the
|
||||||
|
resulting pitch change cancelled in the existing SOLA shifter. This is an explicit duration
|
||||||
|
control, **not** a covert Preserve path — `core/instrument/CLAUDE.md`'s "never wire
|
||||||
|
`WDL_Resampler` as the duration-preserving path" is untouched. Quality is Γ-W4-T1's.
|
||||||
|
- **Rate is latched at note-on**, delivered by a **third commit class**: published into the
|
||||||
|
live block like any live parameter, read only by `snapLive`, never by `applyLive`.
|
||||||
|
`isLiveDeckParam`/`liveCommitFor` widens from two states to three
|
||||||
|
(`Live` / `NoteOnLatched` / `Reload`) in that one predicate — **not** a second table, and
|
||||||
|
**not** the reload tier (a swept knob must never trigger a WAV re-decode). **Record the
|
||||||
|
reason in the header:** loop resolution and contour mapping are note-on folds, so live rate
|
||||||
|
means re-folding a resolved loop and re-mapping a contour mid-note.
|
||||||
|
- **Pitch is live** — under Varispeed one more factor in a multiply the pitch envelope already
|
||||||
|
performs; under Preserve an addend to a shift the pitch envelope already modulates.
|
||||||
|
- **Loop points scale with rate; contours scale with rate.** Neither rewrites stored values:
|
||||||
|
the loop is source-frame facts traversed at the new increment (Varispeed) or the new read
|
||||||
|
rate (Preserve), and a contour is a function of normalized position. **Staged envelope stage
|
||||||
|
times do NOT scale** — 30 ms is 30 ms at any rate. That asymmetry is deliberate: a contour is
|
||||||
|
of the sample, a staged envelope is of the performance.
|
||||||
|
- **Deck descriptor:** three cells; `captionWidth` **70**, hard ceiling **80** (above that the
|
||||||
|
caption row overtakes the 180 px knob row and the group exceeds 192). If the text will not
|
||||||
|
fit at 80, narrow the `Varisp|Presrv` segments 48 → 44 (ceiling becomes 88) — **do not widen
|
||||||
|
the group**.
|
||||||
|
- **Payload v15** appends both fields as a strict suffix; a v14 blob lifts to rate 100 % /
|
||||||
|
pitch 0 st, bit-identical playback.
|
||||||
|
|
||||||
|
**Acceptance criteria.**
|
||||||
|
- Rate at 50 % plays an octave down and half speed under Varispeed; at 200 %, an octave up and
|
||||||
|
double speed. Under Preserve the same settings change duration only — pitch is unchanged
|
||||||
|
within the shifter's tolerance.
|
||||||
|
- Rate at exactly 100 % and Pitch at exactly 0 st render **bit-identical** to the
|
||||||
|
pre-change build, in both engines.
|
||||||
|
- Shift-drag on Rate lands on whole semitones (so an octave and a fifth are reachable by
|
||||||
|
hand); Shift-drag on Pitch lands on whole semitones; Ctrl gives cents on both.
|
||||||
|
- **A Rate change while a note sounds does not alter that note**; the next note-on takes it.
|
||||||
|
**It does not trigger a reload or an engine rebuild** — assert the tier, not just the sound.
|
||||||
|
- A Pitch change **does** move a sounding note, in both engines.
|
||||||
|
- With a loop set, changing Rate changes the loop's audible period without moving either
|
||||||
|
waveform marker.
|
||||||
|
- The PITCH/RATE group measures **exactly 192 px**; adding the two `DeckParam`s produces a
|
||||||
|
compile error in `isLiveDeckParam`'s exhaustive switch until they are classified.
|
||||||
|
- A v14 project reopens at rate 100 % / pitch 0 st and sounds identical.
|
||||||
|
|
||||||
|
**Open questions.** None **[Daniel]**. **[propose at review]** the exact interim
|
||||||
|
resample+cancel composition and its clamp behaviour at the range extremes, which Γ-W4-T1 then
|
||||||
|
replaces.
|
||||||
|
|
||||||
|
#### Γ-W2-T2 — `loop-crossfade-ux`
|
||||||
|
|
||||||
|
**Goal.** Give the loop an explicit enable, make the loop and crossfade marks legible, and
|
||||||
|
paint the crossfade where it is actually heard.
|
||||||
|
|
||||||
|
**Spec:** `docs/product/instrument-control-surface.md` §6 — **read §6.1 (the diagnosis),
|
||||||
|
§6.4 (the enable) and §6.5 (trade-offs) in full before starting.** This is the phase's one
|
||||||
|
genuinely designed surface; the sections are the brief.
|
||||||
|
|
||||||
|
**Surface boundary — owns:** `shell/instrument/editor_paint_waveform.cpp`'s marker/loop draw,
|
||||||
|
`shell/instrument/editor_input_waveform.cpp`'s marker hit-test routing,
|
||||||
|
`core/instrument/ui/waveform_view` (cap rects, label boxes, the label-suppression rule — all
|
||||||
|
pure, all CTest-covered), and — **new, from the Γ-F4 ruling** —
|
||||||
|
`core/instrument/ui/sample_chrome` (the enable's rect in the toolbar control run),
|
||||||
|
`shell/instrument/editor_paint_chrome` + `editor_input_chrome` (its draw and hit-test), and
|
||||||
|
`shell/instrument/editor_session.cpp`'s **`pickedMarkers` / `applyMarkers` only** (the
|
||||||
|
retention rule — see the wave header for the shared-file partition). **Does not own**
|
||||||
|
`loop_span`, the crossfade model, any parameter, or any `ComponentState` version. **This
|
||||||
|
track changes drawing, hit-testing and one editor-state retention rule — no format change.**
|
||||||
|
|
||||||
|
**Behavior.**
|
||||||
|
- **An explicit loop enable on the CHROME ROW (Γ-F4, ruled).** A two-segment `Loop Off|On`
|
||||||
|
toggle joins the toolbar row's right-anchored control run, **immediately left of the
|
||||||
|
`Mono|Stereo` toggle**, with Browse still rightmost. Loop is a waveform-overlay concept and
|
||||||
|
**has no deck** — a deck cell was never the right home. Because the run is right-anchored
|
||||||
|
and the title slot absorbs it, **this costs zero window width and none of the 90 px
|
||||||
|
headroom**; if the title will not hold its text at the 1190 floor, **the enable's segments
|
||||||
|
narrow — the floor does not move.**
|
||||||
|
- **The enable IS `SampleLoop::hasLoop`. No new field, no version bump.** The field already
|
||||||
|
exists (`play_params.h:210`), is already what `resolveLoop` refuses on
|
||||||
|
(`loop_span.cpp:12`), and is already persisted in the payload's `loopOverride` block —
|
||||||
|
where **`start`/`end` are written unconditionally whatever `hasLoop` says**
|
||||||
|
(`params_payload.cpp:31-36`), so the wire can already carry "off, with a span remembered."
|
||||||
|
What changes is the field's *provenance*: today it is derived from the marker gesture, and
|
||||||
|
after this track it is **user-owned**, with the gestures as shortcuts onto it.
|
||||||
|
- **Collapse-to-off survives as a shortcut, not as a second state machine.** `hasLoop` is the
|
||||||
|
single authority; four gestures reach it:
|
||||||
|
| Gesture | Enable | Span | Crossfade |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Enable → On | on | retained | retained |
|
||||||
|
| Enable → Off | off | **retained** | **retained** |
|
||||||
|
| Collapse the span onto itself | off | **destroyed**, re-parked at `defaultLoopBounds` | **zeroed** |
|
||||||
|
| Drag either loop mark while off | **on** | takes the drag | retained, re-clamped |
|
||||||
|
- **Two consequent behaviour changes, each with its reason.** (a) `pickedMarkers`'
|
||||||
|
re-park (`editor_session.cpp:221-226`) currently triggers on `!hasLoop`; it must become
|
||||||
|
conditional on the span being **invalid** (collapsed / inverted / out of range) rather than
|
||||||
|
on the enable being off — a toggle whose off→on does not restore what was there is a delete
|
||||||
|
button, not a toggle. (b) `applyMarkers`' crossfade zeroing (`:240-243`) moves from "the
|
||||||
|
enable is off" to "the span was destroyed." **The original reasoning is preserved, not
|
||||||
|
overruled:** it zeroes so a stale length cannot silently re-apply against a span that no
|
||||||
|
longer exists; with the span retained, its clamp bound is retained too and there is nothing
|
||||||
|
stale.
|
||||||
|
- **The "drag me" affordance splits into two off-states.** Off with **no span ever set** —
|
||||||
|
pair parked at `defaultLoopBounds`, Disabled, caption `DRAG TO SET LOOP`. Off with a **span
|
||||||
|
retained** — pair Disabled *at its own positions*, caption `LOOP OFF` (there is nothing to
|
||||||
|
"set"). In both, **dragging a mark turns the enable on** — the shipped drag-to-create
|
||||||
|
gesture survives and now teaches the enable by demonstration.
|
||||||
|
- **In Trigger the enable draws Disabled and inert, and does NOT clear `hasLoop`** —
|
||||||
|
Disabled-not-hidden, the same grammar as the marks, with its state restored on the return to
|
||||||
|
Gate. This transitively covers the drawn-EG case via `enforceGateUnavailableWhileDrawn`
|
||||||
|
(`play_params.h:198-205`), which forces Trigger whenever an envelope is drawn — one
|
||||||
|
predicate, not a second rule. **Disabled-but-grabbable (the off marks) vs.
|
||||||
|
Disabled-and-inert (Trigger) is deliberate:** the user's own off is reversible by the very
|
||||||
|
gesture on offer; Trigger's refusal comes from the engine and no drag can talk it out of it.
|
||||||
|
- **One mark grammar: line + shaped cap + label. The cap IS the grip.** Four marks:
|
||||||
|
**START** (`accent/primary`, solid right-pointing triangle cap, solid line — the only
|
||||||
|
primary-ink mark, because it is the only one always in effect); **LOOP** (`accent/secondary`,
|
||||||
|
L-cap opening right); **END** (`accent/secondary`, L-cap opening left); **XFADE**
|
||||||
|
(`accent/secondary` reduced alpha, ramp cap, **dashed** line — a soft boundary). This
|
||||||
|
replaces the bare 10 px orphan tab that today marks the crossfade with no line of its own.
|
||||||
|
- **Labels** in `Font::Micro`/`TextDim`, drawn **beneath** the trace and handles in z-order.
|
||||||
|
A mark's label **re-draws on top on hover or drag** of that mark. **A label is suppressed if
|
||||||
|
its box would overlap one already placed**; placement order is grabbed/hovered first, then
|
||||||
|
START, LOOP, END, XFADE. Occlusion by an envelope node is **accepted and named** — the cap
|
||||||
|
shape carries the identity permanently, the label is for learning.
|
||||||
|
- **The crossfade moves to `[loopEnd − crossfade, loopEnd)`** — where it is audible. The
|
||||||
|
handle moves to the loop-end side; **drag direction is unchanged** (left lengthens), so the
|
||||||
|
muscle memory survives.
|
||||||
|
- **The crossfade region draws as a top-and-bottom edge wedge, NEVER as a second fill.** A
|
||||||
|
triangular band at the overlay's top and bottom edges growing from zero at
|
||||||
|
`loopEnd − crossfade` to ~10 px at `loopEnd`. **This is a hard constraint:** the region is
|
||||||
|
now *inside* the loop span, where a translucent fill would stack on the 0.20 loop fill, and
|
||||||
|
the envelope trace crossing that fill is a known, accepted under-floor pair at 2.25:1
|
||||||
|
(`editor_paint_waveform.cpp:28-34`), whose own note says the FILL is what changes if it is
|
||||||
|
ever resolved. **The loop fill's peak alpha must stay exactly 0.20.**
|
||||||
|
- **The ingredient draws as a ghost.** `[loopStart − crossfade, loopStart)` draws the mirror
|
||||||
|
wedge at half alpha, no handle — **a hairline dashed outline at rest, filling in on hover or
|
||||||
|
drag of the crossfade handle**. This makes the `crossfade ≤ min(start, loopLength)` clamp
|
||||||
|
self-explanatory: the fade stops growing exactly when the ghost's left edge reaches START or
|
||||||
|
LOOP, so the user sees the reason instead of hitting an invisible wall.
|
||||||
|
- **Trigger mode:** the loop pair and the crossfade mark draw **Disabled and are not
|
||||||
|
grabbable**, with a dim `LOOP — GATE ONLY` caption — Disabled rather than hidden, matching
|
||||||
|
the editor's existing Gate-segment grammar, and because hiding a set loop on a mode flip
|
||||||
|
destroys information the user put there. START stays fully live.
|
||||||
|
|
||||||
|
**Acceptance criteria.**
|
||||||
|
- The four marks are distinguishable by ink and cap shape with the labels suppressed, and
|
||||||
|
named when they are not.
|
||||||
|
- **The shaded crossfade region sits over the frames where the fade is audible** — verify
|
||||||
|
against a rendered loop, not by reading the code.
|
||||||
|
- Every mark is grabbable by its cap; grabbing a mark shows its label.
|
||||||
|
- The crossfade at its clamp shows the ghost's left edge coincident with the bounding mark.
|
||||||
|
- **The loop fill's peak alpha is unchanged at 0.20** and the accepted 2.25:1 trace pair is
|
||||||
|
neither improved nor worsened.
|
||||||
|
- In Trigger, no loop mark accepts a grab, the chrome enable is Disabled and inert, and the
|
||||||
|
reason is on screen. Returning to Gate restores the enable's prior state.
|
||||||
|
- **Turning the enable off and on again restores the loop exactly** — same span, same
|
||||||
|
crossfade, no re-park. Collapsing the span instead turns it off, re-parks at
|
||||||
|
`defaultLoopBounds` and zeroes the crossfade. Both paths asserted.
|
||||||
|
- **Dragging a loop mark while the enable is off turns it on**, in both off-states.
|
||||||
|
- **The enable costs no window width:** `kEditorMinWidth` is unchanged by this track, asserted
|
||||||
|
by the same derived test that guards the floor.
|
||||||
|
- **No `ComponentState` version moves; no new persisted field; `resolveLoop` is untouched;
|
||||||
|
audio is unchanged.** The enable round-trips save/reload through the existing
|
||||||
|
`loopOverride` block, in both states, with the span retained across an off.
|
||||||
|
- All cap/label/suppression geometry is pure and unit-tested; no hit-test math in the painter.
|
||||||
|
The enable's rect lands in `sample_chrome` alongside the rest of the control run.
|
||||||
|
|
||||||
|
**Open questions.**
|
||||||
|
- **[propose at review, then verify by hand]** The claim-arbitration inputs change:
|
||||||
|
`markerHandleRect` today gives a tab to the crossfade only, and `resolveWaveformClaim`
|
||||||
|
breaks ties by smallest nominal target area. Giving every mark a cap-grip changes the
|
||||||
|
candidate set **and every nominal area in it**. The arbitration must be re-derived, and
|
||||||
|
`docs/TODO.md`'s open entry *"Pre-existing staged-envelope-node shadow at zero-attack"*
|
||||||
|
must be **re-evaluated against the new cap geometry and its outcome recorded** — resolved or
|
||||||
|
worsened, either is acceptable, silence is not.
|
||||||
|
- **No [Daniel] questions.** Fork Γ-F4 is ruled — there **is** an explicit enable and it is on
|
||||||
|
the chrome row, in this track. The prior framing ("an enable needs a cell, so it is a Γ-W3
|
||||||
|
layout decision") was wrong and is retired: loop has no deck, so it never needed one.
|
||||||
|
- **[propose at review]** every site that currently *infers* `hasLoop` — two in
|
||||||
|
`editor_input_waveform` (`:255`, `:258`), two in `editor_session` (`:222`, `:236`) — is now
|
||||||
|
writing to a user-visible control rather than to an internal flag. Re-read each in that
|
||||||
|
light; "it still compiles" is not a disposition.
|
||||||
|
- **Named escalation, not a fallback to take silently:** if the top strip reads crowded in the
|
||||||
|
DAW, the pre-designed answer is the marker rail (spec §6.2, Direction 2) — a larger build
|
||||||
|
that would also dissolve the arbitration problem structurally. Escalate; do not improvise a
|
||||||
|
half-rail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Γ-W3 — The reflow
|
||||||
|
|
||||||
|
**Depends on Γ-W2 for:** the PITCH/RATE descriptor (W2-T1) — the reflow measures the real
|
||||||
|
three-cell group, and laying it out against a forecast of that group means re-measuring
|
||||||
|
afterward. **Depends on Γ-W1 for:** W1-T2's published meter/GR/clip state, which MASTER's
|
||||||
|
deck draws; drawing against a stub would mean building the meter twice.
|
||||||
|
|
||||||
|
**One track.** The row law, the group inventory, the double-height deck and the window floor
|
||||||
|
are one geometry decision spread over `knob_deck`, `deck_groups`, `sample_bands` and the deck
|
||||||
|
painter. Splitting it would put two tracks in the same three pure modules.
|
||||||
|
|
||||||
|
#### Γ-W3-T1 — `deck-reflow`
|
||||||
|
|
||||||
|
**Goal.** Two categorical rows plus a double-height MASTER bus deck, inside a 1280 × 720
|
||||||
|
ceiling, returning 112 px to the waveform.
|
||||||
|
|
||||||
|
**Spec:** `docs/product/instrument-control-surface.md` §1 (the whole section, incl. the §1.2
|
||||||
|
measured table **and §1.6, the headroom ledger**) and §3.2–3.3 (what MASTER draws). **§7 lists
|
||||||
|
the invariants this track invalidates or widens — read it before touching `knob_deck.h`.**
|
||||||
|
|
||||||
|
**Surface boundary — owns:** `core/instrument/ui/knob_deck` (the row law, the double-height
|
||||||
|
group, the justification), `core/instrument/ui/deck_groups` (row assignment, FILTER's toggle
|
||||||
|
move, MASTER's inventory), `core/instrument/ui/sample_bands` (`kEditorMinWidth`), and
|
||||||
|
`shell/instrument/editor_paint_deck` (the MASTER meter/limiter/bubble draw). **Does not own**
|
||||||
|
any parameter, the limiter DSP, or the waveform band.
|
||||||
|
|
||||||
|
**Behavior.**
|
||||||
|
- **Row 1 (sound), one row, non-negotiable:** PITCH/RATE 192 · FILTER 432 · VELOCITY 192 ·
|
||||||
|
VOICE 164 = **980** natural.
|
||||||
|
- **Row 2 (contour):** PITCH ENV 252 · FILTER ENV 312 · AMP ENVELOPE 312 = **876** natural.
|
||||||
|
- **MASTER is double-height (216 px) and right-anchored**, outside both rows, 142 px wide.
|
||||||
|
- **FILTER's `Band|Notch` moves from its row-toggle position to the caption corner**, taking
|
||||||
|
the group 524 → **432** (−92 px). It occupies FILTER's currently-unused `captionToggle2`
|
||||||
|
slot — **no new geometry is required**.
|
||||||
|
- **VOICE keeps its `Retrig|Legato` row toggle.** Moving it to the caption makes VOICE
|
||||||
|
*wider* (226, not narrower), because its caption row is the binding side. Verified; do not
|
||||||
|
"fix" it.
|
||||||
|
- **Justification law, applied to BOTH rows:** space-between within the row block; slack
|
||||||
|
divided equally among the row's (n−1) gutters, integer residue to the leftmost;
|
||||||
|
**no gutter narrower than `kDeckGroupGap` (12)**. **Decks are never stretched.** MASTER is
|
||||||
|
not part of either row's justification.
|
||||||
|
- **Row block = 1020 px at the floor**, giving row 1 gutters 12/14/14 and row 2 gutters 72/72,
|
||||||
|
at which width **FILTER's right edge and FILTER ENV's right edge both land on x = 636**.
|
||||||
|
That tie-line, row 2's equal gutters, and row 1's minimum gutter being exactly
|
||||||
|
`kDeckGroupGap` all hold at 1020 and only at 1020 — **this is why the floor is 1190 and not
|
||||||
|
1186.** Above the floor the tie-line drifts and that is accepted (spec §1.3).
|
||||||
|
- **`kEditorMinWidth` 980 → 1190**; `kEditorMinHeight` **stays 680** (Γ-F1, ruled — the
|
||||||
|
reflow's 112 px goes entirely to the waveform); deck band height 328 → **216**; waveform
|
||||||
|
band at the floor 246 → **358**.
|
||||||
|
- **The 90 px of remaining headroom is the budget for the life of this layout**, and one deck
|
||||||
|
cell is 60 px. **This is why MASTER's reserved slot is ONE cell** (Γ-F5, ruled): two would
|
||||||
|
spend 60 of the 90 up front on a control nobody has named, leaving 30 — which would freeze
|
||||||
|
row 1 forever, since any later row-1 addition needs 60. Widening MASTER later costs the same
|
||||||
|
60 it would cost now, and by then the trade is against a real control instead of a guess.
|
||||||
|
**State this ledger where a future reader will hit it** — spec §1.6 is its home, and a
|
||||||
|
reader proposing a new knob needs to see it before they propose.
|
||||||
|
- **MASTER's interior** (spec §1.4, exact to the pixel): caption row with the limiter toggle
|
||||||
|
and a **round** 12 px `warn` GR bubble in the far corner (non-interactive — the same slot the
|
||||||
|
envelope decks' radio uses; round so it reads as a lamp, not a control); **gain knob in the
|
||||||
|
upper-left cell at box-relative y = 26** and a **reserved empty slot at y = 138** — i.e. the
|
||||||
|
two cells land on row 1's and row 2's knob baselines exactly, which is what stitches the
|
||||||
|
spanning deck to both rows; **meter column 62 px wide × 186 px tall** on the right.
|
||||||
|
- **Three rules not to generalise wrongly:** MASTER's left column uses **fixed cell slots at
|
||||||
|
the two baselines, NOT the horizontal run-division law** (that law would stretch one knob
|
||||||
|
over 186 px); the reserved slot **draws nothing** (blank reads as breathing room, a dashed
|
||||||
|
placeholder reads as unfinished); the meter is **one rect spanning both baselines**, not two
|
||||||
|
per-row meters.
|
||||||
|
- **The meter draws W1-T2's published state**, with the ballistics run on the UI timer.
|
||||||
|
**Bar count follows the same `LaneSplit` decision `waveformSurface` already folds** (channel
|
||||||
|
mode ∧ source channel count) — one wide bar when the waveform draws one lane, two skinnier
|
||||||
|
bars when it draws two. Not a second rule: a mono source in stereo mode is dual-mono, and
|
||||||
|
two identical bars would be a lie.
|
||||||
|
- **Meter appearance:** bar in `accent/primary`; peak-hold tick 2 px in `text/primary`; clip
|
||||||
|
cap in `warn`, latched, click-to-clear; scale linear in dB over −60…+6 with ticks every
|
||||||
|
6 dB and numerals at 0/−12/−24/−36/−48/−60, the 0 dB tick heavier. **No green/yellow/red
|
||||||
|
segmentation** — `warn` stays reserved for clip states.
|
||||||
|
|
||||||
|
**Acceptance criteria.**
|
||||||
|
- At the floor width the deck lays out in **exactly two rows plus the spanning MASTER**,
|
||||||
|
**by construction** — asserted against the group inventory, not observed as a wrap outcome.
|
||||||
|
- Every group's width matches the §1.2 table exactly, **in both Gate and Trigger** (row 2's
|
||||||
|
natural width is mode-stable at 876 because the reserve slots hold FILTER ENV and AMP at
|
||||||
|
312 in both modes — assert it).
|
||||||
|
- Row 1 and row 2 are **flush left and flush right**; at the floor width the filter tie-line
|
||||||
|
is exact (both edges at x = 636) and row 2's two gutters are equal.
|
||||||
|
- **`kEditorMinWidth` is 1190 and the floor is ≤ 1280 × 720**, asserted by a derived test
|
||||||
|
rather than a literal (the Θ-W6-T1 precedent).
|
||||||
|
- The waveform band is **358 px at the floor**, and the deck band is 216.
|
||||||
|
- MASTER's gain knob shares a knob baseline with FILTER's knobs; its reserved slot shares one
|
||||||
|
with AMP ENVELOPE's.
|
||||||
|
- The meter reads correctly in mono and stereo, the peak-hold tick holds 1.5 s, the clip cap
|
||||||
|
latches and clears, and the GR bubble lights only while the limiter reduces gain.
|
||||||
|
- **With the limiter engaged the clip cap never latches** on material the limiter is catching;
|
||||||
|
if it does, that is a defect report against W1-T2, not a user error.
|
||||||
|
- `knob_deck`'s and `sample_bands`' tests are updated to the new law, and the invalidated
|
||||||
|
notes in `knob_deck.h` (the fourteen-pixel headroom figure; the cells-and-floor pairing) are
|
||||||
|
**re-derived, not deleted** — spec §7.1, §7.4.
|
||||||
|
|
||||||
|
**Open questions.**
|
||||||
|
- **[propose at review]** Whether the greedy whole-group wrap survives at all as a sub-floor
|
||||||
|
degrade, or is replaced outright by explicit row assignment. What is **not** optional: at
|
||||||
|
and above the floor width the layout is the specified arrangement, reached by construction.
|
||||||
|
`DeckLayout::rowCount`/`::height` change meaning either way (spec §7.3).
|
||||||
|
- **No [Daniel] questions.** Forks Γ-F5 (**one cell**) and Γ-F1 (**680 stays**) are both
|
||||||
|
ruled; they are stated in Behavior above, not carried here as options.
|
||||||
|
- **[verify]** `deck_groups.cpp`'s `kEnvModeSegW = 23` ceiling rises to **47** once PITCH ENV
|
||||||
|
is on row 2 (AMP binds at 55). No change is required; the comment stating the old ceiling
|
||||||
|
stops being true and must be corrected (spec §7.2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Γ-W4 — The Preserve time-stretcher
|
||||||
|
|
||||||
|
**Depends on Γ-W2 for:** Rate existing as a shipped, working control on the interim
|
||||||
|
resample+cancel path. Landing the stretcher against a working feature makes it a measurable
|
||||||
|
quality upgrade with an A/B; landing them together would make one track responsible for both
|
||||||
|
a feature and an algorithm.
|
||||||
|
|
||||||
|
**One track.** **This is the phase's largest unknown** — flagged the way Ξ-W2-T1's crossing
|
||||||
|
was. If the measure-and-report gate fails, what gives is the *algorithm choice*, not Rate:
|
||||||
|
the interim path already ships in W2-T1, so the fallback is "keep the interim path and record
|
||||||
|
why," not "cut the feature."
|
||||||
|
|
||||||
|
#### Γ-W4-T1 — `preserve-time-stretch`
|
||||||
|
|
||||||
|
**Goal.** Replace the interim resample+cancel composition with a real pitch-preserving
|
||||||
|
time-stretcher written from established state-of-the-art literature.
|
||||||
|
|
||||||
|
**Spec:** `docs/product/instrument-control-surface.md` §2.5.
|
||||||
|
|
||||||
|
**Surface boundary — owns:** `core/instrument/engine/pitch_shift` and whatever new pure
|
||||||
|
module the stretcher needs, plus `voice.cpp`'s Preserve read path. **Does not own** any
|
||||||
|
parameter, any UI, or the varispeed path.
|
||||||
|
|
||||||
|
**Behavior and constraints.** The algorithm is **the engineer's call under a
|
||||||
|
measure-and-report gate — this plan deliberately names none.** The constraints:
|
||||||
|
- **CPU stance (Daniel, verbatim intent):** *"we should be efficient but accept the cost of
|
||||||
|
high-quality algorithm choices. It's 2026, most people's computers can handle audio with
|
||||||
|
ease. Just don't be wasteful."*
|
||||||
|
- **RT-safe:** no allocation, no I/O, no lock in `process()`; buffers sized at voice
|
||||||
|
allocation or at the off-audio-thread reload, on `pitch_shift`'s existing pre-warm
|
||||||
|
precedent.
|
||||||
|
- **Per-voice state, holding up at the 32-voice ceiling.** The gate is 32 simultaneous
|
||||||
|
Preserve voices at 50 % and 200 %, not one voice at 100 %.
|
||||||
|
- **No new third-party dependency** (`pitch_shift`'s standing property).
|
||||||
|
- **No dispatch on the per-sample path** — concrete, inlineable types; no `IStretcher`.
|
||||||
|
- **Onset behaviour is a regression surface.** GA2 eliminated Preserve's ~25 ms onset latency
|
||||||
|
by priming the ring with the actual upcoming source. **A stretcher that reintroduces an
|
||||||
|
onset delay or a first-frame smear is a regression, not a trade-off.**
|
||||||
|
|
||||||
|
**Acceptance criteria.**
|
||||||
|
- **Rate 100 % with no shift is bit-identical to the un-stretched read.**
|
||||||
|
- Preserve speaks on frame 0 — no added onset latency, no first-frame smear, in any
|
||||||
|
rate/shift combination.
|
||||||
|
- No audible metallic or phasey artefacting on sustained tonal material at ±6 st and
|
||||||
|
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
|
## 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.
|
||||||
@@ -652,6 +1373,13 @@ 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
|
||||||
|
(2026-08-01), not from `TODO-1.0.md`. Listed here as a block rather than per track,
|
||||||
|
because the whole phase is outside the source doc; the product reasoning lives in
|
||||||
|
`docs/product/instrument-control-surface.md` and the automation scoping it defers in
|
||||||
|
`docs/product/parameter-automation.md`. Γ-W3-T1 additionally **discharges** the
|
||||||
|
`docs/TODO.md` deck-rework entry, whose original "one row of taller decks with
|
||||||
|
within-deck stacking" shape Daniel explicitly superseded.
|
||||||
|
|
||||||
### Deliberate compressions
|
### Deliberate compressions
|
||||||
|
|
||||||
@@ -713,8 +1441,21 @@ Phase Ξ — The resample loop (W1 concurrency-safe with Θ from Θ-W
|
|||||||
W1 Consolidated tracking, and the programmed-note model
|
W1 Consolidated tracking, and the programmed-note model
|
||||||
T1 tracking-consolidation .................. 17
|
T1 tracking-consolidation .................. 17
|
||||||
T2 note-program-model ...................... 15 (model)
|
T2 note-program-model ...................... 15 (model)
|
||||||
W2 The bake chain [requires all of Phase Θ]
|
W2 The bake chain [requires all of Phase Θ, and Phase Γ before it]
|
||||||
T1 resample-bake-chain ..................... 15 (chain)
|
T1 resample-bake-chain ..................... 15 (chain)
|
||||||
W3 The capture-signal popup
|
W3 The capture-signal popup
|
||||||
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)
|
||||||
|
W1 Foundations [3 tracks, disjoint by surface]
|
||||||
|
T1 knob-interaction-law ....... modifiers + ms/semitone tapers + reset bypass
|
||||||
|
T2 master-bus-audio ........... limiter + meter ballistics + processor [payload v14]
|
||||||
|
T3 contour-trace-curves ....... staged traces draw curved, knot on its trace
|
||||||
|
W2 New controls, and the overlay's marks [2 tracks]
|
||||||
|
T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [payload v15]
|
||||||
|
T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard
|
||||||
|
W3 The reflow [1 track]
|
||||||
|
T1 deck-reflow ................ two rows + double-height MASTER; floor 1190x680
|
||||||
|
W4 Preserve time-stretch [1 track]
|
||||||
|
T1 preserve-time-stretch ...... real stretcher [measure-and-report gate]
|
||||||
```
|
```
|
||||||
|
|||||||
+40
-36
@@ -208,50 +208,54 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
|
|||||||
|
|
||||||
**Done looks like.** Switching projects mid-record leaves the recorded file with a ledger record in the project it belongs to, so a later prune of that project can reclaim it normally.
|
**Done looks like.** Switching projects mid-record leaves the recorded file with a ledger record in the project it belongs to, so a later prune of that project can reclaim it normally.
|
||||||
|
|
||||||
## The deck layout needs a real rework — one row, taller decks, controls stacked within a deck
|
## Raise the stage-time ceiling above 2 s for long-decay sound design
|
||||||
|
|
||||||
**Context (what shipped — Θ-W4-T2).** The VELOCITY deck group landed with the amp, pitch,
|
**Context (Daniel, 2026-08-01 — Phase Γ fork Γ-F3).** Every stage-time knob spans `[0, kEnvTimeMaxSeconds]` (`core/instrument/ui/deck_values.h:22`), which reads the AHDSR overlay's schematic scale `kGateStageMaxSeconds = 2.0` (`core/instrument/ui/envelope_overlay.h:85`). Γ-F3 asked whether Phase Γ's log taper should also raise that ceiling. **Ruled: not in Phase Γ — the ceiling stays 2.0 s.** But the ambition is real and is recorded here rather than dropped: Daniel *"can foresee wanting 10-second stages"* for certain sound-design cases, his example being **"a horrifically long decay with tight exp[onent]"** — a very long stage whose curve exponent puts almost all the motion at one end. That shape is unreachable at a 2 s ceiling.
|
||||||
and filter velocity-curve buttons together, but the group placement lands wherever the
|
|
||||||
existing per-row deck layout happens to put it. Daniel does not want multiple rows of
|
|
||||||
decks. He wants **one row**, with the decks **much taller** to accommodate the controls —
|
|
||||||
knobs stacked *within* a deck rather than strung out in a long line of small knobs. His
|
|
||||||
example: the filter's static control knobs go **above** the filter envelope knobs.
|
|
||||||
**He has further design notes coming**, so this is not ready to implement — it is
|
|
||||||
recorded here so it is not lost and not attempted piecemeal.
|
|
||||||
|
|
||||||
**The wart.** Θ-W4-T2's acceptance criterion "VELOCITY sits immediately to the left of
|
**Why it is deferred rather than done.** It is a musical-range question, not a correctness one, and the right time to answer it is with the new taper in the DAW under the hand — a 5× ceiling change judged from a spec is a guess.
|
||||||
the VOICE group" is **not met at the default window size**. This is **superseded by the
|
|
||||||
deferred rework**, not silently dropped — recorded here so a later reader does not see an
|
|
||||||
unmet criterion with no explanation.
|
|
||||||
|
|
||||||
**Intended fix.** Not yet specified beyond the shape above (one row, taller decks,
|
**Two things landing in Phase Γ make it cheap afterwards.**
|
||||||
within-deck knob stacking) — Daniel's further design notes are still to come.
|
1. **The log taper (Γ-W1-T1) is what makes a higher ceiling usable rather than unusable.** Under the current linear map, a 10 s ceiling would put 10 ms at 0.001 of travel — one pixel of a 128 px drag — so the ceiling and the low-end resolution were in direct conflict. Under the log taper the low end keeps its resolution regardless of where the ceiling sits (Γ-W1-T1's landmarks: 10 ms within 0.12–0.20 of travel, 100 ms within 0.42–0.52). **The taper is the prerequisite, and it is why this is a follow-up rather than a dead end.**
|
||||||
|
2. **`resetDeckParam` bypassing the taper (also Γ-W1-T1) removes the power-of-two blocker.** `deck_values.h:42-46` records that exact default recovery currently depends on the ceiling being a power of two — `x/2^n*2^n` is lossless, an arbitrary ceiling is not. **2.0 is a power of two; 10.0 is not**, so under today's norm round-trip a 10 s ceiling would land every stage-time reset a mantissa bit off its own default. Γ-W1-T1 makes reset write the default value directly, which retires that dependency outright.
|
||||||
|
|
||||||
**The constraint the fix MUST handle.** Not yet known — this is exactly why the rework is
|
**The constraint the change MUST handle.** `kEnvTimeMaxSeconds` and `kGateStageMaxSeconds` **move together, or not at all.** The AHDSR overlay's schematic scale is derived from the ceiling so that a maxed knob lands exactly at the canvas edge — the agreement requirement is documented at `deck_values.h:19-22`, and `deck_values.h` reads the overlay's constant rather than restating it precisely so the two cannot drift. Raising the ceiling therefore also stretches every drawn AHDSR: at 10 s, a 30 ms attack occupies 0.3% of the schematic's stage domain and becomes visually indistinguishable from zero. **Whether the overlay's schematic scale should stay linear in seconds at a 10 s ceiling, or take a taper of its own, is the real design question underneath this entry** — the constant change is trivial; keeping the drawing legible is not.
|
||||||
deferred rather than attempted against the current spec.
|
|
||||||
|
|
||||||
**The measured geometry — the data the rework starts from.** Measured at the 840 px
|
**Priority / risk.** Low / deferred by ruling. No correctness risk: the persisted payload stores raw seconds, so a ceiling change re-tapers needle angles only and every saved project reloads bit-identical (the same persistence-neutrality Γ-W1-T1's own taper changes are held to). **One timing risk that is NOT low:** if VST3 automation parameters ship before this, the ceiling becomes part of the host-facing normalization and moving it silently re-interprets every recorded automation point — the same one-way door `docs/product/parameter-automation.md` §4 states for the taper itself. **If this is wanted, it wants to happen before the parameter system, not after.**
|
||||||
floor window, `availWidth = 824`, Gate mode. Group widths: PITCH 150, PITCH ENV 204,
|
|
||||||
FILTER 440, FILTER ENV 252, AMP 252, VELOCITY 156, VOICE 152, MASTER 60. Row 1 = PITCH +
|
|
||||||
PITCH ENV + FILTER (818 of 824). Row 2 = FILTER ENV + AMP + VELOCITY (684). Row 3 = VOICE
|
|
||||||
+ MASTER (224). Deck height 280 px; waveform band 246 px against a 150 px two-lane floor,
|
|
||||||
so 96 px of margin. No reordering can fit two rows — 1666 px of groups plus 72 px of gaps
|
|
||||||
against a 1648 px two-row capacity. Two rows first fit at a 936 px window.
|
|
||||||
|
|
||||||
**Unresolved measurement discrepancy.** The VELOCITY↔VOICE adjacency threshold was
|
**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.
|
||||||
measured at a 864 px window by the implementation and stated as 872 px by code review;
|
|
||||||
the implementation flagged the difference rather than asserting the reviewer wrong, and
|
|
||||||
it was not re-measured because the deferral made it moot.
|
|
||||||
|
|
||||||
**Priority / risk.** Deliberate deferral, not oversight. Daniel wants to finish his
|
## The deck layout rework — SPECCED, and the original shape SUPERSEDED
|
||||||
design notes before this is attempted, to avoid building a layout that gets reworked
|
|
||||||
piecemeal.
|
|
||||||
|
|
||||||
**Done looks like.** One row of decks, each much taller than today's, with each deck's
|
**Status (2026-08-01): no longer a deferral. The design notes Daniel owed this entry have
|
||||||
controls stacked internally (e.g. the filter's static control knobs above its envelope
|
arrived, and they change the shape.** The rework is specced in
|
||||||
knobs) per Daniel's design notes once they land. The VELOCITY↔VOICE adjacency criterion
|
`docs/product/instrument-control-surface.md` §1 and sequenced as **Phase Γ** in
|
||||||
is re-evaluated (or explicitly retired) against the new layout rather than the old
|
`docs/PLAN.md`. This entry is retained only until that work lands, because one loose end
|
||||||
per-row one.
|
below (the Θ-W4-T2 acceptance criterion) still needs an explicit disposition.
|
||||||
|
|
||||||
|
**What was superseded, and confirmed superseded by Daniel.** The original entry recorded a
|
||||||
|
directive of Daniel's for **one row of much *taller* decks with knobs stacked *within* a
|
||||||
|
deck** (his example: the filter's static knobs above its envelope knobs). **The new framing
|
||||||
|
replaces that.** The decks stay **single-height with knobs side-by-side**; what becomes
|
||||||
|
one row is the **sound** category (PITCH/RATE, FILTER, VELOCITY, VOICE), with the three
|
||||||
|
envelope decks on a second **contour** row and MASTER as a double-height deck spanning both.
|
||||||
|
The within-deck stacking idea is retired, not deferred.
|
||||||
|
|
||||||
|
**The measured-geometry block that used to live here has been deleted, not moved.** It was
|
||||||
|
taken at the 840 px floor with `kDeckCellW = 48` and is wrong twice over — Θ-W6-T1 changed
|
||||||
|
both the floor (980) and the cell metrics (60 × 74). The current, re-derived geometry — every
|
||||||
|
group's width, both row totals, and the resulting 1190 × 680 floor — is the table in
|
||||||
|
`docs/product/instrument-control-surface.md` §1.2. **Do not resurrect the old numbers.**
|
||||||
|
The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with
|
||||||
|
them; it was measured against a layout that no longer exists.
|
||||||
|
|
||||||
|
**The one live loose end.** Θ-W4-T2's acceptance criterion *"VELOCITY sits immediately to
|
||||||
|
the left of the VOICE group"* is not met at the default window size today. Under the new
|
||||||
|
layout it **is** met by construction — row 1 is PITCH/RATE, FILTER, VELOCITY, VOICE, in that
|
||||||
|
order, at every window width — so the criterion is satisfied rather than retired. Confirm it
|
||||||
|
when Phase Γ-W3 lands and remove this entry.
|
||||||
|
|
||||||
|
**Done looks like.** Phase Γ-W3 (`deck-reflow`) has landed; the VELOCITY↔VOICE adjacency
|
||||||
|
criterion is confirmed met at the floor width; this entry is removed.
|
||||||
|
|
||||||
## The AA waveform stroke's cost on the docked bank panel's card thumbnails
|
## The AA waveform stroke's cost on the docked bank panel's card thumbnails
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
|||||||
|
# VST3 automation parameters — scoping, not scheduling
|
||||||
|
|
||||||
|
> Daniel, 2026-08-01: *"we need to scope the whole parameter system for automation, which
|
||||||
|
> will require redesigning the existing wiring."* And, on Rate being latched at note-on:
|
||||||
|
> *"we will need to analyze ALL the controls for LIVE use/automation use."*
|
||||||
|
|
||||||
|
**This is NOT Phase Γ work.** It is framing and open questions, written now because Phase Γ
|
||||||
|
makes two decisions whose cost changes permanently the day parameters ship, and because
|
||||||
|
knowing that is what makes Γ's ordering correct rather than arbitrary. Nothing here is
|
||||||
|
scheduled; nothing here is settled.
|
||||||
|
|
||||||
|
The one thing this doc *does* assert is a sequencing constraint — §4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current state, verified
|
||||||
|
|
||||||
|
**The instrument has zero VST3 automatable parameters.**
|
||||||
|
|
||||||
|
- `ReaSamplerProcessor::initialize` never populates the parameter list, so
|
||||||
|
`getParameterCount()` returns the SDK default **0**. A host sees no parameters at all.
|
||||||
|
- All state lives in the **`ComponentState` blob** — envelope v1…v11, params payload
|
||||||
|
v1…v13 (`core/instrument/map/component_state_io.h`), a cross-artifact contract the
|
||||||
|
extension's `instrument_drop` and the instrument's processor both read and write.
|
||||||
|
- `DeckParam` ids are **runtime-only and explicitly free to reorder** — the editor's
|
||||||
|
`ParamControl` is an alias of the same enum, and nothing persists an id.
|
||||||
|
|
||||||
|
Consequently the instrument today has: no host automation, no MIDI learn, no parameter
|
||||||
|
linking, no host-side modulation, and no generic FX panel. Everything is reached through the
|
||||||
|
plugin's own editor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What is actually on the table (name the prize properly)
|
||||||
|
|
||||||
|
Framing this as "automation" undersells it. **REAPER gives every exposed VST3 parameter a
|
||||||
|
parameter-modulation block for free** — an LFO, an envelope, an audio-follower/sidechain,
|
||||||
|
MIDI link, and parameter linking, per parameter, with no plugin-side work beyond declaring
|
||||||
|
the parameter.
|
||||||
|
|
||||||
|
So exposing parameters is not primarily about drawing automation lanes. It is the
|
||||||
|
instrument **gaining a modulation system it would otherwise have to design, build, persist
|
||||||
|
and draw itself.** A sampler with a host-provided LFO on filter cutoff, an envelope-follower
|
||||||
|
on drive, and MIDI-CC on pitch offset is a materially different instrument, and none of that
|
||||||
|
is our code.
|
||||||
|
|
||||||
|
That is the reason this is worth a phase. It is also the reason the parameter list's design
|
||||||
|
matters more than the plumbing: the list *is* the modulation matrix's rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The hard problems, in the order they bite
|
||||||
|
|
||||||
|
### 3.1 Parameter IDs become FOREVER-STABLE
|
||||||
|
|
||||||
|
A VST3 `ParamID` is recorded in the project file. Once shipped, the id ↔ meaning mapping is
|
||||||
|
**frozen forever** — the same discipline the extension's command-id strings and the VST3
|
||||||
|
class UIDs already carry, and the same discipline the params-payload version ladder carries.
|
||||||
|
|
||||||
|
**`DeckParam`'s "free to reorder" property dies the day parameters ship.** Anything that
|
||||||
|
wants to renumber, regroup or reorder that enum has to happen first. This is not a
|
||||||
|
theoretical concern: Phase Γ adds two entries to it.
|
||||||
|
|
||||||
|
Open: is `ParamID` the `DeckParam` value directly, or an independent id space with an
|
||||||
|
explicit mapping table? Direct is simpler and tempting; an independent space is what lets
|
||||||
|
the enum keep being a UI-ordering convenience. **Lean: independent id space with an explicit
|
||||||
|
frozen table**, on the same reasoning the command-id family already uses — the display order
|
||||||
|
and the wire identity should not be the same number.
|
||||||
|
|
||||||
|
### 3.2 The taper becomes the host-facing contract
|
||||||
|
|
||||||
|
VST3 parameters are normalized `[0,1]` with the plugin owning the taper
|
||||||
|
(`toPlain`/`toNormalized`). An automation envelope a user draws in REAPER is drawn against
|
||||||
|
the **normalized** value.
|
||||||
|
|
||||||
|
**Therefore: re-tapering a parameter after it ships silently re-interprets every recorded
|
||||||
|
automation point in every saved project.** A 40 %-of-travel node on an attack knob means
|
||||||
|
16 ms under a log taper and 800 ms under a linear one. There is no version ladder that can
|
||||||
|
fix this, because the data is in the host's project file, not ours.
|
||||||
|
|
||||||
|
Today, re-tapering is **free** — normalization exists only in `ui/deck_values.cpp` as a
|
||||||
|
display/interaction layer, and the payload stores raw engine values as doubles. That is a
|
||||||
|
property worth spending while we still have it. See §4.
|
||||||
|
|
||||||
|
### 3.3 Two sources of truth
|
||||||
|
|
||||||
|
VST3 saves parameter values *and* calls `setState`/`setComponentState`. A value that lives
|
||||||
|
in both can drift — a project reloaded with automation could restore the blob's value and
|
||||||
|
then have it immediately overwritten by the host's parameter value, or the reverse,
|
||||||
|
depending on call order.
|
||||||
|
|
||||||
|
Two coherent answers, both viable, neither obviously right:
|
||||||
|
|
||||||
|
- **(a) Parameters are the source of truth** for everything automatable; the blob carries
|
||||||
|
only non-automatable structure — sample refs, velocity curves, spline contours, mode
|
||||||
|
selections, the loaded capture. Cleanest host semantics; largest rewrite; and it splits
|
||||||
|
the parameter set across two storage mechanisms, which the "one parameter set" invariant
|
||||||
|
went out of its way to unify.
|
||||||
|
- **(b) The blob stays authoritative; parameters are a projection** with one defined sync
|
||||||
|
direction and a defined precedence at load. Smallest change; preserves the cross-artifact
|
||||||
|
blob contract intact; risks exactly the drift class above if the precedence is not
|
||||||
|
airtight.
|
||||||
|
|
||||||
|
Open. This is the load-bearing architectural decision of that phase and it should be made
|
||||||
|
first, the way Ξ-W2-T1's crossing decision was.
|
||||||
|
|
||||||
|
### 3.4 Which controls can be parameters at all — three classes
|
||||||
|
|
||||||
|
The good news: **this analysis is already done once, in one place.** `isLiveDeckParam` /
|
||||||
|
`liveCommitFor` (`ui/deck_groups`) is exactly "which controls can change without a rebuild,"
|
||||||
|
which is the same question automation asks. Phase Γ widens it from two states to three
|
||||||
|
(§3.5). The parameter work should widen the *same* decision point again rather than start a
|
||||||
|
second table — that is the standing rule (`core/instrument/CLAUDE.md`: *"which controls are
|
||||||
|
live is ONE decision, recorded in ONE place"*).
|
||||||
|
|
||||||
|
| Class | Examples | Automatable? |
|
||||||
|
|---|---|---|
|
||||||
|
| **Continuous, live-safe** | filter cutoff/Q/morph/drive/mod amt/key-track, every stage time and level, pitch env depth, master gain, pitch offset | **Yes** — the live tier already delivers them at block boundaries |
|
||||||
|
| **Discrete / rebuild-tier** | voice count, Poly/Mono, Retrig/Legato, Gate/Trigger, Staged/Spline, pitch engine, filter law | **Only if** each gains a live path, or is exposed as a stepped parameter that is explicitly *not* safe to automate at rate. Today they route through `rebuildVoiceEngine` or a full reload — neither is RT-safe at automation rates |
|
||||||
|
| **Structural** | the loaded capture, `SampleRefs`, the three velocity curves, the three spline contours, loop points, root note | **No.** These are not scalars; they stay in the blob |
|
||||||
|
| **Latency-changing** | the **limiter enable** | **No** — and for a reason unrelated to the live tier. Flipping it changes reported latency, which the SDK defines as a host deactivate/reactivate. §3.8 |
|
||||||
|
|
||||||
|
The awkward middle class is the second row, and it is where the design work is.
|
||||||
|
|
||||||
|
### 3.5 Live vs. latched is a per-parameter decision, and Γ opens the seam
|
||||||
|
|
||||||
|
Phase Γ settles that **Rate is latched at note-on**, and — importantly — settles it as a
|
||||||
|
*third commit class* (`Live` / `NoteOnLatched` / `Reload`) recorded in the same
|
||||||
|
`deck_groups` predicate, rather than as a special case at a call site. See
|
||||||
|
`docs/product/instrument-control-surface.md` §2.3.
|
||||||
|
|
||||||
|
That is exactly the vocabulary the parameter system needs. A VST3 parameter has to declare
|
||||||
|
what it means to move it mid-note, and the answer is per parameter:
|
||||||
|
|
||||||
|
- **Live** — the sounding voice follows (the φ-holding `applyLive` rule).
|
||||||
|
- **Note-on latched** — published, but read only by `snapLive`. Automation still works; it
|
||||||
|
just takes effect on the next note. Rate is here, and the *reason* it is here is recorded:
|
||||||
|
loop resolution and contour mapping are note-on folds, so live rate means re-folding a
|
||||||
|
resolved loop mid-note without a discontinuity.
|
||||||
|
- **Not automatable** — rebuild or structural.
|
||||||
|
|
||||||
|
**Lifting Rate from latched to live is a real feature with a named cost, not a flag flip.**
|
||||||
|
When someone proposes it, that is the paragraph to read first.
|
||||||
|
|
||||||
|
### 3.6 Parameter count, grouping, and the generic panel
|
||||||
|
|
||||||
|
~60 `DeckParam`s plus the non-deck controls (voice count, master gain, key-track, preview
|
||||||
|
velocity, limiter — though the limiter enable is **excluded** from the automatable set, see
|
||||||
|
§3.8). REAPER's generic FX panel and every automation-lane picker will list all of them flat
|
||||||
|
unless they are grouped.
|
||||||
|
|
||||||
|
VST3's answer is `IUnitInfo` — a unit tree that maps naturally onto the deck's own group
|
||||||
|
structure (PITCH/RATE, FILTER, VELOCITY, VOICE, the three envelopes, MASTER). Also needed
|
||||||
|
per parameter: a display name that survives truncation, `ParameterInfo::units`,
|
||||||
|
`stepCount` for discretes, and the right flags (`kCanAutomate`, `kIsBypass` — the limiter
|
||||||
|
toggle is emphatically **not** the plugin's bypass parameter).
|
||||||
|
|
||||||
|
Open: does the unit tree mirror the deck's *visual* grouping (which Phase Γ has just
|
||||||
|
reflowed into two rows) or the engine's signal flow? They currently agree. Keeping them
|
||||||
|
agreeing is a constraint worth stating before they diverge.
|
||||||
|
|
||||||
|
### 3.7 Sample-accurate automation vs. block-boundary observation
|
||||||
|
|
||||||
|
The live-parameter seqlock is observed **once per `render()` and once per note-on** — block
|
||||||
|
boundaries, by design, and that design is what keeps the per-sample path free of
|
||||||
|
indirection. VST3's `IParameterChanges` can carry multiple points inside one block.
|
||||||
|
|
||||||
|
Block-boundary application is standard, acceptable, and what most instruments do. Reading
|
||||||
|
the points sample-accurately would put a per-sample "has a parameter changed" question on
|
||||||
|
the hot path, which the phase-wide guardrail forbids in its current form. **Lean: block
|
||||||
|
boundary, explicitly, and say so — then revisit only if a user reports audible stepping on a
|
||||||
|
fast automation curve.**
|
||||||
|
|
||||||
|
### 3.8 Latency reporting — SETTLED, and it removes one control from the parameter list
|
||||||
|
|
||||||
|
**Fork Γ-F2 is ruled (Daniel, 2026-08-01): the limiter has lookahead and the plugin reports
|
||||||
|
latency DYNAMICALLY** — zero when the limiter is off, the lookahead when it is on. Do not
|
||||||
|
plan against a zero-latency instrument.
|
||||||
|
|
||||||
|
The consequence for this doc is concrete and it is a **subtraction from the parameter list**:
|
||||||
|
|
||||||
|
> **The limiter enable is NOT automatable.** It goes in §3.4's third class, and the reason
|
||||||
|
> is not that it lacks a live path — it is that changing it changes the plugin's reported
|
||||||
|
> latency, and the vendored SDK defines `restartComponent(kLatencyChanged)` as *"the host
|
||||||
|
> has to deactivate and reactivate the plug-in"*
|
||||||
|
> (`pluginterfaces/vst/ivsteditcontroller.h:105-108`). In this plugin a deactivate frees
|
||||||
|
> every sounding voice and a reactivate re-decodes the WAV. **An automation lane toggling
|
||||||
|
> that parameter would deactivate the plugin on every flip.**
|
||||||
|
|
||||||
|
Two corollaries the parameter work must carry rather than rediscover:
|
||||||
|
|
||||||
|
- **It is also not the plugin's `kIsBypass` parameter.** A safety limiter is not a bypass;
|
||||||
|
binding it to `kIsBypass` would hand the host a control that restarts the component.
|
||||||
|
- **Latency reporting must be derived from persisted state, not from a transient.** The SDK
|
||||||
|
states the new latency is what `getLatencySamples` returns *after* `setActive(true)` — and
|
||||||
|
this plugin's `setActive(false)` frees essentially everything. Whatever holds the limiter
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The sequencing assertion — the one thing this doc claims
|
||||||
|
|
||||||
|
**The knob-taper work (Phase Γ item D) must land before the parameter system. Not
|
||||||
|
alongside; before.**
|
||||||
|
|
||||||
|
- Re-tapering is **free today** — normalization is a UI layer, the payload stores raw engine
|
||||||
|
doubles, and a re-taper moves the needle angle and nothing else (saved projects reload
|
||||||
|
bit-identical).
|
||||||
|
- Re-tapering is **permanently expensive after parameters ship** — the taper becomes the
|
||||||
|
host-facing normalization, and changing it re-interprets recorded automation in project
|
||||||
|
files we do not own and cannot migrate (§3.2).
|
||||||
|
|
||||||
|
The same argument, weaker but real, applies to `DeckParam` additions and reordering (§3.1):
|
||||||
|
Phase Γ adds Rate and Pitch to that enum, and doing it now costs one exhaustive-switch
|
||||||
|
update; doing it after parameters ship costs a frozen-id decision.
|
||||||
|
|
||||||
|
**So the tension the brief flags is real, and it resolves in Γ's favour.** Both item D and
|
||||||
|
the parameter system touch `deck_values.cpp` and `editor_controls.cpp`, and the ordering is
|
||||||
|
not a coin flip: item D first, by a wide margin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Open questions, collected
|
||||||
|
|
||||||
|
Not one of these is a Phase Γ blocker. They are what a future phase's first wave answers.
|
||||||
|
|
||||||
|
1. **Blob vs. parameters as the source of truth** (§3.3) — the architectural decision, made
|
||||||
|
first, before any wiring.
|
||||||
|
2. **`ParamID` space** — direct `DeckParam` values, or an independent frozen table (§3.1).
|
||||||
|
*Lean: independent.*
|
||||||
|
3. **The discrete/rebuild-tier controls** (§3.4) — for each of voice count, Poly/Mono,
|
||||||
|
Retrig/Legato, Gate/Trigger, Staged/Spline, pitch engine, filter law: gain a live path,
|
||||||
|
expose as non-automatable, or omit from the parameter list entirely.
|
||||||
|
4. **Per-parameter live/latched classification** (§3.5) — the three-state predicate Phase Γ
|
||||||
|
opens must be filled in for every exposed parameter, including whether Rate is lifted to
|
||||||
|
live and at what cost.
|
||||||
|
5. **Unit tree shape** (§3.6) — mirror the deck's visual grouping, or the signal flow.
|
||||||
|
6. **Sample-accurate vs. block-boundary** (§3.7). *Lean: block boundary, stated explicitly.*
|
||||||
|
7. **Does the extension's `instrument_drop` need to know?** It writes the same
|
||||||
|
`ComponentState` bytes. If parameters become the source of truth for automatable values
|
||||||
|
(option (a)), a blob the extension writes would no longer fully determine the instrument's
|
||||||
|
sound. That is a cross-artifact contract change and it is the sharpest hidden cost of
|
||||||
|
option (a).
|
||||||
|
8. **Migration.** Every saved project predates parameters. On first load a parameter's value
|
||||||
|
comes from the blob; after that the host owns it. Confirm that path is lossless, and
|
||||||
|
confirm what happens to a project saved *by* the new build and opened by an older one
|
||||||
|
(the blob still carries everything, so it should be fine — verify, do not assume).
|
||||||
Reference in New Issue
Block a user