diff --git a/agent-notes/feat-auto-midi.md b/agent-notes/feat-auto-midi.md new file mode 100644 index 00000000..d8b281d7 --- /dev/null +++ b/agent-notes/feat-auto-midi.md @@ -0,0 +1,33 @@ +# feat/auto-midi: make MIDI automatic + +## Goal +MOO MIDI plays without the user setting anything up: +1. `midi.enabled` defaults to true; a storage migration turns it on once for old saved prefs, then the user's choice sticks. +2. When a MIDI message arrives from the MOO (`src/gmcp/Client/Midi.ts`), connect an output if none is connected and the user did not disconnect on purpose: saved `lastOutputDeviceId` if it is in the device list, otherwise `SOUNDFONT_OUTPUT`. One in-flight connect shared by concurrent notes. +3. Report whether `JZZ()` calls `navigator.requestMIDIAccess`; avoid initializing JZZ on the soundfont path. + +## State +- Worktree `C:\Users\Q\code\react-client-auto-midi`, branch `feat/auto-midi` off origin/master b26b4bd4. +- Worktree had no node_modules; ran `npm ci` (lockfile identical to main checkout's). +- Done: tests written first and seen failing (Midi.test.ts 5 failed; preferencesStore.test.ts 3 failed), then implemented; both files pass (29 tests). Full suite/typecheck left to PR CI. +- `preferencesStore.ts`: STORAGE_VERSION 3 (131), default `midi.enabled: true` (155), migration `storedVersion < 3` sets `midi.enabled = true` (214-216). +- PR #202 open. Second commit: midi-tab gets `mountOnSelect: true` (sidebar.tsx:220), like haptics (228). Test in sidebar.test.tsx shows MidiStatus is not mounted until the MIDI tab is clicked (failed before the change, passes after). +- CI run 39531908947 (head ea28d5a1) failed "Verify lazy audio caching": `scripts/check-audio-cache.mjs:108` saw `VirtualMidiService-*.js` and `MidiService-*.js` load at startup, because MIDI now defaults on. Third commit fixes it in code (script unchanged): + - App.tsx: removed the `midiEnabled` effect that imported VirtualMidiService, bound `setPlaybackSource`/`setAudioOutput(client.media.getMidiAudioOutput())` and called `initialize()` (was App.tsx:415-449), and the `midiEnabled` selector (was 165). + - Midi.ts `loadMidiService()` (117-129) now loads MidiService and VirtualMidiService together and does that wiring before resolving, so it runs before any output connects (every connect path goes through `loadMidiService`: automatic connect, `ensureInitialized`, `connectOutputDevice`). + - client.ts: removed `resetMidiIntentionalDisconnectFlags` (was 41-51, imported MidiService on every connect). onopen (244) and the worker path (446) call `this.gmcp.handlers["Client.Midi"]?.resetIntentionalDisconnects()`, which (Midi.ts:132-134) resets only if MidiService already loaded. + - Midi.ts `sendAllNotesOff` (419) uses `midiServicePromise?.then` instead of loading MidiService: `reset()` runs on every disconnect (session.ts registers `registerDisconnectReset`), so with MIDI on it would also have loaded the chunk at startup when the socket closes. +- `Midi.ts`: `loadMidiOutput()` (128-136) shares one `outputConnecting` promise (96); `connectAutomaticOutput()` (138-149) picks saved output or `SOUNDFONT_OUTPUT`. Used by `handleNoteAsync` (291) and `sendRawToOutput` (346, renamed from `sendRawIfOutputConnected`). + +## Decisions and why +- Soundfont path skips `ensureInitialized()`: `connectOutputDevice(SOUNDFONT_OUTPUT)` (MidiService.ts:293-319) never touches `this.jzz`, and `JZZ()` reaches `navigator.requestMIDIAccess` (see Surprises). JZZ is only initialized when the saved output is something other than the soundfont (a hardware port needs JZZ to appear in the device list). +- Wiring moved into `GMCPClientMidi` rather than gated in App: the package already owns the one lazy load and has `this.client.media`. Lost from App's effect: releasing the playback source when MIDI is turned off or the App's client changes; the binding now lasts for the package's lifetime. `virtualMidiService.initialize()` is not awaited, because it waits while playback is paused (VirtualMidiService.ts:106-110) and would hold up notes. +- The in-flight promise is cleared when it settles, so a failed connect is retried by the next message rather than never. + +## Surprises +- JZZ 1.9.6 (`node_modules/jzz/javascript/JZZ.js`): `JZZ()` (1342-1345) runs `_initJZZ` (812-818), which tries engines `node, extension, plugin, webmidi` (790); the webmidi engine `_tryWebMIDI` (697-714) calls `_requestMIDIAccess.call(_navigator, {})` (709), i.e. `navigator.requestMIDIAccess` (688-690). +- `MidiStatus` (the MIDI sidebar tab) calls `midiPackage.ensureInitialized()` on mount when MIDI is enabled (MidiStatus.tsx:219-223), and the midi tab has no `mountOnSelect` (sidebar.tsx:212-221, tabs.tsx:139), so with MIDI on by default JZZ was still initialized when the sidebar rendered. The coordinator ruled to add `mountOnSelect: true`; done in the second commit. Selecting the MIDI tab still initializes JZZ, which is expected (it lists hardware ports). +- Saved `virtual-synth` only appears in `getOutputDevices()` once `virtualMidiService.initialized` (MidiService.ts:172); otherwise the auto-connect falls back to the soundfont, per the plan's "in the device list" rule. + +## Next step +Read PR #202's CI result. diff --git a/src/App.test.tsx b/src/App.test.tsx index 64a83b30..fe073903 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -51,6 +51,13 @@ const { }; }); +const { virtualMidiLoaded } = vi.hoisted(() => ({ virtualMidiLoaded: vi.fn() })); + +vi.mock('./VirtualMidiService', () => { + virtualMidiLoaded(); + return { virtualMidiService: { setPlaybackSource: vi.fn(() => () => {}), setAudioOutput: vi.fn(async () => {}), initialize: vi.fn(async () => true) } }; +}); + vi.mock('react-beforeunload', () => ({ useBeforeunload: vi.fn(), })); @@ -183,6 +190,13 @@ describe('App haptics backend lifecycle', () => { cleanup(); }); + it('does not load the MIDI synth at startup when MIDI is on', async () => { + mockPreferences.midi.enabled = true; + render(); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); + expect(virtualMidiLoaded).not.toHaveBeenCalled(); + }); + it('loads haptics only after enabling and disposes its runtime on cleanup', async () => { const view = render(); diff --git a/src/App.tsx b/src/App.tsx index d238c3e8..1b289571 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -162,7 +162,6 @@ function App() { const clientInitialized = useRef(false); const hapticsRuntimeRef = useRef(null); - const midiEnabled = usePreferences((state) => state.midi.enabled); const hapticsEnabled = usePreferences((state) => state.haptics.enabled); const connected = useConnectionStore((state) => state.connected); useFileTransferNotifications(client); @@ -412,42 +411,6 @@ function App() { }; }, [handleAppKeyDown]); - useEffect(() => { - if (!midiEnabled) return; - - let cancelled = false; - let releasePlayback: (() => void) | undefined; - import("./VirtualMidiService") - .then(async ({ virtualMidiService }) => { - if (cancelled) return false; - releasePlayback = virtualMidiService.setPlaybackSource(client?.media ?? null); - if (client) { - // Render into the client's master gain so the synth shares its - // clock, volume, mute, and autoplay unlock. - await virtualMidiService.setAudioOutput(client.media.getMidiAudioOutput()); - } - if (cancelled) return false; - return virtualMidiService.initialize(); - }) - .then((success) => { - if (cancelled) return; - if (success) { - console.log("Virtual MIDI synthesizer initialized"); - } else { - console.log("Failed to initialize virtual MIDI synthesizer"); - } - }) - .catch((error) => { - if (cancelled) return; - console.error("Error initializing virtual MIDI synthesizer:", error); - }); - - return () => { - cancelled = true; - releasePlayback?.(); - }; - }, [midiEnabled, client]); - // Window subtitle tracks the current room from the room store. On disconnect // the client resets the store, which clears roomInfo and so clears the subtitle. const roomInfo = useRoomStore((state) => state.roomInfo); diff --git a/src/client.test.ts b/src/client.test.ts index b99b9897..5a767ca1 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -229,6 +229,13 @@ class MockAutoLoginPackage { shutdown = vi.fn(); } +class MockClientMidiPackage { + packageName = 'Client.Midi'; + resetIntentionalDisconnects = vi.fn(); + reset = vi.fn(); + shutdown = vi.fn(); +} + class MockClientMediaPackage { packageName = 'Client.Media'; packageVersion = 1; @@ -686,6 +693,16 @@ describe('MudClient lifecycle cleanup', () => { client.close(); }); + it('resets MIDI intentional disconnects through the MIDI package when the socket opens', () => { + const client = new MudClient('example.test', 443); + const midi = client.gmcp.register(MockClientMidiPackage as never) as unknown as MockClientMidiPackage; + client.connect(); + expect(midi.resetIntentionalDisconnects).not.toHaveBeenCalled(); + mockWebSocketInstances[0].onopen?.(new Event('open')); + expect(midi.resetIntentionalDisconnects).toHaveBeenCalledOnce(); + client.close(); + }); + it('sends through a connected open WebSocket', () => { const client = new MudClient('example.test', 443); client.connect(); diff --git a/src/client.ts b/src/client.ts index b9db2e28..0c93b859 100644 --- a/src/client.ts +++ b/src/client.ts @@ -38,18 +38,6 @@ import { clientSpeechSuspended } from './clientSpeechSuspension'; // treated as dead. const LIVENESS_TIMEOUT_MS = 4000; -function resetMidiIntentionalDisconnectFlags(): void { - if (!usePreferences.getState().midi.enabled) return; - - import("./MidiService") - .then(({ midiService }) => { - midiService.resetIntentionalDisconnectFlags(); - }) - .catch((error) => { - console.error("Failed to reset MIDI disconnect flags:", error); - }); -} - class MudClient { private ws!: WebSocket; private decoder = new TextDecoder("utf8"); @@ -253,7 +241,7 @@ class MudClient { this.reconnectAttempt = 0; // Reset MIDI intentional disconnect flags when successfully reconnecting to server - resetMidiIntentionalDisconnectFlags(); + this.gmcp.handlers["Client.Midi"]?.resetIntentionalDisconnects(); useConnectionStore.getState().setConnected(true); }; @@ -455,7 +443,7 @@ class MudClient { // Mark as connected immediately — the worker will send the "connected" // message once the virtual connection is created. this._connected = true; - resetMidiIntentionalDisconnectFlags(); + this.gmcp.handlers["Client.Midi"]?.resetIntentionalDisconnects(); useConnectionStore.getState().setConnected(true); this.gmcp.markReady(); } diff --git a/src/components/sidebar.test.tsx b/src/components/sidebar.test.tsx index 7b6c0424..c21960c9 100644 --- a/src/components/sidebar.test.tsx +++ b/src/components/sidebar.test.tsx @@ -12,6 +12,9 @@ vi.mock('./ServerFeaturesPanel', () => ({ default: () => Server informatio vi.mock('./AudioDiagnostics', () => ({ default: () => Diagnostics })); vi.mock('./HelpPanel', () => ({ default: () => Help contents })); vi.mock('./MapPanel', () => ({ default: () => Map contents })); +vi.mock('./MidiStatus', () => ({ default: () => MIDI contents })); + +const client = { connected: false, gmcp: { handlers: {} }, media: { diagnostics: {} }, onFileTransferOffer: () => () => {} } as unknown as MudClient; beforeEach(() => { useRoomStore.getState().reset(); @@ -21,7 +24,6 @@ beforeEach(() => { it('reaches active destinations repeatedly and keeps shortcut positions stable as tabs arrive', () => { const ref = React.createRef(); - const client = { connected: false, gmcp: { handlers: {} }, media: { diagnostics: {} }, onFileTransferOffer: () => () => {} } as unknown as MudClient; render(<>); expect(screen.getByRole('tablist', { name: 'Sidebar' })).toBeTruthy(); const outside = screen.getByRole('button', { name: 'Elsewhere' }); @@ -39,8 +41,16 @@ it('reaches active destinations repeatedly and keeps shortcut positions stable a expect(screen.getByRole('tab', { name: 'Users' })).toHaveAttribute('aria-keyshortcuts', 'Control+Shift+3'); }); +it('does not mount the MIDI panel, which initializes Web MIDI, until its tab is selected', async () => { + usePreferences.setState({ midi: { enabled: true } }); + render(); + await act(async () => { await import('./MidiStatus'); }); + expect(screen.queryByText('MIDI contents')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'MIDI' })); + expect(await screen.findByText('MIDI contents')).toBeInTheDocument(); +}); + it('puts Help last, after Map, with no number shortcut of its own', async () => { - const client = { connected: false, gmcp: { handlers: {} }, media: { diagnostics: {} }, onFileTransferOffer: () => () => {} } as unknown as MudClient; act(() => useRoomStore.getState().setRoomInfo({ num: 1, name: 'Room', exits: {} })); render(); const tabs = screen.getAllByRole('tab'); diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 1f0229cf..2c3b9471 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -217,6 +217,7 @@ const Sidebar = React.forwardRef( ), + mountOnSelect: true, condition: midiPreferences.enabled, }, { diff --git a/src/gmcp/Client/Midi.test.ts b/src/gmcp/Client/Midi.test.ts index 85f4dc41..001b14e2 100644 --- a/src/gmcp/Client/Midi.test.ts +++ b/src/gmcp/Client/Midi.test.ts @@ -1,14 +1,45 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { midiService } = vi.hoisted(() => ({ - midiService: { - connectionStatus: { inputConnected: false, outputConnected: true }, - hasOutputDevice: true, - sendRawMessage: vi.fn(), - }, -})); +const { midiService, output } = vi.hoisted(() => { + const output = { connected: true, intentional: false, initialized: false }; + return { + output, + midiService: { + get connectionStatus() { return { inputConnected: false, outputConnected: output.connected }; }, + get intentionalDisconnectStatus() { return { input: false, output: output.intentional }; }, + get hasOutputDevice() { return output.connected; }, + get isInitialized() { return output.initialized; }, + isSupported: true, + initialize: vi.fn(async () => { output.initialized = true; return true; }), + attemptAutoReconnectInput: vi.fn(async () => false), + getOutputDevices: vi.fn(() => [{ id: 'soundfont-synth', name: 'Soundfont Synthesizer' }]), + connectOutputDevice: vi.fn(async () => { await Promise.resolve(); output.connected = true; return true; }), + sendNote: vi.fn(), + scheduleNoteOff: vi.fn(() => true), + sendRawMessage: vi.fn(), + sendAllNotesOff: vi.fn(), + resetIntentionalDisconnectFlags: vi.fn(), + }, + }; +}); + +const { virtualMidiService, audioOutput, media } = vi.hoisted(() => { + const audioOutput = { context: {}, destination: {} }; + return { + audioOutput, + media: { getMidiAudioOutput: () => audioOutput }, + virtualMidiService: { + setPlaybackSource: vi.fn(() => () => {}), + setAudioOutput: vi.fn(async () => {}), + initialize: vi.fn(async () => true), + }, + }; +}); vi.mock('../../MidiService', () => ({ midiService })); +vi.mock('../../VirtualMidiService', () => ({ virtualMidiService })); + +const makeClient = () => ({ gmcp: { send: vi.fn() }, media }) as never; import { usePreferences } from '../../stores/preferencesStore'; import { GMCPClientMidi } from './Midi'; @@ -20,7 +51,7 @@ describe('GMCPClientMidi ControlChange', () => { beforeEach(() => { vi.clearAllMocks(); usePreferences.getState().setMidi({ ...previous, enabled: true }); - midi = new GMCPClientMidi({ gmcp: { send: vi.fn() } } as never); + midi = new GMCPClientMidi(makeClient()); }); afterEach(() => { @@ -53,7 +84,7 @@ describe('GMCPClientMidi Song', () => { }); it('plays each note of one Song message at its offset', async () => { - const midi = new GMCPClientMidi({ gmcp: { send: vi.fn() } } as never); + const midi = new GMCPClientMidi(makeClient()); const played: number[] = []; vi.spyOn(midi as never, 'handleNoteAsync').mockImplementation(((note: { note: number }) => { played.push(note.note); @@ -74,6 +105,99 @@ describe('GMCPClientMidi Song', () => { }); }); +describe('GMCPClientMidi automatic output', () => { + const previous = usePreferences.getState().midi; + let midi: GMCPClientMidi; + + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(output, { connected: false, intentional: false, initialized: false }); + usePreferences.getState().setMidi({ ...previous, enabled: true, lastOutputDeviceId: undefined }); + midi = new GMCPClientMidi(makeClient()); + }); + + afterEach(() => { + Object.assign(output, { connected: true, intentional: false, initialized: false }); + usePreferences.getState().setMidi(previous); + }); + + const note = (n: number) => midi.receiveRegisteredMessage('Note', { note: n, velocity: 90, on: true }); + + it('connects the soundfont before the first MOO note, without initializing Web MIDI', async () => { + note(60); + await vi.waitFor(() => expect(midiService.sendNote).toHaveBeenCalledWith({ note: 60, velocity: 90, on: true, channel: undefined })); + expect(midiService.connectOutputDevice).toHaveBeenCalledWith('soundfont-synth'); + expect(midiService.connectOutputDevice.mock.invocationCallOrder[0]) + .toBeLessThan(midiService.sendNote.mock.invocationCallOrder[0]); + expect(midiService.initialize).not.toHaveBeenCalled(); + }); + + it('connects once for notes that arrive while the output is connecting', async () => { + note(60); + note(64); + note(67); + await vi.waitFor(() => expect(midiService.sendNote).toHaveBeenCalledTimes(3)); + expect(midiService.connectOutputDevice).toHaveBeenCalledOnce(); + }); + + it('does not connect after the user disconnected the output on purpose', async () => { + output.intentional = true; + note(60); + await vi.waitFor(() => expect(midiService.sendNote).toHaveBeenCalled()); + expect(midiService.connectOutputDevice).not.toHaveBeenCalled(); + }); + + it('initializes and connects the saved output when it is in the device list', async () => { + usePreferences.getState().setMidi({ ...usePreferences.getState().midi, lastOutputDeviceId: 'hw-1' }); + midiService.getOutputDevices.mockReturnValue([ + { id: 'soundfont-synth', name: 'Soundfont Synthesizer' }, { id: 'hw-1', name: 'Port 1' }, + ]); + note(60); + await vi.waitFor(() => expect(midiService.sendNote).toHaveBeenCalled()); + expect(midiService.initialize).toHaveBeenCalledOnce(); + expect(midiService.connectOutputDevice).toHaveBeenCalledWith('hw-1'); + }); + + it('falls back to the soundfont when the saved output is gone', async () => { + usePreferences.getState().setMidi({ ...usePreferences.getState().midi, lastOutputDeviceId: 'hw-gone' }); + midiService.getOutputDevices.mockReturnValue([{ id: 'soundfont-synth', name: 'Soundfont Synthesizer' }]); + note(60); + await vi.waitFor(() => expect(midiService.sendNote).toHaveBeenCalled()); + expect(midiService.connectOutputDevice).toHaveBeenCalledWith('soundfont-synth'); + }); + + it('renders the synth into the client audio graph before the first output connects', async () => { + note(60); + await vi.waitFor(() => expect(midiService.connectOutputDevice).toHaveBeenCalled()); + expect(virtualMidiService.setPlaybackSource).toHaveBeenCalledWith(media); + expect(virtualMidiService.setAudioOutput).toHaveBeenCalledWith(audioOutput); + expect(virtualMidiService.setAudioOutput.mock.invocationCallOrder[0]) + .toBeLessThan(midiService.connectOutputDevice.mock.invocationCallOrder[0]); + }); + + it('loads no MIDI code when reset or reconnected before any MIDI message', async () => { + midi.reset(); + midi.resetIntentionalDisconnects(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(virtualMidiService.setPlaybackSource).not.toHaveBeenCalled(); + expect(midiService.sendAllNotesOff).not.toHaveBeenCalled(); + expect(midiService.resetIntentionalDisconnectFlags).not.toHaveBeenCalled(); + }); + + it('resets the intentional disconnect flags once MIDI is loaded', async () => { + note(60); + await vi.waitFor(() => expect(midiService.sendNote).toHaveBeenCalled()); + midi.resetIntentionalDisconnects(); + await vi.waitFor(() => expect(midiService.resetIntentionalDisconnectFlags).toHaveBeenCalledOnce()); + }); + + it('connects an output for a MOO ProgramChange before sending it', async () => { + midi.receiveRegisteredMessage('ProgramChange', { channel: 0, program: 5 }); + await vi.waitFor(() => expect(midiService.sendRawMessage).toHaveBeenCalledWith([0xc0, 5])); + expect(midiService.connectOutputDevice).toHaveBeenCalledWith('soundfont-synth'); + }); +}); + describe('GMCPClientMidi effects route', () => { it('routes browser synths through the media chain system without loading a synth', () => { const previous = usePreferences.getState().midi; diff --git a/src/gmcp/Client/Midi.ts b/src/gmcp/Client/Midi.ts index 36ceb594..3d5db497 100644 --- a/src/gmcp/Client/Midi.ts +++ b/src/gmcp/Client/Midi.ts @@ -2,6 +2,7 @@ import type { MidiMessage, MidiNote } from "../../MidiService"; import { usePreferences } from "../../stores/preferencesStore"; import { duplex, inbound } from "../../protocol/messages"; import { decodeMidiRoute, type MidiRoute } from '../../audio/MidiAudioRoute'; +import { SOUNDFONT_OUTPUT } from "../../midi/soundfonts"; import { gmcpJsonMessage } from "../messages"; import { GMCPMessage, GMCPPackage } from "../package"; @@ -92,6 +93,7 @@ export class GMCPClientMidi extends GMCPClientMidiBase { private isAdvertised: boolean = false; private debugCallback?: (hex: string, type: string, gmcpMessage: string) => void; private midiServicePromise: Promise | null = null; + private outputConnecting: Promise | null = null; private serviceInitialized = false; private hasInputDeviceConnected = false; private hasOutputDeviceConnected = false; @@ -113,12 +115,51 @@ export class GMCPClientMidi extends GMCPClientMidiBase { } private loadMidiService(): Promise { - if (!this.midiServicePromise) { - this.midiServicePromise = import("../../MidiService").then(({ midiService }) => midiService); - } + this.midiServicePromise ??= Promise.all([import("../../MidiService"), import("../../VirtualMidiService")]) + .then(async ([{ midiService }, { virtualMidiService }]) => { + // Render the synth (and soundfonts, which use its output) into the + // client's master gain before any output connects, so it shares the + // client's clock, volume, mute, and autoplay unlock. + virtualMidiService.setPlaybackSource(this.client.media); + await virtualMidiService.setAudioOutput(this.client.media.getMidiAudioOutput()); + void virtualMidiService.initialize(); + return midiService; + }); return this.midiServicePromise; } + /** On server reconnect. Before MIDI loads, the flags are already false. */ + resetIntentionalDisconnects(): void { + void this.midiServicePromise?.then((midiService) => midiService.resetIntentionalDisconnectFlags()); + } + + /** + * The service, with an output connected for a MOO message unless the user + * disconnected it on purpose. Concurrent callers share one connect. + */ + private async loadMidiOutput(): Promise { + const midiService = await this.loadMidiService(); + if (!midiService.connectionStatus.outputConnected && !midiService.intentionalDisconnectStatus.output) { + this.outputConnecting ??= this.connectAutomaticOutput(midiService) + .finally(() => { this.outputConnecting = null; }); + await this.outputConnecting; + } + return midiService; + } + + private async connectAutomaticOutput(midiService: MidiService): Promise { + const saved = usePreferences.getState().midi.lastOutputDeviceId; + // The soundfont needs no Web MIDI, so only a saved hardware or virtual + // output initializes JZZ (and its permission prompt). + if (saved && saved !== SOUNDFONT_OUTPUT) { + await this.ensureInitialized(); + if (midiService.connectionStatus.outputConnected) return; + } + const available = midiService.getOutputDevices().some((device) => device.id === saved); + await midiService.connectOutputDevice(saved && available ? saved : SOUNDFONT_OUTPUT); + this.syncConnectionState(midiService); + } + private syncConnectionState(midiService: MidiService): void { const status = midiService.connectionStatus; this.hasInputDeviceConnected = status.inputConnected; @@ -259,7 +300,7 @@ export class GMCPClientMidi extends GMCPClientMidiBase { } private async handleNoteAsync(data: GMCPMessageClientMidiNote): Promise { - const midiService = await this.loadMidiService(); + const midiService = await this.loadMidiOutput(); const note: MidiNote = { note: data.note, velocity: data.velocity, @@ -296,26 +337,26 @@ export class GMCPClientMidi extends GMCPClientMidiBase { handleControlChange(data: GMCPMessageClientMidiControlChange): void { if (!this.enabled) return; - void this.sendRawIfOutputConnected([0xB0 | data.channel, data.controller, data.value]); + void this.sendRawToOutput([0xB0 | data.channel, data.controller, data.value]); } handleProgramChange(data: GMCPMessageClientMidiProgramChange): void { if (!this.enabled) return; - void this.sendRawIfOutputConnected([0xC0 | data.channel, data.program]); + void this.sendRawToOutput([0xC0 | data.channel, data.program]); } handleSystemMessage(data: GMCPMessageClientMidiSystemMessage): void { if (!this.enabled) return; - void this.sendRawIfOutputConnected(data.data); + void this.sendRawToOutput(data.data); } handleRawMessage(data: GMCPMessageClientMidiRawMessage): void { if (!this.enabled) return; - void this.sendRawIfOutputConnected(data.data); + void this.sendRawToOutput(data.data); } - private async sendRawIfOutputConnected(data: number[]): Promise { - const midiService = await this.loadMidiService(); + private async sendRawToOutput(data: number[]): Promise { + const midiService = await this.loadMidiOutput(); this.syncConnectionState(midiService); if (midiService.hasOutputDevice) { midiService.sendRawMessage(data); @@ -373,8 +414,9 @@ export class GMCPClientMidi extends GMCPClientMidiBase { } sendAllNotesOff(): void { + // Nothing can be sounding before MIDI has loaded, so don't load it here. if (this.enabled) { - void this.loadMidiService().then((midiService) => { + void this.midiServicePromise?.then((midiService) => { midiService.sendAllNotesOff(); }); } diff --git a/src/stores/preferencesStore.test.ts b/src/stores/preferencesStore.test.ts index 21747ed7..d046aaad 100644 --- a/src/stores/preferencesStore.test.ts +++ b/src/stores/preferencesStore.test.ts @@ -61,7 +61,7 @@ describe("preferencesStore", () => { expect(setItem).toHaveBeenCalledWith("preferences", expect.any(String)); const lastCall = setItem.mock.calls.at(-1); const stored = JSON.parse(lastCall?.[1] as string); - expect(stored.version).toBe(2); + expect(stored.version).toBe(3); expect(stored.data.general.localEcho).toBe(true); expect(stored.data.general.syncTimezoneToServer).toBe(false); expect(stored.data.general.syncLocationToServer).toBe(true); @@ -247,6 +247,21 @@ describe("preferencesStore", () => { expect(unknown.midi.midiJsSoundfont).toBe('MusyngKite'); }); + it("turns MIDI on by default", async () => { + const fresh = await loadWith(null); + expect(fresh.midi.enabled).toBe(true); + }); + + it("turns MIDI on once for preferences saved before version 3", async () => { + const old = await loadWith({ version: 2, data: { midi: { enabled: false } } }); + expect(old.midi.enabled).toBe(true); + }); + + it("keeps MIDI off when the user turned it off at version 3", async () => { + const chosen = await loadWith({ version: 3, data: { midi: { enabled: false } } }); + expect(chosen.midi.enabled).toBe(false); + }); + it("keeps changed keys and starts with none", async () => { const empty = await loadWith({ version: 2, data: {} }); expect(empty.keystrokes).toEqual({}); diff --git a/src/stores/preferencesStore.ts b/src/stores/preferencesStore.ts index 3338400a..f85ef404 100644 --- a/src/stores/preferencesStore.ts +++ b/src/stores/preferencesStore.ts @@ -128,7 +128,7 @@ type PrefActions = { }; const STORAGE_KEY = "preferences"; -const STORAGE_VERSION = 2; +const STORAGE_VERSION = 3; const PERSIST_DEBOUNCE_MS = 250; function getInitialPreferences(): PrefState { @@ -152,7 +152,7 @@ function getInitialPreferences(): PrefState { }, editor: { autocompleteEnabled: true, accessibilityMode: true, lineComments: false }, keyboard: { navigationKeyScheme: "jkli" }, - midi: { enabled: false }, + midi: { enabled: true }, haptics: { enabled: false, intensityCap: 1.0, autoStopTimeout: 5 }, autologging: { enabled: false, maxBytes: 100 * 1024 * 1024 }, voiceInput: { enabled: false, sendImmediately: false, language: "" }, @@ -210,6 +210,11 @@ const preferencesSchema: LocalStorageSchema = { delete (parsed as Partial).channels; } + // Version 3 turned MIDI on by default; turn it on once for older saves. + if (storedVersion < 3) { + parsed.midi = { ...parsed.midi, enabled: true }; + } + return mergePreferences(getInitialPreferences(), parsed); }, };