input.mjs - MIDI input wrapper Copyright (C) 2022 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs 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 https://www.gnu.org/licenses/.
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).
16export class MidiInput { 17 /** 18 * 19 * @param {string | number} input MIDI device name or index defaulting to 0 20 */ 21 constructor(input) { 22 this.input = input; 23 this.stateKey = typeof input === 'string' ? input : undefined; // Saved state is not tracked for numeric index inputs 24 25 this._refs = {}; 26 this._refsByChan = {}; 27 28 this._loadAllStates(); 29 30 this.initialDevice = this._startDeviceListener(); 31 }
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
Background connection loop
Wait a bit for device to be ready to receive last state
62 await new Promise((resolve) => setTimeout(resolve, 2000));
Listen for incoming MIDI messages and for disconnection
72 device.addListener('midimessage', midiListener);
Returns a promise that resolves when the specified device is connected
85 _waitForDevice() { 86 return new Promise((resolve) => { 87 const connListener = () => { 88 const device = getDevice(this.input, WebMidi.inputs); 89 if (device) { 90 logger(`[midi] device reconnected: ${device.name}`); 91 92 WebMidi.removeListener('connected', connListener); 93 resolve(device); 94 } 95 }; 96 97 WebMidi.addListener('connected', connListener); 98 }); 99 }
Returns a promise that resolves when the specified device is disconnected
102 _waitForDeviceDisconnect(device) { 103 return new Promise((resolve) => { 104 const disconnListener = (e) => { 105 if (e.port.name === device.name) { 106 logger(`[midi] device disconnected: ${device.name}`); 107 108 WebMidi.removeListener('disconnected', disconnListener); 109 resolve(); 110 } 111 }; 112 113 WebMidi.addListener('disconnected', disconnListener); 114 }); 115 }
117 _onMidiMessage(e) { 118 const ccNum = e.dataBytes[0]; 119 const v = e.dataBytes[1]; 120 const chan = e.message.channel; 121 const scaled = v / 127; 122 123 this._refs[ccNum] = scaled; 124 this._refsByChan[chan] ??= {}; 125 this._refsByChan[chan][ccNum] = scaled; 126 127 this._saveState(undefined, ccNum, scaled); 128 this._saveState(chan, ccNum, scaled); 129 } 130 131 _loadAllStates() { 132 Object.assign(this._refs, this._loadState(undefined)); 133 134 for (let chan = 1; chan <= 16; chan++) { 135 this._refsByChan[chan] ??= {}; 136 Object.assign(this._refsByChan[chan], this._loadState(chan)); 137 } 138 } 139 140 _loadState(chan) { 141 if (!this.stateKey) { 142 return {}; 143 } 144 145 const initialDataRaw = localStorage.getItem( 146 `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, 147 ); 148 if (!initialDataRaw) { 149 return {}; 150 } 151 152 try { 153 return JSON.parse(initialDataRaw); 154 } catch (err) { 155 console.warn( 156 `Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`, 157 initialDataRaw, 158 err, 159 ); 160 return {}; 161 } 162 } 163 164 _saveState(chan, cc, value) { 165 if (!this.stateKey) { 166 return; 167 } 168 169 const state = this._loadState(chan); 170 state[cc] = value; 171 localStorage.setItem( 172 `strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`, 173 JSON.stringify(state), 174 ); 175 }
Send CC values back to device to restore encoders and motorized sliders
178 _sendAllStates(device) { 179 const output = WebMidi.outputs.find((o) => o.name === device.name); 180 if (!output) { 181 return; 182 } 183 184 for (const [chan, refs] of Object.entries(this._refsByChan)) { 185 const channel = Number(chan); 186 for (const [cc, value] of Object.entries(refs)) { 187 const ccn = Number(cc); 188 const scaled = Math.round(value * 127); 189 output.sendControlChange(ccn, scaled, channel); 190 } 191 } 192 } 193}