docs: close out Θ-W5, and record two spline-overlay UX deferrals

Collapse Θ-W5 to wave-level in PLAN.md, append its narrative to COMPLETED.md, fix a stale resolvePlay reference in core/instrument/CLAUDE.md, and file the drag-off-margin and AttackEnd/Origin shadow warts in TODO.md.
This commit is contained in:
2026-08-01 00:29:40 -04:00
parent 85cf34f858
commit 213ecfafe6
4 changed files with 103 additions and 107 deletions
+48
View File
@@ -504,3 +504,51 @@ over the amp gain curve and persist it. Fixed on both routes (the popup close no
cancels the drag; the mutable accessor refuses `kNone`). It has **no automated
regression pin** — `src/shell/instrument/` has no test target, and the bug is shell
state-machine coupling with no pure-layer equivalent.
### Θ-W5-T1 — spline-egs
Ships a free-drawn alternative to every staged envelope: the pitch, filter, and amp EGs
can each switch Staged → Spline and have their contour drawn directly on the waveform
overlay. The one shared monotone-spline implementation
(`core/instrument/engine/velocity_curve`) gained **hard points** as a per-segment rule —
a hard point does no curve smoothing on either adjacent segment, so the natural sharp
angle stands instead of a continuous derivative — and the enhancement flows to every
consumer, including the existing velocity→amp transfer curve, with no fork.
- **Dual state, save-but-inactive.** Both the Staged and Spline state persist
simultaneously; switching modes never converts or discards the inactive one, so
Staged↔Spline round-trips losslessly. Params payload reached **v13**; v12 projects
still load.
- **Gate unavailable in Spline mode.** A Spline EG's contour always covers the full
sample length as a pure time function (the Trigger/one-shot playback model), so Gate
is not selectable while it's active.
- **Point-editing grammar converged**: left-click adds a point, right-click deletes it,
control-click toggles hard/smooth — one grammar shared by both spline consumers (the
EG overlay and the velocity-curve popup), matching the popup's already-shipped
right-click delete.
- **Point-count ceiling: 128 — a musical bound, not a performance one.** Segment lookup
is an indexed binary search (≤7 steps at 128 points); the cap exists so long rhythmic
phrases (roughly two points per articulation event) aren't limited, not because the
evaluator is expensive.
- **Staged controls disabled while Spline is active** — that envelope's segment knobs
and their inner curve dials render disabled and reject edits; the dormant staged state
is edited only by switching back to Staged.
- The overlay's contour is normalized to the full sample length and drawn 1:1 with the
sample's time axis; a different-length capture rescales the stored contour
proportionally.
A follow-on change in the same track reworked deck cell width: `-1` in `cellIds` changed
meaning from "a blank cell holding geometry" to **one cell's width, reserved and
redistributed** — a Trigger face that drops Sustain and Release now gets wider cells
instead of 144 px of dead slots. Group widths, row packing, deck height, and Gate-mode
cell widths are unchanged.
**Deviations from spec / code review:**
- A pure `resolveWaveformClaim` predicate (`core/instrument/ui/spline_edit`) now resolves
competing waveform-band clicks — contour node, crossfade tab, marker column, staged
envelope node — by **smallest nominal target area among candidates that actually
contain the click**, replacing resolution by check order.
- The Gate-unavailable-while-drawn rule was consolidated into
`enforceGateUnavailableWhileDrawn` (`core/instrument/engine/play_params.h`), now the
single home of that rule, called by both `resolvePlay` and the editor's
`applyControl`.
+23 -105
View File
@@ -218,108 +218,24 @@ curve; and for the Gate-mode loop (W4-T1), since "Gate is unavailable in Spline
only a real, testable rule once Gate has something to be unavailable *for*. It also
depends on W3's radio switch, which is how a spline contour reaches the overlay at all.
**One track.** The spline algorithm is one implementation with several consumers; a
second concurrent writer to it collides by construction.
#### Θ-W5-T1 — `spline-egs`
**Goal.** Offer a free-drawn alternative to every staged envelope: switch any EG from
Staged to Spline and draw the contour directly — with the monotone-spline machinery
already proven by the velocity curve, enhanced so sharp corners are possible rather than
everything smoothed.
**Consolidates item 3.**
**Surface boundary — owns:** `core/instrument/engine/velocity_curve` (the shared spline
implementation — hard points), the spline evaluator on the voice-render path,
`core/instrument/ui/curve_popup` + the overlay's spline mode
(`envelope_overlay`/`envelope_edit`), `core/instrument/map/component_state_io` (dual
state persistence), and the deck band's **disabled-knob rendering only**.
**Behavior.**
- **Mode toggle per EG.** Every processor that has an envelope (pitch, filter, amp) can
switch its Staged EG to a **Spline EG**.
- **Dual state — save but inactive.** Both the Staged and the Spline state are persisted;
switching modes keeps the inactive one **saved but inactive**. No conversion, no
discard — round-tripping Staged↔Spline restores the other mode's shape untouched.
- **Gate unavailable in Spline mode.** Gate mode is **not available while a Spline EG is
active**; the spline **always covers the full sample length** — a pure time function
over the sample, i.e. the Trigger/one-shot playback model.
- **Time axis: normalized, visually 1:1.** The contour is normalized to the full sample
length and the overlay represents **the time axis of the actual sample visually 1:1**.
Consequently a different-length capture rescales the stored contour to its own length,
shape preserved proportionally.
- **Point-editing grammar. Left-click adds a point; right-click deletes a point;
control-click toggles a point hard/smooth.** (The source doc's second follow-up said
alt-click delete; the third **supersedes** it with right-click — matching the
velocity-curve popup's already-shipped right-click node delete, giving **one
point-editing grammar across both spline consumers, with no migration on either side**.)
- **Point-count ceiling: 128.** Floor: the two endpoints implied by full-length coverage.
Daniel floated 64 and raised it to 128 explicitly so the cap does not limit **long
rhythmic phrases, which require high resolution to be interesting** — at roughly two
points per articulation event, 64 is about two bars of 16ths and 128 about four. **The
ceiling is a musical bound, not a performance one** (segment lookup is logarithmic; the
editor's 8 px minimum node separation is the practical density limit anyway). **An
engineer tempted to lower this number must read that motivation first.**
- **Staged controls disabled while Spline is active.** That envelope's staged segment
knobs are **disabled — inert, not merely inaudible** — including their inner curve dials
(the dial is part of the knob). The dormant staged state is edited only by switching
back to Staged.
- **Not globally monotone.** Contours don't have to rise and fall and are not globally
monotone; the monotone guarantee is **per-segment** — all soft points between any hard
points are smooth/monotone, with no overshoot between adjacent points.
- **Default contour: a smooth y = 1 x** — a smooth downward slope over the full sample
length. (The Spline-EG default; not a change to the velocity→amp curve's existing
default.)
- **Hard points — the algorithm enhancement.** The one spline implementation is enhanced
to support **multiple segments that don't minimally smooth**, so hard points are
possible: the contour is defined by **one or more monotone spline functions** which
together form the full time function — e.g. the first three points form a curved segment
connecting **at a sharp angle** to the next three, finishing the contour over the full
sample length. A hard point does no curve smoothing on either adjacent segment; the
natural sharp angle stands instead of a continuous derivative.
- **Shared across consumers.** The enhancement applies to the one implementation and flows
to every consumer: the **velocity→amp transfer curve** gains hard-point support by the
same change, as do the pitch, filter, and amp EGs in spline mode.
- **Hot-path constraint.** Spline evaluation runs per-voice per-sample. It is an indexed
binary search over a point array plus a polynomial evaluation — **not** a polymorphic
curve object, and not a per-sample allocation. With a 128-point ceiling the search is
≤7 steps; cache the segment index across samples where the read position is monotone.
**Acceptance criteria.**
- Each of the pitch, filter, and amp EGs offers a Staged/Spline mode switch; in Spline
mode the overlay (via the W3 radio switch) shows and edits the drawn contour, and played
notes audibly follow it.
- Left-click on the contour adds a point; right-click on a point deletes it; control-click
toggles it hard/smooth. Points are smooth by default; a hard point renders a **visible
sharp angle with no smoothing on either adjacent segment**, and the discontinuous slope
is audible where the modulation target makes it so (e.g. a pitch EG corner).
- A contour accepts points up to the **128-point ceiling**; attempting to add beyond it is
refused **without disturbing the existing contour**. The two endpoints cannot be deleted.
- A contour of several segments joined at hard points plays back over the full sample
length exactly as drawn — including contours that rise and fall freely (no
globally-monotone restriction), with **no overshoot between any adjacent pair of
points**.
- A freshly created Spline EG shows the smooth y = 1 x default contour.
- While a Spline EG is active, **Gate mode is not selectable**; the spline plays as a pure
time function over the full sample length.
- The overlay contour spans the full displayed sample width, 1:1 with the sample's time
axis; loading a different-length capture rescales the contour to the new length with the
drawn shape preserved proportionally.
- While a Spline EG is active, that envelope's staged segment knobs **and their inner
curve dials** render disabled and reject edits; switching back to Staged re-enables them
with values exactly as left.
- **Staged↔Spline round-trip preserves both states**: switch to Spline, draw, switch back
— staged values exactly as left; switch forward again — the contour exactly as drawn.
Both survive save/reload.
- The velocity→amp transfer-curve editor supports the same control-click hard/smooth
toggle with identical rendering behavior, and its existing right-click node delete
matches the Spline EG's delete gesture unchanged — **one point-editing grammar across
both consumers.**
- Spline evaluation adds no allocation and no virtual dispatch to `process()`.
**Open questions.** None — item 3's two remaining questions (point-count bounds; gesture
convergence) closed in the source doc's third follow-up round.
**Θ-W5-T1 has landed** — `spline-egs` — see `docs/COMPLETED.md` for the full narrative.
It shipped a free-drawn alternative to every staged envelope: pitch, filter, and amp
EGs can switch Staged → Spline and have their contour drawn directly on the waveform
overlay. The one shared monotone-spline implementation gained hard points — sharp
corners, no smoothing on either adjacent segment — flowing to every consumer including
the existing velocity→amp transfer curve, no fork. Both Staged and Spline state persist
simultaneously (saved-but-inactive, lossless round-trip); params payload reached v13,
and v12 projects still load. Gate is unavailable while a Spline EG is active; the
contour is a pure time function over the full sample length, normalized and drawn 1:1
with the sample's time axis. Point grammar converged on left-click add / right-click
delete / control-click hard-smooth toggle, one grammar across both spline consumers.
Staged segment knobs and their inner curve dials render disabled and reject edits while
Spline is active. Point-count ceiling: 128, a musical bound rather than a performance
one. A follow-on change in the same track reworked deck cell width: `-1` in `cellIds`
now means one cell's width, reserved and redistributed, rather than a blank cell holding
geometry — a Trigger face that drops Sustain and Release gets wider cells instead of
dead slots; group widths, row packing, deck height, and Gate-mode cell widths are
unchanged.
---
@@ -328,9 +244,11 @@ convergence) closed in the source doc's third follow-up round.
**Depends on W5 for:** the last change to a drawn surface. Item 13 is an audit whose
output is a disposition list over "every class of drawn surface," and item 10's sizing
pass is judged by eye over the finished layout — running either while the spline contour,
the disabled-knob state, or the deck inventory is still moving means auditing and then
re-auditing. The source doc names this sequencing as an observation (item 13 after the
layout/knob work); this plan adopts it as the boundary.
the disabled-knob state, or the deck inventory was still moving would have meant
auditing and then re-auditing. Θ-W5-T1 has landed (see `docs/COMPLETED.md`), so that
surface has stopped moving and this dependency is satisfied. The source doc names this
sequencing as an observation (item 13 after the layout/knob work); this plan adopts it
as the boundary.
**One track.** Both items repaint essentially every surface in the editor; concurrent
tracks would collide everywhere.
+28
View File
@@ -152,6 +152,34 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
**Done looks like.** Not stated in the source beyond "confirm no surprising fight."
## Spline overlay's drag-off delete margin may be too generous for its box
**Context (what shipped — Θ-W5-T1, spline-egs).** `kCurveDragOffMargin = 24` (`editor_internal.h`) was sized for the velocity-curve popup, whose editing box floats with slack on all sides — the popup's own sheet border sits well outside the box, so 24px of overshoot before a drag-off delete arms is comfortably inside the sheet. The Spline EG overlay reuses the same constant and the same drag-off-delete logic verbatim (`editor_paint_waveform.cpp`), but its box abuts the deck directly with no equivalent slack.
**The wart.** Dragging an overlay contour node toward the bottom of the waveform band and overshooting roughly 24px past the box floor carries the drag into the deck below and arms a delete — a gesture that reads as "drag toward the deck" rather than "delete this point." Mitigations already in place: a WARN paint cue while the drag is armed-to-delete, and `VelocityCurve::deletePoint` unconditionally refuses the two endpoints regardless of margin.
**Intended fix.** Not yet proposed — likely a smaller, overlay-specific margin (or a margin derived from the actual gap between the overlay box and the deck) rather than sharing the popup's constant.
**The constraint the fix MUST handle.** Whatever margin the overlay uses must still comfortably permit an intentional delete-by-drag-off gesture (the design's stated point-removal path) without shrinking it into a hair-trigger; the popup's own margin and delete behavior must be left untouched.
**Priority / risk.** Low, pending Daniel's hands-on assessment. Flagged by code review as an unmeasured UX judgment, not a confirmed defect — whether the overshoot is a real hazard in practice is Daniel's call.
**Done looks like.** Daniel has used the Spline EG overlay hands-on and either confirms the current margin is fine as shared, or a separate overlay margin is chosen and the WARN cue's trigger point is verified to match it.
## Pre-existing staged-envelope-node shadow at zero-attack (AttackEnd on Origin)
**Context (what shipped — Θ-W5-T1, spline-egs).** The staged envelope-node hit-test (`nodeAtPoint`, `envelope_edit.cpp`) and the drawn contour's node hit-test now feed the SAME `WaveformClaim` arbitration slot in `resolveWaveformClaim` (`spline_edit.h`), which resolves competing waveform-band claims — node, crossfade tab, marker column — by smallest nominal target area among the candidates that actually hit. This is the same defect class as the contour-node/marker collision W5 fixed by replacing check-order resolution with that arbitration.
**The wart.** A zero-attack `AttackEnd` vertex is drawn at the same pixel as `Origin` (the envelope's non-draggable start anchor), which for an AHD envelope sits at the start marker's frame. Because a node's nominal pick-box area is smaller than the marker's full-height grab-column area, and `resolveWaveformClaim`'s rule is "smallest area among hit candidates wins," the draggable `AttackEnd` node still claims the click over the start marker when the two coincide — and, at a loop starting there, over the crossfade tab. Folding the staged pass into the shared arbitration slot did not change this specific outcome, since the rule that decides node-vs-marker priority is unchanged from what the contour-node fix established. `Origin` itself is excluded from `nodeAtPoint`'s candidate set entirely (never draggable, never a hit), so the common case — attack > 0, no coincidence — is unaffected.
**Intended fix.** Not yet proposed. Bringing the staged pass into the shared arbitration slot was the natural first step and has landed; closing the remaining collision needs either a per-affordance priority rule for genuinely coincident precision targets, or accepting the current smallest-area outcome as intended and documenting it as such rather than as an open wart.
**The constraint the fix MUST handle.** Whatever rule changes must not regress the contour-node/marker and tab/marker arbitration W5 already fixed, and must not make `Origin` draggable or otherwise touch `isDraggable`'s AHD/AHDSR shape rules.
**Priority / risk.** Low. Pre-existing, not introduced by W5; the common case (nonzero attack) is unaffected, and the collision requires both a zero-attack stage and a coincident marker/tab to be reachable at all.
**Done looks like.** A zero-attack `AttackEnd` node coincident with the start marker (or, on a loop starting there, the crossfade tab) no longer silently claims the click ahead of the marker/tab — either by an explicit priority rule or by a recorded decision that the current behavior is intended.
## Active-bank indicator placement (B4 polish)
**Context.** CONTEXT-ARCHIVE.md's "Open questions to resolve during build" (B4 panel section): forks 15 are all settled; one panel-polish detail remains open. Fork 4 already settled that the active-bank indicator must be "visually unmistakable" — only its placement is undecided.