The invariant
Every sound-producing input enters midi.dispatch(ev, source) in src/lib/midi/manager.svelte.ts. Hardware bytes are parsed first; typing keys, screen keys, pads, the developer injector, and phrase replay then use the same method. UI components do not call engine.noteOn() or engine.drum() directly.
The narrow exceptions do not create sound events: dials and selectors call engine.setParam() or engine.setWaveform(), persistence controls call settings.set(), and the power control calls engine.start() (src/components/Knobs.svelte:137-223, src/components/Settings.svelte:10-21, src/App.svelte:27-35).
This single seam is why several features remain coherent without duplicate integration code:
- audio routing, sustain, bend, pressure, program change, and learned CCs happen once;
- pressed notes and pad flashes follow hardware, UI, typing, automation, and replay alike;
- every accepted event reaches the 50-entry monitor ring with its source;
- particles are downstream of the same note and drum decisions;
- the phrase recorder can capture at the dispatch tail, ignore
source === 'loop', and replay only throughmidi.dispatch(ev, 'loop'); window.__midi.inject(bytes)tests the production parser and dispatch path rather than a test-only shortcut.
A feature that calls engine.noteOn(), engine.noteOff(), or engine.drum() from a component creates a second truth and must be rejected.
Event flow
flowchart LR HW["MIDI input"] --> PARSE["parseMidi(bytes)"] PARSE --> DISPATCH["midi.dispatch(event, source)"] TYPE["Typing keyboard"] --> DISPATCH SCREEN["Piano and pads"] --> DISPATCH INJECT["window.__midi.inject"] --> PARSE LOOP["Phrase timers"] -->|"source: loop"| DISPATCH DISPATCH --> ROUTE["Engine routing"] DISPATCH --> STATE["Pressed keys and pad hits"] DISPATCH --> VISUAL["Particles and stage reactions"] DISPATCH --> MONITOR["50-event monitor ring"] DISPATCH --> CAPTURE["phrase.capture"] CAPTURE -->|"ignores loop source"| SLOT["One persisted free-time slot"] SLOT --> LOOP
parseMidi is deliberately pure and returns null for unsupported status families (src/lib/midi/parse.ts:3-30). MidiManager.init() owns capability checks, one single-flight permission request, hot-plug rebinding, and status projection (src/lib/midi/manager.svelte.ts:58-112). The dispatch switch owns all accepted event effects; its log records the original zero-based wire event, source, note name, and time. The monitor alone adds one for human-facing channel display. phrase.capture() is the final call at the dispatch tail (src/lib/midi/manager.svelte.ts:208-218).
Module map
| Module | Responsibility |
|---|---|
src/App.svelte | Power gesture, computer-keyboard fallback, focus and blur drains, surface composition |
src/lib/midi/types.ts | Closed MidiEvent union, MidiSource, note naming |
src/lib/midi/parse.ts | Raw status-byte parsing and 14-bit pitch-bend assembly |
src/lib/midi/manager.svelte.ts | Web MIDI lifecycle, the dispatch funnel, live-versus-loop note ownership, sustain aggregation, monitor ring, learned-CC bridge, projections and browser injector |
src/lib/state/phrase.svelte.ts | One free-time phrase, defensive persistence, replay timer, loop-owned drain, record/play/clear state |
src/lib/state/mapping.ts | Factory CC 70–77 bindings, learned rebinds, normalization and storage validation |
src/lib/state/settings.svelte.ts | Theme, drum channel, waveform, mix and monitor persistence with delayed writes |
src/lib/audio/engine.svelte.ts | AudioContext lifecycle, buses, effects, analyser, parameter bridge and browser probe |
src/lib/audio/synth.ts | Sixteen active voices, dual oscillators, ADSR, low-pass filter, vibrato, bend and stealing |
src/lib/audio/drums.ts | Eight synthesized drum recipes and closed-hat choke behavior |
src/lib/juice/particles.svelte.ts | Reduced-motion-aware, capped visual particles |
src/components/*.svelte | Input and projection surfaces; no independent sound router |
The state modules use Svelte 5 runes and singleton instances. Settings, mapping, and phrase loading protect startup from unavailable, corrupt, or hostile storage (src/lib/state/settings.svelte.ts:8-48; src/lib/state/mapping.ts:70-90; src/lib/state/phrase.svelte.ts:26-33,102-134).
Phrase lifecycle
Recording always starts a fresh slot. The recorder copies each accepted non-loop event after dispatch has routed and logged it, with an offset from performance.now(). It stops at 2,000 events or 60,000 ms. A finished loop is at least 500 ms long and adds a 300 ms release tail after its last event (src/lib/state/phrase.svelte.ts:4-8,136-139,182-236).
The exported singleton API is intentionally small (src/lib/state/phrase.svelte.ts:141-236):
| Member | Contract |
|---|---|
mode | Reactive idle, recording, or playing state |
hasPhrase | Whether the slot contains at least one event |
eventCount | Stored event count |
lengthMs | Derived free-time loop length including release tail |
toggleRecord() | Start a fresh recording or finish the current one |
togglePlay() | Start repeating, finish recording then play, or stop playback |
clear() | Stop, empty, and persist the empty slot |
capture(ev, source) | Copy a non-loop event only while recording |
The slot is stored under midi.phrase.v1 as a bounded event array. Loading validates the array size, monotonic offsets, duration, channel and every event-specific MIDI range; one invalid member or corrupt JSON produces an empty phrase rather than a boot failure (src/lib/state/phrase.svelte.ts:39-83,102-134). Recording stop and clear persist immediately. Private or blocked storage degrades to the in-memory slot (src/lib/state/phrase.svelte.ts:250-267,352-361).
Playback requires a phrase, a registered dispatcher, a running audio engine, and a visible document. One timer schedules the next offset and catches up events already due; at wrap it first drains loop-owned sustain and notes, then restarts from a new monotonic-time origin (src/lib/state/phrase.svelte.ts:196-217,286-350). Visibility loss, page hide, stop, clear, and panic cancel timers and drain loop state. The callback registered after the MIDI singleton exists has only one implementation: midi.dispatch(ev, 'loop') (src/lib/midi/manager.svelte.ts:220-232,262-263).
Live play has priority over replay at the same pitch. The manager tracks live owners separately from loop owners: a loop note-on cannot retrigger a live-held pitch, a live note-on replaces loop ownership, and a loop note-off cannot silence a remaining live owner. Live and loop sustain owners are likewise aggregated before the engine sees pedal state (src/lib/midi/manager.svelte.ts:40-43,119-195). This ownership belongs at dispatch because the synth itself intentionally owns only one voice per pitch.
Top-bar icon controls expose Record or Stop recording, Play or Stop phrase, and Clear phrase. Play and Clear are disabled without a stored phrase, and tooltip text is identical to the accessible label (src/components/TopBar.svelte:28-29,109-163).
Audio graph
flowchart LR SYNTH["16-voice polysynth"] --> SBUS["synthBus"] DRUMS["8 synthesized drums"] --> DBUS["drumBus"] SBUS --> DRY["dry gain"] DBUS --> DRY SBUS --> DSEND["delay send"] DBUS --> DSEND SBUS --> RSEND["reverb send"] DBUS --> RSEND DSEND --> DELAY["0.28 s delay"] DELAY -->|"0.35 feedback"| DELAY DELAY --> MASTER["master"] RSEND --> REVERB["convolver reverb"] RSEND -.->|"fallback"| DIFFUSER["3 cascaded feedback delays"] REVERB --> MASTER DIFFUSER --> MASTER DRY --> MASTER MASTER --> COMP["compressor"] COMP --> POST["postGain"] POST --> ANALYSER["analyser"] ANALYSER --> DEST["AudioContext destination"]
The terminal chain is exactly master → compressor → postGain → analyser → destination (src/lib/audio/engine.svelte.ts:62-69). Volume belongs to postGain, after compression, so the control changes output level instead of driving harder into the compressor (src/lib/audio/engine.svelte.ts:191-207). The analyser therefore sees the level that reaches the destination; its 256-point FFT supplies 128 bins, while a separate time-domain read produces the RMS-style engine.level meter (src/lib/audio/engine.svelte.ts:66-68,120-121,135-153).
Both synth and drum buses feed dry, delay-send, and reverb-send paths in parallel (src/lib/audio/engine.svelte.ts:70-113). Delay is a 0.28-second feedback loop. Reverb normally uses a generated two-second stereo impulse through ConvolverNode; if buffer or engine behavior rejects that graph, three cascaded feedback delays at 31, 47, and 71 ms preserve an audible reverb control (src/lib/audio/engine.svelte.ts:6-17,74-105).
Synth and voice ownership
MAX_VOICES is 16. Each voice has two oscillators, one low-pass filter, one amplitude gain, and a per-voice vibrato-depth gain fed by a single global 5.5 Hz LFO (src/lib/audio/synth.ts:37-68,129-175). Velocity follows a power curve into the gain envelope. The second oscillator adds the configured detune; pitch bend adds up to ±200 cents to both.
Voices live in a note-keyed map. Re-triggering one pitch replaces its prior voice. When the map reaches 16 entries, the synth finds the smallest started time and releases that oldest voice over 80 ms before creating the next (src/lib/audio/synth.ts:115-128). Normal note-off uses the configured release, sustain parks held voices, and panic drains the map through a 30 ms fade (src/lib/audio/synth.ts:178-205,254-264). Cleanup disconnects each voice from the global LFO and output graph (src/lib/audio/synth.ts:70-95).
Drums
MIDI notes fold into eight slots with (((note - 36) % 8) + 8) % 8, so notes 36–43 and 44–51 address the same bank (src/lib/audio/drums.ts:79-83). Kick, rim, snare, clap, tom, closed hat, open hat, and cowbell are synthesized from oscillators, filtered noise, or both; no samples are downloaded (src/lib/audio/drums.ts:6-15,84-137). Every recipe scales amplitude from velocity. A closed hat anchors and rapidly damps every tracked open-hat gain, so overlapping open hats are all chokeable (src/lib/audio/drums.ts:25-27,103-124).
Browser projection
The engine owns one analyser loop for level and voice count. Stage.svelte owns a second loop that reads bins and writes bar transforms directly; the audit measured both loops together at less than 0.5% of one frame, so this is an intentional projection boundary rather than a performance backlog (src/lib/audio/engine.svelte.ts:135-153; src/components/Stage.svelte:41-71). Svelte templates do not subscribe to those per-frame scalar writes.
Reduced motion stops particle creation before allocation and disables the four keyframe animations; spectrum transforms remain because they are the live analyser rather than decorative motion (src/lib/juice/particles.svelte.ts:15-25; src/app.css:68-75).
Read midi-reference for the event contract, private page (sign-in required) for the automation surface, and roadmap before adding generated-note sources.