1/* 2input.mjs - MIDI input wrapper 3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs> 4This 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/>. 5*/ 6 7import { WebMidi } from 'webmidi'; 8import { logger, ref } from '@strudel/core'; 9import { getDevice } from './util.mjs'; 10 11/** 12 * MIDI input device wrapper that manages connection and reconnection, tracks 13 * persisted CC states, etc. These instances are long-lived and are maintained as singletons 14 * (keyed globally by input string/number). 15 */ 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 } 32 33 /** 34 * Implementation for the cc() factory function tied to this specific input. 35 * @param {number} cc MIDI CC number 36 * @param {number | undefined} chan MIDI channel (1-16) or undefined for all channels 37 */ 38 createCC(cc, chan) { 39 const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan]; 40 if (!(cc in lookupMap)) { 41 const initialState = this._loadState(chan); 42 lookupMap[cc] = initialState[cc] || 0; 43 } 44 45 return ref(() => lookupMap[cc]); 46 } 47 48 _startDeviceListener() { 49 const initialDevice = getDevice(this.input, WebMidi.inputs); 50 51 // Background connection loop 52 (async () => { 53 const midiListener = this._onMidiMessage.bind(this); 54 let device = initialDevice; 55 56 while (true) { 57 if (!device) { 58 device = await this._waitForDevice(); 59 } 60 61 // Wait a bit for device to be ready to receive last state 62 await new Promise((resolve) => setTimeout(resolve, 2000)); 63 64 try { 65 // Still continue if sending did not work 66 this._sendAllStates(device); 67 } catch (err) { 68 console.error('midiin: failed to send last state on connect:', device.name, err); 69 } 70 71 // Listen for incoming MIDI messages and for disconnection 72 device.addListener('midimessage', midiListener); 73 74 await this._waitForDeviceDisconnect(device); 75 76 device.removeListener('midimessage', midiListener); 77 device = null; // Clear var to trigger wait for connection 78 } 79 })(); 80 81 return initialDevice; 82 } 83 84 // 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 } 100 101 // 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 } 116 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 } 176 177 // 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}