diff --git a/packages/midi/input.mjs b/packages/midi/input.mjs index 8e4889427..498e6fe21 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 99158e68a..f02f1f64e 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; +}