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 through midi.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

ModuleResponsibility
src/App.sveltePower gesture, computer-keyboard fallback, focus and blur drains, surface composition
src/lib/midi/types.tsClosed MidiEvent union, MidiSource, note naming
src/lib/midi/parse.tsRaw status-byte parsing and 14-bit pitch-bend assembly
src/lib/midi/manager.svelte.tsWeb 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.tsOne free-time phrase, defensive persistence, replay timer, loop-owned drain, record/play/clear state
src/lib/state/mapping.tsFactory CC 70–77 bindings, learned rebinds, normalization and storage validation
src/lib/state/settings.svelte.tsTheme, drum channel, waveform, mix and monitor persistence with delayed writes
src/lib/audio/engine.svelte.tsAudioContext lifecycle, buses, effects, analyser, parameter bridge and browser probe
src/lib/audio/synth.tsSixteen active voices, dual oscillators, ADSR, low-pass filter, vibrato, bend and stealing
src/lib/audio/drums.tsEight synthesized drum recipes and closed-hat choke behavior
src/lib/juice/particles.svelte.tsReduced-motion-aware, capped visual particles
src/components/*.svelteInput 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):

MemberContract
modeReactive idle, recording, or playing state
hasPhraseWhether the slot contains at least one event
eventCountStored event count
lengthMsDerived 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.