docs: spec live-parameter delivery as Theta-W3-T1

Note-on latching rejected for continuous controls. New serial track ahead of
staged-envelope-curves (renumbered T2); edit-model and mid-stage-rule questions
left open for Daniel. Records two deferred filter follow-ups.
This commit is contained in:
2026-07-30 18:33:01 -04:00
parent 413967a205
commit 7bd911d58b
2 changed files with 342 additions and 22 deletions
+310 -22
View File
@@ -28,11 +28,21 @@ transliterate: **Θ → `th`**, **Ξ → `xi`**. So Θ-W1-T1 dispatches into
## Decision state
**Nothing in this plan awaits a Daniel decision.** Every question carried forward from
`TODO-1.0.md` is classified **[verify]** (answerable by reading code or running the DAW)
or **[propose]** (a design call made at implementation review with a proposal, not a
Daniel call). That classification is preserved per question, attached to the track that
will answer it.
**Two questions in this plan await a Daniel decision, and both are in Θ-W3-T1.**
Everything carried forward from `TODO-1.0.md` is still classified **[verify]**
(answerable by reading code or running the DAW) or **[propose]** (a design call made at
implementation review with a proposal, not a Daniel call); that classification is
preserved per question, attached to the track that will answer it.
Θ-W3-T1 (`live-parameter-delivery`) is the exception, and it is an honest one: it is not
from `TODO-1.0.md` — it arose from Θ-W2-T1's implementation review — and it carries two
genuine **[Daniel]** questions that no amount of code-reading answers. **Which edits stop
triggering an instrument reload** is an edit-model call about the product's shape, and
**what a stage-time change does to a voice already inside that stage** is sound-defining.
Both are stated in that track with candidates laid out and neither pre-picked. The track
should not be dispatched at full scope until they are answered; a reduced-scope fallback
is named there so the track is not hard-blocked if Daniel would rather rule after seeing
the mechanism work.
### Flagged for awareness — not blocking, but decision-grade
@@ -169,21 +179,287 @@ the chrome band — and none re-allocated the band stack.
---
### Θ-W3 — The staged envelope system
### Θ-W3 — Live parameters, then the staged envelope system
**Depends on W2 for:** the filter envelope's existence — items 1, 8, and 14 govern
*three* envelopes, and the filter is the third; and for the Filter deck, which must
exist before it can receive a corner radio switch and inner curve dials.
exist before it can receive a corner radio switch and inner curve dials. T1 additionally
depends on W2-T1 for the filter itself: the filter is the first control set where the
latched-at-note-on delivery model fails audibly, and it is what made the defect visible.
**One track.** This is the phase's densest collision: the envelope parameter model, the
per-sample envelope evaluation, the deck-band inner dials, and the waveform-band overlay
are one system with one source of truth (the settled S-VIEW rule: node-drag and
slider-edit read/write the same model, structurally, not via a listener chain). Splitting
it into model and UI tracks would put two writers on `zone_params.h` +
`component_state_io` and two writers on the overlay's forward/inverse map pair. The
serialization is the correct answer.
**Two tracks, and they are SERIAL — T1 before T2. This is not a priority hint.** Θ-W4's
two tracks are disjoint and merely ordered; these two are not. T1
(`live-parameter-delivery`) changes how every playback parameter *reaches* a sounding
voice; T2 (`staged-envelope-curves`) changes what *shape* the three envelopes have. Both
land inside `envelopes.h`'s stage math: T1 has to define what happens to a stage's
counters when its duration changes mid-flight, and T2 rewrites the same level
computation to apply a per-segment curve exponent and to grow the Trigger AHD. Two
writers on those forty lines is a merge fight over the phase's hottest code.
#### Θ-W3-T1 — `staged-envelope-curves`
The ordering is T1-first for one structural reason: **T2 adds parameters, T1 defines how
parameters are delivered.** Authoring T2's curve exponents, Hold fraction, and Trigger
AHD fields under the latching model means adding them, then re-plumbing every one of them
through the live path afterwards — a backfill cliff, and the exact shape of avoidable
rework. T1 first means every parameter T2 introduces is authored into the delivery
mechanism from the start. The cost of the ordering is honest and worth stating: T2 is the
wave's user-visible payload and T1 delays it by one track.
T1 must specify its mid-stage rule in terms that survive T2 — see that track's open
question 2, whose candidates are deliberately expressed over *normalized stage position*
rather than over output level, because T2's exponent is a pure map of that position and
must compose with the rule rather than invalidate it.
Beyond the ordering, the W3 collision is the phase's densest, which is why neither track
splits further: the envelope parameter model, the per-sample envelope evaluation, the
deck-band inner dials, and the waveform-band overlay are one system with one source of
truth (the settled S-VIEW rule: node-drag and slider-edit read/write the same model,
structurally, not via a listener chain). Splitting T2 into model and UI tracks would put
two writers on `play_params.h` + `component_state_io` and two writers on the overlay's
forward/inverse map pair. The serialization is the correct answer.
#### Θ-W3-T1 — `live-parameter-delivery`
**Goal.** Retire note-on latching as the delivery model for continuous playback controls:
a knob moved while a note is sounding changes *that* note, not merely the next one.
Establishes the live-parameter block and its publish path, splits the editor's commit
routing so a parameter edit stops rebuilding the instrument, and makes reachable a
property the filter DSP was deliberately built with and that nothing currently exercises.
**Consolidates:** nothing from `TODO-1.0.md`. This track carries no item number — it
arose from Θ-W2-T1's implementation review. See "Work in this plan that is not one of the
seventeen" below the traceability table.
**Origin — Daniel's ruling, recorded verbatim** (2026-07-30, on the filter's latched
cutoff):
> *"hell no, I was going to bring that up for the other envelopes. We must live compute,
> latching the parameters at note on is not acceptable. long term these will be
> automatable parameters."*
The ruling rejects the **precedent, not one instance of it.** Every playback parameter is
latched into the `Voice` at note-on today — the filter (Θ-W2-T1), the amp AHDSR, and the
pitch envelope alike. It also silently defeats a property that was built on purpose:
`src/core/instrument/engine/filter/CLAUDE.md` records that `prepare()` does **not** clear
state precisely so a live parameter move glides rather than clicks. Nothing reaches it.
**The architectural finding — read this before scoping the track.** A staff-engineer
attempted the live-cutoff fix inside Θ-W2-T1 and correctly stopped. The fix is **not
local to the engine:**
- An editor knob release calls `commitAndReload()``reloadInstrument()`, which re-reads
the bridge, **re-decodes the WAV from disk**, builds a fresh `SampleData` +
`VoiceEngine`, and atomic-swaps it into `live_`.
- The displaced instrument moves to `draining_`, where already-sounding voices keep
rendering **their own frozen `SampleData`**. New note-ons route only to `live_`.
- Therefore a `Voice` that read `sample_->play` per frame instead of latching would
**still** not move a sustaining note: that note lives in a different snapshot, and that
snapshot is by design never updated again.
Two consequences bind the whole track. The live-parameter block's ownership must sit
**above** `LoadedInstrument` so `live_` and `draining_` observe the same one — a block
owned by a snapshot reproduces the defect exactly. And the set of edits that trigger a
reload has to shrink; that is the open product question below.
**Three commit tiers already exist. The work is reassigning edits across them, not
inventing a taxonomy.**
1. **Full reload**`commitAndReload()``reloadInstrument()`: bridge read, WAV
re-decode, fresh engine, snapshot swap. Today: every parameter edit.
2. **Engine rebuild**`rebuildVoiceEngine()`: rebuilds around the already-decoded
`SampleData`, no disk, same drain-slot swap. Today: voice count, voice mode, mono
trigger.
3. **Live**`masterGain_`: a lock-free atomic the audio thread reads per block and
ramps toward per sample (`gainCurrent_` / `gainRampStep_`), no rebuild, no snapshot.
Today: master gain alone.
Tier 3 is the shape this track generalizes, and it is a **house precedent already
shipped and already zipper-free** — not a pattern imported from elsewhere. Read it before
designing the block.
**Behavior — what must become live.** Every continuous user-facing playback control:
- **Filter** — cutoff, Q, morph, drive, mod amount, key-track.
- **All three envelopes** — every stage time and every stage level, on the amp AHDSR, the
pitch envelope, and the filter AHDSR alike.
**Behavior — what stays latched per note, where making it live is a bug.** These are
facts about the note event, not controls, and a design that "generalizes" them into the
live block has misread the ruling:
- **Velocity**, and everything derived from it — in particular the velocity-curve
evaluation result (`velocityGain_`, `filterVelOffset_`), which is evaluated once in
`Voice::start` on purpose.
- **The note number**, and the pitch ratio derived from it against the root note
(`baseRatio_`). Moving a root-note or key-track control must not retune a sounding
note; the legato `retune()` path is the only sanctioned mid-note pitch move.
- **The sample/capture identity and its decoded PCM.** Loading a different capture is a
new sound, not a parameter change.
**Behavior — the live block.** Requirements, stated as constraints rather than as a
chosen design:
- **Ownership outlives every `LoadedInstrument`.** One block per processor instance,
observed identically by `live_` and `draining_`. A drain-slot voice therefore keeps
tracking the knob — that is the *desired* behavior (it is the note the user is
hearing), and it should be stated so nobody later "fixes" it.
- **`PlayParams` stays a plain copyable value type.** `component_state_io`, `sample_map`,
and the editor all pass it by value; atomics cannot simply be pushed into it.
- **No lock on the audio path**, and **no torn read**: the editor writes on the UI
thread while `process()` reads on the audio thread, so observing half of one edit and
half of another mid-block is a real hazard the design must close, not a theoretical one.
- **No constructor growth, and no dependency grab-bag.** An `IServiceProvider`-shaped
parameter object threaded into `VoiceEngine`/`Voice` constructors is a smell and is
rejected. One candidate that satisfies both: a single `const LiveParams*` field on
`SampleData`, defaulting to `nullptr` so the bare core behaves exactly as today
(latched, byte-identical, existing tests untouched) and the shell sets it on the path
`SampleData` already travels. That is a [propose]-class mechanism note, not a mandate.
**Behavior — the per-frame constraint (Daniel's standing non-negotiable).** Root
`CLAUDE.md`'s performance guardrails and structural heuristic 3 both bind here:
- **No allocation and no virtual dispatch on the per-voice-per-sample path.** A per-frame
copy of a params struct is a violation; so is an added header→TU indirection.
`envelopes.h`'s evaluators and `Voice::advanceFrame` are header-inline by RT
constraint (no LTO configured) — concrete, no common base, no virtual `tick()`.
- **Observation happens at block boundaries, not per frame.** The voice already caches
its filter parameters in members (`filterCutoffNorm_`, `filterModAmount_`,
`filterKeyTrack_`, …); the minimal live implementation refreshes those cached members
at a block boundary instead of only at note-on, leaving the per-sample shape unchanged.
At 512 frames / 48 kHz that is ~93 Hz of control resolution — ample for a hand on a
knob, and the coarse floor for automation later.
- **Smoothing is required, and it is what makes the block-rate step inaudible.** A
block-rate jump in a base value is a step; `masterGain_`'s per-sample linear ramp
toward the target is the in-house answer. **The ramp must terminate exactly**, not
asymptotically: `tickFilterCutoff`'s two exact skips compare the value itself, so a
never-quite-arriving one-pole would pin the filter on the always-re-solve path forever.
Cost while a cutoff ramp is running is known and affordable — 15.5 ns/frame/voice
measured in `engine/filter/CLAUDE.md`, ~1.2% of one core at 16 voices — and it returns
to the skip path when the ramp completes.
**Acceptance criteria.**
- With a note held, sweeping filter cutoff, Q, morph, drive, mod amount, or key-track
audibly moves *that* note. Same for every stage time and stage level on all three
envelopes.
- A knob move on a live parameter performs **no** WAV re-decode and **no** snapshot
rebuild — verified against `reloadInstrument`, not by ear alone.
- A note sounding out of the **drain slot** responds to a live parameter move identically
to a `live_` voice.
- **No click, no step, no zipper** on any live parameter move, at any block size —
including a full-range cutoff sweep at maximum Q, and a sustain-level change on a note
held in Sustain.
- **The per-sample path costs nothing at rest.** With no live value changed, the render
is unchanged in call/inline shape from today; all live observation is at block
boundaries. No allocation, no lock, no virtual call is added on the audio thread.
- The audio thread never observes a partially-applied edit within one block.
- `PlayParams` is still a plain copyable value type, still passed by value by
`component_state_io`, `sample_map`, and the editor.
- **Velocity, the velocity-curve result, the note number, the pitch ratio, and the
decoded PCM are still latched at note-on.** Dragging a velocity-curve point does not
retune or re-gain a sounding note.
- **With no live block attached the core is byte-identical to today** — `sampler_core`'s
existing regression tests pass unchanged, including the bare-engine baselines.
- **Migration:** a project saved before this change reopens sounding identical. Persisted
values are unchanged; `ComponentState` gains no version bump unless the chosen
representation forces one, and if it does, the bump is additive and pre-existing blobs
lift with no audible change.
- The filter DSP's glide property is finally exercised by a test: a cutoff move across a
`prepare()` without a `reset()` produces no output discontinuity.
**Open questions.**
1. **Which edits stop triggering a reload? [Daniel]** Sample/capture selection genuinely
needs a reload; filter cutoff plainly does not. The line between them is an edit-model
decision, not an implementation one. Three candidate groupings, in widening order:
- **Grouping C — continuous controls only.** Only knob-valued continuous controls go
live (filter cutoff/Q/morph/drive/mod/key-track, envelope times and levels). Every
discrete toggle — play mode, pitch engine, filter law, filter enable, channel mode,
velocity curve — keeps reloading. Narrowest blast radius, smallest verification
surface, and it fully satisfies the ruling as stated.
- **Grouping B — capture-anchored edits reload.** C, plus: the three capture-anchored
overrides (root note, loop span, start frame) also reload, because they name
positions in the decoded PCM and `loadSelection` already treats them as one family.
Everything else goes live or drops to the tier-2 rebuild.
- **Grouping A — only the sound source reloads.** Reload if and only if the identity
of the decoded audio changes: capture selection and channel mode (a decode policy).
Everything else is live or a rebuild. Widest live surface, largest verification
surface, and the one that most nearly matches "these will be automatable
parameters."
No pick is made here. **Proposed fallback if Daniel would rather rule after seeing the
mechanism run:** build the mechanism and ship Grouping C, leaving B and A reachable as
later reassignments rather than rework — the tier a given edit sits in is a routing
decision at the editor's commit site, not a property of the block. That is a scope
proposal, not a decision taken.
2. **What does a stage-time change do to a voice already inside that stage? [Daniel]**
Sound-defining, and there is no single right answer. Bounded by one non-negotiable:
**it must not click.** A discontinuity in output level on a parameter move is a
defect, and a sustain level changed while a voice is held must glide, not step. The
candidates, with the click property of each named:
- **(i) Jump / recompute from absolute elapsed.** Keep `framesInStage_`, divide by the
new duration. This is what today's code would do if params were simply swapped —
`level_ = framesInStage_ / attackFrames`. Steps the level discontinuously. Fails the
non-negotiable unaided.
- **(ii) Clamp.** As (i), but a stage whose new duration is already exceeded completes
immediately. Still steps.
- **(iii) Re-derive rate from the current level.** Hold the level, recompute the
per-frame advance so the stage completes at the new duration measured *from now*.
Continuous by construction. Costs: the stage's total time becomes elapsed +
remaining rather than the dialed value, and it needs a rate representation. This is
what a hardware EG with a rate DAC does.
- **(iv) Hold normalized stage position.** Keep phase φ = elapsed / duration fixed
across the change, then advance at 1/newDuration. Continuous in level (φ unchanged →
level unchanged) and the remaining stage takes its share of the dialed duration.
- **(v) Track absolute elapsed against the new duration.** Identical to (i); listed so
it is visibly not a distinct third option.
- **(vi) Keep (i) and smooth the envelope output.** Accept the recompute and put a
short declick ramp on the envelope's output level — the codebase already owns a
bounded-blend declick primitive (`kDeclickDecay`, `seedDeclick`) built for exactly
this class of step.
Only **(iii)** and **(iv)** are continuous without added machinery, and both compose
with T2's curve exponent because both are expressed over normalized position, which
the exponent is a pure map of. **(vi)** is the option that makes the cheap rule
acceptable, at the cost of a second smoother. Sustain is a separate sub-case under
every candidate: it is a *level*, not a timed stage, so a live sustain edit is a direct
level step and needs the ramp regardless of which rule wins.
3. **Representation: atomics in the value type, or a second live representation?
[propose]** Two forks, both with real costs:
- **F1 — atomics in the value type.** Push atomics into the live-relevant fields of
`PlayParams` / `FilterParams` / `AdsrParams`. One model, no mapping to drift.
Blast radius is wide and probably disqualifying: atomics are non-copyable, and
`PlayParams` is passed by value by `component_state_io`, `sample_map`, and the
editor — the plain-copyable-value-type constraint above is a hard bound on how far
this fork can go.
- **F2 — a second live-parameter representation.** A parallel block the audio thread
reads, published by the UI thread. Keeps the value type plain; the cost is that the
model exists twice and the mapping between them can drift. Mitigation to require if
this fork wins: derive the live block from `PlayParams` through exactly **one**
explicit fold function, so there is a single writer and a single site to keep in
step. Three publication mechanisms sit under F2, and the choice among them is the
torn-read answer: a **seqlock** (odd/even generation counter, audio thread copies
the block once per block and retries on a torn read — the standard single-writer
lock-free pattern); **per-parameter atomics** (simplest, exactly what `masterGain_`
does today, but offers no coherence *across* parameters — a set like an envelope's
A/H/D/S/R can be observed mid-edit); or a **double-buffered block published by
atomic pointer swap** (coherent, no retry, needs a reclaim rule — and the processor
already owns that muscle in `live_`/`draining_`/`graveyard_`).
4. **Does the block need sample-accurate (sub-block) resolution now? [propose]** Daniel's
ruling names automation as the long-term destination, and VST3 delivers parameter
changes on the audio thread with sample offsets inside `ProcessData` — so per-block is
the coarse floor, not the ceiling. Building sub-block splitting now is speculative;
*foreclosing* it is the failure mode. The requirement this track must carry either
way: **the block's writer interface must not assume a UI thread**, so the audio
thread's own parameter-change queue can drive it later without a redesign.
5. **Does a live edit still persist immediately? [verify]** Today `commitAndReload` also
writes the edited set into `params_`, which is what `getState` serializes. A live path
must keep making that UI-thread write so a saved project carries the edit — but that
write is no longer the audio thread's source. Confirm by reading that nothing else
depends on the reload as its persistence trigger.
#### Θ-W3-T2 — `staged-envelope-curves`
**Prerequisite: Θ-W3-T1 must land first** — see the wave preamble. Both tracks write
`envelopes.h`'s stage math, and this track's curve exponent must be authored as a map of
whatever normalized stage position T1's mid-stage rule establishes.
**Goal.** Grow the envelope-overlay editor from an amp-only fixture into the shared
graphical surface for every envelope, give every envelope shapeable segments, fix the
@@ -1108,24 +1384,35 @@ The check that nothing was dropped. Every row points at a track that exists abov
| # | Item (short) | Phase-Wave-Track | Worktree slug |
|---|---|---|---|
| 1 | Envelope editor: radio switch, curve dials, overlay recolor | Θ-W3-T1 | `pth-w3-t1-staged-envelope-curves` |
| 1 | Envelope editor: radio switch, curve dials, overlay recolor | Θ-W3-T2 | `pth-w3-t2-staged-envelope-curves` |
| 2 | MM preamp Filter — resonant HP/LP stage | Θ-W1-T3 (DSP) **+** Θ-W2-T1 (integration) | `pth-w1-t3-filter-dsp-port`, `pth-w2-t1-filter-voice-path` |
| 3 | Alternative Spline EGs | Θ-W5-T1 | `pth-w5-t1-spline-egs` |
| 4 | Bug: end-of-sample click, Trigger × Preserve | Θ-W3-T1 | `pth-w3-t1-staged-envelope-curves` |
| 4 | Bug: end-of-sample click, Trigger × Preserve | Θ-W3-T2 | `pth-w3-t2-staged-envelope-curves` |
| 5 | Bug: drag-out sometimes lands without audio | Θ-W1-T2 | `pth-w1-t2-capture-handoff-bugs` |
| 6 | Bug: FX-container drop loses the capture | Θ-W1-T2 | `pth-w1-t2-capture-handoff-bugs` |
| 7 | Stereo waveform shows both channels | Θ-W2-T2 | `pth-w2-t2-stereo-waveform-lanes` |
| 8 | Release anchoring; the Pitch AD becomes AHD | Θ-W3-T1 | `pth-w3-t1-staged-envelope-curves` |
| 8 | Release anchoring; the Pitch AD becomes AHD | Θ-W3-T2 | `pth-w3-t2-staged-envelope-curves` |
| 9 | Loop points — regression + Gate loop-sustain | Θ-W4-T1 | `pth-w4-t1-gate-loop-sustain` |
| 10 | Knob/label sizing, ms units, double-click reset | Θ-W6-T1 | `pth-w6-t1-legibility-and-antialiasing` |
| 11 | Preview glyph; VELOCITY deck; bipolar curves | Θ-W4-T2 | `pth-w4-t2-velocity-deck-and-bipolar-curves` |
| 12 | Toolbar cleanup; full-width piano strip; tooltips | Θ-W2-T3 | `pth-w2-t3-toolbar-and-piano-strip` |
| 13 | Antialiased rendering audit for high-DPI | Θ-W6-T1 | `pth-w6-t1-legibility-and-antialiasing` |
| 14 | Trigger amp/filter fade → AHD consolidation | Θ-W3-T1 | `pth-w3-t1-staged-envelope-curves` |
| 14 | Trigger amp/filter fade → AHD consolidation | Θ-W3-T2 | `pth-w3-t2-staged-envelope-curves` |
| 15 | One-click in-sampler resample | Ξ-W1-T2 (note model) **+** Ξ-W2-T1 (bake chain) **+** Ξ-W3-T1 (popup) | `pxi-w1-t2-note-program-model`, `pxi-w2-t1-resample-bake-chain`, `pxi-w3-t1-capture-signal-popup` |
| 16 | Retire the zone mapping system | Θ-W1-T1 | `pth-w1-t1-zone-retirement` |
| 17 | Consolidate provenance/usage tracking | Ξ-W1-T1 | `pxi-w1-t1-tracking-consolidation` |
### Work in this plan that is not one of the seventeen
The table above is a completeness proof over `TODO-1.0.md` — every row points at a track,
so nothing from the source was dropped. It is deliberately **not** an index of the plan:
work that did not come from the source doc has no row, and inventing one would weaken the
proof it exists to give.
- **Θ-W3-T1 — `live-parameter-delivery`** (`pth-w3-t1-live-parameter-delivery`). Arose
from Θ-W2-T1's implementation review, not from `TODO-1.0.md`. It is the only such track
in this plan today; if others appear, they belong on this list rather than in the table.
### Deliberate compressions
Recorded so a reader of `TODO-1.0.md` can see what this plan did to the source, rather
@@ -1137,7 +1424,7 @@ than discovering it later:
acceptance criteria are split accordingly — the range/resonance assertions land in
W1-T3's tests, the audible/pipeline/deck criteria in W2-T1.
- **Item 2's filter envelope ships twice.** W2-T1 ships it in the existing staged AHDSR
shape; W3-T1 gives it the curve treatment and the mode-driven Gate→AHDSR /
shape; W3-T2 gives it the curve treatment and the mode-driven Gate→AHDSR /
Trigger→AHD shape. This is deliberate: waiting would put the filter behind the whole
envelope system.
- **Item 4 lands in Θ-W3, not Θ-W1.** Its fix region is the region item 14 retires, and
@@ -1169,8 +1456,9 @@ Phase Θ — ReaSampler 9000: one parameter set, filter, shapeable envelopes, le
T1 filter-voice-path ....................... 2 (integration)
T2 stereo-waveform-lanes ................... 7
T3 toolbar-and-piano-strip ................. 12
W3 The staged envelope system
T1 staged-envelope-curves .................. 1, 8, 14, 4
W3 Live parameters, then the staged envelope system [T1 before T2 — serial]
T1 live-parameter-delivery ................. (not one of the seventeen)
T2 staged-envelope-curves .................. 1, 8, 14, 4
W4 Loop sustain and the velocity deck
T1 gate-loop-sustain ....................... 9
T2 velocity-deck-and-bipolar-curves ........ 11