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,756 @@
|
|
|
1
|
+
import { clamp, normalize14BitValue, normalizeValue } from "../utils/midi.js"
|
|
2
|
+
import { EventEmitter } from "./EventEmitter.js"
|
|
3
|
+
import { MIDIValidationError } from "./errors.js"
|
|
4
|
+
import { MIDIConnection } from "./MIDIConnection.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} PatchData
|
|
8
|
+
* @property {string} name - Patch name
|
|
9
|
+
* @property {string|null} device - Output device name
|
|
10
|
+
* @property {string} timestamp - ISO timestamp
|
|
11
|
+
* @property {string} version - Patch format version
|
|
12
|
+
* @property {Object.<number, ChannelData>} channels - Channel data indexed by channel number
|
|
13
|
+
* @property {Object.<string, SettingData>} settings - Control settings indexed by setting key
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} ChannelData
|
|
18
|
+
* @property {Object.<number, number>} ccs - CC values indexed by CC number
|
|
19
|
+
* @property {Object.<number, number>} notes - Note velocities indexed by note number
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} SettingData
|
|
24
|
+
* @property {number} min - Minimum value
|
|
25
|
+
* @property {number} max - Maximum value
|
|
26
|
+
* @property {boolean} invert - Invert flag
|
|
27
|
+
* @property {boolean} is14Bit - 14-bit CC flag
|
|
28
|
+
* @property {string|null} label - Optional label
|
|
29
|
+
* @property {string|null} elementId - Element ID if available
|
|
30
|
+
* @property {Function|null} onInput - Optional callback for value updates
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Controller event constants
|
|
35
|
+
*/
|
|
36
|
+
export const CONTROLLER_EVENTS = {
|
|
37
|
+
READY: "ready",
|
|
38
|
+
ERROR: "error",
|
|
39
|
+
CC_SEND: "cc-send",
|
|
40
|
+
CC_RECV: "cc-recv",
|
|
41
|
+
NOTE_ON_SEND: "note-on-send",
|
|
42
|
+
NOTE_ON_RECV: "note-on-recv",
|
|
43
|
+
NOTE_OFF_SEND: "note-off-send",
|
|
44
|
+
NOTE_OFF_RECV: "note-off-recv",
|
|
45
|
+
SYSEX_SEND: "sysex-send",
|
|
46
|
+
SYSEX_RECV: "sysex-recv",
|
|
47
|
+
OUTPUT_CHANGED: "output-changed",
|
|
48
|
+
INPUT_CONNECTED: "input-connected",
|
|
49
|
+
DESTROYED: "destroyed",
|
|
50
|
+
MIDI_MSG: "midi-msg",
|
|
51
|
+
PATCH_SAVED: "patch-saved",
|
|
52
|
+
PATCH_LOADED: "patch-loaded",
|
|
53
|
+
PATCH_DELETED: "patch-deleted",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Main controller for browser-based MIDI operations. Provides APIs for:
|
|
58
|
+
* - Device management (connect/disconnect, enumerate devices)
|
|
59
|
+
* - Control binding (DOM elements ↔ MIDI CC)
|
|
60
|
+
* - MIDI messaging (send/receive CC, Note, SysEx)
|
|
61
|
+
* - Patch management (save/load presets)
|
|
62
|
+
* - Event handling (MIDI messages, connection changes, errors)
|
|
63
|
+
* @extends EventEmitter
|
|
64
|
+
*/
|
|
65
|
+
export class MIDIController extends EventEmitter {
|
|
66
|
+
/**
|
|
67
|
+
* @param {Object} options
|
|
68
|
+
* @param {number} [options.channel=1] - Default MIDI channel (1-16)
|
|
69
|
+
* @param {string|number} [options.output] - MIDI output device
|
|
70
|
+
* @param {string|number} [options.input] - MIDI input device
|
|
71
|
+
* @param {boolean} [options.sysex=false] - Request SysEx access
|
|
72
|
+
* @param {boolean} [options.autoConnect=true] - Auto-connect to first available output
|
|
73
|
+
* @param {Function} [options.onReady] - Callback when MIDI is ready
|
|
74
|
+
* @param {Function} [options.onError] - Error handler
|
|
75
|
+
*/
|
|
76
|
+
constructor(options = {}) {
|
|
77
|
+
super()
|
|
78
|
+
|
|
79
|
+
this.options = {
|
|
80
|
+
channel: 1,
|
|
81
|
+
autoConnect: true,
|
|
82
|
+
sysex: false,
|
|
83
|
+
...options,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.connection = null
|
|
87
|
+
this.bindings = new Map()
|
|
88
|
+
this.state = new Map() // Track all CC values
|
|
89
|
+
this.initialized = false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Initialize MIDI access
|
|
94
|
+
* @returns {Promise<void>}
|
|
95
|
+
*/
|
|
96
|
+
async initialize() {
|
|
97
|
+
if (this.initialized) {
|
|
98
|
+
console.warn("MIDI Controller already initialized")
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
this.connection = new MIDIConnection({
|
|
104
|
+
sysex: this.options.sysex,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await this.connection.requestAccess()
|
|
108
|
+
|
|
109
|
+
if (this.options.autoConnect) {
|
|
110
|
+
await this.connection.connect(this.options.output)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Connect input if specified
|
|
114
|
+
if (this.options.input !== undefined) {
|
|
115
|
+
await this.connectInput(this.options.input)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.initialized = true
|
|
119
|
+
this.emit(CONTROLLER_EVENTS.READY, this)
|
|
120
|
+
this.options.onReady?.(this)
|
|
121
|
+
} catch (err) {
|
|
122
|
+
this.emit(CONTROLLER_EVENTS.ERROR, err)
|
|
123
|
+
this.options.onError?.(err)
|
|
124
|
+
throw err
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Connect to a MIDI input device for receiving messages
|
|
130
|
+
* @param {string|number} device - Device name, ID, or index
|
|
131
|
+
* @returns {Promise<void>}
|
|
132
|
+
*/
|
|
133
|
+
async connectInput(device) {
|
|
134
|
+
await this.connection.connectInput(device, (event) => {
|
|
135
|
+
this._handleMIDIMessage(event)
|
|
136
|
+
})
|
|
137
|
+
this.emit(CONTROLLER_EVENTS.INPUT_CONNECTED, this.connection.getCurrentInput())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Send a control change message
|
|
142
|
+
* @param {number} cc - CC number (0-127)
|
|
143
|
+
* @param {number} value - CC value (0-127)
|
|
144
|
+
* @param {number} [channel] - MIDI channel (defaults to controller channel)
|
|
145
|
+
*/
|
|
146
|
+
sendCC(cc, value, channel = this.options.channel) {
|
|
147
|
+
if (!this.initialized) {
|
|
148
|
+
console.warn("MIDI not initialized. Call initialize() first.")
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Validate inputs
|
|
153
|
+
cc = clamp(Math.round(cc), 0, 127)
|
|
154
|
+
value = clamp(Math.round(value), 0, 127)
|
|
155
|
+
channel = clamp(Math.round(channel), 1, 16)
|
|
156
|
+
|
|
157
|
+
const status = 0xb0 + (channel - 1) // Control Change status
|
|
158
|
+
this.connection.send([status, cc, value])
|
|
159
|
+
|
|
160
|
+
// Update state
|
|
161
|
+
const key = `${channel}:${cc}`
|
|
162
|
+
this.state.set(key, value)
|
|
163
|
+
|
|
164
|
+
this.emit(CONTROLLER_EVENTS.CC_SEND, { cc, value, channel })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Send a SysEx message
|
|
169
|
+
* @param {Array<number>} data - SysEx data bytes (without F0/F7 wrapper)
|
|
170
|
+
* @param {boolean} [includeWrapper=false] - If true, data already includes F0/F7
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* // Send with wrapper included
|
|
174
|
+
* midi.sendSysEx([0xF0, 0x42, 0x30, 0x00, 0x01, 0x2F, 0x12, 0xF7], true)
|
|
175
|
+
*/
|
|
176
|
+
sendSysEx(data, includeWrapper = false) {
|
|
177
|
+
if (!this.initialized) {
|
|
178
|
+
console.warn("MIDI not initialized. Call initialize() first.")
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!this.options.sysex) {
|
|
183
|
+
console.warn("SysEx not enabled. Initialize with sysex: true")
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.connection.sendSysEx(data, includeWrapper)
|
|
188
|
+
this.emit(CONTROLLER_EVENTS.SYSEX_SEND, { data, includeWrapper })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Send a note on message
|
|
193
|
+
* @param {number} note - Note number (0-127)
|
|
194
|
+
* @param {number} [velocity=64] - Note velocity (0-127)
|
|
195
|
+
* @param {number} [channel] - MIDI channel
|
|
196
|
+
*/
|
|
197
|
+
sendNoteOn(note, velocity = 64, channel = this.options.channel) {
|
|
198
|
+
if (!this.initialized) return
|
|
199
|
+
|
|
200
|
+
note = clamp(Math.round(note), 0, 127)
|
|
201
|
+
velocity = clamp(Math.round(velocity), 0, 127)
|
|
202
|
+
channel = clamp(Math.round(channel), 1, 16)
|
|
203
|
+
|
|
204
|
+
const status = 0x90 + (channel - 1)
|
|
205
|
+
this.connection.send([status, note, velocity])
|
|
206
|
+
|
|
207
|
+
this.emit(CONTROLLER_EVENTS.NOTE_ON_SEND, { note, velocity, channel })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Send a note off message
|
|
212
|
+
* @param {number} note - Note number (0-127)
|
|
213
|
+
* @param {number} [channel] - MIDI channel
|
|
214
|
+
* @param {number} [velocity=0] - Release velocity (0-127)
|
|
215
|
+
*/
|
|
216
|
+
sendNoteOff(note, channel = this.options.channel, velocity = 0) {
|
|
217
|
+
if (!this.initialized) return
|
|
218
|
+
|
|
219
|
+
note = clamp(Math.round(note), 0, 127)
|
|
220
|
+
velocity = clamp(Math.round(velocity), 0, 127)
|
|
221
|
+
channel = clamp(Math.round(channel), 1, 16)
|
|
222
|
+
|
|
223
|
+
// Use Note On with velocity 0 for better compatibility with some synths
|
|
224
|
+
const status = 0x90 + (channel - 1)
|
|
225
|
+
this.connection.send([status, note, velocity])
|
|
226
|
+
|
|
227
|
+
this.emit(CONTROLLER_EVENTS.NOTE_OFF_SEND, { note, channel, velocity })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Bind a control programmatically
|
|
232
|
+
* @param {HTMLElement} element - DOM element
|
|
233
|
+
* @param {Object} config - Binding configuration
|
|
234
|
+
* @param {number} config.cc - CC number
|
|
235
|
+
* @param {number} [config.min=0] - Minimum input value
|
|
236
|
+
* @param {number} [config.max=127] - Maximum input value
|
|
237
|
+
* @param {number} [config.channel] - Override channel
|
|
238
|
+
* @param {boolean} [config.invert=false] - Invert the value
|
|
239
|
+
* @param {Function} [config.onInput] - Optional callback for value updates (receives normalized element value)
|
|
240
|
+
* @param {Object} [options={}] - Additional options
|
|
241
|
+
* @param {number} [options.debounce=0] - Debounce delay in ms for high-frequency updates
|
|
242
|
+
* @returns {Function} Unbind function
|
|
243
|
+
*/
|
|
244
|
+
bind(element, config, options = {}) {
|
|
245
|
+
if (!element) {
|
|
246
|
+
console.warn("Cannot bind: element is null or undefined")
|
|
247
|
+
return () => {}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const binding = this._createBinding(element, config, options)
|
|
251
|
+
this.bindings.set(element, binding)
|
|
252
|
+
|
|
253
|
+
// Send initial value
|
|
254
|
+
if (this.initialized && this.connection?.isConnected()) {
|
|
255
|
+
binding.handler({ target: element })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return () => this.unbind(element)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Unbind a control
|
|
263
|
+
* @param {HTMLElement} element
|
|
264
|
+
*/
|
|
265
|
+
unbind(element) {
|
|
266
|
+
const binding = this.bindings.get(element)
|
|
267
|
+
if (binding) {
|
|
268
|
+
binding.destroy()
|
|
269
|
+
this.bindings.delete(element)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get current value of a CC
|
|
275
|
+
* @param {number} cc - CC number
|
|
276
|
+
* @param {number} [channel] - MIDI channel
|
|
277
|
+
* @returns {number|undefined}
|
|
278
|
+
*/
|
|
279
|
+
getCC(cc, channel = this.options.channel) {
|
|
280
|
+
const key = `${channel}:${cc}`
|
|
281
|
+
return this.state.get(key)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get all available MIDI outputs
|
|
286
|
+
* @returns {Array<{id: string, name: string, manufacturer: string}>}
|
|
287
|
+
*/
|
|
288
|
+
getOutputs() {
|
|
289
|
+
return this.connection?.getOutputs() || []
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get all available MIDI inputs
|
|
294
|
+
* @returns {Array<{id: string, name: string, manufacturer: string}>}
|
|
295
|
+
*/
|
|
296
|
+
getInputs() {
|
|
297
|
+
return this.connection?.getInputs() || []
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Switch to a different output device
|
|
302
|
+
* @param {string|number} output - Device name, ID, or index
|
|
303
|
+
* @returns {Promise<void>}
|
|
304
|
+
*/
|
|
305
|
+
async setOutput(output) {
|
|
306
|
+
await this.connection.connect(output)
|
|
307
|
+
this.emit(CONTROLLER_EVENTS.OUTPUT_CHANGED, this.connection.getCurrentOutput())
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Get current output device
|
|
312
|
+
* @returns {Object|null}
|
|
313
|
+
*/
|
|
314
|
+
getCurrentOutput() {
|
|
315
|
+
return this.connection?.getCurrentOutput() || null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get current input device
|
|
320
|
+
* @returns {Object|null}
|
|
321
|
+
*/
|
|
322
|
+
getCurrentInput() {
|
|
323
|
+
return this.connection?.getCurrentInput() || null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Clean up resources
|
|
328
|
+
*/
|
|
329
|
+
destroy() {
|
|
330
|
+
for (const binding of this.bindings.values()) {
|
|
331
|
+
binding.destroy()
|
|
332
|
+
}
|
|
333
|
+
this.bindings.clear()
|
|
334
|
+
this.state.clear()
|
|
335
|
+
this.connection?.disconnect()
|
|
336
|
+
this.initialized = false
|
|
337
|
+
this.emit(CONTROLLER_EVENTS.DESTROYED)
|
|
338
|
+
this.removeAllListeners()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Handle incoming MIDI messages
|
|
343
|
+
* @private
|
|
344
|
+
*/
|
|
345
|
+
_handleMIDIMessage(event) {
|
|
346
|
+
const [status, data1, data2] = event.data
|
|
347
|
+
const messageType = status & 0xf0
|
|
348
|
+
const channel = (status & 0x0f) + 1
|
|
349
|
+
|
|
350
|
+
// SysEx message
|
|
351
|
+
if (status === 0xf0) {
|
|
352
|
+
this.emit(CONTROLLER_EVENTS.SYSEX_RECV, {
|
|
353
|
+
data: Array.from(event.data),
|
|
354
|
+
timestamp: event.midiwire,
|
|
355
|
+
})
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Control Change
|
|
360
|
+
if (messageType === 0xb0) {
|
|
361
|
+
const key = `${channel}:${data1}`
|
|
362
|
+
this.state.set(key, data2)
|
|
363
|
+
|
|
364
|
+
this.emit(CONTROLLER_EVENTS.CC_RECV, {
|
|
365
|
+
cc: data1,
|
|
366
|
+
value: data2,
|
|
367
|
+
channel,
|
|
368
|
+
})
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Note On
|
|
373
|
+
if (messageType === 0x90 && data2 > 0) {
|
|
374
|
+
this.emit(CONTROLLER_EVENTS.NOTE_ON_RECV, {
|
|
375
|
+
note: data1,
|
|
376
|
+
velocity: data2,
|
|
377
|
+
channel,
|
|
378
|
+
})
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Note Off (either 0x80 or 0x90 with velocity 0)
|
|
383
|
+
if (messageType === 0x80 || (messageType === 0x90 && data2 === 0)) {
|
|
384
|
+
this.emit(CONTROLLER_EVENTS.NOTE_OFF_RECV, {
|
|
385
|
+
note: data1,
|
|
386
|
+
channel,
|
|
387
|
+
})
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Other messages
|
|
392
|
+
this.emit(CONTROLLER_EVENTS.MIDI_MSG, {
|
|
393
|
+
status,
|
|
394
|
+
data: [data1, data2],
|
|
395
|
+
channel,
|
|
396
|
+
timestamp: event.midiwire,
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Create a binding between an element and MIDI CC
|
|
402
|
+
* @private
|
|
403
|
+
*/
|
|
404
|
+
_createBinding(element, config, options = {}) {
|
|
405
|
+
const {
|
|
406
|
+
min = parseFloat(element.getAttribute("min")) || 0,
|
|
407
|
+
max = parseFloat(element.getAttribute("max")) || 127,
|
|
408
|
+
channel,
|
|
409
|
+
invert = false,
|
|
410
|
+
onInput = undefined,
|
|
411
|
+
} = config
|
|
412
|
+
const { debounce = 0 } = options
|
|
413
|
+
|
|
414
|
+
// Store resolved values back to config for patch saving
|
|
415
|
+
// Don't store channel if not explicitly provided - use dynamic midi.options.channel
|
|
416
|
+
const resolvedConfig = {
|
|
417
|
+
...config,
|
|
418
|
+
min,
|
|
419
|
+
max,
|
|
420
|
+
invert,
|
|
421
|
+
onInput,
|
|
422
|
+
}
|
|
423
|
+
if (channel !== undefined) {
|
|
424
|
+
resolvedConfig.channel = channel
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Handle 14-bit CC (MSB + LSB)
|
|
428
|
+
if (config.is14Bit) {
|
|
429
|
+
const { msb, lsb } = config
|
|
430
|
+
|
|
431
|
+
const handler = (event) => {
|
|
432
|
+
const value = parseFloat(event.target.value)
|
|
433
|
+
|
|
434
|
+
if (Number.isNaN(value)) return
|
|
435
|
+
|
|
436
|
+
// Normalize to 14-bit range (0-16383)
|
|
437
|
+
const { msb: msbValue, lsb: lsbValue } = normalize14BitValue(value, min, max, invert)
|
|
438
|
+
|
|
439
|
+
// Send MSB and LSB using dynamic channel
|
|
440
|
+
const channelToUse = channel || this.options.channel
|
|
441
|
+
this.sendCC(msb, msbValue, channelToUse)
|
|
442
|
+
this.sendCC(lsb, lsbValue, channelToUse)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Add debouncing if specified
|
|
446
|
+
let timeoutId = null
|
|
447
|
+
const debouncedHandler =
|
|
448
|
+
debounce > 0
|
|
449
|
+
? (event) => {
|
|
450
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
451
|
+
timeoutId = setTimeout(() => {
|
|
452
|
+
handler(event)
|
|
453
|
+
timeoutId = null
|
|
454
|
+
}, debounce)
|
|
455
|
+
}
|
|
456
|
+
: handler
|
|
457
|
+
|
|
458
|
+
element.addEventListener("input", debouncedHandler)
|
|
459
|
+
element.addEventListener("change", debouncedHandler)
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
element,
|
|
463
|
+
config: resolvedConfig,
|
|
464
|
+
handler,
|
|
465
|
+
destroy: () => {
|
|
466
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
467
|
+
element.removeEventListener("input", debouncedHandler)
|
|
468
|
+
element.removeEventListener("change", debouncedHandler)
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Handle 7-bit CC
|
|
474
|
+
const { cc } = config
|
|
475
|
+
|
|
476
|
+
const handler = (event) => {
|
|
477
|
+
const value = parseFloat(event.target.value)
|
|
478
|
+
|
|
479
|
+
if (Number.isNaN(value)) return
|
|
480
|
+
|
|
481
|
+
// Normalize to 0-127 MIDI range
|
|
482
|
+
const midiValue = normalizeValue(value, min, max, invert)
|
|
483
|
+
|
|
484
|
+
// Use dynamic channel from midi.options.channel
|
|
485
|
+
const channelToUse = channel === undefined ? this.options.channel : channel
|
|
486
|
+
this.sendCC(cc, midiValue, channelToUse)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Add debouncing if specified
|
|
490
|
+
let timeoutId = null
|
|
491
|
+
const debouncedHandler =
|
|
492
|
+
debounce > 0
|
|
493
|
+
? (event) => {
|
|
494
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
495
|
+
timeoutId = setTimeout(() => {
|
|
496
|
+
handler(event)
|
|
497
|
+
timeoutId = null
|
|
498
|
+
}, debounce)
|
|
499
|
+
}
|
|
500
|
+
: handler
|
|
501
|
+
|
|
502
|
+
element.addEventListener("input", debouncedHandler)
|
|
503
|
+
element.addEventListener("change", debouncedHandler)
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
element,
|
|
507
|
+
config: resolvedConfig,
|
|
508
|
+
handler,
|
|
509
|
+
destroy: () => {
|
|
510
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
511
|
+
element.removeEventListener("input", debouncedHandler)
|
|
512
|
+
element.removeEventListener("change", debouncedHandler)
|
|
513
|
+
},
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Get current state as a patch object
|
|
519
|
+
* @param {string} [name] - Optional patch name
|
|
520
|
+
* @returns {Object} Patch object
|
|
521
|
+
*/
|
|
522
|
+
getPatch(name = "Unnamed Patch") {
|
|
523
|
+
const patch = {
|
|
524
|
+
name,
|
|
525
|
+
device: this.getCurrentOutput()?.name || null,
|
|
526
|
+
timestamp: new Date().toISOString(),
|
|
527
|
+
version: "1.0",
|
|
528
|
+
channels: {},
|
|
529
|
+
settings: {},
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Collect CC values by channel
|
|
533
|
+
for (const [key, value] of this.state.entries()) {
|
|
534
|
+
const [channel, cc] = key.split(":").map(Number)
|
|
535
|
+
if (!patch.channels[channel]) {
|
|
536
|
+
patch.channels[channel] = { ccs: {}, notes: {} }
|
|
537
|
+
}
|
|
538
|
+
patch.channels[channel].ccs[cc] = value
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Collect control settings
|
|
542
|
+
for (const [element, binding] of this.bindings.entries()) {
|
|
543
|
+
const { config } = binding
|
|
544
|
+
if (config.cc) {
|
|
545
|
+
const settingKey = `cc${config.cc}`
|
|
546
|
+
patch.settings[settingKey] = {
|
|
547
|
+
min: config.min,
|
|
548
|
+
max: config.max,
|
|
549
|
+
invert: config.invert || false,
|
|
550
|
+
is14Bit: config.is14Bit || false,
|
|
551
|
+
label: element.getAttribute?.("data-midi-label") || null,
|
|
552
|
+
elementId: element.id || null,
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return patch
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Apply a patch to the controller
|
|
562
|
+
* @param {PatchData} patch - Patch object
|
|
563
|
+
* @returns {Promise<void>}
|
|
564
|
+
*/
|
|
565
|
+
async setPatch(patch) {
|
|
566
|
+
if (!patch || !patch.channels) {
|
|
567
|
+
throw new MIDIValidationError("Invalid patch format", "patch")
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Handle different patch versions
|
|
571
|
+
const version = patch.version || "1.0"
|
|
572
|
+
|
|
573
|
+
if (version === "1.0") {
|
|
574
|
+
await this._applyPatchV1(patch)
|
|
575
|
+
} else {
|
|
576
|
+
console.warn(`Unknown patch version: ${version}. Attempting to apply as v1.0`)
|
|
577
|
+
await this._applyPatchV1(patch)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
this.emit(CONTROLLER_EVENTS.PATCH_LOADED, { patch })
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Apply v1.0 patch format
|
|
585
|
+
* @private
|
|
586
|
+
* @param {PatchData} patch
|
|
587
|
+
*/
|
|
588
|
+
async _applyPatchV1(patch) {
|
|
589
|
+
// Apply CC values
|
|
590
|
+
for (const [channelStr, channelData] of Object.entries(patch.channels)) {
|
|
591
|
+
const channel = parseInt(channelStr, 10)
|
|
592
|
+
|
|
593
|
+
// Apply CC values
|
|
594
|
+
if (channelData.ccs) {
|
|
595
|
+
for (const [ccStr, value] of Object.entries(channelData.ccs)) {
|
|
596
|
+
const cc = parseInt(ccStr, 10)
|
|
597
|
+
this.sendCC(cc, value, channel)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Apply note states if present
|
|
602
|
+
if (channelData.notes) {
|
|
603
|
+
for (const [noteStr, velocity] of Object.entries(channelData.notes)) {
|
|
604
|
+
const note = parseInt(noteStr, 10)
|
|
605
|
+
if (velocity > 0) {
|
|
606
|
+
this.sendNoteOn(note, velocity, channel)
|
|
607
|
+
} else {
|
|
608
|
+
this.sendNoteOff(note, channel)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Apply settings to controls (if they exist)
|
|
615
|
+
if (patch.settings) {
|
|
616
|
+
for (const [bindingKey, setting] of Object.entries(patch.settings)) {
|
|
617
|
+
// Find controls by CC number and update their settings
|
|
618
|
+
for (const [element, binding] of this.bindings.entries()) {
|
|
619
|
+
if (binding.config.cc?.toString() === bindingKey.replace("cc", "")) {
|
|
620
|
+
// Update element attributes if they exist
|
|
621
|
+
if (element.min !== undefined && setting.min !== undefined) {
|
|
622
|
+
element.min = String(setting.min)
|
|
623
|
+
}
|
|
624
|
+
if (element.max !== undefined && setting.max !== undefined) {
|
|
625
|
+
element.max = String(setting.max)
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Update element values from channel data for all bound elements
|
|
633
|
+
for (const [element, binding] of this.bindings.entries()) {
|
|
634
|
+
const { config } = binding
|
|
635
|
+
if (config.cc !== undefined) {
|
|
636
|
+
const channel = config.channel || this.options.channel
|
|
637
|
+
const channelData = patch.channels[channel]
|
|
638
|
+
if (channelData?.ccs) {
|
|
639
|
+
const ccValue = channelData.ccs[config.cc]
|
|
640
|
+
if (ccValue !== undefined) {
|
|
641
|
+
// Convert MIDI value (0-127) back to element value
|
|
642
|
+
const min =
|
|
643
|
+
config.min !== undefined ? config.min : parseFloat(element.getAttribute?.("min")) || 0
|
|
644
|
+
const max =
|
|
645
|
+
config.max !== undefined
|
|
646
|
+
? config.max
|
|
647
|
+
: parseFloat(element.getAttribute?.("max")) || 127
|
|
648
|
+
const invert = config.invert || false
|
|
649
|
+
|
|
650
|
+
let elementValue
|
|
651
|
+
if (invert) {
|
|
652
|
+
elementValue = max - (ccValue / 127) * (max - min)
|
|
653
|
+
} else {
|
|
654
|
+
elementValue = min + (ccValue / 127) * (max - min)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Use onInput callback if provided, otherwise fall back to direct element update
|
|
658
|
+
if (config.onInput && typeof config.onInput === "function") {
|
|
659
|
+
config.onInput(elementValue)
|
|
660
|
+
} else {
|
|
661
|
+
element.value = elementValue
|
|
662
|
+
// Dispatch input event to trigger any display updates
|
|
663
|
+
element.dispatchEvent(new Event("input", { bubbles: true }))
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Save a patch to localStorage
|
|
673
|
+
* @param {string} name - Patch name
|
|
674
|
+
* @param {Object} [patch] - Optional patch object (will use getPatch() if not provided)
|
|
675
|
+
* @returns {string} Storage key used
|
|
676
|
+
*/
|
|
677
|
+
savePatch(name, patch = null) {
|
|
678
|
+
const patchToSave = patch || this.getPatch(name)
|
|
679
|
+
const key = `midiwire_patch_${name}`
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
localStorage.setItem(key, JSON.stringify(patchToSave))
|
|
683
|
+
this.emit(CONTROLLER_EVENTS.PATCH_SAVED, { name, patch: patchToSave })
|
|
684
|
+
return key
|
|
685
|
+
} catch (err) {
|
|
686
|
+
console.error("Failed to save patch:", err)
|
|
687
|
+
throw err
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Load a patch from localStorage
|
|
693
|
+
* @param {string} name - Patch name
|
|
694
|
+
* @returns {Object|null} Patch object or null if not found
|
|
695
|
+
*/
|
|
696
|
+
loadPatch(name) {
|
|
697
|
+
const key = `midiwire_patch_${name}`
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
const stored = localStorage.getItem(key)
|
|
701
|
+
if (!stored) {
|
|
702
|
+
return null
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const patch = JSON.parse(stored)
|
|
706
|
+
this.emit(CONTROLLER_EVENTS.PATCH_LOADED, { name, patch })
|
|
707
|
+
return patch
|
|
708
|
+
} catch (err) {
|
|
709
|
+
console.error("Failed to load patch:", err)
|
|
710
|
+
return null
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Delete a patch from localStorage
|
|
716
|
+
* @param {string} name - Patch name
|
|
717
|
+
* @returns {boolean} Success
|
|
718
|
+
*/
|
|
719
|
+
deletePatch(name) {
|
|
720
|
+
const key = `midiwire_patch_${name}`
|
|
721
|
+
|
|
722
|
+
try {
|
|
723
|
+
localStorage.removeItem(key)
|
|
724
|
+
this.emit(CONTROLLER_EVENTS.PATCH_DELETED, { name })
|
|
725
|
+
return true
|
|
726
|
+
} catch (err) {
|
|
727
|
+
console.error("Failed to delete patch:", err)
|
|
728
|
+
return false
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* List all saved patches
|
|
734
|
+
* @returns {Array<Object>} Array of { name, patch }
|
|
735
|
+
*/
|
|
736
|
+
listPatches() {
|
|
737
|
+
const patches = []
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
741
|
+
const key = localStorage.key(i)
|
|
742
|
+
if (key?.startsWith("midiwire_patch_")) {
|
|
743
|
+
const name = key.replace("midiwire_patch_", "")
|
|
744
|
+
const patch = this.loadPatch(name)
|
|
745
|
+
if (patch) {
|
|
746
|
+
patches.push({ name, patch })
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
} catch (err) {
|
|
751
|
+
console.error("Failed to list patches:", err)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return patches.sort((a, b) => a.name.localeCompare(b.name))
|
|
755
|
+
}
|
|
756
|
+
}
|