midiwire 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +845 -0
- package/dist/midiwire.es.js +1987 -0
- package/dist/midiwire.umd.js +1 -0
- package/package.json +58 -0
- package/src/bindings/DataAttributeBinder.js +198 -0
- package/src/bindings/DataAttributeBinder.test.js +825 -0
- package/src/core/EventEmitter.js +93 -0
- package/src/core/EventEmitter.test.js +357 -0
- package/src/core/MIDIConnection.js +364 -0
- package/src/core/MIDIConnection.test.js +783 -0
- package/src/core/MIDIController.js +756 -0
- package/src/core/MIDIController.test.js +1958 -0
- package/src/core/MIDIDeviceManager.js +204 -0
- package/src/core/MIDIDeviceManager.test.js +638 -0
- package/src/core/errors.js +99 -0
- package/src/index.js +181 -0
- package/src/utils/dx7.js +1294 -0
- package/src/utils/dx7.test.js +1208 -0
- package/src/utils/midi.js +244 -0
- package/src/utils/midi.test.js +260 -0
- package/src/utils/sysex.js +98 -0
- package/src/utils/sysex.test.js +222 -0
- package/src/utils/validators.js +88 -0
- package/src/utils/validators.test.js +300 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { CONNECTION_EVENTS, MIDIConnection } from "./MIDIConnection.js"
|
|
3
|
+
|
|
4
|
+
describe("MIDIConnection", () => {
|
|
5
|
+
let originalNavigator
|
|
6
|
+
let mockMIDIAccess
|
|
7
|
+
let mockOutput
|
|
8
|
+
let mockInput
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Save original navigator
|
|
12
|
+
originalNavigator = global.navigator
|
|
13
|
+
|
|
14
|
+
// Create mock MIDI port objects
|
|
15
|
+
mockOutput = {
|
|
16
|
+
id: "test-output-1",
|
|
17
|
+
name: "Test Output Device",
|
|
18
|
+
manufacturer: "Test Manufacturer",
|
|
19
|
+
state: "connected",
|
|
20
|
+
send: vi.fn(),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
mockInput = {
|
|
24
|
+
id: "test-input-1",
|
|
25
|
+
name: "Test Input Device",
|
|
26
|
+
manufacturer: "Test Manufacturer",
|
|
27
|
+
state: "connected",
|
|
28
|
+
onmidimessage: null,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Create mock MIDI access
|
|
32
|
+
mockMIDIAccess = {
|
|
33
|
+
outputs: new Map([
|
|
34
|
+
["output-1", mockOutput],
|
|
35
|
+
[
|
|
36
|
+
"output-2",
|
|
37
|
+
{
|
|
38
|
+
id: "output-2",
|
|
39
|
+
name: "Second Output",
|
|
40
|
+
manufacturer: "Another Manufacturer",
|
|
41
|
+
state: "connected",
|
|
42
|
+
send: vi.fn(),
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
]),
|
|
46
|
+
inputs: new Map([["input-1", mockInput]]),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Mock navigator.requestMIDIAccess
|
|
50
|
+
global.navigator = {
|
|
51
|
+
requestMIDIAccess: vi.fn().mockResolvedValue(mockMIDIAccess),
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
// Restore original navigator
|
|
57
|
+
global.navigator = originalNavigator
|
|
58
|
+
vi.clearAllMocks()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe("constructor", () => {
|
|
62
|
+
it("should create instance with default options", () => {
|
|
63
|
+
const connection = new MIDIConnection()
|
|
64
|
+
expect(connection.options.sysex).toBe(false)
|
|
65
|
+
expect(connection.midiAccess).toBeNull()
|
|
66
|
+
expect(connection.output).toBeNull()
|
|
67
|
+
expect(connection.input).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("should merge custom options", () => {
|
|
71
|
+
const connection = new MIDIConnection({ sysex: true })
|
|
72
|
+
expect(connection.options.sysex).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("requestAccess", () => {
|
|
77
|
+
let connection
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
connection = new MIDIConnection({ sysex: true })
|
|
81
|
+
// Spy on emit method
|
|
82
|
+
vi.spyOn(connection, "emit")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("should request MIDI access successfully", async () => {
|
|
86
|
+
const connection = new MIDIConnection({ sysex: true })
|
|
87
|
+
await connection.requestAccess()
|
|
88
|
+
|
|
89
|
+
expect(navigator.requestMIDIAccess).toHaveBeenCalledWith({
|
|
90
|
+
sysex: true,
|
|
91
|
+
})
|
|
92
|
+
expect(connection.midiAccess).toBe(mockMIDIAccess)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("should emit devicechange event when input is connected", async () => {
|
|
96
|
+
await connection.requestAccess()
|
|
97
|
+
|
|
98
|
+
// Simulate device connection
|
|
99
|
+
const mockPort = {
|
|
100
|
+
id: "test-port-1",
|
|
101
|
+
type: "input",
|
|
102
|
+
name: "Test Input Device",
|
|
103
|
+
manufacturer: "Test Manufacturer",
|
|
104
|
+
state: "connected",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
connection.midiAccess.onstatechange({ port: mockPort })
|
|
108
|
+
|
|
109
|
+
expect(connection.emit).toHaveBeenCalledWith(CONNECTION_EVENTS.DEVICE_CHANGE, {
|
|
110
|
+
port: mockPort,
|
|
111
|
+
state: "connected",
|
|
112
|
+
type: "input",
|
|
113
|
+
device: {
|
|
114
|
+
id: "test-port-1",
|
|
115
|
+
name: "Test Input Device",
|
|
116
|
+
manufacturer: "Test Manufacturer",
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("should emit devicechange event when output is connected", async () => {
|
|
122
|
+
await connection.requestAccess()
|
|
123
|
+
|
|
124
|
+
// Simulate device connection
|
|
125
|
+
const mockPort = {
|
|
126
|
+
id: "test-port-2",
|
|
127
|
+
type: "output",
|
|
128
|
+
name: "Test Output Device",
|
|
129
|
+
manufacturer: "Test Manufacturer",
|
|
130
|
+
state: "connected",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
connection.midiAccess.onstatechange({ port: mockPort })
|
|
134
|
+
|
|
135
|
+
expect(connection.emit).toHaveBeenCalledWith(CONNECTION_EVENTS.DEVICE_CHANGE, {
|
|
136
|
+
port: mockPort,
|
|
137
|
+
state: "connected",
|
|
138
|
+
type: "output",
|
|
139
|
+
device: {
|
|
140
|
+
id: "test-port-2",
|
|
141
|
+
name: "Test Output Device",
|
|
142
|
+
manufacturer: "Test Manufacturer",
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("should emit inputdisconnect when current input is disconnected", async () => {
|
|
148
|
+
await connection.requestAccess()
|
|
149
|
+
|
|
150
|
+
// Connect an input first
|
|
151
|
+
await connection.connectInput(0, vi.fn())
|
|
152
|
+
expect(connection.input).toBe(mockInput)
|
|
153
|
+
|
|
154
|
+
// Clear the emit spy to track only the disconnect call
|
|
155
|
+
connection.emit.mockClear()
|
|
156
|
+
|
|
157
|
+
// Simulate device disconnection
|
|
158
|
+
const mockPort = {
|
|
159
|
+
id: "test-input-1",
|
|
160
|
+
type: "input",
|
|
161
|
+
name: "Test Input Device",
|
|
162
|
+
manufacturer: "Test Manufacturer",
|
|
163
|
+
state: "disconnected",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
connection.midiAccess.onstatechange({ port: mockPort })
|
|
167
|
+
|
|
168
|
+
expect(connection.input).toBeNull()
|
|
169
|
+
expect(connection.emit).toHaveBeenCalledWith(CONNECTION_EVENTS.INPUT_DEVICE_DISCONNECTED, {
|
|
170
|
+
device: mockPort,
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("should emit outputdisconnect when current output is disconnected", async () => {
|
|
175
|
+
await connection.requestAccess()
|
|
176
|
+
|
|
177
|
+
// Connect an output first
|
|
178
|
+
await connection.connect()
|
|
179
|
+
expect(connection.output).toBe(mockOutput)
|
|
180
|
+
|
|
181
|
+
// Clear the emit spy to track only the disconnect call
|
|
182
|
+
connection.emit.mockClear()
|
|
183
|
+
|
|
184
|
+
// Simulate device disconnection
|
|
185
|
+
const mockPort = {
|
|
186
|
+
id: "test-output-1",
|
|
187
|
+
type: "output",
|
|
188
|
+
name: "Test Output Device",
|
|
189
|
+
manufacturer: "Test Manufacturer",
|
|
190
|
+
state: "disconnected",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
connection.midiAccess.onstatechange({ port: mockPort })
|
|
194
|
+
|
|
195
|
+
expect(connection.output).toBeNull()
|
|
196
|
+
expect(connection.emit).toHaveBeenCalledWith(CONNECTION_EVENTS.OUTPUT_DEVICE_DISCONNECTED, {
|
|
197
|
+
device: mockPort,
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("should not emit disconnect events for other devices", async () => {
|
|
202
|
+
await connection.requestAccess()
|
|
203
|
+
await connection.connect() // Connect to test-output-1
|
|
204
|
+
|
|
205
|
+
// Clear the emit spy
|
|
206
|
+
connection.emit.mockClear()
|
|
207
|
+
|
|
208
|
+
// Simulate a different device disconnecting
|
|
209
|
+
const mockPort = {
|
|
210
|
+
id: "different-output",
|
|
211
|
+
type: "output",
|
|
212
|
+
name: "Different Output",
|
|
213
|
+
manufacturer: "Test Manufacturer",
|
|
214
|
+
state: "disconnected",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
connection.midiAccess.onstatechange({ port: mockPort })
|
|
218
|
+
|
|
219
|
+
// Should emit devicechange and outputdisconnect (code emits for ALL disconnects)
|
|
220
|
+
expect(connection.emit).toHaveBeenCalledWith(
|
|
221
|
+
CONNECTION_EVENTS.DEVICE_CHANGE,
|
|
222
|
+
expect.any(Object),
|
|
223
|
+
)
|
|
224
|
+
expect(connection.emit).toHaveBeenCalledWith(
|
|
225
|
+
CONNECTION_EVENTS.OUTPUT_DEVICE_DISCONNECTED,
|
|
226
|
+
expect.any(Object),
|
|
227
|
+
)
|
|
228
|
+
expect(connection.output).not.toBeNull() // Still connected to original output
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("should handle manufacturer being undefined", async () => {
|
|
232
|
+
await connection.requestAccess()
|
|
233
|
+
|
|
234
|
+
// Simulate device connection without manufacturer
|
|
235
|
+
const mockPort = {
|
|
236
|
+
id: "test-port-3",
|
|
237
|
+
type: "input",
|
|
238
|
+
name: "Device without manufacturer",
|
|
239
|
+
manufacturer: undefined,
|
|
240
|
+
state: "connected",
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
connection.midiAccess.onstatechange({ port: mockPort })
|
|
244
|
+
|
|
245
|
+
expect(connection.emit).toHaveBeenCalledWith(
|
|
246
|
+
CONNECTION_EVENTS.DEVICE_CHANGE,
|
|
247
|
+
expect.objectContaining({
|
|
248
|
+
device: {
|
|
249
|
+
id: "test-port-3",
|
|
250
|
+
name: "Device without manufacturer",
|
|
251
|
+
manufacturer: "Unknown",
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it("should throw error if Web MIDI API not supported", async () => {
|
|
258
|
+
global.navigator.requestMIDIAccess = undefined
|
|
259
|
+
const connection = new MIDIConnection()
|
|
260
|
+
|
|
261
|
+
await expect(connection.requestAccess()).rejects.toThrow(
|
|
262
|
+
"Web MIDI API is not supported in this browser",
|
|
263
|
+
)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it("should handle SecurityError", async () => {
|
|
267
|
+
global.navigator.requestMIDIAccess = vi.fn().mockRejectedValue({ name: "SecurityError" })
|
|
268
|
+
|
|
269
|
+
const connection = new MIDIConnection()
|
|
270
|
+
await expect(connection.requestAccess()).rejects.toThrow(
|
|
271
|
+
"MIDI access denied. SysEx requires user permission.",
|
|
272
|
+
)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it("should handle other errors", async () => {
|
|
276
|
+
global.navigator.requestMIDIAccess = vi.fn().mockRejectedValue(new Error("Unknown error"))
|
|
277
|
+
|
|
278
|
+
const connection = new MIDIConnection()
|
|
279
|
+
await expect(connection.requestAccess()).rejects.toThrow(
|
|
280
|
+
"Failed to get MIDI access: Unknown error",
|
|
281
|
+
)
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe("getOutputs", () => {
|
|
286
|
+
it("should return outputs when MIDI access is available", async () => {
|
|
287
|
+
const connection = new MIDIConnection()
|
|
288
|
+
await connection.requestAccess()
|
|
289
|
+
|
|
290
|
+
const outputs = connection.getOutputs()
|
|
291
|
+
expect(outputs).toHaveLength(2)
|
|
292
|
+
expect(outputs[0]).toEqual({
|
|
293
|
+
id: "test-output-1",
|
|
294
|
+
name: "Test Output Device",
|
|
295
|
+
manufacturer: "Test Manufacturer",
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it("should return empty array when no MIDI access", () => {
|
|
300
|
+
const connection = new MIDIConnection()
|
|
301
|
+
const outputs = connection.getOutputs()
|
|
302
|
+
expect(outputs).toEqual([])
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("should handle outputs without manufacturer", async () => {
|
|
306
|
+
const outputWithoutManufacturer = {
|
|
307
|
+
id: "output-no-manufacturer",
|
|
308
|
+
name: "Device without manufacturer",
|
|
309
|
+
state: "connected",
|
|
310
|
+
send: vi.fn(),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
mockMIDIAccess.outputs.set("output-3", outputWithoutManufacturer)
|
|
314
|
+
|
|
315
|
+
const connection = new MIDIConnection()
|
|
316
|
+
await connection.requestAccess()
|
|
317
|
+
|
|
318
|
+
const outputs = connection.getOutputs()
|
|
319
|
+
const device = outputs.find((o) => o.id === "output-no-manufacturer")
|
|
320
|
+
expect(device.manufacturer).toBe("Unknown")
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
describe("getInputs", () => {
|
|
325
|
+
it("should return inputs when MIDI access is available", async () => {
|
|
326
|
+
const connection = new MIDIConnection()
|
|
327
|
+
await connection.requestAccess()
|
|
328
|
+
|
|
329
|
+
const inputs = connection.getInputs()
|
|
330
|
+
expect(inputs).toHaveLength(1)
|
|
331
|
+
expect(inputs[0]).toEqual({
|
|
332
|
+
id: "test-input-1",
|
|
333
|
+
name: "Test Input Device",
|
|
334
|
+
manufacturer: "Test Manufacturer",
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it("should return empty array when no MIDI access", () => {
|
|
339
|
+
const connection = new MIDIConnection()
|
|
340
|
+
const inputs = connection.getInputs()
|
|
341
|
+
expect(inputs).toEqual([])
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it("should handle inputs without manufacturer", async () => {
|
|
345
|
+
const inputWithoutManufacturer = {
|
|
346
|
+
id: "input-no-manufacturer",
|
|
347
|
+
name: "Input without manufacturer",
|
|
348
|
+
state: "connected",
|
|
349
|
+
onmidimessage: null,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
mockMIDIAccess.inputs.set("input-3", inputWithoutManufacturer)
|
|
353
|
+
|
|
354
|
+
const connection = new MIDIConnection()
|
|
355
|
+
await connection.requestAccess()
|
|
356
|
+
|
|
357
|
+
const inputs = connection.getInputs()
|
|
358
|
+
const device = inputs.find((i) => i.id === "input-no-manufacturer")
|
|
359
|
+
expect(device.manufacturer).toBe("Unknown")
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe("connect", () => {
|
|
364
|
+
it("should connect to first available output by default", async () => {
|
|
365
|
+
const connection = new MIDIConnection()
|
|
366
|
+
await connection.requestAccess()
|
|
367
|
+
await connection.connect()
|
|
368
|
+
|
|
369
|
+
expect(connection.output).toBe(mockOutput)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it("should connect by index", async () => {
|
|
373
|
+
const connection = new MIDIConnection()
|
|
374
|
+
await connection.requestAccess()
|
|
375
|
+
await connection.connect(1)
|
|
376
|
+
|
|
377
|
+
expect(connection.output.id).toBe("output-2")
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it("should connect by name", async () => {
|
|
381
|
+
const connection = new MIDIConnection()
|
|
382
|
+
await connection.requestAccess()
|
|
383
|
+
await connection.connect("Test Output Device")
|
|
384
|
+
|
|
385
|
+
expect(connection.output).toBe(mockOutput)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it("should connect by ID", async () => {
|
|
389
|
+
const connection = new MIDIConnection()
|
|
390
|
+
await connection.requestAccess()
|
|
391
|
+
await connection.connect("test-output-1")
|
|
392
|
+
|
|
393
|
+
expect(connection.output).toBe(mockOutput)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it("should throw error if MIDI access not initialized", async () => {
|
|
397
|
+
const connection = new MIDIConnection()
|
|
398
|
+
await expect(connection.connect()).rejects.toThrow(
|
|
399
|
+
"MIDI access not initialized. Call requestAccess() first.",
|
|
400
|
+
)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it("should throw error if no outputs available", async () => {
|
|
404
|
+
mockMIDIAccess.outputs.clear()
|
|
405
|
+
const connection = new MIDIConnection()
|
|
406
|
+
await connection.requestAccess()
|
|
407
|
+
|
|
408
|
+
await expect(connection.connect()).rejects.toThrow("No MIDI output devices available")
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it("should throw error for out of range index", async () => {
|
|
412
|
+
const connection = new MIDIConnection()
|
|
413
|
+
await connection.requestAccess()
|
|
414
|
+
|
|
415
|
+
await expect(connection.connect(-1)).rejects.toThrow("Output index -1 out of range")
|
|
416
|
+
await expect(connection.connect(99)).rejects.toThrow("Output index 99 out of range")
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it("should throw error if device not found", async () => {
|
|
420
|
+
const connection = new MIDIConnection()
|
|
421
|
+
await connection.requestAccess()
|
|
422
|
+
|
|
423
|
+
await expect(connection.connect("Non-existent Device")).rejects.toThrow(
|
|
424
|
+
'MIDI output "Non-existent Device" not found',
|
|
425
|
+
)
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
describe("connectInput", () => {
|
|
430
|
+
const mockOnMessage = vi.fn()
|
|
431
|
+
|
|
432
|
+
it("should validate onMessage is a function", async () => {
|
|
433
|
+
const connection = new MIDIConnection()
|
|
434
|
+
await connection.requestAccess()
|
|
435
|
+
|
|
436
|
+
await expect(connection.connectInput(0, "not a function")).rejects.toThrow(
|
|
437
|
+
/onMessage callback must be a function/,
|
|
438
|
+
)
|
|
439
|
+
await expect(connection.connectInput(0, null)).rejects.toThrow(
|
|
440
|
+
/onMessage callback must be a function/,
|
|
441
|
+
)
|
|
442
|
+
await expect(connection.connectInput(0, undefined)).rejects.toThrow(
|
|
443
|
+
/onMessage callback must be a function/,
|
|
444
|
+
)
|
|
445
|
+
await expect(connection.connectInput(0, 123)).rejects.toThrow(
|
|
446
|
+
/onMessage callback must be a function/,
|
|
447
|
+
)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it("should connect to first available input by default", async () => {
|
|
451
|
+
const connection = new MIDIConnection()
|
|
452
|
+
await connection.requestAccess()
|
|
453
|
+
await connection.connectInput(undefined, mockOnMessage)
|
|
454
|
+
|
|
455
|
+
expect(connection.input).toBe(mockInput)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it("should disconnect existing input before connecting new one", async () => {
|
|
459
|
+
const connection = new MIDIConnection()
|
|
460
|
+
await connection.requestAccess()
|
|
461
|
+
|
|
462
|
+
const firstHandler = vi.fn()
|
|
463
|
+
// Connect first input
|
|
464
|
+
await connection.connectInput(0, firstHandler)
|
|
465
|
+
const firstOnMessage = mockInput.onmidimessage
|
|
466
|
+
expect(firstOnMessage).toBeTruthy()
|
|
467
|
+
|
|
468
|
+
// Connect to different input device
|
|
469
|
+
mockMIDIAccess.inputs.set("input-2", {
|
|
470
|
+
id: "input-2",
|
|
471
|
+
name: "Second Input",
|
|
472
|
+
manufacturer: "Test",
|
|
473
|
+
state: "connected",
|
|
474
|
+
onmidimessage: null,
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
await connection.connectInput("Second Input", mockOnMessage)
|
|
478
|
+
// First input should be disconnected (onmidimessage set to null)
|
|
479
|
+
// Even though it's mocked, we verify the disconnect happened
|
|
480
|
+
expect(connection.input.id).toBe("input-2")
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it("should set up message handler", async () => {
|
|
484
|
+
const connection = new MIDIConnection()
|
|
485
|
+
await connection.requestAccess()
|
|
486
|
+
await connection.connectInput(0, mockOnMessage)
|
|
487
|
+
|
|
488
|
+
// Simulate MIDI message
|
|
489
|
+
const mockEvent = { data: [0x90, 60, 100], midiwire: 1234 }
|
|
490
|
+
connection.input.onmidimessage(mockEvent)
|
|
491
|
+
|
|
492
|
+
expect(mockOnMessage).toHaveBeenCalledWith(mockEvent)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it("should throw error if MIDI access not initialized", async () => {
|
|
496
|
+
const connection = new MIDIConnection()
|
|
497
|
+
await expect(connection.connectInput(0, mockOnMessage)).rejects.toThrow(
|
|
498
|
+
"MIDI access not initialized. Call requestAccess() first.",
|
|
499
|
+
)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it("should throw error if no inputs available", async () => {
|
|
503
|
+
mockMIDIAccess.inputs.clear()
|
|
504
|
+
const connection = new MIDIConnection()
|
|
505
|
+
await connection.requestAccess()
|
|
506
|
+
|
|
507
|
+
await expect(connection.connectInput(0, mockOnMessage)).rejects.toThrow(
|
|
508
|
+
"No MIDI input devices available",
|
|
509
|
+
)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it("should throw error for out of range index", async () => {
|
|
513
|
+
const connection = new MIDIConnection()
|
|
514
|
+
await connection.requestAccess()
|
|
515
|
+
|
|
516
|
+
await expect(connection.connectInput(99, mockOnMessage)).rejects.toThrow(
|
|
517
|
+
"Input index 99 out of range",
|
|
518
|
+
)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it("should throw error if input device not found", async () => {
|
|
522
|
+
const connection = new MIDIConnection()
|
|
523
|
+
await connection.requestAccess()
|
|
524
|
+
|
|
525
|
+
await expect(connection.connectInput("Non-existent Input", mockOnMessage)).rejects.toThrow(
|
|
526
|
+
'MIDI input "Non-existent Input" not found',
|
|
527
|
+
)
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
describe("send", () => {
|
|
532
|
+
it("should send MIDI message without timestamp", async () => {
|
|
533
|
+
const connection = new MIDIConnection()
|
|
534
|
+
await connection.requestAccess()
|
|
535
|
+
await connection.connect()
|
|
536
|
+
|
|
537
|
+
const message = [0x90, 60, 100]
|
|
538
|
+
connection.send(message)
|
|
539
|
+
|
|
540
|
+
expect(mockOutput.send).toHaveBeenCalledWith(new Uint8Array(message))
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it("should send MIDI message with timestamp", async () => {
|
|
544
|
+
const connection = new MIDIConnection()
|
|
545
|
+
await connection.requestAccess()
|
|
546
|
+
await connection.connect()
|
|
547
|
+
|
|
548
|
+
const message = [0x90, 60, 100]
|
|
549
|
+
const timestamp = performance.now() + 1000
|
|
550
|
+
connection.send(message, timestamp)
|
|
551
|
+
|
|
552
|
+
expect(mockOutput.send).toHaveBeenCalledWith(new Uint8Array(message), timestamp)
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it("should convert array to Uint8Array", async () => {
|
|
556
|
+
const connection = new MIDIConnection()
|
|
557
|
+
await connection.requestAccess()
|
|
558
|
+
await connection.connect()
|
|
559
|
+
|
|
560
|
+
const message = [0xb0, 7, 64]
|
|
561
|
+
connection.send(message)
|
|
562
|
+
|
|
563
|
+
expect(mockOutput.send).toHaveBeenCalledWith(new Uint8Array(message))
|
|
564
|
+
expect(mockOutput.send.mock.calls[0][0]).toBeInstanceOf(Uint8Array)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
it("should warn if no output connected", async () => {
|
|
568
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
569
|
+
const connection = new MIDIConnection()
|
|
570
|
+
await connection.requestAccess()
|
|
571
|
+
|
|
572
|
+
connection.send([0x90, 60, 100])
|
|
573
|
+
|
|
574
|
+
expect(consoleSpy).toHaveBeenCalledWith("No MIDI output connected. Call connect() first.")
|
|
575
|
+
consoleSpy.mockRestore()
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it("should handle send errors gracefully", async () => {
|
|
579
|
+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
580
|
+
const connection = new MIDIConnection()
|
|
581
|
+
await connection.requestAccess()
|
|
582
|
+
await connection.connect()
|
|
583
|
+
|
|
584
|
+
mockOutput.send.mockImplementationOnce(() => {
|
|
585
|
+
throw new Error("Send failed")
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
connection.send([0x90, 60, 100])
|
|
589
|
+
|
|
590
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
591
|
+
"Failed to send MIDI message:",
|
|
592
|
+
expect.any(Error),
|
|
593
|
+
)
|
|
594
|
+
consoleErrorSpy.mockRestore()
|
|
595
|
+
})
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
describe("sendSysEx", () => {
|
|
599
|
+
it("should send SysEx message with wrapper", async () => {
|
|
600
|
+
const connection = new MIDIConnection({ sysex: true })
|
|
601
|
+
await connection.requestAccess()
|
|
602
|
+
await connection.connect()
|
|
603
|
+
|
|
604
|
+
const data = [0x42, 0x30, 0x00, 0x01, 0x2f, 0x12]
|
|
605
|
+
connection.sendSysEx(data, true)
|
|
606
|
+
|
|
607
|
+
expect(mockOutput.send).toHaveBeenCalledWith(new Uint8Array([0xf0, ...data, 0xf7]))
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it("should send SysEx message without adding wrapper", async () => {
|
|
611
|
+
const connection = new MIDIConnection({ sysex: true })
|
|
612
|
+
await connection.requestAccess()
|
|
613
|
+
await connection.connect()
|
|
614
|
+
|
|
615
|
+
const data = [0xf0, 0x42, 0x30, 0x00, 0x01, 0x2f, 0x12, 0xf7]
|
|
616
|
+
connection.sendSysEx(data)
|
|
617
|
+
|
|
618
|
+
expect(mockOutput.send).toHaveBeenCalledWith(new Uint8Array(data))
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it("should warn if SysEx not enabled", async () => {
|
|
622
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
623
|
+
const connection = new MIDIConnection() // sysex defaults to false
|
|
624
|
+
await connection.requestAccess()
|
|
625
|
+
await connection.connect()
|
|
626
|
+
|
|
627
|
+
connection.sendSysEx([0x42, 0x30, 0x00])
|
|
628
|
+
|
|
629
|
+
expect(consoleSpy).toHaveBeenCalledWith("SysEx not enabled. Initialize with sysex: true")
|
|
630
|
+
expect(mockOutput.send).not.toHaveBeenCalled()
|
|
631
|
+
consoleSpy.mockRestore()
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it("should warn if sysex disabled and wrapper included", async () => {
|
|
635
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
636
|
+
const connection = new MIDIConnection() // sysex defaults to false
|
|
637
|
+
await connection.requestAccess()
|
|
638
|
+
await connection.connect()
|
|
639
|
+
|
|
640
|
+
connection.sendSysEx([0xf0, 0x42, 0xf7], true)
|
|
641
|
+
|
|
642
|
+
expect(consoleSpy).toHaveBeenCalledWith("SysEx not enabled. Initialize with sysex: true")
|
|
643
|
+
consoleSpy.mockRestore()
|
|
644
|
+
})
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
describe("disconnect", () => {
|
|
648
|
+
it("should disconnect from output and input", async () => {
|
|
649
|
+
const connection = new MIDIConnection()
|
|
650
|
+
await connection.requestAccess()
|
|
651
|
+
await connection.connect()
|
|
652
|
+
await connection.connectInput(0, vi.fn())
|
|
653
|
+
|
|
654
|
+
expect(connection.output).toBeTruthy()
|
|
655
|
+
expect(connection.input).toBeTruthy()
|
|
656
|
+
|
|
657
|
+
connection.disconnect()
|
|
658
|
+
|
|
659
|
+
expect(connection.output).toBeNull()
|
|
660
|
+
expect(connection.input).toBeNull()
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it("should clear input message handler", async () => {
|
|
664
|
+
const mockOnMessage = vi.fn()
|
|
665
|
+
const connection = new MIDIConnection()
|
|
666
|
+
await connection.requestAccess()
|
|
667
|
+
await connection.connectInput(0, mockOnMessage)
|
|
668
|
+
|
|
669
|
+
expect(connection.input.onmidimessage).toBeTruthy()
|
|
670
|
+
|
|
671
|
+
connection.disconnect()
|
|
672
|
+
|
|
673
|
+
expect(connection.input).toBeNull()
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it("should handle disconnect when already disconnected", () => {
|
|
677
|
+
const connection = new MIDIConnection()
|
|
678
|
+
expect(() => connection.disconnect()).not.toThrow()
|
|
679
|
+
})
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
describe("isConnected", () => {
|
|
683
|
+
it("should return true when connected", async () => {
|
|
684
|
+
const connection = new MIDIConnection()
|
|
685
|
+
await connection.requestAccess()
|
|
686
|
+
await connection.connect()
|
|
687
|
+
|
|
688
|
+
expect(connection.isConnected()).toBe(true)
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
it("should return false when not connected", () => {
|
|
692
|
+
const connection = new MIDIConnection()
|
|
693
|
+
expect(connection.isConnected()).toBe(false)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
it("should return false after disconnect", async () => {
|
|
697
|
+
const connection = new MIDIConnection()
|
|
698
|
+
await connection.requestAccess()
|
|
699
|
+
await connection.connect()
|
|
700
|
+
connection.disconnect()
|
|
701
|
+
|
|
702
|
+
expect(connection.isConnected()).toBe(false)
|
|
703
|
+
})
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
describe("getCurrentOutput", () => {
|
|
707
|
+
it("should return current output info", async () => {
|
|
708
|
+
const connection = new MIDIConnection()
|
|
709
|
+
await connection.requestAccess()
|
|
710
|
+
await connection.connect()
|
|
711
|
+
|
|
712
|
+
const output = connection.getCurrentOutput()
|
|
713
|
+
expect(output).toEqual({
|
|
714
|
+
id: "test-output-1",
|
|
715
|
+
name: "Test Output Device",
|
|
716
|
+
manufacturer: "Test Manufacturer",
|
|
717
|
+
})
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it("should return null when not connected", () => {
|
|
721
|
+
const connection = new MIDIConnection()
|
|
722
|
+
expect(connection.getCurrentOutput()).toBeNull()
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it("should handle output without manufacturer", async () => {
|
|
726
|
+
const outputWithoutManufacturer = {
|
|
727
|
+
id: "output-no-manufacturer",
|
|
728
|
+
name: "Device without manufacturer",
|
|
729
|
+
manufacturer: undefined,
|
|
730
|
+
state: "connected",
|
|
731
|
+
send: vi.fn(),
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
mockMIDIAccess.outputs.set("output-3", outputWithoutManufacturer)
|
|
735
|
+
|
|
736
|
+
const connection = new MIDIConnection()
|
|
737
|
+
await connection.requestAccess()
|
|
738
|
+
await connection.connect("Device without manufacturer")
|
|
739
|
+
|
|
740
|
+
const output = connection.getCurrentOutput()
|
|
741
|
+
expect(output.manufacturer).toBe("Unknown")
|
|
742
|
+
})
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
describe("getCurrentInput", () => {
|
|
746
|
+
it("should return current input info", async () => {
|
|
747
|
+
const connection = new MIDIConnection()
|
|
748
|
+
await connection.requestAccess()
|
|
749
|
+
await connection.connectInput(0, vi.fn())
|
|
750
|
+
|
|
751
|
+
const input = connection.getCurrentInput()
|
|
752
|
+
expect(input).toEqual({
|
|
753
|
+
id: "test-input-1",
|
|
754
|
+
name: "Test Input Device",
|
|
755
|
+
manufacturer: "Test Manufacturer",
|
|
756
|
+
})
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
it("should return null when not connected", () => {
|
|
760
|
+
const connection = new MIDIConnection()
|
|
761
|
+
expect(connection.getCurrentInput()).toBeNull()
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
it("should handle input without manufacturer", async () => {
|
|
765
|
+
const inputWithoutManufacturer = {
|
|
766
|
+
id: "input-no-manufacturer",
|
|
767
|
+
name: "Input without manufacturer",
|
|
768
|
+
manufacturer: undefined,
|
|
769
|
+
state: "connected",
|
|
770
|
+
onmidimessage: null,
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
mockMIDIAccess.inputs.set("input-3", inputWithoutManufacturer)
|
|
774
|
+
|
|
775
|
+
const connection = new MIDIConnection()
|
|
776
|
+
await connection.requestAccess()
|
|
777
|
+
await connection.connectInput("Input without manufacturer", vi.fn())
|
|
778
|
+
|
|
779
|
+
const input = connection.getCurrentInput()
|
|
780
|
+
expect(input.manufacturer).toBe("Unknown")
|
|
781
|
+
})
|
|
782
|
+
})
|
|
783
|
+
})
|