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 nowapps/euterpe/studio-web/…, and the DSP effects it names (autowah.rs,deesser.rs,dynamic_eq.rs) are inlibs/euterpe/audio-engine/crates/dsp-core/src/, beside the graph cratecrates/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)#
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)#
# 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
timeoutcommand on macOS.grepexit 1 = "no matches" = success in a one-shot batch (don't let it fail a chain). git diff --cached | grep 'wasm/'FALSE-POSITIVES on thecrates/dsp-wasm/SOURCE path — verify artifacts aren't staged by checking the file list foraudio-engine-web/wasm/andpublic/audio/specifically.- Commit + push to BOTH refs every completed item:
git push origin <branch>thengit push origin <branch>:main.--no-verifyis 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/masterlib (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/provenancelib 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_rpost-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: exposeWaveshaper::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: addedfn latency(&self) -> usize { 0 }toAudioEffect; overridden onWaveshaperNode/SaturatorNode→self.<inner>.latency(). (All other nodes inherit 0.) -
dsp-graph/track.rs:pub fn insert_latency(&self) -> usize= sum oflatency()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_delayreallocs only on size change (==pdc_lenguard → no per-block alloc in steady state);reset_stateclears 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: eachprocess()block computesbase = max insert_latency; per trackdelay = 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) -> usizeon the engine (= base) for the "PDC: N samples" readout. - REC-9 manual track-delay nudge folded in:
Track::set_pdc_nudge/pdc_nudge+ engineset_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 carrieslatencySamples; EngineCommandsetTrackDelay; 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_latencysums only non-bypassed; per-track delay math + REC-9 nudge. Node-boundaryengine_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==0byte-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.rsvolume/pan/cutoff/send/delay-send + per-insert-param;engine.rsmaster gain/ceiling/width. - Granularity: CTRL = 16-sample sub-blocks (matches the synth voice's internal control rate).
-
track.processrefactored into a dispatch:has_active_automation() && playing→ awhileloop over CTRL slices, each callingapply_automation(beat)thenprocess_slice(start,end)(extracted render→inserts→PDC→strip); else a single full-width slice = byte-identical to before (no-op invariant).engine.processmaster loop re-evaluatesapply_master_automation(beat)at eachi % CTRL == 0while 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(reusesfft.ts):windowSample/makeWindow+coherentGainfor 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 awindowprop is set it tapsanalyser.getFloatTimeDomainData(the documented copy approach) →windowedSpectrum→spectrumBars, replacing the fixed-windowgetByteFrequencyDatapath (kept as the fallback). FFT size drivesanalyser.fftSize. - UI:
SpectrumPaneladds 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;
binFrequencycorrect;spectrumBarsfloors 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
getFloatTimeDomainDatacopy).
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.rsalready had transient-locked WSOLA; added a from-scratch offline phase vocoder (per-bin instantaneous-frequency phase propagation on the existingfft.rs, Hermitian-mirrored, unity-gain weighted-overlap-add) + a quality flagStretchMode::{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_modedispatches;time_stretchdelegates 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_shiftdelegates. Wired end-to-end: dsp-wasmtime_stretch_buffer_mode/pitch_shift_buffer_mode→ workletloadSampleWarped→ audio-engine-webloadSampleWarped(.., mode, preserveFormants)+StretchModetype → daw-appwarpSample→ 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. (commitfeat(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-
Samplermodel makes a re-render the clean fit).dsp-core/timestretch.rswarp_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 viatime_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→ workletloadSampleWarpMarkers→ audio-engine-webloadSampleWarpMarkers(.., samplesPerBeat, mode)→ daw-appwarpSampleMarkers(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 onwarpMarkersAreActive). New puredaw/warp-markers.ts(sanitizeWarpMarkerssorts + 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-freefft.rs(radix-2 FFT/IFFT — round-trip identity + sine-peak tests) +stft.rsStft(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/wetmix) +SpectralVocoder(FX-9s final piece, 2026-06-08): a self-carrier channel vocoder — averages the modulator's magnitude intobandsfrequency 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/wetmix. 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 scalarsidechain_levelin ProcessContext → an engine-wide lifetime change, deferred). All →*Nodeimpls withlatency()→ 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.rsChannelVocoder— two 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/wetmix. Avoided the engine-wide ProcessContext lifetime ripple the note feared: the modulator reaches the insert via an additive, defaultedAudioEffectseam (feed_sidechain(&key)+wants_sidechain(), no-op for all existing nodes), so no&ProcessContextsite 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 onwants_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. (commitstwo-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_sidechainAudioEffect 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
F32x4abstraction (DONE 2026-06-09):libs/euterpe/audio-engine/crates/dsp-graph/src/simd.rs— a 4-wide f32 vector with twocfg-selected backends: onwasm32+simd128it lowers tocore::arch::wasm32v128ops (f32x4_add/mul/abs/max, safef32x4()lane-constructor load +extract_lanestore — no raw-pointerv128_load, so the crate's#![forbid(unsafe_code)]holds); on every other target (incl. the nativecargo testhost) a[f32; 4]fallback the autovectorizer still lowers to NEON/SSE. Only add/mul/abs/max are used — no FMA — andmacdoes 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] * gmultiply-accumulate, nowcrate::simd::mac(per bus, per channel, per block); (2) the per-track sidechain-key detector —max_i max(|key_l[i]|, |key_r[i]|), nowcrate::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-exactassert_eqvs 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). -
+simd128enabled in the wasm build (DONE 2026-06-09): newlibs/euterpe/audio-engine/.cargo/config.toml([target.wasm32-unknown-unknown] rustflags = ["-C","target-feature=+simd128"], scoped to wasm so native tests are untouched) +--enable-simdadded to thedsp-wasmwasm-opt flags. Verified the shipped artifact actually contains SIMD: an opcode-byte scan ofwasm/dsp_engine_bg.wasmfinds realf32x4.abs×2 +f32x4.max×2 (theabs_peakkernel),f32x4.add×14 +f32x4.mul×74 (themackernel + autovectorized loops); module validates. The runtime feature check islibs/euterpe/audio-engine-web/src/wasm-simd.tswasmSimdSupported()(the canonical 31-byte() -> v128probe viaWebAssembly.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); thecfgscalar 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]'dlarge_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-barCPU 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.rs—SpeakerLayout(5.1/7.1/7.1.4 with real ITU/Dolby azimuth/elevation positions, channel orderL 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;spreadwidens 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:encodeAdmBwfframes a multi-channel RIFF/WAVE withfmt+bext(EBU R98 602-byte ext)+chna(BS.2088 channel allocation, one audioTrackUID/track→track+pack format)+axml(BS.2076audioFormatExtended)+data.buildAdmXmlemits 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 compiledsurround_pan_gainswithin 1e-5);renderSurroundBedcomposites per-track mono objects. App:TrackState.pan3d {azimuth,elevation,spread}(display-only, default centre) + asetTrackPan3dreducer (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 liveaudioObjectwith 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.mjsservesCross-Origin-Opener-Policy: same-origin+Cross-Origin-Embedder-Policy: require-corpfor the browser build (Tauri shell serves the same pair), enablingSharedArrayBuffer(also unblocks PLAT-5/COLLAB-19). Backed by a canonical, tested policy modulelibs/euterpe/audio-engine-web/src/cross-origin-isolation.ts—crossOriginIsolationHeaderEntries()(the values next.config mirrors, asserted by a test so drift is caught),isCrossOriginIsolated/sharedArrayBufferConstructible/canUseSharedArrayBuffercapability predicates, and a typedCrossOriginIsolationErrorfail-loud seam.SabRing.createnow guards onSabRing.isSupported()→ throws the typed error (not an opaqueSharedArrayBuffer is not defined) when a tab isn't isolated. 8 tests. (Remaining browser tail: confirm the live app still loads underrequire-corpwith no blocked cross-origin subresource — switch tocredentialless(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.tsSampleStreamScheduler— the deterministic playback policy for OPFS-streamed samples: keeps aprebufferFrameswindow ahead of the playhead,planNextFetch(ringFree)returns the next{sourceStart, length}to read (capped by chunk granularity, ring backpressure, and source/loop end),onFetched/onConsumedadvance 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,isEndedonly after source exhausted AND buffer drained). +frameByteOffsetfor the read offset. 9 Node tests (prebuffer fill / backpressure / consume-resumes / partial-final / end-after-drain / zero-length / loop-wrap / loop-never-ends). Feeds theSabRing(2.8 above). - OPFS read adapter (REMAINS — browser tail): open a
FileSystemSyncAccessHandle, on eachplanNextFetch()read()lengthframes atframeByteOffset(sourceStart,…), push into the ring, callonFetched; the workletpulls + reportsonConsumed. 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 andFileSystemSyncAccessHandleinside 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.tsSabRing— a single- producer/single-consumer f32 ring over aSharedArrayBuffer, lock-free viaAtomicson 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; partialpush/pullreport their count (honest underrun);pullOrSilencezero-fills a short block;create/wrapsplit 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. (commitfeat(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:NotationNotemodel ↔ 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 + aproject.exportMusicXmlpalette command (export the selected track's note clip; import a.musicxmlfile as a new note-clip track). - Static score render (DONE 2026-06-08): pure engraving geometry
apps/euterpe-studio-web/src/daw/score-layout.ts—diatonicStep(octave×7 + letter; sharps share their natural's line),accidentalFor,stemUp(B4-and-above ⇒ down),glyphFor(head fill / stem / flags / dots fromnotation.noteValue),ledgerSteps(middle C gets its ledger; the space just below the staff gets none),noteY/stepToY(higher pitch ⇒ smaller y; top line F5 attopPad), andlayoutScore(notes positioned by beat, barlines perbeatsPerBar, length rounded up to whole bars). Rendered bycomponents/daw/score-view.tsxScoreView— 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 (beatsPerBarthreaded 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; fullsrc/daw+src/components/dawsuite 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.tsgains aCleftype + a bass staff (G2..A3, steps 18–26) andlayoutScore(..., 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 publicstemUp/ledgerStepsnow delegate to the parameterized helpers — all 17 prior score tests unchanged).ScoreViewauto-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 aclefprop). 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.tscomputeBeams(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.ScoreViewsuppresses 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; fullsrc/daw+src/components/daw1597 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:spellInKeypicks 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), andaccidentalGlyphInKeyprints 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).keySignatureStepslays the ♯/♭ block on its conventional treble/bass staff positions;MAJOR_KEYSis the 15-key selector. No-op invariant:fifths === 0delegates tomidiToPitch(legacy all-sharp spelling) so the default score + all prior tests are byte-identical.score-layout.layoutScore/computeBeamstake afifthsparam (default 0) and route placement through the key-aware spelling + reserve a key-signature column (zero-width at fifths 0);score-viewrenders 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) + 6score-layout(no-op default, reserved column shifts notes/barlines, accidental suppress/♮, B♭ respelled to a higher line, grand-staff key sig on both staves) + 3score-viewjsdom (no accidentals in C, ♯ block + suppressed per-note accidental, both-staves grand sig) + 1 piano-roll selector. App tsc 0; fullsrc/daw+src/components/daw1627 green; stub scan clean. (TS-only — no engine/wasm change.) - MusicXML time-signature + tempo round-trip (DONE 2026-06-08):
musicXmlToNotesalso 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, returningnullfor either when the file omits it (so a session keeps its own). The import handler dispatchessetTimeSignature+setTempoonly 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 propertyDawSession.keySignatureFifths+ asetKeySignaturereducer action (clamped −7…+7, display-only → emits no engine command, so session-rebuild is untouched), persisted throughproject-io(back-compat: absent → 0).musicXmlToNotesnow parses<key><fifths>and returns it, so the existingnotesToMusicXmlkeyFifthsexport round-trips: the MusicXML export command passessession.keySignatureFifths, and the import dispatchessetKeySignature(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-layoutengraves aPlacedTimeSig(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.layoutScoretakes atimeSignatureparam defaulting tonull→ zero-width → byte-identical (pure tests unchanged);score-viewalways passes the session meter asbeatsPerBar/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.measureAccidentalsresolves 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-partnotesToMusicXmlto share apartMeasureshelper (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 sharedparsePartNoteshelper, 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
editable —
score-layoutgains 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 },nullleft of the first note column), andtoggleScoreNote(click an empty spot to add a note, click an existing note to erase it).score-viewtakes an optionalonEdit: when supplied the SVG becomesrole="application"with a click handler that maps the click (viagetBoundingClientRect→ viewBox coords) →scorePointToNote→toggleScoreNote→ the new clip; the piano-roll passesonEditdispatchingsetNoteClip. 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.tsassignVoices— 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).layoutScoretags eachPlacedNote.voice+ flips the stem by voice when polyphonic (top up / next down);computeBeamsbeams each voice independently (no beams across voices) following that voice's direction (buildBeamGroupgains aforcedStemUp);score-viewrenders both directions (added astemtestid). 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 (commitMusicXML multi-voice export/import):partMeasuresgates on >1 voice →multiVoiceMeasures/fillVoiceMeasures(each voice fills every bar, then a<backup>rewinds for the next voice;noteElementgains<voice>); the importerparsePartNoteswalks 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+resolveHeadCollisionsinscore-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 onpolyphonicso monophonic content — incl. a block chord, whose pitch-based stems mix up/down — keepsheadDx 0→ byte-identical (no-op invariant);computeBeamsreadsx + headDxso beamed displaced heads keep aligned stems;score-viewrenders head + ledgers + accidental + dots + stem atx + 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:
SynthVoicegains per-notebend_semitones/pressure/timbre(set live, independent of the channel-wide LFO/matrix): bend sums into the pitch (play_freq), pressure swells the ampamp_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) soundingnoteand routes dim 0/1/2 → so two held notes bend/press/brighten independently. Threaded throughTrack/Engine/dsp-wasmnote_expression(track, note, dim, value)(with the same scale-lock snap). - Capture:
apps/euterpe-studio-web/src/daw/mpe.tsMpeRouter— 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-notenote_expressionevents; 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.noteExpressionbridge. 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_expressionpressure 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.rsNoteExpression(per-note bend/pressure/timbre breakpoint curves,(beat-offset, value)relative to the note start) stored parallel to the notes (ClipNote staysCopy), sorted together by start beat.NoteClip::advanceinterpolates each sounding note's curves at its note-local beat (eval_curve, linear + hold-endpoints, empty→skip) and emitsTrack::note_expression(pitch, dim, value)per block (control-rate, absolute → smoothly tracks the curve).NoteClip::with_expressionsconstructor;newdelegates 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
MpeRouterchannel-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
NoteExpressionper 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).
- a flat ragged expression encoding (per curve: note-idx / dim / count +
concatenated beats/values), reconstructs one
- App model + persistence + bridge wiring (DONE 2026-06-09):
ClipNote.expression+NoteExpressiontypes;note-expression.tsragged encoder; asetNoteExpressionreducer (set one note's curves by sorted index, re-emit the clip through the MPE bridge) +setNoteClipnow PRESERVES expression; thesetNoteClipcommand + session-rebuild carry the encoding only when present (no-expression command byte-identical); the worklet routes toset_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.tsxNoteExpressionEditor(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 dispatchessetNoteExpressionso the curve plays through the MPE bridge + persists. 3 jsdom tests. - Visual SVG expression LANE (DONE 2026-06-09):
components/daw/note-expression-lane.tsxNoteExpressionLanedraws 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 mockinggetBoundingClientRect(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/TakeCompintypes.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, andresolveCompClips— 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
materializeComphelper 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 viaTakeCompSectionfor 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/daw1281 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
materializeComphelper (reusesarrangementCommand), 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-takeaudio-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 readssin(t·π/2)and outgoingcos(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=1layers forever /<1decays older layers / silence-start makes the first pass a plain record); wrap +loopCount/position/cleartested. - REC-12 multitrack arm:
MultitrackRecorder— per-armed-trackCaptureBuffers, routedappendignores un-armed tracks (no fabricated take),finishAllreturns only non-empty takes keyed by id; arm/disarm/armedTrackstested. - 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 atnow, FIFO-pairs overlapping repeats of one pitch, excludes pre-window onsets, clamps the window at the song start,trimbounds the ring.
- REC-3 punch-in/out:
- REC-N5 wired END-TO-END (verified here):
RetroMidiBufferfed fromrecordDispatch(the single keyboard+MIDI note choke point) against aperformance.now-anchored tempo clock that advances whether or not the transport plays; acaptureRetrocallback 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, fullsrc/daw+src/components/dawsuite 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 splicingapplyPunchinto the recorded sample on stop — these consume the actualgetUserMediaaudio 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.ts—strategyForRequest(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-URLallSettledso a missing optional asset never fails install), activate deletes staleeuterpe-cache-*clients.claim, fetch routes throughcache-first/network-first(offline→cached shell/)/stale-while-revalidate. Conservative (navigations never served cache-first → no stale-app footgun). Themanifest.webmanifestalready existed + is linked inapp/layout.tsx.
- Registration (DONE, opt-in seam):
src/components/sw-register.tsxServiceWorkerRegister— registers/sw.jsonly whenNEXT_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.tsOfflineSyncQueue<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),setOnlinereturns the offline→online reconnect cue, andack(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
FileSystemSyncAccessHandleper project/sample path, drive it fromdrainBatch()→write→ack(key, seq), wiresetOnlineto theonline/offlineevents. 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; theSabRingtransport 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; togglecontext.setOfflineto 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, matchingencodeWav. Wired: daw-appbounce('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) macOSafconvert(CoreAudio) decodes it bit-exact — external real-FLAC proof. (commitfeat(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/SSNDchunks, 16-bit signed big-endian PCM, the sample rate as an 80-bit IEEE-754 extended float (writeExtended; 48 kHz → the canonical40 0E BB 80 …), optionalANNOannotation for the provenance label. Wired: daw-appbounce('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.)
- 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
- 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=0independent 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 viaMath.imul+|0so 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.cafbit-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-appbounce('alac')+ palette "Bounce to ALAC".
- un-mix) round-trips every packet bit-exact (multi-frame / partial-frame
/ silence-zero-run / incompressible-escape; mono + stereo); (2) macOS
-
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 typedCodecUnavailableError(code:'codec_unavailable') — an honest fail-loud seam, never fabricated audio; aLOSSY_ENCODERSregistry lets a vendored WASM codec slot in with zero call-site change.CODEC_INFO/SUPPORTED_CODECS/AVAILABLE_CODECSare the single source of truth. The daw-appbounce()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/lamejsMP3,opus-encoderOpus, etc. NEW dependency edge (pnpm catalog) — deferred on this memory-constrained box (a freshpnpm installrisks thrashing per the macOS guidance). The lossy codecs are patented psychoacoustic encoders not worth hand-rolling; they fail loud viaencodeCodecuntil 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 (checkfree -mfirst; if the worktree's link farm is incomplete, link the packages from.pnpmas 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 inencodeCodec'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
encodeCodecdispatcher 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;
encodeCodecis 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, andserializeWorkspace/parseWorkspace(fail-loud-tolerant — null/garbage/wrong-shape ⇒ default, never throws; drops nameless screensets + a dangling active pointer). TheWORKSPACE_PANELSregistry is the 23 DAW panels. - Workspace shell (DONE, React):
components/daw/workspace-shell.tsx—WorkspaceControls(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.tsxnow renders its ~23 top-level panels through a panel registry (panelNodesid→node, conditional panels = a null node that's skipped) driven byorderedVisiblePanels— so visibility, order, collapse, and the work-area sidebar width are all live + persisted tolocalStorage(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; fullsrc/daw+src/components/dawsuite 1582 green; stub scan clean. - Drag-to-reorder panels (DONE 2026-06-08): a pure
workspace.reorderPanel(state, id, beforeId, amongIds)— movesidto just beforebeforeIdamong 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 nowdraggablewith a ⠿ grip + HTML5 DnD handlers (dragStart records the id, drop firesonReorder(dragged, target)) — testable bydata-idtarget, 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 verticalrole="separator"handle between the sidebar and the work area that, on mouse-down, captures the start x + width and tracks the move onwindow(so the drag continues past the thin handle), reportingclampSidebarWidth(startWidth + Δx)(the same 240–680 clamp the reducer applies) until mouse-up; wired into daw-app drivingwsSetSidebarWidth. 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>carryids; 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.dawprojectexport/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.tsemits 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>targetingsend-rev-/send-dly-{id}) all serialize. Import skips the aux-bus tracks (recognized by id, so engine ids stay aligned) and parses sends back tosetTrackSend/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 existingfadeInTime/fadeOutTimeround-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 zipsproject.fcpxml+Stems/*.wav(so the referenced media genuinely exists), and.fcpxmlimport inloadProject(+ the Open accept). Tests: 13 pure/jsdom (rational time round-trip/gcd/zero/ malformed-throw,stemFileNameslug, 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; fullsrc/daw+src/components/dawsuite 1499 green; stub scan clean. (commitfeat(euterpe): FCPXML post-production interchange….) AAF substrate — CFB/OLE2 container DONE + INDEPENDENTLY VERIFIED 2026-06-09 (incr 1, commitMS-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)readCfbround-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) macOSfile(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.aafwith 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 (instrumentSynthPatch+ 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.adgrack / 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),applyDevicePresetlowers a preset to the existing reducer actions in dependency order (configureSynth → setInsertChain → per-insert toggleBypass/setSidechain by index → strip setters), andimportDeviceTextcomposes parse+apply. Cross-DAW.dawprojectdevice 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-appsaveDevicePreset/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; fullsrc/daw+src/components/dawsuite 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/fadeOutTimerecovered; 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):SampleLayergains 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 legacyset_rate_semitones(note−60)+trigger_velocity(both step-grid sites now calltrigger_note, all dsp-graph tests green). Wired dsp-wasm/engineadd_sampler_zone→ worklet →addSamplerZone→ daw-apploadSfz(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. (commitfeat(euterpe): SFZ instrument parser + key-zone multi-sample engine.) Decent Sampler.dspresetALSO DONE (2026-06-08, commitfeat(euterpe): Decent Sampler (.dspreset) parser…):decent-sampler.tsparses the XML into the SAMESfzInstrumentmodel (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-.wavLOAD 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.ts—parseScript(pure) compiles a tiny line-based DAW-control language (tempo / synth|audio "name" / gain|pan|mute|solo| rename - 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 argsi/f/d/s/b/T/F/N/I, 4-byte aligned, plus#bundledecode + flatten — fail-loud on a misaligned packet); (2) address/route → action —oscToAction(/transport/play,/tempo,/transport/seek,/track/<n>/gain|pan|mute|solo|volume,/master/gain) andrestToAction(PUT /track/3/gain {db:-6}, POST/PUT verbs only, GET = read) both yield the same typedControlAction(values clamped to the reducer's safe ranges; mute/solo readT/Fas absolute or no-arg as toggle); (3) execution —applyControlresolves a 1-based track index against the live list (re-read each call) and drives aControlRuntime(the reducer, via the app), failing loud on an out-of-range track. A realOscControlBridgeWebSocket client (injectable socket) decodes each binary frame → action → runtime, proving the frame→decode→map→apply chain end-to-end without a network. Wired: daw-appControlRuntimeadapter (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, defaultnew WebSocketfactory) + 4 jsdom (RemoteControlPanel connect/disconnect/status/log). App tsc 0; fullsrc/daw+src/components/dawsuite 1417 green; stub scan clean. OSC feedback (2026-06-08):oscFeedbackMessages(state)encodes the live mixer/transport as the inverse ofoscToAction's address set (round-trips back throughdecodeOscMessages+oscToAction);OscControlBridge.send+sendFeedbackpush 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-jsrelay) 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 decoderdecodeMcu(midi) → ControlAction[]reusing the IO-8ControlAction/applyControlexecutor verbatim (no new arbitrary-code path). Decodes faders (14-bit Pitch-Bend: ch 0–7 → strip gain, ch 8 → master, viafaderToDb/dbToFader14linear-in-dB over [−60,+12]), V-Pots (CC 0x10–0x17 endless encoders → a newtrackPanRelativeaction: low bits = ticks, bit 6 = sign), the jog wheel (CC 0x3c → aseekRelativetransport scrub), buttons (Note-On press: Mute 0x10–0x17 / Solo 0x08–0x0f → toggle; transport Play 0x5e / Stop 0x5d) — everything else →[](releases + unowned messages). Added thetrackPanRelativevariant + an optionalnudgeTrackPantoControlRuntime+applyControlhandling (unsupported runtime → honestok:false, not a silent no-op). Wired: acreateControlRuntimefactory now backs BOTH the OSC bridge and a new MCU MIDI path; the once-installed Web-MIDI handler routes input throughdecodeMcu→applyControlwhen the RemoteControlPanel's new "Control surface (MIDI): Off / Mackie Control (MCU)" selector is set (es-localized). Bidirectional feedback (2026-06-08):mcuFeedback(state)/encodeMcuFader/encodeMcuButtonLedencode 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 effectport.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/dbToFader14round-trip;applyControlrelative-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; fullsrc/daw+src/components/dawsuite 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) decoderHuiDecoder.decode(midi) → ControlAction[]reusing the sameControlAction/applyControlexecutor. 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'sfaderToDblaw); a button is a zone/port pair — CC 0x0F selects the zone, the next CC 0x2F carriesport | 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 persistentHuiDecoderref (statefulness needs one instance across messages) →applyControlwhen 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; fullsrc/daw+src/components/dawsuite 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 0x2Fport|0x40), so a fader feedback round-trips back throughHuiDecoderto 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 effectport.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
seekRelativenudge (HUI_TRANSPORT_NUDGE_BEATS), Stop (port 3) →stop, Play (port 4) →play; Record (port 5) decodes to[](the typedControlActionset 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.huiFeedbacknow also lights the Play/Stop/ Record transport LEDs (zone 0x0e) fromHuiSurfaceState.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), sotranslate(locale, key, params)does catalog-lookup →{param}interpolation → English fallback for any untranslated key. This makes adoption non-breaking: wrapping a literal int('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) +isLocaleguard. React bindingcomponents/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 aLocaleSwitcher(<select>of locale endonyms, live switching). Wired: the whole DAW tree is wrapped inLocaleProvider+ a compactLocaleSwitcherin 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/dawsuite 1443 green; stub scan clean.
- persists) + 1 jsdom (the OSC panel renders Spanish under an es provider).
App tsc 0; full
- 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 int(...)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 toapp/daw/page.tsx(a component can't consume the context it provides;useLocale/useTare 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.tsscans every DAW component and fails if anytitle/aria-label/placeholderliteral 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 (useTadded 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.sampleVectorlog-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),patchVectorperceptually weighted (brightness ×1.7 + richness ×1.5 dominate, the way spectral centroid dominates heard timbre). -
cosineSimilarity+rankSimilar(seed-excluding, top-N) +brightnessLabel.
- Sample (PCM):
- 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 insampler-panel—brightnessLabel+ 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;rankSimilarover 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; fullsrc/daw+src/components/dawsuite 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 ONEBrowserAsset[](buildAssets/collectAssets),searchAssets(fuzzy-subsequence over name/category/kind),rankAssetsByPatch(cross-library timbre ranking — reusespatchVector+rankSimilar, synth-only, FX excluded), andapplyAsset→ reducer actions (synth →configureSynth, FX →setInsertChain).components/daw/asset-browser-panel.tsxAssetBrowserPanel— a searchable modal list (kind/source/category badges,draggablerows), 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 fromselected); chrome localized viat()(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. ALibrarySampleis{ id, name, pcm, sampleRate, vector, features }where the timbre vector + features are computed once from the PCM via the existingsampleFeatures/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 vialoadSampleFileinto a session library (id = file name → reloading replaces) + records each track's loaded sample as its similarity seed; a refactoredapplySampleToTrackpowers both file-load andloadLibrarySample(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; fullsrc/daw+src/components/dawsuite 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/studiohas three livecalliopeconsumers —calliope/musereference-track-analyzer usesaiFeatures.classifyDrumHits/detectDrumPattern;calliope/bridgeaudio-tools-bridge uses@euterpe/studio/daw-enginecreateStream/pushToStream/getStreamStatus/stopStream;calliope/bridgevirtuoso-migration-bridge uses@euterpe/studio/project-managementmigration 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) andstudio/{timeline, midi-editing}(→ shipped DAW view-state + live editing). Barrels updated. KEPTstudio/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-graphnodes; state/control models → the TS reducer):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. Readapps/euterpe/studio-web/src/daw/routing-matrix.tsandconsole-routing.tsfirst: 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, adsp-graphtest 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 (arbitraryReturnBus+ per-trackConsoleSendpre/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-invariantdefaultConsoleModelthat 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-graphgroup buses generalized from flat 4→master to subgroup→group→master (per-bus output target; topological Kahn summing order; gains compound;set_group_bus_outputrejects 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+setGroupBusOutputaction (reuses incr-1wouldCreateFeedbackLoopso 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:
BusPanelgains a "→ Master / → Bus N" output selector per group bus, offering only loop-safe destinations (filtered via incr-1wouldCreateFeedbackLoop), dispatchingsetGroupBusOutput. 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.tsextended (MonitorPolarity+applyMonitorrewrite, RT-safe single-gain collapse) → workletengine-processor.template.jsmirrors it inline (this.monitorstate +setMonitorcase) →messages.ts setMonitor(+dimDb/polarity/levelDb/mute, re-exportsMonitorPolarity) →audio-engine.tsfacade (options object). App side promoted from transientuseStateindaw-app.tsxto reducer/session-backed (persisted + undoable + rebuilt):types.tsControlRoomState+setControlRoomaction;daw-session.tsDEFAULT_CONTROL_ROOM/controlRoomCommand/clampControlRoom(level∈[−60,12] dB, dim∈[−60,0] dB) + reducer case emitting the full monitor command;session-rebuild.tsreplays only when engaged (no-op default);project-io.tspersist + restore (back-compat default-merge for older files). UI:transport-bar.tsxcontrol-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 asolo_busthat 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_offlinesets anofflineflag that forces SIP so the listen bus never leaks into a render or a per-track stem isolation. Wired end-to-end:track.rsSoloTapenum +process_solo/process_inner(the 9-argprocessis unchanged → no test churn) →engine.rsSoloMode(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→ workletsetSoloMode→messages.ts→ reducersoloModefield +setSoloModeaction (SOLO_MODE_CODEmap) + 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: acue_send_level+set_cue_send+ aprocess_cuethat tapspre × cueinto cue slices (threaded throughprocess_inner/process_slicewith the empty-slice guard →process/process_solopass 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 throughprocess_cueand the cue bus replaces the speaker feed after the master chain (forced off during a bounce, like solo-listen).dsp-wasmsetters → worklet →messages.ts2 commands. App:TrackState.cueSend+cueMonitorsession flag,setTrackCueSend/setCueMonitorreducers, 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, commitaux returns with per-bus insert chains — engine + wasm): per-trackaux_send/aux_send_pre(track.rs) threaded via a flataux_l/aux_raccumulator (bus k at[k·n,(k+1)·n)) through a newprocess_auxvariant + an aux pair onprocess_inner/process_slice(empty slices on the common path → skipped); engineaux_l/r+aux_gain+ two parallel mono insert chains per bus (L/R, like the reverb return's twoReverbs — the engine is mono-per-channel) + anaux_activegate (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_tracksdrops the chains (rebuild replays) +render_offlineresets their DSP state. dsp-wasmset_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, commitaux 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 engineset_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).
- Increment 1 DONE 2026-06-09 — pure routing model:
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.tsgenerateDrumGroove(style, opts)— genre-aware drum-groove generation (real drumming knowledge, NOT a generic euclidean rhythm) emitting the engine'snumber[][]step→GM-pitch format so it drops straight into a step track vialoadPattern. 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 dispatchesloadPattern. 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-bitapps/euterpe-studio-web/src/daw/ midi2.ts— the MIDI Association's official Min-Center-Max scaling (M2-104-UM):scaleUp/scaleDown+ thecc7to32/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 mapssurface-map.ts— a parameterizedControlSurfaceMap+ genericdecodeSurfaceturning grid controllers' user-assignable MIDI into the existingControlActionset (relative encoders two's-complement/sign-bit, pitch-bend faders, note mute/solo, transport CC/note), with presetspush2(Ableton's documented CC85 transport + CC71–78 encoders),mpc,maschine(user-mode templates) +generic. Wired live:daw-appcontrol-surface handler routes MIDI throughdecodeSurface(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.)
- Drummer Session-Player DONE 2026-06-09 (pure-TS vertical):
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-coreSamplerset_granular→ dsp-wasmset_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.rsadds op3/op4 (ratio + index + phase) +set_fm_ops; each op's instantaneous phase is offset by the modulator above it, and*_index == 0drops that operator — so with op3/op4 off the voice is bit-identical to the prior 2-op FM (the no-op invariant, assertedassert_eq!). Wired via the granular-setter pattern:dsp-wasm set_synth_fm_ops→ worklet (in theconfigureSynthcase, next toset_synth_fm) →messages.ts configureSynth(+fmOp3/4Ratio/Index) →SynthPatchfields + 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.rsFmCore— six sine operators (each ratio or fixed-Hz + output level + its own ADSR) wired through the canonical 32 DX7 algorithms (transcribed verbatim from the MSFA/Dexedfm_core.ccbyte table + ported bus model: per-op input/output mod-bus,OUT_BUS_ADDmerge-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 onSynthVoice(NOT a newSourcevariant): 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_fm6bulk setter → worklet (folded into theconfigureSynthcase) →messages.tsfm6*fields →SynthPatch.fm6+DEFAULT_FM6_PATCH→ reducer (configureSynthmerge) +session-rebuildreplay +project-iopersistence (free via passthrough; the command is byte-identical when a patch has nofm6) → aFm6Editorin 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).
- 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
- Granular synth was ALREADY wired (sampler-panel: enable + grain
size/density/position/spray;
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-graphPhaserNode→ dsp-wasmadd_phaser/set_phaser_params→ worklet → EngineCommandaddPhaser/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), solow+band=xexactly → 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-wasmadd_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 sox − highpassinterferes — 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-wasmadd_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.rsCompressorgains aCompCircuitvoicing (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: CompressorNodeset_param("circuit")→ dsp-wasmset_compressor_circuit→ worklet → EngineCommandsetCompressorCircuit→ reducer (circuitparam 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
MultibandCompressorprimitive (master-bus 3-band LR4 split) is now a per-track insert.effect.rsMultibandNodewraps 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-wasmadd_multiband/set_multiband_params(downcast like dynamiceq) → workletaddMultiband/setMultibandParams→messages.ts2 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+ awiden()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_width→dsp-wasm set_track_width→ worklettrackWidth→messages.ts→TrackState.width+setTrackWidthreducer (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. (commitper-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.
- Phaser DONE 2026-06-09 — full vertical:
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 thinning —
automation-lane-helpers.tssimplifyLane(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/logvalueToNorm, sotoleranceis 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 palette —automation-shapes.tsgainstriangle,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 — noMath.random). The lane's shape row auto-renders them (maps overAUTOMATION_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.tsgainsMidiCurve(linear/exponential/logarithmic/inverted) +applyMidiCurve(reshapes the 0..127 CC value: exp eases in x², log opens fast √x, inverted flips the knob) applied inmidiCcToActionbefore the parameter, acurve?onMidiMapping, and a newcutofftarget (ccCutoffToHzlog 20 Hz..20 kHz →configureSynth({cutoffHz})). UI: a per-mapping curve<select>+ the Filter-cutoff target inmidi-learn-panel.tsx;daw-appsetMidiMappingCurvehandler. 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
automation— ASSESSED 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 (isWritinggates 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.rscutoff,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.
- DP thinning + modulation palette DONE 2026-06-09 (pure-TS
vertical): Douglas-Peucker automation thinning —
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 indsp-graph, reusing the follower indsp-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-coreChannelStrip(asignon 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_invert→dsp-wasm set_track_polarity(viatrack_mut) → worklettrackPolarity→messages.ts→ reducerTrackState.polarityInvert+togglePolarityaction + 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-hygieneDSP — 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-alignmentfindAlignmentLag(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-matchmatchGainsRms(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 reductiondetectSilenceSegments(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 viaengine.renderTrackOffline→setTrackGain(gain-match) /setTrackDelaySamples(alignment) + a panel. 2026-09-18: open for an agent. The residue is 2b as written: wire the three primitives throughengine.renderTrackOfflinetosetTrackGainandsetTrackDelaySamples, 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 inworkspace.ts) drives threeuseDawEnginehandlers that render tracks offline (engine.renderTrackOffline→interleavedToMono) and apply the 2a DSP via existing reducer actions: Match levels (render all tracks →matchGainsRms→setTrackGainper track, adding the dB delta), Align (render two selected tracks →findAlignmentLag±50 ms →planAlignmentDelays→setTrackDelaySampleson the earlier one) and Reduce breaths (render track →detectSilenceSegments→breathReductionEnvelope→applyGainEnvelope→ commit as a "(de-breathed)" audio track + mute the source, the bounce pattern).planAlignmentDelaysis 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).
- 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
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-editorsparts-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 insrc/daw/, not lib imports; also sidesteps the lib'ssection-lyrics-editors.ts:1214readonly 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 eachSongSection(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.tssongStructure+nextSectionSeq+ 8 actions; reducer cases (pure view-state,commands:[], ids fromnextSectionSeq);project-io.tspersist+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 viaenergyCurveOf). 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 +markStructureSectionsreducer 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-derivedmarkSongSections("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
designTransitionmodel 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. ReducersetSongSectionTransition {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.tsanalyzeLyrics(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 pairwiseanalyzeRhyme+ repeated-end-word grouping), and the dominant metrical foot. Wiring: narrow oxc-safe@euterpe/lyrics/rhyme-metersubpath added totsconfig.base.jsonpaths + appvitest.config.tstest-alias + libpackage.jsonexport subpath + apppackage.jsondep (pnpm install clean no-op); sessionlyrics: string+setLyricsreducer + 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 ontoSongSection.vocals?(auto-prunes with the section, persists free);setSectionVocalsupdater; reducersetSongSectionVocalPreset+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).
- structured lyric/vocal-arrangement editor (delegate rhyme/meter to
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-domaindetectKeyinaudio-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 amatched − 1.1·missing − 0.55·extrascore (root must sound; correctly handles the 5th-omitted dominant 7th and rejects rootless/partial guesses; inversion- + octave-invariant).analyzeChordSegmentswindows a clip and merges held chords into one-per-change;analyzeClipHarmonyreturns {key, chords}. UI:harmony-readout.tsxHarmonyReadout(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.tsromanNumeral(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-rollHarmonyReadout("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.clipContentKeyis a stable content fingerprint (sorted notes + the placement fields that change what plays — ignores id/name/colour/ position), so identical-sounding clips share it.analyzeSongFormgroups 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.tsxSongFormBar(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): amarkSongSectionsreducer action derives navigation markers fromanalyzeSongForm("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 theSongFormBar. 1 reducer + 1 jsdom test; suite 1861 green. - Extract chords → chord track DONE 2026-06-09 (connects the harmony
detection to MIDI-9): a
detectChordTrackreducer action — the inverse ofapplyChordTrack— runsanalyzeChordSegmentson a track's note clip and replaces the chord track with the detected progression (per-beat detection, held chords merged, ids minted fromnextChordSeq). 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.tsgains 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.tsgenerateConditionalVariation— generates NEW material conditioned on the clip + the track's scale (distinct fromvaryClipNotes, 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). ReducerconditionalVaryTrack {trackId, mode}(readsnoteClip+scaleLock, grows length to fit the response). (b) Deeper NL phrasing —command-parser.tsgains a name-awareresolveTrackLoose("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.
- Symbolic chord/key on MIDI clips DONE 2026-06-09 (pure-TS vertical,
verified vs music-theory ground truth):
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 persistedGeneratorBinding(provider / prompt / drums / styleRef) so a generator track's configuration is part of the project (survives save/load + undo), not transient panel state.GENERATOR_PROVIDERSregistry (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+setGeneratorBindingreducer (patch-merge, view-state only, persisted free via the track spread);addGeneratorTrackseeds the default. UI:generator-panel.tsxpromoted from transientuseStateto 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 primitiveclipEffectiveNotes(the notes a clip actually plays on its local timeline once the trim offset +loopWithintiling are applied — the same placement math the preview renderer draws).reverseClipNotesmirrors each note in time (its end becomes its start; it's its own inverse);consolidateClipNotesbakes several same-lane clips into one spanning their union, repositioning each clip's effective notes by its song offset (offsetBeats 0 / loopWithin false result);repeatCountToFillis the tiling arithmetic. Reducer actionsreverseClip/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 standardClipStateso 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: afade_curve_shape(frac, curve)(equal-powersin(frac·π/2)/ linearfrac/ logfrac²;f(0)=0,f(1)=1for all) + afade_curvefield +set_fade_curve+fade_gaincalls it. No-op invariant: curve 0 = the priorsinexactly (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_arrangementgains aclip_fade_curve: &[u8]param → applied liketempo_mult; worklet passesclipFadeCurve;messages.ts(optional, back-compat). App:ClipState.fadeCurve,arrangementCommandemitsclipFadeCurve,setClipFadeCurvereducer (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-trackbinding model was already done — see item 9's earlier[x].) ✅ ITEM 9 COMPLETE.
- Generator-track binding model DONE 2026-06-09 (provider-agnostic +
honest seam):
- 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 withmoveCursor/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(reusesnotation.midiToPitch). -
piano-roll-grid.tsxPianoRollGrid— an ARIAtreegridwithrole=row/rowheader/gridcell,aria-activedescendantcursor,aria-selected+ per-cellaria-label, arrow-key navigation, Enter/Space toggle,[/]velocity nudge, Home/End, click-to-place, and a politerole=statuslive region announcing the cell/edit. Edits dispatch the samesetNoteClipas 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 sameLanePoint[]/LaneSpecthe visual lane edits. Wired intoautomation-lane.tsx: a focusablerole=applicationregion with arrow-key breakpoint navigation + value/time nudge (Shift = ×5 / time) + Enter add + Delete remove, a polite status live region, dispatching via the samecommit. 2 jsdom tests (ArrowDown nudges the value viasetVolumeAutomation; 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.
3.14 [~] PLAT-N4 — Tempo/timecode sync (Ableton Link / MTC / LTC) · P2 · effort M ✅ MTC + LTC DECODE DONE 2026-06-08 · ✅ JAM-SYNC CHASE-WHILE-PLAYING DONE 2026-06-09 (varispeed rate-lock + LTC-audio + Link remain)#
-
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:
MtcQuarterFrameAssemblerreassembles 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: a0= one full-period interval, a1= 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. -
timecodeDriftSamplesphase-sync (transport-vs-incoming drift for a chase controller).
- SMPTE model:
- Wired: a SMPTE timecode readout (30 fps) in the transport-bar
(
samplesToTimecode+formatTimecodeof 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.tschaseDecision(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 livetransport.playheadSamplesand dispatchingseekonchaseDecision().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 = falseflag + 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 typedPluginHostinterface (scan/instantiate) +webPluginHostwhose ops throwNotConfiguredError(code:'not_configured') instead of fabricating an empty success; aCapabilityStatus(reason + how-to-enable);isNotConfiguredhelper. insert-rack surfaces an honest "VST3/CLAP/AU hosting is desktop-build only" note. 4 unit + 1 jsdom test. (commitfeat(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
collabClientinterface 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
StemSeparatorinterface 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/provenancelib 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 throwsnot_configuredwithout a signing key. To finish: a real C2PA lib (c2pa-rswasm) + 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.rsDynamicEq— a peakingBiquadEQ whose gain is pulled down when the band's own level (isolated by a sidechain band-pass + a peak-tracking envelope follower) exceedsthreshold_db, byratio, bounded byrange_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-graphDynamicEqNode(AudioEffect +set_param);dsp-wasmadd_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'sadd_dynamiceqcuts 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
dynamicEqResponseis 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-structuredNewReleaseMessageserializer (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).validateUpcis the real GS1 GTIN-12 mod-10 check (a known code like036000291452passes, any flipped digit fails);validateGtinextends it to UPC-A or EAN-13 (the global barcode;4006381333931/5901234123457pass) and a UPC/EAN barcode field in the tags panel (redden-on-invalid like ISRC) feeds the ERN<ICPN>;formatDuration→ ISO-8601;validateIsrc/formatIsrcreused frommetadata-tags. The delivery (uploading the ERN + audio to a distributor) is the fail-loud seam:unconfiguredDistribution.deliver()throwsNotConfiguredError(reused fromplugin-host) rather than fabricating a "delivered" success;distributionCapability()is surfaced in the build's capability report (capabilities.tsgains thedistributionentry — 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; fullsrc/daw+src/components/dawsuite 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.
4.10 [ ] RIGHTS-7 / RIGHTS-8 / RIGHTS-10 / RIGHTS-15 — Blockchain rights registry, royalty AMM, GDPR console, copyright-similarity scan · P2/P3 · varies#
- 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)#
- ENG-3 PDC (2.1) — unlocks correct latency for FX-9s/spectral + is a clean no-op-invariant engine win. Do first.
- ENG-2 sub-block automation (2.2) — quality win, contained once the no-op invariant test is in place.
- Take comping + recording capture (3.3 + 3.4) — a coherent recording subsystem; high user value.
- MusicXML interop (3.1, pure half) and codec export (3.6) — standalone, testable, no engine risk.
- STFT engine → FX-9s + ENG-14 (2.5 + 2.3) — build the STFT framework once, two features fall out.
- MPE (3.2) and dockable panels (3.7) — large but high-value UX; budget a full session each.
- i18n (3.10) — mechanical breadth sweep, do in isolation.
- Surround/Atmos (2.7, ENG-N3) — XL channel-count refactor; sequence last among engine work.
- 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.