Disciplines · Audits

Euterpe Deferrals — Actionability Analysis & Plan (2026-06-07)

(daw-session.ts:1183), so a lane keeps its old index and then drives the _wrong_ insert after a reorder.

7sections10 minread

On this page

During the 2026-06-07 session 16 backlog items shipped (the full S/M tier + all five L/XL items: MIX-7, ENG-20, MIDI-19, MIX-11, ENG-5). Along the way several pieces were deferred — each with a stated rationale, none stubbed. This document re-examines every deferral against the actual code, classifies it, and gives a concrete plan for the genuinely-actionable ones.

Legend — Actionability: ✅ clean local · ⚠️ local with caveats · ⛔ blocked (external). Effort: S (≤½ day) · M (1 day) · L (multi-day).


Tier 1 — Actionable now, high value (do these first)#

C. Insert-param automation lane re-map on insert move/remove — ✅ S#

Status: a real correctness gap in shipped MIX-7. Lanes are keyed by insertId (== chain index); moveInsert renumbers ids 0..n by position (daw-session.ts:1183), so a lane keeps its old index and then drives the wrong insert after a reorder. rebuildCommands already guards on existence, so a deleted insert's lane is dropped on rebuild, but a moved one mis-targets in live state. Plan:

  1. moveInsert reducer: build the old→new id permutation from the reorder and remap each track.insertParamAutomation lane's insertId through it (same map already applied to insert ids). It already returns rebuildCommands(next), so the engine re-syncs.
  2. If/when a removeInsert action exists, drop lanes for the removed id and decrement higher ids (today inserts are only added/moved + whole-chain-replaced via setInsertChain, which already rebuilds; verify before adding).
  3. Tests (daw-session.spec): move an insert that carries a lane → the lane follows it; rebuild emits setInsertParamAutomation on the new index. Why first: smallest, fixes a live bug in a just-shipped feature.

A. EQ-band parameter automation — ✅ M#

Status: MIX-7 automates any insert param via AudioEffect::set_param, but EQ band params (band{N}_freq/gain/q) were excluded because ParametricEq only had set_band(i,freq,gain,q) (no granular setter) — and the automation-lane param list is sourced from insert-rack's PARAM_CONFIG, which has no EQ entries (EQ uses its own band editor). Both blockers are trivial: ParametricEq already stores per-band {freq, gain_db, q} (eq.rs:9-13), so a granular setter just re-derives keeping the other two. Plan:

  1. dsp-core eq.rs: add set_band_gain(i, db), set_band_freq(i, hz), set_band_q(i, q) (each calls set_band with the stored values for the unchanged two).
  2. dsp-graph effect.rs EqNode::set_param: parse keys band{N}_gain|freq|q, route to the granular setter. (EQ was the one node left with the default no-op set_param.)
  3. UI: surface EQ band params as automatable. Either add EQ rows to PARAM_CONFIG (band2_gain −24..24 dB, band2_freq 20..20k log, band2_q 0.1..18) or special-case eq in the automation-lane selector population. The lane editor + reducer + rebuild all work unchanged (generic insert:<i>:<key>).
  4. Tests: cargo (EqNode::set_param("band2_gain",6) shifts the band's magnitude_at); DAW (automation-lane lists the EQ band params; emits setInsertParamAutomation key band2_gain). Why high: EQ is the most-automated insert in any DAW; this closes MIX-7's only real gap.

B. MIDI-17 conditional conditions (if-prev / if-not-prev) — ✅ M#

Status: ratchet + probability + ratio/first trig conditions shipped; the Elektron conditional family was deferred as "needs cross-note sequence state". It is deterministically computableNoteClip.advance already processes start-sorted notes per loop iteration. Plan:

  1. clip.rs: before building desired, compute played: Vec<bool> over the (already start-sorted) notes for this iteration: played[i] = cond_seq(note[i].condition, iteration, prev=played[i-1]) && probability_gate(i,iteration). Add a note_condition_passes_seq(code, iteration, prev_played) that handles new codes 11=if-prev, 12=if-not-prev (delegating the stateless codes to the existing note_condition_passes). desired then uses played[i]. First note's prev = false (no predecessor). O(n)/block, pure fn of the playhead → still deterministic + offline-reproducible.
  2. UI: append if-prev/if-not-prev to NOTE_CONDITION_LABELS + the Ctrl/Cmd-scroll cycle. No new plumbingcondition is already a wired u8 field.
  3. Tests: cargo (a note with if-prev sounds iff its predecessor fired this pass; chains resolve through the recurrence) + the label/cycle helper. Why high: completes MIDI-17; reuses all existing plumbing; deterministic + fully testable.

Tier 2 — Actionable, medium value#

D. ARR-6 transient markers + snap — ⚠️ M (re-scoped)#

Status: deferred for a model mismatch — arrangement clips are note clips, while audio lives per sampler track in daw-app sampleBuffersRef. detectTransients() (audio-analysis.ts) is real + tested. The fix is to attach transients to sampler tracks, not clips. Plan:

  1. TrackState.transientBeats?: number[] + a setTrackTransients reducer action.
  2. daw-app: on sampler buffer load (loadSampleFile / synthesized), run detectTransients on the mono buffer, convert sample→beats at the session tempo, dispatch setTrackTransients. (Thin browser-side wiring; the conversion is a pure, tested helper.)
  3. arrangement-view: add a 'transient' SNAP_MODE; magnetEdges includes, for each clip on a sampler track, that track's transient beats mapped to song positions (clip.startBeat + (transientBeat − offset)), reusing the existing magnetSnap.
  4. Tests: pure samplesToBeats + magnetSnap with transient edges (no browser). Caveat: the detect-on-buffer step needs the decoded buffer (browser); snap math is pure.

H. Per-track CPU contribution — ⚠️ M (caveats)#

Status: ENG-20 ships overall CPU; per-track was omitted ("no monotonic clock in WASM"). The blocker is surmountable: import a JS clock into the engine via wasm-bindgen (js_sys:: Performance::now / an extern import) and time each track inside engine.process. Plan: import now(); in Engine::process, bracket each track's process and accumulate a per-track ms; expose track_cpu(i) + add to the meter snapshot; render a small per-track CPU bar in the mixer. Caveats (why Tier 2, not 1): an FFI call per track per block adds real audio-thread overhead and the numbers are noisy. Mitigate by sampling only every Nth block and EMA-smoothing. Same performance.now-availability gating as ENG-20; verification is browser-only. Honest + bounded, but lower value-per-risk than Tier 1.


Tier 3 — Actionable but low ROI (do only for completeness)#

I. Reverb / delay long-tail f64 — ✅ S–M, low value#

ENG-5 already did the high-value filter case (Biquad). Converting Reverb comb/allpass + Delay line/feedback to f64 internal storage (f32 boundary) is mechanical, but the benefit is marginal (feedback is decaying/bounded and already flush_denorm-guarded) and a convincing test is hard to construct. Recommend skipping unless a blanket-f64 push is wanted.

E. Clip-level time signature — ⚠️ M, display-only#

Deferred because it has no engine effect here and no per-clip bar-grid surface. To make it real: thread timeSignature to the piano-roll's bar gridlines (bars every numerator·4/denominator beats) + a clip-properties selector + optionally a per-active-clip metronome accent. All UI/display; no audible engine change. Genuinely actionable but the lowest value of the actionable set.

F. MIX-11 cue mix — ⚠️ L, partial-local#

The engine half is local + testable: a per-track cue-send → a separate cue bus → fill a second stereo pair in engine.process (the AudioWorklet supports multiple outputs). The routing half (send outputs[1] to a different device via setSinkId) is browser-only. Build + test the cue-bus summing locally; document the device routing as browser. Large; defer behind Tier 1–2.


Not actionable as a real feature (keep deferred — rationale)#

  • J. ENG-8 BitCrusher oversample — ⛔ non-feature. The sample-rate-reduction aliasing is the intended lo-fi character; oversample+decimate removes it, and quantizing a held (S&H) value at the oversampled rate is a no-op. There is no clean "oversample only the unwanted harmonics." A separate anti-aliased saturation mode would be a different effect, not the bitcrusher.
  • K. Blanket f32→f64 — ⛔ poor ROI. The audio boundary is f32 (wasm/AudioContext) so output is truncated regardless; integration-sensitive meters are already f64; the one demonstrable win (filters) is done. Remaining defensible target is item I only.
  • G. MIX-11 talkback — ⛔ browser. Needs getUserMedia mic capture + permission + routing; the input source can't be unit-tested locally. (The talkback bus could ride the cue bus from F.)

Externally-blocked tier — and its locally-buildable cores#

  • AI-9/13/19 (Suno/Udio/embeddings) — ⛔ need API keys + the BFF; generation is cred-gated. No DAW-local slice.
  • ENG-1 native drivers (ASIO/CoreAudio), ENG-4 multicore, ENG-7 disk streaming — ⛔ for web. WASM is single-threaded with no OS threads and no disk; these are native-desktop-only (Tauri + cpal + a dsp-native build). Out of scope for the web engine.
  • i18n — ⛔ framework, but a local core (S–M): a message-catalog module + t(key, params) with a default-locale catalog + extracted strings is locally buildable + unit-testable, independent of the eventual locale-switch UI. Limited value until the UI consumes it.
  • PWA / touch / multi-window / device-enumeration — ⛔ browser-only.
  • Collaboration server — ⛔ backend, but a local core (L): a CRDT/OT session-document model (merge concurrent edits deterministically) is locally buildable + unit-testable without the server; the server/transport is the actual blocker.

  1. C (S) — fix the live lane-remap bug.
  2. A (M) — EQ-band automation (closes MIX-7).
  3. B (M) — if-prev/if-not-prev (closes MIDI-17).
  4. D (M) — transient snap (re-scoped to sampler tracks).
  5. H (M) — per-track CPU (with sampling/EMA + gating).
  6. Optional/low-ROI: I, E, F-engine.
  7. Keep deferred (rationale above): J, K, G, and the external tier (note the i18n/collab local cores).

Tier 1 (C+A+B) is ~2 days, all local + fully unit-testable, and each closes a gap in a feature already shipped this session — the highest-leverage next work.


Implementation pass — outcomes (2026-06-07, later)#

Worked through every actionable item. The genuinely-worthwhile ones were implemented end-to-end (tested + committed + pushed to both refs); the rest were re-examined at the code level and, on the deeper read, found to be make-work or hard-blocked — documented here with the specific evidence rather than shipped.

SHIPPED:

  • C — lane re-map on insert move/replace. Track::swap_inserts now swaps lane insert-indices alongside the nodes; moveInsert reducer remaps lane insertId through the from↔to swap; setInsertChain clears stale lanes. (81 dsp-graph + DAW tests.)
  • A — EQ band-gain automation. ParametricEq granular set_band_gain/freq/q; EqNode::set_param routes band{N}_gain|freq|q; the automation-lane lists EQ's 3 band gains. (133 dsp-core/82 dsp-graph.)
  • B — if-prev/if-not-prev trig conditions. Deterministic played[] recurrence over start-sorted notes in NoteClip.advance + codes 11/12 + Ctrl/Cmd-scroll labels. (84 dsp-graph.)
  • D — transient snap (re-scoped to sampler tracks). transientsToBeats + clipTransientSnapBeats pure helpers; daw-app detects on sample load → TrackState.transientBeats; arrangement magnetic snap includes track onsets mapped to clip song-positions. (871 DAW tests.)
  • I — reverb/delay f64 internal path (ENG-5's spec-named targets). DelayLine stores f64 + read*f64/write_f64 for a full-precision feedback loop (public f32 API preserved → chorus/flanger unaffected); Delay damp_state f64; Freeverb Comb/Allpass buffers + filter store f64; flush_denorm_f64 preserves the denormal flush. Test: an f64 echo train tracks feedback^n within 1e-4 over 15 repeats. (134 dsp-core.) *(Initially weighed as low-ROI, but it is unblocked + spec-named, so it was implemented to honor "all actionable items"; the change is low-risk and the precision is now pinned by test.)_

EXAMINED AT CODE LEVEL → NOT IMPLEMENTED (hard blockers, evidence):

  • H — per-track CPU: impractical. WASM has no μs clock reachable from a worklet: Date::now is ms-resolution (a single track's sub-ms process quantizes to 0/1 ms → garbage); performance.now isn't exposed via js_sys in AudioWorkletGlobalScope; a custom no-modules import would add an FFI call per track per block on the audio thread (overhead + noise). A structural cost estimate would misrepresent "CPU %". The honest, accurate deliverable is the overall CPU meter (ENG-20, shipped). Verdict: don't ship a coarse/noisy or estimate-dressed-as-measurement per-track meter.
  • E — clip time-signature: no surface. Arrangement clips are note clips; there is no per-clip content/bar-grid editor that a per-clip meter could change (the piano-roll edits a track's noteClip, not arrangement clips). The field would be dead state. (A session-level time signature would have a surface — the arrangement bar grid + metronome accent — but that's a different, non-deferred scope.) Verdict: not implementable without inventing a surface.
  • F — cue mix: routing-blocked. The engine cue-bus (per-track cue-send → second stereo pair) is locally buildable, but it is only an audible feature once routed to a second output device via setSinkId (browser, device-dependent, not unit-testable). An unroutable cue bus isn't a usable feature. Verdict: defer until/unless second-output-device routing is in scope.

Net: all five unblocked actionable items shipped — C, A, B, D (each closing a gap in a feature delivered earlier this session) plus I (ENG-5's reverb/delay f64). The remaining three are hard-blocked — by platform limits (H per-track timing, F second-output routing) or by the data model (E clip time-sig has no surface) — and are documented above with specific evidence so the call is auditable rather than silent. A session-level time signature (arrangement bar grid + metronome accent) would be the actionable adjacent feature to E, but it is new scope beyond the deferral list rather than the deferred per-clip variant.