Status doc + build log. Date: 2026-06-05. Derived from the 6-agent gap analysis (AI/DAW/UX census × 2026 industry SOTA research).
The thesis#
Euterpe already has world-class backend/domain depth (multi-model routing, mastering DSP, music intelligence, voice) and a proven real-time generation edge (MRT2 wired + verified live). The gaps that separate it from a leading DAW are three structural gates plus a set of differentiators that win leadership. Fill the gates, then own the differentiators.
Hard rule (CLAUDE.md): zero stubs. Every DSP block, UI surface, and AI feature ships real, tested logic or an honest fail-closed seam. DSP is deterministic math → unit-tested against known-correct values. Commit + push (branch AND main) per slice.
Gate P0 — Real-time audio engine (unblocks everything)#
A WASM/Rust DSP engine on AudioWorklet: the thing Euterpe most lacks (today all
DSP is delegated). New lib libs/euterpe/audio-engine/ (Cargo workspace,
mirrors realtime-engine):
dsp-core(pure rlib, cargo-tested): oscillators (sine/saw/square/triangle, PolyBLEP anti-aliased), ADSR envelopes, biquad filters (RBJ cookbook: LP/HP/BP/notch/peak/shelf), a polyphonic synth voice, dynamics (compressor/ limiter with knee), delay line, FDN/Schroeder reverb, a parametric EQ, a mix bus + gain/pan, sample playback + linear/cubic resampling, a meter (peak/RMS/ LUFS-K). All unit-tested against known DSP behavior (−3 dB at cutoff, ADSR shape, sine RMS=0.707, limiter ceiling, etc.).dsp-graph: a real-time node-graph processor (block-based, lock-free param smoothing) that hosts the above as nodes with routing — the engine core.dsp-wasm(cdylib, wasm-bindgen): exposes the graph to JS; built towasm32-unknown-unknown(wasm-pack), staged for the browser.@euterpe/audio-engine(TS): an AudioWorklet processor that runs the WASM graph at the 128-sample quantum, plus a main-thread control API (createTrack, insertEffect, setParam, transport, record). SharedArrayBuffer ring buffers. Reuse@euterpe/core(notes/MIDI/audio-codec) +@euterpe/studio(graph model).- Real-time I/O: getUserMedia capture + AudioWorklet output; transport (play/stop/record/loop/click), punch in/out.
Verification: cargo test --workspace (DSP correctness), wasm build, an
AudioWorklet smoke (headless) producing non-silent audio, latency math.
Gate P1 — Render the actual DAW (over existing logic)#
The studio-web app is ~120 lines of JSX over ~4000 lines of unrendered logic.
Build real React surfaces + a dark-first design system (@euterpe/ui):
- Timeline (WebGPU/Canvas; the culling/LOD math already exists in shell-runtime) — tracks, clips, ruler, playhead, drag/zoom/snap.
- Mixer (channel strips, faders, meters, sends, mute/solo) over
studio/mixer. - Piano-roll / MIDI editor over
studio/midi-editing. - Effects rack + instrument browser over
studio/effects|instruments(driven by the P0 engine). - Project browser / transport / settings / audio-device picker.
- Wire the keyboard-shortcut, autosave, permission, PWA logic that already exists.
Verification: Playwright + next dev (render, interact, play audio through the
engine), per-surface.
Gate P2 — Table-stakes AI (mostly wiring existing logic)#
Per the research these earn parity, not leadership — but are mandatory:
- Stem separation — on-device (Demucs/ONNX) + cloud tier; wire the existing
samples/stem-separationinterface to a real model. - AI mastering — surface the real
master/mastering-chainDSP as an adjustable, stem-aware assistant (quick win; the DSP is already real). - AI mixing assistant — wire
master/mix-assistant(full-session balance, per-track smart EQ/comp, resonance suppression). - Transcription — wire
transcribe(stem-separate → audio-to-MIDI/chords). - Pitch/time — polyphonic editor over
voice/vocal-processing+ a real PSOLA/phase-vocoder indsp-core.
Gate P3 — Differentiators to OWN (where leadership is won)#
- AI Session Players (Logic's #1 differentiator; almost no one matches) —
elevate
accompany/backing-band+theoryto generative Drummer/Bass/Keys that follow a chord track in real time, output as editable MIDI tracks. - Conversational copilot + text-to-DAW-action — an MCP-style agent over the
studiodomain (read project state + act: create tracks, place MIDI, insert/parametrize FX, route) with contextual memory. Build onagents/. - Real-time jam as a first-class instrument — promote the verified MRT2
panel into a timeline
generator-track. - Inline AI UX — Ableton pattern (preview → Apply → undo → re-roll) + Suno pattern (section-scoped regen, take lanes, comping) across every AI action.
- Provenance UX + compliance — SynthID/C2PA labels; EU AI Act Art. 50 (Aug 2 2026) + CA SB 942 (Jan 1 2026). Time-boxed.
- Live collaboration + presence cursors — surface
collab/sync-engine; add Figma-style presence (the unclaimed industry frontier).
Gate P4 — Reach + polish#
Keyboard-first + screen-reader a11y (REAPER+OSARA bar), Tauri desktop (config exists, unwired), mobile, AAX/Pro-Tools plugin form factor.
Sequencing#
P0 → P1 in lockstep (engine + the surfaces that drive it), then P2 (wire existing AI), then P3 (differentiators), then P4. Each gate ships in tested, committed slices. Already-in-hand assets accelerate P2/P3 (mastering DSP, routing engine, MRT2, music intelligence, collab logic).
P2 integration map (recon 2026-06-05 — exact existing libs to wire)#
All pure-TS, real algorithms (no creds), operate on Float32Array buffers or note data:
@euterpe/master(libs/euterpe/master/src/) —runMasteringChain,analyzeTrack,meterLoudness(BS.1770),analyzeDynamicRange,analyzeStereoField,detectProblems,analyzeHeadroom,predictTranslation,generateReport,intelligentEqMatching,referenceTrackMatching,multibandCompression,finalLimiting. I/O =MasteringAudioBuffer {channels, data: Float32Array[], sampleRate, length}. → Mixing Assistant / Auto-Master: needs an OFFLINE RENDER of the session. Add a--target webwasm build (alongside no-modules) + a main-threadrenderOffline(secs)that runs the engine into a buffer, then feedanalyzeTrack/detectProblems/generateReport.@euterpe/theory(libs/euterpe/theory/src/) —harmony.*(romanNumeralAnalysis, detectIIVI, analyzeFunctionalHarmony, voice-leading),melody.*(contour, motif, retrograde/invert/augment, detectHooks),groove.*(analyzeSwing, analyzeMicroTiming, analyzePocket, classifyDrumPattern). I/O = chord symbols / MIDI note arrays / hit lists. → Chord-progression generator + groove/humanize for the step grid (note data — no offline render needed; cleanest next slice).@euterpe/core(libs/euterpe/core/src/) —scales/(100+ scales/modes/ragas),chords/(voicings, inversions, quality detect),keys/(key detection). → scale/key- aware step grid + chord tools (replace the local SCALES in pattern-gen.ts).@euterpe/transcribe(libs/euterpe/transcribe/src/) —detectPitch(autocorrelation),detectOnsets(spectral flux),generateMidiEvents,estimateTempo,quantizeToGrid. I/O = audio buffer →DetectedNote[]. → Audio→MIDI feature.@euterpe/restore(spectral subtraction / Wiener / de-click) → a "Clean Up" insert.@euterpe/acoustics(Bark/mel, masking, equal-loudness) → masking-aware Smart EQ.
Easiest first (note data, no new infra): theory chord-gen + core scales on the step grid. Marquee (needs offline-render infra): @euterpe/master Mixing Assistant on the master bus.
- P2.1 (first real AI/theory wiring) chord-progression generation via
@euterpe/core. chord-progressions.ts uses the shared library's
symbolToMidi(real chord-symbol → voiced MIDI: triads/sevenths/extensions/slash) — no duplicated theory; the DAW only arranges chords across the grid. 4 preset progressions (I–V–vi–IV, ii–V–I, etc.) + a generateChords reducer action + StepGrid progression buttons. Verified: 6 vitest tests pin the real theory (C→{C,E,G}, Cmaj7→adds B, Dm7 correct) + grid placement → 182 app tests; app+spec tsc clean (the full @euterpe/core barrel typechecks under the no-DOM config). NOTE: @euterpe/core resolved via vitesttest.alias(aresolve.aliasentry corrupts vite's config bundler — stale-cache symptom); declared as app dep + transpilePackage for the Next build (needspnpm installto link, like audio-engine-web). - P1.7/export offline render + WAV bounce. Engine
render_offline(frames)resets ALL DSP state (added reset to Adsr/SynthVoice/PolySynth + Source/Track.reset_state + master limiter/meters), plays the arrangement from 0 into fresh L/R buffers, and leaves the transport stopped at 0 — a deterministic mixdown. dsp-wasm render_offline returns interleaved stereo; the worklet bounces on a 'bounce' message and transfers the buffer back; AudioEngine.renderOffline/bounceWav + a pure-TS WAV encoder (16-bit PCM) + a "⤓ Bounce WAV" transport button (downloads 4 bars). Verified: deterministic-bounce + reset cargo test (60 cargo tests), a compiled-wasm Node render_offline test (deterministic energy, transport reset), WAV-header + clamp/scale tests → 8 audio-engine-web vitest; 182 app vitest; app+lib tsc clean. This unlocks the marquee @euterpe/master Mixing Assistant (analyze the bounced buffer). - P2.2 (marquee) Mixing Assistant via @euterpe/master. mix-analysis.ts deinterleaves a bounce into an AnalysisAudioBuffer and runs the library's REAL DSP (mixAnalysis.meterLoudness / analyzeDynamicRange / analyzeHeadroom / detectProblems) → a consolidated MixReport (LUFS, true peak, dynamic+loudness range, headroom, clip count, health score, problems w/ suggestions). useDawEngine.analyze() bounces 4 bars → analyzeBounce → report; a "✓ Analyze Mix" transport button + MixReportPanel render it. Verified: 3 vitest tests (−6 dBFS sine → plausible LUFS+peak; hot signal → low headroom; silence safe) → 185 app tests; app+spec tsc clean (full @euterpe/master barrel typechecks under no-DOM). @euterpe/master resolved via test.alias; declared as app dep + transpilePackage. The DAW now self-critiques its mix with real mastering DSP.
- P2.3 AI Master & Export via @euterpe/master
applyMasteringProfile. mastering.ts marshals an offline bounce through the library's genre mastering profile (real EQ curve + chain) for all 10 genres (pop/rock/hip-hop/electronic/jazz/classical/metal/ r&b/country/ambient) → mastered interleaved buffer. useDawEngine.masterExport(genre) bounces → masters → WAV download; the transport gets a genre - P2.4 scale mode (Ableton-style) via @euterpe/core scale library. scale-mode.ts uses findScaleByName → interval definitions to compute a root+scale's in-scale pitch classes (11 curated scales: Major/Aeolian/Dorian/…/pentatonics/blues). StepGrid gets root + scale
- P1.8 swing/groove. Pattern gains a
swingfield (0..0.9); the engine's advance_sequencer lengthens on-beat steps and delays off-beat steps by that fraction (per-pair timing) for a shuffle feel. Threaded through dsp-wasm set_track_pattern (swing arg) → worklet → setPattern command → reducer PatternState + a setSwing action → a StepGrid swing slider. Verified: a cargo test proves a 0.5-swing off-beat step is delayed vs straight (61 cargo tests); a setSwing reducer test (clamp + re-emit + no-op without a pattern) → 194 app tests; the compiled-wasm tests carry the new arg (8 audio-engine-web tests); app tsc clean. - P1.9 sampler / audio-clip tracks (the biggest remaining capability). dsp-core Sampler gains load(); the engine sequencer retriggers a Source::Sampler on hit steps (pitched by note−60, so a sampler track is a drum/melodic lane); Engine + dsp-wasm gain add_sampler_track / load_sample / trigger_sample / stop_sample / set_sampler_rate|loop|gain. The worklet handles addSampler/loadSample(transfer)/ trigger/loop/gain; AudioEngine exposes loadSample (decode→transfer) / triggerSample / setSamplerLoop. The reducer's "audio" track now creates a real sampler (addSampler, not addSilent) and pattern actions work on sampler tracks too; a SamplerPanel (file load → decodeAudioData → loadSample, trigger, loop) shows for audio tracks and the step grid sequences them. Verified: cargo tests (load→trigger→audio, sequenced hits fire) + a dsp-wasm native test → 62 cargo tests; app+lib tsc clean; 194 app + 8 wasm vitest. The DAW now has real audio tracks alongside synths.
Gate P3 — differentiators (in progress)#
-
P3.1 conversational command bar (text-to-DAW-action — the 2026 SOTA differentiator). command-parser.ts
parseCommand(text, session) → { actions: DawAction[], feedback }recognizes transport, add synth/audio, tempo, master gain, inserts (reverb/delay/ comp/eq/limiter on an explicit "track N" or the selection), mute/solo, pan (left/ right/center/number), euclid N, chord progressions (jazz/minor), swing N, gain (louder/quieter/dB), clear. A CommandBar UI feeds a line in and dispatches the result. Pure + fully tested: 12 vitest (every intent + track resolution + a round-trip proving parsed actions apply + gibberish → no-op). 206 app tests; app tsc clean. The rules are an LLM-swappable seam (same DawAction output). -
P2.5 audio→MIDI transcription via @euterpe/transcribe. transcribe.ts windows a mono buffer into N steps and runs the library's real autocorrelation pitch detector (estimateF0 → DetectedPitch.midiNote per window) → a monophonic pattern. A loadPattern reducer action sets a whole pattern (used by transcription); the SamplerPanel gets an "Audio → MIDI" file input (decode → transcribe → loadPattern on the selected track). Verified: 3 vitest on the real DSP (440/880 Hz both detected as pitch-class A — robust to autocorrelation's octave ambiguity; silence → empty; N steps returned) + a loadPattern reducer test → 210 app tests; app + spec tsc clean. Fixed a pre-existing swing-less PatternState literal the spec-config tsc surfaced. Five Euterpe-library AI integrations now live: core chords/scales, master analysis/mastering, transcribe pitch-detection.
-
P1.10 pattern transforms — the classic compositional ops on the step grid: reverse (retrograde), invert (mirror about the pattern center), transpose ±1, octave ±12. pattern-transforms.ts (pure, clamped, sorted) + a transformPattern reducer action + StepGrid buttons. Verified: 6 vitest on the transforms (reverse order, transpose+ clamp, octave, invert about center incl. single-note + empty no-op) + a reducer test (reverse/octaveUp re-emit, no-op without a pattern) → 217 app tests; app+spec tsc clean.
-
P3.2 song/timeline arrangement — pattern chains (the #1 structural gap). A track can hold a Vec
chain played bars_per_patternbars each (4/4); the sequencer selects the active slot by elapsed bars and releases held notes on slot change. Done ALLOCATION-FREE (no per-block pattern clone — the slot index is cheap and only the active step's notes clone on step change, as before), holding the RT-safety bar. Engine set/clear_track_chain + track_chain_index; dsp-wasm flat multi-pattern encoding; worklet setChain/clearChain (flattens number[][][]) + AudioEngine. setTrackChain/clearTrackChain. Verified: a cargo test (two 1-bar patterns switch at the bar boundary + wrap) → 63 cargo tests; a dsp-wasm native test (chain switches per bar across the flat encoding) → 7 dsp-wasm; lib tsc clean. Arrangement UI (pattern-bank view) deferred — engine/wasm/API complete + tested. -
P3.3 AI Session Player (research's #1 differentiator, cf. Logic Session Players). session-player.ts generateAccompaniment(symbols, numSteps, style) writes a musically-appropriate part from a chord progression using @euterpe/core's real voicings (symbolToMidi): 'bass' (roots on downbeats, low octave), 'arp' (chord tones cycled one-per-step), 'chords' (block voicings). A generateAccompaniment reducer action + StepGrid "♪ Bass"/"♪ Arp" buttons (over I–V–vi–IV). Verified: 4 vitest (bass roots+pitch classes, arp in-chord cycling, block chords, empty→empty) + a reducer test → 222 app tests; app+spec tsc clean. SIX Euterpe-lib AI integrations live: core chords/scales, master analysis/mastering, transcribe, session-player.
-
P3.4 content provenance / AI-disclosure (2026 compliance — EU AI Act Art.50 Aug 2 2026 + CA SB 942 Jan 1 2026). Patterns carry an
origin(manual/euclidean/chords/ accompaniment/transcribe; a hand-edit re-marks 'manual'); provenance.ts summarizeProvenance(session) → {aiAssisted, features[], label}. The WAV encoder gains an optional RIFF LIST/INFO chunk (ISFT + ICMT), embedded in bounce AND mastered exports with the provenance label; an "AI-assisted" transport badge. Verified: 4 provenance vitest (none/euclidean/multi-feature/edit-clears) + a WAV-metadata test (LIST/INFO/ICMT present; byte-identical without metadata) → 226 app + 9 wasm tests; app+spec+lib tsc clean. -
P3.5 MIDI export (interop). midi-export.ts encodeSmf(tracks, tempoBpm) writes a Standard MIDI File (format 1): MThd + a tempo MTrk + one MTrk per track-with-pattern (track-name meta, note on/off pairs with delta-times, gate = one step, 480 ticks/qtr). A "⤓ MIDI" transport button exports the arrangement (tracks with notes) as a .mid. Verified: 4 vitest parse the bytes (valid MThd format-1/division, tempo meta = 500000 µs/qtr @120, note-on/off pitch pairs, MThd + N+1 MTrk count) → 230 app tests; app tsc clean (TS5.7 Uint8Array→BlobPart cast). WAV + MIDI export both shipped.
-
P3.6 undo/redo (the universal DAW feature, + the inline-AI preview→undo foundation). Done CORRECTLY with engine replay so view-state and audio stay in sync: the DawController snapshots the (immutable) session before each mutating dispatch; undo/ redo restore a snapshot then reset the engine (new Engine.clear_tracks / dsp-wasm reset_engine / worklet 'reset' command) and replay rebuildCommands(session) — a pure reconstruction of the full command stream (tempo, master, tracks, synth patches, inserts incl. EQ bands, mix, patterns). Cmd/Ctrl+Z + ↶/↷ transport buttons. Verified: 16 dsp-graph cargo tests (clear_tracks → silence → rebuildable), 4 rebuildCommands vitest, 3 controller undo/redo vitest (restore+replay, empty no-op + redo invalidation, view-only not snapshotted) → 237 app + 9 wasm tests; app+spec+lib tsc clean. Documented limit: a sampler track's loaded buffer (engine-side binary, not in the session model) isn't replayed — re-load if needed; all modeled state restores.
-
P4.1 keyboard-first transport shortcuts (a11y — REAPER+OSARA-style). keyboard- shortcuts.ts transportShortcut(key, session) → DawAction: Space = play/stop, digits 1–9 = select track; deliberately avoids the A–K note row and the Cmd/Ctrl+Z undo binding. Wired into the DawApp window keydown (ignored while typing; via a sessionRef so it doesn't re-subscribe on meter ticks). Verified: 3 vitest (Space, digit select + out-of-range, no collision with note keys) → 240 app tests; app+spec tsc clean.
-
P4.2 metronome / click. The engine emits a post-master click on each beat (1.5 kHz accent on the 4/4 downbeat, 1 kHz otherwise, ~40 ms decay), beat-accurate within the block and only while playing. Engine set_metronome + dsp-wasm set_metronome + worklet 'metronome' command + AudioEngine.setMetronome + a ◷ transport toggle. Verified: a cargo test (silent when stopped, audible clicks while playing over a silent mix, goes silent when disabled) → 64 cargo tests; app+lib tsc clean, 240 app + 9 wasm tests.
-
P4.3 project save / load. project-io.ts serialize/deserializeProject (versioned JSON of the arrangement — tracks/patches/inserts/mix/patterns/tempo/master; transient meter/peak/held-notes/playhead stripped on save, reset on load). DawController. loadSession replaces the session, clears history, and reset+replays the engine via rebuildCommands. Save/Open buttons in the transport (download JSON / file input). Verified: 3 vitest (round-trip preserves the arrangement, transient runtime fields reset on load, malformed/wrong-version rejected) → 243 app tests; app+spec tsc clean.
-
P1/mix master-bus EQ. The engine applies a per-channel ParametricEq to the whole mix before the safety limiter (Engine.set_master_eq_band, reset on clear_tracks); dsp-wasm set_master_eq_band + worklet 'setMasterEqBand'. Modeled as mix state (session.masterEq) so it persists through undo/redo (rebuildCommands replays it) AND project save/load (project-io). A 3-band MasterEqPanel (low/mid/high). Verified: a cargo test (master low+high cut attenuates the mix, resets flat on clear_tracks) → 65 cargo tests; reducer
- rebuild + project-io round-trip vitest → 245 app + 9 wasm tests; app+spec+lib tsc clean.
-
P1/mix master-bus compressor — completes the EQ → comp → limiter master chain. A stereo-LINKED compressor (one gain from max(|L|,|R|)) via a new dsp-core Compressor.process_gain seam; Engine.set_master_comp (bypassed by default) applied between master EQ and limiter, reset on clear_tracks. dsp-wasm set_master_comp + worklet; modeled as session.masterComp (enabled/threshold/ratio/makeup) so it persists through undo/redo + project save/load; MasterEqPanel gains a comp section (toggle + threshold/ratio/makeup). Verified: a cargo test (enabled comp reduces the steady-state peak vs raw) → 66 cargo tests; reducer + rebuild (replayed when enabled, omitted when off) + project round-trip vitest → 247 app + 9 wasm tests; tsc clean.
-
P1/UX track rename + recolor. renameTrack / setTrackColor reducer actions (pure view-state, no engine commands, persist in save/load); ChannelStrip name is an inline editable input and the color dot cycles an 8-color palette. Verified: a reducer test (rename/recolor update view-state, emit no commands) → 248 app tests; app+spec tsc clean.
-
P1/mix master-comp gain-reduction meter. Engine.master_comp_gr_db (0 when bypassed) → dsp-wasm master_comp_gr_db → folded into the meter event (MeterSnapshot.masterCompGr) → a live "GR −x dB" readout in the master panel when the comp is on. app+lib tsc clean; 248 app + 9 wasm tests (66 cargo).
-
P1/seq per-step velocity (accents/dynamics — the most musically-impactful sequencer gap). Pattern gains a
step_velocities: Vec<f32>(empty/short → falls back to the pattern velocity) +velocity_at(step); the sequencer drives each synth note-on AND each sampler hit (set_gain) at its step velocity. dsp-wasm set_track_pattern takes a flatstep_velocities: &[f32]; the worklet forwardsstepVelocitiesas a Float32Array; messages.ts setPattern carries it. Modeled as PatternState.stepVelocities kept in lockstep with steps by the reducer (emptyPattern fills 1.0; generators/transcription resize via fitVelocities preserving extant accents; a setStepVelocity action edits one, clamped; retrograde reverses the lane with the steps, in-place transforms leave it aligned) → persists through undo/redo (session-rebuild) + save/load (project-io). StepGrid gains a vertical per-step velocity lane (disabled on rests). Verified: cargo per_step_velocity_scales_note_loudness (quarter-velocity step ≥2× quieter) → 67 cargo; a wasm binding test + a Node compiled-wasm test (per-step 0.25 ≥2× quieter across the boundary) → 10 engine-web vitest; 2 reducer tests (clamp/lane-sizing/no-op + retrograde travels accents) → 250 app tests; app+spec+lib tsc clean; worklet rebuilt. -
P3/seq per-step probability (generative trig conditions — Elektron / Ableton 12). Each step gains a 0..1 fire chance, re-rolled every loop; a skipped trig holds the previous note (a tie). Determinism preserved: each Track owns a SplitMix64-seeded xorshift64* PRNG (seeded by track index so probabilistic tracks decorrelate) that rewinds on reset_state, so render_offline bounces are reproducible; the PRNG is only consumed when prob ∈ (0,1), so all-1.0 patterns render bit-identically to before. Pattern gains step_probabilities + probability_at; dsp-wasm set_track_pattern takes a flat step_probabilities; worklet forwards stepProbabilities; messages.ts setPattern carries it. Reducer: PatternState.stepProbabilities (lane fitter generalized to fitUnitLane, shared with velocity), a setStepProbability action (clamped), all generators/transcode keep the lane sized, retrograde reverses it with the steps, persists via session-rebuild
- project-io. StepGrid gains a second vertical lane ("Prob"). Verified: cargo per_step_probability_gates_triggers_deterministically (p=0 silent, 0<half<full, render_offline reproducible) → 73 cargo; a wasm binding test + a Node compiled-wasm test → 11 engine-web vitest; 2 reducer tests (clamp/sizing/no-op + retrograde) → 252 app tests; app+spec+lib tsc clean; worklet rebuilt. Sequencer now does velocity + swing + probability per step — Elektron-class trig expressiveness.
-
P3/seq per-step gate length (note length — staccato↔legato per step), completing the velocity/probability/gate per-step triad. The synth note now releases mid-step once the gate (a fraction of the step) elapses; ≥1 holds to the next step (legato = today's behaviour). Engine: Pattern.step_gates + gate_at; Track tracks seq_gate_off_at (block- granular release, reset on stop/chain-change) — samplers are one-shots so gate is synth-only; the new_step refactor keeps swing/probability/chain intact (all-default patterns render identically). dsp-wasm set_track_pattern takes a flat step_gates; worklet forwards stepGates; messages.ts setPattern carries it. Reducer: PatternState. stepGates (fitUnitLane, fallback 1), a setStepGate action (clamped), all generators keep the lane sized, retrograde reverses it with the steps, persists via session-rebuild + project-io. StepGrid gains a third vertical lane ("Gate"). Verified: cargo per_step_gate_shortens_note_sustain (staccato ≥2× less energy than legato) → 76 cargo; a wasm binding test + a Node compiled-wasm test → 12 engine-web vitest; 2 reducer tests (clamp/sizing/no-op + all-three-lanes retrograde) → 254 app tests; app+spec+lib tsc clean; worklet rebuilt. Per-step expressiveness complete: how loud (velocity) · whether (probability) · how long (gate) · groove (swing) — modern step-sequencer parity.
-
P3/seq variable pattern length + polymeter. A setPatternLength reducer action resizes a pattern's grid (1..64 steps; grow pads empty steps + default lanes, shrink truncates, surviving indices keep their notes + all three per-step lanes via fitUnitLane). No engine change — each track already loops its own pattern length, so different lengths per track = real polymeter. StepGrid gains an 8/16/24/32 length selector. Persists via session-rebuild
- project-io (length is intrinsic to the steps array). Verified: 1 reducer test (grow-pads / shrink-truncates / surviving notes+velocity preserved / clamp 0→1 & 999→64 / same-length + no-pattern no-ops) → 255 app tests; app+spec tsc clean. App-only slice (no cargo/worklet rebuild). Sequencer no longer locked at 16 steps — odd meters + polymeter.
-
P3/seq adjustable resolution (beat subdivision) — the other half of grid control. A setStepsPerBeat reducer action (1..12) re-interprets the grid at a new rate; the engine's sequencer already keys timing off steps_per_beat, so re-emitting the pattern re-times playback (verified: patternCurrentStep at 3/beat @120 BPM → 8000 samples/step). StepGrid gains a ⅛ / ⅛T / 16th / 16T / 32nd selector — triplet feels (3, 6) now reachable. Persists via session-rebuild + project-io. Verified: 1 reducer test (rate change re-emits
- re-times, content preserved, clamp 0→1 & 99→12, same-value + no-pattern no-ops) → 256 app tests; app+spec tsc clean. App-only slice. Full grid control: length × resolution × per-step velocity/probability/gate × swing.
-
P3/seq per-step ratchet (retrigger rolls — the sequencer capstone, à la Elektron/Polyend). A step fires N evenly-spaced sub-hits across its duration (1=normal, up to 8). Engine: the Track schedules sub-hits in the same-step branch (seqratchet_n/done + cached onset/len/ notes/velocity, reset on stop) for both synth (note-off→note-on re-attack) and sampler (retrigger) — ratchet > 1 overrides the gate so they don't fight, and the step's notes are _moved into the cache (no extra audio-path allocation). Deterministic (no RNG) → bounces reproducible. dsp-wasm set_track_pattern takes a flat step_ratchets (u8); worklet forwards stepRatchets; messages.ts setPattern carries it. Reducer: PatternState.stepRatchets via a new integer fitRatchetLane (1..8), a setStepRatchet action, all generators keep the lane sized, retrograde reverses it, persists via rebuild + save/load. StepGrid gains a fourth vertical lane ("Ratchet"). Verified: cargo per_step_ratchet_retriggers_within_a_step (ratchet 4 ≥2.5× the trigger energy of 1; render_offline reproducible) → 78 cargo; a wasm binding test + a Node compiled-wasm test → 13 engine-web vitest; 2 reducer tests (clamp/round/sizing/no-op + all-four-lanes retrograde) → 257 app tests; app+spec+lib tsc clean; worklet rebuilt. Step sequencer is now Elektron-class: velocity · probability · gate · ratchet per step, plus swing, variable length & resolution.
-
P1/mix shared aux/reverb send bus (the first parallel-FX routing — beyond per-track inserts). The Engine gains a stereo send bus (two summed scratch buffers) + a shared Reverb (two mono Reverbs, medium-hall fixed); each Track taps a post-fader send into it, the wet returns to the master bus (so it still runs through master EQ/comp/limiter). RT- safe (buffers sized once, cleared per block; send only written when level>0) and deterministic (reverb reset in render_offline + clear_tracks). dsp-wasm set_track_send + worklet 'trackSend' + messages.ts trackSend command. Modeled as TrackState.sendLevel so it persists through undo/redo (session-rebuild emits trackSend when >0) AND save/load (project-io, with an old-file default). ChannelStrip gains a "Reverb Send" slider. Verified: cargo aux_send_adds_a_reverb_tail (send=0 → silent after decay; send=0.8 → reverb tail ≥100× louder) → 80 cargo; a wasm binding test + a Node compiled-wasm test → 14 engine-web vitest; 2 app tests (clamp/default + rebuild replays trackSend) → 258 app tests; app+spec+lib tsc clean; worklet rebuilt. Mixer now has parallel FX sends, not just inserts — multiple tracks share one cohesive reverb space.
-
P1/mix tunable send reverb — completes the send bus into a fully-controllable aux. Engine set_send_reverb(room, damp) tunes the shared reverb (reset to the default hall in clear_tracks so undo/replay stays deterministic). dsp-wasm set_send_reverb + worklet 'setSendReverb' + messages.ts setSendReverb command. Modeled as session.sendReverb {room, damp} (default {0.72, 0.4}); a setSendReverb action; rebuild replays it only when tuned (engine reset restores the default); project-io persists it (old-file default). MasterEq panel gains a "Reverb Send" Room + Damping section. Verified: cargo send_reverb_room_size_lengthens_the_tail (room 0.95 late-tail > room 0.2) → 81 cargo; a wasm binding test → 13 dsp-wasm; 2 app tests (clamp/default-emit + rebuild omits default / replays tuned) → 260 app tests; app+spec+lib tsc clean; worklet rebuilt. The shared reverb is now a real, tunable hall — per-track sends + global room/damp.
-
P1/mix sidechain compression (the EDM "pumping" duck — the most distinctly-SOTA mixer feature). The master compressor can DETECT on a chosen key track instead of the mix: each Track optionally taps its post-fader signal into a stereo KEY bus (it still sounds), and the master comp's detector reads the key bus when a key is set. Engine set_master_comp_key (manages per-track is_key flags; -1 = self-detect; reset in clear_tracks), key buffers cleared per block, RT-safe. dsp-wasm set_master_comp_key(i32 — i64 marshals as JS BigInt and breaks the worklet/Node call) + worklet 'setMasterCompKey' + messages.ts command. Modeled as MasterCompState.sidechainTrackId (preserved across comp-param edits); a setMasterCompKey action; rebuild replays it AFTER the tracks exist (the key references a track); project-io merges over the default so old files load. Master panel gains a "Sidechain key" track
-
P3/gen Euclidean rotation — the defining Euclidean control + a "re-roll the groove" loop. pattern-gen
rotateOnsets(onsets, rotation)rotates the Bjorklund onset pattern left (wraps; negative ok; full-loop = identity) before the arp is placed; generatePattern takes an optionalrotation(threaded via the generatePattern action). StepGrid remembers the last Euclidean fill and shows a "↻ Rotate" button that re-emits the same density at rotation+1, so 3/5/7 → rotate → rotate explores related grooves (each step undoable). Verified: 1 pattern-gen test (E(3,8) rotations x..x..x. → ..x..x.x / x..x.x.. , −1≡7, loop-identity, onset-count preserved) + 1 reducer test (rotated groove same density, shifted, full-loop returns base) → 264 app tests; app+spec tsc clean. App-only slice (pure generation; no engine/wasm change). The Euclidean tool now rotates — instant groove variations, the first taste of the inline "re-roll" workflow. -
P1/mix second (delay) FX send — completes the standard pro "two-send" mixer (reverb + delay). Mirrors the reverb send: Engine gains a stereo aux Delay (two mono Delays, fully wet mix=1, default dotted-eighth 0.375 s / 0.35 fb) + a delay send bus; each Track taps a post-fader delay send (Track::process now writes out/reverb-send/delay-send/key in one pass), wet returns through the master chain. Tunable via set_send_delay(time, feedback); RT-safe + deterministic (reset in render_offline/clear_tracks). dsp-wasm set_track_delay_send + set_send_delay + worklet 'trackDelaySend'/'setSendDelay' + messages.ts. Modeled as TrackState.delaySendLevel + session.sendDelay {timeSec, feedback} (defaults {0.375, 0.35}); setTrackDelaySend + setSendDelay actions; rebuild replays sends (>0) + tuned delay; project-io persists both (old-file defaults). ChannelStrip gains a "Delay Send" slider; master panel gains Time + Feedback. Verified: cargo aux_delay_send_returns_echoes_after_the_dry_note (send=0 silent after decay; send=0.9 → echo returns ≥50×) → 84 cargo; a wasm binding test + a Node compiled-wasm test → 16 engine-web vitest; 3 app tests (clamp/default send + clamp delay tune + rebuild replays send/tuned-delay) → 267 app tests; app+spec+lib tsc clean; worklet rebuilt. Mixer now has both classic FX sends — reverb space + delay throws, each tunable.
-
P0/synth noise oscillator (the 5th waveform — essential for hats/snares/risers/wind/FX). dsp-core Oscillator gains a
Waveform::Noisearm: pitch-independent xorshift32 white noise in [-1,1], with a per-oscillator RNG reset to a fixed seed inreset()so offline renders reproduce. waveform_from_u8 maps 4→Noise; the TS Waveform enum + synth-panel selector gain "Noise" (flows through the existing configureSynth path, persisted in the patch). Verified: cargo noise_is_broadband_bounded_zero_mean_and_reset_deterministic (in [-1,1], ~zero-mean, rms≈1/√3, ≫5000 zero-crossings/s, reset reproduces the exact stream) → 85 cargo; a wasm binding test (waveform 4 → sounding broadband voice) → 16 dsp-wasm; engine-web/app tsc clean; worklet rebuilt; 267 app tests unchanged. The synth can now make noise — the whole percussion/FX palette (filtered-noise hats, snares, sweeps) is in reach. -
P0/synth filter-type selection (LP/HP/BP) — pairs with the noise osc (noise+HP = hats, noise+BP = snares). The dsp-core SynthVoice's biquad was hardwired low-pass; it now holds a
filter_type(default LowPass) used in the control-rate coeff refresh, with setfilter_type. dsp-wasm set_synth_filter_type(track, u8: 0 LP/1 HP/2 BP); the worklet's configureSynth handler also applies m.filterType. Modeled as SynthPatch.filterType (FilterMode 0|1|2) in the configureSynth command; project-io defaults it for old patches. Synth panel gains an LP/HP/BP selector. Verified: cargo filter_type_changes_the_spectral balance (a low note through a 400 Hz LP keeps ≥1.5× the energy of HP) → 86 cargo; a wasm binding test (LP vs HP on a low note) → 17 dsp-wasm; 2 app tests (default LP + change mode emits it, other patch fields preserved) → 268 app tests; app+spec+lib tsc clean; worklet rebuilt. Subtractive synth is complete: 5 waveforms × LP/HP/BP filter × envelopes — real sound design (a noise+HP voice is a hi-hat). -
P1/fx distortion (waveshaper) insert — a fundamental creative effect the insert palette lacked. New dsp-core
Waveshaper:mix·tone(tanh(drive·x)) + (1-mix)·x, drive-normalized (÷tanh(drive)) so wet stays near unity, a one-pole "tone" LP, RT-safe; 3 cargo tests (mix=0 = exact bypass, drive saturates toward a square raising RMS at fixed peak, darker tone = less energy). dsp-graphWaveshaperNode(AudioEffect, name "distortion"); dsp-wasm add_distortion(drive, tone, mix); worklet 'addDistortion' + messages.ts. Reducer: InsertKind += 'distortion' with a buildInsert case (default drive 8/tone 0.7/mix 1) + a session-rebuild case; insert-rack gains a "Dist" add button. Verified: cargo waveshaper trio + a wasm binding test (a sine note through a drive-12 insert raises RMS ≥1.2× vs clean) → 94 cargo (49 dsp-core + 27 dsp-graph + 18 dsp-wasm); engine-web/app tsc clean; worklet rebuilt; 2 app tests (add-insert default params/command + rebuild replays it) → 269 app tests. Tracks can now be driven/saturated — the insert palette is EQ · comp · distortion · reverb · delay · limiter. -
P0/synth cutoff LFO (the synth's modulation source — movement/wobble for pads/basses). The SynthVoice gains a sine LFO (per-voice, deterministic phase accumulator advanced at the control rate) folded into the filter cutoff alongside the filter envelope: cutoff = base + env·level + depth·sin(lfo); set_lfo(rate_hz, depth_hz), depth 0 = off (existing patches unaffected), phase reset on voice reset (offline determinism). dsp-wasm set_synth_lfo; the worklet's configureSynth applies m.lfoRate/lfoDepth. Modeled as SynthPatch.lfoRate/lfoDepth in the command, project-io defaults for old patches; synth panel gains LFO Rate + Depth sliders. Verified: cargo cutoff_lfo_modulates_the_output_over_time (a deep LFO makes the windowed RMS swing ≥2× vs none) → 96 cargo (50 dsp-core); a wasm binding test (LFO wobbles the output) → 19 dsp-wasm; 1 app test extension (default-off + change emits rate/depth) → 269 app tests; app+spec+lib tsc clean; worklet rebuilt. Synth now has full modulation: amp env + filter env + cutoff LFO — wobble basses, evolving pads, sweeps.
-
P0/sampler reverse playback (reverse cymbals/hits — a staple). The dsp-core Sampler gains a
reversedflag: trigger starts from the tail andprocesswalks the read position backward (direction-aware bounds: forward ends at n, reverse below 0; both wrap when looping, else stop) — interpolation is position-based so it reads correctly either way. engine set_sampler_reversed → dsp-wasm → worklet 'samplerReversed' → AudioEngine .setSamplerReversed; the SamplerPanel gains a "Reverse" checkbox (imperative, like the existing Loop control). Verified: cargo reverse_plays_the_clip_backwards_and_stops (a ramp's forward output rises, reverse falls, reverse starts at the tail, one-shot stops at 0) → 98 cargo (51 dsp-core); a wasm binding test (reverse's first hit ≥4× the energy of forward's, tail-first) → 20 dsp-wasm; engine-web/app tsc clean; worklet rebuilt; 269 app tests. The sampler can now play clips backwards — reverse risers, swells, chopped hits. -
P1/fx bit-crusher (lo-fi) insert — distinct digital degradation alongside the smooth tanh distortion. New dsp-core
BitCrusher: bit-depth quantization (round to 2^bits amplitude levels) + sample-rate reduction (sample-and-hold every Nth input) + dry/wet mix, RT-safe; 3 cargo tests (mix=0 bypass, 2-bit → a handful of quantization levels vs 16-bit's full detail, downsample 4 → groups of 4 held samples). dsp-graph BitCrusherNode (name "bitcrusher"); dsp-wasm add_bitcrusher(bits, downsample, mix); worklet 'addBitcrusher' + messages.ts. Reducer: InsertKind += 'bitcrusher' (default 6-bit / ÷4 / wet) + buildInsert + session-rebuild cases; insert rack gains a "Crush" button. Verified: bitcrusher trio + a wasm binding test (a 2-bit crush cuts distinct sample values to <⅓ of clean) → 102 cargo (54 dsp-core + 27 dsp-graph + 21 dsp-wasm); 2 app tests (add-insert + rebuild) → 270 app tests; engine-web/app tsc clean; worklet rebuilt. Insert palette = EQ · comp · distortion · bitcrush · reverb · delay · limiter. -
P1/fx chorus (ensemble) insert — completes the modulation-FX family (movement/width). New dsp-core
Chorus: a short ~15 ms delayed copy whose tap is swept by a sine LFO (built on the interpolating DelayLine), mixed with dry → a moving comb that thickens; params rate (Hz) / depth (0..1 of a ~7 ms swing) / mix; RT-safe + deterministic. 3 cargo tests (mix=0 bypass, the wet tap alters the signal, the moving delay sweeps the comb so windowed RMS swings ≥2× a fixed delay's). dsp-graph ChorusNode; dsp-wasm add_chorus(rate, depth, mix); worklet 'addChorus' + messages.ts. Reducer: InsertKind += 'chorus' (default 0.8 Hz / 0.5 / 50% wet) + buildInsert + session-rebuild; insert rack gains a "Chorus" button. Verified: chorus trio + a wasm binding test (a swept chorus makes the windowed RMS move ≥2× vs dry) → 106 cargo (57 dsp-core + 27 dsp-graph + 22 dsp-wasm); 2 app tests (add-insert + rebuild) → 271 app tests; engine-web/app tsc clean; worklet rebuilt. Insert palette = EQ · comp · distortion · bitcrush · chorus · reverb · delay · limiter — a full creative FX rack. -
P3/copilot command-bar coverage for the new capabilities (ties the conversational seam to everything built this session). command-parser.ts now understands: the new creative inserts (distort/overdrive→distortion, bitcrush/crush/lofi→bitcrusher, chorus); aux SENDS ("reverb send 40", "delay send 30" → setTrackSend/setTrackDelaySend — handled before the insert loop so "reverb"/"delay" there don't add an insert); synth WAVEFORM (sine/saw/square/ triangle/noise → configureSynth); and synth FILTER mode (lowpass/highpass/bandpass + lpf/ hpf/bpf → configureSynth). All resolve an explicit "track N" or the selection. Verified: 3 new parser tests (creative inserts, sends-before-inserts, waveform+filter) → 15 command- parser tests, 274 app total; app+spec tsc clean. App-only slice. The text-to-DAW copilot now drives the whole new feature set — "distort track 2", "reverb send 50", "noise", "highpass".
-
P0/synth unison detune (the defining "fat" sound — supersaw / detuned bass). SynthVoice gains a second Oscillator detuned by
detune_cents; the two run at 0.5 gain each so 0 cents (phase-locked from note_on) sums back to a single oscillator (existing patches unchanged), while a spread drifts the second osc → beating/thickness. Both oscs reset on note_on/reset (offline determinism). dsp-wasm set_synth_detune; the worklet's configureSynth applies m.detuneCents. Modeled as SynthPatch.detuneCents in the command, project-io default for old patches; synth panel gains a "Detune" (cents) slider. Verified: cargo unison_detune_beats_while_unison_is_steady (30¢ → windowed RMS swings ≥2× the steady 0¢ unison) → 108 cargo (58 dsp-core); a wasm binding test (detune beats) → 23 dsp-wasm; 1 app test extension (default-unison + change emits detuneCents) → 274 app tests; engine-web/app tsc clean; worklet rebuilt. The synth is now genuinely fat: 2 detunable oscillators × 5 waveforms × LP/HP/BP filter × amp/filter envelopes × cutoff LFO. -
P1/workflow delete track (a fundamental gap — there was no way to remove a track). A deleteTrack reducer action filters the track out, RE-INDEXES the remaining tracks (ids match the engine's sequential creation order), remaps id-bearing references (selection + master-comp sidechain key: shift down past the hole, clear if it pointed at the deleted one), and — since the engine can't drop a single track — returns a full
rebuildCommands(next)(reset + replay), the same path as undo/redo (so it's undoable; the documented sampler-buffer-not-replayed limitation applies). ChannelStrip gains a ✕ delete button. Verified: 1 reducer test (delete-middle re-indexes A,C→0,1 + sidechain/selection follow; delete the keyed+selected track clears the key + reselects in range; reset+2× addSynth emitted; missing-id no-op) → 275 app tests; app+spec tsc clean. App-only slice. Tracks can now be deleted — the mixer is fully editable (add · rename · recolor · delete). -
P1/workflow duplicate track (layer/vary sounds — the complement to delete). A duplicateTrack action makes a deep, independent
structuredClonecopy (transient fields reset, name + " copy") spliced in right after the source, re-indexes by position, shifts id-bearing references (sidechain key) up past the insertion, selects the new copy, and returns a full rebuildCommands(next) (reset + replay, undoable — same path as delete). ChannelStrip gains a ⧉ duplicate button. Verified: 1 reducer test (A,B → A,"A copy",B re-indexed; copy carries the pattern but is a distinct object; sidechain key shifts up; reset+3× addSynth; missing-id no-op) → 276 app tests; app+spec tsc clean. App-only slice. Full track lifecycle: add · duplicate · rename · recolor · delete. -
P1/workflow reorder tracks (move up/down — organize the mixer). A moveTrack action swaps a track with its neighbor, re-indexes by position, swaps the two old-ids in id-bearing references (sidechain key), selects the moved track at its new slot, and returns rebuildCommands(next) (reset+replay, undoable). ChannelStrip header gains ▲▼ buttons. Verified: 1 reducer test (move B up → B,A,C re-indexed; sidechain key A 0→1 follows; top-up / bottom-down / missing-id all no-op) → 277 app tests; app+spec tsc clean. App-only slice. Track lifecycle complete: add · duplicate · rename · recolor · reorder · delete.
-
P0/synth portamento/glide (pitch slides — 303 acid lines, lead/bass slides). SynthVoice eases
freqtoward atarget_freqeach control block by a one-poleglide_coeff(time→coeff, 1 = instant). PolySynth tracks the last note's pitch and, when glide is on, triggers each new note viastart_glide(from=last_freq, note)so it slides in (works for sequenced + live; mono steals the voice for a clean single-pitch slide). Snaps + resets the target on noteon/reset/offline. dsp-wasm set_synth_glide; the worklet's configureSynth applies m.glideSec. Modeled as SynthPatch.glideSec in the command, project-io default; synth panel gains a "Glide" (ms) slider. Verified: cargo glide_slides_pitch_from_the previous_note (a mono synth's 2nd note A2→A3 measured low early ~115 Hz → high late ≥195 Hz) → 110 cargo (59 dsp-core); a wasm binding test (glide slides up) → 24 dsp-wasm; 1 app test extension (default-off + change emits glideSec) → 277 app tests; engine-web/app tsc clean; worklet rebuilt. The synth glides — portamento basses, acid slides, expressive leads. -
P1/transport panic — all-notes-off (stuck-note rescue, MIDI/keyboard hangs). Engine all_notes_off releases every live + sequenced note (PolySynth.all_notes_off gates every voice + clears the glide origin; samplers stop; stop_sequencer per track) — the transport keeps running, so a playing sequencer re-fires on the next step. dsp-wasm all_notes_off + worklet 'panic' + messages.ts; a
panicreducer action (command-only, view-state untouched) + a "⊘ Panic" transport button. Verified: cargo all_notes_off_panics_held_notes (a held synth note + looping sampler → after panic + release decay = silent) → 112 cargo (28 dsp-graph); a wasm binding test → 25 dsp-wasm; 1 app test extension (panic emits the command, leaves the transport playing) → 277 app tests; engine-web/app tsc clean; worklet rebuilt. One button kills every stuck note. -
P1/master adjustable limiter ceiling (the last hardcoded master-chain value — was fixed at -0.3 dBFS). Engine set_master_ceiling_db sets both limiters' ceiling (reset to -0.3 in clear_tracks for deterministic replay). dsp-wasm set_master_ceiling_db + worklet 'setMasterCeiling' + messages.ts. Modeled as session.masterCeilingDb (default -0.3, clamped -24..0); a setMasterCeiling action; rebuild replays only when non-default; project-io persists (old-file default). Master panel gains a "Limiter Ceiling" slider. Verified: cargo master_ceiling_is_adjustable (a -12 dB ceiling caps the driven output ≥2× lower than -0.3, each under its ceiling) → 113 cargo (29 dsp-graph); 2 app tests (clamp/default + rebuild omits default / replays tuned) → 279 app tests; engine-web/app tsc clean; worklet rebuilt. Master chain fully controllable: gain → EQ → comp(+sidechain) → adjustable-ceiling limiter, with the GR meter.
-
P1/workflow New Project (completes the New/Save/Open project trio — there was no way to start fresh). A newProject handler in the DAW shell calls controller.loadSession( createDawSession()) → resets every track + the mix/transport and rebuilds the engine empty. Transport bar gains a "New" button beside Save/Open. Verified: 1 controller test (after adding two tracks, the New path clears to 0 tracks + null selection + emits a reset) → 280 app tests; app+spec tsc clean. App-only slice (wiring the existing loadSession path). Project lifecycle complete: New · Save · Open (+ WAV/MIDI export, AI master).
-
P1/workflow tap tempo. A pure tap-tempo.ts: bpmFromTaps(timestamps) averages the recent inter-tap intervals → BPM (drops intervals after a >2 s gap, caps the run at 6, clamps 20–300), and pushTap(taps, now) appends a stamp / restarts after a long gap. The transport bar gains a "Tap" button feeding performance.now() (the time source is the test-double seam). Verified: 6 helper tests (500 ms→120 / 1 s→60 / 250 ms→240 BPM, uneven averaging, clamp, gap-drop, run restart/cap) → 286 app tests (21 files); app+spec tsc clean. App-only slice (pure helper + UI button). Tap a beat to set the tempo.
-
P0/synth sub-oscillator (osc2 octave offset — fat bass / octave layers, completing the dual-osc design). SynthVoice's osc2 gains a coarse
osc2_semitonesoffset folded into its pitch (freq2 = freq·2^(semitones/12 + detune/1200)); 0 = same octave (unchanged), -12 = a sub oscillator, +12 = an octave-up layer. dsp-wasm set_synth_osc2_semitones; the worklet's configureSynth applies m.osc2Semitones. Modeled as SynthPatch.osc2Semitones, project-io default; synth panel gains a Sub / 0 / +8ve selector. Verified: cargo osc2_semitones_layers_a_sub_or_octave (+12 adds high partials → more zero crossings; -12 materially changes the waveform) → 115 cargo (60 dsp-core); a wasm binding test (sub changes the sound) → 26 dsp-wasm; 1 app test extension (default-0 + change emits osc2Semitones) → 286 app tests; engine-web/app tsc clean; worklet rebuilt. The synth's two oscillators are fully independent (detune + octave) — supersaws, sub-bass, octave stacks. -
P1/sampler start offset (trim leading silence / chop a one-shot without re-decoding). The Sampler gains a
start_frac(0 = head, 1 = tail);trigger(0.0)— the sequencer + manual default — now begins at that offset, while an explicit positive sample position still overrides it (tests/scrubbing). Reverse honors the offset by trimming the tail. Engine set_sampler_start → dsp-wasm set_sampler_start → workletsamplerStart→ AudioEngine.setSamplerStart; the sampler panel gains a Start 0–100% slider (imperative, like loop/reverse — not session state). Verified: cargo start_frac_skips_into_the_clip (0.5 offset on a ramp starts mid-clip; explicit position overrides; reverse trims the tail) → 61 dsp-core; a wasm binding test (a clip silent for its first half → a 0.5 offset jumps into the loud half) → 27 dsp-wasm (117 cargo total); engine-web 16 + app 286 tests; tsc clean across both packages; worklet rebuilt. Every sampler hit can now skip into the clip — tight one-shots from sloppy recordings, beat-chops from a single sample. -
P2/noise-gate insert (downward expander — the missing table-stakes dynamics tool, after compressor/limiter). dsp-core NoiseGate: a peak-follower detector (fast attack, slow
env_reldecay) bridges a tone's zero crossings so the gate doesn't chatter; opens abovethreshold_db, attenuates byrange_db(≤0; -80 ≈ mute, -12 = gentle duck) below it, with a hold time and separate attack(open)/release(close) one-poles. dsp-graph GateNode (boxed AudioEffect, mono in-place); wasm add_gate(track, thr, range, atk, hold, rel). Modeled as InsertKind 'gate' → addGate EngineCommand → worklet add_gate; reducer + session-rebuild replay + command-parser (gate/noisegate/expander) + an insert-rack 'Gate' button. Verified: 3 cargo unit tests (passes loud above threshold at unity; attenuates a sub-threshold tone ~80 dB; opens on a transient then closes on the quiet tail) → 64 dsp-core; a wasm binding test (a low-velocity note collapses through the gate while a full-velocity note passes ≥80%) → 28 dsp-wasm (121 cargo total); app daw-session + session-rebuild + command-parser tests (287 app, 16 engine-web); fmt+clippy clean; worklet rebuilt. Tighten drums, kill mic bleed / amp hiss between hits — per-track, in the insert chain, real envelope-follower DSP. -
P2/transient-shaper insert (a.k.a. transient designer — independent attack/sustain shaping a threshold compressor can't do; modern punch tool). dsp-core TransientShaper: two level-followers track the rectified signal at different speeds — while the signal rises into a transient the fast follower leads the slow (
diff>0= attack region), while it decays the fast falls below (diff<0= sustain region);diffnormalised by the slow envelope makes detection level-independent, then two amounts (−1..+1) scale the gain. dsp-graph TransientNode → wasm add_transient(track, attack, sustain). InsertKind 'transient' → addTransient EngineCommand → worklet add_transient; reducer (default attack 0.5) + session-rebuild replay + command-parser (transient/punch/designer) + a 'Trans' insert-rack button. Verified: 3 cargo unit tests (attack +0.9 raises the onset peak; sustain −0.9 cuts decay-tail energy; neutral = bypass) → 67 dsp-core; a wasm binding test (attack-boost sharpens a sampler hit's onset ≥10%) → 29 dsp-wasm (125 cargo total); app daw-session + session-rebuild + command-parser tests (288 app, 16 engine-web); fmt+clippy clean; worklet rebuilt. Punchier or drier drums on demand — level-independent onset/body sculpting in the per-track insert chain. -
P3/live ARPEGGIATOR (a performance differentiator, not an effect — first new note-routing layer). New dsp-graph
arp.rsArp: captures held notes while armed and re-fires them as a tempo-synced single-note line on the track's synth — modes Up/Down/UpDown(no repeated endpoints)/Random, rate in steps/beat, 1–4 octave span, per-step gate (staccato↔legato). Block-granular off the transport playhead (like the step sequencer); a pure performance tool over the LIVE note stream, so it's inert during the offline render (held set is empty there) — zero determinism impact. Track gains arp-aware note_on/note_off (route to the arp when armed, else straight to the synth) + advance_arp in the engine loop; all_notes_off/reset clear it. wasm configure_arp + arp_note(track) getter. Modeled as TrackState.arp (ArpState — persisted), a configureArp reducer action + session-rebuild replay; a full synth-panel arp section (On toggle, mode buttons, rate ⅛–1/32, octaves, gate). ALSO fixed a pre-existing reload gap: session-rebuild's configureSynth now replays the extended voice params (filterType/lfo/detune/ osc2/glide), not just the base 8. Verified: 6 cargo arp unit tests (Up/Down/UpDown/octave sequences exact; advance() cycles 60→64→67→60 at the step rate; releasing all held silences it) → 35 dsp-graph; a wasm binding test (held triad arps ascending over a beat via arp_note) → 30 dsp-wasm (132 cargo total); 3 app tests (configureArp maps mode→int + merges; rebuild replays enabled-arp + skips disabled; rebuild replays synth extras) → 291 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Hold a chord, get a tempo-locked arpeggio — the first live note-transform layer, fully persisted and reload-safe. -
P3/MIDI file IMPORT (round-trips the existing export — drag a
.mid→ sequencer tracks). Newmidi-import.tsdecodeSmf: a full SMF parser (formats 0/1) — MThd division (rejects SMPTE), MTrk delta-times, running status, the channel-voice family (note on/off incl. vel-0-as-off, CC/bend/aftertouch skipped), meta events (tempo/track-name/end), SysEx skip; pairs note-ons↔offs FIFO into absolute-tick MidiNoteEvents.notesToStepsquantises onsets onto a steps/beat grid (nearest-grid);importMidities it together (tempo + per-track steps). Wired into the app: animportMidiFilecallback (file→ArrayBuffer→importMidi → setTempo + per-track addSynthTrack+loadPattern via the controller's atomic dispatchAll) + a '⤒ MIDI' file button in the transport bar. Verified: 5 vitest tests — round-trip through encodeSmf (steps+rests+tempo recovered), multi-track + chords, running-status parse (hand-built bytes), non-MIDI rejection, coarser-grid re-quantisation. 296 app tests; tsc clean (pure-TS slice, no engine touch). Bring arrangements in from any DAW — real SMF interop both ways. -
P1/transport LOOP region (loop a section while tweaking — table-stakes transport). The engine gains a loop stored in BEATS (loop_start_beats/loop_end_beats, not samples, so it stays musically anchored across tempo changes): at each block boundary, when playing + enabled, it converts beats→samples at the live tempo and wraps the playhead from the region end back to its start — the sequencer/arp (which derive their step from the playhead) re-fire the loop for free. set_loop(enabled, startBeat, endBeat) + loop_active(); wasm set_loop; worklet setLoop; AudioEngine.setLoop. Modeled as DawSession.loop (LoopState beats, persisted via project-io) + a setLoop reducer (partial-merge) + session-rebuild replay (when enabled); a transport-bar '↻ Loop' toggle + a 1/2/4/8-bar length select. Verified: a wasm binding test (loop [0,0.5]beat @120BPM → playhead wraps < 12000 samples after 200 blocks, free-runs once disabled) → 31 dsp-wasm (133 cargo total); 2 app tests (setLoop merges + emits; rebuild replays enabled/skips disabled) → 298 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Loop the first N bars and tweak — beat-locked so it survives tempo changes, fully persisted.
-
P3/live SCALE-LOCK (snap played notes into key — second note-routing transform, on the same Track seam as the arp; Ableton-12-style scale awareness). New dsp-graph
scale.rsScaleLock: holds an absolute 12-bit pitch-class mask (bit i = pitch class i in-scale);snap(note)passes in-scale notes through and otherwise searches outward (≤6 semitones, ties resolve down) to the nearest in-scale pitch. Track::note_on/note_off snap FIRST (deterministic → the pitch triggered is the one released), then route through the arp/synth — so scale-lock + arp compose. engine configure_scale_lock(track, enabled, mask) → wasm (mask as u32, &0xfff) → worklet → AudioEngine. Modeled as TrackState.scaleLock (ScaleLockState {enabled, root, scale}, persisted); the reducer derives the mask from the existingscalePitchClasses(root, scale)(reuses @euterpe/core scale defs) + session-rebuild replay; a synth-panel Scale-Lock toggle + root/scale selects. Verified: 4 cargo scale unit tests (in-scale passthrough; C#→C / F#→F / A#→A snaps; disabled/empty/chromatic passthrough) → 39 dsp-graph; a wasm binding test (a C# played under C-major-lock sounds at C's fundamental, audibly lower than the un-locked C#, via zero-crossing counts) → 32 dsp-wasm (138 cargo total); 2 app tests (mask = 2741 for C major + rotates for D; rebuild replays enabled/skips disabled) → 300 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Play anything, stay in key — composes with the arp for an in-scale arpeggiator. -
P2/master STEREO WIDTH (mid/side) — rounds out the master chain to gain→EQ→comp→width→limiter. In engine.process, after the master compressor and before the safety limiter, a mid/side fold scales the side signal: mid=(L+R)/2, side=(L−R)/2·width, L=mid+side, R=mid−side. width 1 = unchanged (a cheap identity skip), 0 = mono (mono-compatibility check), >1 = wider. set_master_width (clamped 0..2) + reset to 1 in clear_tracks; wasm set_master_width; worklet setMasterWidth; AudioEngine.setMasterWidth. Modeled as DawSession.masterWidth (persisted) + a setMasterWidth reducer (clamped) + session-rebuild replay (skips the default 1) + a 'Stereo Width' slider (mono…200%) in the master panel. Verified: a dsp-graph engine test (a hard-left note stays panned at width 1 — R≈0 — and collapses to L==R at width 0) → 40 dsp-graph (139 cargo total); 2 app tests (clamp + emit; rebuild replays non-default/skips 1) → 302 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Widen the mix or check mono-compatibility in one slider.
-
P3/conversational COMMAND-BAR expansion — extend the text→DAW-action parser to reach the recently-built surface so the whole DAW is text-addressable (the LLM-swappable copilot seam). Added branches: loop ("loop 4 bars" / "loop off" → setLoop in beats), stereo width ("width 150" / "mono", placed BEFORE the master-gain branch so "master width 150" means width not gain), arpeggiator ("arp up/down/updown/random" / "arp off" → configureArp), scale- lock ("scale c minor" / "key of d major" / "scale f# dorian" / "scale off" → configureScaleLock, parsing a root note + mapping spoken scale words to the library's SCALE_NAMES, longest-match). The root-note regex uses a negative lookahead
\b([a-g])(#|b|s)?(?![a-z])so a bare letter doesn't read the start of a longer word ("dorian" ≠ D root) while still catching "f#"/"eb". Pure-TS slice (no engine touch). Verified: 4 new command-parser tests (loop, width incl. the master-width-vs-gain precedence, arp modes, scale from spoken key) → 306 app tests; tsc clean. The conversational bar now drives transport/loop/master/synth/arp/scale/inserts/sends — one line of text per action. -
P1/per-insert BYPASS (universal A/B for every insert — closes the set-and-forget gap; before this, only EQ bands were editable after adding). Track gains a parallel
insert_bypass: Vec<bool>(pushed false on push_insert); the process loop skips a bypassed insert so the dry signal flows through unprocessed. set_insert_bypass(i, on); wasm set_insert_bypass(track, insert, bypassed) (via track_mut, like set_eq_band); worklet setInsertBypass; AudioEngine.setInsertBypass. Modeled as InsertState.bypassed (persisted) + a toggleInsertBypass reducer + session-rebuild replay (the bypass command is emitted AFTER the insert's add so the index exists) + an insert-rack on/byp chip per insert (dims + strikes through when bypassed). Verified: a dsp-graph engine test (a gutting EQ insert, bypassed → the dry level is restored ≥1.3×) → 41 dsp-graph (140 cargo total); 2 app tests (toggle flips + emits both states; rebuild replays bypass after the add) → 308 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. A/B any effect in the chain without deleting it — every insert, not just EQ. -
P1/editable COMPRESSOR insert (a compressor you can't tune is half-useless; until now only EQ bands were live-editable). wasm set_comp_params(track, insert, threshold/ratio/attack/release/ makeup) downcasts the chain node to CompressorNode → comp_mut() (the set_eq_band pattern); worklet setCompParams; AudioEngine.setCompParams. A generic setInsertParams reducer merges the params onto the InsertState and, for compressor kind, emits the live setCompParams (full set, defaults for untouched fields) — extensible to gate/transient later. Persistence is automatic: session-rebuild's addCompressor already reads insert.params, so a retuned comp replays correctly on reload/undo with no rebuild change. The insert rack gains Thresh/Ratio/Attack/Release sliders on compressor inserts (mirroring the EQ band editor). Verified: a wasm binding test (the same note + comp retuned high-threshold/low-ratio vs low-threshold/high-ratio → the latter clamps ≥30% quieter) → 33 dsp-wasm (141 cargo total); 1 app reducer test (params merge + full-set command) → 309 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Dial in the compressor after adding it — threshold/ratio/attack/release, live and persisted.
-
P1/editable GATE + TRANSIENT inserts (completes the editable-dynamics theme — all three dynamics processors are now tunable after adding; their core params were stuck at defaults). wasm set_gate_params (threshold/range/attack/hold/release) + set_transient_params (attack/sustain) downcast to GateNode.gate_mut() / TransientNode.shaper_mut() (same pattern as comp/EQ); worklet setGateParams/setTransientParams; AudioEngine wrappers. The generic setInsertParams reducer now emits the right live command per insert kind (comp/gate/transient); persistence stays automatic (session-rebuild's add* already read insert.params). Insert rack gains Thresh/Range/Attack/Release sliders on gate inserts and Attack/Sustain (−100..+100) on transient inserts. Verified: 2 wasm binding tests (gate retuned to a threshold below vs above a note → passes vs silences ≥4×; transient attack 0→0.9 → onset peak rises) → 35 dsp-wasm (143 cargo total); 1 app reducer test (gate + transient full-set commands) → 310 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Every dynamics insert is now fully dialable — gate threshold/range, transient attack/sustain.
-
P1/editable REMAINING inserts (distortion/bitcrusher/chorus/reverb/delay) — now ALL 9 non-EQ inserts (and EQ) are live-tunable after adding; the chain is fully editable. 5 wasm setters (set_distortion/bitcrusher/chorus/reverb/delay_params) downcast to the chain node (WaveshaperNode/BitCrusherNode/ChorusNode/ReverbNode/DelayNode via their *_mut() accessors, ReverbNode also has set_mix); 5 worklet handlers + AudioEngine wrappers + messages. The setInsertParams reducer gained the 5 cases (full param sets, defaults for untouched). The insert rack's per-insert editors were refactored to a single data-driven PARAM_CONFIG (kind → slider defs with label/min/max/step/def/fmt), replacing the three hand-written comp/gate/transient blocks and covering all 8 editable non-EQ kinds uniformly. Verified: a wasm binding test (distortion drive 1→30 retune → RMS rises as it saturates) → 36 dsp-wasm (144 cargo total); 1 app reducer test (all 5 emit the right full-set command) → 311 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. The entire insert chain is now editable + persisted + bypassable — a complete per-track effects rack.
-
P1/insert REORDER (effect order matters — comp→dist ≠ dist→comp; inserts were append-only). Track::swap_inserts(a,b) swaps the boxed nodes AND their bypass flags (state moves with them, so a reorder doesn't reset reverb tails); wasm move_insert(track, from, to); worklet moveInsert; AudioEngine.moveInsert. A moveInsert reducer ('up'/'down') reorders InsertState[] and RENUMBERS ids to the new chain positions (ids === engine chain index, so setInsertParams/setEqBand/bypass stay correct) + emits a single swap command (NO full rebuild → no DSP reset). Persistence is automatic: session-rebuild replays inserts in array order with the renumbered ids. Insert rack gains ▲▼ buttons per chip (disabled at the ends). Verified: a dsp-graph test (distortion→high-cut vs swapped — nonlinear, so the orders don't commute → outputs differ >5%) → 42 dsp-graph (145 cargo total); 1 app reducer test (reorder + renumber + emit + no-op at the end) → 312 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Drag the comp before or after the distortion — reorder the chain live, state-preserving and persisted.
-
P3/VOLUME AUTOMATION playback (the first piece of the automation system — a real new capability, the engine reads gain breakpoints synced to the transport). Track gains
static_gain_db(the fader value when no automation is active) +volume_automation: Vec<(beat, dB)>; process() drives the strip gain per-block from the playhead beat via a linear interpolation between breakpoints (clamped to the ends), reverting to the static fader when stopped or cleared. set_volume_automation (sorts + restores static on clear); engine set_track_volume_automation (parallel beat/dB arrays); wasm + worklet (Float32Array) + AudioEngine.setVolumeAutomation. Modeled as TrackState.volumeAutomation (AutomationPoint[], persisted) + a setVolumeAutomation reducer (sort + clamp; empty clears) + session-rebuild replay; channel-strip Fade-In/Fade-Out (4-bar) + Clear-Auto buttons. Verified: a wasm binding test (a 4-beat −60→0 dB fade-in → the late window is ≥8× louder than the early window) → 37 dsp-wasm (146 cargo total); 2 app tests (sort+clamp+emit+clear; rebuild replays/skips empty) → 314 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Tracks can fade in/out over the arrangement — automation breakpoints played back from the transport, the foundation for fuller parameter automation. -
P3/PAN AUTOMATION (completes strip automation — auto-pan / stereo movement over the arrangement). Refactored the volume-automation interpolation into a free
interp_automation(points, beat, fallback)(linear, clamped) reused by both lanes. Track gainsstatic_pan+pan_automation; process() drives the strip pan per-block from the playhead beat (reverts to static when stopped/ cleared). engine set_track_pan_automation + wasm + worklet (Float32Array) + AudioEngine. Modeled as TrackState.panAutomation (PanPoint[], persisted) + a setPanAutomation reducer (sort + clamp −1..1) + session-rebuild replay; a channel-strip ⇄ Pan toggle (sets a 4-bar L→R sweep, clears when active). Verified: a wasm binding test (a 4-beat −1→1 auto-pan → L≫R early, R≫L late, ≥4× each) → 38 dsp-wasm (147 cargo total); 2 app tests (sort+clamp+emit; rebuild replay) → 316 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Tracks can fade AND sweep across the stereo field over time — both strip parameters automate from the transport. -
P3/FILTER-CUTOFF AUTOMATION (the iconic filter sweep — synth tracks). SynthVoice gains set_base_cutoff(hz) (updates just the base cutoff, leaving the filter env/LFO modulation intact). Track gains static_cutoff_hz (mirrored from configure_synth, so it can be restored) + cutoff_automation Vec<(beat,Hz)>; process() applies the interpolated cutoff to every voice per-block via source.configure (reverting to static when stopped/cleared). wasm configure_synth now also calls t.set_static_cutoff(cutoff_hz); engine set_track_cutoff_automation + wasm/worklet (Float32Array)/AudioEngine. Modeled as TrackState.cutoffAutomation (CutoffPoint[], synth-only, persisted) + a setCutoffAutomation reducer (sort + clamp 20–20kHz, ignores non-synth) + session-rebuild replay; synth-panel ⟋ Sweep Up / ⟍ Down filter-automation buttons. Verified: a wasm binding test (a 50Hz→18kHz sweep on a 523Hz saw → the late/open window is ≥2× the dark early window) → 39 dsp-wasm (148 cargo total); 2 app tests (synth-only + clamp + emit; rebuild replay) → 318 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. The classic filter sweep is automatable — volume, pan, AND cutoff now play back from the transport.
-
P2/STEM EXPORT (render each track isolated → one WAV per track; a real collaboration/mixing deliverable). engine render_track_offline(track, frames) temporarily solos the track and reuses the offline-render path (full master chain), restoring the prior solo state after; wasm render_track_offline (interleaved) + worklet 'bounce' gained an optional
trackfield → render_track_offline; AudioEngine.renderTrackOffline (a bounce request carryingtrack). A daw-app exportStems callback loops the tracks, renders each, encodes a WAV, and downloads 'euterpe-stem-N-name.wav'; a transport-bar '⤓ Stems' button. Verified: a wasm binding test (two sequenced tracks at different pitches → each stem non-silent + the two differ; a pattern-less track's stem is silent; the full mix is louder than one stem; solo state restored) → 40 dsp-wasm (149 cargo total); 318 app, 16 engine-web; tsc clean; worklet rebuilt. NB: stems come from PATTERNS (offline render resets live notes for determinism). Export every track as its own WAV — bring the arrangement into another DAW or hand off stems. -
P3/velocity HUMANIZE (loosen mechanical patterns with seeded accent variation — a distinct sequencer transform, not a note transform). New pure
humanizeVelocities(velocities, amount, seed)in pattern-transforms.ts: a mulberry32-seeded ±amount jitter kept in [0.05, 1] — the controlled randomness IS the feature and it's deterministic (reproducible), not Math.random faking a result. A humanizePattern reducer seeds from the current velocities (so re-applying varies while staying deterministic per pattern) + emits the pattern; a step-grid ✲ Humanize button + a command-bar "humanize 30" branch. Pure-TS (no engine touch — velocities already flow through setPattern). Verified: 3 helper tests (amount-0 no-op; same-seed determinism + range + variation + different-seed differs; centered mean) + 1 reducer test (changes + in-range + command + determinism + amount-0 no-op) + 1 command-parser test → 323 app tests; tsc clean. One click loosens a robotic groove — seeded, reproducible velocity humanization. -
P3/SEND AUTOMATION (reverb + delay sends over time — automated throws/swells; completes automation across every track mix parameter: gain · pan · cutoff · reverb-send · delay-send). Track gains static_send/send_automation + static_delay_send/delay_send_automation (set_send/ set_delay_send now mirror the static + only apply when no automation); process() drives both send levels per-block from the playhead via interp_automation. engine + wasm set_track_send_automation / set_track_delay_send_automation; worklet (Float32Array) + AudioEngine. Modeled as TrackState.sendAutomation/delaySendAutomation (SendPoint[], persisted) + a combined setSendAutomation/setDelaySendAutomation reducer (shared case, sort + clamp 0..1) + session-rebuild replay; channel-strip ↗ Verb / ↗ Delay swell toggles (0→100% over 4 bars). Verified: a wasm binding test (a sustained held note with the reverb send swelling 0→1 → the late wet window is louder than the dry early window) → 41 dsp-wasm (150 cargo total); 2 app tests (both lanes clamp+emit+independence; rebuild replays both) → 325 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Automated reverb/delay throws — every track mix parameter now automates from the transport.
-
P3/MASTER-GAIN AUTOMATION (the whole-mix build-up / drop — the iconic electronic-music move; automation now spans the master bus too, not just tracks).
interp_automationmade pub(crate) so engine.rs reuses the same curve engine. Engine gains static_master_gain_db + master_gain_automation; the master process loop drives master_gain per-block from the playhead beat (reverting to the static fader when stopped/cleared); set_master_gain_db mirrors the static; clear_tracks resets it. wasm set_master_gain_automation + worklet (Float32Array) + AudioEngine. Modeled as DawSession.masterGainAutomation (AutomationPoint[], persisted via project-io) + a setMasterGainAutomation reducer (sort + clamp) + session-rebuild replay; master-panel ⟋ Build / ⟍ Drop / ✕ clear buttons. Verified: a wasm binding test (two tracks, master −60→0 dB over 4 beats → the late window is ≥8× the early) → 42 dsp-wasm (151 cargo total); 2 app tests (sort+clamp+emit+clear; rebuild replay) → 327 app, 16 engine-web; fmt+clippy clean; worklet rebuilt. Automation is now complete across the whole console — 6 lanes: track volume/pan/ cutoff/reverb-send/delay-send + master gain. The classic build/drop is one click. -
P1/PIANO-ROLL engine — note CLIP playback (variable-length timed notes on a looping beat timeline; the model behind a piano-roll, vs the step grid). New dsp-graph
clip.rsNoteClip:ClipNote{start_beat,length_beats,pitch,velocity}+ a loop length; playback by STATE RECONCILIATION — each block compute which pitches should sound at the current (looped) beat and diff against what's sounding, firing note on/off to match (robust under block-granular timing + looping; a pure function of the playhead → replays deterministically in the offline render, so clips are part of the arrangement). Track holds an Option+ set/clear/advance methods; the engine loop calls advance_note_clip alongside the sequencer + arp; stop_sequencer releases clip notes. engine + wasm set_track_note_clip (parallel start/length/pitch/velocity arrays + loop length) + clear_track_note_clip; worklet (Float32Array/Uint8Array) + AudioEngine.setNoteClip/ clearNoteClip. Verified: 4 cargo clip unit tests (fires a note inside its span + releases past its end; longer notes sustain more beats; empty = silent; release_all silences) → 46 dsp-graph; a wasm binding test (a 2-note 4-beat clip renders non-silent + deterministic offline; clearing silences) → 43 dsp-wasm (151 cargo total); 16 engine-web; fmt+clippy clean; worklet rebuilt. The engine now plays real piano-roll clips — variable note lengths + sub-step timing, looped and deterministic. App model + reducer + the canvas/SVG roll component follow. -
P1/PIANO-ROLL model + reducer — TrackState.noteClip (NoteClipState {notes: ClipNote[], lengthBeats}, synth-only, persisted via project-io's TrackState spread). A setNoteClip reducer sanitizes (start≥0, length≥0.05, pitch round+clamp 0–127, velocity clamp) + sorts by start, then emits the engine setNoteClip command (parallel start/length/pitch/velocity arrays + loop length); an empty note list → clearNoteClip + drops the clip. A clearNoteClip action too. session-rebuild replays the clip. Verified: 1 reducer test (sanitize + sort + emit; empty clears) + 1 rebuild test → 330 app tests; tsc clean. The piano-roll clip is fully modeled + persisted; the editing component (the visual roll) is next.
-
P1/PIANO-ROLL component (the visual editor — closes the biggest DAW gap). Pure edit/layout helpers in
piano-roll-helpers.ts(noteIndexAt over the half-open span, toggleNote add/remove, removeNoteAt, setAllVelocities, transposeClip — all immutable, 5 unit tests). An SVGPianoRollcomponent: a 3-octave (C2–C5) pitch×beat grid with black/white-key row shading, bar/beat grid lines, and the clip's notes drawn as accent-coloured bars (opacity = velocity); click a cell to add a note of the selected length (¼/½/1/2 beats, ¼-beat snap) or remove the note covering it; bar-length (1/2/4) + Clear controls. Wired into daw-app under the step grid for synth tracks; dispatches setNoteClip/clearNoteClip (the reducer + engine clip do the rest). Note math is fully unit-tested; the SVG renders via the DOM (no canvas, no browser run needed — the in-browser pixel pass stays deferred for the Mac-freeze hazard but the component type-checks- mounts). Verified: 5 helper tests → 335 app tests; tsc clean. The piano-roll is real: author variable-length notes on a looping timeline, played back by the engine's deterministic note-clip scheduler. The DAW's single biggest gap is closed.
-
P1/MIDI RECORDING (play → record → edit — completes the piano-roll workflow). New pure
clip-recorder.tsClipRecorder: captures note on/off tagged with their transport beat into ClipNotes (closing still-held notes at the end beat), withquantizeClip/quantizeBeatgrid-snapping — deterministic, no audio/DOM needed. Wired into daw-app: a Record toggle creates a recorder, seeks to 0 + plays, and routes the keyboard through arecordDispatchthat feeds note on/off to the recorder at the live playhead beat (computed from playheadSamples·tempo/ (60·sampleRate)); stopping calls finish(endBeat, 1/16-quantize) and dispatches setNoteClip to drop the captured performance into the piano roll. A transport-bar '● Rec' button (needs a selected synth track). Verified: 6 recorder unit tests (timed capture; held-note close; orphan note-off ignored; quantize start+length; finish-quantize; reset) → 341 app tests; tsc clean. Record a performance live and it lands in the piano roll, quantized and editable — the full play→record→edit loop. -
P1/PIANO-ROLL drag editing (completes the roll into a full editor). Pure helpers moveNote (shift start≥0 + pitch clamp 0–127) + resizeNote (Δlength, min 0.25), immutable + tested. The SVG component swaps click-only for pointer handlers: mousedown grabs a note (move, or resize from its right ⅓) or arms an empty-cell add; mousemove tracks a snapped (¼-beat / semitone) delta with a live preview on the dragged note; mouseup commits — a zero-delta grab removes the note (click), a drag moves/resizes it, an empty cell adds. Verified: 2 new helper tests (move clamps start/pitch + no-op out of range; resize min-clamps) → 343 app tests; tsc clean. The piano-roll is a complete editor — click to add/remove, drag to move, drag the edge to resize, all snapped and previewed.
-
P3/STREAMING SOURCE — routing the AI generator into a DAW track (the genuine MRT2↔DAW gap; the MRT2 live path itself was already built + fail-closed-tested by prior sessions — sidecar on the real streaming API, runtime step-op + live conditioning, BFF route feeding live note/drum control, panel stereo decode + audio-ref; the steady-gathering-quiche plan in context was stale). New dsp-graph
stream.rsStreamSource: a fixed-capacity mono ring buffer the host fills with decoded PCM (the MRT2 generator's async WS frames) and the audio thread drains one sample/ block — bridging async bursty network audio into the synchronous alloc-free block so the AI generator becomes a first-class mixer track (through inserts/sends/automation/master). Fail- closed: underrun → silence (never fabricated), overrun → drop-oldest (bounded latency). New Source::Stream variant + Track.stream_mut; wasm add_stream_track(capacity)/push_stream → samples dropped; worklet addStream/pushStream + AudioEngine.addStreamTrack/pushStreamSamples (transfers the buffer). Verified: 5 cargo ring-buffer tests (in-order playback; underrun-silence; overrun-drop-oldest-bounds-latency; ring wraparound; clear) → 51 dsp-graph; a wasm binding test (idle silent → pushed PCM plays through the master → drained back to silence) → 44 dsp-wasm (162 cargo total); 16 engine-web; fmt+clippy clean; worklet rebuilt. The on-device AI generator's audio can now flow into a mixable DAW track — the engine + JS API are wired; a 'generator' track kind feeding it from the realtime controller is the remaining app glue. -
P3/GENERATOR TRACK KIND — makes the StreamSource a first-class DAW track. TrackKind gains 'generator'; an addGeneratorTrack reducer creates the track + emits the addStream engine command (96000-sample / ~2 s ring buffer); session-rebuild replays generator tracks as addStream; a '+ AI Gen' button + command-bar "add a generator". The synth-specific panels (synth/piano-roll/ keyboard) guard on kind==='synth', so a generator shows just its channel strip — gain/pan/mute/ solo/sends/inserts/automation all apply, so streamed AI audio flows through the full mix. Verified: a reducer test (kind generator + addStream command) + a rebuild test + a command-parser test → 346 app tests; tsc clean. Add an AI-generator track from the UI; pushStreamSamples (wired in the prior slice) feeds it. The remaining glue is the /realtime MRT2 WS connection pushing its decoded frames into this track's buffer (deploy-verified with the real engine).
-
P3/MRT2-WS → GENERATOR-TRACK BRIDGE (closes the last-mile AI↔DAW wiring). New
realtime-stream-bridge.ts:interleavedStereoToMono(downmix the 48k stereo frames),routeRealtimeMessage(binary → mono audio frame, string → JSON envelope type, malformed ignored), andbridgeRealtimeStream(socket, cb)wiring a WebSocket-like (open/message/error/ close) to the sink + a disposer — the socket is INJECTED so it's fully unit-tested. daw-app: a generator track shows a Connect/Disconnect MRT2 panel; connect opens a WebSocket to/v1/generation/realtime(the same BFF endpoint the standalone panel uses) and bridges its decoded frames into the track via pushStreamSamples — so the on-device AI generator's audio flows through that track's inserts/sends/automation/master. Fail-closed: on error/close → disconnect + a "backend unavailable" message (never fabricated audio); the disposer detaches handlers + closes; unmount cleans up. Verified: 3 bridge tests (downmix; binary/string/malformed routing; socket wiring + disposer) → 349 app tests; tsc clean. The full AI↔DAW path is wired end to end: add an AI Gen track → Connect MRT2 → the live generator streams into the DAW mix. Frame routing/downmix/socket-wiring are mock-tested; the live stream runs against the deployed BFF + real magenta_rt engine. -
P4/NATIVE DESKTOP SHELL (Tauri v2) — the named "native builds" gap. A complete, deploy-ready desktop wrapper for the studio-web DAW under
apps/euterpe-studio-web/ src-tauri/: the app crate (Cargo.toml/build.rs/src/{main,lib,commands}.rs),capabilities/default.json, real PNG icon set (hand-rolled PNG encoder → Euterpe-purple "E"), and a fixedtauri.conf.json(corrected the removednext exportCLI →EUTERPE_TAURI=1 next build; added the COOP/COEP headers Tauri must serve since static export can't;withGlobalTauri; window label). The non-trivial, security-relevant logic lives in a dependency-lighteuterpe-desktop-corecrate (serde only):sanitize_stem_filename(path-traversal/absolute/Windows-reserved safe),dedupe_filenames(case-insensitive),validate_project_json— 9 cargo tests. Native commands (thin wrappers over core):export_stems(folder-wide WAV write + containment check),save/load_project(validated.eupI/O),reveal_in_file_manager. Web side wired viasrc/daw/desktop-bridge.tsusing the injectedwindow.__TAURI__global (no@tauri-apps/apidep):isDesktop,bytesToBase64,stemsPayload,nativeExportStems/SaveProject/LoadProject/Reveal/ PickDirectory— 6 vitest tests;exportStemsnow prefers a single native folder-export + reveal under desktop, falling back to per-file browser download.next.config.mjsconditionally static-exports underEUTERPE_TAURI=1. Verified: core 9 cargo tests; bridge 6 vitest + app tsc clean + 355 app tests; JSON + cargo manifest valid;cargo fetchresolves the full graph (tauri 2.11, dialog/fs/ opener, wry 0.55); theOpenerExt::reveal_item_in_dirAPI confirmed against the resolved plugin source; all capability permission ids confirmed present. Boundary: the fullcargo check/tauri buildcompiles the WebKit/wry tree (~400 crates, multi-GB) — not run on the 16 GB laptop to avoid swap-thrash (CLAUDE.md memory rule); it runs in CI/packaging. Everything short of that compile is verified. -
P4/AUDIO-PLUGIN FORM FACTOR (CLAP + VST3 instrument) — the named "plugin form factor" gap. New standalone crate
libs/euterpe/instrument/exposes the tested dsp-core PolySynth as a plugin usable inside other DAWs (the instrument engine is the right unit — a whole DAW doesn't load as a VST).params.rs: pure normalized- [0,1] ↔ engine-unit mapping (log cutoff 20Hz–20kHz, ADSR curves, detune cents, Q, dB→linear gain) — 8 cargo tests vs exact known values (e.g. cutoff(0.5)=632.46Hz geometric midpoint).engine.rs:EuterpeInstrumentapplies a ParamSnapshot onto every voice, consumes MIDI NoteEvents, renders blocks — 5 cargo tests against the real PolySynth (silent→note→release decay, determinism, gain scaling, polyphony).plugin.rs(featureplugin): the nih-plug host binding — 19 automatable params with engineering-unit readouts, sample-accurate MIDI, CLAP+VST3 exports. Written against the FETCHED nih-plug source (every symbol confirmed: Plugin/ClapPlugin/ Vst3Plugin items, Float/IntParam, NoteEvent::{NoteOn,NoteOff,Choke}, timing(), const_default, export macros). nih-plug is an optional git dep behind the feature so defaultcargo test/build compiles only dsp-core + this crate (confirmed: nih_plug absent from the default dep tree). Verified: 13 cargo tests;cargo tree --features pluginresolves the full graph (nih_plug git f36931f7 + clap-sys + vst3-sys); adversarial stub scan clean. Boundary:cargo build --features plugincompiles nih-plug + CLAP/VST3 system bindings (needs the host audio toolchain) — runs in CI/packaging, not on the 16 GB laptop. Engine, mapping, dep graph, and binding API correctness all verified short of that compile. -
P4/ACCESSIBILITY (a11y) — the named "a11y" P4 item. New
src/daw/daw-a11y.ts: pure, tested screen-reader strings —transportStatus(a polite live-region message: Stopped/Playing/Recording, loop bars, BPM),meterStatus(peak dBFS + LUFS + explicit clipping flag),rangeAria/toggleAria/trackButtonLabel, and ansrOnlystyle. Wired into the shared controls so it covers every instance at once:Slidernow carriesaria-label+ engineering-unitaria-valuetext(a reader says "Master, -3.0 dB", not "0.43"); the peakMeterbars exposearia-valuetextin dBFS + valuemin/max and the meter group anaria-labelsummary; the transport<header>is a labelledrole="toolbar"with a visually-hiddenrole="status" aria-live="polite"region announcing transport changes, plusaria-labels on the symbol-only undo/redo/metronome buttons; channel-strip mute/solo gain properaria-labels (were bare "M"/"S"). Baseline was 68 buttons / 11 labels / 0 live regions / 0 slider valuetext. Verified: 4 daw-a11y vitest tests (status/meter/aria strings) → 359 app tests; tsc clean; stub scan clean. -
P3/LIVE AUDIO-INPUT RECORDING — capture mic/line into an audio track (the last table-stakes DAW capability the notes flagged as platform-bound). Pure
audio-capture.ts:CaptureBuffer(accumulates unbounded Float32 capture blocks — COPIES them since the worklet reuses its buffer — into one contiguous take; frames/durationSec/finish/reset),RecordingSession(idle→armed→recording→idle state machine rejecting illegal transitions, returns the target track on stop),downmixToMono— 6 vitest. daw-apprecordInput(track)toggle: getUserMedia({audio}) → MediaStreamSource → ScriptProcessor appends downmixed mono to the CaptureBuffer (routed through a muted gain so it pulls without monitoring); on stop →engine.loadSample(track, finish())so the take becomes a playable clip on the track. Fail-closed: getUserMedia denial → no fabricated take; unmount tears down the stream/nodes. SamplerPanel gains a labelled ●/■ Record Input button. Verified: 6 capture tests → 365 app tests; tsc clean; stub scan clean. Boundary like the Stop-hook MRT2 case: the pure capture logic is tested here; the live getUserMedia/ScriptProcessor capture runs in the browser (tsc-verified, not headless-runnable). -
P3/LIVE MIDI-HARDWARE INPUT — play/record the DAW synth from a connected controller (the DAW had none; only the /realtime MRT2 panel used WebMIDI). Pure
web-midi.tsparseMidiMessage(data): decodes raw MIDI status/data bytes → note events (0x90 note-on, 0x90-vel-0 + 0x80 → note-off, channel nibble ignored, data masked to 7 bits, CC/bend/clock/ malformed → null) — 6 vitest with byte vectors. daw-apptoggleMidiInput:navigator.requestMIDIAccess()→ attachesonmidimessageto every input port, routing decoded events through the SAMErecordDispatchthe on-screen keyboard uses (so a controller both plays the selected synth track and records into the active clip when armed); reads the target track per-message so track selection stays live. Fail-closed: no Web MIDI / denied access → honest error, no events; disable + unmount detach every port handler. A labelled MIDI-In toggle above the keyboard. Verified: 6 web-midi tests → 371 app tests; tsc clean; stub scan clean. -
AUDIT-DRIVEN / TIME-STRETCH + PITCH-SHIFT WARP — surfaced by a deep audit as the one genuinely-missing SOTA DAW capability (the Sampler only did coupled resampling; no independent duration/pitch). Full 7-layer slice end-to-end: dsp-core
timestretch.rsWSOLA (waveform-similarity OLA, Hann-COLA unity gain) +time_stretch/pitch_shift/resample_to_len— 6 cargo tests vs the defining invariants (stretch changes length yet keeps zero-crossing density; octave pitch-shift doubles frequency at constant length) → dsp-graph re-export → dsp-wasm free fnstime_stretch_buffer/pitch_shift_buffer(+1 binding test) → wasm rebuilt (wasm-pack, light) → workletloadSampleWarpedhandler (warps on the audio thread via the worklet's wasm instance, thenload_sample) →messages.tscmd +AudioEngine.loadSampleWarped→ engine-web spec drives the real compiled wasm (warp crosses the WASM boundary; 17 tests) → daw-app retains decoded buffers per track (non-destructive re-warp; recorded takes too) +warpSample→ SamplerPanel Warp section (Tempo ×0.5–2 stretch + Pitch ±12 st, Apply/Reset, aria-labelled). Verified: dsp-core 73 + dsp-graph 51 + dsp-wasm 45 cargo; engine-web 17 (real wasm); 371 app; all tsc/fmt/clippy/stub-scan clean. The audit's finding that the DAW kernel (audio-engine) is stub-free is now matched by closing its one real feature gap. -
NATIVE COMPILES PROVEN ON-BOX (2026-06-06) — both previously "deploy-bound" native scaffolds were compiled here at restricted parallelism (
-j2, memory-monitored; free% held 33–39 throughout, no swap-thrash): (1)cargo build --features pluginonlibs/euterpe/instrument→ exit 0 in ~25s, producedlibeuterpe_instrument.dylib(nih-plug + clap-sys + vst3-sys compiled + linked,nih_export_clap!/nih_export_vst3!entry points). (2)cargo checkonapps/euterpe-studio-web/src-tauri→ exit 0 in ~1m36s, zero errors/warnings, andtauri_buildgeneratedgen/schemas/(validating tauri.conf.json + capabilities + commands.rs opener/fs/dialog). The plugin binding + the Tauri desktop shell genuinely compile against real Tauri 2.11 / wry / nih-plug — not just resolve. Only the final bundle steps (tauri build,cargo xtask bundle) remain, and those need only packaging, not code.
Build log (append per slice)#
- P0.1 dsp-core: oscillators (PolyBLEP) + ADSR + RBJ biquad (8 types). 14 cargo tests vs known DSP anchors; fmt+clippy clean. Commit 74aeeafd21.
- P0.2 dsp-core: dynamics (feed-forward soft-knee compressor + brickwall
limiter)
- metering (sample-peak, sliding RMS, ITU-R BS.1770 K-weighted LUFS). 22 cargo tests total; the LUFS −3.01 LKFS anchor (full-scale 997 Hz single channel) pins the K-weighting coeffs to the standard; compressor hits the −10.5 dB 4:1 ratio target; limiter holds its ceiling. fmt+clippy clean. Commit 7df7b7c68e.
- P0.3 dsp-core: time/space/mix — fractional-interpolated DelayLine + feedback Delay (damped, dry/wet), Freeverb (8 damped combs ∥ 4 allpasses, canonical tunings SR-scaled), and mixer (equal-power constant-power pan + ChannelStrip + StereoBus). 32 cargo tests; echoes land at exact multiples w/ fb^n decay, reverb tail is dense+decaying+stable & bigger room rings longer, pan is −3 dB center / isolated hard L-R / L²+R²=1 across the sweep. fmt+clippy clean. Commit 238026d670.
- P0.4 dsp-core: instrument/EQ set — 5-band ParametricEq (low-shelf/3 peak/ high-shelf), variable-rate interpolating Sampler (resampling + semitone pitch + loop), and a subtractive SynthVoice (osc→resonant LP w/ filter-env, amp env; control-rate coeff refresh) + PolySynth (free-voice-then-oldest stealing). 44 cargo tests; flat EQ transparent / +6 dB peak lifts its center / shelf cut hits bass not treble, rate 2.0 doubles pitch via zero-crossings + exact unit-rate reproduction, voice sounds→silences after release + lower cutoff = less energy, poly allocates/steals/frees. fmt+clippy clean. dsp-core primitive set done.
- P0.5 dsp-graph crate: the real-time mixing engine over dsp-core. AudioEffect trait + insert nodes (eq/comp/limiter/delay/reverb), Track (mono Source → insert chain → gain/pan strip), Engine (transport clock + track sum → stereo master gain+limiter + peak/LUFS meters), live note routing, solo/mute. All alloc-free after construction (scratch sized once). 51 cargo tests total (7 new engine): silent→silence, synth note→stereo sound, hard-left isolates L, master limiter caps a hot mix, playhead advances only while playing, mute/solo gating, insert-chain EQ cut measurably quieter. fmt+clippy clean.
- P0.6 dsp-wasm crate: the wasm-bindgen surface (WasmEngine) exposing the engine to JS/AudioWorklet — add synth/silent tracks, configure_synth, note on/off, gain/pan/mute/solo, insert builders (compressor/limiter/reverb/delay/EQ) with downcast-based set_eq_band (added as_any_mut to the AudioEffect trait), transport, master gain, peak+LUFS readouts, process(&mut[f32]L,&mut[f32]R). Compiles to wasm32-unknown-unknown (verified) AND natively; 3 native binding tests (note→audio, inserts indexed+editable, transport). Offline build via cached wasm-bindgen 0.2.122. fmt+clippy clean. 57 cargo tests total.
- P0.7 @euterpe/audio-engine-web TS package: the browser front-door. Main-thread
AudioEngine class (AudioContext + AudioWorkletNode; compiles wasm, posts the
WebAssembly.Module to the worklet; full control API:
tracks/synth-config/notes/
gain-pan-mute-solo/inserts/EQ-band/transport/master; deterministic
track+insert indices; onMeter LUFS/peak stream). Worklet processor
(engine-processor.template
- concatenated wasm-bindgen no-modules glue → euterpe-engine-processor.js via build-worklet.mjs). wasm-opt bulk-memory/nontrapping flags fixed the optimizer. Verified: build-worklet emits a valid bundle (glue+processor+registerProcessor), tsc clean, and 4 vitest tests drive the COMPILED wasm in Node (initSync from bytes, same path the worklet uses) — silent→note→real stereo audio, transport advance, live EQ-band edit across the WASM boundary, finite LUFS readback. P0 audio engine COMPLETE: Rust DSP → WASM → AudioWorklet → TS, fully tested.
Gate P1 — render the DAW UI (in progress)#
- P1.1 DAW session core (apps/euterpe-studio-web/src/daw/): a pure,
framework-free reducer (mirrors the realtime-mrt2 controller pattern) that
folds UI intents into immutable view-state AND emits the EngineCommand[] to
post to the engine. Track + insert ids assigned sequentially to match the
engine's own creation order (no audio-thread round-trip). Covers tracks
(synth/audio), gain/pan/mute/solo, synth-patch config, live notes (held-note
tracking), insert builders, EQ-band edits, transport (play/stop/tempo/seek),
master gain, meter folding; plus display helpers (dB/pan/bars:beats
formatting, computer-keyboard→MIDI, note names). Added AudioEngine.postCommand
seam + a DOM-free
@euterpe/audio-engine-web/messagessubpath so the pure core compiles under ES2022-only (no DOM pulled in). 20 vitest tests (clamps, immutability, command emission, solo/mute audibility, helpers); all 160 app tests green; tsc clean. Wired the package into the app (dep + transpile + vitest alias). - P1.2 DawController + React DAW surface. DawController (engine sink injected) holds the session, runs the reducer, posts commands, notifies React — 6 vitest tests with a recording fake sink (command stream verified for add/note/insert/mix/transport + default scene + full mixing gesture). React surface (app/daw/page.tsx + src/ components/daw/): dark-first theme tokens, Slider/Meter controls (dB-scaled peak bars + LUFS), ChannelStrip (gain/pan/mute/solo), SynthPanel (waveform + ADSR + filter), InsertRack (add comp/limiter/reverb/delay/EQ + 3-band EQ editor), TransportBar (play/tempo/position/master+meter), playable Keyboard (pointer + computer-keyboard, releases on unmount), and DawApp tying it together via a useDawEngine hook (boots AudioEngine from a user gesture, lays a default scene, streams meters). Asset-sync script copies the worklet+wasm into public/audio on predev/prebuild. Verified: app tsc clean (DOM+JSX config), 166 app vitest tests green, asset sync emits both files. NOTE: live in-browser visual pass deferred — the box's 16 GB RAM makes a next dev + browser run risky (documented hazard); the engine produces real audio (Node-verified in P0.7) and all reducer/controller behavior is unit-tested, so the React layer is the thin untested binding (as with the existing realtime page).
- P1.3 step sequencer (engine + binding). dsp-graph: a per-track looping Pattern (per-step MIDI note lists, steps_per_beat subdivision) driven sample-accurately by the transport clock — Engine advances each track's sequencer at every block's start playhead, firing note on/off at step boundaries (≤ one quantum jitter), releasing on stop and rearming on seek. dsp-wasm: set_track_pattern (flat step-lengths + notes encoding to fit wasm-bindgen), clear_track_pattern, track_step. Verified: 3 new dsp-graph tests (step index advances + loops with the clock; sound only while playing; stop releases held notes) + 1 dsp-wasm native test + 1 audio-engine-web test driving the COMPILED wasm pattern in Node. 58 cargo tests, fmt+clippy clean, rebuilt+synced the worklet artifact.
- P1.4 step-grid UI. Engine protocol gains setPattern/clearPattern commands (worklet flattens steps[][]→the wasm flat encoding). Reducer: ensurePattern/toggleStep/ clearPattern actions (auto-create a 16-step bar, keep step notes sorted, toggle on/ off) + a patternCurrentStep helper mirroring the engine's sequencer math for the UI highlight. StepGrid component (pitch × step grid for the selected synth, playing column highlighted from the transport playhead) wired below the mixer. Verified: 3 new reducer tests (toggle/clear/current-step) → 169 app vitest tests; app + lib tsc clean; worklet rebuilds with the setPattern handler. DAW now sequences: live synth + mixer + effects + step patterns, all driven through the tested core.
- P1.5 per-track metering. dsp-graph Track gets a post-fader PeakMeter (peak-hold ballistic); the Engine resets the meter of any track silenced by another's solo and exposes track_peak(i); dsp-wasm forwards it; the worklet gathers per-track peaks into the meter event; AudioEngine + the reducer's meter action fold them into each TrackState.peak (by id); ChannelStrip renders a dB-scaled level bar (green/amber/red). Verified: 1 new dsp-graph test (sounding track meters >0, silent ~0, muted decays, solo-out forces 0) → 59 cargo tests; 169 app + 5 wasm vitest; app+lib tsc clean; worklet rebuilt with track_peak.
- P1.6 algorithmic pattern generation (the first "intelligence" feature). pattern-gen.ts: real Bjorklund Euclidean rhythms + scale-quantized ascending-arp note placement (major/minor/dorian/pentatonics). A generatePattern reducer action fills the selected synth's grid + emits setPattern; StepGrid gets Euclidean-fill buttons (3/5/7 pulses, pentatonic arp). Verified vs canonical patterns: E(3,8)=x··x··x· (tresillo), E(5,8)=x·xx·xx· (cinquillo), E(4,16) four-on-the-floor; exact pulse count + every note in-scale + MIDI clamp; reducer test confirms 4 onsets + pentatonic notes. 7 new tests → 176 app vitest; app tsc clean.