diff --git a/packages/midi/input.mjs b/packages/midi/input.mjs new file mode 100644 index 00000000..18fb687d --- /dev/null +++ b/packages/midi/input.mjs @@ -0,0 +1,193 @@ +/* +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'; + +/** + * 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 { + /** + * + * @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(); + } + + /** + * 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)) { + 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; + } + + // Returns a promise that resolves when the specified device is connected + _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); + }); + } + + // Returns a promise that resolves when the specified device is disconnected + _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[chan] ??= {}; + this._refsByChan[chan][ccNum] = 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), + ); + } + + // 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) { + 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 bb52b994..bd40b743 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -23,6 +23,8 @@ import { noteToMidi, getControlName } from '@strudel/core'; import { Note } from 'webmidi'; import { getAudioContext } from '@strudel/webaudio'; 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; @@ -31,10 +33,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) { @@ -72,29 +70,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]; - } - 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) { - throw new Error( - `🔌 MIDI device '${device ? device : ''}' not found. Use one of ${getMidiDeviceNamesString(devices)}`, - ); - } - - return IACOutput ?? devices[0]; -} - // send start/stop messages to outputs when repl starts/stops if (typeof window !== 'undefined') { window.addEventListener('message', (e) => { @@ -495,9 +470,9 @@ Pattern.prototype.midi = function (midiport, options = {}) { }; /** - * Initialize a midi device + * Initialize a midi input device */ -async function _initialize(input) { +async function _initializeInput(input) { if (isPattern(input)) { throw new Error( `[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${ @@ -505,24 +480,31 @@ async function _initialize(input) { }')`, ); } + const initial = await enableWebMidi(); // only returns on first init - const device = getDevice(input, WebMidi.inputs); - if (!device) { - throw new Error( - `[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, - ); - } + + const instance = midiInputs[input] || new MidiInput(input); + midiInputs[input] = instance; + if (initial) { + const device = instance.initialDevice; + const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name); logger( - `[midi] Midi enabled! Using "${device.name}". ${ - otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : '' - }`, + device + ? `[midi] Midi enabled! Using "${device.name}". ${ + otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : '' + }` + : `[midi] Midi enabled! Waiting for device "${input}"... Currently connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, ); } - return device; + + return instance; } +// MIDI input wrappers, by specified input string/index +const midiInputs = {}; + /** * MIDI input: Opens a MIDI input port to receive MIDI control change messages. * @@ -543,31 +525,10 @@ async function _initialize(input) { * note("c a f e").s("saw") * .when(cc(0).gt(0), x => x.postgain(0)) */ -let listeners = {}; -const refs = {}; -const refsByChan = {}; export async function midin(input) { - const device = await _initialize(input); - refs[input] ??= {}; - refsByChan[input] ??= {}; - const cc = (cc, chan) => { - if (chan !== undefined) { - return ref(() => refsByChan[input][cc]?.[chan] || 0); - } - return ref(() => refs[input][cc] || 0); - }; + const instance = await _initializeInput(input); - listeners[input] && device.removeListener('midimessage', listeners[input]); - listeners[input] = (e) => { - const [ccNum, v] = e.dataBytes; - const chan = e.message.channel; - const scaled = v / 127; - refsByChan[input][ccNum] ??= {}; - refsByChan[input][ccNum][chan] = scaled; - refs[input][ccNum] = scaled; - }; - device.addListener('midimessage', listeners[input]); - return cc; + return instance.createCC.bind(instance); } /** @@ -622,7 +583,16 @@ function _triggerKeyboard(input, cps, now, latencyCycles) { return true; } export async function midikeys(input) { - const device = await _initialize(input); + const instance = await _initializeInput(input); + + // TODO: support unpluggable device usage + const device = instance.initialDevice; + if (!device) { + throw new Error( + `[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, + ); + } + if (!kHaps[input]) { kHaps[input] = []; } diff --git a/packages/midi/util.mjs b/packages/midi/util.mjs new file mode 100644 index 00000000..f02f1f64 --- /dev/null +++ b/packages/midi/util.mjs @@ -0,0 +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; +}