Parser contract

parseMidi(data) is a pure boundary. It reads the status high nibble as message kind, masks the low nibble into a zero-based wire channel, and returns one member of the closed MidiEvent union. Anything the app does not act on returns null (src/lib/midi/parse.ts:3-30; src/lib/midi/types.ts:1-7).

Status familyReturned eventDispatch behavior
0x8nnoteoffReleases a non-drum synth voice; drum-channel note-off is ignored because drums are one-shots
0x9nnoteon, unless velocity is zeroRoutes to synth or drums according to the configured drum channel
0xBnccCC64 sustain, CC1 vibrato, otherwise the learned or factory parameter map
0xCnprogramchangeSelects one of four waveforms with program % 4
0xDnpressureMaps channel pressure from 0–127 to vibrato depth
0xEnpitchbendApplies normalized bend to every active synth voice

A note-on with velocity 0 becomes noteoff, preserving its note and channel (src/lib/midi/parse.ts:10-17). Explicit note-off accepts a missing release velocity as zero. Program change and channel pressure are two-byte messages; the parser requires at least a status byte and one data byte.

The deliberate ignore set includes polyphonic key pressure (0xAn), system common and system real-time (0xFn, including clock, SysEx status, active sensing, and reset), and every other unsupported status family. MIDI access is requested with sysex: false (src/lib/midi/manager.svelte.ts:79-95). Unsupported messages never enter dispatch, the monitor, the phrase recorder, or audio.

Pitch bend assembly

MIDI pitch bend carries 14 bits as least-significant seven bits first, then most-significant seven bits:

const raw = data[1] | ((data[2] ?? 0) << 7)
const value = (raw - 8192) / 8192

That makes raw 0 equal -1, raw 8192 equal 0, and raw 16383 equal 8191 / 8192, just below +1 (src/lib/midi/parse.ts:20-23). The synth maps the normalized value to ±200 cents and smooths changes over 10 ms on every oscillator (src/lib/audio/synth.ts:58,68,206-218). Panic resets both the visible bend and the synth closure to zero (src/lib/audio/engine.svelte.ts:209-214).

Channel convention

There is no drum-channel off-by-one.

LayerConventionEvidence
MIDI wire and MidiEvent.ch0 through 15status & 0x0f in src/lib/midi/parse.ts:6-9
Settings selector1 through 16channel options in src/components/Settings.svelte:7,43-57
Drum comparisonconvert UI once with drumChannel - 1src/lib/midi/manager.svelte.ts:114-123
UI pad and typing-drum eventsemit settings.current.drumChannel - 1src/components/Pads.svelte:6-15; src/App.svelte:68-82
Monitor displayconvert wire once with entry.ev.ch + 1src/components/Monitor.svelte:81-87

The default UI drum channel is 10, which is wire channel 9 and status 0x99 for note-on (src/lib/state/settings.svelte.ts:26-34). Every other channel reaches the polysynth. Changing the selector changes routing immediately; it does not rewrite stored or already-sounding notes.

Drum notes fold by modulo eight. The intended banks are 36–43 and 44–51, both mapping to kick, rim, snare, clap, tom, closed hat, open hat, and cowbell (src/lib/audio/drums.ts:6-15,79-83; src/components/CheatSheet.svelte:137-153).

Continuous controllers

Two CCs have fixed performance meaning before learned mapping is consulted:

CCMeaningThreshold or range
1Mod wheel to vibrato depthvalue / 127, then up to 60 cents
64Sustain pedalvalues 64–127 on; 0–63 off

The factory parameter bindings are:

CCParameterInternal range
70Detune0–50 cents
71Resonancenormalized 0–1, rendered as Q 0.1–20
72Release0.02–3 seconds
73Attack0.001–1.5 seconds
74Cutoffnormalized 0–1, exponentially rendered as 80 Hz–12 kHz
75Delay mix0–1
76Reverb mix0–1
77Volume0–1

mapping.apply() clamps incoming values to 0–127 and linearly normalizes them into each parameter range (src/lib/state/mapping.ts:22-43,65-68). Cutoff’s normalized value is converted exponentially in the synth; resonance is expanded to Q (src/lib/audio/synth.ts:41,130-133). Changes to cutoff, resonance, detune, and bend are smoothed on active voices rather than applied as discrete jumps.

MIDI-learn

Each dial has a Zap control. Activating it calls midi.startLearn(param) and leaves that parameter armed until one CC arrives, the same control is toggled, or Escape cancels (src/components/Knobs.svelte:219-223,280-292; src/App.svelte:43-49).

On the next non-special CC:

  1. Existing bindings for that parameter are removed.
  2. The incoming controller number is assigned to it.
  3. The binding is persisted under midi.mapping.v1.
  4. Learning clears.
  5. That same CC value is normalized and applied immediately.

The new controller can replace a CC that previously pointed elsewhere. Invalid persisted parameter names and corrupt JSON are ignored so startup keeps the factory bindings (src/lib/state/mapping.ts:70-90,102-125). CC1 and CC64 never reach learn because dispatch reserves them first.

Program change and pressure

Program change currently chooses sawtooth, square, triangle, or sine by modulo four and applies the waveform to active and future voices (src/lib/midi/manager.svelte.ts:203-205; src/lib/audio/synth.ts:251-253). A named patch bank is aspirational; see roadmap.

Channel pressure and CC1 share the vibrato path. The manager normalizes either to 0–1, and the engine converts that to up to 60 cents of per-voice LFO depth (src/lib/midi/manager.svelte.ts:177-205; src/lib/audio/engine.svelte.ts:181-183). Polyphonic pressure is not parsed.

Sources in the monitor

Accepted events carry a source string: midi, ui, kbd, or loop. The monitor renders that value without translation alongside the event value and one-based display channel (src/lib/midi/types.ts:1-9; src/components/Monitor.svelte:57-90). Replay is therefore visible and distinguishable. Dispatch logs first and calls phrase.capture() last; capture rejects loop, so replay can never recursively append itself (src/lib/midi/manager.svelte.ts:208-218; src/lib/state/phrase.svelte.ts:226-236).

For non-MIDI input paths, continue with playing-without-hardware. For byte-level runtime checks, use private page (sign-in required).