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,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
+ }