From 4df19f9ff846c9bd62e640688cd51b71eb50632b Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Wed, 24 Dec 2025 16:05:46 -0500 Subject: [PATCH 01/16] Save MIDI-in CC state --- packages/midi/midi.mjs | 47 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index cd38d358..8abfe6cd 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -481,6 +481,43 @@ let listeners = {}; const refs = {}; const refsByChan = {}; +/** + * + * @param {string} input + * @param {number|undefined} chan + * @returns {Object} + */ +function getMidinState(input, chan) { + const initialDataRaw = localStorage.getItem(`strudel-midin-${input}-chan${chan !== undefined ? chan : 'all'}`); + if (!initialDataRaw) { + return {}; + } + + try { + return JSON.parse(initialDataRaw); + } catch (err) { + console.warn( + `Failed to parse MIDI state from localStorage for input "${input}" and channel "${chan}"`, + initialDataRaw, + err, + ); + return {}; + } +} + +/** + * + * @param {string} input + * @param {number|undefined} chan + * @param {number} cc + * @param {number} value + */ +function saveMidinState(input, chan, cc, value) { + const state = getMidinState(input, chan); + state[cc] = value; + localStorage.setItem(`strudel-midin-${input}-chan${chan !== undefined ? chan : 'all'}`, JSON.stringify(state)); +} + /** * MIDI input: Opens a MIDI input port to receive MIDI control change messages. * @@ -524,10 +561,13 @@ export async function midin(input) { refs[input] ??= {}; refsByChan[input] ??= {}; const cc = (cc, chan) => { + const initialState = getMidinState(input, chan); + const initialValue = initialState[cc] || 0; + if (chan !== undefined) { - return ref(() => refsByChan[input][cc]?.[chan] || 0); + return ref(() => refsByChan[input][cc]?.[chan] || initialValue); } - return ref(() => refs[input][cc] || 0); + return ref(() => refs[input][cc] || initialValue); }; listeners[input] && device.removeListener('midimessage', listeners[input]); @@ -539,6 +579,9 @@ export async function midin(input) { refsByChan[input][ccNum] ??= {}; refsByChan[input][ccNum][chan] = scaled; refs[input][ccNum] = scaled; + + saveMidinState(input, undefined, ccNum, scaled); + saveMidinState(input, chan, ccNum, scaled); }; device.addListener('midimessage', listeners[input]); return cc; From 84e59645fd630f13ccb478c24876becd5e7257dd Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 18:27:28 -0500 Subject: [PATCH 02/16] Encapsulate MIDI input handling --- packages/midi/midi.mjs | 80 ++++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 8abfe6cd..8d54d768 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -477,10 +477,6 @@ Pattern.prototype.midi = function (midiport, options = {}) { }); }; -let listeners = {}; -const refs = {}; -const refsByChan = {}; - /** * * @param {string} input @@ -518,6 +514,51 @@ function saveMidinState(input, chan, cc, value) { localStorage.setItem(`strudel-midin-${input}-chan${chan !== undefined ? chan : 'all'}`, JSON.stringify(state)); } +class MidiInput { + /** + * + * @param {string | number} input MIDI device name or index defaulting to 0 + * @param {WebMidi.MIDIInput | undefined} device MIDI input device instance (or empty to use as placeholder) + */ + constructor(device) { + this.id = device.id; + this.name = device.name; + + this._refs = {}; + this._refsByChan = {}; + + this.cc = (cc, chan) => { + const initialState = getMidinState(this.name, chan); + const initialValue = initialState[cc] || 0; + + if (chan !== undefined) { + return ref(() => this._refsByChan[cc]?.[chan] || initialValue); + } + return ref(() => this._refs[cc] || initialValue); + }; + + const midiListener = this._onMidiMessage.bind(this); + device.addListener('midimessage', midiListener); + } + + _onMidiMessage(e) { + const ccNum = e.dataBytes[0]; + const v = e.dataBytes[1]; + const chan = e.message.channel; + const scaled = v / 127; + + this._refs[ccNum] = scaled; + this._refsByChan[ccNum] ??= {}; + this._refsByChan[ccNum][chan] = scaled; + + saveMidinState(this.name, undefined, ccNum, scaled); + saveMidinState(this.name, chan, ccNum, scaled); + } +} + +// MIDI input wrappers, by device ID +const midiInputs = {}; + /** * MIDI input: Opens a MIDI input port to receive MIDI control change messages. * @@ -550,6 +591,10 @@ export async function midin(input) { `midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, ); } + + const instance = midiInputs[device.id] || new MidiInput(device); + midiInputs[device.id] = instance; + if (initial) { const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name); logger( @@ -558,31 +603,6 @@ export async function midin(input) { }`, ); } - refs[input] ??= {}; - refsByChan[input] ??= {}; - const cc = (cc, chan) => { - const initialState = getMidinState(input, chan); - const initialValue = initialState[cc] || 0; - if (chan !== undefined) { - return ref(() => refsByChan[input][cc]?.[chan] || initialValue); - } - return ref(() => refs[input][cc] || initialValue); - }; - - listeners[input] && device.removeListener('midimessage', listeners[input]); - listeners[input] = (e) => { - const ccNum = e.dataBytes[0]; - const v = e.dataBytes[1]; - const chan = e.message.channel; - const scaled = v / 127; - refsByChan[input][ccNum] ??= {}; - refsByChan[input][ccNum][chan] = scaled; - refs[input][ccNum] = scaled; - - saveMidinState(input, undefined, ccNum, scaled); - saveMidinState(input, chan, ccNum, scaled); - }; - device.addListener('midimessage', listeners[input]); - return cc; + return instance.cc; } From 213edfb211e08cc7ea9fd2649b50b875ad61fc5c Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 18:32:53 -0500 Subject: [PATCH 03/16] Roll up save/load state into MIDI wrapper --- packages/midi/midi.mjs | 67 +++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 8d54d768..27c865e7 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -477,43 +477,6 @@ Pattern.prototype.midi = function (midiport, options = {}) { }); }; -/** - * - * @param {string} input - * @param {number|undefined} chan - * @returns {Object} - */ -function getMidinState(input, chan) { - const initialDataRaw = localStorage.getItem(`strudel-midin-${input}-chan${chan !== undefined ? chan : 'all'}`); - if (!initialDataRaw) { - return {}; - } - - try { - return JSON.parse(initialDataRaw); - } catch (err) { - console.warn( - `Failed to parse MIDI state from localStorage for input "${input}" and channel "${chan}"`, - initialDataRaw, - err, - ); - return {}; - } -} - -/** - * - * @param {string} input - * @param {number|undefined} chan - * @param {number} cc - * @param {number} value - */ -function saveMidinState(input, chan, cc, value) { - const state = getMidinState(input, chan); - state[cc] = value; - localStorage.setItem(`strudel-midin-${input}-chan${chan !== undefined ? chan : 'all'}`, JSON.stringify(state)); -} - class MidiInput { /** * @@ -528,7 +491,7 @@ class MidiInput { this._refsByChan = {}; this.cc = (cc, chan) => { - const initialState = getMidinState(this.name, chan); + const initialState = this._loadState(chan); const initialValue = initialState[cc] || 0; if (chan !== undefined) { @@ -551,8 +514,32 @@ class MidiInput { this._refsByChan[ccNum] ??= {}; this._refsByChan[ccNum][chan] = scaled; - saveMidinState(this.name, undefined, ccNum, scaled); - saveMidinState(this.name, chan, ccNum, scaled); + this._saveState(undefined, ccNum, scaled); + this._saveState(chan, ccNum, scaled); + } + + _loadState(chan) { + const initialDataRaw = localStorage.getItem(`strudel-midin-${this.name}-chan${chan !== undefined ? chan : 'all'}`); + if (!initialDataRaw) { + return {}; + } + + try { + return JSON.parse(initialDataRaw); + } catch (err) { + console.warn( + `Failed to parse MIDI state from localStorage for input "${this.name}" and channel "${chan}"`, + initialDataRaw, + err, + ); + return {}; + } + } + + _saveState(chan, cc, value) { + const state = this._loadState(chan); + state[cc] = value; + localStorage.setItem(`strudel-midin-${this.name}-chan${chan !== undefined ? chan : 'all'}`, JSON.stringify(state)); } } From 47557cf06ff06e94e6407ba62955a7c5768c71ec Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 18:38:55 -0500 Subject: [PATCH 04/16] Move device lookup inside input wrapper --- packages/midi/midi.mjs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 27c865e7..97226dc5 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -481,11 +481,18 @@ class MidiInput { /** * * @param {string | number} input MIDI device name or index defaulting to 0 - * @param {WebMidi.MIDIInput | undefined} device MIDI input device instance (or empty to use as placeholder) */ - constructor(device) { + constructor(input) { + const device = getDevice(input, WebMidi.inputs); + if (!device) { + throw new Error( + `midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, + ); + } + this.id = device.id; this.name = device.name; + this.device = device; this._refs = {}; this._refsByChan = {}; @@ -572,15 +579,10 @@ export async function midin(input) { ); } const initial = await enableWebMidi(); // only returns on first init - const device = getDevice(input, WebMidi.inputs); - if (!device) { - throw new Error( - `midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, - ); - } - const instance = midiInputs[device.id] || new MidiInput(device); - midiInputs[device.id] = instance; + const instance = midiInputs[input] || new MidiInput(input); + midiInputs[input] = instance; + const device = instance.device; if (initial) { const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name); From 1860d31624d0cb1e7186bf273acb09cedb5ee184 Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 18:46:39 -0500 Subject: [PATCH 05/16] Tweak cc init --- packages/midi/midi.mjs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 97226dc5..c2c08c07 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -497,20 +497,24 @@ class MidiInput { this._refs = {}; this._refsByChan = {}; - this.cc = (cc, chan) => { - const initialState = this._loadState(chan); - const initialValue = initialState[cc] || 0; - - if (chan !== undefined) { - return ref(() => this._refsByChan[cc]?.[chan] || initialValue); - } - return ref(() => this._refs[cc] || initialValue); - }; - const midiListener = this._onMidiMessage.bind(this); device.addListener('midimessage', midiListener); } + createCC(cc, chan) { + if (chan !== undefined && !(chan in this._refsByChan)) { + this._refsByChan[chan] = {}; + } + + const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan]; + if (!(cc in lookupMap)) { + const initialState = this._loadState(chan); + lookupMap[cc] = initialState[cc] || 0; + } + + return ref(() => lookupMap[cc]); + } + _onMidiMessage(e) { const ccNum = e.dataBytes[0]; const v = e.dataBytes[1]; @@ -593,5 +597,5 @@ export async function midin(input) { ); } - return instance.cc; + return instance.createCC.bind(instance); } From 1aaa04ab50af665a612ca0f295a4153a2054724d Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 18:58:13 -0500 Subject: [PATCH 06/16] Send stored CC values to controller --- packages/midi/midi.mjs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index c2c08c07..859c54f9 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -499,13 +499,16 @@ class MidiInput { const midiListener = this._onMidiMessage.bind(this); device.addListener('midimessage', midiListener); + + this._loadAllStates(); + + const output = WebMidi.outputs.find((o) => o.name === this.name); + if (output) { + this._sendAllStates(output); + } } createCC(cc, chan) { - if (chan !== undefined && !(chan in this._refsByChan)) { - this._refsByChan[chan] = {}; - } - const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan]; if (!(cc in lookupMap)) { const initialState = this._loadState(chan); @@ -529,6 +532,15 @@ class MidiInput { this._saveState(chan, ccNum, scaled); } + _loadAllStates() { + Object.assign(this._refs, this._loadState(undefined)); + + for (let chan = 1; chan <= 16; chan++) { + this._refsByChan[chan] ??= {}; + Object.assign(this._refsByChan[chan], this._loadState(chan)); + } + } + _loadState(chan) { const initialDataRaw = localStorage.getItem(`strudel-midin-${this.name}-chan${chan !== undefined ? chan : 'all'}`); if (!initialDataRaw) { @@ -552,6 +564,17 @@ class MidiInput { state[cc] = value; localStorage.setItem(`strudel-midin-${this.name}-chan${chan !== undefined ? chan : 'all'}`, JSON.stringify(state)); } + + _sendAllStates(output) { + for (const [chan, refs] of Object.entries(this._refsByChan)) { + const channel = Number(chan); + for (const [cc, value] of Object.entries(refs)) { + const ccn = Number(cc); + const scaled = Math.round(value * 127); + output.sendControlChange(ccn, scaled, channel); + } + } + } } // MIDI input wrappers, by device ID From b8dc50267e44ccaa39ddd2b638ccc34420476450 Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 19:02:06 -0500 Subject: [PATCH 07/16] Clean up unused state fields --- packages/midi/midi.mjs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 859c54f9..49a87ac1 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -490,9 +490,7 @@ class MidiInput { ); } - this.id = device.id; this.name = device.name; - this.device = device; this._refs = {}; this._refsByChan = {}; @@ -502,7 +500,7 @@ class MidiInput { this._loadAllStates(); - const output = WebMidi.outputs.find((o) => o.name === this.name); + const output = WebMidi.outputs.find((o) => o.name === device.name); if (output) { this._sendAllStates(output); } @@ -577,7 +575,7 @@ class MidiInput { } } -// MIDI input wrappers, by device ID +// MIDI input wrappers, by specified input string/index const midiInputs = {}; /** @@ -609,12 +607,11 @@ export async function midin(input) { const instance = midiInputs[input] || new MidiInput(input); midiInputs[input] = instance; - const device = instance.device; if (initial) { - const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name); + const otherInputs = WebMidi.inputs.filter((o) => o.name !== instance.name); logger( - `Midi enabled! Using "${device.name}". ${ + `Midi enabled! Using "${instance.name}". ${ otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : '' }`, ); From 0c01a997ac3e21227ac092248c4d20e06e422017 Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 19:06:19 -0500 Subject: [PATCH 08/16] Avoid referencing found MIDI device name --- packages/midi/midi.mjs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 49a87ac1..eb6ca12e 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -483,6 +483,8 @@ class MidiInput { * @param {string | number} input MIDI device name or index defaulting to 0 */ constructor(input) { + this.stateKey = typeof input === 'string' ? input : undefined; + const device = getDevice(input, WebMidi.inputs); if (!device) { throw new Error( @@ -490,8 +492,6 @@ class MidiInput { ); } - this.name = device.name; - this._refs = {}; this._refsByChan = {}; @@ -540,7 +540,13 @@ class MidiInput { } _loadState(chan) { - const initialDataRaw = localStorage.getItem(`strudel-midin-${this.name}-chan${chan !== undefined ? chan : 'all'}`); + if (!this.stateKey) { + return {}; + } + + const initialDataRaw = localStorage.getItem( + `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, + ); if (!initialDataRaw) { return {}; } @@ -549,7 +555,7 @@ class MidiInput { return JSON.parse(initialDataRaw); } catch (err) { console.warn( - `Failed to parse MIDI state from localStorage for input "${this.name}" and channel "${chan}"`, + `Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`, initialDataRaw, err, ); @@ -558,9 +564,16 @@ class MidiInput { } _saveState(chan, cc, value) { + if (!this.stateKey) { + return; + } + const state = this._loadState(chan); state[cc] = value; - localStorage.setItem(`strudel-midin-${this.name}-chan${chan !== undefined ? chan : 'all'}`, JSON.stringify(state)); + localStorage.setItem( + `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, + JSON.stringify(state), + ); } _sendAllStates(output) { From dab1a018e4cf0e7c35ea3c1317c303df1d4c1b5e Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 19:52:07 -0500 Subject: [PATCH 09/16] Support delayed connection and reconnection of MIDI inputs --- packages/midi/midi.mjs | 105 +++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index eb6ca12e..56e7f005 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -59,9 +59,6 @@ export function enableWebMidi(options = {}) { } function getDevice(indexOrName, devices) { - if (!devices.length) { - throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`); - } if (typeof indexOrName === 'number') { return devices[indexOrName]; } @@ -73,12 +70,13 @@ function getDevice(indexOrName, devices) { const IACOutput = byName('IAC'); const device = IACOutput ?? devices[0]; if (!device) { - throw new Error( - `🔌 MIDI device '${device ? device : ''}' not found. Use one of ${getMidiDeviceNamesString(devices)}`, - ); + if (!devices.length) { + throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`); + } + throw new Error(`🔌 Default MIDI device not found. Use one of ${getMidiDeviceNamesString(devices)}`); } - return IACOutput ?? devices[0]; + return device; } // send start/stop messages to outputs when repl starts/stops @@ -483,27 +481,15 @@ class MidiInput { * @param {string | number} input MIDI device name or index defaulting to 0 */ constructor(input) { + this.input = input; this.stateKey = typeof input === 'string' ? input : undefined; - const device = getDevice(input, WebMidi.inputs); - if (!device) { - throw new Error( - `midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, - ); - } - this._refs = {}; this._refsByChan = {}; - const midiListener = this._onMidiMessage.bind(this); - device.addListener('midimessage', midiListener); - this._loadAllStates(); - const output = WebMidi.outputs.find((o) => o.name === device.name); - if (output) { - this._sendAllStates(output); - } + this.initialDevice = this._startDeviceListener(); } createCC(cc, chan) { @@ -516,6 +502,71 @@ class MidiInput { return ref(() => lookupMap[cc]); } + _startDeviceListener() { + const initialDevice = getDevice(this.input, WebMidi.inputs); + if (!initialDevice) { + console.warn(`midiin: device "${this.input}" not found`); + } + + // Background connection loop + (async () => { + const midiListener = this._onMidiMessage.bind(this); + let device = initialDevice; + + while (true) { + if (!device) { + device = await this._waitForDevice(); + } + + console.log('midiin: device connected:', device.name); + + // Wait a bit for device to be ready to receive last state + await new Promise((resolve) => setTimeout(resolve, 2000)); + + try { + const output = WebMidi.outputs.find((o) => o.name === device.name); + if (output) { + this._sendAllStates(output); + } + } catch (err) { + console.error('midiin: failed to send last state on connect:', device.name, err); + } + + // Listen for incoming MIDI messages and for disconnection + device.addListener('midimessage', midiListener); + + await new Promise((resolve) => { + const disconnectListener = (e) => { + if (e.port.name === device.name) { + console.warn('midiin: device disconnected:', device.name); + WebMidi.removeListener('disconnected', disconnectListener); + resolve(); + } + }; + WebMidi.addListener('disconnected', disconnectListener); + }); + + device = null; // Clear var to trigger wait for connection + } + })(); + + return initialDevice; + } + + _waitForDevice() { + return new Promise((resolve) => { + const connListener = () => { + const device = getDevice(this.input, WebMidi.inputs); + if (device) { + WebMidi.removeListener('connected', connListener); + resolve(device); + } + }; + + WebMidi.addListener('connected', connListener); + }); + } + _onMidiMessage(e) { const ccNum = e.dataBytes[0]; const v = e.dataBytes[1]; @@ -622,11 +673,15 @@ export async function midin(input) { midiInputs[input] = instance; if (initial) { - const otherInputs = WebMidi.inputs.filter((o) => o.name !== instance.name); + const device = instance.initialDevice; + + const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name); logger( - `Midi enabled! Using "${instance.name}". ${ - otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : '' - }`, + device + ? `Midi enabled! Using "${device.name}". ${ + otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : '' + }` + : `Midi enabled! Waiting for device "${input}"... Currently connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, ); } From bb7f587025c071bc13e13904eb0084345d807b77 Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 19:56:15 -0500 Subject: [PATCH 10/16] Encapsulate more logic --- packages/midi/midi.mjs | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 56e7f005..08defe7e 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -524,10 +524,8 @@ class MidiInput { await new Promise((resolve) => setTimeout(resolve, 2000)); try { - const output = WebMidi.outputs.find((o) => o.name === device.name); - if (output) { - this._sendAllStates(output); - } + // Still continue if sending did not work + this._sendAllStates(device); } catch (err) { console.error('midiin: failed to send last state on connect:', device.name, err); } @@ -535,16 +533,10 @@ class MidiInput { // Listen for incoming MIDI messages and for disconnection device.addListener('midimessage', midiListener); - await new Promise((resolve) => { - const disconnectListener = (e) => { - if (e.port.name === device.name) { - console.warn('midiin: device disconnected:', device.name); - WebMidi.removeListener('disconnected', disconnectListener); - resolve(); - } - }; - WebMidi.addListener('disconnected', disconnectListener); - }); + await this._waitForDeviceDisconnect(device); + + console.warn('midiin: device disconnected:', device.name); + device.removeListener('midimessage', midiListener); device = null; // Clear var to trigger wait for connection } @@ -567,6 +559,19 @@ class MidiInput { }); } + _waitForDeviceDisconnect(device) { + return new Promise((resolve) => { + const disconnListener = (e) => { + if (e.port.name === device.name) { + WebMidi.removeListener('disconnected', disconnListener); + resolve(); + } + }; + + WebMidi.addListener('disconnected', disconnListener); + }); + } + _onMidiMessage(e) { const ccNum = e.dataBytes[0]; const v = e.dataBytes[1]; @@ -627,7 +632,12 @@ class MidiInput { ); } - _sendAllStates(output) { + _sendAllStates(device) { + const output = WebMidi.outputs.find((o) => o.name === device.name); + if (!output) { + return; + } + for (const [chan, refs] of Object.entries(this._refsByChan)) { const channel = Number(chan); for (const [cc, value] of Object.entries(refs)) { From ecada58edc9999f3dce3f23c5b8ec445243866fd Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 20:59:50 -0500 Subject: [PATCH 11/16] Carry out MIDI input wrapper into dedicated file --- packages/midi/input.mjs | 183 ++++++++++++++++++++++++++++++++++++ packages/midi/midi.mjs | 203 +--------------------------------------- packages/midi/util.mjs | 38 ++++++++ 3 files changed, 224 insertions(+), 200 deletions(-) create mode 100644 packages/midi/input.mjs create mode 100644 packages/midi/util.mjs diff --git a/packages/midi/input.mjs b/packages/midi/input.mjs new file mode 100644 index 00000000..aa16cfea --- /dev/null +++ b/packages/midi/input.mjs @@ -0,0 +1,183 @@ +/* +midi.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { WebMidi } from 'webmidi'; +import { ref } from '@strudel/core'; +import { getDevice } from './util.mjs'; + +export class MidiInput { + /** + * + * @param {string | number} input MIDI device name or index defaulting to 0 + */ + constructor(input) { + this.input = input; + this.stateKey = typeof input === 'string' ? input : undefined; + + this._refs = {}; + this._refsByChan = {}; + + this._loadAllStates(); + + this.initialDevice = this._startDeviceListener(); + } + + createCC(cc, chan) { + const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan]; + if (!(cc in lookupMap)) { + const initialState = this._loadState(chan); + lookupMap[cc] = initialState[cc] || 0; + } + + return ref(() => lookupMap[cc]); + } + + _startDeviceListener() { + const initialDevice = getDevice(this.input, WebMidi.inputs); + if (!initialDevice) { + console.warn(`midiin: device "${this.input}" not found`); + } + + // Background connection loop + (async () => { + const midiListener = this._onMidiMessage.bind(this); + let device = initialDevice; + + while (true) { + if (!device) { + device = await this._waitForDevice(); + } + + console.log('midiin: device connected:', device.name); + + // Wait a bit for device to be ready to receive last state + await new Promise((resolve) => setTimeout(resolve, 2000)); + + try { + // Still continue if sending did not work + this._sendAllStates(device); + } catch (err) { + console.error('midiin: failed to send last state on connect:', device.name, err); + } + + // Listen for incoming MIDI messages and for disconnection + device.addListener('midimessage', midiListener); + + await this._waitForDeviceDisconnect(device); + + console.warn('midiin: device disconnected:', device.name); + device.removeListener('midimessage', midiListener); + + device = null; // Clear var to trigger wait for connection + } + })(); + + return initialDevice; + } + + _waitForDevice() { + return new Promise((resolve) => { + const connListener = () => { + const device = getDevice(this.input, WebMidi.inputs); + if (device) { + WebMidi.removeListener('connected', connListener); + resolve(device); + } + }; + + WebMidi.addListener('connected', connListener); + }); + } + + _waitForDeviceDisconnect(device) { + return new Promise((resolve) => { + const disconnListener = (e) => { + if (e.port.name === device.name) { + WebMidi.removeListener('disconnected', disconnListener); + resolve(); + } + }; + + WebMidi.addListener('disconnected', disconnListener); + }); + } + + _onMidiMessage(e) { + const ccNum = e.dataBytes[0]; + const v = e.dataBytes[1]; + const chan = e.message.channel; + const scaled = v / 127; + + this._refs[ccNum] = scaled; + this._refsByChan[ccNum] ??= {}; + this._refsByChan[ccNum][chan] = scaled; + + this._saveState(undefined, ccNum, scaled); + this._saveState(chan, ccNum, scaled); + } + + _loadAllStates() { + Object.assign(this._refs, this._loadState(undefined)); + + for (let chan = 1; chan <= 16; chan++) { + this._refsByChan[chan] ??= {}; + Object.assign(this._refsByChan[chan], this._loadState(chan)); + } + } + + _loadState(chan) { + if (!this.stateKey) { + return {}; + } + + const initialDataRaw = localStorage.getItem( + `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, + ); + if (!initialDataRaw) { + return {}; + } + + try { + return JSON.parse(initialDataRaw); + } catch (err) { + console.warn( + `Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`, + initialDataRaw, + err, + ); + return {}; + } + } + + _saveState(chan, cc, value) { + if (!this.stateKey) { + return; + } + + const state = this._loadState(chan); + state[cc] = value; + localStorage.setItem( + `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, + JSON.stringify(state), + ); + } + + _sendAllStates(device) { + const output = WebMidi.outputs.find((o) => o.name === device.name); + if (!output) { + return; + } + + for (const [chan, refs] of Object.entries(this._refsByChan)) { + const channel = Number(chan); + for (const [cc, value] of Object.entries(refs)) { + const ccn = Number(cc); + const scaled = Math.round(value * 127); + output.sendControlChange(ccn, scaled, channel); + } + } + } +} diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 08defe7e..1e106a89 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -5,10 +5,12 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as _WebMidi from 'webmidi'; -import { Pattern, isPattern, logger, ref } from '@strudel/core'; +import { Pattern, isPattern, logger } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core'; import { Note } from 'webmidi'; import { scheduleAtTime } from '../superdough/helpers.mjs'; +import { getMidiDeviceNamesString, getDevice } from './util.mjs'; +import { MidiInput } from './input.mjs'; // if you use WebMidi from outside of this package, make sure to import that instance: export const { WebMidi } = _WebMidi; @@ -17,10 +19,6 @@ function supportsMidi() { return typeof navigator.requestMIDIAccess === 'function'; } -function getMidiDeviceNamesString(devices) { - return devices.map((o) => `'${o.name}'`).join(' | '); -} - export function enableWebMidi(options = {}) { const { onReady, onConnected, onDisconnected, onEnabled } = options; if (WebMidi.enabled) { @@ -58,27 +56,6 @@ export function enableWebMidi(options = {}) { }); } -function getDevice(indexOrName, devices) { - if (typeof indexOrName === 'number') { - return devices[indexOrName]; - } - const byName = (name) => devices.find((output) => output.name.includes(name)); - if (typeof indexOrName === 'string') { - return byName(indexOrName); - } - // attempt to default to first IAC device if none is specified - const IACOutput = byName('IAC'); - const device = IACOutput ?? devices[0]; - if (!device) { - if (!devices.length) { - throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`); - } - throw new Error(`🔌 Default MIDI device not found. Use one of ${getMidiDeviceNamesString(devices)}`); - } - - return device; -} - // send start/stop messages to outputs when repl starts/stops if (typeof window !== 'undefined') { window.addEventListener('message', (e) => { @@ -475,180 +452,6 @@ Pattern.prototype.midi = function (midiport, options = {}) { }); }; -class MidiInput { - /** - * - * @param {string | number} input MIDI device name or index defaulting to 0 - */ - constructor(input) { - this.input = input; - this.stateKey = typeof input === 'string' ? input : undefined; - - this._refs = {}; - this._refsByChan = {}; - - this._loadAllStates(); - - this.initialDevice = this._startDeviceListener(); - } - - createCC(cc, chan) { - const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan]; - if (!(cc in lookupMap)) { - const initialState = this._loadState(chan); - lookupMap[cc] = initialState[cc] || 0; - } - - return ref(() => lookupMap[cc]); - } - - _startDeviceListener() { - const initialDevice = getDevice(this.input, WebMidi.inputs); - if (!initialDevice) { - console.warn(`midiin: device "${this.input}" not found`); - } - - // Background connection loop - (async () => { - const midiListener = this._onMidiMessage.bind(this); - let device = initialDevice; - - while (true) { - if (!device) { - device = await this._waitForDevice(); - } - - console.log('midiin: device connected:', device.name); - - // Wait a bit for device to be ready to receive last state - await new Promise((resolve) => setTimeout(resolve, 2000)); - - try { - // Still continue if sending did not work - this._sendAllStates(device); - } catch (err) { - console.error('midiin: failed to send last state on connect:', device.name, err); - } - - // Listen for incoming MIDI messages and for disconnection - device.addListener('midimessage', midiListener); - - await this._waitForDeviceDisconnect(device); - - console.warn('midiin: device disconnected:', device.name); - device.removeListener('midimessage', midiListener); - - device = null; // Clear var to trigger wait for connection - } - })(); - - return initialDevice; - } - - _waitForDevice() { - return new Promise((resolve) => { - const connListener = () => { - const device = getDevice(this.input, WebMidi.inputs); - if (device) { - WebMidi.removeListener('connected', connListener); - resolve(device); - } - }; - - WebMidi.addListener('connected', connListener); - }); - } - - _waitForDeviceDisconnect(device) { - return new Promise((resolve) => { - const disconnListener = (e) => { - if (e.port.name === device.name) { - WebMidi.removeListener('disconnected', disconnListener); - resolve(); - } - }; - - WebMidi.addListener('disconnected', disconnListener); - }); - } - - _onMidiMessage(e) { - const ccNum = e.dataBytes[0]; - const v = e.dataBytes[1]; - const chan = e.message.channel; - const scaled = v / 127; - - this._refs[ccNum] = scaled; - this._refsByChan[ccNum] ??= {}; - this._refsByChan[ccNum][chan] = scaled; - - this._saveState(undefined, ccNum, scaled); - this._saveState(chan, ccNum, scaled); - } - - _loadAllStates() { - Object.assign(this._refs, this._loadState(undefined)); - - for (let chan = 1; chan <= 16; chan++) { - this._refsByChan[chan] ??= {}; - Object.assign(this._refsByChan[chan], this._loadState(chan)); - } - } - - _loadState(chan) { - if (!this.stateKey) { - return {}; - } - - const initialDataRaw = localStorage.getItem( - `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, - ); - if (!initialDataRaw) { - return {}; - } - - try { - return JSON.parse(initialDataRaw); - } catch (err) { - console.warn( - `Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`, - initialDataRaw, - err, - ); - return {}; - } - } - - _saveState(chan, cc, value) { - if (!this.stateKey) { - return; - } - - const state = this._loadState(chan); - state[cc] = value; - localStorage.setItem( - `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, - JSON.stringify(state), - ); - } - - _sendAllStates(device) { - const output = WebMidi.outputs.find((o) => o.name === device.name); - if (!output) { - return; - } - - for (const [chan, refs] of Object.entries(this._refsByChan)) { - const channel = Number(chan); - for (const [cc, value] of Object.entries(refs)) { - const ccn = Number(cc); - const scaled = Math.round(value * 127); - output.sendControlChange(ccn, scaled, channel); - } - } - } -} - // MIDI input wrappers, by specified input string/index const midiInputs = {}; diff --git a/packages/midi/util.mjs b/packages/midi/util.mjs new file mode 100644 index 00000000..f2e7e822 --- /dev/null +++ b/packages/midi/util.mjs @@ -0,0 +1,38 @@ +import { Input, Output } from 'webmidi'; + +/** + * Get a string listing device names for error messages. + * @param {Input[] | Output[]} devices + * @returns {string} + */ +export function getMidiDeviceNamesString(devices) { + return devices.map((o) => `'${o.name}'`).join(' | '); +} + +/** + * Look up a device by index or name. Otherwise return a default device, or fail if none are connected. + * + * @param {string | number} indexOrName + * @param {Input[] | Output[]} devices + * @returns {Input | Output | undefined} + */ +export function getDevice(indexOrName, devices) { + if (typeof indexOrName === 'number') { + return devices[indexOrName]; + } + const byName = (name) => devices.find((output) => output.name.includes(name)); + if (typeof indexOrName === 'string') { + return byName(indexOrName); + } + // attempt to default to first IAC device if none is specified + const IACOutput = byName('IAC'); + const device = IACOutput ?? devices[0]; + if (!device) { + if (!devices.length) { + throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`); + } + throw new Error(`🔌 Default MIDI device not found. Use one of ${getMidiDeviceNamesString(devices)}`); + } + + return device; +} From a0291e8a428d6dbf96f6e1c93b1a31423f1172f6 Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 21:07:39 -0500 Subject: [PATCH 12/16] Fix comments --- packages/midi/input.mjs | 2 +- packages/midi/util.mjs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/midi/input.mjs b/packages/midi/input.mjs index aa16cfea..943916e5 100644 --- a/packages/midi/input.mjs +++ b/packages/midi/input.mjs @@ -1,5 +1,5 @@ /* -midi.mjs - +input.mjs - MIDI input wrapper Copyright (C) 2022 Strudel contributors - see This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ diff --git a/packages/midi/util.mjs b/packages/midi/util.mjs index f2e7e822..99158e68 100644 --- a/packages/midi/util.mjs +++ b/packages/midi/util.mjs @@ -1,3 +1,9 @@ +/* +util.mjs - MIDI utility functions +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + import { Input, Output } from 'webmidi'; /** From ee326c709e201c55f0d61b421f9cf1e90565f9d8 Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 25 Dec 2025 21:42:58 -0500 Subject: [PATCH 13/16] Fix MIDI input logging --- packages/midi/input.mjs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/midi/input.mjs b/packages/midi/input.mjs index 943916e5..8e488942 100644 --- a/packages/midi/input.mjs +++ b/packages/midi/input.mjs @@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import { WebMidi } from 'webmidi'; -import { ref } from '@strudel/core'; +import { logger, ref } from '@strudel/core'; import { getDevice } from './util.mjs'; export class MidiInput { @@ -15,7 +15,7 @@ export class MidiInput { */ constructor(input) { this.input = input; - this.stateKey = typeof input === 'string' ? input : undefined; + this.stateKey = typeof input === 'string' ? input : undefined; // Saved state is not tracked for numeric index inputs this._refs = {}; this._refsByChan = {}; @@ -37,9 +37,6 @@ export class MidiInput { _startDeviceListener() { const initialDevice = getDevice(this.input, WebMidi.inputs); - if (!initialDevice) { - console.warn(`midiin: device "${this.input}" not found`); - } // Background connection loop (async () => { @@ -51,8 +48,6 @@ export class MidiInput { device = await this._waitForDevice(); } - console.log('midiin: device connected:', device.name); - // Wait a bit for device to be ready to receive last state await new Promise((resolve) => setTimeout(resolve, 2000)); @@ -68,9 +63,7 @@ export class MidiInput { await this._waitForDeviceDisconnect(device); - console.warn('midiin: device disconnected:', device.name); device.removeListener('midimessage', midiListener); - device = null; // Clear var to trigger wait for connection } })(); @@ -83,6 +76,8 @@ export class MidiInput { const connListener = () => { const device = getDevice(this.input, WebMidi.inputs); if (device) { + logger(`[midi] device reconnected: ${device.name}`); + WebMidi.removeListener('connected', connListener); resolve(device); } @@ -96,6 +91,8 @@ export class MidiInput { return new Promise((resolve) => { const disconnListener = (e) => { if (e.port.name === device.name) { + logger(`[midi] device disconnected: ${device.name}`); + WebMidi.removeListener('disconnected', disconnListener); resolve(); } From 44ad05fb203368f6cc1fa17144b7b527bdf0c453 Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Wed, 14 Jan 2026 23:37:26 -0500 Subject: [PATCH 14/16] Whitespace fixes --- packages/midi/input.mjs | 360 ++++++++++++++++++++-------------------- packages/midi/util.mjs | 88 +++++----- 2 files changed, 224 insertions(+), 224 deletions(-) diff --git a/packages/midi/input.mjs b/packages/midi/input.mjs index 8e488942..498e6fe2 100644 --- a/packages/midi/input.mjs +++ b/packages/midi/input.mjs @@ -1,180 +1,180 @@ -/* -input.mjs - MIDI input wrapper -Copyright (C) 2022 Strudel contributors - see -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -*/ - -import { WebMidi } from 'webmidi'; -import { logger, ref } from '@strudel/core'; -import { getDevice } from './util.mjs'; - -export class MidiInput { - /** - * - * @param {string | number} input MIDI device name or index defaulting to 0 - */ - constructor(input) { - this.input = input; - this.stateKey = typeof input === 'string' ? input : undefined; // Saved state is not tracked for numeric index inputs - - this._refs = {}; - this._refsByChan = {}; - - this._loadAllStates(); - - this.initialDevice = this._startDeviceListener(); - } - - createCC(cc, chan) { - const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan]; - if (!(cc in lookupMap)) { - const initialState = this._loadState(chan); - lookupMap[cc] = initialState[cc] || 0; - } - - return ref(() => lookupMap[cc]); - } - - _startDeviceListener() { - const initialDevice = getDevice(this.input, WebMidi.inputs); - - // Background connection loop - (async () => { - const midiListener = this._onMidiMessage.bind(this); - let device = initialDevice; - - while (true) { - if (!device) { - device = await this._waitForDevice(); - } - - // Wait a bit for device to be ready to receive last state - await new Promise((resolve) => setTimeout(resolve, 2000)); - - try { - // Still continue if sending did not work - this._sendAllStates(device); - } catch (err) { - console.error('midiin: failed to send last state on connect:', device.name, err); - } - - // Listen for incoming MIDI messages and for disconnection - device.addListener('midimessage', midiListener); - - await this._waitForDeviceDisconnect(device); - - device.removeListener('midimessage', midiListener); - device = null; // Clear var to trigger wait for connection - } - })(); - - return initialDevice; - } - - _waitForDevice() { - return new Promise((resolve) => { - const connListener = () => { - const device = getDevice(this.input, WebMidi.inputs); - if (device) { - logger(`[midi] device reconnected: ${device.name}`); - - WebMidi.removeListener('connected', connListener); - resolve(device); - } - }; - - WebMidi.addListener('connected', connListener); - }); - } - - _waitForDeviceDisconnect(device) { - return new Promise((resolve) => { - const disconnListener = (e) => { - if (e.port.name === device.name) { - logger(`[midi] device disconnected: ${device.name}`); - - WebMidi.removeListener('disconnected', disconnListener); - resolve(); - } - }; - - WebMidi.addListener('disconnected', disconnListener); - }); - } - - _onMidiMessage(e) { - const ccNum = e.dataBytes[0]; - const v = e.dataBytes[1]; - const chan = e.message.channel; - const scaled = v / 127; - - this._refs[ccNum] = scaled; - this._refsByChan[ccNum] ??= {}; - this._refsByChan[ccNum][chan] = scaled; - - this._saveState(undefined, ccNum, scaled); - this._saveState(chan, ccNum, scaled); - } - - _loadAllStates() { - Object.assign(this._refs, this._loadState(undefined)); - - for (let chan = 1; chan <= 16; chan++) { - this._refsByChan[chan] ??= {}; - Object.assign(this._refsByChan[chan], this._loadState(chan)); - } - } - - _loadState(chan) { - if (!this.stateKey) { - return {}; - } - - const initialDataRaw = localStorage.getItem( - `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, - ); - if (!initialDataRaw) { - return {}; - } - - try { - return JSON.parse(initialDataRaw); - } catch (err) { - console.warn( - `Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`, - initialDataRaw, - err, - ); - return {}; - } - } - - _saveState(chan, cc, value) { - if (!this.stateKey) { - return; - } - - const state = this._loadState(chan); - state[cc] = value; - localStorage.setItem( - `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, - JSON.stringify(state), - ); - } - - _sendAllStates(device) { - const output = WebMidi.outputs.find((o) => o.name === device.name); - if (!output) { - return; - } - - for (const [chan, refs] of Object.entries(this._refsByChan)) { - const channel = Number(chan); - for (const [cc, value] of Object.entries(refs)) { - const ccn = Number(cc); - const scaled = Math.round(value * 127); - output.sendControlChange(ccn, scaled, channel); - } - } - } -} +/* +input.mjs - MIDI input wrapper +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { WebMidi } from 'webmidi'; +import { logger, ref } from '@strudel/core'; +import { getDevice } from './util.mjs'; + +export class MidiInput { + /** + * + * @param {string | number} input MIDI device name or index defaulting to 0 + */ + constructor(input) { + this.input = input; + this.stateKey = typeof input === 'string' ? input : undefined; // Saved state is not tracked for numeric index inputs + + this._refs = {}; + this._refsByChan = {}; + + this._loadAllStates(); + + this.initialDevice = this._startDeviceListener(); + } + + createCC(cc, chan) { + const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan]; + if (!(cc in lookupMap)) { + const initialState = this._loadState(chan); + lookupMap[cc] = initialState[cc] || 0; + } + + return ref(() => lookupMap[cc]); + } + + _startDeviceListener() { + const initialDevice = getDevice(this.input, WebMidi.inputs); + + // Background connection loop + (async () => { + const midiListener = this._onMidiMessage.bind(this); + let device = initialDevice; + + while (true) { + if (!device) { + device = await this._waitForDevice(); + } + + // Wait a bit for device to be ready to receive last state + await new Promise((resolve) => setTimeout(resolve, 2000)); + + try { + // Still continue if sending did not work + this._sendAllStates(device); + } catch (err) { + console.error('midiin: failed to send last state on connect:', device.name, err); + } + + // Listen for incoming MIDI messages and for disconnection + device.addListener('midimessage', midiListener); + + await this._waitForDeviceDisconnect(device); + + device.removeListener('midimessage', midiListener); + device = null; // Clear var to trigger wait for connection + } + })(); + + return initialDevice; + } + + _waitForDevice() { + return new Promise((resolve) => { + const connListener = () => { + const device = getDevice(this.input, WebMidi.inputs); + if (device) { + logger(`[midi] device reconnected: ${device.name}`); + + WebMidi.removeListener('connected', connListener); + resolve(device); + } + }; + + WebMidi.addListener('connected', connListener); + }); + } + + _waitForDeviceDisconnect(device) { + return new Promise((resolve) => { + const disconnListener = (e) => { + if (e.port.name === device.name) { + logger(`[midi] device disconnected: ${device.name}`); + + WebMidi.removeListener('disconnected', disconnListener); + resolve(); + } + }; + + WebMidi.addListener('disconnected', disconnListener); + }); + } + + _onMidiMessage(e) { + const ccNum = e.dataBytes[0]; + const v = e.dataBytes[1]; + const chan = e.message.channel; + const scaled = v / 127; + + this._refs[ccNum] = scaled; + this._refsByChan[ccNum] ??= {}; + this._refsByChan[ccNum][chan] = scaled; + + this._saveState(undefined, ccNum, scaled); + this._saveState(chan, ccNum, scaled); + } + + _loadAllStates() { + Object.assign(this._refs, this._loadState(undefined)); + + for (let chan = 1; chan <= 16; chan++) { + this._refsByChan[chan] ??= {}; + Object.assign(this._refsByChan[chan], this._loadState(chan)); + } + } + + _loadState(chan) { + if (!this.stateKey) { + return {}; + } + + const initialDataRaw = localStorage.getItem( + `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, + ); + if (!initialDataRaw) { + return {}; + } + + try { + return JSON.parse(initialDataRaw); + } catch (err) { + console.warn( + `Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`, + initialDataRaw, + err, + ); + return {}; + } + } + + _saveState(chan, cc, value) { + if (!this.stateKey) { + return; + } + + const state = this._loadState(chan); + state[cc] = value; + localStorage.setItem( + `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, + JSON.stringify(state), + ); + } + + _sendAllStates(device) { + const output = WebMidi.outputs.find((o) => o.name === device.name); + if (!output) { + return; + } + + for (const [chan, refs] of Object.entries(this._refsByChan)) { + const channel = Number(chan); + for (const [cc, value] of Object.entries(refs)) { + const ccn = Number(cc); + const scaled = Math.round(value * 127); + output.sendControlChange(ccn, scaled, channel); + } + } + } +} diff --git a/packages/midi/util.mjs b/packages/midi/util.mjs index 99158e68..f02f1f64 100644 --- a/packages/midi/util.mjs +++ b/packages/midi/util.mjs @@ -1,44 +1,44 @@ -/* -util.mjs - MIDI utility functions -Copyright (C) 2022 Strudel contributors - see -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -*/ - -import { Input, Output } from 'webmidi'; - -/** - * Get a string listing device names for error messages. - * @param {Input[] | Output[]} devices - * @returns {string} - */ -export function getMidiDeviceNamesString(devices) { - return devices.map((o) => `'${o.name}'`).join(' | '); -} - -/** - * Look up a device by index or name. Otherwise return a default device, or fail if none are connected. - * - * @param {string | number} indexOrName - * @param {Input[] | Output[]} devices - * @returns {Input | Output | undefined} - */ -export function getDevice(indexOrName, devices) { - if (typeof indexOrName === 'number') { - return devices[indexOrName]; - } - const byName = (name) => devices.find((output) => output.name.includes(name)); - if (typeof indexOrName === 'string') { - return byName(indexOrName); - } - // attempt to default to first IAC device if none is specified - const IACOutput = byName('IAC'); - const device = IACOutput ?? devices[0]; - if (!device) { - if (!devices.length) { - throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`); - } - throw new Error(`🔌 Default MIDI device not found. Use one of ${getMidiDeviceNamesString(devices)}`); - } - - return device; -} +/* +util.mjs - MIDI utility functions +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { Input, Output } from 'webmidi'; + +/** + * Get a string listing device names for error messages. + * @param {Input[] | Output[]} devices + * @returns {string} + */ +export function getMidiDeviceNamesString(devices) { + return devices.map((o) => `'${o.name}'`).join(' | '); +} + +/** + * Look up a device by index or name. Otherwise return a default device, or fail if none are connected. + * + * @param {string | number} indexOrName + * @param {Input[] | Output[]} devices + * @returns {Input | Output | undefined} + */ +export function getDevice(indexOrName, devices) { + if (typeof indexOrName === 'number') { + return devices[indexOrName]; + } + const byName = (name) => devices.find((output) => output.name.includes(name)); + if (typeof indexOrName === 'string') { + return byName(indexOrName); + } + // attempt to default to first IAC device if none is specified + const IACOutput = byName('IAC'); + const device = IACOutput ?? devices[0]; + if (!device) { + if (!devices.length) { + throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`); + } + throw new Error(`🔌 Default MIDI device not found. Use one of ${getMidiDeviceNamesString(devices)}`); + } + + return device; +} From bc9e97b9ac2473095d6b807297f530269b512ae8 Mon Sep 17 00:00:00 2001 From: Nick Matantsev Date: Thu, 15 Jan 2026 00:13:49 -0500 Subject: [PATCH 15/16] Add comments --- packages/midi/input.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/midi/input.mjs b/packages/midi/input.mjs index 498e6fe2..b9f07581 100644 --- a/packages/midi/input.mjs +++ b/packages/midi/input.mjs @@ -8,6 +8,11 @@ import { WebMidi } from 'webmidi'; import { logger, ref } from '@strudel/core'; import { getDevice } from './util.mjs'; +/** + * MIDI input device wrapper that manages connection and reconnection, tracks + * persisted CC states, etc. These instances are long-lived and are maintained as singletons + * (keyed globally by input string/number). + */ export class MidiInput { /** * @@ -25,6 +30,11 @@ export class MidiInput { this.initialDevice = this._startDeviceListener(); } + /** + * Implementation for the cc() factory function tied to this specific input. + * @param {number} cc MIDI CC number + * @param {number | undefined} chan MIDI channel (1-16) or undefined for all channels + */ createCC(cc, chan) { const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan]; if (!(cc in lookupMap)) { @@ -71,6 +81,7 @@ export class MidiInput { return initialDevice; } + // Returns a promise that resolves when the specified device is connected _waitForDevice() { return new Promise((resolve) => { const connListener = () => { @@ -87,6 +98,7 @@ export class MidiInput { }); } + // Returns a promise that resolves when the specified device is disconnected _waitForDeviceDisconnect(device) { return new Promise((resolve) => { const disconnListener = (e) => { @@ -162,6 +174,7 @@ export class MidiInput { ); } + // Send CC values back to device to restore encoders and motorized sliders _sendAllStates(device) { const output = WebMidi.outputs.find((o) => o.name === device.name); if (!output) { From deb69ced9ffa4b27c586d115ab8cf42f3b094ffe Mon Sep 17 00:00:00 2001 From: unframework Date: Sun, 8 Feb 2026 06:26:44 +0100 Subject: [PATCH 16/16] Fix setting that uses a specific MIDI channel --- packages/midi/input.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/midi/input.mjs b/packages/midi/input.mjs index b9f07581..18fb687d 100644 --- a/packages/midi/input.mjs +++ b/packages/midi/input.mjs @@ -121,8 +121,8 @@ export class MidiInput { const scaled = v / 127; this._refs[ccNum] = scaled; - this._refsByChan[ccNum] ??= {}; - this._refsByChan[ccNum][chan] = scaled; + this._refsByChan[chan] ??= {}; + this._refsByChan[chan][ccNum] = scaled; this._saveState(undefined, ccNum, scaled); this._saveState(chan, ccNum, scaled);