diff --git a/src/gmcp/Client/Midi.test.ts b/src/gmcp/Client/Midi.test.ts index 996a3604..85f4dc41 100644 --- a/src/gmcp/Client/Midi.test.ts +++ b/src/gmcp/Client/Midi.test.ts @@ -38,6 +38,42 @@ describe('GMCPClientMidi ControlChange', () => { }); }); +describe('GMCPClientMidi Song', () => { + const previous = usePreferences.getState().midi; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + usePreferences.getState().setMidi({ ...previous, enabled: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + usePreferences.getState().setMidi(previous); + }); + + it('plays each note of one Song message at its offset', async () => { + const midi = new GMCPClientMidi({ gmcp: { send: vi.fn() } } as never); + const played: number[] = []; + vi.spyOn(midi as never, 'handleNoteAsync').mockImplementation(((note: { note: number }) => { + played.push(note.note); + return Promise.resolve(); + }) as never); + midi.receiveRegisteredMessage('Song', { notes: [ + { note: 60, offset: 0, duration: 250 }, + { note: 64, offset: 500, duration: 250, velocity: 90, channel: 9 }, + ] }); + await vi.advanceTimersByTimeAsync(0); + expect(played).toEqual([60]); + await vi.advanceTimersByTimeAsync(499); + expect(played).toEqual([60]); + await vi.advanceTimersByTimeAsync(1); + expect(played).toEqual([60, 64]); + expect((midi as never as { handleNoteAsync: ReturnType }).handleNoteAsync).toHaveBeenLastCalledWith( + { note: 64, velocity: 90, on: true, channel: 9, duration: 250 }); + }); +}); + 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 99a509d3..36ceb594 100644 --- a/src/gmcp/Client/Midi.ts +++ b/src/gmcp/Client/Midi.ts @@ -13,6 +13,19 @@ export class GMCPMessageClientMidiNote extends GMCPMessage { public readonly duration?: number; } +export interface MidiSongNote { + note: number; + offset: number; + duration: number; + velocity?: number; + channel?: number; +} + +/** A whole song in one message: each note starts `offset` ms after arrival. */ +export class GMCPMessageClientMidiSong extends GMCPMessage { + public readonly notes!: MidiSongNote[]; +} + export class GMCPMessageClientMidiControlChange extends GMCPMessage { public readonly controller!: number; public readonly value!: number; @@ -42,6 +55,7 @@ export class GMCPMessageClientMidiEnable extends GMCPMessage { type MidiService = typeof import("../../MidiService").midiService; const midiNote = gmcpJsonMessage<"Note", GMCPMessageClientMidiNote>("Note"); +const midiSong = gmcpJsonMessage<"Song", GMCPMessageClientMidiSong>("Song"); const midiControlChange = gmcpJsonMessage< "ControlChange", GMCPMessageClientMidiControlChange @@ -62,6 +76,7 @@ const GMCPClientMidiBase = GMCPPackage.with({ packageName: "Client.Midi", messages: [ duplex(midiNote), + inbound(midiSong), duplex(midiControlChange), duplex(midiProgramChange), duplex(midiSystemMessage), @@ -88,6 +103,7 @@ export class GMCPClientMidi extends GMCPClientMidiBase { constructor(client: ConstructorParameters[0]) { super(client); this.on("note", (data) => this.handleNote(data)); + this.on("song", (data) => this.handleSong(data)); this.on("controlChange", (data) => this.handleControlChange(data)); this.on("programChange", (data) => this.handleProgramChange(data)); this.on("systemMessage", (data) => this.handleSystemMessage(data)); @@ -232,6 +248,16 @@ export class GMCPClientMidi extends GMCPClientMidiBase { void this.handleNoteAsync(data); } + handleSong(data: GMCPMessageClientMidiSong): void { + if (!this.enabled) return; + for (const row of data.notes) { + const note: GMCPMessageClientMidiNote = { + note: row.note, velocity: row.velocity ?? 64, on: true, channel: row.channel, duration: row.duration, + }; + setTimeout(() => void this.handleNoteAsync(note), row.offset); + } + } + private async handleNoteAsync(data: GMCPMessageClientMidiNote): Promise { const midiService = await this.loadMidiService(); const note: MidiNote = {