From e1e668a521a7ff401bbe62b01000fdc31b98606c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 31 Jul 2026 13:20:33 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20close=20out=20=CE=98-W3-T2=20into=20COM?= =?UTF-8?q?PLETED,=20collapse=20the=20W3=20wave,=20and=20record=20the=20ov?= =?UTF-8?q?erlay=20contrast=20wart=20as=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/COMPLETED.md | 68 ++++++++++++++ docs/PLAN.md | 228 +++------------------------------------------- docs/TODO.md | 14 +++ 3 files changed, 97 insertions(+), 213 deletions(-) diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index 1f8cdb6..48ec0d5 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -261,6 +261,74 @@ single derivation from the value type; `PlayParams` stays a plain copyable value - A filter envelope only advances while its depth is non-zero (the exact-skip at `modAmount == 0`), which is what keeps the at-rest path byte-identical. +### Θ-W3-T2 — staged-envelope-curves + +Grows the envelope-overlay editor from an amp-only fixture into the shared graphical +surface for all three envelopes (amp, pitch, filter): a corner radio switch per deck +selects which envelope is overlay-active (none by default, exclusive); every sloped +stage on every envelope (Attack/Decay/Release — Hold and Sustain stay flat) gets an +editable curve exponent (0.1–10, 1.0 the linear neutral) via a paired inner knob dial +and a round mid-segment overlay knot, both resolving through the one curve law in +`src/core/util/curve_law.h`; the pitch envelope becomes AHD (Attack → Hold → Decay, +Hold a fraction of the time remaining after Attack and Decay, so A+H+D ≤ span holds by +construction, no clamp); AHDSR envelopes get a right-anchored release, dragged from +its top node with the bottom-right corner fixed; and the Trigger amp/filter +fade-in/fade-out pair is retired in favor of a Trigger AHD, consolidating what were +two staged-shape mechanisms into one — item 8's rule (pitch always AHD; amp and filter +AHDSR in Gate, AHD in Trigger) governs all three. The Trigger × Preserve end-of-sample +click is fixed at its root cause: `freezeTail()` stopping the pitch shifter's writer a +full window before the read head arrives. + +**Open question resolved — per-mode stage-value state.** Gate and Trigger keep +SEPARATE stored stage values, on both the amp (`PlaySeconds::adsr` + +`PlaySeconds::trigAhd`) and the filter (`FilterSeconds::env` + `FilterSeconds::trigEnv`). +Migration forces it: an old instance carries both an AHDSR and a fade pair, and one +shared set cannot preserve both modes' prior sound. Cost: ~160 bytes of persisted +state per instance, 6 additional `DeckParam` ids. + +**Deviations from spec:** +- **The migration exponent is FITTED, not neutral — Daniel's explicit ruling, + resolving a spec contradiction.** PLAN.md stated both "pre-existing instances load + at exponent 1.0" and "exponents at whatever reproduces the prior fade shape"; + those conflict, and the fix resolves toward the second, since it carries the + migration guarantee. Attack lifts at **p = 0.6133**, decay at **q = 1.7437**; max + deviation from the retired equal-power (sin/cos) fade shape drops from 0.2105 to + 0.0875. Every non-migrated curve still lifts to the 1.0 neutral. +- **Item 4's fix is deliberately WIDER than spec.** The spec scoped the end-of-sample + click fix to Trigger × Preserve; the landed fix is not mode-scoped, so Gate × + Preserve × source-exhaustion also now rings out (~4 ms) where it previously + hard-cut. A held Gate note whose source runs out with no loop is cut at sustain + level, landing on the same recycled synthetic tail — scoping the fix to Trigger + alone would have knowingly left that click. +- **Migration is lossy under a sample-rate mismatch** — a documented bound, not a + bug. The retired fades were source frames; the lift divides by the project rate + while the AHD rebuilds at decode rate, so a rate mismatch shifts migrated stage + lengths by that ratio. Documented in the v10 version ladder + (`component_state_io.h`) with a test. +- **Payload version is v10.** `component_state_io.cpp` was split on the format seam + into `component_state_io.cpp` + a new `params_payload.{h,cpp}`. +- **New pure module:** `src/core/util/curve_law.h` — the one per-segment curve law + (exponent domain, normalized-position→level map, the mid-segment inverse an overlay + knot drags through, and the knob's norm↔exponent travel with an exact centre + detent). The neutral exponent is a bit-identity. Measured cost of a non-neutral + exponent: ~4.7 ns per evaluation, +224 ns/output frame worst case at 16 voices — + 3.1% → 4.1% of one core at 44.1 kHz. +- **`OverlayEnv` and the overlay-selection state machine live in + `core/instrument/ui/deck_groups`**, not the shell. +- **The knot-creation gesture differs from spec.** Spec said dragging a segment + *adds* a knot; the landed behavior draws the knot unconditionally on every sloped + non-zero segment and responds to a drag within the grab radius. Daniel confirmed + this reading stands. +- **Loop markers moved from `AccentTertiary` to `AccentSecondary`** — they collided + exactly with the envelope trace (RGB delta 0) in the same overlay rect. Daniel + ruled. The palette is under active change on a separate branch, so the specific hex + values are in flux (see `docs/TODO.md`). + +**Still open — not closed by this track.** The envelope overlay's contrast against +the waveform (tertiary purple, measured 1.37:1, below the 3:1 indicator floor) awaits +Daniel's eye on a build; pinned as a flagged deviation in `tests/test_theme.cpp`. See +`docs/TODO.md`. + ### Ξ-W1-T1 — tracking-consolidation Consolidates the provenance/usage territory into one system: the retired diff --git a/docs/PLAN.md b/docs/PLAN.md index 0c217dd..8f714e6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -172,219 +172,21 @@ exist before it can receive a corner radio switch and inner curve dials. T1 addi 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. -**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. - -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 has landed and specified its mid-stage rule in terms that survive T2: **hold -normalized stage position** — φ = elapsed/duration held fixed across a duration change, -then advancing at 1/newDuration (Daniel's pick among the candidates T1 laid out; see -`docs/COMPLETED.md`). It is expressed over *normalized stage position* rather than over -output level specifically so T2's exponent is a pure map of that position and composes -with the rule rather than invalidating 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` - -**Θ-W3-T1 has landed** — see `docs/COMPLETED.md` for the full narrative. Continuous -playback controls (filter cutoff/Q/morph/drive/mod amount/key-track; every stage time -and level on all three envelopes) are now delivered live to sounding voices via a -seqlock-published `LiveValues` block owned at processor-instance scope, above -`LoadedInstrument`, so a drain-slot voice keeps tracking the knob (the desired -behavior). `foldLive(const PlayParams&)` is the single derivation from the value type. -Both of Daniel's ruling questions are answered and implemented: **reload tier = -Grouping B** (root note, loop span, and start frame still reload; everything else -continuous goes live), and **mid-stage rule = candidate (iv), hold normalized stage -position** (φ = elapsed/duration held fixed across a duration change, then advancing -at 1/newDuration) — expressed over normalized position specifically so T2's -per-segment curve exponent, below, composes with it. **Trigger's %-length and fades -are NOT live** — they are baked into `SampleData` at build, so a Trigger-mode instance -gets zero live amp delivery until T2 folds the fade pair into the AHD. The five -non-live exclusions (`kKeyTrack`, `kFilterVel`, `kTrigLength`, `kTrigFadeIn`, -`kTrigFadeOut`) are documented in `src/core/instrument/ui/deck_groups.h`, now their -single home. - -#### Θ-W3-T2 — `staged-envelope-curves` - -**Prerequisite: Θ-W3-T1 has landed** — see `docs/COMPLETED.md`. Both tracks write -`envelopes.h`'s stage math, and this track's curve exponent must be authored as a map of -the normalized stage position T1's mid-stage rule holds fixed (φ = elapsed/duration, -candidate (iv)). - -**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 -overlay's layout and contrast failures, consolidate the Trigger amp fade pair into the -AHD — and, in the same region, kill the Trigger×Preserve end-of-sample click. - -**Consolidates items 1, 8, 14, 4.** - -**Surface boundary — owns:** -- `core/instrument/engine/` — the envelope evaluator (from W1's seam), `zone_params.h`, - `pitch_shift.{h,cpp}` (item 4's tail), the voice-render envelope and tail path. -- `core/instrument/map/` — `trigger_seam`, `component_state_io` (curve exponents, the - pitch Hold stage, the Trigger AHD, migration). -- `core/instrument/ui/` — `envelope_overlay`, `envelope_edit`, `knob_deck` (the inner - dial). -- `src/shell/instrument/` — the deck band (inner dials, radio switches) and the - waveform band's **overlay layer only** (T2's lane geometry from W2 is consumed, not - re-owned). - -**Behavior — the overlay surface (item 1).** -- **Radio switch per envelope deck.** Each envelope knob-deck group (AMP ENVELOPE, - PITCH ENV, and now Filter) gains a radio switch in the corner of its deck. Selecting a - deck's radio makes *that* envelope the one displayed and editable in the graphic - waveform overlay — replacing today's always-on Amp AHDSR. The switch is exclusive: - **one overlay-active envelope at a time, or none** — no-envelope-shown is a valid - state, not an error. -- **Default overlay: none.** The editor opens with **no envelope selected**. -- **Segment curve values on all envelopes.** Amp AHDSR, Pitch AHD, and Filter AHDSR all - gain an editable curve value per *sloped* segment. The curve is exponential; the - per-segment parameter is the exponent scalar, range **0.1 to 10**. **1.0 is the linear - neutral** (y = x^1.0 is linear). -- **Which segments are sloped: every stage except Hold and Sustain.** For an AHDSR that - is Attack, Decay, Release; for the Pitch AHD, Attack and Decay. Hold is flat - everywhere and carries no curve dial. -- **Inner dial on curvable-segment knobs.** Every knob controlling a sloped, curvable - segment gains an **inner dial**: its own inner arc, its own hover accent (tertiary - purple), its own needle, its own numerical label. The inner dial controls that - segment's curve exponent; the outer knob keeps controlling the segment's time/level - value as today. -- **Curve editing in the overlay.** Dragging on a segment **adds a round mid-segment - knot** whose drag adjusts that segment's curve — the overlay is a curve-edit surface - in its own right, alongside (not instead of) the inner dial. Same one-model rule as - the existing draggable nodes: knot-drag and inner dial cannot diverge. -- **Overlay recolor.** Envelope segments change from secondary blue to **tertiary - purple** — secondary blue contrasts poorly against the primary green waveform behind - it. Re-verify the tertiary-purple-on-green pair against `theme`'s contrast-floor - assertions. -- **Pre-existing instances load at exponent 1.0** — the linear neutral — so their - audible envelope behavior is unchanged. - -**Behavior — layout policy and the pitch AHD (item 8).** -- **The release segment anchors to the right edge** of the overlay. Release is dragged - from its **top node** (the node joining sustain to release) instead of the bottom - corner; the bottom corner — the envelope's end point — becomes **fixed/anchored**, not - draggable. Today's failure is that with little or no release the sustain portion - occupies only a small stretch and the whole figure reads off-center. -- **Spec change: the pitch envelope becomes AHD** — Attack → Hold → Decay. **Hold ranges - 0 to 100% of the time remaining after Attack and Decay** (100% of - `sample length − (attack + decay)`). At 100% Hold fills all remaining time; at 0% it - takes none. -- **The A + H + D ≤ sample-length bound holds by construction, not by a clamp.** Hold is - expressed as a fraction of what is left, so the sum cannot overflow. Do not add a - separate clamp; none is needed. -- **Scope rule — split on the sustain stage.** The 1:1-overlay property and the - combined-time bound apply to **all envelopes that do NOT have a sustain stage**; - envelopes **with** a sustain stage get the right-anchored-release policy instead. The - two policies **coexist rather than merge**. Daniel's rationale: the 1:1 mapping only - makes sense for trigger, not gated envelopes. **The pitch envelope's D is not a - release, so it is not right-anchored.** - -**Behavior — mode-driven envelope shape (item 14).** -- **Trigger mode: fades out, AHD in.** In Trigger mode the amp envelope becomes an AHD - per item 8's definition. The fade-in-length and fade-out-length controls **go away in - Trigger mode**; Attack and Decay carry those roles. Daniel's motive is stated: - reducing code for the same job — one staged-envelope design covering what is currently - two mechanisms. -- **The Trigger AHD's sloped segments get the full curve treatment** — exponent 0.1–10, - inner dials, mid-segment overlay knots, tertiary-purple rendering. -- **Gate mode unchanged** — the amp envelope stays the AHDSR with the right-anchored - release. -- **The filter envelope follows the same rule.** Stated once, governing all three: - **pitch is always AHD (1:1 overlay); amp and filter are AHDSR in Gate - (right-anchored release) and AHD in Trigger (1:1 overlay).** The rule selects by the - envelope's **current shape under the active playback mode**, not by which processor it - modulates. No new rule is needed — item 8's rule already decides every case. -- **Pre-existing instances reopen sounding identical.** A Trigger instance's prior - fade-in/fade-out contour must be reproduced by the loaded AHD. The evident mapping - (Attack ← fade-in, Decay ← fade-out, Hold ← the full remainder, exponents at whatever - reproduces the prior fade shape) is a verify-at-implementation detail. **A prior zero - fade-out is Decay = 0** — the abrupt end stays representable, so nothing the old - controls could express is lost. - -**Behavior — the Trigger×Preserve click (item 4).** -- In Trigger mode — polyphonic or monophonic alike — with the pitch engine in - **Preserve mode only**, the end of the sample produces an audible click. The scoping is - the useful part of the report and is recorded as given: Trigger × Preserve × - end-of-sample; Varispeed is not implicated. -- Expected: a Trigger one-shot in Preserve ends silently — no click or discontinuity at - the sample end, in either voice mode. -- **Why it is in this track and not earlier:** the defect sits in the region the - fade-out currently governs, and this track retires that mechanism. Fixing it in a - prior wave means fixing it, then re-verifying it under the surviving mechanism - anyway — and the only other instrument-side track before this one owns the whole - engine. The gate below is the *post-consolidation* gate. - -**Acceptance criteria.** -- Each envelope deck shows a corner radio switch; activating one puts that envelope in - the overlay, editable there, and the overlay tracks the switch immediately. At most one - envelope is overlay-active; with none active the overlay draws no envelope. -- The editor opens with no envelope overlay-active. -- Every curvable-segment knob shows the inner dial (inner arc, tertiary-purple hover - accent, needle, numeric label); sweeping it 0.1 → 10 visibly reshapes the overlay - segment and **audibly** reshapes the envelope on played notes. Hold and Sustain knobs - carry no inner dial. -- Dragging on an overlay segment adds a round mid-segment knot; dragging the knot adjusts - that segment's curve, and the segment's inner dial reflects the same value. -- Overlay envelope segments render in tertiary purple and are clearly legible against the - primary green waveform. -- With release at zero or minimum, the sustain segment extends to (near) the right edge — - the overlay reads full-width, not bunched left. Dragging the sustain→release top node - adjusts release; the bottom-right corner is fixed and not draggable. -- Envelopes with a sustain stage follow that anchoring policy; the pitch envelope's Decay - is **not** right-anchored. -- The pitch envelope plays and displays three stages — Attack, Hold, Decay — with Hold - spanning 0–100% of the time remaining after Attack and Decay. **No combination of A, H, - D exceeds the sample length, and no clamp fires, because none exists.** -- The overlay of any sustain-less envelope is 1:1 with the waveform's time axis: a stage - boundary at N seconds sits over the waveform at N seconds. -- In Trigger mode the amp deck shows Attack / Hold / Decay — **no fade-in or fade-out - control anywhere in Trigger mode** — with curve inner dials on Attack and Decay and - none on Hold. -- Switching between Gate and Trigger switches the amp *and* filter envelope surfaces - (deck and overlay) between AHDSR and AHD accordingly. -- The Trigger amp AHD behaves stage-for-stage like the pitch AHD — Hold semantics, curve - treatment, and overlay mapping match. (The observable proxy for the consolidation - motive: one staged-envelope design, several consumers.) -- **Migration:** a project saved before this change reopens sounding identical — every - curve at exponent 1.0, and a Trigger instance's prior fade contour (including a zero - fade-out's abrupt end) audibly reproduced by the loaded AHD. -- **Item 4's gate, under the surviving mechanism:** a Trigger-mode note played in - Preserve, in both Poly and Mono, ends with no audible click at the sample end — - verified by ear *and* by inspecting the rendered output for a terminal discontinuity. - Varispeed playback is unchanged. - -**Open questions.** -- **Stage-value state across the Gate/Trigger switch — shared or per-mode? [propose]** - The Gate AHDSR and Trigger AHD share stage names (A, H, D); whether they share *values* - (one envelope whose S and R fall away in Trigger) or keep per-mode state (item 3's - "save but inactive" dual-state precedent) is unspecified — and the question covers the - **filter envelope too**. Migration leans per-mode for the amp: an old instance carries - both its AHDSR values and its Trigger fade values, and a shared-value model cannot - preserve both modes' prior sound at once. The filter is new and carries no migration - weight either way; matching the amp's answer is the natural default. Resolve with a - proposal at review — the sound-identical gate must hold for whichever mode a saved - instance plays in. +**Both tracks have landed** — Θ-W3-T1 (`live-parameter-delivery`) and Θ-W3-T2 +(`staged-envelope-curves`) — see `docs/COMPLETED.md` for the full narrative of each. +Between them: every continuous playback control (filter cutoff/Q/morph/drive/mod +amount/key-track, every stage time and level on all three envelopes) now reaches a +sounding voice live instead of latching at note-on, and the envelope-overlay editor +grew from an amp-only fixture into the shared graphical surface for all three +envelopes — a corner radio switch per deck (none active by default), curve-shapeable +segments on every sloped stage (0.1–10 exponent, an inner dial paired with an overlay +knot), the release-right-anchored AHDSR layout against the pitch envelope's 1:1 AHD, +and the Trigger amp/filter fade pair folded into a Trigger AHD, consolidating what +were two staged-shape mechanisms into one. The Trigger × Preserve end-of-sample click +is fixed; the landed fix is wider than scoped, also ringing out Gate × Preserve × +source-exhaustion, previously a hard cut. The ordering was deliberately serial — T1's +live-delivery mechanism landed first so T2's new curve exponents and Trigger AHD +fields were authored directly into it rather than backfilled afterward. --- diff --git a/docs/TODO.md b/docs/TODO.md index 5f904dc..af6a8ea 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -179,3 +179,17 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r **Priority / risk.** Low / deferred. Mitigated in the meantime: the console message names the stranded file's project-relative path, so the operator can recover or remove it rather than discovering it later as an unexplained orphan. **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. + +## Envelope overlay's tertiary-purple trace fails the 3:1 indicator contrast floor against the waveform + +**Context (what shipped — Θ-W3-T2, staged-envelope-curves).** The overlay recolor moved every envelope's segment trace to `Role::AccentTertiary` (purple), specifically because it separates from the primary-green waveform underneath it more than the previously-used secondary blue did — `tests/test_theme.cpp`'s `testOverlayAccentSeparatesFromTheWaveformAccent` pins that the tertiary pairing measures a HIGHER contrast than the retired secondary one against the same waveform color. + +**The wart.** Even the improved pairing measures **~1.37:1**, well below the **3:1** floor `theme.h` states as the indicator-class contrast floor everywhere else in the kit. The test pins the measured value as a KNOWN, HELD deviation rather than asserting the floor. + +**Intended fix.** Not yet specified — awaiting Daniel's eye on a running build before choosing a new tertiary hue or a different palette approach. + +**The constraint the fix MUST handle.** A separate palette branch is in flight (worktree `palette-darker-teal`) and may change the tertiary/secondary hues involved; a fix landed here could collide with or be superseded by that branch. Also: whatever fix lands must not reopen the loop-marker/envelope-overlay role collision this same track resolved by moving loop markers to `AccentSecondary` — a new tertiary hue must keep that separation too. + +**Priority / risk.** Not yet triaged — pinned as a flagged deviation in `tests/test_theme.cpp`, open pending a visual pass. + +**Done looks like.** The envelope overlay trace clears the 3:1 indicator floor against the primary-green waveform, `tests/test_theme.cpp`'s pinned deviation is replaced with a floor assertion matching every other indicator pairing, and the fix is reconciled with whatever the in-flight palette branch lands.