Merge pull request 'Feature: reconnectable MIDI inputs that keep track of last known CC state' (#1852) from unframework/strudel:nm/midi-input-state into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1852
This commit is contained in:
commit
2755a7eebf
3 changed files with 270 additions and 63 deletions
193
packages/midi/input.mjs
Normal file
193
packages/midi/input.mjs
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
/*
|
||||||
|
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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,8 @@ import { noteToMidi, getControlName } from '@strudel/core';
|
||||||
import { Note } from 'webmidi';
|
import { Note } from 'webmidi';
|
||||||
import { getAudioContext } from '@strudel/webaudio';
|
import { getAudioContext } from '@strudel/webaudio';
|
||||||
import { scheduleAtTime } from '../superdough/helpers.mjs';
|
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:
|
// if you use WebMidi from outside of this package, make sure to import that instance:
|
||||||
export const { WebMidi } = _WebMidi;
|
export const { WebMidi } = _WebMidi;
|
||||||
|
|
@ -31,10 +33,6 @@ function supportsMidi() {
|
||||||
return typeof navigator.requestMIDIAccess === 'function';
|
return typeof navigator.requestMIDIAccess === 'function';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMidiDeviceNamesString(devices) {
|
|
||||||
return devices.map((o) => `'${o.name}'`).join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function enableWebMidi(options = {}) {
|
export function enableWebMidi(options = {}) {
|
||||||
const { onReady, onConnected, onDisconnected, onEnabled } = options;
|
const { onReady, onConnected, onDisconnected, onEnabled } = options;
|
||||||
if (WebMidi.enabled) {
|
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
|
// send start/stop messages to outputs when repl starts/stops
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('message', (e) => {
|
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)) {
|
if (isPattern(input)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${
|
`[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 initial = await enableWebMidi(); // only returns on first init
|
||||||
const device = getDevice(input, WebMidi.inputs);
|
|
||||||
if (!device) {
|
const instance = midiInputs[input] || new MidiInput(input);
|
||||||
throw new Error(
|
midiInputs[input] = instance;
|
||||||
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (initial) {
|
if (initial) {
|
||||||
|
const device = instance.initialDevice;
|
||||||
|
|
||||||
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
|
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
|
||||||
logger(
|
logger(
|
||||||
`[midi] Midi enabled! Using "${device.name}". ${
|
device
|
||||||
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
? `[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.
|
* 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")
|
* note("c a f e").s("saw")
|
||||||
* .when(cc(0).gt(0), x => x.postgain(0))
|
* .when(cc(0).gt(0), x => x.postgain(0))
|
||||||
*/
|
*/
|
||||||
let listeners = {};
|
|
||||||
const refs = {};
|
|
||||||
const refsByChan = {};
|
|
||||||
export async function midin(input) {
|
export async function midin(input) {
|
||||||
const device = await _initialize(input);
|
const instance = await _initializeInput(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);
|
|
||||||
};
|
|
||||||
|
|
||||||
listeners[input] && device.removeListener('midimessage', listeners[input]);
|
return instance.createCC.bind(instance);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -622,7 +583,16 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
export async function midikeys(input) {
|
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]) {
|
if (!kHaps[input]) {
|
||||||
kHaps[input] = [];
|
kHaps[input] = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
packages/midi/util.mjs
Normal file
44
packages/midi/util.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
util.mjs - MIDI utility functions
|
||||||
|
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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue