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.
@@ -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
+ }