Carry out MIDI input wrapper into dedicated file
This commit is contained in:
parent
bb7f587025
commit
ecada58edc
3 changed files with 224 additions and 200 deletions
183
packages/midi/input.mjs
Normal file
183
packages/midi/input.mjs
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/*
|
||||||
|
midi.mjs - <short description TODO>
|
||||||
|
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 { 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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (!initialDevice) {
|
||||||
|
console.warn(`midiin: device "${this.input}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background connection loop
|
||||||
|
(async () => {
|
||||||
|
const midiListener = this._onMidiMessage.bind(this);
|
||||||
|
let device = initialDevice;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (!device) {
|
||||||
|
device = await this._waitForDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('midiin: device connected:', device.name);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
console.warn('midiin: device disconnected:', device.name);
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,10 +5,12 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as _WebMidi from 'webmidi';
|
import * as _WebMidi from 'webmidi';
|
||||||
import { Pattern, isPattern, logger, ref } from '@strudel/core';
|
import { Pattern, isPattern, logger } from '@strudel/core';
|
||||||
import { noteToMidi, getControlName } from '@strudel/core';
|
import { noteToMidi, getControlName } from '@strudel/core';
|
||||||
import { Note } from 'webmidi';
|
import { Note } from 'webmidi';
|
||||||
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;
|
||||||
|
|
@ -17,10 +19,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) {
|
||||||
|
|
@ -58,27 +56,6 @@ export function enableWebMidi(options = {}) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) => {
|
||||||
|
|
@ -475,180 +452,6 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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);
|
|
||||||
if (!initialDevice) {
|
|
||||||
console.warn(`midiin: device "${this.input}" not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background connection loop
|
|
||||||
(async () => {
|
|
||||||
const midiListener = this._onMidiMessage.bind(this);
|
|
||||||
let device = initialDevice;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
if (!device) {
|
|
||||||
device = await this._waitForDevice();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('midiin: device connected:', device.name);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
console.warn('midiin: device disconnected:', device.name);
|
|
||||||
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) {
|
|
||||||
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) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MIDI input wrappers, by specified input string/index
|
// MIDI input wrappers, by specified input string/index
|
||||||
const midiInputs = {};
|
const midiInputs = {};
|
||||||
|
|
||||||
|
|
|
||||||
38
packages/midi/util.mjs
Normal file
38
packages/midi/util.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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