Skip to content

Make MIDI automatic: on by default, output connects on the first MOO message - #202

Merged
ctoth merged 3 commits into
masterfrom
feat/auto-midi
Oct 21, 2026
Merged

ctoth merged 3 commits into
masterfrom
feat/auto-midi

Conversation

@ctoth

@ctoth ctoth commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

MOO MIDI now plays without the user setting anything up, and no MIDI code loads until MIDI is used.

What the code does

MIDI on by default (src/stores/preferencesStore.ts)

  • midi.enabled defaults to true (line 155).
  • STORAGE_VERSION is 3 (line 131). The migration step at lines 213-216 sets midi.enabled = true once for preferences saved at version 2 or earlier. After that, a saved false at version 3 stays false.

Output connects when the MOO sends MIDI (src/gmcp/Client/Midi.ts)

  • loadMidiOutput() (line 140) awaits a connect if no output is connected and the user didn't disconnect on purpose (connectionStatus.outputConnected, intentionalDisconnectStatus.output). Concurrent callers share one in-flight promise (outputConnecting, line 96). The promise is cleared when it settles, so after a failed connect the next message tries again.
  • connectAutomaticOutput() (line 150) connects the saved lastOutputDeviceId when it appears in getOutputDevices(), and SOUNDFONT_OUTPUT otherwise. It calls ensureInitialized() first only when the saved output isn't the soundfont, because hardware ports only show up once JZZ is running.
  • handleNoteAsync (line 302, used by Note and Song) and sendRawToOutput (line 358, used by ControlChange, ProgramChange, SystemMessage and RawMessage) get the service through loadMidiOutput(). Before this change, MidiService.sendNote returned early when no output was connected (src/MidiService.ts:390).

No MIDI chunks at startup (CI's scripts/check-audio-cache.mjs requires this)

  • src/App.tsx used to load and initialize VirtualMidiService on startup whenever MIDI was enabled. That effect is removed. loadMidiService() (Midi.ts:117) now loads MidiService and VirtualMidiService together. Before returning, it attaches the synth to the client's playback source and audio output, so the first output always connects into the client's audio graph.
  • src/client.ts used to import MidiService on every connect to reset the intentional-disconnect flags. Now it calls resetIntentionalDisconnects() on the Client.Midi handler (client.ts:244, :446). That method (Midi.ts:132) does nothing until MIDI has loaded, and the flags start out false anyway.
  • sendAllNotesOff() (Midi.ts:416) only silences a service that has already loaded.
  • The MIDI sidebar tab now has mountOnSelect: true (src/components/sidebar.tsx:220), the same as the Haptics tab. MidiStatus calls ensureInitialized() when it mounts (MidiStatus.tsx:219-223), and that runs JZZ().

JZZ and Web MIDI

In JZZ 1.9.6, _tryWebMIDI calls navigator.requestMIDIAccess (node_modules/jzz/javascript/JZZ.js:709, captured at 688-690). connectOutputDevice(SOUNDFONT_OUTPUT) (src/MidiService.ts:293-319) never touches JZZ. So hearing the MOO through the soundfont never requests Web MIDI access. That only happens when the saved output is a hardware port, or when the MIDI tab is first opened.

Behaviour changes to know about

  • A MIDI keyboard input reconnects only after the MIDI tab is first opened.
  • App used to release the synth's link to the client's audio when MIDI was turned off. That link now lasts as long as the MIDI handler.

Tests

  • src/gmcp/Client/Midi.test.ts:
    • the soundfont connects before the first note, without initialize;
    • three notes arriving at once connect only once;
    • an intentional disconnect blocks the connect;
    • a saved output that is in the list connects, and a missing one falls back to the soundfont;
    • ProgramChange connects before it sends;
    • the synth is wired before connectOutputDevice;
    • reset() and resetIntentionalDisconnects() before MIDI loads load nothing.
  • src/stores/preferencesStore.test.ts: MIDI is on by default, a version 2 save is turned on, and a version 3 false is kept.
  • src/App.test.tsx: rendering App with MIDI on doesn't load VirtualMidiService.
  • src/client.test.ts: opening the socket calls resetIntentionalDisconnects.
  • src/components/sidebar.test.tsx: MidiStatus doesn't mount until its tab is selected.

ctoth and others added 3 commits October 20, 2026 20:11
…message

- preferences: midi.enabled defaults to true; STORAGE_VERSION 3 turns it on
  once for older saves, after which the user's choice sticks.
- Client.Midi: before sending a MOO note/CC/program/system/raw message, connect
  an output if none is connected and the user did not disconnect on purpose:
  the saved output when it is in the device list, else the soundfont. One
  in-flight connect is shared by concurrent messages. The soundfont path does
  not initialize JZZ, whose webmidi engine calls navigator.requestMIDIAccess.

Co-Authored-By: Claude <noreply@anthropic.com>
MidiStatus calls ensureInitialized() on mount (MidiStatus.tsx:219-223), which
runs JZZ() and navigator.requestMIDIAccess. With MIDI on by default, the tab
mounted with the sidebar. mountOnSelect defers that to the first time the
user opens the MIDI tab, as the haptics tab already does.

Co-Authored-By: Claude <noreply@anthropic.com>
CI's lazy-audio check (check-audio-cache.mjs:108) saw VirtualMidiService and
MidiService load at startup once MIDI defaulted on.

- App.tsx: drop the effect that loaded VirtualMidiService and bound it to the
  client's audio whenever MIDI was enabled.
- Client.Midi loadMidiService(): load both services together and do that
  binding before resolving, so it precedes every output connect.
- client.ts: reset intentional-disconnect flags through the Client.Midi
  package, which does nothing until MidiService has loaded.
- Client.Midi sendAllNotesOff(): only silence an already loaded service; it
  runs on every disconnect and was loading MidiService too.

Co-Authored-By: Claude <noreply@anthropic.com>
@ctoth
ctoth merged commit f955956 into master Oct 21, 2026
2 checks passed
@ctoth
ctoth deleted the feat/auto-midi branch October 21, 2026 02:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant