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:
+310
-22
@@ -28,11 +28,21 @@ transliterate: **Θ → `th`**, **Ξ → `xi`**. So Θ-W1-T1 dispatches into
|
|||||||
|
|
||||||
## Decision state
|
## Decision state
|
||||||
|
|
||||||
**Nothing in this plan awaits a Daniel decision.** Every question carried forward from
|
**Two questions in this plan await a Daniel decision, and both are in Θ-W3-T1.**
|
||||||
`TODO-1.0.md` is classified **[verify]** (answerable by reading code or running the DAW)
|
Everything carried forward from `TODO-1.0.md` is still classified **[verify]**
|
||||||
or **[propose]** (a design call made at implementation review with a proposal, not a
|
(answerable by reading code or running the DAW) or **[propose]** (a design call made at
|
||||||
Daniel call). That classification is preserved per question, attached to the track that
|
implementation review with a proposal, not a Daniel call); that classification is
|
||||||
will answer it.
|
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
|
### 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
|
**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
|
*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
|
**Two tracks, and they are SERIAL — T1 before T2. This is not a priority hint.** Θ-W4's
|
||||||
per-sample envelope evaluation, the deck-band inner dials, and the waveform-band overlay
|
two tracks are disjoint and merely ordered; these two are not. T1
|
||||||
are one system with one source of truth (the settled S-VIEW rule: node-drag and
|
(`live-parameter-delivery`) changes how every playback parameter *reaches* a sounding
|
||||||
slider-edit read/write the same model, structurally, not via a listener chain). Splitting
|
voice; T2 (`staged-envelope-curves`) changes what *shape* the three envelopes have. Both
|
||||||
it into model and UI tracks would put two writers on `zone_params.h` +
|
land inside `envelopes.h`'s stage math: T1 has to define what happens to a stage's
|
||||||
`component_state_io` and two writers on the overlay's forward/inverse map pair. The
|
counters when its duration changes mid-flight, and T2 rewrites the same level
|
||||||
serialization is the correct answer.
|
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
|
**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
|
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 |
|
| # | 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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
|
### Deliberate compressions
|
||||||
|
|
||||||
Recorded so a reader of `TODO-1.0.md` can see what this plan did to the source, rather
|
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
|
acceptance criteria are split accordingly — the range/resonance assertions land in
|
||||||
W1-T3's tests, the audible/pipeline/deck criteria in W2-T1.
|
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
|
- **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
|
Trigger→AHD shape. This is deliberate: waiting would put the filter behind the whole
|
||||||
envelope system.
|
envelope system.
|
||||||
- **Item 4 lands in Θ-W3, not Θ-W1.** Its fix region is the region item 14 retires, and
|
- **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)
|
T1 filter-voice-path ....................... 2 (integration)
|
||||||
T2 stereo-waveform-lanes ................... 7
|
T2 stereo-waveform-lanes ................... 7
|
||||||
T3 toolbar-and-piano-strip ................. 12
|
T3 toolbar-and-piano-strip ................. 12
|
||||||
W3 The staged envelope system
|
W3 Live parameters, then the staged envelope system [T1 before T2 — serial]
|
||||||
T1 staged-envelope-curves .................. 1, 8, 14, 4
|
T1 live-parameter-delivery ................. (not one of the seventeen)
|
||||||
|
T2 staged-envelope-curves .................. 1, 8, 14, 4
|
||||||
W4 Loop sustain and the velocity deck
|
W4 Loop sustain and the velocity deck
|
||||||
T1 gate-loop-sustain ....................... 9
|
T1 gate-loop-sustain ....................... 9
|
||||||
T2 velocity-deck-and-bipolar-curves ........ 11
|
T2 velocity-deck-and-bipolar-curves ........ 11
|
||||||
|
|||||||
@@ -2,6 +2,38 @@
|
|||||||
|
|
||||||
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
|
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
|
||||||
|
|
||||||
|
## The per-voice filter is solved against the WAV's sample rate, not the render rate
|
||||||
|
|
||||||
|
**Context (what shipped — Θ-W2-T1, the filter in the voice path).** `Voice::start` sets `filterRate_ = sample.sampleRate` — the rate read off the **decoded WAV header** — and hands it to `VoiceFilter::prepare()` and every later `setCutoffNorm()`. But the voice emits exactly one frame per **host** frame, so the rate the corner should be solved against is the project/render rate the processor already latches in `setupProcessing` (`ReaSamplerProcessor::sampleRate_`), not the file's. The rate enters the DSP only through `g = tan(pi*fc/sr)` (`engine/filter/CLAUDE.md`), so a wrong `sr` scales the realized corner by exactly the ratio of the two rates.
|
||||||
|
|
||||||
|
**The wart.** When capture rate ≠ project rate, the corner lands at the wrong frequency, by that ratio. A 44.1 kHz capture in a 48 kHz project puts the corner roughly **1.5 semitones sharp** (48000/44100 ≈ 1.088×); the same capture in a 96 kHz project is roughly **13.5 semitones off**. The Nyquist clamp (`kFilterNyquistFraction`) measures against the wrong Nyquist for the same reason. This falsifies the guarantee `filter_params.h` states in its own words — that the persisted value is a normalized knob position precisely so one preset does not sound different at 44.1k and 96k. The control law honors that; the solve defeats it.
|
||||||
|
|
||||||
|
**Intended fix.** Thread the host render rate onto `SampleData` and set `filterRate_` from it. The processor already holds `sampleRate_` from `setupProcessing` and already guards on it being non-zero before building, so the value is available at exactly the point `SampleData` is constructed — this is a plumbing change, not a new mechanism.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** The engine **already conflates the two rates everywhere** — `sample_map` resolves the AHDSR's stored seconds at the WAV's own rate, and nothing resamples the source — so a cross-rate capture already plays back sharp *and* short by the same ratio. This is an inherited assumption, not a defect introduced by the filter; the filter is simply the first module where it lands as an audible **frequency** error rather than a timing one. A fix that corrects only the filter leaves the filter rate-correct while envelope timing stays rate-wrong. That is strictly less wrong and defensible, but it splits one assumption into two, and the split must be a deliberate choice rather than a side effect of fixing the loudest symptom. Second constraint: `filterRate_ <= 0` must keep meaning **bypass** — the filter module forbids a reference, calibration, or fallback rate anywhere in itself, and a plumbing fix must not smuggle one in as a default.
|
||||||
|
|
||||||
|
**Priority / risk.** Deferred by ruling — Daniel, 2026-07-30: *"record this and proceed."* Inaudible whenever capture rate == project rate, which is the common case for captures this tool made in the project they belong to. Audible and large on an imported or cross-rate capture, and worse the further the two rates diverge.
|
||||||
|
|
||||||
|
**Done looks like.** The realized filter corner matches `filterCutoffHzFromNorm(pos)` within measurement tolerance at every combination of capture rate and project rate; the Nyquist clamp measures against the render rate; and the decision about whether envelope timing follows the same correction is recorded rather than left implicit.
|
||||||
|
|
||||||
|
## Filter ring-out is truncated on the source-exhaustion path
|
||||||
|
|
||||||
|
**Context (what shipped — Θ-W2-T1).** The per-voice filter runs between the pitch stage and the amp multiply. When `readPos_` runs past the end of the sample with no usable loop, `Voice::advanceFrame` latches `active_ = false` and returns 0 — the voice stops feeding, and whatever energy remains in the filter's two integrators is discarded rather than rung out.
|
||||||
|
|
||||||
|
**The wart.** The filter's tail is cut at source exhaustion instead of decaying to the filter's own denormal floor.
|
||||||
|
|
||||||
|
**Why the common case is unaffected.** A released Gate note's filter tail is shaped to silence by the **amp release** before the read head reaches the end — that is the pipeline ordering (pitch → filter → amp) working exactly as designed. Trigger's fade-out has already taken the amp to ~0 at `playEnd`, so the discarded state is multiplied by ~0 regardless. The exposed case is a voice that reaches source exhaustion with the amp envelope still open.
|
||||||
|
|
||||||
|
**Intended fix.** Let a voice keep rendering the filter past source exhaustion — zero input, filter ringing — until `VoiceFilter::isSilent()`.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle (why deferred).** Extending a voice past source exhaustion changes `active()` and `soundingNote()`, and those two predicates feed `VoiceEngine`'s oldest-first stealing policy and the Preserve-voice tally. A ring-out voice would hold an allocation slot and could suppress or be stolen by a note-on that today would be routed differently — a materially larger blast radius than the track that found the defect, which is why it is deferred rather than patched at the call site. The existing takeover declick already carves out an `active() && !soundingNote()` ring-out state; a filter ring-out would be a second occupant of that state and must compose with it rather than fight it.
|
||||||
|
|
||||||
|
**The caveat both reviewers recorded.** The discarded state can be roughly `2Q` larger than the source that produced it, so at high Q the cut **amplifies** the step that already existed at source exhaustion rather than merely preserving it. The defect gets worse the more resonance is dialled in — it is not a uniformly small residual.
|
||||||
|
|
||||||
|
**Priority / risk.** Low / deferred. Recorded during Θ-W2-T1 review and left for a track that can own the voice-lifetime predicates.
|
||||||
|
|
||||||
|
**Done looks like.** A high-Q filtered voice that reaches source exhaustion with the amp envelope still open decays to the filter's denormal floor rather than cutting, with no change to voice-stealing behavior, the Preserve tally, or the takeover-declick ring-out state.
|
||||||
|
|
||||||
## Persist ReaSampler 9000 instance identity to let prune reclaim de-referenced captures after reopen
|
## Persist ReaSampler 9000 instance identity to let prune reclaim de-referenced captures after reopen
|
||||||
|
|
||||||
**Context (what shipped — Phase S usage-detection).** Each ReaSampler 9000 instance publishes the captures it holds to project ext-state (`rsusage_<guid>` keys, ComponentState v11). The extension's prune reads those records and unions every live instance's held captures into the referenced-set, so a capture any live instance holds can never be pruned. Fail-safe: unreadable/ambiguous usage state aborts prune (deletes nothing). Airtight on safety.
|
**Context (what shipped — Phase S usage-detection).** Each ReaSampler 9000 instance publishes the captures it holds to project ext-state (`rsusage_<guid>` keys, ComponentState v11). The extension's prune reads those records and unions every live instance's held captures into the referenced-set, so a capture any live instance holds can never be pruned. Fail-safe: unreadable/ambiguous usage state aborts prune (deletes nothing). Airtight on safety.
|
||||||
|
|||||||
Reference in New Issue
Block a user