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,364 @@
|
|
|
1
|
+
import { EventEmitter } from "./EventEmitter.js"
|
|
2
|
+
import {
|
|
3
|
+
MIDIAccessError,
|
|
4
|
+
MIDIConnectionError,
|
|
5
|
+
MIDIDeviceError,
|
|
6
|
+
MIDIValidationError,
|
|
7
|
+
} from "./errors.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Connection event constants
|
|
11
|
+
*/
|
|
12
|
+
export const CONNECTION_EVENTS = {
|
|
13
|
+
DEVICE_CHANGE: "device-change",
|
|
14
|
+
INPUT_DEVICE_CONNECTED: "input-device-connected",
|
|
15
|
+
INPUT_DEVICE_DISCONNECTED: "input-device-disconnected",
|
|
16
|
+
OUTPUT_DEVICE_CONNECTED: "output-device-connected",
|
|
17
|
+
OUTPUT_DEVICE_DISCONNECTED: "output-device-disconnected",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Low-level Web MIDI API connection handler. Manages:
|
|
22
|
+
* - MIDI device access/request
|
|
23
|
+
* - Device enumeration (inputs/outputs)
|
|
24
|
+
* - Device connection/disconnection
|
|
25
|
+
* - Hotplug detection and events
|
|
26
|
+
* - Raw MIDI message sending/receiving
|
|
27
|
+
* - SysEx message handling
|
|
28
|
+
* - Connection state tracking
|
|
29
|
+
*
|
|
30
|
+
* NOTE: Typically used internally by MIDIController. Most applications
|
|
31
|
+
* should use MIDIController instead for higher-level APIs.
|
|
32
|
+
* @extends EventEmitter
|
|
33
|
+
*/
|
|
34
|
+
export class MIDIConnection extends EventEmitter {
|
|
35
|
+
/**
|
|
36
|
+
* @param {Object} options
|
|
37
|
+
* @param {boolean} [options.sysex=false] - Request SysEx access
|
|
38
|
+
*/
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
super()
|
|
41
|
+
this.options = {
|
|
42
|
+
sysex: false,
|
|
43
|
+
...options,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.midiAccess = null
|
|
47
|
+
this.output = null
|
|
48
|
+
this.input = null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Request MIDI access from the browser
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
* @throws {MIDIAccessError} If MIDI is not supported or access is denied
|
|
55
|
+
*/
|
|
56
|
+
async requestAccess() {
|
|
57
|
+
if (!navigator.requestMIDIAccess) {
|
|
58
|
+
throw new MIDIAccessError("Web MIDI API is not supported in this browser", "unsupported")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
this.midiAccess = await navigator.requestMIDIAccess({
|
|
63
|
+
sysex: this.options.sysex,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Set up device state change listener for hotplugged devices
|
|
67
|
+
this.midiAccess.onstatechange = (event) => {
|
|
68
|
+
const port = event.port
|
|
69
|
+
const state = event.port.state
|
|
70
|
+
|
|
71
|
+
// Emit device state change event
|
|
72
|
+
this.emit(CONNECTION_EVENTS.DEVICE_CHANGE, {
|
|
73
|
+
port,
|
|
74
|
+
state,
|
|
75
|
+
type: port.type,
|
|
76
|
+
device: {
|
|
77
|
+
id: port.id,
|
|
78
|
+
name: port.name,
|
|
79
|
+
manufacturer: port.manufacturer || "Unknown",
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Check if current devices were disconnected
|
|
84
|
+
if (state === "disconnected") {
|
|
85
|
+
// Emit events for ALL device disconnects, not just current ones
|
|
86
|
+
if (port.type === "input") {
|
|
87
|
+
this.emit(CONNECTION_EVENTS.INPUT_DEVICE_DISCONNECTED, { device: port })
|
|
88
|
+
// Clear current input if it was this device
|
|
89
|
+
if (this.input && this.input.id === port.id) {
|
|
90
|
+
this.input = null
|
|
91
|
+
}
|
|
92
|
+
} else if (port.type === "output") {
|
|
93
|
+
this.emit(CONNECTION_EVENTS.OUTPUT_DEVICE_DISCONNECTED, { device: port })
|
|
94
|
+
// Clear current output if it was this device
|
|
95
|
+
if (this.output && this.output.id === port.id) {
|
|
96
|
+
this.output = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} else if (state === "connected") {
|
|
100
|
+
if (port.type === "input") {
|
|
101
|
+
this.emit(CONNECTION_EVENTS.INPUT_DEVICE_CONNECTED, { device: port })
|
|
102
|
+
} else if (port.type === "output") {
|
|
103
|
+
this.emit(CONNECTION_EVENTS.OUTPUT_DEVICE_CONNECTED, { device: port })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err.name === "SecurityError") {
|
|
109
|
+
throw new MIDIAccessError("MIDI access denied. SysEx requires user permission.", "denied")
|
|
110
|
+
}
|
|
111
|
+
throw new MIDIAccessError(`Failed to get MIDI access: ${err.message}`, "failed")
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get all available MIDI outputs
|
|
117
|
+
* @returns {Array<{id: string, name: string, manufacturer: string}>}
|
|
118
|
+
*/
|
|
119
|
+
getOutputs() {
|
|
120
|
+
if (!this.midiAccess) return []
|
|
121
|
+
|
|
122
|
+
const outputs = []
|
|
123
|
+
this.midiAccess.outputs.forEach((output) => {
|
|
124
|
+
// Only include connected devices
|
|
125
|
+
if (output.state === "connected") {
|
|
126
|
+
outputs.push({
|
|
127
|
+
id: output.id,
|
|
128
|
+
name: output.name,
|
|
129
|
+
manufacturer: output.manufacturer || "Unknown",
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return outputs
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get all available MIDI inputs
|
|
139
|
+
* @returns {Array<{id: string, name: string, manufacturer: string}>}
|
|
140
|
+
*/
|
|
141
|
+
getInputs() {
|
|
142
|
+
if (!this.midiAccess) return []
|
|
143
|
+
|
|
144
|
+
const inputs = []
|
|
145
|
+
this.midiAccess.inputs.forEach((input) => {
|
|
146
|
+
if (input.state === "connected") {
|
|
147
|
+
inputs.push({
|
|
148
|
+
id: input.id,
|
|
149
|
+
name: input.name,
|
|
150
|
+
manufacturer: input.manufacturer || "Unknown",
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return inputs
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Connect to a MIDI output device
|
|
160
|
+
* @param {string|number} [device] - Device name, ID, or index (defaults to first available)
|
|
161
|
+
* @returns {Promise<void>}
|
|
162
|
+
* @throws {MIDIConnectionError} If MIDI access not initialized
|
|
163
|
+
* @throws {MIDIDeviceError} If device not found or index out of range
|
|
164
|
+
*/
|
|
165
|
+
async connect(device) {
|
|
166
|
+
if (!this.midiAccess) {
|
|
167
|
+
throw new MIDIConnectionError("MIDI access not initialized. Call requestAccess() first.")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const outputs = Array.from(this.midiAccess.outputs.values())
|
|
171
|
+
|
|
172
|
+
if (outputs.length === 0) {
|
|
173
|
+
throw new MIDIDeviceError("No MIDI output devices available", "output")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// If no device specified, use first available
|
|
177
|
+
if (device === undefined) {
|
|
178
|
+
this.output = outputs[0]
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Connect by index
|
|
183
|
+
if (typeof device === "number") {
|
|
184
|
+
if (device < 0 || device >= outputs.length) {
|
|
185
|
+
throw new MIDIDeviceError(
|
|
186
|
+
`Output index ${device} out of range (0-${outputs.length - 1})`,
|
|
187
|
+
"output",
|
|
188
|
+
device,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
this.output = outputs[device]
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Connect by name or ID
|
|
196
|
+
this.output = outputs.find((output) => output.name === device || output.id === device)
|
|
197
|
+
|
|
198
|
+
if (!this.output) {
|
|
199
|
+
const availableNames = outputs.map((o) => o.name).join(", ")
|
|
200
|
+
throw new MIDIDeviceError(
|
|
201
|
+
`MIDI output "${device}" not found. Available: ${availableNames}`,
|
|
202
|
+
"output",
|
|
203
|
+
device,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Connect to a MIDI input device for receiving messages
|
|
210
|
+
* @param {string|number} [device] - Device name, ID, or index (defaults to first available)
|
|
211
|
+
* @param {Function} onMessage - Callback for incoming MIDI messages
|
|
212
|
+
* @returns {Promise<void>}
|
|
213
|
+
* @throws {MIDIConnectionError} If MIDI access not initialized
|
|
214
|
+
* @throws {MIDIValidationError} If onMessage is not a function
|
|
215
|
+
* @throws {MIDIDeviceError} If device not found or index out of range
|
|
216
|
+
*/
|
|
217
|
+
async connectInput(device, onMessage) {
|
|
218
|
+
if (!this.midiAccess) {
|
|
219
|
+
throw new MIDIConnectionError("MIDI access not initialized. Call requestAccess() first.")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Validate onMessage is a function
|
|
223
|
+
if (typeof onMessage !== "function") {
|
|
224
|
+
throw new MIDIValidationError("onMessage callback must be a function", "callback")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const inputs = Array.from(this.midiAccess.inputs.values())
|
|
228
|
+
|
|
229
|
+
if (inputs.length === 0) {
|
|
230
|
+
throw new MIDIDeviceError("No MIDI input devices available", "input")
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Disconnect existing input
|
|
234
|
+
if (this.input) {
|
|
235
|
+
this.input.onmidimessage = null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// If no device specified, use first available
|
|
239
|
+
if (device === undefined) {
|
|
240
|
+
this.input = inputs[0]
|
|
241
|
+
} else if (typeof device === "number") {
|
|
242
|
+
// Connect by index
|
|
243
|
+
if (device < 0 || device >= inputs.length) {
|
|
244
|
+
throw new MIDIDeviceError(
|
|
245
|
+
`Input index ${device} out of range (0-${inputs.length - 1})`,
|
|
246
|
+
"input",
|
|
247
|
+
device,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
this.input = inputs[device]
|
|
251
|
+
} else {
|
|
252
|
+
// Connect by name or ID
|
|
253
|
+
this.input = inputs.find((input) => input.name === device || input.id === device)
|
|
254
|
+
|
|
255
|
+
if (!this.input) {
|
|
256
|
+
const availableNames = inputs.map((i) => i.name).join(", ")
|
|
257
|
+
throw new MIDIDeviceError(
|
|
258
|
+
`MIDI input "${device}" not found. Available: ${availableNames}`,
|
|
259
|
+
"input",
|
|
260
|
+
device,
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Set up message handler
|
|
266
|
+
this.input.onmidimessage = (event) => {
|
|
267
|
+
onMessage(event)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Send a MIDI message
|
|
273
|
+
* @param {Uint8Array|Array<number>} message - MIDI message bytes
|
|
274
|
+
* @param {number} [timestamp=performance.now()] - Optional timestamp
|
|
275
|
+
*/
|
|
276
|
+
send(message, timestamp = null) {
|
|
277
|
+
if (!this.output) {
|
|
278
|
+
console.warn("No MIDI output connected. Call connect() first.")
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
// Convert to Uint8Array for Web MIDI API
|
|
284
|
+
const data = new Uint8Array(message)
|
|
285
|
+
|
|
286
|
+
if (timestamp === null) {
|
|
287
|
+
this.output.send(data)
|
|
288
|
+
} else {
|
|
289
|
+
this.output.send(data, timestamp)
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error("Failed to send MIDI message:", err)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Send a SysEx message
|
|
298
|
+
* @param {Array<number>} data - SysEx data bytes (without F0/F7 wrapper)
|
|
299
|
+
* @param {boolean} [includeWrapper=false] - If true, data already includes F0/F7
|
|
300
|
+
*/
|
|
301
|
+
sendSysEx(data, includeWrapper = false) {
|
|
302
|
+
if (!this.options.sysex) {
|
|
303
|
+
console.warn("SysEx not enabled. Initialize with sysex: true")
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let message
|
|
308
|
+
if (includeWrapper) {
|
|
309
|
+
// Add SysEx wrapper bytes
|
|
310
|
+
message = [0xf0, ...data, 0xf7]
|
|
311
|
+
} else {
|
|
312
|
+
message = data
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
this.send(message)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Disconnect from current output and input
|
|
320
|
+
*/
|
|
321
|
+
disconnect() {
|
|
322
|
+
if (this.input) {
|
|
323
|
+
this.input.onmidimessage = null
|
|
324
|
+
this.input = null
|
|
325
|
+
}
|
|
326
|
+
this.output = null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Check if currently connected to an output
|
|
331
|
+
* @returns {boolean}
|
|
332
|
+
*/
|
|
333
|
+
isConnected() {
|
|
334
|
+
return this.output !== null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Get current output device info
|
|
339
|
+
* @returns {Object|null}
|
|
340
|
+
*/
|
|
341
|
+
getCurrentOutput() {
|
|
342
|
+
if (!this.output) return null
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
id: this.output.id,
|
|
346
|
+
name: this.output.name,
|
|
347
|
+
manufacturer: this.output.manufacturer || "Unknown",
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get current input device info
|
|
353
|
+
* @returns {Object|null}
|
|
354
|
+
*/
|
|
355
|
+
getCurrentInput() {
|
|
356
|
+
if (!this.input) return null
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
id: this.input.id,
|
|
360
|
+
name: this.input.name,
|
|
361
|
+
manufacturer: this.input.manufacturer || "Unknown",
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|