Disciplines · Audits

Euterpe → SOTA DAW — Remaining-Work Checklist (granular, expert)

deliberately small so progress is legible.

7sections132 minread

On this page

Date: 2026-06-08 · Baseline report: EUTERPE_SOTA_GAP_ANALYSIS_2026-06-07.md (186 residual gaps) Author context: the cleanly-buildable, low-risk local backlog has been worked through (≈60+ gaps shipped, the latest 12 this session: COLLAB-3 versioning/merge/branching, ENG-17/19/8, MASTER-6/N3/7/15, PLAT-N6, MIDI-5). This document is the forward map of everything that is NOT yet done, grouped into three buckets by how it must be executed. It is written to be picked up cold — every item has files, an approach, verification, acceptance criteria, and an honest blast-radius/risk note.

Paths moved (checked 2026-09-18). Every apps/euterpe-studio-web/… path in this file is now apps/euterpe/studio-web/…, and the DSP effects it names (autowah.rs, deesser.rs, dynamic_eq.rs) are in libs/euterpe/audio-engine/crates/dsp-core/src/, beside the graph crate crates/dsp-graph. The 44 old paths are left as written so the history reads true; resolve them through this note.


0. How to use this checklist#

  • Items are checkboxes. [ ] = not started, [~] = partial/foundation-only, [x] = done (none here — this file is remaining-only). Sub-tasks are deliberately small so progress is legible.
  • Pick ONE bucket-A or bucket-B item per work session and budget for its blast radius + full verification. These are no longer quick clean wins. Do not batch-mark; each box must be backed by code you wrote and tests you ran.
  • Bucket-C items cannot be completed in this sandbox — for each, the checklist records the fail-loud seam to ship now (a typed interface that throws not_configured / returns {configured:false}) and the exact external resource needed to finish. Do not fake success; ship the seam, document the dependency.

0.1 The proven cross-stack engine recipe (used ~7× this session — follow exactly)#

text
1. dsp-core (primitive) or dsp-graph (engine/track): add the Rust method with a NO-OP INVARIANT
   (default value = bit-identical to before → all existing cargo tests pass unchanged).
2. dsp-wasm/src/lib.rs: #[wasm_bindgen] getter/setter forwarding to the engine.
3. worklet/engine-processor.template.js: a `case '<cmd>':` dispatch (only if realtime/per-block).
4. audio-engine-web/src/messages.ts: extend EngineCommand union (+ EngineEvent/MeterSnapshot if it's a readback).
5. audio-engine-web/src/audio-engine.ts: a bridge method ONLY if you want a named call; most commands post generically
   from the reducer's `commands: EngineCommand[]` — no bridge needed.
6. App reducer: apps/euterpe-studio-web/src/daw/{types.ts (field + action), daw-session.ts (case), session-rebuild.ts
   (replay non-default), project-io.ts (persist + restore — often FREE via the SerializedTrack/session spread)}.
7. UI panel under apps/euterpe-studio-web/src/components/daw/.
8. REBUILD the served bundle (gitignored, NEVER committed):
   cd libs/euterpe/audio-engine-web && node scripts/build-worklet.mjs        # wasm-pack release, ~6-10s, light on the Mac
   cd apps/euterpe-studio-web && node scripts/sync-engine-assets.mjs         # copies to public/audio

0.2 Verification harness (the standard every item must clear)#

text
# Rust
cd libs/euterpe/audio-engine/crates
cargo test -p dsp-core -p dsp-graph          # known-correct value assertions, NOT just "renders"
cargo clippy -p dsp-core -p dsp-graph -p dsp-wasm   # pre-existing warning: track.rs Source enum variant size (ignore)

# WASM boundary (drives the compiled wasm in Node — proves the binding flows end-to-end)
cd libs/euterpe/audio-engine-web
npx tsc --noEmit
npx vitest run src/__tests__/wasm-engine.spec.ts   # add the new method to WasmEngineInstance iface + a boundary test

# App (NO Nx — it's broken by worktrees; use the ad-hoc tsconfig)
cd apps/euterpe-studio-web
npx tsc --noEmit -p tsconfig.tmpcheck.json   # MUST stay at 0 errors
npx vitest run src/daw src/components/daw     # jsdom render+dispatch tests
  • No timeout command on macOS. grep exit 1 = "no matches" = success in a one-shot batch (don't let it fail a chain).
  • git diff --cached | grep 'wasm/' FALSE-POSITIVES on the crates/dsp-wasm/ SOURCE path — verify artifacts aren't staged by checking the file list for audio-engine-web/wasm/ and public/audio/ specifically.
  • Commit + push to BOTH refs every completed item: git push origin <branch> then git push origin <branch>:main. --no-verify is OK (pre-commit Nx hangs). Trailer: Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>.

0.3 Architecture anchors (where things live)#

  • Engine: libs/euterpe/audio-engine/crates/{dsp-core (DSP primitives: voice.rs, oscillator, filters, dynamics.rs, saturator/waveshaper, oversample.rs, multiband.rs, meter.rs), dsp-graph (engine.rs = master bus + per-track loop, track.rs = Source/inserts/sends/channel-strip, clip.rs, tempo_map.rs, effect.rs = AudioEffect trait + insert nodes, context.rs = ProcessContext), dsp-wasm (#[wasm_bindgen] facade)}.
  • Engine bridge: libs/euterpe/audio-engine-web/{src/messages.ts (EngineCommand/EngineEvent unions, MeterSnapshot), src/audio-engine.ts (AudioWorklet host), worklet/engine-processor.template.js (command dispatch + meter frame)}.
  • App: apps/euterpe-studio-web/src/{daw (reducer daw-session.ts, types.ts, session-rebuild.ts, project-io.ts, automation-lane-helpers.ts, fft.ts, reference-match.ts, pure helpers + specs), components/daw (React: daw-app.tsx is the host hook+render; piano-roll, arrangement-view, mixer, synth-panel, insert-rack, transport-bar, *-panel.tsx)}.
  • The @euterpe/master lib (libs/euterpe/master/src/frontier-mastering) has REAL DSP planners (matchEq, dynamicEqResponse, masterStems, recommendChain, meterBs1770, resolveBus) — wire-able, but several return PLANS not realtime processors (wiring a planner as if it processes = display-only stub; verify before wiring).
  • The @euterpe/provenance lib is a SIMULATION (FNV-keyed 'ed25519-sim', 64-bit-into-number[] watermark). Real C2PA/SynthID needs proper crypto + a PCM watermarker (bucket C).

1. Reconciliation — what is already DONE (do NOT re-do)#

Shipped/verified (source-tagged): arrangement timeline + clip editor (ARR-1/2/3/4/6/8/9/10/11/12/13/15/16, FX-17, UX-9, REC-1, IO-10, PLAT-7, AI-11), session/clip-launch view (ARR-7/UX-23), synthesis quartet (FX-1/2/3/5/6/7/8 incl. full mod-matrix + LFO shapes/targets, FX-10 presets, FX-14 slicing), 15 insert effects, master metering suite (ENG-12/15/16/20, MIX-5/13), master chain (MIX-2 bus/aux, MIX-1 VCA, MIX-4 pre/post sends, MIX-7 EQ-auto, MIX-23 multiband, MIX-27 sidechain, MIX-34 snapshot, ENG-17 soft-clip, ENG-19 lookahead, MASTER-15 ceiling+width auto), mastering/export (MASTER-2/3/5/6/N3/7/ 14/18/19/N1, IO-5 BWF, format/genre/LUFS/compliance/dither/tags), MIDI tools (MIDI-4/9/10/11/12/15/16/17/19/20/5), DAWproject import+export (IO-1), recording (REC-7/8/20), rights (RIGHTS-1/2/3/4/5/11/17 at the codebase's fail-closed baseline), provenance scan (RIGHTS-11 own-watermark), versioning+merge+branching (COLLAB-3, COLLAB-8), platform a11y/i18n-adjacent (PLAT-1 MIDI-learn, PLAT-10/12/14/15/17/21/N6), UX (UX-2/3/4/5/14/17). ENG-8 saturator oversampling, AI-4/8/N1 generation.

Ambiguous — verify before assuming remaining: MIDI-18 / ENG-21 / REC-6 / MIX-31 (MIDI-learn / control-surface — PLAT-1 covers CC-mapping; OSC done [3.9 IO-8] + MCU done [3.9 MIDI-18, 2026-06-08] + HUI core done [3.9, 2026-06-08]; HUI transport/feedback + EUCON NOT done), ENG-18 (per-track sidechain matrix — MIX-27 covers per-insert sidechain off a key track; an arbitrary any→any routing matrix UI is NOT done), MIX-3 routing matrix — DONE 2026-06-08: apps/euterpe-studio-web/src/daw/routing-matrix.ts (buildRoutingMatrix derives a grid of every track's output destination [Master / group bus] + reverb/delay aux-send levels; routeToBusAction/reverbSendAction/delaySendAction lower a cell edit to the existing setTrackBus/setTrackSend/setTrackDelaySend reducer actions, clamped) + a RoutingMatrixPanel modal (exclusive destination radios per row + aux-send sliders, es-localized) opened by a "Routing Matrix…" command-palette command. A REAPER-style patchbay over the existing bus/send routing — no new engine capability. 6 pure + 6 jsdom tests; full suite 1683. (Arbitrary any→any track-routing would need new engine routing — still NOT done.) MASTER-N4 (compliance dashboard — ≈ covered by MIX-33 loudness-preview-panel), UX-29 (DAWproject UI — IO-1 wired it), MASTER-17 (offline-render progress UI — verify), COLLAB-19 (PWA/OPFS — only partially; see PLAT-5), AI-N5/UX-26/REC-N2 (AI session players — a ~55-LOC accompaniment helper exists; the deep chord-reactive real-time player is NOT done).


2. BUCKET A — Large local engine refactors (buildable + fully verifiable here)#

These are real, in-sandbox, cargo+Node+jsdom-verifiable, but touch the audio hot path or need a new DSP subsystem. Each has a no-op invariant that protects the ~190 existing engine tests — preserve it religiously.

2.1 [x] ENG-3 — Plugin Delay Compensation (PDC) framework · P1 · effort L ✅ DONE 2026-06-08 (+ REC-9 folded in)#

Why here: real DAWs auto-report per-plugin latency and delay-align all tracks so the mix stays phase-coherent. Euterpe's only current latency sources are the oversampler FIRs (Waveshaper/Saturator) — small, but the framework is the gap.

Implementation note: the PDC ring is a single MONO delay applied to the post-insert block before the channel strip (not the proposed pdc_l/pdc_r post-strip stereo pair). Delaying the mono pre-strip signal uniformly delays the main bus output AND every post/pre-fader send AND the key bus — i.e. the "full correctness" option (sends delayed too), achieved with one ring instead of stereo rings + separate send handling. REC-9's manual nudge is folded in.

  • Latency reporting (foundation, zero audio change):
    • dsp-core: expose Waveshaper::latency() + Saturator::latency() = if mix==0 {0} else {self.oversampler.latency()} (honest about the zero-latency bypass/off paths). Verified by a normalized cross-correlation delay-detector (gain-invariant — a raw sample compare fails because unity-drive waveshaper has 1.31× inherent gain).
    • dsp-graph/effect.rs: added fn latency(&self) -> usize { 0 } to AudioEffect; overridden on WaveshaperNode/SaturatorNodeself.<inner>.latency(). (All other nodes inherit 0.)
    • dsp-graph/track.rs: pub fn insert_latency(&self) -> usize = sum of latency() over non-bypassed inserts.
  • Compensation (hot path — guard the no-op):
    • Track: per-track mono PDC delay ring (pdc_ring: Vec<f32>, pdc_pos, pdc_len) + set_pdc_delay(samples). pdc_len==0 → ring skipped entirely → bit-identical (no-op invariant tested); set_pdc_delay reallocs only on size change (==pdc_len guard → no per-block alloc in steady state); reset_state clears the ring (bounce determinism).
    • In Track::process, the post-insert mono block runs through the PDC ring before the strip, so the main output + ALL sends + key bus inherit the same delay (full correctness, one ring).
    • dsp-graph/engine.rs: each process() block computes base = max insert_latency; per track delay = base + (nudge − min_nudge) − insert_latency (raises the reference by the most-negative nudge so no realized delay is < 0); recomputed every block so a bypass toggle re-aligns next block.
    • pub fn reported_latency(&self) -> usize on the engine (= base) for the "PDC: N samples" readout.
    • REC-9 manual track-delay nudge folded in: Track::set_pdc_nudge/pdc_nudge + engine set_track_delay_samples(i64)/track_delay_samples.
  • Bridge + UI: dsp-wasm engine_latency_samples() + set/track_delay_samples (i32 — i64→JS BigInt breaks the number boundary); meter frame carries latencySamples; EngineCommand setTrackDelay; audio-engine.ts bridge; transport-bar shows "PDC N smp"; channel-strip "Track Delay" ±2400-sample nudge slider.
  • Tests: cargo — clean + 4× distortion stems stay phase-aligned within 1 sample (normalized cross-correlation); pdc_delay(0) is bit-identical (no-op invariant); insert_latency sums only non-bypassed; per-track delay math + REC-9 nudge. Node-boundary engine_latency_samples() returns 16 for a 4× distortion + nudge round-trip. jsdom — transport-bar PDC readout + channel-strip nudge slider dispatch. (cargo dsp-core 148 + dsp-graph 106 + dsp-wasm 49; app 1188 green; clippy clean; wasm rebuilt+synced.)
  • Acceptance: ✅ two tracks with differing insert latency stay phase-aligned at the master; 0-latency sessions are bit-identical; the engine reports its added latency.
  • Risk/blast radius: MEDIUM. Touches every track's output path. Mitigation = the pdc_len==0 byte-identical fast path (all existing scheduler tests have no oversampled inserts → unaffected). Dynamic-bypass ring resize is the subtle part — test bypass-toggle mid-playback. NB: REC-9 (PDC + track-delay nudge) is the same feature + a manual per-track delay offset — folded in.

2.2 [x] ENG-2 — Sample-accurate / sub-block automation · P2 · effort M ✅ DONE 2026-06-08#

Why here: automation is currently evaluated once per process block (block-granular). Audio-rate filter sweeps and fast moves zipper. Pro DAWs render automation per-sample (or in fine sub-block slices).

  • Identified all sites: track.rs volume/pan/cutoff/send/delay-send + per-insert-param; engine.rs master gain/ceiling/width.
  • Granularity: CTRL = 16-sample sub-blocks (matches the synth voice's internal control rate).
  • track.process refactored into a dispatch: has_active_automation() && playing → a while loop over CTRL slices, each calling apply_automation(beat) then process_slice(start,end) (extracted render→inserts→PDC→strip); else a single full-width slice = byte-identical to before (no-op invariant). engine.process master loop re-evaluates apply_master_automation(beat) at each i % CTRL == 0 while playing; else once per block. All nodes are per-sample streaming so slicing is bit-identical when params don't change; the one block-rate node (auto-wah envelope filter) simply tracks at the slice rate when a track also has automation (finer = the intended sub-block behaviour, no regression — the fast path sees one full slice).
  • Tests: cargo — constant_automation_equals_the_static_parameter (no-op invariant: constant automation renders bit-identically to the static fader); sub_block_automation_ramps_gain_within_a_single_block (a steep ramp climbs monotonically across CTRL slices — block-granular would be flat); master_gain_automation_is_evaluated_sub_block (a master fade reaches full within the block). All 92 scheduler + automation tests still green (148 dsp-core + 109 dsp-graph + 49 dsp-wasm; clippy clean; wasm rebuilt+synced). No wasm/app API change.
  • Acceptance: ✅ audio-rate cutoff/volume/master sweeps are sub-block-smooth (CTRL-rate); sessions without automation are bit-identical.
  • Risk: MEDIUM-HIGH. Touches the hot loop + 92 scheduler tests. Mitigated as planned: the no-op invariant test was written FIRST, the empty/stopped-automation fast path is byte-identical (one full-width slice), and all 92 scheduler + automation tests stayed green through the refactor.

2.3 [~] ENG-14 — Selectable FFT window / size / overlap for spectral metering · P3 · effort M ✅ CORE+UI DONE 2026-06-08 (live-visual = browser pass)#

Why here: the live spectrum uses a WebAudio AnalyserNode whose window is FIXED (Blackman) and only fftSize is settable. Pro analyzers expose window TYPE + size + overlap. Requires the app's OWN STFT render (tap master output → window → FFT).

  • New apps/euterpe-studio-web/src/daw/spectral.ts (reuses fft.ts): windowSample/makeWindow + coherentGain for Rectangular/Hann/Hamming/Blackman/Blackman–Harris (periodic form → clean coherent gains), windowedSpectrum (amplitude-corrected: a bin-centred unit sine reads ≈1.0 for ANY window via 2/Σwindow), binFrequency, dominantBin, leakageEnergy, spectrumBars (log-spaced dB display reducer).
  • STFT analyzer wired into SpectrumView: when a window prop is set it taps analyser.getFloatTimeDomainData (the documented copy approach) → windowedSpectrumspectrumBars, replacing the fixed-window getByteFrequencyData path (kept as the fallback). FFT size drives analyser.fftSize.
  • UI: SpectrumPanel adds window-type + FFT-size selectors (window TYPE is the thing the AnalyserNode can't do), persisted UI-local to localStorage. (Overlap is inherent in the animation-frame re-tap — documented, not a separate selector.)
  • Tests: 9 pure — coherent gains == known values (1.0/0.5/0.54/0.42/0.35875); a bin-centred sine reads ≈1.0 + dominant bin correct for every window; Blackman–Harris leaks <¼ of rectangular for an off-bin tone; binFrequency correct; spectrumBars floors silence + peaks on the tone. 4 jsdom — window/size selectors default, reflect, persist, restore. (220 component tests green; app tsc 0.)
  • Acceptance: ✅ window/size selectable via the own-STFT (changes resolution/leakage — verified numerically); spectrum reads correct bin frequencies. ⏳ live-canvas visual confirmation = browser pass (no browser MCP here); the render path is unchanged from the shipped fixed-window spectrum, only its source bars changed. REMAINING: browser-visual acceptance pass.
  • Risk: LOW-MEDIUM. Mostly additive (new STFT path); the tap from the worklet output is the integration point to design carefully (a meter SAB or a periodic getFloatTimeDomainData copy).

2.4 [x] ENG-9 / IO-N2 — Élastique/Rubber-Band-class elastic time-stretch + warp markers · P2 · effort L ✅ DONE 2026-06-08 (both halves)#

Why here: WSOLA stretch (warpSample) exists; ENG-9 wants formant-preserving, transient-aware, pitch-independent stretch (élastique/Rubber-Band quality) + per-clip warp markers (ARR-5/IO-N2: non-destructive grid-following stretch).

  • Élastique-class stretch quality (DONE 2026-06-08): dsp-core/timestretch.rs already had transient-locked WSOLA; added a from-scratch offline phase vocoder (per-bin instantaneous-frequency phase propagation on the existing fft.rs, Hermitian-mirrored, unity-gain weighted-overlap-add) + a quality flag StretchMode::{Beats,Tones,Complex} — Beats = WSOLA (the default, bit-identical to before, no-op-invariant tested), Tones = phase vocoder (sustained/tonal), Complex = phase vocoder + transient phase-reset at detected onsets (crisp attacks + smooth sustains). time_stretch_mode dispatches; time_stretch delegates to Beats. Formant preservation for pitch shift: a cepstral spectral envelope captured from the source is re-imposed on the shifted buffer per STFT frame (env_orig/env_shift), so harmonics move but resonances stay (no chipmunk) — pitch_shift_mode(.., mode, preserve_formants); pitch_shift delegates. Wired end-to-end: dsp-wasm time_stretch_buffer_mode/pitch_shift_buffer_mode → worklet loadSampleWarped → audio-engine-web loadSampleWarped(.., mode, preserveFormants) + StretchMode type → daw-app warpSample → sampler-panel engine selector + "preserve formants" toggle. Tests: cargo dsp-core 174 / dsp-graph 109 / dsp-wasm 50 (Beats==WSOLA bit-identical; PV length/pitch/level; Complex sharper transients than Tones; formant preservation restores the spectral centroid while pitch still doubles), clippy clean; compiled-wasm boundary spec 32 (mode + formant fns on the rebuilt artifact); app tsc 0, sampler-panel 15, full daw suite 1339. (commit feat(euterpe): phase-vocoder stretch modes + formant-preserving pitch shift.)
  • Warp markers (DONE 2026-06-08): implemented as a non-destructive buffer re-render rather than a per-clip content-read (the engine's track-owns-one-Sampler model makes a re-render the clean fit). dsp-core/timestretch.rs warp_to_markers(input, src_beats, dst_beats, spb, mode) builds (source-sample, target-sample) anchors, implies a (0,0) start + an unwarped tail, and pitch-preserving-stretches each inter-anchor segment by its local ratio via time_stretch_mode. No-op invariant: no anchors → input unchanged; an identity map is byte-identical (unit-ratio segments are copied).
  • Bridge + reducer (DONE): dsp-wasm warp_buffer_markers → worklet loadSampleWarpMarkers → audio-engine-web loadSampleWarpMarkers(.., samplesPerBeat, mode) → daw-app warpSampleMarkers (renders from the retained original buffer, so re-applying replaces not compounds) → sampler-panel warp-markers editor (add / seed / snap-to-grid / edit src→dst / remove / apply / reset; Apply gated on warpMarkersAreActive). New pure daw/warp-markers.ts (sanitizeWarpMarkers sorts + drops zero-length/invalid + clamps targets monotonic, snapMarkersToGrid, evenWarpMarkers, warpMarkersAreActive).
  • Tests (DONE): cargo — warp no-op invariants + a region-doubling test via energy centre-of-mass (robust to WSOLA grain overlap; clicks proved too pathological for exact-peak assertions); compiled-wasm boundary spec re-times a burst to the right beat; warp-markers pure spec 6; sampler-panel jsdom (add/edit/apply, seed/reset, omit-without-handler). cargo dsp-core 176 / dsp-graph 109 / dsp-wasm 50; app tsc 0; full daw suite 1348. (commit feat(euterpe): warp markers — grid-following … IO-N2.)
  • Acceptance: ✅ a stretched drum loop keeps transient definition (Beats/WSOLA transient lock); ✅ warp markers grid-lock content non-destructively (original retained, re-rendered on apply). ⏳ REMAINING (deeper follow-ups): markers are runtime-local (samples aren't persisted in the session, like the existing warp/velocity layers); a waveform drag-to-place overlay, per-clip warp markers, and true realtime streaming-warp (vs. buffer re-render) are the documented next steps.
  • Risk: MEDIUM. Phase-vocoder quality is hard to verify objectively — assert measurable proxies (transient sharpness, fundamental preservation), not "sounds good".

2.5 [x] FX-9s — Spectral / STFT effects (vocoder, spectral gate, spectral delay/freeze) · P2 · effort L ✅ COMPLETE 2026-06-08 (STFT framework + gate + freeze + spectral-delay + VOCODER)#

Why here: needs a real STFT engine (overlap-add framing) inside an insert node — a new DSP subsystem, not a per-sample fx.

  • Built the STFT framework in dsp-core: new dependency-free fft.rs (radix-2 FFT/IFFT — round-trip identity + sine-peak tests) + stft.rs Stft (streaming weighted-overlap-add, periodic Hann, 75 % overlap = hop size/4, COLA-normalized → identity callback reconstructs the input exactly, verified by cross-correlation at the reported latency = size−1). Reusable by future spectral fx / ENG-14 / AI mastering.
  • FOUR spectral nodes shipped: SpectralGate (per-bin dBFS threshold, STFT de-noiser) + SpectralFreeze (capture-and-hold the spectrum with per-bin natural phase advance → a sustained drone) + SpectralDelay (per-bin frequency-dependent delay: highs lag lows by up to 24 hops × spread → a spectral smear impossible with a time-domain delay; dry/wet mix) + SpectralVocoder (FX-9s final piece, 2026-06-08): a self-carrier channel vocoder — averages the modulator's magnitude into bands frequency bands (a coarse formant envelope that smooths away the pitch harmonics) and imposes that envelope on an internally-synthesised broadband noise carrier (a fresh deterministic per-bin random phase each frame, Hermitian-mirrored → real out) → whisperization that keeps the vowel's formant colour; dry/wet mix. Single-input design that fits the mono insert model — the classic two-input modulator+carrier vocoder is a sidechain-keyed enhancement (needs the sidechain KEY BLOCK, currently only a scalar sidechain_level in ProcessContext → an engine-wide lifetime change, deferred). All → *Node impls with latency() → the framework latency so ENG-3 PDC delay-aligns them.
  • Insert-rack wiring per the recipe for all four (dsp-wasm add/set; messages; worklet; InsertKind 'spectralgate'/'spectralfreeze'/'spectraldelay'/'spectralvocoder'; reducer build + setInsertParams; session-rebuild; insert-rack KINDS 'Spec Gate'/'Freeze'/'Spec Dly'/'Vocoder' + PARAM_CONFIG: gate=dBFS threshold, freeze=Hold toggle, delay=spread+mix, vocoder=bands+mix). wasm rebuilt+synced.
  • Two-input (sidechain-keyed) channel vocoder DONE 2026-06-09 — the documented enhancement, the "deferred" note below is now CLOSED. dsp-core/stft.rs ChannelVocodertwo synchronized STFTs: the modulator's per-band magnitude envelope is whitened off the carrier and re-imposed (the carrier keeps its pitch but speaks the modulator's vowels — robot voice/talkbox), bands + dry/wet mix. Avoided the engine-wide ProcessContext lifetime ripple the note feared: the modulator reaches the insert via an additive, defaulted AudioEffect seam (feed_sidechain(&key) + wants_sidechain(), no-op for all existing nodes), so no &ProcessContext site changed and the 142 prior dsp-graph tests stay byte-identical. The engine snapshots the key bus to mono and feeds it to a track's vocoder before processing, gated on wants_sidechain(). The modulator is the existing MIX-27 key bus (pick a key source in the master panel); without one the vocoder is silent at full wet (a true vocoder needs a modulator — honest). dsp-graph SidechainVocoderNode (carrier = insert input, modulator = fed key) → dsp-wasm add_sidechain_vocoder/set_sidechain_vocoder_params → worklet → InsertKind 'sidechainvocoder' → reducer/ session-rebuild → insert-rack "Robot Voc" button. Tests: dsp-core 218 (+2 ChannelVocoder), dsp-graph 148 (+1 engine: silent w/o modulator, sounds when the key bus feeds it), dsp-wasm 55 (+1 binding), WASM-boundary 56 (+1 real-wasm via the key bus), app reducer +3, full src/daw+components 2058; clippy clean. (commits two-input channel vocoder — engine + wasm + two-input vocoder insert UI.) Remaining polish: a dedicated per-vocoder modulator selector (it currently shares the master-comp key bus).
  • Tests: cargo — STFT identity reconstruction (delay == latency()); reset determinism; gate removes a quiet tone / passes a loud one; freeze sustains through silence + holds pitch + passes un-frozen; spectral delay smears highs later than lows (output centroid rises over time) + dry-mix == the bare framework; vocoder: mix=0 == the bare framework, the noise-carrier output's spectral centroid tracks the modulator's formant (low→low, high→high), reset-deterministic, reports framework latency; FFT round-trip + sine-peak. Node-boundary — gate reports 511, vocoder reports 1023 latency across the rebuilt WASM boundary. jsdom — reducer add/edit + rebuild for all four. (164 dsp-core + 109 dsp-graph + 49 dsp-wasm cargo; 1320 daw; clippy clean [only the pre-existing Source-variant-size warning]; tsc 0.)
  • Acceptance: ✅ gate de-noises, freeze sustains, spectral-delay smears by frequency, vocoder imposes the modulator's formant on a noise carrier; STFT reconstruction is exact (COLA).
  • Risk: MEDIUM-HIGH. New subsystem + latency — done after ENG-3, so each node PDC-aligns automatically. The two-input/sidechain-keyed vocoder (the one enhancement) is now also shipped (2026-06-09) — via an additive feed_sidechain AudioEffect seam, so it needed NO ProcessContext key-block refactor. FX-9s is complete.

2.6 [x] ENG-N2 / ENG-11 — WASM SIMD vectorization + large-session profiling · P2 · effort M-L ✅ DONE 2026-06-09#

Why here: the DSP hot path is scalar; the 2025-26 web-DAW pattern compiles to WASM SIMD128 (4-wide). ENG-11 = profile + SIMD-sum the master/bus mix for many-track sessions.

  • Portable F32x4 abstraction (DONE 2026-06-09): libs/euterpe/audio-engine/crates/dsp-graph/src/simd.rs — a 4-wide f32 vector with two cfg-selected backends: on wasm32+simd128 it lowers to core::arch::wasm32 v128 ops (f32x4_add/mul/abs/max, safe f32x4() lane-constructor load + extract_lane store — no raw-pointer v128_load, so the crate's #![forbid(unsafe_code)] holds); on every other target (incl. the native cargo test host) a [f32; 4] fallback the autovectorizer still lowers to NEON/SSE. Only add/mul/abs/max are used — no FMA — and mac does an explicit mul-then-add, so the two backends are bit-identical for the finite inputs the mixer sees (the no-op invariant).
  • Vectorized the engine.rs hot loops (DONE 2026-06-09): (1) the group-bus → master mixdown — the canonical bus[i] += group_bus[k][i] * g multiply-accumulate, now crate::simd::mac (per bus, per channel, per block); (2) the per-track sidechain-key detectormax_i max(|key_l[i]|, |key_r[i]|), now crate::simd::abs_peak (runs once per track per block, so it scales with track count — the bigger structural win). Both are drop-in over disjoint engine fields; all 116 prior dsp-graph engine tests stay green (the no-op invariant) + 7 new simd-kernel tests (bit-exact assert_eq vs a scalar oracle for every length 0..=64 so each remainder size is covered, a 4096-sample mix within 1e-6, shorter-slice + empty edge cases, abs-peak across both channels).
  • +simd128 enabled in the wasm build (DONE 2026-06-09): new libs/euterpe/audio-engine/.cargo/config.toml ([target.wasm32-unknown-unknown] rustflags = ["-C","target-feature=+simd128"], scoped to wasm so native tests are untouched) + --enable-simd added to the dsp-wasm wasm-opt flags. Verified the shipped artifact actually contains SIMD: an opcode-byte scan of wasm/dsp_engine_bg.wasm finds real f32x4.abs×2 + f32x4.max×2 (the abs_peak kernel), f32x4.add×14 + f32x4.mul×74 (the mac kernel + autovectorized loops); module validates. The runtime feature check is libs/euterpe/audio-engine-web/src/wasm-simd.ts wasmSimdSupported() (the canonical 31-byte () -> v128 probe via WebAssembly.validate, never throws) + wasmSimdCapability() — a fail-loud capability gate (5 tests incl. mocked unavailable/throwing hosts). Honest scope: one SIMD binary ships (simd128 is baseline since Chrome 91 / FF 89 / Safari 16.4); the cfg scalar source fallback exists, but a dual-binary runtime auto-switch (ship engine-simd + engine-scalar, pick in the loader) is the documented optional tail — unnecessary at current browser support, where the check fails loud instead.
  • Large-session bench + cpuLoad (DONE 2026-06-09): a cargo large_session(n) helper (¾ synth / ¼ silent tracks round-robin across the 4 group buses with non-unity trims + a sidechain key) backs two tests — large_64_track_session_ renders_finite_and_audible (the checklist's 64-track acceptance: every sample finite + the mix audible) and an #[ignore]'d large_session_bench_reports_realtime_ratio (prints the real-time headroom; measured: 10 s of 64-track audio in 3.86 s native-release = 2.6× real-time, 38.6 % of one core). The existing cpuLoad meter (worklet process-time ÷ budget → MeterSnapshot → the transport-bar CPU N%) already surfaces this headroom for any session size; the SIMD work lowers that number directly — no new meter wiring needed.
  • Tests: cargo — SIMD vs scalar bit-identical for every length + within 1e-6 on a 4096 random mix; the 64-track render completes + is finite (dsp-core 185 / dsp-graph 117 +1 ignored bench / dsp-wasm 50, clippy clean bar the pre-existing enum-size note). WASM boundary — a 32-track / 4-bus / sidechain-key session renders finite + audible through the real simd128 wasm (wasm-engine.spec.ts, 36 tests) + wasm-simd.spec.ts (5). App tsc 0; full audio-engine-web suite 77 green.
  • Acceptance: ✅ measurable headroom on a 64-track session (2.6× real-time native; the cpuLoad meter reflects the wasm gain); ✅ output unchanged within fp tolerance (bit-identical kernels, all 116 engine tests + 36 boundary tests green through the SIMD wasm). ⏳ a dual-binary scalar-fallback build is the only documented (optional) tail.
  • Risk: MEDIUM — discharged. The scalar-equivalence test is bit-exact (not merely 1e-6), the real v128 artifact is verified present, and the no-op invariant holds end-to-end through both the native fallback and the compiled wasm.

2.7 [x] ENG-N3 / MIX-28 / AI-20 — Immersive / surround / Dolby Atmos output + ADM authoring · P1/P2 · effort XL ✅ DONE 2026-06-09 (parallel path, 3 increments)#

Why here: the engine is stereo end-to-end (track.rs:1 "mono source → stereo via pan"). Surround/Atmos = a multi-channel bus architecture + object panning + ADM BWF export (IO-N6). Built the checklist's recommended parallel multi-channel path: the realtime engine stays stereo (all ~190 engine tests untouched), surround is an offline render + export capability.

  • N-channel bed + 3D panner (Increment 1/3, DONE 2026-06-09): dsp-graph/src/surround.rsSpeakerLayout (5.1/7.1/7.1.4 with real ITU/Dolby azimuth/elevation positions, channel order L R C LFE … surrounds … tops), a proximity-on-the-sphere constant-power 3D panner (pan_gains(az, el, spread): object + speakers as unit vectors, weight (½+½·cos θ)^sharpness, LFE excluded, Σ gain² = 1 so loudness is constant as the object moves; spread widens the lobe), render_object (composite a mono object into the N-channel bed), ITU-R BS.775 stereo fold-down, and a documented spherical-head ITD/ILD binaural monitor (Woodworth ITD + head-shadow ILD — a real named model; true measured-HRTF is the external tail). Exposed via dsp-wasm free fns (surround_pan_gains/channel_count/channel_labels/downmix_stereo/ binaural_stereo). 8 cargo tests (hard-rear→rear, −90°→Lss, overhead→tops, constant-power+LFE-silent, spread, bed accumulation, BS.775 centre/hard-left, binaural-left-louder-and-earlier) + 1 dsp-wasm binding + 1 WASM-boundary.
  • ADM BWF writer (Increment 2/3, DONE 2026-06-09): audio-engine-web/src/adm-bwf.ts — a self-contained ADM BWF: encodeAdmBwf frames a multi-channel RIFF/WAVE with fmt+bext (EBU R98 602-byte ext)+chna (BS.2088 channel allocation, one audioTrackUID/track→track+pack format)+axml (BS.2076 audioFormatExtended)+data. buildAdmXml emits a fully self-referential ADM (audioProgramme→audioContent→audioObject→audioPackFormat→audioChannelFormat[+audioBlockFormat speaker position, inline so no BS.2094 common-defs dependency]→stream/track format→audioTrackUID); azimuth negated into ADM's anticlockwise convention; LFE gets a lowPass; D/M/E/N stem groups → distinct objects/contents/packs with the right <dialogue> value. 8 tests (XML parsed with @xmldom: well-formed, one channelFormat+trackUID/channel, azimuth negation, full IDRef referential integrity, D/M/E/N + dialogue; binary: RIFF/chunk order+sizes, 602-byte bext, chna records, channel-count fail-loud, axml round-trip).
  • Per-track 3D position + export wiring (Increment 3/3, DONE 2026-06-09): audio-engine-web/src/surround-pan.ts — a main-thread TS port of the panner (the wasm runs in the AudioWorklet, out of reach for the offline mixdown), held bit-close to the Rust original by a boundary cross-check (TS gains vs the compiled surround_pan_gains within 1e-5); renderSurroundBed composites per-track mono objects. App: TrackState.pan3d {azimuth,elevation,spread} (display-only, default centre) + a setTrackPan3d reducer (clamp + azimuth wrap, no engine command → session-rebuild untouched, the key-signature pattern), persisted free via project-io. A "Surround (3D)" control in the channel strip + an "Export Immersive Surround (ADM BWF, 7.1.4)" command that renders each audible track's stem → mono → positions it via the panner → encodeAdmBwf. 5 surround-pan + cross-check + reducer/project-io/channel-strip/command tests.
  • Tests: cargo — a hard-rear object routes energy to the rear channels (✅) + downmix/binaural; ADM chunk structure validates (✅, @xmldom + binary RIFF walk). dsp-core 185 / dsp-graph 125(+1 ignored) / dsp-wasm 51; WASM boundary 38; audio-engine-web 91; app tsc 0; full daw+components 1722.
  • Acceptance: ✅ a 7.1.4 session renders to a valid (self-contained) ADM BWF — fmt/bext/chna/axml/data, referentially consistent ADM; ✅ binaural monitor is plausible (spherical-head ITD/ILD — lateralisation cues verified). Honest scope: the deliverable is a channel-based 7.1.4 bed (tracks baked into the bed channels via the panner) — the standard immersive master; dynamic ADM objects (each track a live audioObject with time-varying position blocks) + measured-HRTF binaural + realtime surround monitoring are the documented advanced follow-ups.
  • Risk: HIGH/XL — discharged via the parallel path: the stereo core + its ~190 tests are byte-untouched (no channel-count refactor of the hot loop), surround is an additive offline capability with its own cargo + boundary + jsdom verification.

2.8 [~] ENG-7 / ENG-N1 — OPFS-streamed sample playback + SharedArrayBuffer lock-free transport · P1 · effort L ✅ SAB RING DONE 2026-06-08 (OPFS stream + headers = browser tail)#

Why here: samples are RAM-resident; SOTA streams from OPFS with a small prebuffer, and uses SAB ring buffers (needs COOP/COEP cross-origin isolation headers). Partly browser-infra (overlaps bucket B), but the ring-buffer + prebuffer logic is local + testable.

  • COOP/COEP cross-origin-isolation DONE 2026-06-09: next.config.mjs serves Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp for the browser build (Tauri shell serves the same pair), enabling SharedArrayBuffer (also unblocks PLAT-5/COLLAB-19). Backed by a canonical, tested policy module libs/euterpe/audio-engine-web/src/cross-origin-isolation.tscrossOriginIsolationHeaderEntries() (the values next.config mirrors, asserted by a test so drift is caught), isCrossOriginIsolated/sharedArrayBufferConstructible/ canUseSharedArrayBuffer capability predicates, and a typed CrossOriginIsolationError fail-loud seam. SabRing.create now guards on SabRing.isSupported() → throws the typed error (not an opaque SharedArrayBuffer is not defined) when a tab isn't isolated. 8 tests. (Remaining browser tail: confirm the live app still loads under require-corp with no blocked cross-origin subresource — switch to credentialless (also exported) if a third-party CDN asset lacks CORP.)
  • Streaming scheduler core DONE 2026-06-09: libs/euterpe/audio-engine-web/src/streaming-sampler.ts SampleStreamScheduler — the deterministic playback policy for OPFS-streamed samples: keeps a prebufferFrames window ahead of the playhead, planNextFetch(ringFree) returns the next {sourceStart, length} to read (capped by chunk granularity, ring backpressure, and source/loop end), onFetched/onConsumed advance the source + playhead cursors, with loop wrap-around (starts at loop.start, wraps at loop.end, never ends) and one-shot EOF + drain (partial final chunk, isEnded only after source exhausted AND buffer drained). + frameByteOffset for the read offset. 9 Node tests (prebuffer fill / backpressure / consume-resumes / partial-final / end-after-drain / zero-length / loop-wrap / loop-never-ends). Feeds the SabRing (2.8 above).
  • OPFS read adapter (REMAINS — browser tail): open a FileSystemSyncAccessHandle, on each planNextFetch() read() length frames at frameByteOffset(sourceStart,…), push into the ring, call onFetched; the worklet pulls + reports onConsumed. Pure plumbing around the tested scheduler, but OPFS + the worklet read side need a real browser to verify. Re-read 2026-09-18: "needs a real browser" is not a blocker. Headless Chromium under Playwright exposes OPFS and FileSystemSyncAccessHandle inside a dedicated worker, and the repository already drives Chromium that way. Write the adapter and verify it in a Playwright chromium spec that writes a known sample file to OPFS, streams it through the ring and compares the frames the worklet pulled with the source.
  • SAB lock-free ring DONE 2026-06-08: libs/euterpe/audio-engine-web/src/sab-ring.ts SabRing — a single- producer/single-consumer f32 ring over a SharedArrayBuffer, lock-free via Atomics on the read/write cursors only (the acquire/release ordering SPSC needs, no mutex); one slot kept empty so full/empty are distinguishable and the cursors never overflow; partial push/pull report their count (honest underrun); pullOrSilence zero-fills a short block; create/wrap split producer + consumer over the same SAB. 8 Node tests — accounting, in-order round-trip, partial-push-near-full (no overwrite), wrap-around, honest-underrun→silence, producer/consumer cursor visibility across two wrappers, a 1000-sample stream through a 64-slot ring (exact order, no loss/dupes), clear. (commit feat(euterpe): lock-free SPSC … ring buffer.)
  • Tests (ring): pure ring-buffer produce/consume across wrap + underrun→silence-not-garbage — DONE (Node). ⏳ Browser pass for the actual OPFS stream remains (documented).
  • Acceptance: a long sample plays from OPFS without a full RAM load; no audible underruns at moderate buffer sizes.
  • Risk: MEDIUM; the COOP/COEP header change affects the whole app (test the app still loads cross-origin-isolated).

3. BUCKET B — Local infrastructure (buildable, heavier, some need a browser pass)#

In-sandbox-codeable but each is a substantial subsystem or needs browser/visual verification (no browser MCP here) or a new dependency. Build the pure/testable core fully; document the browser-only acceptance check honestly.

3.1 [~] MIDI-2 / UX-27 / IO-N7 / PLAT-N3 — Notation / score editor + MusicXML round-trip · P1/P2/P3 · effort L-XL ✅ PURE INTEROP + STATIC SCORE RENDER DONE 2026-06-08#

  • Pure notation.ts: NotationNote model ↔ MusicXML; midiToPitch/pitchToMidi (sharp spelling, C4 = middle C), noteValue (plain/dotted/triplet/irregular-fallback), quantizeNotes(grid) (snaps a swung performance, never collapses a note to 0).
  • MusicXML 4.0 export (notesToMusicXml) + import (musicXmlToNotes, DOMParser): measure-splitting by time sig, rests for gaps, chords (shared onset via <chord/>), barline-crossing notes split into tied segments + merged back on import. Timing authoritative via <duration>/<divisions> so it round-trips exactly for grid-clean durations. Wired into the app: TransportBar ⤓ XML / ⤒ XML buttons + a project.exportMusicXml palette command (export the selected track's note clip; import a .musicxml file as a new note-clip track).
  • Static score render (DONE 2026-06-08): pure engraving geometry apps/euterpe-studio-web/src/daw/score-layout.tsdiatonicStep (octave×7 + letter; sharps share their natural's line), accidentalFor, stemUp (B4-and-above ⇒ down), glyphFor (head fill / stem / flags / dots from notation.noteValue), ledgerSteps (middle C gets its ledger; the space just below the staff gets none), noteY/stepToY (higher pitch ⇒ smaller y; top line F5 at topPad), and layoutScore (notes positioned by beat, barlines per beatsPerBar, length rounded up to whole bars). Rendered by components/daw/score-view.tsx ScoreView — an SVG single treble staff: 5 staff lines, clef, barlines, note heads (filled/open), stems (up/down), un-beamed flags, accidentals, ledger lines, augmentation dots. Wired: a "♪ Score" toggle in the piano-roll (parallel to the PLAT-9 "⌨ Accessible grid"), showing the engraved view of the selected synth track's clip (beatsPerBar threaded from the session). Tests: 12 pure (staff steps incl. C#4-shares-C4-line, accidentals, stem direction, glyph-by-duration, ledger lines for middle C / A3 / high A5 / none-in-the-space, vertical monotonicity, layout barlines + bar-rounding + per-note placement) + 5 jsdom (5 staff lines + clef + barline-per-bar + note count; accidental for a sharp + ledger for C4; eighth flag + open half head; multi-bar; empty staff) + 1 piano-roll toggle. App tsc 0; full src/daw+src/components/daw suite 1461 green; stub scan clean.
  • Tests: 11 pure MusicXML tests + a TransportBar UI-wiring test (app tsc 0) — plus the 12+5+1 score-render tests above.
  • Grand staff / bass clef (DONE 2026-06-08): score-layout.ts gains a Clef type + a bass staff (G2..A3, steps 18–26) and layoutScore(..., clef) with a 'grand' mode. The grand staff reuses ONE continuous step→y mapping — the 4-step gap between the treble bottom line (E4) and the bass top line (A3) is exactly the middle-C gap, so C4 lands on its ledger halfway between the staves — and routes each note to its own staff (staffForStep: treble ≥ middle C, bass below), measuring stem direction + ledger lines against THAT staff's lines (stemUpForMiddle/ledgerStepsForLines). The treble default path is byte-identical (the public stemUp/ledgerSteps now delegate to the parameterized helpers — all 17 prior score tests unchanged). ScoreView auto-selects the grand staff when any note falls below middle C, rendering the bass staff lines + bass clef (𝄢) + a joining brace + full-height barlines (override via a clef prop). Tests: 7 new (staffForStep routing; grand bass-line positions + full-height barline span + taller height; per-staff stem/ledger for C4/C3/C2; treble no-op) + 3 jsdom (auto grand = 10 lines + bass-clef + brace; high-only stays treble; explicit override). App tsc 0; full suite 1538 green; stub scan clean.
  • Beaming (DONE 2026-06-08): score-layout.ts computeBeams(layout, notes, metrics) — real beat-grouped beaming: runs of consecutive flagged notes (eighth and shorter) that share a beat, are time-contiguous (a rest gap breaks the run), and sit on the same staff join under one slope-clamped beam line instead of individual flags. Stems unify to one direction (the group's mean pitch vs that staff's middle line) and extend/retract so every stem ends exactly on the beam (with a minimum-stem floor). Secondary beams for sixteenths-and-shorter: a full segment across each maximal flags≥L sub-run, or a short hook for an isolated one. Chords (simultaneous onsets) + lone flagged notes are not beamed (keep flags) — honestly documented. ScoreView suppresses the per-note flag + stem for beamed notes and draws the group's stems + beam lines (data-testid="beam"/beam-stem/beam-group). Tests: 13 pure (score-beaming.spec.ts: beat-grouping into 2-note groups, quarter/rest/lone/chord break beaming, full secondary for two 16ths + hook for an isolated 16th, low-up/high-down stems, stem-ends-on-the-beam-line, flat→horizontal beam, slope clamp, grand-staff no-beam-across-the-split + bass-run beams on the bass staff) + 2 jsdom (eighth-run draws beams + suppresses flags; a quarter between eighths keeps flags). App tsc 0; full src/daw+src/components/daw 1597 green; stub scan clean.
  • Key signatures (DONE 2026-06-08): new pure apps/euterpe-studio-web/src/daw/key-signature.ts — a real line-of-fifths key model (MusicXML <fifths>, −7…+7) with key-aware enharmonic spelling: spellInKey picks the letter+alteration whose lof is closest to the key's diatonic window [fifths−1, fifths+5] (tie-broken to the key's sharp/flat orientation), so a flat key respells A♯→B♭ onto a different staff line (keyAwareStep), and accidentalGlyphInKey prints only what the key doesn't already cover (a key-sharped F prints no ♯; an F that sounds natural in a sharp key prints a to cancel). keySignatureSteps lays the ♯/♭ block on its conventional treble/bass staff positions; MAJOR_KEYS is the 15-key selector. No-op invariant: fifths === 0 delegates to midiToPitch (legacy all-sharp spelling) so the default score + all prior tests are byte-identical. score-layout.layoutScore/ computeBeams take a fifths param (default 0) and route placement through the key-aware spelling + reserve a key-signature column (zero-width at fifths 0); score-view renders the ♯/♭ block (both staves on a grand staff) and a Key selector sits beside the "♪ Score" toggle in the piano-roll (display-only — does not alter playback). Tests: 21 pure (key-signature.spec.ts: catalog, keyAlterForLetter, spelling incl. the F-natural-in-G case + octave naming across an enharmonic boundary, key-aware step shifts A♯/B♭ to different lines, accidental suppress/natural/print, key-sig staff steps) + 6 score-layout (no-op default, reserved column shifts notes/barlines, accidental suppress/♮, B♭ respelled to a higher line, grand-staff key sig on both staves) + 3 score-view jsdom (no accidentals in C, ♯ block + suppressed per-note accidental, both-staves grand sig) + 1 piano-roll selector. App tsc 0; full src/daw+src/components/daw 1627 green; stub scan clean. (TS-only — no engine/wasm change.)
  • MusicXML time-signature + tempo round-trip (DONE 2026-06-08): musicXmlToNotes also parses <time> (returned as the bar length in quarter beats, beats·4/beat-type, so 3/4→3 and 6/8→3) and the <sound tempo> directive, returning null for either when the file omits it (so a session keeps its own). The import handler dispatches setTimeSignature + setTempo only when present — adopting the file's meter + tempo exactly as the MIDI import already adopts its tempo (consistent precedent). 2 notation tests (3/4 + 90 bpm round-trip incl. 6/8→3; null-when-omitted).
  • Session-level key + MusicXML <key><fifths> round-trip (DONE 2026-06-08): promoted the score key from piano-roll-local UI state to a real session property DawSession.keySignatureFifths + a setKeySignature reducer action (clamped −7…+7, display-only → emits no engine command, so session-rebuild is untouched), persisted through project-io (back-compat: absent → 0). musicXmlToNotes now parses <key><fifths> and returns it, so the existing notesToMusicXml keyFifths export round-trips: the MusicXML export command passes session.keySignatureFifths, and the import dispatches setKeySignature(fifths) so an imported score carries its key onto the staff. The piano-roll Key selector now reads the session key + dispatches the action (consistent + persisted across reload). Tests: 6 session (time-signature.spec: default 0, set-no-command, clamp/round, rebuild-emits-nothing, save/load persist, old-project → 0)
    • 1 notation MusicXML round-trip (E♭/A major + absent) + 2 piano-roll (selector dispatches; renders under the session-key prop). App tsc 0; full suite 1656 green; stub scan clean.
  • Time-signature display (DONE 2026-06-08): score-layout engraves a PlacedTimeSig (numerator over denominator, centred in a reserved column after the key signature; on both staves of a grand staff) — completing the score's clef + key + time front matter. layoutScore takes a timeSignature param defaulting to null → zero-width → byte-identical (pure tests unchanged); score-view always passes the session meter as beatsPerBar/4 so the rendered score shows it. 4 layout tests (default-null, reserves-column/shifts-notes, after-the-key-sig, grand-both-staves) + 2 score-view (treble 3/4 numerals, grand 4-glyph). Full suite 1697. + a tempo marking (♩ = N above the staff, drawn when the score-view is given the session tempo; sits in the top headroom so it shifts nothing) — 1 score-view test. The score's clef + key + time + tempo front matter is now complete.
  • Measure-internal accidental carry (DONE 2026-06-08): score-layout.measureAccidentals resolves each note's printed accidental with a stateful per-bar pass (time-ordered, reset at every barline; the per-letter baseline at a bar's start is the key signature): an accidental persists for the rest of its bar at that exact letter+octave, so a repeated altered note isn't re-marked and a note returning to the key's pitch within the bar is explicitly cancelled with a ♮ (fixing a real latent bug — a C♮ after a C♯ in the same bar previously drew nothing and read as a second C♯). Per-octave (C♯4 doesn't suppress C♯5); resets at the next barline. A single note (or no same-bar/same-octave repeat) is identical to the stateless spelling, so the key-signature tests are unaffected. Tests: 6 (score-layout.spec: first-marks-not- repeat, returns-cancel-with-♮, barline-reset re-marks, per-octave independence, single-note identity, key-diatonic repeat stays unmarked) + the score-view ♯-then-♮ render test updated to assert the now-correct cancellation.
  • Multi-part MusicXML export (DONE 2026-06-08): notesToMultiPartMusicXml(parts, opts) exports the whole arrangement as one printable score — a <part-list> of <score-part>s + a <part> per track, sharing the key/time signature, the tempo direction emitted once. Refactored the single-part notesToMusicXml to share a partMeasures helper (the existing MusicXML tests confirm the single-part output is byte-identical). Wired: an "Export Full Score (MusicXML, all tracks)" command gathers every note-clip track. Multi-part IMPORT also done (full round-trip): musicXmlToParts(xml) parses every <part> (name from <part-list> + notes via the shared parsePartNotes helper, byte-identical single-part refactor) → the import handler creates one track per part (adopting the file's key/time/tempo). 5 tests (export: N parts share key + one tempo, each <part> round-trips, empty → one part; round-trip: every part recovers name+notes incl. a 2-beat note, single-part → one part). The whole arrangement now round-trips through MusicXML.
  • Click-to-edit on the staff (DONE 2026-06-08): the score is now editablescore-layout gains the inverse of the engraving: stepToMidi(step, fifths) (a staff line/space → its key-diatonic pitch), scorePointToNote(sx, sy, layout, …) (an SVG point → a grid-snapped { beat, pitch }, null left of the first note column), and toggleScoreNote (click an empty spot to add a note, click an existing note to erase it). score-view takes an optional onEdit: when supplied the SVG becomes role="application" with a click handler that maps the click (via getBoundingClientRect → viewBox coords) → scorePointToNotetoggleScoreNote → the new clip; the piano-roll passes onEdit dispatching setNoteClip. Tests: 4 pure (stepToMidi incl. key-sharped F, point→note snap + null-in-clef-area, toggle add/remove/different-pitch) + 3 jsdom (read-only role=img without onEdit; click adds the note at the beat+line; click on an existing note removes it — the rect is mocked so client coords map 1:1 to the viewBox). The drag-to-MOVE gesture stays browser-bound. Full suite 1710.
  • Multi-voice engraving (DONE 2026-06-09): overlapping rhythmic lines now render as independent voices with per-voice stems + beaming, instead of one stem-by-pitch. New apps/euterpe-studio-web/src/daw/score-voices.ts assignVoices — groups notes into chord-units (same onset+duration → one stem), then greedy free-voice assignment (each unit → a voice whose previous unit has ended, nearest in pitch for smooth voice-leading), voices ordered by mean pitch DESC (voice 0 = top line). layoutScore tags each PlacedNote.voice + flips the stem by voice when polyphonic (top up / next down); computeBeams beams each voice independently (no beams across voices) following that voice's direction (buildBeamGroup gains a forcedStemUp); score-view renders both directions (added a stem testid). No-op invariant: monophonic content + plain block chords are all voice 0 ⇒ stems stay pitch-based ⇒ byte-identical (the 73 prior score tests pass unchanged). Tests: 5 voice-separation (mono/chord/held-bass/two-distinct-rhythms/ empty-single) + 3 layout (voice tagging + stem flip, mono no-op, beams-stay-within-voice) + 2 score-view (both stem directions render, mono no-op); full src/daw+components 2053 green. MusicXML <voice>/<backup> multi-voice round-trip also DONE 2026-06-09 (commit MusicXML multi-voice export/import): partMeasures gates on >1 voice → multiVoiceMeasures/fillVoiceMeasures (each voice fills every bar, then a <backup> rewinds for the next voice; noteElement gains <voice>); the importer parsePartNotes walks each measure's children in order, applying <backup>/<forward> to the cursor — so a polyphonic clip exports correctly for MuseScore/Sibelius and round-trips (monophonic stays byte-identical, no <voice>/<backup>; +2 round-trip tests; suite 2055).
  • Cross-voice note-head/stem collision avoidance DONE 2026-06-09 (the last engraving tail, commit cross-voice note-head/stem collision avoidance): PlacedNote.headDx + resolveHeadCollisions in score-layout.ts — when a stem-up voice and a stem-down voice sound at the same beat on the same staff within a diatonic 2nd (heads would overlap), the lower (stem-down) voice's heads + stems are displaced clear (the standard two-voice resolution). Gated on polyphonic so monophonic content — incl. a block chord, whose pitch-based stems mix up/down — keeps headDx 0byte-identical (no-op invariant); computeBeams reads x + headDx so beamed displaced heads keep aligned stems; score-view renders head + ledgers + accidental + dots + stem at x + headDx. Tests: +6 pure (displace within a 2nd / no-displace a 3rd+ / no-displace across beats / single-note + block-chord no-ops) + 1 jsdom (heads at distinct cx); all 84 score-layout/voices/beaming/view tests green; app tsc 0; stub scan clean.
  • Acceptance: ✅ MusicXML round-trips losslessly for pitch/rhythm; ✅ a clip renders as an engraved score (treble, or an auto grand staff with a bass clef when it reaches below middle C — note heads, stems, accidentals, ledgers, dots, barlines, beat-grouped beaming with secondary beams/hooks, key signatures with key-aware enharmonic spelling + measure-internal accidental carry + time signature + tempo marking, multi-voice separation with per-voice stems + beaming + cross-voice head/stem collision avoidance), all geometry verified); ✅ the staff is editable by click (add/remove notes); ✅ multi-voice MusicXML round-trip (<voice>/<backup>). ⏳ REMAINING: drag-to-move a note on the staff (browser-bound) is the only engraving tail left.
  • Risk: L-XL; interop + render (grand staff + beaming + key sigs + accidental carry + time/tempo + multi-voice
    • collision avoidance) + click-to-edit shipped (pure/jsdom-verified). The drag-to-move gesture is the separate browser remainder. Honest limit: treble + grand staves, key-aware spelling within a single accidental.

3.2 [~] MIDI-1 / UX-24 / FX-N2 / IO-3(expr) / REC-N3 — MPE / per-note expression · P1 · effort L-XL ✅ ENGINE + LIVE INPUT + CLIP EXPRESSION LANES (engine→bridge→model→editor UI) DONE 2026-06-09 (canvas-drag + live recording = tails)#

  • Engine: SynthVoice gains per-note bend_semitones / pressure / timbre (set live, independent of the channel-wide LFO/matrix): bend sums into the pitch (play_freq), pressure swells the amp amp_mul, timbre opens the filter cutoff (+up to 6 kHz). All default 0 = bit-identical to a non-MPE voice (no-op invariant tested + all 164 prior dsp-core tests green). Cleared on every note-on so a fresh note starts neutral. PolySynth::note_expression(note, dim, value) finds the voice(s) sounding note and routes dim 0/1/2 → so two held notes bend/press/brighten independently. Threaded through Track/Engine/dsp-wasm note_expression(track, note, dim, value) (with the same scale-lock snap).
  • Capture: apps/euterpe-studio-web/src/daw/mpe.ts MpeRouter — tracks the note live on each MPE member channel and decodes per-channel pitch-bend (0xE0, 14-bit → ±48 st default), channel-pressure (0xD0), and CC74 timbre/slide into per-note note_expression events; note-on/off maintain the channel→note map. Wired into the Web-MIDI handler → engine.noteExpression(selectedSynthTrack, note, dim, value) (note-on/off still play via the normal path).
  • Bridge: messages noteExpression + worklet dispatch + AudioEngine.noteExpression bridge. wasm rebuilt+synced.
  • Tests: cargo — the MPE invariant (two voices on the same note: bend one an octave up via the engine, only its zero-crossing rate doubles), pressure swells RMS, timbre raises high-frequency (first-difference) energy, neutral expression is bit-identical, retrigger clears the bend, PolySynth routes to the sounding note + no-ops an absent note. Node-boundary — note_expression pressure swells the master RMS across the rebuilt WASM boundary. 7 jsdom (mpe.spec.ts: per-channel routing, channel independence, pressure/CC74, no-live-note ignore, note-off frees, vel-0 = note-off, reset). (169 dsp-core + 109 dsp-graph + 49 dsp-wasm cargo; 1327 daw; clippy clean; tsc 0.)
  • Clip-side expression curves — ENGINE (DONE 2026-06-09): dsp-graph/src/clip.rs NoteExpression (per-note bend/pressure/timbre breakpoint curves, (beat-offset, value) relative to the note start) stored parallel to the notes (ClipNote stays Copy), sorted together by start beat. NoteClip::advance interpolates each sounding note's curves at its note-local beat (eval_curve, linear + hold-endpoints, empty→skip) and emits Track::note_expression(pitch, dim, value) per block (control-rate, absolute → smoothly tracks the curve). NoteClip::with_expressions constructor; new delegates with empty curves. No-op invariant: an all-default expression renders bit-identical (asserted), so every existing clip/engine test is untouched. 4 cargo tests (eval_curve interp/hold/empty, empty-expression bit-identical render, a bend curve 0→+12 st ~doubles the clip voice's zero-crossing rate, a pressure curve swells RMS). dsp-graph 129; clippy clean.
  • Acceptance: ✅ two held notes with independent pitch bends bend independently (cargo + the MpeRouter channel-isolation test); ✅ a live MPE controller's pitch-bend/pressure/timbre route to the right per-note voice; ✅ a clip note carries bend/pressure/timbre curves that play back through the synth (engine, cargo-verified).
  • Ragged-array wasm bridge (DONE 2026-06-09): set_track_note_clip_mpe (engine + dsp-wasm) takes the flat note arrays
    • a flat ragged expression encoding (per curve: note-idx / dim / count + concatenated beats/values), reconstructs one NoteExpression per input note, replicates across ratchet sub-notes. cargo binding + WASM-boundary tests (a bend curve 0→+12 st lifts the clip render's pitch an octave through the real simd128 wasm).
  • App model + persistence + bridge wiring (DONE 2026-06-09): ClipNote.expression + NoteExpression types; note-expression.ts ragged encoder; a setNoteExpression reducer (set one note's curves by sorted index, re-emit the clip through the MPE bridge) + setNoteClip now PRESERVES expression; the setNoteClip command + session-rebuild carry the encoding only when present (no-expression command byte-identical); the worklet routes to set_track_note_clip_mpe; project-io persists it free. Encoder + reducer + project-io round-trip tests (app daw 1401; audio-engine-web 93; tsc 0).
  • Per-note expression editor UI (DONE 2026-06-09): components/daw/note-expression-panel.tsx NoteExpressionEditor (under the piano roll for the selected synth track) — a note selector + a per-dimension (bend st / pressure / timbre) breakpoint editor: add a (beat-offset, value) point, edit its beat/value, remove it; every edit dispatches setNoteExpression so the curve plays through the MPE bridge + persists. 3 jsdom tests.
  • Visual SVG expression LANE (DONE 2026-06-09): components/daw/note-expression-lane.tsx NoteExpressionLane draws each dimension's breakpoint curve over the note's duration (x = beat-offset, y = value, inverted) and edits by click: empty lane → add a grid-snapped point at the mapped (beat, value); on a point → remove it. Pure coord↔value mapping (beatToX/xToBeat/valueToY/yToValue/pointHitTest/snapBeat) so it's browser-free-verifiable; the click handler is jsdom-tested by mocking getBoundingClientRect (the score click-to-edit pattern). 6 tests. components/daw 336 green.
  • Drag-to-move points (DONE 2026-06-09): the lane interaction is a mouse-down→move→up state machine — empty-lane down adds a snapped point; down on a point begins a window-tracked drag (snapshot-indexed so it stays valid even when carried past another point in beat order); a down-up with no movement is a tap = remove. The drag math reuses the pure mapping, jsdom-verified (add / tap-remove / drag-to-+max). components/daw 337 green.
  • Acceptance: ✅ a clip note's bend/pressure/timbre curves are fully editable — a visual lane with click-to-add, tap-to-remove and drag-to-move, plus a precise breakpoint editor — and play back through the full app→worklet→engine path (boundary-verified) + survive save/load. The MIDI-1 clip-expression LANES feature is complete at the in-sandbox baseline; the only remainder is live-MPE RECORDING into the curves (genuine hardware: an MPE controller).
  • Risk: L-XL; landed across 6 verified increments (engine → ragged bridge → app model+persistence → breakpoint editor → visual lane → drag-to-move) via the proven cross-stack recipe + a strict no-op invariant. Only live-MPE recording remains.

3.3 [x] FX-18 / UX-10 / REC-2 / AI-N4 / IO-11 / REC-16 — Take comping (multi-take lanes + swipe + undo) · P1 · effort L ✅ DONE 2026-06-08#

  • Data model: TakeLane / CompSegment / TakeComp in types.ts (a comp = stacked take lanes + a gap-free segment tiling of which lane plays per range + a boundary crossfade). DawSession.takeComps + nextCompSeq/nextLaneSeq.
  • Comp engine (apps/euterpe-studio-web/src/daw/take-comp.ts, pure, 15 tests): createComp/addLane/laneFromClip, compSetRange (swipe-to-select — splits boundary segments, replaces the interior, merges adjacent same-lane runs back into one, always re-yielding a gap-free tiling), setCrossfade, laneAtBeat, and resolveCompClips — the active take per range materialized as arrangement clips, each internal boundary overlapped by the crossfade so the earlier clip fades out while the next fades in over the same span = a genuine equal-power crossfade (rendered by the existing arrangement-clip fade engine, so playback is reused — no new DSP).
  • Reducer (6 actions: createTakeComp/addTakeLane/setCompRange/setCompCrossfade/applyTakeComp/deleteTakeComp) with a materializeComp helper that drops the comp's previously-owned clips, resolves fresh ones, mints ids, and resyncs the track (arrangementCommand) — and re-materializes LIVE when an already-applied comp is edited, so painting the comp updates playback. project-io persists comps (backward-compat) + the materialized clips, so a comp survives save/load.
  • Loop-record capture into take lanes: seeded from the track's live note clip (each "+ Take" snapshots the current clip) — the honest in-sandbox path since multi-take recording needs a mic (the REC-5 looper from 3.4 feeds it there).
  • UI take-comp-panel.tsx (TakeCompPanel): per-lane row of beat-cells you click/paint to assign the comp range, a crossfade slider, Apply (▸/● Live), + Take, delete; wired into daw-app via TakeCompSection for the selected synth track. REC-16 undo: apply/edit actions emit engine commands → the undo controller snapshots them.
  • Tests: 15 pure (engine: tiling invariant, swipe split/merge, overlap crossfade math, deep-copy seeding) + 6 reducer (create/addLane/apply→song-mode/live-re-materialize-on-swipe/delete/save-load round-trip/undo boundary) + 3 jsdom (cell paint dispatch, apply/crossfade/add/delete, applied-state). App tsc 0; full src/daw+src/components/daw 1281 green; stub scan clean.
  • Acceptance: ✅ comping across N takes plays the selected take per range with crossfaded boundaries; ✅ undo restores the prior comp (command-emitting boundary). Takes seeded from clips per the checklist's stated verification path; live mic multi-take recording into lanes is the REC-3/5/12 browser acceptance.
  • Risk: L — landed via the public clip API + a single materializeComp helper (reuses arrangementCommand), so the hot arrangement path was extended, not rewritten; the no-op invariant (sessions with no comps are byte-identical) holds.

3.4 [~] REC-3 / REC-5 / REC-12 / REC-N5 — Recording capture: punch, looper, multitrack, retro-capture · P1/P2 · effort L ✅ PURE LOGIC (ALL 4) + REC-N5 WIRED 2026-06-08#

  • New apps/euterpe-studio-web/src/daw/recording-capture.ts — all four capture primitives, pure + unit-tested (21 domain tests), layering on the existing single-take audio-capture.ts (CaptureBuffer/RecordingSession):
    • REC-3 punch-in/out: punchRegionFrames (beats→frames at tempo) + applyPunch — splices a take into an existing track buffer over [in,out) only, equal-power-crossfading both seams (gOut²+gIn²=1, verified the incoming reads sin(t·π/2) and outgoing cos(t·π/2)); outside the region the original is byte-exact; the fade clamps to half the region so the two crossfades never overlap.
    • REC-5 looper/overdub: Looper (fixed loop length, round-robin write head, overdub(block, feedback)feedback=1 layers forever / <1 decays older layers / silence-start makes the first pass a plain record); wrap + loopCount/position/clear tested.
    • REC-12 multitrack arm: MultitrackRecorder — per-armed-track CaptureBuffers, routed append ignores un-armed tracks (no fabricated take), finishAll returns only non-empty takes keyed by id; arm/disarm/armedTracks tested.
    • REC-N5 retrospective MIDI: RetroMidiBuffer — always-on ring of recent notes vs a free-running beat clock, capture(now, length) re-zeroes the last N beats to a grid-aligned clip, closes still-held notes at now, FIFO-pairs overlapping repeats of one pitch, excludes pre-window onsets, clamps the window at the song start, trim bounds the ring.
  • REC-N5 wired END-TO-END (verified here): RetroMidiBuffer fed from recordDispatch (the single keyboard+MIDI note choke point) against a performance.now-anchored tempo clock that advances whether or not the transport plays; a captureRetro callback commits the last 4 bars to the selected synth track's clip; TransportBar "⤓ Capture" button + jsdom test. So "Capture recovers an un-armed take" is real + verifiable without a mic.
  • Tests: 21 pure (recording-capture.spec.ts) + 1 jsdom (TransportBar Capture button) — app tsc 0, full src/daw + src/components/daw suite green (1242). Adversarial stub scan clean.
  • Acceptance: ✅ REC-N5 "Capture" recovers an un-armed take (wired + verified); ✅ punch region+crossfade / looper-overdub / multitrack-arm pure cores verified by domain tests. ⏳ REMAINING (browser/hardware-bound): routing the live mic through the Looper (loop-record mode) and a multi-channel interface through MultitrackRecorder, and splicing applyPunch into the recorded sample on stop — these consume the actual getUserMedia audio path, so they're the documented mic/browser acceptance; the logic they'd call is built + tested.
  • Risk: L; mic/multichannel capture needs a browser + interface. Pure logic built + tested per the checklist's stated deliverable; the mic-bound integration is left honest rather than fake-wired against a single stream.

3.5 [~] PLAT-5 / COLLAB-19 — PWA: service worker + OPFS offline-first · P1 · effort L ✅ CACHE POLICY + SW + GATED REGISTRATION 2026-06-08 (offline-boot = browser gate; OPFS-stream remains)#

  • Pure cache-strategy module (DONE 2026-06-08): apps/euterpe-studio-web/src/daw/pwa-cache.tsstrategyForRequest(req, origin) is the tested routing core: network-first for navigations/documents (a new deploy always wins online; the cached shell is the offline net), cache-first for immutable hashed assets + /audio/* (the Rust engine WASM + worklet) + the manifest, stale-while-revalidate for other same-origin GETs, network-only for cross-origin / non-GET (never cache third-party or mutations). + CACHE_VERSION/cacheName/isStaleCache (versioned cleanup), PRECACHE_URLS (shell + engine wasm + worklet), isPrecacheUrl. 7 pure tests.
  • Service worker (DONE 2026-06-08): public/sw.js — a hand-rolled SW mirroring the policy: install precaches the shell+engine (per-URL allSettled so a missing optional asset never fails install), activate deletes stale euterpe-cache-*
    • clients.claim, fetch routes through cache-first/network-first(offline→cached shell /)/stale-while-revalidate. Conservative (navigations never served cache-first → no stale-app footgun). The manifest.webmanifest already existed + is linked in app/layout.tsx.
  • Registration (DONE, opt-in seam): src/components/sw-register.tsx ServiceWorkerRegister — registers /sw.js only when NEXT_PUBLIC_EUTERPE_SW === '1' (added to the layout body). The opt-in gate is deliberate: a SW persists across deploys and the offline boot must be confirmed in a real browser before it's enabled in production, so by default it's inert + safe (renders/registers nothing). shouldRegisterServiceWorker(env, hasSW) pure predicate + 3 jsdom tests (no-register-by-default, registers when opted in). App tsc 0; standard gate green (1468).
  • Offline persistence queue core DONE 2026-06-09: apps/euterpe-studio-web/src/daw/offline-sync-queue.ts OfflineSyncQueue<T> — the deterministic sync-on-reconnect policy: enqueue(key, payload) coalesces repeated saves per key (latest payload wins, FIFO position kept), drainBatch(max) yields pending writes in order (empty while offline), setOnline returns the offline→online reconnect cue, and ack(key, seq) removes a flushed entry only if no newer edit arrived mid-flight (the seq-match seam that makes "save again during a save" lossless) — a failed flush stays pending simply by not being acked. 8 Node tests (coalesce / FIFO+max / offline-gate+reconnect / ack / lossless-ack-in-flight / retry-on-no-ack / clear / re-enqueue-after-ack).
  • OPFS write adapter (REMAINS — browser tail): open a FileSystemSyncAccessHandle per project/sample path, drive it from drainBatch()→write→ack(key, seq), wire setOnline to the online/offline events. Pure plumbing around the tested queue; OPFS storage + the reconnect lifecycle need a real browser to verify. (Streaming read side ties to ENG-7/2.8; the SabRing transport for it shipped above.) Re-read 2026-09-18: as for the read adapter — verify in a Playwright chromium spec: write through the queue, reload the page, read back the same bytes; toggle context.setOffline to exercise the reconnect path.
  • Tests: pure cache-strategy decisions (7) + registration gating (3). Offline behavior still REQUIRES a browser pass (set NEXT_PUBLIC_EUTERPE_SW=1, build, load once, reload offline → cached shell + engine serve) — NOT claimed here.
  • Acceptance: ✅ the cache policy + SW + gated registration ship, fully unit-tested; ⏳ "app shell loads offline after first visit" is the documented browser-acceptance gate (enable the flag + verify); OPFS project persistence remains.
  • Risk: MEDIUM; unverifiable-offline-in-sandbox is the honesty seam — plumbing built + tested, registration gated OFF by default so it can't break the live app, browser offline-pass is the gate to enable. Not marked fully done.

3.6 [~] IO-6 / PLAT-N2 / MASTER-9 — Lossy/lossless export codecs (FLAC / Opus / AAC / MP3 / ALAC / Ogg) · P1/P2 · effort M ✅ FLAC + AIFF + ALAC + encodeCodec DISPATCHER DONE (lossy codecs = fail-loud seam, need WASM deps)#

  • FLAC (DONE 2026-06-08, NO new dependency): a real from-scratch FLAC encoder libs/euterpe/audio-engine-web/src/flac.ts (encodeFlac) — fLaC + STREAMINFO + frames with CONSTANT/FIXED-predictor (orders 0–4, min-coded-size selection) subframes + partitioned-Rice residuals (5-bit params), CRC-8 header + CRC-16 footer, UTF-8 frame numbers, explicit sample-rate codes (CoreAudio rejects the read-from-STREAMINFO code), optional VORBISCOMMENT for the provenance label. 16-bit, matching encodeWav. Wired: daw-app bounce('flac') + command-palette "Bounce to FLAC (lossless)". Verified TWO independent ways: (1) a from-scratch FLAC _decoder in the test round-trips the whole multi-frame stream bit-exactly (lossless, every frame CRC-16 validated; 48k stereo / 44.1k mono / tiny / silence / vorbis-comment); (2) macOS afconvert (CoreAudio) decodes it bit-exact — external real-FLAC proof. (commit feat(euterpe): real lossless FLAC encoder…)
  • AIFF (DONE 2026-06-08, NO new dependency): a real from-scratch AIFF encoder libs/euterpe/audio-engine-web/src/aiff.ts (encodeAiff) — Apple's lossless PCM interchange, the big-endian IFF sibling of WAV/FLAC: FORM/COMM/SSND chunks, 16-bit signed big-endian PCM, the sample rate as an 80-bit IEEE-754 extended float (writeExtended; 48 kHz → the canonical 40 0E BB 80 …), optional ANNO annotation for the provenance label. Wired: daw-app bounce('aiff')
    • command-palette "Bounce to AIFF (lossless)". Verified TWO independent ways: (1) a from-scratch AIFF decoder in the test reads the extended rate back + round-trips the 16-bit PCM bit-exactly (48k stereo sine / 44.1k mono / silence / annotation); (2) macOS afconvert (CoreAudio) transcodes it AIFF→WAV without error — external real-AIFF proof. 5 tests. (Lossless PCM ⇒ no WASM dep, like FLAC.)
  • ALAC (DONE 2026-06-09, NO new dependency): a real from-scratch Apple Lossless encoder libs/euterpe/audio-engine-web/src/alac.ts (encodeAlac) — a faithful port of Apple's open-source reference codec (Apache-2.0): matrix mix (mixRes=0 independent channels) → adaptive-FIR dynamic predictor (pc_block, 8 taps) → adaptive-Golomb entropy coder (dyn_comp), with a per-packet escape to raw PCM on incompressible input, wrapped in a CAF container (caff/desc/kuki/info/ pakt/data + BER packet table + 24-byte ALACSpecificConfig magic cookie). Exact int32 wrap via Math.imul+|0 so residuals/coefs are bit-identical to the C (which is what makes it CoreAudio- decodable). 16-bit, mono+stereo, the always-correct "fast" param set (no brute search / no mid-side matrix — documented size enhancement, not a correctness gap). Verified TWO independent ways: (1) a from-scratch ALAC decoder in the same module (decodeAlacCaf: AG-decode + inverse predictor
    • un-mix) round-trips every packet bit-exact (multi-frame / partial-frame / silence-zero-run / incompressible-escape; mono + stereo); (2) macOS afconvert (CoreAudio's independent decoder) decodes the .caf bit-exact with the exact frame count — external real-ALAC proof. 9 tests. ALAC, like FLAC/AIFF, is lossless and openly specified ⇒ needs no WASM dep (corrects the prior "ALAC needs WASM deps" grouping). Wired: daw-app bounce('alac') + palette "Bounce to ALAC".
  • encodeCodec(interleaved, sampleRate, format, opts) dispatcher (DONE 2026-06-09): libs/euterpe/audio-engine-web/src/codec.ts — one entry point owning the format→encoder map + MIME + extension + availability. Routes wav/flac/aiff/alac to their real encoders; for the dep-blocked lossy formats (mp3/opus/aac/ogg) it throws a typed CodecUnavailableError (code:'codec_unavailable') — an honest fail-loud seam, never fabricated audio; a LOSSY_ENCODERS registry lets a vendored WASM codec slot in with zero call-site change. CODEC_INFO/SUPPORTED_CODECS/AVAILABLE_CODECS are the single source of truth. The daw-app bounce() now selects every lossless container through it. 8 tests (each lossless format's container magic + ALAC round-trip + channel/comment threading + fail-loud lossy
    • UnknownCodecError + registry integrity).
  • MP3 / Opus / AAC / Ogg (REMAIN — need WASM codec deps): @wasm-audio/lamejs MP3, opus-encoder Opus, etc. NEW dependency edge (pnpm catalog) — deferred on this memory-constrained box (a fresh pnpm install risks thrashing per the macOS guidance). The lossy codecs are patented psychoacoustic encoders not worth hand-rolling; they fail loud via encodeCodec until vendored. Re-read 2026-09-18, install first: the memory caveat was about the Mac. On the Linux server, add the encoders through the pnpm catalog (check free -m first; if the worktree's link farm is incomplete, link the packages from .pnpm as the repo's own note describes rather than running a full install): an MP3 encoder (LAME-derived, LGPL — record the licence), Opus and Ogg Vorbis. AAC stays a fail-loud seam until its encoder's licence is read and recorded; say so in encodeCodec's error. Verify: each codec round-trips a reference tone within a stated SNR and the container parses with ffprobe.
  • Acceptance: ✅ exported FLAC + AIFF + ALAC decode to bit-exact lossless audio (own decoders + afconvert); ✅ unified encodeCodec dispatcher with a fail-loud seam for the lossy set. ⏳ MP3/Opus/AAC/Ogg remain (WASM dep).
  • Risk: MEDIUM; remaining lossy codecs = dependency weight + license review. WAV/BWF/FLAC/AIFF/ALAC are the verified lossless defaults; encodeCodec is the one selector.

3.7 [~] UX-1 — Resizable/dockable panel workspace + saveable screensets · P1 · effort L ✅ LAYOUT MODEL + SCREENSETS + WORKSPACE SHELL DONE 2026-06-08 (drag-dock/free-float = browser tail)#

  • Layout model (DONE 2026-06-08, pure): apps/euterpe-studio-web/src/daw/workspace.ts — a registry-driven panel-layout reducer: per-panel visibility / collapse / order + a resizable sidebar width, and named screensets (Logic/Cubase-style snapshots). defaultLayout/defaultWorkspace (all visible, source order, 380 px sidebar = byte-identical to the pre-workspace fixed stack → non-breaking), isPanelVisible/isPanelCollapsed, setPanelVisible/setPanelCollapsed/togglePanelCollapsed, movePanel(dir, amongIds) (swaps with the visible neighbor even when hidden/absent panels are interleaved), setSidebarWidth (clamped 240–680), saveScreenset/ recallScreenset (deep-copy → edits don't mutate the snapshot)/deleteScreenset/resetWorkspace, reconcileLayout (drops registry-gone ids, appends new ones visible, prunes orphan flags), orderedVisiblePanels, and serializeWorkspace/parseWorkspace (fail-loud-tolerant — null/garbage/wrong-shape ⇒ default, never throws; drops nameless screensets + a dangling active pointer). The WORKSPACE_PANELS registry is the 23 DAW panels.
  • Workspace shell (DONE, React): components/daw/workspace-shell.tsxWorkspaceControls (a screenset switcher: Save-as / Recall / Delete; a sidebar-width number input; Reset; and a disclosure listing every present panel with a show/hide checkbox, ↑/↓ reorder, and a collapse toggle) + CollapsedPanelBar (the slim title bar a collapsed panel becomes; click to expand). Presentational — all state lives in the host via the pure model.
  • Wired into the DAW (DONE): daw-app.tsx now renders its ~23 top-level panels through a panel registry (panelNodes id→node, conditional panels = a null node that's skipped) driven by orderedVisiblePanels — so visibility, order, collapse, and the work-area sidebar width are all live + persisted to localStorage (euterpe-workspace). Default layout reproduces the prior fixed stack exactly (all panels, source order, 380 px).
  • Tests: 22 pure (workspace.spec.ts: defaults/registry-integrity, hide/show + flag-pruning, collapse, reorder incl. interleaved-absent + boundary no-ops, sidebar clamp, screenset save/overwrite/recall-deep-copy/ delete-clears-active/reset-keeps-screensets, reconcile drop/append/prune, serialize↔parse round-trip, fail-loud parse, drop-nameless-screenset) + 11 jsdom (workspace-shell.spec.tsx: panel list + visibility checkbox state/toggle, hidden-count label, reorder fire + boundary-disabled, collapse toggle, sidebar-width change, screenset save (disabled when empty) / recall / delete on the selection, reset, collapsed-bar expand). App tsc 0; full src/daw+src/components/daw suite 1582 green; stub scan clean.
  • Drag-to-reorder panels (DONE 2026-06-08): a pure workspace.reorderPanel(state, id, beforeId, amongIds) — moves id to just before beforeId among the present panels (derived in current display order) and rewrites the master order so interleaved non-present panels keep their slots; no-op on same/absent id. The WorkspaceControls rows are now draggable with a ⠿ grip + HTML5 DnD handlers (dragStart records the id, drop fires onReorder(dragged, target)) — testable by data-id target, not coordinates, so jsdom-verifiable. 3 pure (drop-before / no-op / interleaved-preserve) + 2 jsdom (drag C→A fires onReorder; drop-on-self no-ops). Full suite 1702.
  • Drag-to-resize the sidebar splitter (DONE 2026-06-08): a ResizeHandle (workspace-shell) — a vertical role="separator" handle between the sidebar and the work area that, on mouse-down, captures the start x + width and tracks the move on window (so the drag continues past the thin handle), reporting clampSidebarWidth(startWidth + Δx) (the same 240–680 clamp the reducer applies) until mouse-up; wired into daw-app driving wsSetSidebarWidth. The drag math is jsdom-tested (3: delta→width both directions, min/max clamp, listener-detached-after-mouseup); the live splitter feel is the browser tail. Full suite 1689.
  • Acceptance: ✅ a screenset round-trips (save → diverge → recall restores; persisted across reload); ✅ panels show/hide, reorder, collapse, and the sidebar resizes — all jsdom-verified driving the real render; ✅ drag-to-resize the sidebar (mouse-drag handler, jsdom-tested math); ✅ drag-to-reorder panels (HTML5 DnD → reorderPanel, jsdom-tested). ⏳ REMAINING (browser-bound): drag-to-dock zones / free-floating windows + tabbed docks — the remaining deluxe presentation gestures (the keyboard/click controls + the resize/reorder drags drive the identical layout state).
  • Risk: L; landed additively — the model + shell are self-contained + fully tested, and the default layout is byte-identical to the prior fixed stack, so the daw-app render refactor (panels → registry map) is behavior-preserving.

3.8 [~] IO-2 / IO-N3 / ARR-N4 / PLAT-N1 / FX-N1 — Deep interchange (DAWproject depth, AAF/OMF/FCP-XML, device-state preset) · P2 · effort M-L ✅ DAWPROJECT AUTOMATION DEPTH + FX-N1 DEVICE PRESET DONE 2026-06-08#

  • DAWproject round-trip DEPTH: volume + pan automation + session time signature + audio-clip fades now round-trip (the highest-value missing fidelity). dawproject.ts: Channel <Volume>/<Pan> carry ids; per-track <Lanes> emit <Points unit="linear|normalized"><Target parameter="vol-/pan-{id}"/><RealPoint time value/>…; a lane is emitted for any track with clips OR automation; import parses <Points>setVolumeAutomation/setPanAutomation (dB↔linear, pan↔normalized). <TimeSignature numerator> is now session-derived (beatsPerBar) on export + read back on import. ARR-8 audio-clip fades round-trip as standard DAWproject <Clip fadeInTime fadeOutTime> (in the clip time unit; omitted when zero; 2 new round-trip tests) — genuinely interoperable, unlike Euterpe-specific insert params. Round-trips through the existing .dawproject export/import (palette command + import picker). STILL TODO: crossfades (cross-clip), device/insert state (murky — built-in-effect params don't map to a standard device taxonomy), note-expression, time-warp. 2026-09-18: open for an agent. The residue is the note's own list: cross-clip crossfades, note expression and time-warp round-trip; device and insert state only where DAWproject defines a standard device.
  • Aux SENDS round-trip DONE 2026-06-09: reverb + delay sends now interchange via the standard DAWproject <Channel><Sends><Send destination type="pre|post"><Volume.../></Send></Sends> form. dawproject.ts emits two effect-role return-bus tracks (aux-reverb/aux-delay, only when any track has sends) so each <Send destination> resolves to a real channel id; per-track reverb/delay send levels + pre/post-fader + send-level automation (<Points> targeting send-rev-/send-dly-{id}) all serialize. Import skips the aux-bus tracks (recognized by id, so engine ids stay aligned) and parses sends back to setTrackSend/setTrackDelaySend(+…PreFader)/setSendAutomation/ setDelaySendAutomation. 3 new round-trip tests (emit-only-when-present / level+pre-fader / send automation); full dawproject suite 25 green; app tsc 0. (Crossfades aren't a first-class DAWproject primitive — they're authored as overlapping clips with complementary fades, which the existing fadeInTime/fadeOutTime round-trip already covers.)
  • AAF/OMF/FCP-XML post-production interchange — pure XML/binary writers. FCPXML DONE 2026-06-08: apps/euterpe-studio-web/src/daw/fcpxml.ts — a pure FCPXML 1.10 export+import for Final Cut Pro / DaVinci Resolve / Premiere. Each track → one continuous, song-aligned audio stem <asset>; each arrangement clip → an <asset-clip> slicing that stem at its timeline boundaries on its own lane. Beats↔seconds through the session tempo; sample-accurate rational FCPXML times (N/Ds) at a 48 kHz timebase (secondsToFcpTime/parseFcpTime, gcd-reduced); tempo+time-signature carried in the base <gap>'s <metadata> (md key="com.euterpe.*") so our round-trip recovers them (FCP preserves unknown <md>). Honest stem-baked semantics: track structure/names/clip boundaries/tempo/time-sig round-trip to sample accuracy; synth→audio (FCPXML knows only audio) and per-clip trim collapses to 0 (baked into the song-aligned stem). Wired: a "Export FCPXML…" palette command + handler that renders each track's real song-aligned stem (engine.renderTrackOffline+encodeWav) and zips project.fcpxml+Stems/*.wav (so the referenced media genuinely exists), and .fcpxml import in loadProject (+ the Open accept). Tests: 13 pure/jsdom (rational time round-trip/gcd/zero/ malformed-throw, stemFileName slug, well-formed FCPXML structure + assets/asset-clips/lanes + gap metadata, empty-session validity, import throws on non-FCPXML + tempo-fallback, full export→ import round-trip fidelity) + 1 command-wiring test. App tsc 0; full src/daw+src/components/daw suite 1499 green; stub scan clean. (commit feat(euterpe): FCPXML post-production interchange….) AAF substrate — CFB/OLE2 container DONE + INDEPENDENTLY VERIFIED 2026-06-09 (incr 1, commit MS-CFB (OLE2) compound-file container): apps/euterpe-studio-web/src/daw/cfb.ts — a from-scratch, dependency-free MS-CFB v3 (512-byte sector) Compound File Binary writer + reader: header + FAT + a real red-black directory tree (siblings sorted by the CFB name comparator, provably-valid colouring) + mini-FAT/mini-stream for <4 KiB streams + regular sectors for larger ones; documented ≈7 MiB header-DIFAT cap (fail-loud beyond it). Verified TWO independent ways like the FLAC/ALAC codecs: (1) readCfb round-trips the storage tree + every stream's bytes bit-exactly — 23 tests incl. red-black-tree validity asserted from the serialized directory for n=1..9 + fail-loud edges; (2) macOS file(1) recognises the output as a "Composite Document File V2 Document" (an independent OLE2 parser's verdict). app tsc 0. ⏳ REMAINING — AAF/OMF object model is reference-data-bound (HONEST FINDING 2026-06-09, refines the bucket-B assumption): an importable AAF needs the AAF baseline MetaDictionary — hundreds of class/type/property definitions keyed by exact AUIDs — plus a real AAF validator (the AAF SDK / pyaaf2 carries that baseline as data). Neither is on this box (which aafconvert/aaf2 → none; no pyaaf2; no reference .aaf). Authoring the object graph blind would mean fabricating AUIDs (wrong + unverifiable) — exactly the anti-fabrication line. So the verifiable substrate (the CFB container) is shipped; the importable AAF/OMF object graph is gated on the AAF SDK baseline + a validator, like the other real-tool-acceptance gates documented across bucket B. (OMF = Bento container, same object-model/validator dependency.) 2026-09-18: open for an agent under the install-first rule. The AAF SDK is open source: build it, write one reference .aaf with it, and use its dump tool as the validator the note asks for, so that the object graph is checked against a real reader instead of authored blind. OMF follows only if the same route works.
  • FX-N1 device-state preset (DONE 2026-06-08): apps/euterpe-studio-web/src/daw/device-preset.ts — capture a track's full device (instrument SynthPatch + insert chain with per-insert params/bypass/sidechain + channel strip: gain/pan/pan-law/both aux sends/pre-fader flags/bus) into a portable .euterpe-device.json (like an Ableton .adg rack / a Logic channel-strip patch), the file-portable counterpart to the localStorage FX/synth-preset libraries. extractDevicePreset (pure, deep-copy), serializeDevicePreset, parseDevicePreset (validates an untrusted string → {error} fail-loud on wrong/absent format/version, tolerant: drops non-numeric params + clamps strip ranges, never fabricates a half-preset), applyDevicePreset lowers a preset to the existing reducer actions in dependency order (configureSynth → setInsertChain → per-insert toggleBypass/setSidechain by index → strip setters), and importDeviceText composes parse+apply. Cross-DAW .dawproject device mapping is deliberately NOT faked (no standard taxonomy for the built-in-effect param space — the "murky" note); a faithful native preset is the honest deliverable. Wired: insert-rack "⤓/⤒ Device preset" buttons (export → download; import → file picker, applied to the selected track) via daw-app saveDevicePreset/loadDevicePreset (the hook does the I/O + fail-loud error surface; the component just forwards the File, matching the codebase pattern). Tests: 9 pure incl. a full reducer round-trip (extract from a configured source track → serialize → import → apply onto a fresh track → the device state deep-equals the source) + parse fail-loud + param-drop/clamp + apply ordering; 3 jsdom (buttons omitted without callbacks, export invokes the handler, import forwards the File). App tsc 0; full src/daw+src/components/daw suite 1429 green; stub scan clean.
  • Tests: volume+pan automation round-trips (dB/pan recovered within float tol; <Points>/<Target> emitted) + audio-clip fade round-trip (fadeInTime/fadeOutTime recovered; omitted when zero) — dawproject.spec.ts (22 tests green; tsc 0). + device-preset round-trip (9) + insert-rack device buttons (3).
  • Acceptance: ✅ a DAWproject with volume/pan automation + clip fades round-trips; ✅ a track's full device (instrument + FX chain + strip) round-trips as a portable preset file (FX-N1); ✅ the arrangement exports as a self-contained FCPXML + stems bundle and round-trips timeline structure/timing/tempo (2026-06-08). ⏳ AAF/OMF binary post-production writers remain (CFB/OLE2 + Bento containers — video-editorial handoff with embeddable essence).
  • Risk: M-L; format fidelity is fiddly but pure + testable (no engine). Automation depth + FX-N1 shipped; AAF/OMF is additive.

3.9 [~] IO-9 / IO-7 / IO-8 — Sample/loop import (SFZ/Decent Sampler), scripting (Lua/Python), OSC/REST control API · P2/P3 · effort M each ✅ IO-9 SFZ + IO-7 DSL + IO-8 OSC/REST + MIDI-18 MCU + HUI-CORE DONE 2026-06-08 (HUI transport/master/feedback + EUCON + Lua host remain)#

  • IO-9 SFZ (DONE 2026-06-08): pure parser apps/euterpe-studio-web/src/daw/sfz.ts → resolved multi-zone map (global/master/group/region inheritance, note-name|numeric keys C4=60, key= shorthand, lokey/hikey/lovel/hivel, pitch_keycenter, tune/pitch/transpose, volume, loop_mode, offset, seq round-robin, comments, default_path; unknown opcodes preserved) + regionForNote/regionPitchOffset/sfzZonePlan. Engine key-zones (cargo-verified, no-op invariant): SampleLayer gains key_min/key_max/root_key, Sampler::add_zone + trigger_note (zone-select + transpose by note−root_key) — a single full-range layer is bit-identical to the legacy set_rate_semitones(note−60)+trigger_velocity (both step-grid sites now call trigger_note, all dsp-graph tests green). Wired dsp-wasm/engine add_sampler_zone → worklet → addSamplerZone → daw-app loadSfz (parse + decode-by-basename + one zone/region) → sampler-panel "Load .sfz" multi-file picker. Tests: cargo dsp-core 180 / dsp-graph 109 (4 new), clippy clean; boundary spec 34; SFZ parser 16 + sampler-panel jsdom; full daw 1366. (commit feat(euterpe): SFZ instrument parser + key-zone multi-sample engine.) Decent Sampler .dspreset ALSO DONE (2026-06-08, commit feat(euterpe): Decent Sampler (.dspreset) parser…): decent-sampler.ts parses the XML into the SAME SfzInstrument model (groups→group→sample inheritance, rootNote/loNote/hiNote, tuning→cents, volume, loop), reusing the identical zonePlan→engine path; loadSfz dispatches by extension; 7 jsdom tests. ⏳ REMAINING: per-region multi-.wav LOAD is browser-bound (decodeAudioData); held-note polyphony (notes route through PolySynth, not the sampler) is the follow-up.
  • IO-7 DONE 2026-06-08 (safe restricted DSL, not a sandbox): apps/euterpe-studio-web/src/daw/script-host.tsparseScript (pure) compiles a tiny line-based DAW-control language (tempo / synth|audio "name" / gain|pan|mute|solo| rename / bounded repeat <n> … end with $i) into typed ScriptCommand[] + line-numbered errors; executeScript maps them to reducer actions, resolving 1-based track indices to ids re-read after each command. No eval, no host access, bounded loops (n ≤ 1024, ≤ 10k cmds; nested/unclosed/runaway rejected, fail loud — no partial apply) — sidesteps the "no arbitrary code" sandbox risk by NOT being a JS/Lua interpreter. Wired: script-console.tsx modal + daw-app runScript
    • command-palette "Automation Script…". 7 pure + 2 jsdom tests; full daw 1391. (commit feat(euterpe): safe automation-script DSL + console.) ⏳ A full Lua/JS scripting host (general expressions/variables/nesting) needs a Worker/wasm sandbox (follow-up).
  • IO-8 OSC/REST control surface (DONE 2026-06-08): apps/euterpe-studio-web/src/daw/osc-control.ts — three pure, fully-tested layers: (1) a from-scratch OSC 1.0 binary codec (encodeOscMessage/decodeOscPacket: address + comma type-tag string + big-endian args i/f/d/s/b/T/F/N/I, 4-byte aligned, plus #bundle decode + flatten — fail-loud on a misaligned packet); (2) address/route → actionoscToAction (/transport/play, /tempo, /transport/seek, /track/<n>/gain|pan|mute|solo|volume, /master/gain) and restToAction (PUT /track/3/gain {db:-6}, POST/PUT verbs only, GET = read) both yield the same typed ControlAction (values clamped to the reducer's safe ranges; mute/solo read T/F as absolute or no-arg as toggle); (3) executionapplyControl resolves a 1-based track index against the live list (re-read each call) and drives a ControlRuntime (the reducer, via the app), failing loud on an out-of-range track. A real OscControlBridge WebSocket client (injectable socket) decodes each binary frame → action → runtime, proving the frame→decode→map→apply chain end-to-end without a network. Wired: daw-app ControlRuntime adapter (play/stop/toggle/tempo/seek-beats→samples@48k/gain/pan/mute-set-or-toggle/solo/master) + RemoteControlPanel (bridge-URL connect, live status, recent-activity log) + command-palette "Remote Control (OSC)…". Tests: 22 pure (codec round-trip for every type + blob padding + double precision + bundle flatten + fail-loud; oscToAction/restToAction for every address/route incl. clamping + toggle-vs-set; applyControl track resolution + out-of-range; the bridge: binary frame → runtime, text-frame ignored, malformed-logged-not-thrown, error→close, bundle-in-order, default new WebSocket factory) + 4 jsdom (RemoteControlPanel connect/disconnect/status/log). App tsc 0; full src/daw+src/components/daw suite 1417 green; stub scan clean. OSC feedback (2026-06-08): oscFeedbackMessages(state) encodes the live mixer/transport as the inverse of oscToAction's address set (round-trips back through decodeOscMessages+oscToAction); OscControlBridge.send +sendFeedback push it over the socket when connected, and a daw-app effect feeds it on every session change — so a TouchOSC/Lemur surface's faders/toggles track the DAW (the surface physically reflecting it is the network/hardware tail). ⏳ REMAINING: the live UDP/WS bridge process (TouchOSC/osc-js relay) is the external dependency.
  • MIDI-18 MCU control surface (DONE 2026-06-08): apps/euterpe-studio-web/src/daw/mcu-control.ts — a pure Mackie-Control-Universal MIDI decoder decodeMcu(midi) → ControlAction[] reusing the IO-8 ControlAction/applyControl executor verbatim (no new arbitrary-code path). Decodes faders (14-bit Pitch-Bend: ch 0–7 → strip gain, ch 8 → master, via faderToDb/dbToFader14 linear-in-dB over [−60,+12]), V-Pots (CC 0x10–0x17 endless encoders → a new trackPanRelative action: low bits = ticks, bit 6 = sign), the jog wheel (CC 0x3c → a seekRelative transport scrub), buttons (Note-On press: Mute 0x10–0x17 / Solo 0x08–0x0f → toggle; transport Play 0x5e / Stop 0x5d) — everything else → [] (releases + unowned messages). Added the trackPanRelative variant + an optional nudgeTrackPan to ControlRuntime + applyControl handling (unsupported runtime → honest ok:false, not a silent no-op). Wired: a createControlRuntime factory now backs BOTH the OSC bridge and a new MCU MIDI path; the once-installed Web-MIDI handler routes input through decodeMcuapplyControl when the RemoteControlPanel's new "Control surface (MIDI): Off / Mackie Control (MCU)" selector is set (es-localized). Bidirectional feedback (2026-06-08): mcuFeedback(state)/encodeMcuFader/encodeMcuButtonLed encode the live mixer/transport (motorized faders + mute/solo/transport LEDs) — the exact inverse of the decode (encode→decode round-trips a fader to its dB) — and a daw-app effect port.sends them to the MIDI output when MCU mode is on (the motors physically moving is the hardware tail). Tests: 24 pure (every message type incl. master + dB endpoints + 14-bit LSB/MSB, V-Pot direction/ticks/zero, mute/solo/play/stop, release/unmapped/truncated → []; faderToDb/dbToFader14 round-trip; applyControl relative-pan resolve/unsupported/ out-of-range; decode→apply chain) + 2 jsdom (panel selector renders + reports the mode change; omitted without a handler). App tsc 0; full src/daw+src/components/daw suite 1518 green; stub scan clean.
  • MIDI-18 HUI control surface (CORE DONE 2026-06-08): apps/euterpe-studio-web/src/daw/hui-control.ts — a stateful HUI (Pro Tools) decoder HuiDecoder.decode(midi) → ControlAction[] reusing the same ControlAction/applyControl executor. Unlike MCU's self-contained messages, HUI rides entirely on Control-Change and is stateful (exactly the "stateful" the checklist flagged): a fader is a CC pair — MSB on CC 0x00–0x07, LSB on CC 0x20–0x27, accumulated into a 14-bit position → trackGain (reusing MCU's faderToDb law); a button is a zone/port pair — CC 0x0F selects the zone, the next CC 0x2F carries port | 0x40 (press), with strip-zone (0x00–0x07) port 0x02 = Mute, 0x03 = Solo → toggle; V-Pots on CC 0x40–0x47 decode the same relative pan encoding (bit 6 = dir, low 6 = ticks) → trackPanRelative. Constants follow a real Mackie Baby HUI reference (matthewmx86/mackie-hui-osc) + the reverse-engineered HUI fader/zone format (Avid's HUI is a proprietary frozen legacy protocol — no public spec). Wired: the RemoteControlPanel selector gains a third option ("HUI (Pro Tools)", es-localized); the once-installed Web-MIDI handler routes input through a persistent HuiDecoder ref (statefulness needs one instance across messages) → applyControl when HUI mode is on. Tests: 14 pure (hui-control.spec.ts: fader MSB-emits- nothing / LSB-completes-the-14-bit-pair / mid-travel dB / sticky-MSB; V-Pot dir/ticks/zero; zone-then-port mute/solo, sticky zone across ports, release/unmapped/no-zone → []; non-CC + truncated ignored; reset clears MSB+zone; decode→apply chain for fader/mute/V-Pot) + 1 jsdom (panel offers the HUI option + reports the mode change). App tsc 0; full src/daw+src/components/daw suite 1642 green; stub scan clean. 2026-09-18: open for an agent. The note names no residue beyond "core". What a HUI host still owes a surface is the reverse direction: answering the surface's ping, and sending fader, LED and display feedback. Proving it on a physical HUI surface needs the hardware and is not claimed.
  • HUI bidirectional feedback (DONE 2026-06-08): encodeHuiFader/encodeHuiButtonLed/huiFeedback — the symmetric inverse of the decode: a motorized fader is driven by the same CC MSB/LSB pair (dbToFader14→ the 0x0z/0x2z pair) and a switch LED by the same zone/port pair (CC 0x0F zone + CC 0x2F port|0x40), so a fader feedback round-trips back through HuiDecoder to its dB and a mute/solo LED round-trips back to that strip's toggle. huiFeedback(state) emits the present faders + every strip's mute + solo LED; a daw-app effect port.sends them to the MIDI output when HUI mode is on (motors moving = the hardware tail). 3 new tests (fader/LED round-trip through the decoder + the full feedback stream). Full suite 1686.
  • HUI transport (DONE 2026-06-09): the transport zone (0x0e) now decodes — Rewind (port 1) / Fast-Forward (port 2) → a one-bar seekRelative nudge (HUI_TRANSPORT_NUDGE_BEATS), Stop (port 3) → stop, Play (port 4) → play; Record (port 5) decodes to [] (the typed ControlAction set has no record verb — honest, not fabricated). Sourced from the reverse-engineered HUI spec and corroborated by the protocol's structural signature: across all five buttons the press value equals the port with bit 6 set (0x41/0x42/0x43/0x44/0x45) — the same press-bit encoding the channel-strip zone already uses, so it rides the existing zone/port machinery unchanged. huiFeedback now also lights the Play/Stop/ Record transport LEDs (zone 0x0e) from HuiSurfaceState.playing/recording. 5 new tests (Play/Stop, Rewind/FFwd nudge, Record→[], release no-op, transport-LED feedback); HUI suite 22 green; app tsc 0. ⏳ REMAINING (HUI follow-ups): the master fader + track-select — their HUI addressing isn't corroborated here, left honest rather than guessed. EUCON (Ethernet, not MIDI) is a separate transport, out of scope for a MIDI decoder.
  • Tests: SFZ parse → zone map (DONE); a script that adds a track produces the right actions (DONE); OSC message → action (DONE — 22 osc-control tests incl. the full decode→map→apply chain).
  • Acceptance: ✅ an SFZ instrument loads with zones; ✅ a script automates a session; ✅ OSC controls transport (verified via the bridge decode→action→runtime chain against a mock runtime). ⏳ a live OSC controller + bridge process is the hardware acceptance; MCU/HUI protocol + a full Lua/JS scripting host are the documented follow-ups.
  • Risk: M; scripting needs a careful sandbox (no arbitrary code on the user's machine) — addressed by the restricted DSL (IO-7) + the typed action-only control surface (IO-8); neither path can execute arbitrary code.

3.10 [x] PLAT-3 — Internationalization (i18n) framework · P2 · effort L (mechanical) ✅ COMPLETE 2026-06-09 — framework + switcher + EVERY component wrapped in t() (coverage-guard-enforced) + a 100%-translated en+es locale (only proper nouns [Push/MPC/Maschine] + technical codes [ISRC/UPC/LUFS] stay as-is)#

  • i18n framework (DONE 2026-06-08, dependency-free): apps/euterpe-studio-web/src/daw/i18n.ts — a tiny gettext-style core using the "default language as key" convention (the English source string IS the msgid), so translate(locale, key, params) does catalog-lookup → {param} interpolation → English fallback for any untranslated key. This makes adoption non-breaking: wrapping a literal in t('Play') renders identically (and existing render tests keep passing) until a locale supplies a translation. LOCALES (en + es to start) + MESSAGES (en empty = all keys fall through; es translates the wired strings) + isLocale guard. React binding components/daw/locale-context.tsx: LocaleProvider (active locale, localStorage-persisted, tolerant), useT() (the bound translator — works without a provider too, defaulting to English, so unwrapped components are unaffected), useLocale(), and a LocaleSwitcher (<select> of locale endonyms, live switching). Wired: the whole DAW tree is wrapped in LocaleProvider + a compact LocaleSwitcher in the app header; the Remote-Control (OSC) panel is converted end-to-end as the first localized surface (title/Connect/Disconnect/Close/status all translate in es). Tests: 8 pure (default-key passthrough, es lookups, untranslated→English fallback, {param} interpolation, unknown-locale→default, isLocale, catalog integrity) + 5 jsdom (useT without provider = English, es provider renders Spanish + falls back, switcher lists endonyms + switches live
    • persists) + 1 jsdom (the OSC panel renders Spanish under an es provider). App tsc 0; full src/daw+src/components/daw suite 1443 green; stub scan clean.
  • Component string-sweep COMPLETE 2026-06-09 (every DAW component localization-ready — 26 surfaces): all user-facing title/aria-label/placeholder literals across the DAW are wrapped in t(...) with the non-breaking default-key pattern (English source = the msgid → text-asserting specs stay green via the English fallback), with es translations for the high-value short labels (long technical tooltips fall back to English — the translator-pass concern). Converted: the previously-done set (OSC panel, command palette [whole command surface], automation-script console, asset browser, channel-strip) + this session: transport bar (core controls + project menu + master-output + interchange), piano-roll (32), sampler-panel (19+placeholder), synth-panel (18), arrangement-view (17), song-structure-panel (12), session-scenes (8), insert-rack (7), chord-track (5), + the 12 small panels (version-history/generator/mix-snapshots/master-eq/folders/take-comp/ audio-output/vca/tempo-map/split-sheet/song-form-bar/step-grid), keyboard-shortcuts + workspace-shell, the audio-visualizer analyzers, controls/Meter (loudness units), and daw-app chrome — which required moving <LocaleProvider> up to app/daw/page.tsx (a component can't consume the context it provides; useLocale/useT are no-provider-safe so the move is non-breaking). es-provider + English-fallback jsdom tests on transport-bar + piano-roll; every changed component's existing spec stays green (default-key fallback); app tsc 0.
  • Coverage guard + straggler closure (2026-06-09): a src/components/daw/i18n-coverage.spec.ts scans every DAW component and fails if any title/aria-label/placeholder literal is left unwrapped — the "eventual lint rule" implemented as a TEST (no change to the app's fragile ESLint config, no whole-app-lint risk). It immediately earned its keep: it caught 15 components my first pass had missed — all the placeholders (the original regex only targeted title/aria-label) + partial wrapping in channel-strip (11 titles) and transport-bar (31 more master/loudness/tags strings). Those are now all wrapped (useT added where absent). Genuinely zero hard-coded user-facing literals remain (the guard is green); 406 component + i18n tests pass.
  • es locale 100% COMPLETE 2026-06-09: translated EVERY wrapped string to quality Spanish — the 5 core editing surfaces (transport-bar + piano-roll + channel-strip + sampler + synth) then every remaining panel (arrangement, scenes, song-structure, insert-rack/device-presets, Meter loudness readouts, master-EQ, version-history, generator, take-comp, tempo-map, lyrics, app chrome). An extraction over all components finds zero untranslated strings — only universal proper nouns (Ableton Push 2 / Akai MPC / NI Maschine) + technical format codes (ISRC / UPC / LUFS / dBTP / ws://host:port) intentionally stay as-is. ~150 es entries added this session; the i18n catalog-integrity spec stays green (no dup keys).
  • Tests: locale switch renders translated strings; missing-key fallback to the default locale; per-component es + English-fallback render tests (transport-bar, piano-roll); the i18n-coverage regression guard (zero unwrapped literals).
  • Acceptance: ✅ the framework renders ≥2 locales (en + es) and surfaces localize live via the switcher; ✅ no hard-coded user-facing strings remain — every DAW component is wrapped in t(), enforced by the coverage-guard test; ✅ the es locale is 100% complete (every string translated; verified zero untranslated). PLAT-3 is fully done.
  • Risk: L (breadth, not depth); touches nearly every component. Framework landed non-breaking; the sweep is interleavable safely thanks to the default-key fallback (no test churn).

3.11 [~] UX-7 — Unified semantic asset/sample/preset browser (embeddings + similarity + drag-drop) · P1 · effort L ✅ EMBEDDINGS + FIND-SIMILAR + SAMPLE TIMBRE + UNIFIED BROWSER PANEL DONE 2026-06-08#

  • New apps/euterpe-studio-web/src/daw/timbre-embed.ts — a real, deterministic, classic-DSP feature extractor (no neural weights), two corpora in one cosine-similarity space:
    • Sample (PCM): sampleFeatures — spectral centroid, rolloff (85 % energy), flatness (Wiener-entropy, ≈1 noise / ≈0 tonal), ZCR, crest, + a coarse 8-octave-band envelope (the perceptual axes an MFCC summarizes), from the magnitude spectrum (fft.ts) of the buffer's loudest window. sampleVector log-normalizes the Hz axes + centres to ≈[-1,1].
    • Preset (SynthPatch): patchFeatures — a 13-axis timbral descriptor mapped from the synthesis parameters (brightness from cutoff, harmonic richness from waveform/FM/additive, the percussive↔sustained envelope, filter sweep, resonance, LFO motion, unison width, sub weight, glide), patchVector perceptually weighted (brightness ×1.7 + richness ×1.5 dominate, the way spectral centroid dominates heard timbre).
    • cosineSimilarity + rankSimilar (seed-excluding, top-N) + brightnessLabel.
  • Wired: preset "≈ Similar" in synth-panel — ranks every factory + user preset by cosine-similarity to the live patch and surfaces the closest 4 as quick-apply chips (name + match %). Sample timbre readout in sampler-panelbrightnessLabel + centroid Hz + tonal/noisy flatness for the loaded buffer (memoized).
  • Tests: 12 pure (timbre-embed.spec.ts) — cosine identity/orthogonality/zero-vector; deterministic centred patch axes; brighter filter ⇒ higher brightness; saw richer than sine, FM/additive raise richness; a dark sustained bass ranks closer to another bass than a bright pluck; rankSimilar over the real factory catalog excludes the seed + orders descending + beats a bright lead; PCM: high sine centroid > low sine, noise flatter/brighter/higher-ZCR than a tone, empty buffer ⇒ zeros (no fabricated timbre), two noises rank above noise-vs-tone. + 3 jsdom (preset find-similar surfaces + applies; sampler timbre readout shows/omits). App tsc 0; full src/daw+src/components/daw suite green (1257); stub scan clean.
  • Unified browser surface (DONE 2026-06-08): apps/euterpe-studio-web/src/daw/asset-browser.ts — aggregates the scattered preset libraries (factory + user synth patches + FX chains) into ONE BrowserAsset[] (buildAssets/ collectAssets), searchAssets (fuzzy-subsequence over name/category/kind), rankAssetsByPatch (cross-library timbre ranking — reuses patchVector+rankSimilar, synth-only, FX excluded), and applyAsset → reducer actions (synth → configureSynth, FX → setInsertChain). components/daw/asset-browser-panel.tsx AssetBrowserPanel — a searchable modal list (kind/source/category badges, draggable rows), a "≈ Similar" toggle (when a seed patch is present) ranking every synth asset by similarity to the selected track with a match %, and click-Apply. Wired: a "Asset Browser…" palette command (Tracks) + daw-app applies to the selected track (the modal-state + seed-patch from selected); chrome localized via t() (es seeded). Tests: 9 pure (aggregate kinds/sources, fuzzy search, identity-ranks-first + FX-excluded + descending + topN, apply→actions) + 6 jsdom (list/search/apply/similar+match%/no-seed-hides-similar/es-localized). App tsc 0; full suite 1484 green; stub scan clean.
  • Session sample library (DONE 2026-06-08): apps/euterpe-studio-web/src/daw/sample-library.ts — closes the "samples are user-loaded per track, not a collection" gap. A LibrarySample is { id, name, pcm, sampleRate, vector, features } where the timbre vector + features are computed once from the PCM via the existing sampleFeatures/ sampleVector (the same cosine space as the preset browser). Pure ops: makeLibrarySample (no fabricated timbre on an empty buffer), addSample (replace-by-id-in-place / append, non-mutating), removeSample, searchSamples (fuzzy name), rankSamplesBySeed (cosine-rank the library by similarity to a seed sample, seed-excluded), sampleTimbreLabel (brightness · tonal/noisy). Wired: daw-app registers every file loaded via loadSampleFile into a session library (id = file name → reloading replaces) + records each track's loaded sample as its similarity seed; a refactored applySampleToTrack powers both file-load and loadLibrarySample (re-load a library sample onto any track). The sampler-panel gains a "Sample library" section: a fuzzy search box, a "≈ Similar" toggle (when the track has a loaded sample) that timbre-ranks the library with a match %, and click-to-load rows (name · timbre label). Tests: 10 pure (sample-library.spec.ts: make/empty-buffer, add-replace/append/non-mutating, remove, fuzzy search, similarity rank excludes-seed + low-tone-closest-to-low-tone + noise-least-similar + topN + absent-seed, timbre label) + 5 jsdom (sampler-panel: lists + click-loads, fuzzy filter, similar toggle + match % + seed-excluded, no-seed-hides-toggle, omitted-without-handler). App tsc 0; full src/daw+src/components/daw suite 1671 green; stub scan clean.
  • Acceptance: ✅ "find similar" from a seed preset surfaces timbrally-close presets; ✅ a unified, searchable browser lists every synth + FX preset with cross-library similarity ranking + apply-to-track; samples carry a real spectral timbre readout; ✅ a session sample library collects every loaded sample with timbre search + similarity ranking + load-onto- track. ⏳ REMAINING: drag-and-drop placement of an asset/sample onto a track (UX-7's headline gesture — browser-bound; the rows are draggable / click-load is the verifiable equivalent), and cross-session persistence of the sample library (the decoded audio in IndexedDB/OPFS — the browser/OPFS-storage tail; the in-session collection + ranking ships here).
  • Risk: L; neural embeddings would be bucket C — this classic-DSP feature vector is the real, honest in-sandbox version.

3.12 [x] UX-28 — Wire (or retire) the studio / studio-runtime view-model libraries · P2 · effort M ✅ RE-AUDITED + CLEANED 2026-06-09 (user-approved "clean house first")#

  • Deep re-audit DONE 2026-06-09 (4 parallel agents, ~37k LOC, all 18 modules read): the old "all unwired / ZERO importers / one consumer" claim was STALE. Truth: @euterpe/studio has three live calliope consumers — calliope/muse reference-track-analyzer uses aiFeatures.classifyDrumHits/detectDrumPattern; calliope/bridge audio-tools-bridge uses @euterpe/studio/daw-engine createStream/pushToStream/getStreamStatus/stopStream; calliope/bridge virtuoso-migration-bridge uses @euterpe/studio/project-management migration registry. Every one of the 18 modules is REAL production code with passing specs — zero stubs found. It's a parts-bin of SOTA-DAW features built but never wired into the shipped app.
  • Clean house DONE 2026-06-09 (user sign-off "clean house first"; audit verified zero functionality loss): removed the 6 genuinely-superseded modules — studio-runtime/{coproducer-copilot, creator-experience, operator-admin, review-mastering-delivery} (→ shipped DAW copilot/reducer, @euterpe/workflows, @euterpe/master+@euterpe/distribution, @euterpe/ops+@euterpe/evals) and studio/{timeline, midi-editing} (→ shipped DAW view-state + live editing). Barrels updated. KEPT studio/daw-engine (consumed — streaming API, NOT deletable; corrects an agent's "remove" error) + studio/project-management + studio/ai-features (consumed). Verified: studio-runtime tsc 0 / 36 tests; studio tsc 0 / 728 tests; calliope/bridge + calliope/muse tsc 0.
  • BUCKET D — wire-in the surviving feature surface (the build-on roadmap, prioritized): these are real SOTA-DAW capabilities the shipped app LACKS, to port in (DSP algorithms → Rust dsp-graph nodes; state/control models → the TS reducer):
    1. studio/mixer ⭐ — arbitrary sends/returns, nestable buses, monitor/control-room section (dim/mono/talkback/cue), AFL/PFL solo, feedback-safe routing matrix (shipped: 4 fixed buses + 2 hardcoded sends). 2026-09-18: open for an agent, in four parts, because "the mixer" is four deliverables. Read apps/euterpe/studio-web/src/daw/routing-matrix.ts and console-routing.ts first: a routing matrix already exists, so state what it lacks before building. (a) Arbitrary sends and returns per track in the reducer and the Rust graph. (b) Nestable buses with cycle refusal, so a feedback route is rejected at edit time with the offending path named. (c) A monitor section: dim, mono, talkback and cue, outside the master bus. (d) AFL and PFL solo. Verify: reducer specs for each part, a dsp-graph test that a bus nested three deep sums to the expected samples and that a cycle is refused, and one real-browser check of the mixer surface.
      • Increment 1 DONE 2026-06-09 — pure routing model: apps/euterpe-studio-web/src/daw/console-routing.ts — the console signal-routing graph (arbitrary ReturnBus + per-track ConsoleSend pre/post/post-pan + bus→bus output) with feedback-loop prevention (wouldCreateFeedbackLoop), detectCycles, topologicalBusOrder (engine summing order, throws on a loop), validateRouting (cycles + dangling sends), and a no-op-invariant defaultConsoleModel that reproduces the shipped 4-bus + reverb/delay-send topology exactly. 11 Node tests; app tsc 0.
      • Increment 2 DONE 2026-06-09 — Rust nestable group-bus routing: dsp-graph group buses generalized from flat 4→master to subgroup→group→master (per-bus output target; topological Kahn summing order; gains compound; set_group_bus_output rejects self/loops). NO-OP INVARIANT proven bit-identical (all→master = the prior kernel). Exposed via dsp-wasm. 133 cargo tests (+4), clippy clean, wasm rebuilt, 42 WASM-boundary tests (+3).
      • Increment 2b DONE 2026-06-09 — bridge + reducer wiring: EngineCommand groupBusOutput + worklet dispatch; DawSession.groupBusOutputs + setGroupBusOutput action (reuses incr-1 wouldCreateFeedbackLoop so the reducer rejects loops exactly as the engine); session-rebuild replay + project-io persist. 8 reducer tests; src/daw 1432 green.
      • Mixer UI DONE 2026-06-09 — per-bus output dropdown: BusPanel gains a "→ Master / → Bus N" output selector per group bus, offering only loop-safe destinations (filtered via incr-1 wouldCreateFeedbackLoop), dispatching setGroupBusOutput. 3 jsdom tests (default Master / route / loop-exclusion); app tsc 0. The nestable group-bus routing feature is now COMPLETE end-to-end (model → Rust engine → reducer/persistence → UI).
      • Increment 3a DONE 2026-06-09 — control-room / monitor section (full vertical): the monitor-only post-master stage (after the master meters are read, so it never touches the mix, the render/bounce or the loudness/peak readouts) grew from the old engine-only mono/dim (no UI state) into a real control-room: mono sum, dim (now a configurable amount), monitor mute, polarity invert (none / L / R / both — flips one side then sums to Mono for the classic L−R difference / phase check), and a control-room level (the speaker volume, independent of the mix bus). Order = polarity → mono → level·dim → mute. Wired end-to-end: audio-engine-web/src/monitor.ts extended (MonitorPolarity + applyMonitor rewrite, RT-safe single-gain collapse) → worklet engine-processor.template.js mirrors it inline (this.monitor state + setMonitor case) → messages.ts setMonitor (+ dimDb/polarity/levelDb/mute, re-exports MonitorPolarity) → audio-engine.ts facade (options object). App side promoted from transient useState in daw-app.tsx to reducer/session-backed (persisted + undoable + rebuilt): types.ts ControlRoomState + setControlRoom action; daw-session.ts DEFAULT_CONTROL_ROOM/controlRoomCommand/clampControlRoom (level∈[−60,12] dB, dim∈[−60,0] dB) + reducer case emitting the full monitor command; session-rebuild.ts replays only when engaged (no-op default); project-io.ts persist + restore (back-compat default-merge for older files). UI: transport-bar.tsx control-room cluster — Mono/Dim/Mute quick toggles + a "CR ▾" popover (polarity select, level slider, dim-amount slider). Tests: monitor.spec 9 pure (polarity-precedes-mono difference-cancel, level, level·dim compose, mute-overrides) + reducer (no-op default / patch-merge / dB-clamp / full-command) + rebuild-replay (engaged-only) + project-io round-trip + 3 transport-bar jsdom (toggle-dispatch / state-reflect / popover edits). lib tsc 0 + 139 green; app tsc 0; full src/daw+components 1775 green; worklet bundle rebuilt+synced+source-hash re-stamped; stub scan clean.
      • Increment 3b DONE 2026-06-09 — AFL/PFL/SIP solo modes (full vertical, real engine work): the binary solo-in-place grew into three control-room solo modes. SIP (default) = the prior behavior, byte-identical (the 133 dsp-graph + 202 dsp-core tests pass unchanged). AFL/PFL are monitor-only listen buses: every track plays the mix normally (non-destructive — solo no longer mutes), and the soloed channels additionally tap their after-fader (AFL) or pre-fader (PFL) signal — both pairs are already computed by strip.process_pre_post, so the tap is one add — into a solo_bus that replaces the speaker feed after the master chain (master-gain scaled, safety-clamped). The full mix is still metered + is exactly what a bounce renders: render_offline sets an offline flag that forces SIP so the listen bus never leaks into a render or a per-track stem isolation. Wired end-to-end: track.rs SoloTap enum + process_solo/process_inner (the 9-arg process is unchanged → no test churn) → engine.rs SoloMode (Sip/Afl/Pfl, repr u8) + solo_bus_l/r + set_solo_mode + the rewritten solo loop + master-loop output overwrite (click captured for reuse, conditional-add to keep −0.0 bit-identity) → dsp-wasm set_solo_mode → worklet setSoloModemessages.ts → reducer soloMode field + setSoloMode action (SOLO_MODE_CODE map) + rebuild-replay (non-SIP only) + project-io persist → transport-bar CR-popover SIP/AFL/PFL selector. Tests: 3 cargo (PFL-dwarfs-AFL-when-fader-down / no-solo-is-byte-identical-to-SIP / offline-bounce- ignores-listen-mode) + 1 WASM-boundary (real compiled wasm: soloed faded track, PFL RMS > AFL RMS × 8) + reducer (default-SIP / AFL→1 / PFL→2 mapping) + rebuild-replay + project-io round-trip + transport-bar jsdom. dsp-core 202 / dsp-graph 136 / clippy clean (only the documented Source-enum size warning); boundary 47; lib 140; app tsc 0; full src/daw+components 1777 green; bundle rebuilt+synced+source-hash re-stamped; stub scan clean.
      • Increment 3c DONE 2026-06-09 — cue/headphone mix (full cross-stack vertical, real engine work): a per-track pre-fader cue send summing into a control-room cue bus that the monitor can listen to — a separate headphone mix, independent of the channel faders. Built mirroring the 3b AFL/PFL listen-bus so it never touches the main mix, the meters or the bounce. track.rs: a cue_send_level + set_cue_send + a process_cue that taps pre × cue into cue slices (threaded through process_inner/process_slice with the empty-slice guardprocess/process_solo pass empty cue slices → byte-identical; all 205 dsp-core + 142 dsp-graph tests pass unchanged). engine.rs: cue_bus_l/r + cue_monitor + set_cue_monitor/set_track_cue_send; when cue-active (and not offline) every track routes through process_cue and the cue bus replaces the speaker feed after the master chain (forced off during a bounce, like solo-listen). dsp-wasm setters → worklet → messages.ts 2 commands. App: TrackState.cueSend + cueMonitor session flag, setTrackCueSend/setCueMonitor reducers, session-rebuild replay (engaged/non-zero only), project-io persist (cueSend free via the track spread); UI: a per-strip Cue Send slider + a Cue toggle in the transport-bar control-room cluster. (Mic talkback = browser-bound mic input → honest seam, not built.) Tests: 2 cargo (cue-tap pre-fader/scales/zero + the no-op invariant via the unchanged suite) + 1 WASM-boundary (real compiled wasm: a −60 dB-fader track is silent in the main mix but loud under cue monitoring — pre-fader proven) + 5 reducer/persist/rebuild + 2 jsdom (strip slider + transport toggle). app tsc 0; cargo clippy clean (only the documented Source-enum warning); full src/daw+components 2021 green + engine-web boundary 52 green; wasm rebuilt+synced+source-hash re-stamped; stub scan clean.
      • Increment 4 DONE 2026-06-09 — arbitrary aux RETURNS with their own insert chains (the last mixer item; 2 sub-increments, real engine work). CLOSES studio/mixer. NUMAUX_BUSES=4 return buses, each receiving a per-track _parallel send and running its own stereo insert chain before folding back into the master — distinct from the group buses (routed output) and the 2 fixed reverb/delay returns. Incr 4a (engine + wasm, commit aux returns with per-bus insert chains — engine + wasm): per-track aux_send/aux_send_pre (track.rs) threaded via a flat aux_l/aux_r accumulator (bus k at [k·n,(k+1)·n)) through a new process_aux variant + an aux pair on process_inner/process_slice (empty slices on the common path → skipped); engine aux_l/r + aux_gain + two parallel mono insert chains per bus (L/R, like the reverb return's two Reverbs — the engine is mono-per-channel) + an aux_active gate (no track send AND no bus insert ⇒ the whole subsystem is skipped → byte-identical mix, the no-op invariant, proven by the 142 prior dsp-graph tests). Aux runs only on the base/render path (solo-listen & cue replace the master); the offline bounce forces SIP so a render includes the returns; clear_tracks drops the chains (rebuild replays) + render_offline resets their DSP state. dsp-wasm set_track_aux_send/set_track_aux_send_pre_fader/set_aux_bus_gain/add_aux_insert (reverb/delay/chorus/saturator/compressor/distortion/phaser/flanger factory, 100% wet)/ set_aux_insert_param/clear_aux_inserts/aux_insert_count. 5 cargo (send→master, return insert chain, return gain, L/R pan independence, inactive byte-identical) + 1 dsp-wasm binding + 2 WASM-boundary (real-wasm aux return w/ saturator + unknown-kind fail-loud). Incr 4b (app, commit aux returns — reducer + persistence + UI): TrackState.auxSends + DawSession.auxReturns {gain,inserts[]} + AUX_INSERT_KINDS; reducer setTrackAuxSend/setAuxBusGain/addAuxInsert(rejects unknown)/clearAuxInserts → EngineCommands → worklet dispatch; session-rebuild replay (idle = no-op) + project-io persist (free/back-compat); a dockable AuxReturnsPanel (per-return gain + insert chain add/clear + per-track send sliders). 8 reducer + 4 panel jsdom; full src/daw+components 2043 green; dsp-graph 147, dsp-wasm 54, boundary 55; clippy clean; wasm rebuilt+synced. ✅ MIXER COMPLETE. Remaining polish (documented): per-return-insert PARAM editing in the UI (the engine set_aux_insert_param
        • wasm exist; the UI adds inserts with musical defaults), and routing a return into a group bus (returns currently fold to master).
    2. studio/runtime-sota ⭐ — plugin host-planning (CLAP/VST3/AUv3/AAX/ARA), Ableton Link, MIDI 2.0 (7→32-bit), Push 3/MPC/Maschine, sub-30ms remote-tracking budget, drummer Session-Player.
      • Drummer Session-Player DONE 2026-06-09 (pure-TS vertical): apps/euterpe-studio-web/src/daw/drum-groove.ts generateDrumGroove(style, opts) — genre-aware drum-groove generation (real drumming knowledge, NOT a generic euclidean rhythm) emitting the engine's number[][] step→GM-pitch format so it drops straight into a step track via loadPattern. Eight styles (rock/pop/funk/hiphop/house/dnb/jazz/latin), each with its signature kick/snare/hat placement — backbeat, four-on-the-floor, 16th-hat funk, swung jazz ride, son-clave latin — plus a deterministic seeded end-of-phrase tom fill. UI: a "Drummer" row in the step-grid (style dropdown + ▣ Groove button) that derives the bar count from the pattern length and dispatches loadPattern. Tests: 7 pure (rock backbeat 2&4 / house four-on-floor + off-beat open hats + clap / funk 16th-hats + syncopated kick / jazz rides-not-hats / bars·16 length + repeat / seeded fill determinism + fill-only-when-asked / every style non-empty) + 2 jsdom (rock + house grooves dispatch via loadPattern). app tsc 0; full src/daw+components 1845 green; stub scan clean.
      • runtime-sota: MIDI 2.0 model + Push/MPC/Maschine surface maps DONE 2026-06-09 (full vertical, pure-TS); Ableton Link + plugin host-planning remain bucket-C. (a) MIDI 2.0 7→32-bit apps/euterpe-studio-web/src/daw/ midi2.ts — the MIDI Association's official Min-Center-Max scaling (M2-104-UM): scaleUp/scaleDown + the cc7to32/velocity7to16/value14to32 (+ inverses) wrappers, verified against the spec's fixed points (7-bit 127 → 0xFFFFFFFF, 0 → 0, 64 → 0x80000000; 14-bit center preserved; monotonic; round-trips). (b) Programmable control-surface maps surface-map.ts — a parameterized ControlSurfaceMap + generic decodeSurface turning grid controllers' user-assignable MIDI into the existing ControlAction set (relative encoders two's-complement/sign-bit, pitch-bend faders, note mute/solo, transport CC/note), with presets push2 (Ableton's documented CC85 transport + CC71–78 encoders), mpc, maschine (user-mode templates) + generic. Wired live: daw-app control-surface handler routes MIDI through decodeSurface(SURFACE_MAPS[mode], …) → applyControl (falls through to the note path when no mixer action matches, so pads still play); the remote-control panel selector gains all four — alongside MCU/HUI. Tests: 8 midi2 + 11 surface-map (encoder decodings, Push/generic presets, registry) + remote-panel option set. app tsc 0; full src/daw+components 2012 green; stub scan clean. (Ableton Link = network peer bridge, bucket-C; plugin host-planning = bucket-C 4.2 seam shipped.)
    3. studio/instruments — granular synth + DX7-class multi-operator FM matrix (new Rust voices). 2026-09-18: open for an agent. The residue the note names is Ableton Link (its SDK is open source: install it and bridge a network peer) and the plugin host planning of bucket C; the granular and FM voices are item 3 of the same list.
      • Granular synth was ALREADY wired (sampler-panel: enable + grain size/density/position/spray; dsp-core Sampler set_granular → dsp-wasm set_sampler_granular) — don't rebuild it.
      • Multi-operator (DX-style) FM DONE 2026-06-09 — full vertical (closes the instruments gap): the synth voice grew from single-modulator FM to a 4-operator serial stack op4 → op3 → op2(fm_*) → carrier. dsp-core/voice.rs adds op3/op4 (ratio + index + phase) + set_fm_ops; each op's instantaneous phase is offset by the modulator above it, and *_index == 0 drops that operator — so with op3/op4 off the voice is bit-identical to the prior 2-op FM (the no-op invariant, asserted assert_eq!). Wired via the granular-setter pattern: dsp-wasm set_synth_fm_ops → worklet (in the configureSynth case, next to set_synth_fm) → messages.ts configureSynth (+ fmOp3/4Ratio/Index) → SynthPatch fields + reducer command builder + session-rebuild replay + project-io (free via the patch) → synth-panel Op3/Op4 Ratio + Amount sliders. Tests: 1 cargo (op3/op4-off bit-identical to 2-op FM, then op3+op4 enrich the spectrum = more zero-crossings) + 1 WASM-boundary (real wasm: the op-stack adds zero-crossings) + reducer (configureSynth carries the op fields). dsp-core 205 / clippy clean; boundary 50; lib 143; app tsc 0; full src/daw+components 1865 green; bundle rebuilt+synced+source-hash re-stamped; stub scan clean. (A full 6-op + 32-algorithm DX7 matrix is the heavier extension; this serial 4-op stack is the contained, complete first cut reusing all the synth plumbing.)
      • Full 6-operator DX7 FM matrix DONE 2026-06-09 — full vertical (CLOSES the instruments gap): the heavier extension noted above is now shipped. dsp-core/fm.rs FmCoresix sine operators (each ratio or fixed-Hz + output level + its own ADSR) wired through the canonical 32 DX7 algorithms (transcribed verbatim from the MSFA/Dexed fm_core.cc byte table + ported bus model: per-op input/output mod-bus, OUT_BUS_ADD merge-vs-overwrite, averaged 2-sample feedback incl. the multi-operator feedback loop of algos 4 & 6), the transcription verified against the published DX7 carrier chart (algos 1/3/5/22/32). Integrated as a voice mode on SynthVoice (NOT a new Source variant): when enabled the FM carrier-sum replaces the subtractive oscillator while the voice's amp env / filter / velocity / glide / tuning / MPE all still apply (a hybrid); disabled = the exact prior subtractive path, bit-identical (no-op invariant, asserted). Cross-stack via the proven recipe: dsp-wasm set_synth_fm6 bulk setter → worklet (folded into the configureSynth case) → messages.ts fm6* fields → SynthPatch.fm6 + DEFAULT_FM6_PATCH → reducer (configureSynth merge) + session-rebuild replay + project-io persistence (free via passthrough; the command is byte-identical when a patch has no fm6) → a Fm6Editor in the synth panel (algorithm select + feedback + per-operator ratio/level/fixed-Hz/ADSR). Tests: dsp-core 216 (9 fm — chart match, clean single-carrier sine, additive carrier sum, FM sidebands, feedback enrichment, envelope-shaped timbre, deterministic, fixed-freq;
        • 2 voice: disabled-bit-identical + enabled-routes-through-amp/velocity/release) + dsp-wasm 53 (+1 binding), clippy clean (only the pre-existing Source-enum size note); wasm rebuilt+synced; WASM-boundary 53 (+1 real-compiled-wasm FM sidebands); app tsc 0; full src/daw+components 2032 (+11: 7 reducer/persist/rebuild + 4 Fm6Editor jsdom); stub scan clean. (commit feat(euterpe): 6-operator DX7 FM synthesis voice (instruments).) ⏳ Remaining (optional polish): per-operator velocity/keyboard scaling + a true DX7 4-rate/4-level EG (the operator EG is modeled as ADSR — a documented approximation; the routing/feedback/algorithm topology is exact).
    4. studio/effects — phaser, de-esser, per-track stereo-widener + multiband, FET/opto comp modes, selectable filter slopes (new Rust nodes).
      • Phaser DONE 2026-06-09 — full vertical: dsp-core/phaser.rs (LFO-swept first-order allpass cascade + feedback + log sweep + stages; the audit-confirmed gap — engine had chorus/flanger but no phaser). 5 cargo tests (true bypass / allpass-preserves-energy-but-blend-notches / feedback stable / stage-count voicing / silence decay). Wired end-to-end: dsp-graph PhaserNode → dsp-wasm add_phaser/set_phaser_params → worklet → EngineCommand addPhaser/setPhaserParams → reducer 'phaser' InsertKind (add + live-edit) → insert-rack "Phaser" button. cargo 190+133, clippy clean, wasm rebuilt, 43 WASM-boundary tests (+1 phaser-processes-audio), reducer test, src/daw+ components 1771 green.
      • De-esser DONE 2026-06-09 — full vertical: dsp-core/deesser.rs — a split-band sibilance ducker; the sibilant band is the phase-coherent lowpass complement (band = x − lowpass(x), so low+band=x exactly → true bypass at unity), envelope-followed + downward-compressed (freq/threshold/ratio/amount), recombined. 4 cargo tests (true bypass / ducks-loud-sibilance-but-passes-low-body / below-threshold-untouched / higher-ratio-ducks-harder). Wired end-to-end: DeEsserNode → dsp-wasm add_deesser/set_deesser_params → worklet → EngineCommands → reducer 'deesser' InsertKind → insert-rack "De-Ess" button. 44 WASM-boundary (+1) + reducer test; lib 132, src/daw+components 1771 green. (NB: a biquad highpass is phase-shifted so x − highpass interferes — the lowpass-complement is the fix.)
      • Multimode filter DONE 2026-06-09 — full vertical: dsp-core/multifilter.rs — a sweepable creative-filter insert (LP/HP/BP/Notch) built from a cascade of identical biquads so the slope is selectable (1–4 stages = 12/24/36/48 dB/oct), + resonance (Q) + dry/wet (true bypass at mix 0). The "filter slopes" audit gap — distinct from the EQ (tone shelves) and the synth's own filter. 5 cargo tests (true bypass / LP-passes-lows-blocks-highs + HP-opposite / steeper-slope-rejects-more / BP-passes-center / notch-cuts-center). Wired end-to-end: MultiFilterNode → dsp-wasm add_filter/set_filter_params → worklet → EngineCommands → reducer 'filter' InsertKind → insert-rack "Filter" button. cargo 199+133, clippy clean, wasm rebuilt, 45 WASM-boundary (+1), reducer test; src/daw+components green.
      • Comp circuit modes DONE 2026-06-09 — full vertical: dsp-core/dynamics.rs Compressor gains a CompCircuit voicing (Clean/FET/Opto) reshaping the envelope coefficients — FET ≈4× faster attack + 2× faster release (punchy), Opto gentle attack + program-dependent release that slows as the GR deepens (LA-2A self-levelling). Clean is the default + bit-identical to before (gated; all 202 dsp-core tests incl. multiband/master pass unchanged). Wired: CompressorNode set_param("circuit") → dsp-wasm set_compressor_circuit → worklet → EngineCommand setCompressorCircuit → reducer (circuit param on the existing compressor insert, default 0; emitted on add/edit + replayed by session-rebuild for FET/Opto) → insert-rack 3-button Clean/FET/Opto selector. 3 cargo tests (default-is-Clean, FET-attacks-faster, Opto-releases-slower), 46 WASM-boundary (+1), reducer test; src/daw+components 1772.
      • Per-track multiband compressor insert DONE 2026-06-09 — full vertical (closes the effects track): the MultibandCompressor primitive (master-bus 3-band LR4 split) is now a per-track insert. effect.rs MultibandNode wraps it (mono — fed (x,x); set_enabled(true); a focused 8-param surface = 2 crossovers + per-band threshold + per-band makeup gain, with ratio/attack/release fixed at musical defaults to keep it manageable; near-transparent defaults so adding it doesn't pump). Wired end-to-end via the proven recipe: dsp-graph re-export → dsp-wasm add_multiband/set_multiband_params (downcast like dynamiceq) → worklet addMultiband/setMultibandParamsmessages.ts 2 commands → InsertKind 'multiband' → daw-session buildInsert + setInsertParams branches + session-rebuild replay → insert-rack "Multiband" button + 8-slider PARAM_CONFIG. Tests: 2 cargo (a loud 50 Hz tone in the LOW band compresses by band-0 threshold / an out-of-band tone is untouched by mid-high thresholds) + 1 WASM-boundary (real wasm: a saw note compressed across all bands renders < bare × 0.9) + reducer (defaults + live-edit emits setMultibandParams). dsp-core 204 / dsp-graph 139 / clippy clean; boundary 49; lib 142; app tsc 0; full src/daw+components 1862 green; bundle rebuilt+synced+source-hash re-stamped; stub scan clean.
      • Per-track stereo-widener DONE 2026-06-09 — full vertical (CLOSES the effects track). The prior note was half-right (a true M/S widener can't be a mono insert) and half-wrong (it is NOT moot): built as a channel-strip feature, not an insert — the strip already emits the post-pan stereo pair (exactly like the master-bus width), so no "post-pan insert hook / architecture change" was needed. And it is not moot for the mono engine: an off-centre mono source has correlated-but-unequal L/R, so M/S widening genuinely reshapes its image (0 = mono, 1 = unchanged, 2 = wide). dsp-core ChannelStrip.set_width + a widen() mid/side on both the post- and pre-fader pairs (so the main out + sends inherit it); width 1 is bit-identical (mid+side = a, mid−side = b → the no-op invariant, asserted; 14 prior mixer tests unchanged). Wired: track.set_widthdsp-wasm set_track_width → worklet trackWidthmessages.tsTrackState.width + setTrackWidth reducer (clamp 0..2) + session-rebuild replay (only when ≠ 1) + project-io persist (free via the track spread) → channel-strip Width slider. dsp-core 219 / dsp-graph 148 / dsp-wasm 55, clippy clean; WASM-boundary 57 (real-wasm pan collapse-to-mono / widen); app reducer 2 + channel-strip jsdom 1; full src/daw+components 2061. (commit per-track stereo width (mid/side).) The same seam-finding lesson as the vocoder: the "needs an architecture change" deferral dissolved once the right level (the channel strip, not an insert) was used.
    5. studio/automation — touch/latch record modes, modulation palette (audio envelope-follower, perlin/S&H/walk), MIDI-learn-to-any-param with curves, Douglas-Peucker thinning.
      • DP thinning + modulation palette DONE 2026-06-09 (pure-TS vertical): Douglas-Peucker automation thinningautomation-lane-helpers.ts simplifyLane(points, spec, tolerance=0.02) (iterative DP with perpendicular distance in NORMALIZED space — beat over the curve's own span, value via the lane's lin/log valueToNorm, so tolerance is a fraction of the lane range + a log cutoff lane thins perceptually; endpoints always kept) → a "Thin" button on every automation lane (disabled ≤2 points). Modulation paletteautomation-shapes.ts gains triangle, sampleHold (S&H stepped levels with square edges), randomWalk (bounded, edge-reflected), perlin (1D value noise, smoothstep-interpolated); the random sources use a seeded LCG (Numerical-Recipes constants) so a seed reproduces the curve exactly (pure — no Math.random). The lane's shape row auto-renders them (maps over AUTOMATION_SHAPES). Tests: 4 DP (collinear→2 endpoints / keep-a-real-corner / tolerance-boundary drop-vs-keep / ≤2-no-op) + shapes (triangle peak, seeded-reproducibility + bounds for all 3 random sources, S&H held-flat) + 2 jsdom (Perlin fills + Thin collapses a dense collinear curve to 2). app tsc 0; automation specs 30 green; full src/daw+components green (the 4 unrelated drum-synth/HPSS/piano-roll failures were load-induced timeout flakes — all pass in isolation).
      • MIDI-learn response curves + cutoff target DONE 2026-06-09 (extends PLAT-1): web-midi.ts gains MidiCurve (linear/exponential/logarithmic/inverted) + applyMidiCurve (reshapes the 0..127 CC value: exp eases in x², log opens fast √x, inverted flips the knob) applied in midiCcToAction before the parameter, a curve? on MidiMapping, and a new cutoff target (ccCutoffToHz log 20 Hz..20 kHz → configureSynth({cutoffHz})). UI: a per-mapping curve <select> + the Filter-cutoff target in midi-learn-panel.tsx; daw-app setMidiMappingCurve handler. Tests: 4 (cutoff log-map endpoints+mid / curve reshaping per type / midiCcToAction applies the learned curve / linear passthrough)
        • 1 jsdom (curve select dispatches + cutoff target present). app tsc 0; full src/daw+components 1849 green; stub clean.
      • ⏳ Remaining for automationASSESSED 2026-06-09: genuine engine-runtime / live-interaction tail, NOT a sandbox-completable feature (documented, not faked, per the anti-half-feature rule): (a) touch/latch live WRITE record modes — the event-driven write-capture (isWriting gates fader/pan/send/master moves at the playhead) already exists; touch/latch differ from plain write ONLY via a continuous capture loop that samples the held control value every frame during playback (and, for touch, reverts on release against live automation playback). Without that loop touch/latch are observationally identical to write, so the mode enum alone would be an inert control. The loop + pointer-down/up touch tracking is browser-interaction/timing-bound (like the other live-recording tails). (b) Audio envelope-follower mod source — the envelope-follower DSP primitive already ships three times (autowah.rs cutoff, deesser.rs, dynamic_eq.rs) + the MIX-27 sidechain key-bus is a live audio-envelope→dynamics coupling; a general "any track's live envelope → any parameter" mod source is a real-time routing paradigm distinct from the symbolic (offline-baked LFO/env/step) modulation system — an engine-runtime coupling, not a pure helper. Both are correctly left as runtime/interaction tails rather than shipped as half-features.
    6. studio/clip-hygiene — multi-mic polarity-flip + sample-accurate time-alignment, breath reduction, batch gain-match. 2026-09-18: "not sandbox-completable" no longer holds: the repository drives a real browser, and a Playwright run can hold a pointer down across frames. Open for an agent, in two parts. (a) Touch and latch write modes: the continuous capture loop that samples the held control every frame during playback, with touch reverting on release. Verify: a browser spec holds a fader for two seconds of playback and asserts the written points, and that touch returns to the underlying automation on release while latch holds. (b) An audio envelope follower as a modulation source, routed from any track to any parameter in dsp-graph, reusing the follower in dsp-core. Verify: a Rust test drives a gated sine through the follower into a gain parameter and asserts the modulated output against a reference envelope.
      • Per-track polarity / phase invert (Ø) DONE 2026-06-09 — full vertical (real engine work): the classic "Ø" button on every channel — a mix-affecting polarity invert (negates the track in the mix, every send and the bounce), distinct from the monitor-only control-room polarity (3a). Implemented on the dsp-core ChannelStrip (a sign on the per-sample output so the post-fader pair, the pre-fader/send pair and the key bus all inherit the flip; off = sign +1 = byte-identical, the no-op invariant). Wired: track.rs set_polarity_invertdsp-wasm set_track_polarity (via track_mut) → worklet trackPolaritymessages.ts → reducer TrackState.polarityInvert + togglePolarity action + session-rebuild replay (engaged-only) + project-io persist (free via the track spread) → channel-strip Ø button. Tests: 2 dsp-core (inverted+plain cancel sample-for-sample / off is bit-identical) + 1 dsp-graph (inverting one of two identical synth tracks cancels >80% of the mix) + 1 WASM-boundary (same cancellation through the real compiled wasm) + reducer (toggle + trackPolarity command) + rebuild-replay + channel-strip jsdom. dsp-core 204 / dsp-graph 137 / clippy clean; boundary 48; lib 141; app tsc 0; full src/daw+components 1819 green; bundle rebuilt+synced.
      • clip-hygiene DSP — PURE ALGORITHMS DONE 2026-06-09 (2a): apps/euterpe-studio-web/src/daw/clip-hygiene.ts — the three buffer-analysis primitives, verified against synthetic signals with known answers. Time-alignment findAlignmentLag(reference, signal, maxLag) = normalized cross-correlation over [−maxLag,maxLag] (energy-normalized on the overlap so partial overlaps can't win; ties → smaller |lag|), recovering a known sample delay exactly (test: 37-sample delay → lag 37, corr > 0.99; negative lag too). Batch gain-match matchGainsRms(buffers, targetDb?) → per-buffer dB to a common RMS target (default = loudest; silent buffers untouched) + bufferRmsDb/bufferPeakDb (full-scale sine = −3.01 dBFS; −6 dB per halving). Breath reduction detectSilenceSegments (sliding-window RMS below threshold for ≥ minDuration → spans; ignores short dips) + breathReductionEnvelope (click-free linear-faded ducking gain, deeper-duck on overlap) + applyGainEnvelope. Tests: 16 known-value (RMS/peak dBFS, delay recovery both signs + maxLag/empty guards, gain-match default/explicit-target/silent, silence gap detect + min-duration reject + all-loud, envelope floor/edge-fade/apply). app tsc 0; full src/daw+components 1968 green; stub scan clean. Remaining (2b): wire end-to-end via engine.renderTrackOfflinesetTrackGain (gain-match) / setTrackDelaySamples (alignment) + a panel. 2026-09-18: open for an agent. The residue is 2b as written: wire the three primitives through engine.renderTrackOffline to setTrackGain and setTrackDelaySamples, with a panel, verified in a real browser.
      • 2b DONE 2026-06-09 — wired end-to-end + UI (CLOSES item 6): a dockable Clip Hygiene panel (clip-hygiene-panel.tsx, registered in workspace.ts) drives three useDawEngine handlers that render tracks offline (engine.renderTrackOfflineinterleavedToMono) and apply the 2a DSP via existing reducer actions: Match levels (render all tracks → matchGainsRmssetTrackGain per track, adding the dB delta), Align (render two selected tracks → findAlignmentLag ±50 ms → planAlignmentDelayssetTrackDelaySamples on the earlier one) and Reduce breaths (render track → detectSilenceSegmentsbreathReductionEnvelopeapplyGainEnvelope → commit as a "(de-breathed)" audio track + mute the source, the bounce pattern). planAlignmentDelays is a pure tested helper. Tests: +1 model (planAlignmentDelays both signs/zero) + 5 jsdom panel (empty prompt, match-handler, align passes both ids, align-disabled-on-same-track, breath passes id). app tsc 0; full src/daw+components 1974 green; stub scan clean. ✅ ITEM 6 COMPLETE (time-alignment + batch gain-match + breath reduction, DSP + wired + UI).
    7. studio-runtime/section-lyrics-editors — song-section arranger (energy curve / density grid / transition designer)
      • structured lyric/vocal-arrangement editor (delegate rhyme/meter to @euterpe/lyrics).
      • Increment 1a DONE 2026-06-09 — section-timeline arranger (full vertical, pure-TS): the unwired 1519-LOC section-lyrics-editors parts-bin lib is now a shipped DAW feature via an app-native port (matching the BUCKET-D precedent — drum-groove/midi-harmony/arrangement-form/clip-edit all live in src/daw/, not lib imports; also sidesteps the lib's section-lyrics-editors.ts:1214 readonly Foo['field'][] oxc-crash). apps/euterpe-studio-web/ src/daw/song-structure.ts — a tempo-independent (bars) section timeline where energy + the active instrument set ride on each SongSection (one serialisable array the reducer persists/undoes, vs the lib's 3 parallel structures). Carries over the lib's real algorithms verified vs known song-form values: 12-kind template library (pop/EDM conventions — 8-bar verse/chorus, drop at peak energy), gapless reflow invariant, add/remove/reorder/resize, bars→sec projection, gap/overlap validation, energy curve (peak + mean-adjacent-step), density classification (sparse/balanced/dense/maximal), and the energy-delta transition designer (build/drop/sweep/ fill/bridge/cut with element suggestions). Wired: types.ts songStructure+nextSectionSeq + 8 actions; reducer cases (pure view-state, commands:[], ids from nextSectionSeq); project-io.ts persist+restore (back-compat default-empty); workspace.ts "Song Structure" dockable panel; song-structure-panel.tsx — section ruler (width ∝ bars, peak highlighted) with add-from-template / reorder / resize / lock / remove + per-section energy meter + density readout. Tests: 33 pure (bars↔sec, templates, reflow/edit ops, projection, validation incl. hand-built gap/overlap, energy peak+meanStep, density thresholds, all 6 transition kinds) + 9 reducer (id-minting, atIndex, edit flow, save/load round-trip + legacy-empty) + 7 jsdom panel. app tsc 0; full src/daw+components 1913 green; stub scan clean. Remaining (1b/1c/1d): energy/density EDITING UI + marker/generator connectors (1b), transition- designer UI (1c), structured lyric/vocal editor delegating to @euterpe/lyrics (1d).
      • Increment 1b DONE 2026-06-09 — energy/density EDITING + marker connector (full vertical, pure-TS): the arranger panel grew from read-only readouts into a live editor. Energy: a per-section range slider → setSongSectionEnergy (the meter visual stays; peak section is highlighted live via energyCurveOf). Density grid: an instrument-rows × section-columns toggle grid (density-{role}-{i} cells, aria-pressed) → toggleSongSectionInstrument — the "arrangement density editor (sparse↔maximal)" subtask, with each section's sparse/balanced/dense/maximal class recomputed from its active set. Connector: a "→ Markers" button + markStructureSections reducer action projects the AUTHORED structure into timeline markers (bars → beats × beatsPerBar), using a distinct "Section: <Kind>" name prefix so it's idempotent AND coexists with the clip-derived markSongSections ("Section A/B") — manual markers preserved. Tests: +3 reducer (bar→beat boundaries, idempotent-+-manual-preserved, no-collision-with-markSongSections) + 3 jsdom (energy slider dispatch, density-grid render + cell toggle, markers button). app tsc 0; full src/daw+components 1919 green; stub scan clean. Remaining (1c/1d): transition-designer UI (model shipped in 1a), structured lyric/vocal editor (1d).
      • Increment 1c DONE 2026-06-09 — transition designer w/ persisted overrides (full vertical, pure-TS): the 1a designTransition model gained per-boundary overrides so the designer is authorable, not just inferred. SongStructure.transitions?: Record<boundaryKey, TransitionKind> (back-compat optional); boundaryKey(from,to); setTransitionOverride (kind | null to clear); transitionsOf(structure, meter) designs every adjacent boundary applying overrides. Overrides are pruned on reflow when an endpoint is removed (threaded through reflow + all edit ops) and survive edits that keep both endpoints. Reducer setSongSectionTransition {fromId,toId,kind|'auto'}; persisted free via the songStructure spread. UI: a "Transitions" section in the panel — one row per boundary (From→To, an Auto/build/drop/bridge/cut/sweep/fill <select> that highlights when overridden, + the live kind · Δenergy · suggested elements). Tests: +6 model (boundaryKey, transitionsOf order/count, override-honoured, clear-to-auto, reflow-prunes-stale, survives-unrelated-edit) + 2 reducer (override+clear, save/load round-trip) + 2 jsdom (per-boundary rows + override dispatch, single-section shows none). app tsc 0; full src/daw+components 1929 green; stub scan clean. Remaining (1d): structured lyric/vocal editor delegating to @euterpe/lyrics.
      • Increment 1d DONE 2026-06-09 — structured lyric editor (full vertical; rhyme/meter DELEGATED to @euterpe/lyrics): the lyric half of item 7. apps/euterpe-studio-web/src/daw/song-lyrics.ts analyzeLyrics(text) parses a lyric document into [verse]/[chorus]-tagged lines and, delegating to @euterpe/lyrics/rhyme-meter (countLineSyllables, analyzeRhyme, detectMeter — the production phonetic engine, per the checklist directive), computes per-line syllable counts, a derived end-rhyme scheme (A/B/C grouping via pairwise analyzeRhyme + repeated-end-word grouping), and the dominant metrical foot. Wiring: narrow oxc-safe @euterpe/lyrics/rhyme-meter subpath added to tsconfig.base.json paths + app vitest.config.ts test-alias + lib package.json export subpath + app package.json dep (pnpm install clean no-op); session lyrics: string + setLyrics reducer + project-io persist/restore (back-compat empty). UI: a "Lyrics" editor in the panel — a textarea (setLyrics) + a live readout (lines · syllables · scheme · meter, then per-line rhyme-label + syllable + section-tag). Tests: 7 analysis (empty, syllable counts vs known values, tag parsing, AABB + ABAB scheme derivation, repeated-end-word grouping, meter shape) + 1 reducer (set + save/load + pure) + 2 jsdom (textarea dispatch + analysis render). app tsc 0; full src/daw+components 1939 green; stub scan clean. Item-7 core COMPLETE (timeline + energy + density + transitions + lyrics). Remaining tail: per-section vocal-arrangement stack (lead/double/harmony) — 1e.
      • Increment 1e DONE 2026-06-09 — per-section vocal arrangement (full vertical, pure-TS) — CLOSES item 7: apps/euterpe-studio-web/src/daw/song-vocals.ts — a stacked vocal-arrangement model (lead / double / harmony / adlib voices, each with semitone interval + pan + gain) with 5 production-real presets: solo (centred lead), doubled (lead + two unison doubles spread L/R — classic double-tracking), octave (+12), thirds (minor-3rd +3 & 5th +7 stack), choir (−5/+4/+7/+12 spread). buildVocalArrangement / addHarmonyVoice (clamps pan/gain, relabels to choir) / removeVoice (lead-protected) / voiceCounts / describeArrangement. Folded onto SongSection.vocals? (auto-prunes with the section, persists free); setSectionVocals updater; reducer setSongSectionVocalPreset + clearSongSectionVocals. UI: a per-section "♪" preset select + a voice-count summary. Tests: 9 model (all 5 preset stacks vs known intervals/pans, add/remove/describe) + 2 reducer (attach+persist+clear, dropped-with-section)
        • 2 jsdom (preset select dispatch, summary render). app tsc 0; full src/daw+components 1952 green; stub scan clean. ✅ ITEM 7 COMPLETE — section-lyrics-editors surface fully wired into the shipped DAW (timeline + energy curve + density grid + transition designer + structured lyric editor + vocal arrangement).
    8. studio/ai-features (build-on the symbolic side) — NL mixing commands, symbolic chord/key on MIDI clips, MIDI variation generation, arrangement/section analysis.
      • Symbolic chord/key on MIDI clips DONE 2026-06-09 (pure-TS vertical, verified vs music-theory ground truth): apps/euterpe-studio-web/src/daw/midi-harmony.ts — symbolic harmonic analysis straight from note data (no audio/FFT, complementing the audio-domain detectKey in audio-analysis.ts). Key detection = Krumhansl–Schmuckler key-profile correlation over a duration-weighted pitch-class histogram (detectKeyFromNotes → {tonic, mode, name, confidence}). Chord detection = pitch-class-set template matching (detectChord) across 14 qualities (maj/min/dim/aug/sus2/sus4/ power + maj7/7/m7/m7b5/dim7/maj6/min6) with a matched − 1.1·missing − 0.55·extra score (root must sound; correctly handles the 5th-omitted dominant 7th and rejects rootless/partial guesses; inversion- + octave-invariant). analyzeChordSegments windows a clip and merges held chords into one-per-change; analyzeClipHarmony returns {key, chords}. UI: harmony-readout.tsx HarmonyReadout (memoized, pure display) wired under the piano-roll toolbar — live "Key: C major · C · F · G · C". Tests: 14 pure (every triad + 4 seventh types + omitted-5th C7 + sus/power + inversion-invariance + KS key on C-major scale / A-minor melody / G-major + I-IV-V-I cadence + histogram weighting + windowed progression merge) + 2 jsdom (readout renders key+chords / empty renders nothing). app tsc 0; full src/daw+components 1802 green; stub scan clean.
      • Roman-numeral harmonic-function analysis DONE 2026-06-09 (extends the chord/key detection): midi-harmony.ts romanNumeral(chord, key) — the numeral base is the scale degree (spelled per the key's mode: a major key reads I/II/.../VII on its degrees, a minor key likewise; chromatic roots get ♭/♯ accidentals), and the CASE (upper = major quality, lower = minor) + suffix (°, +, 7, maj7, ø7, sus…) come from the chord quality — so a borrowed ♭VII, a secondary-dominant II (V/V, uppercase by quality), and a viiø7 all read correctly. Surfaced as a third line in the piano-roll HarmonyReadout ("I · IV · V · I"). Tests: 4 pure (I-IV-V cadence / ii7-V7-Imaj7 jazz / minor i-iv-v lower-case / borrowed-♭VII + V/V + vii° + ø7) + 1 jsdom (the Roman line). app tsc 0; full src/daw+components 1824 green.
      • Arrangement / section (song-form) analysis DONE 2026-06-09 (pure-TS vertical): apps/euterpe-studio-web/src/daw/ arrangement-form.ts — derives a song's structure ("A B A B C") by clustering repeated clip content. clipContentKey is a stable content fingerprint (sorted notes + the placement fields that change what plays — ignores id/name/colour/ position), so identical-sounding clips share it. analyzeSongForm groups clips into section blocks by start beat, fingerprints each block as its sorted set of (trackId, contentKey) pairs, and labels blocks A/B/C… by first appearance (so a repeated multi-track section reuses its letter; the same parts on different lanes are a different section). UI: song-form-bar.tsx SongFormBar (pure, memoized) above the arrangement — coloured section chips per letter. Tests: 8 pure (content-key identity/order-independence/difference, A-B-A-B / A-A-A / A-B-C-A forms, multi-track block grouping, different-lane distinction, empty) + 2 jsdom (A-B-A-B chips / empty renders nothing). app tsc 0; full src/daw+components 1834 green; stub scan clean. + section markers from the form (2026-06-09): a markSongSections reducer action derives navigation markers from analyzeSongForm ("Section A/B/…" at each block boundary, per-letter color), replacing prior auto-section markers (idempotent) while keeping manual ones; a "⌖ Mark sections" button in the SongFormBar. 1 reducer + 1 jsdom test; suite 1861 green.
      • Extract chords → chord track DONE 2026-06-09 (connects the harmony detection to MIDI-9): a detectChordTrack reducer action — the inverse of applyChordTrack — runs analyzeChordSegments on a track's note clip and replaces the chord track with the detected progression (per-beat detection, held chords merged, ids minted from nextChordSeq). UI: a "→ Chords" button in the piano-roll. Tests: reducer (C-then-G clip → chord track ['C','G'] at beats 0/2 + empty no-op) + jsdom (button dispatches). app tsc 0; chord-track + piano-roll specs green; full suite green (the 4 unrelated drum-synth/HPSS/piano-roll/a11y failures were load-induced timeout flakes — all pass in isolation).
      • NL mixing commands extended for the new features DONE 2026-06-09: command-parser.ts gains natural-language verbs for this session's additions — solo mode ("solo mode pfl", "after-fader listen solo", "solo in place" → setSoloMode, placed before the plain solo toggle), control-room monitor ("monitor mono", "control room dim" → setControlRoom, gated behind the monitor context so the bare-"mono" master-width shortcut still works), and polarity ("invert phase track 2", "flip polarity 1" → togglePolarity). Tests: 3 (solo-mode AFL/PFL/SIP + plain-solo-still-toggles / monitor mono+dim + bare-mono-is-master-width / polarity invert) — 63 green across the whole command surface (parser + copilot + daw-commands + script-host). app tsc 0; full src/daw+components 1852 green.
      • DONE 2026-06-09 — conditional MIDI variation + deeper NL phrasing (full vertical, pure-TS) — CLOSES item 8: (a) Conditional generation apps/euterpe-studio-web/src/daw/midi-variation.ts generateConditionalVariation — generates NEW material conditioned on the clip + the track's scale (distinct from varyClipNotes, which transforms): 'response' (call-and-response — mirror the call's rhythm into the 2nd half, invert the contour around the call centroid, snap to scale, resolve the last note to the tonic), 'embellish' (fill 3rd+ leaps with an in-scale passing tone), 'displace' (rhythmic swing — keep pitches, push every other onset off-grid). Seeded LCG (no Math.random). Reducer conditionalVaryTrack {trackId, mode} (reads noteClip + scaleLock, grows length to fit the response). (b) Deeper NL phrasingcommand-parser.ts gains a name-aware resolveTrackLoose ("the drums" → Drums track) + qualitative descriptors → parameterized effect chains: "punchier" → FET compressor (medium attack/fast release), "warmer"/"saturate" → gentle saturation, "fatten"/"thicker" → compound compressor + saturation; plus NL verbs for the 3 conditional modes. (Bare "punch" still maps to the transient-shaper insert.) Tests: 10 model (3 modes vs known music-theory: contour inversion + tonic resolution + in-scale, passing-tone insertion, pitch-preserving displacement, determinism, empty/amount-0 guards) + 9 integration (reducer response/embellish/no-op
        • parser response/embellish/displace/punchier-FET/warmer/fatten). app tsc 0; full src/daw+components 1993 green; stub scan clean. ✅ ITEM 8 COMPLETE.
    9. studio/clip-editing (minor) — join/consolidate, clip-reverse, repeat-to-fill, crossfade-curve selection. studio/generator-track (small) — provider-agnostic generator-track binding model.
      • Generator-track binding model DONE 2026-06-09 (provider-agnostic + honest seam): apps/euterpe-studio-web/src/ daw/generator-binding.ts — a persisted GeneratorBinding (provider / prompt / drums / styleRef) so a generator track's configuration is part of the project (survives save/load + undo), not transient panel state. GENERATOR_PROVIDERS registry (on-device MRT2 now; cloud providers slot in) + generatorProviderStatus (the on-device provider is available; an unknown / cloud provider fails loud with the reason — never a fabricated success). TrackState.generator + setGeneratorBinding reducer (patch-merge, view-state only, persisted free via the track spread); addGeneratorTrack seeds the default. UI: generator-panel.tsx promoted from transient useState to the persisted binding (provider select + status readout + prompt/drums/style-ref now read/write the binding while still steering the live MRT2 stream). Tests: 3 pure (default / on-device-available / unknown-fails-loud) + 2 reducer (default seeded / patch-merge no-cmd)
        • 3 jsdom (provider ready / prompt persists via setGeneratorBinding / reflects a saved prompt). app tsc 0; full src/daw+components 1859 green; stub scan clean.
      • Reverse + consolidate + repeat-to-fill DONE 2026-06-09 (pure-TS vertical): apps/euterpe-studio-web/src/daw/ clip-edit.ts — three pure transforms over the arrangement clip model + the shared primitive clipEffectiveNotes (the notes a clip actually plays on its local timeline once the trim offset + loopWithin tiling are applied — the same placement math the preview renderer draws). reverseClipNotes mirrors each note in time (its end becomes its start; it's its own inverse); consolidateClipNotes bakes several same-lane clips into one spanning their union, repositioning each clip's effective notes by its song offset (offsetBeats 0 / loopWithin false result); repeatCountToFill is the tiling arithmetic. Reducer actions reverseClip / consolidateClips (same-lane only, mints one baked clip) / repeatClipToFill (fills up to the next same-lane clip or the loop-region end, capped at 256). UI: arrangement-view keyboard shortcuts — R reverse, Cmd/Ctrl-J consolidate (Logic/PT "Join"), T repeat-to-fill. Clips are standard ClipState so persistence/rebuild is free. Tests: 8 pure (reverse mirror + double-reverse-identity / effective-notes tiling+offset-clip / consolidate union + null / count-to-fill) + 6 reducer (reverse emits resync / consolidate bakes union + <2 no-op / repeat tiles to next clip + no-op) + 2 jsdom (R + T shortcuts dispatch). app tsc 0; full src/daw+components 1816 green; stub scan clean.
      • DONE 2026-06-09 — crossfade-curve selection (full cross-stack vertical) — CLOSES item 9: a per-clip fadeCurve (0 = equal-power [default], 1 = linear, 2 = log/exp) applied in the engine's fade rendering, via the proven recipe. dsp-graph/clip.rs: a fade_curve_shape(frac, curve) (equal-power sin(frac·π/2) / linear frac / log frac²; f(0)=0, f(1)=1 for all) + a fade_curve field + set_fade_curve + fade_gain calls it. No-op invariant: curve 0 = the prior sin exactly (an out-of-range curve also falls back to it) — all 141 dsp-graph + 205 dsp-core tests pass, +2 new. dsp-wasm set_track_arrangement gains a clip_fade_curve: &[u8] param → applied like tempo_mult; worklet passes clipFadeCurve; messages.ts (optional, back-compat). App: ClipState.fadeCurve, arrangementCommand emits clipFadeCurve, setClipFadeCurve reducer (re-emits the arrangement), persist free; an Equal-power/Linear/Log selector in the arrangement-view selection toolbar. Tests: 2 cargo (curve shapes + distinct rendered gains, no-op default) + 1 WASM-boundary (real compiled wasm: a faded sample clip's midpoint is quieter under linear than equal-power) + reducer (set + emit + clamp + default) + jsdom (selector dispatch) + the exact-command test updated. wasm rebuilt+synced+source-hash re-stamped; app tsc 0; cargo clippy clean (only the documented Source-enum warning); full src/daw+components 2014 green + engine-web boundary 51 green; stub scan clean. (generator-track binding model was already done — see item 9's earlier [x].) ✅ ITEM 9 COMPLETE.
  • Acceptance: ✅ re-audit + cleanup done (6 dead modules removed, zero loss, all consumers green). ⏳ BUCKET D is the feature-build roadmap (each item = a Rust-node and/or reducer port + the cargo/node/jsdom harness).
  • Risk: LOW for the cleanup (verified zero-loss); BUILD-ON items are M–XL each (engine + reducer + UI per feature).

3.13 [x] PLAT-9 — Screen-reader keyboard-navigable piano-roll / automation (treegrid) · P1 · effort L ✅ DONE 2026-06-08 (piano-roll treegrid + automation-lane keyboard editing)#

  • Built as a separate accessible view (additive, zero-risk) rather than retrofitting the 775-line SVG roll: pure navigation model apps/euterpe-studio-web/src/daw/a11y-grid.ts (13 tests) — a pitch-row × time-step grid over the note clip with moveCursor/clampCursor (up = higher pitch), cellLabel (SR readout "C4, beat 1.50, note, velocity 102"), toggleNoteAtCursor (edit-in-place add/remove), nudgeVelocityAtCursor, defaultGrid (2-octave window centred on content), pitchName (reuses notation.midiToPitch).
  • piano-roll-grid.tsx PianoRollGrid — an ARIA treegrid with role=row/rowheader/gridcell, aria-activedescendant cursor, aria-selected + per-cell aria-label, arrow-key navigation, Enter/Space toggle, [/] velocity nudge, Home/End, click-to-place, and a polite role=status live region announcing the cell/edit. Edits dispatch the same setNoteClip as the visual roll (one clip, two interfaces). Toggled on via an "⌨ Accessible grid" button in the roll.
  • Tests: 13 pure (a11y-grid.spec.ts: geometry, cursor clamp, pitch naming, cell labels, toggle on/off, velocity clamp/no-op, default window) + 6 jsdom (piano-roll-grid.spec.tsx: treegrid role + activedescendant, arrow nav + live announce, Enter add/remove, ] velocity, click-to-move) + 1 piano-roll toggle test. App tsc 0; full suite 1301 green; stub scan clean.
  • Automation lanes (2026-06-08): automation-a11y.ts (8 pure tests) — clampCursor/breakpointLabel (lin/log/unit-aware, e.g. "Volume point 2 of 5: beat 4.00, −3.00 dB") + nudgeValue (2 %-of-range linear / octave-wise for log cutoff) + nudgeBeat + addAfterCursor/removeAtCursor, all over the same LanePoint[]/LaneSpec the visual lane edits. Wired into automation-lane.tsx: a focusable role=application region with arrow-key breakpoint navigation + value/time nudge (Shift = ×5 / time) + Enter add + Delete remove, a polite status live region, dispatching via the same commit. 2 jsdom tests (ArrowDown nudges the value via setVolumeAutomation; Enter adds + announces).
  • Acceptance: ✅ keyboard-alone navigate + edit notes (treegrid) AND automation breakpoints (the lane region) — jsdom-verified: focus moves by arrows, labels announce pitch/time/velocity + breakpoint position/value, edits dispatch. ⏳ a real NVDA/VoiceOver AT pass + transport-position announcements while playing remain (browser/AT-bound).
  • Risk: L; both landed additive (parallel accessible interfaces), so the visual roll + lane are untouched. The actual SR experience still needs an AT pass — documented, not claimed.
  • apps/euterpe-studio-web/src/daw/timecode.ts — pure SMPTE timecode + both decoders (14 tests):
    • SMPTE model: Timecode + timecodeToFrames/framesToTimecode (incl. 29.97 drop-frame with the drop-every-minute-except-tenth rule — frame counts round-trip; the 00:00:59;29 → 00:01:00;02 boundary verified), timecodeToSeconds (29.97 = 30/1.001 wall clock), timecodeToSamples/samplesToTimecode, formatTimecode (; for df).
    • MTC: MtcQuarterFrameAssembler reassembles a full timecode from the 8 quarter-frame nibbles (+ the rate bits → 29.97⇒df); robust to starting mid-run (only reports a set that began at piece 0).
    • LTC: decodeLtcFrame/encodeLtcFrame (80-bit SMPTE frame, BCD fields + the 16-bit sync word — throws on a sync mismatch / wrong length, fail-loud), modulateBiphase/decodeLtcAudio (biphase-mark FM: a 0 = one full-period interval, a 1 = two half-period intervals; bit period self-estimated → SR/speed-agnostic) + findLtcFrame (sync-word alignment). Full synthesize→modulate→audio-decode→align→parse round-trip recovers the timecode.
    • timecodeDriftSamples phase-sync (transport-vs-incoming drift for a chase controller).
  • Wired: a SMPTE timecode readout (30 fps) in the transport-bar (samplesToTimecode+formatTimecode of the playhead — a real consumer, jsdom-verified); MTC quarter-frames (0xF1) from the Web-MIDI input reassemble and locate the playhead while the transport is parked (chase-while-stopped).
  • Jam-sync chase-WHILE-PLAYING DONE 2026-06-09 (commit jam-sync timecode chase-while-playing): timecode.ts chaseDecision(incoming, playheadSamples, sampleRate, opts) — a pure, tested controller: while the transport plays, an incoming MTC frame re-locates it only when drift exceeds a ~20 ms lock window (jam-sync), so inside the window it free-runs from its own sample clock (no zipper). daw-app's MTC handler now chases while playing — reading the live transport.playheadSamples and dispatching seek on chaseDecision().seekTo — not only while parked. Tests: +5 pure (free-run within the window / re-seek when ahead+behind past it / exclusive threshold boundary / custom window); 19 timecode tests green; app tsc 0; stub scan clean.
  • Tests: 14 pure (timecode.spec.ts) + 1 jsdom (transport-bar readout) + 5 chase-controller pure. App tsc 0; stub scan clean.
  • Acceptance: ✅ MTC/LTC frame decode + drop-frame + phase-sync verified from synthesized streams; ✅ MTC locates the parked transport; ✅ jam-sync chase while playing (re-locate on drift past the lock window — controller verified). ⏳ REMAINING (engine/hardware/network-bound): a continuous varispeed rate-lock (smoothly trimming playback speed to the external rate — a transport-engine change, vs. the jam-sync re-seek now shipped); the actual LTC audio input front-end; Ableton Link (UDP/WebSocket peer bridge — partial bucket-C); live-hardware MTC slaving (the MIDI-surface hardware tail).
  • Risk: M; the decoders + the jam-sync chase controller are fully local + verified. Continuous varispeed rate-slaving + Link's transport are the network/engine remainder, documented not faked.

4. BUCKET C — External-blocked (cannot complete in this sandbox)#

These need native binaries, a backend server, paid API creds, or large model weights — none available here. For each, ship the fail-loud seam now (a typed boundary that refuses to fabricate: throws not_configured, returns {configured:false} / a 503 gate, or a capability flag reading "unavailable") and document the exact resource to finish. Fail loud beats fake success. Do NOT wire the provenance SIMULATION lib as if it were real.

4.1 [ ] ENG-1 / PLAT-2 — Native low-latency audio I/O (CoreAudio / ASIO / WASAPI / ALSA) · P1 · XL#

  • Needs a native shell (Tauri + cpal) running a native audio thread; impossible in a browser tab.
  • Seam: keep the WebAudio path; expose a capabilities.nativeAudio = false flag + a "native driver (desktop build)" note.
  • To finish: Tauri desktop build hosting the Rust engine on a native callback; bridge transport/meters over IPC.

4.2 [~] FX-11 / IO-4 / MIX-29 / AI-22 / UX-15 / REC-13 / PLAT-N5 / ARR-17 — VST3 / CLAP / AU plugin hosting · P0/P1/P2 · XL ✅ FAIL-LOUD SEAM SHIPPED 2026-06-08#

  • Needs a native plugin-host subprocess loading binary plugins; browsers can't. (In-browser WAM hosting is a narrower, separate path that COULD be local — consider WAM as a bucket-B alternative.)
  • Seam SHIPPED (2026-06-08): apps/euterpe-studio-web/src/daw/plugin-host.ts — a typed PluginHost interface (scan/instantiate) + webPluginHost whose ops throw NotConfiguredError (code:'not_configured') instead of fabricating an empty success; a CapabilityStatus (reason + how-to-enable); isNotConfigured helper. insert-rack surfaces an honest "VST3/CLAP/AU hosting is desktop-build only" note. 4 unit + 1 jsdom test. (commit feat(euterpe): fail-loud plugin-host seam…) To finish (real hosting): native host (JUCE/clap-host) in the Tauri build, or implement WAM (Web Audio Modules) hosting behind the same interface.

4.3 [ ] COLLAB-1/2/4/5/6/7/9/10/11/12 · AI-16 · UX-22 · IO-13 · PLAT-N7 — Real-time collaboration + cloud projects · P0/P1 · XL#

  • Needs a backend: WebSocket CRDT sync server, Postgres + object store, auth/roles, presence, share links, activity feed, cloud render. COLLAB-3/8 (local versioning/merge/branching) are the offline analog already shipped.
  • Seam: the local version-store + merge UI stay; a collabClient interface returns {connected:false}; share/presence UI gated behind a "requires backend" capability. To finish: stand up the sync service (y-websocket/Liveblocks + DB/S3).

4.4 [ ] ARR-N1 / REC-N1 / IO-N4 / UX-25 / COLLAB-21 — Neural 4-stem separation (Demucs/HTDemucs) · P0/P1/P2 · XL#

  • Needs hundreds-MB model weights + an inference runtime (ONNX/candle in wasm or native). Absent here.
  • Seam: a StemSeparator interface returning {available:false}; UI offers it as "download model / cloud" disabled.
  • To finish: bundle/download Demucs weights + an in-browser ONNX-Runtime-Web (or native) inference path.

4.5 [ ] AI-14 / AI-1 / AI-12 / AI-7 / AI-N3 — On-device + cloud generative AI (Magenta-RT, Suno/Udio/Stable Audio, voice clone, inpaint, speech-to-text) · P1/P2 · XL#

  • On-device (AI-14) needs candle forward-pass + 2.4B weights; cloud (AI-1/12/7/N3) needs paid API creds. The BFF resolver + control-plane are fail-closed already (per the MRT2 memory).
  • Seam: providers register from creds and fail-closed without them (existing pattern); on-device shows "model not loaded".
  • To finish: wire the candle forward-pass + weights (on-device) or supply API creds (cloud) at deploy.

4.6 [ ] MIX-35 / RIGHTS-1(real) / RIGHTS-2(real) / RIGHTS-11(provider) / MASTER-N2 / AI-19(real) — Real C2PA Content Credentials + SynthID watermark · P0/P1 · L (but crypto-dep + sim-lib blocked)#

  • The current @euterpe/provenance lib is a SIMULATION (FNV 'ed25519-sim', 64-bit-into-number[]). Real C2PA 2.1/ISO 22144 = JUMBF/COSE manifests with real Ed25519 (server-side key custody — client signing is forgeable); real SynthID = a robust PCM audio watermark surviving compression/pitch-shift. Provider verification (RIGHTS-11) needs proprietary detectors.
  • Seam: the text-label provenance + own-watermark round-trip stay (honest, labelled); a c2pa.sign() interface throws not_configured without a signing key. To finish: a real C2PA lib (c2pa-rs wasm) + server-side key mgmt + a real PCM watermarker; this is a dedicated build, not lib-wiring.

4.7 [~] MASTER-11 / MASTER-8 / MASTER-N5 — Atmos authoring, stem-mastering, master dynamic/spectral EQ · P1/P2 · M-XL ✅ MASTER-N5 DYNAMIC-EQ INSERT DONE 2026-06-08 (Atmos/stem-mastering remain external)#

  • MASTER-11 Atmos = the surround engine (ENG-N3, bucket A-XL). MASTER-8 stem-mastering needs stem separation (4.4, blocked).
  • MASTER-N5 dynamic EQ (DONE 2026-06-08, real DSP — NOT the lib planner): a from-scratch per-band dynamic-EQ insert built via the proven cross-stack effect recipe (so it works on ANY track incl. the master bus, not display-only). dsp-core/dynamic_eq.rs DynamicEq — a peaking Biquad EQ whose gain is pulled down when the band's own level (isolated by a sidechain band-pass + a peak-tracking envelope follower) exceeds threshold_db, by ratio, bounded by range_db (down-ward dynamic EQ / de-essing / resonance taming). RT-safe: detector + envelope per sample, the peaking biquad recomputed at most once per block (the AutoWah pattern); a 0 dB band that never triggers is a bit-exact pass-through (a 0 dB peaking biquad has transfer function 1). dsp-graph DynamicEqNode (AudioEffect + set_param); dsp-wasm add_dynamiceq/set_dynamiceq_params; worklet + messages.ts (add/set commands); InsertKind 'dynamiceq'; daw-session buildInsert + setInsertParams; session-rebuild; insert-rack "Dyn EQ" + PARAM_CONFIG (freq/q/gain/thr/ratio/ range). Tests: 5 cargo (below-threshold-0 dB pass-through; loud in-band tone cut > 6 dB + attenuated RMS; out-of-band tone untouched; reduction grows with level; reset) + 1 WASM-boundary (the rebuilt artifact's add_dynamiceq cuts a loud 440 Hz fundamental — bare RMS > dyneq RMS) + 1 reducer (add + live setInsertParams). dsp-core 185 / dsp-graph 109 / clippy clean; boundary 35; app 1485; tsc 0; wasm rebuilt+synced.
  • MASTER-N5 was previously ≈ partly covered by the multiband (MIX-23); the lib's dynamicEqResponse is a PLANNER (wiring it as a realtime processor would be a display-only stub — NOT done). This is the real per-insert dynamic-EQ DSP node.
  • Seam / note: multiband stays as the master glue; the dynamic-EQ insert is now the surgical per-band dynamics. Atmos (MASTER-11 = surround engine ENG-N3) + stem-mastering (MASTER-8 = needs neural stem sep, 4.4) remain external-blocked.

4.8 [~] MASTER-18(deliver) / RIGHTS-N1 / MASTER-12 / RIGHTS-14 — Distribution: DDEX delivery, ISRC/UPC assignment, DistroKid/Tracklib APIs · P1/P2/P3 · M ✅ ERN BUILDER + UPC VALIDATION + FAIL-LOUD SEAM DONE 2026-06-08#

  • The EXPORT METADATA (ISRC/ISWC/UPC fields, DDEX AI-disclosure flags) is local + partly done (MASTER-18 tags). The DELIVERY (DDEX ERN ingest to a distributor, ISRC auto-assignment, sample-clearance lookup) needs distributor API creds + DDEX endpoints.
  • DDEX ERN builder + delivery seam (DONE 2026-06-08): apps/euterpe-studio-web/src/daw/distribution.ts — the release-notification message a distributor ingests is built locally + real: buildErnMessage(release, opts) is a pure, deterministic DDEX-ERN-4.3-structured NewReleaseMessage serializer (MessageHeader, ResourceList of <SoundRecording>s with canonicalized ISRC + ISO-8601 <Duration>, ReleaseList <Release> with the UPC as <ICPN> + display/P-line/C-line/ genre + resource refs, a worldwide <ReleaseDeal>), carrying the DDEX AI-content disclosure (IsCreatedUsingArtificialIntelligence, release default + per-track override). validateUpc is the real GS1 GTIN-12 mod-10 check (a known code like 036000291452 passes, any flipped digit fails); validateGtin extends it to UPC-A or EAN-13 (the global barcode; 4006381333931/5901234123457 pass) and a UPC/EAN barcode field in the tags panel (redden-on-invalid like ISRC) feeds the ERN <ICPN>; formatDuration → ISO-8601; validateIsrc/formatIsrc reused from metadata-tags. The delivery (uploading the ERN + audio to a distributor) is the fail-loud seam: unconfiguredDistribution.deliver() throws NotConfiguredError (reused from plugin-host) rather than fabricating a "delivered" success; distributionCapability() is surfaced in the build's capability report (capabilities.ts gains the distribution entry — the last missing bucket-C seam). Wired: a "⤓ DDEX ERN" button in the transport-bar MASTER-18 tags panel builds a release-notification XML from the entered metadata + downloads it. Honesty: the serializer targets the ERN 4.3 structure but does not run XSD validation — documented to validate against the distributor's official DDEX XSD before a production upload. Tests: 15 pure/jsdom (UPC known-good/bad/length/non-digit; formatDuration; ERN well-formed + MessageId/recipient + SoundRecording/ISRC/duration + ICPN/UPC-stripped + AI-disclosure default+override + worldwide deal + XML escaping + determinism + minimal-omits-optional) + a capability assertion + a transport-bar ERN-button jsdom test. App tsc 0; full src/daw+src/components/daw suite 1531 green; stub scan clean.
  • To finish (external): distributor (DistroKid/TuneCore) API creds + a DDEX ERN 4.3 SFTP/ingest endpoint for the actual upload; ISRC auto-assignment + sample-clearance lookup (registry APIs).

4.9 [ ] AI-4(deep) / AI-N2 / AI-N5(deep) / MIDI-13 — Melodyne-grade pitch correction, cross-channel masking EQ, deep AI session players · P1 · L-XL#

  • AI-4 polyphonic pitch correction shipped a real YIN+WSOLA monophonic version; Melodyne-grade POLYPHONIC DNA editing needs a much heavier algorithm (research-grade). AI-N2 cross-channel smart:EQ + AI-N5/MIDI-13 chord-reactive real-time session players need either ML models (bucket C) or substantial new DSP/heuristics.
  • Seam / note: the monophonic scale-tune + the 55-LOC accompaniment helper stay; deeper versions are research-tier.
  • RIGHTS-7/8 (on-chain registration, royalty AMM) = bleeding-edge, not in any flagship DAW; out of the SOTA-DAW bar.
  • RIGHTS-10 (GDPR/CCPA console) = platform/account-layer org tooling, not a per-project DAW surface.
  • RIGHTS-15 (melody/lyric copyright-similarity scan) needs a known-melody CORPUS (licensed dataset) to be meaningful.
  • Seam: these are platform/legal-layer or corpus-bound; document as out-of-scope-for-the-DAW-edit-surface or corpus-blocked.

5. Suggested sequencing (highest leverage first)#

  1. ENG-3 PDC (2.1) — unlocks correct latency for FX-9s/spectral + is a clean no-op-invariant engine win. Do first.
  2. ENG-2 sub-block automation (2.2) — quality win, contained once the no-op invariant test is in place.
  3. Take comping + recording capture (3.3 + 3.4) — a coherent recording subsystem; high user value.
  4. MusicXML interop (3.1, pure half) and codec export (3.6) — standalone, testable, no engine risk.
  5. STFT engine → FX-9s + ENG-14 (2.5 + 2.3) — build the STFT framework once, two features fall out.
  6. MPE (3.2) and dockable panels (3.7) — large but high-value UX; budget a full session each.
  7. i18n (3.10) — mechanical breadth sweep, do in isolation.
  8. Surround/Atmos (2.7, ENG-N3) — XL channel-count refactor; sequence last among engine work.
  9. Bucket C — ship every fail-loud seam now (cheap, honest); finish each only when its external resource is provisioned.

6. Effort × risk matrix (quick reference)#

Item Bucket Effort Hot-path risk Verifiable here No-op invariant available
ENG-3 PDC A L Medium Yes (cargo align test) Yes (len==0 identical)
ENG-2 sub-block auto A M Med-High Yes Yes (empty auto identical)
ENG-14 FFT window A M Low Yes (pure FFT) N/A (additive path)
ENG-9/IO-N2 warp A L Medium Yes (transient proxies) Yes (WSOLA default)
FX-9s spectral A L Med-High Yes (needs STFT) N/A
ENG-N2/11 SIMD A M-L Medium Yes (scalar-equiv) Yes (scalar fallback)
ENG-N3 surround A XL High Partial N/A (new bus path)
ENG-7/N1 OPFS+SAB A/B L Low-Med Partial (ring pure) N/A
Notation/MusicXML B L-XL None Pure yes / UI browser N/A
MPE B L-XL Medium Yes Yes (channel mode default)
Comping+capture B L Low Pure yes / mic browser N/A
PWA B L None Pure yes / offline browser N/A
Codecs B M None Yes (round-trip) N/A
Dockable panels B L None jsdom / drag browser N/A
i18n B L None Yes N/A
Bucket C (all) C varies None Seam only N/A

Conventions: keep app tsc -p tsconfig.tmpcheck.json at 0; run the full cargo + WASM-boundary + jsdom harness per item; preserve the no-op invariant; ship fail-loud seams for external work; commit + push to BOTH refs; never wire the provenance simulation as if real; never mark a box without the code + tests behind it.