jevstrudel.git / packages / midi / input.mjs

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/.

7import { WebMidi } from 'webmidi';
8import { logger, ref } from '@strudel/core';
9import { 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).

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  }

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

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  }
48  _startDeviceListener() {
49    const initialDevice = getDevice(this.input, WebMidi.inputs);

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        }

Wait a bit for device to be ready to receive last state

62        await new Promise((resolve) => setTimeout(resolve, 2000));
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        }

Listen for incoming MIDI messages and for disconnection

72        device.addListener('midimessage', midiListener);
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  }

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  }

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  }
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  }

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}