midi.mjsannotatedmidi.mjssource683 lines · 24.0 KB · raw
1/*
2midi.mjs - <short description TODO>
3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs>
4This 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/>.
5*/
6
7import * as _WebMidi from 'webmidi';
8import {
9  Hap,
10  Pattern,
11  TimeSpan,
12  getCps,
13  getIsStarted,
14  getPattern,
15  getTime,
16  getTriggerFunc,
17  isPattern,
18  logger,
19  ref,
20  reify,
21} from '@strudel/core';
22import { noteToMidi, getControlName } from '@strudel/core';
23import { Note } from 'webmidi';
24import { getAudioContext, getClockBridge } from '@strudel/webaudio';
25import { scheduleAtTime, ensureMinimalOutput } from '../superdough/helpers.mjs';
26import { getMidiDeviceNamesString, getDevice } from './util.mjs';
27import { MidiInput } from './input.mjs';
28
29// if you use WebMidi from outside of this package, make sure to import that instance:
30export const { WebMidi } = _WebMidi;
31
32function supportsMidi() {
33  return typeof navigator.requestMIDIAccess === 'function';
34}
35
36export function enableWebMidi(options = {}) {
37  const { onReady, onConnected, onDisconnected, onEnabled } = options;
38  if (WebMidi.enabled) {
39    return;
40  }
41  if (!supportsMidi()) {
42    throw new Error('Your Browser does not support WebMIDI.');
43  }
44  WebMidi.addListener('connected', () => {
45    onConnected?.(WebMidi);
46  });
47  WebMidi.addListener('enabled', () => {
48    onEnabled?.(WebMidi);
49  });
50  // Reacting when a device becomes unavailable
51  WebMidi.addListener('disconnected', (e) => {
52    onDisconnected?.(WebMidi, e);
53  });
54  return new Promise((resolve, reject) => {
55    if (WebMidi.enabled) {
56      // if already enabled, just resolve WebMidi
57      resolve(WebMidi);
58      return;
59    }
60    WebMidi.enable(
61      (err) => {
62        if (err) {
63          reject(err);
64        }
65        onReady?.(WebMidi);
66        resolve(WebMidi);
67      },
68      { sysex: true },
69    );
70  });
71}
72
73// send start/stop messages to outputs when repl starts/stops
74if (typeof window !== 'undefined') {
75  window.addEventListener('message', (e) => {
76    if (!WebMidi?.enabled) {
77      return;
78    }
79    if (e.data === 'strudel-stop') {
80      WebMidi.outputs.forEach((output) => output.sendStop());
81    }
82    // cannot start here, since we have no timing info, see sendStart below
83  });
84}
85
86// registry for midi mappings, converting control names to cc messages
87export const midicontrolMap = new Map();
88
89// takes midimap and converts each control key to the main control name
90function unifyMapping(mapping) {
91  return Object.fromEntries(
92    Object.entries(mapping).map(([key, mapping]) => {
93      if (typeof mapping === 'number') {
94        mapping = { ccn: mapping };
95      }
96      return [getControlName(key), mapping];
97    }),
98  );
99}
100
101function githubPath(base, subpath = '') {
102  if (!base.startsWith('github:')) {
103    throw new Error('expected "github:" at the start of pseudoUrl');
104  }
105  let [_, path] = base.split('github:');
106  path = path.endsWith('/') ? path.slice(0, -1) : path;
107  if (path.split('/').length === 2) {
108    // assume main as default branch if none set
109    path += '/main';
110  }
111  return `https://raw.githubusercontent.com/${path}/${subpath}`;
112}
113
114/**
115 * configures the default midimap, which is used when no "midimap" port is set
116 * @tags external_io, midi
117 * @example
118 * defaultmidimap({ lpf: 74 })
119 * $: note("c a f e").midi();
120 * $: lpf(sine.slow(4).segment(16)).midi();
121 */
122export function defaultmidimap(mapping) {
123  midicontrolMap.set('default', unifyMapping(mapping));
124}
125
126let loadCache = {};
127
128/**
129 * Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
130 * @tags external_io, midi
131 * @example
132 * midimaps({ mymap: { lpf: 74 } })
133 * $: note("c a f e")
134 * .lpf(sine.slow(4))
135 * .midimap('mymap')
136 * .midi()
137 * @example
138 * midimaps({ mymap: {
139 *   lpf: { ccn: 74, min: 0, max: 20000, exp: 0.5 }
140 * }})
141 * $: note("c a f e")
142 * .lpf(sine.slow(2).range(400,2000))
143 * .midimap('mymap')
144 * .midi()
145 */
146export async function midimaps(map) {
147  if (typeof map === 'string') {
148    if (map.startsWith('github:')) {
149      map = githubPath(map, 'midimap.json');
150    }
151    if (!loadCache[map]) {
152      loadCache[map] = fetch(map).then((res) => res.json());
153    }
154    map = await loadCache[map];
155  }
156  if (typeof map === 'object') {
157    Object.entries(map).forEach(([name, mapping]) => midicontrolMap.set(name, unifyMapping(mapping)));
158  }
159}
160
161// registry for midi sounds, converting sound names to controls
162export const midisoundMap = new Map();
163
164// normalizes the given value from the given range and exponent
165function normalize(value = 0, min = 0, max = 1, exp = 1) {
166  if (min === max) {
167    throw new Error('min and max cannot be the same value');
168  }
169  let normalized = (value - min) / (max - min);
170  normalized = Math.min(1, Math.max(0, normalized));
171  return Math.pow(normalized, exp);
172}
173
174const isFirefox = navigator?.userAgent?.includes('Firefox');
175// call fn either directly with given time (non-firefox) or after scheduleAtTime with undefined (firefox)
176// the scheduleAtTime approach is still jittery, but the best we can be on firefox
177// firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=2062997
178function timedSend(timeMs, fn) {
179  if (isFirefox) {
180    const audioTime = getClockBridge().getAudioContextTime(timeMs);
181    if (!audioTime) {
182      logger('[midi]: skip event, not ready');
183      return;
184    }
185    scheduleAtTime(() => fn(undefined), audioTime);
186  } else {
187    fn(timeMs);
188  }
189}
190
191function mapCC(mapping, value) {
192  return Object.keys(value)
193    .filter((key) => !!mapping[getControlName(key)])
194    .map((key) => {
195      const { ccn, min = 0, max = 1, exp = 1 } = mapping[key];
196      const ccv = normalize(value[key], min, max, exp);
197      return { ccn, ccv };
198    });
199}
200
201// sends a cc message to the given device on the given channel
202function sendCC(ccn, ccv, device, midichan, timeMs) {
203  if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
204    throw new Error('expected ccv to be a number between 0 and 1');
205  }
206  if (!['string', 'number'].includes(typeof ccn)) {
207    throw new Error('expected ccn to be a number or a string');
208  }
209  const scaled = Math.round(ccv * 127);
210  timedSend(timeMs, (timeMs) => device.sendControlChange(ccn, scaled, { channels: midichan, time: timeMs }));
211}
212
213// sends a program change message to the given device on the given channel
214function sendProgramChange(progNum, device, midichan, timeMs) {
215  if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
216    throw new Error('expected progNum (program change) to be a number between 0 and 127');
217  }
218  timedSend(timeMs, (timeMs) => device.sendProgramChange(progNum, { channels: midichan, time: timeMs }));
219}
220
221// sends a sysex message to the given device on the given channel
222function sendSysex(sysexid, sysexdata, device, timeMs) {
223  if (Array.isArray(sysexid)) {
224    if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
225      throw new Error('all sysexid bytes must be integers between 0 and 255');
226    }
227  } else if (!Number.isInteger(sysexid) || sysexid < 0 || sysexid > 255) {
228    throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
229  }
230
231  if (!Array.isArray(sysexdata)) {
232    throw new Error('expected sysex to be an array of numbers (0-255)');
233  }
234  if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
235    throw new Error('all sysex bytes must be integers between 0 and 255');
236  }
237  timedSend(timeMs, (timeMs) => device.sendSysex(sysexid, sysexdata, { time: timeMs }));
238}
239
240// sends a NRPN message to the given device on the given channel
241function sendNRPN(nrpnn, nrpv, device, midichan, timeMs) {
242  if (Array.isArray(nrpnn)) {
243    if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
244      throw new Error('all nrpnn bytes must be integers between 0 and 255');
245    }
246  } else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
247    throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
248  }
249
250  timedSend(timeMs, (timeMs) => device.sendNrpnValue(nrpnn, nrpv, { channels: midichan, time: timeMs }));
251}
252
253// sends a pitch bend message to the given device on the given channel
254function sendPitchBend(midibend, device, midichan, timeMs) {
255  if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
256    throw new Error('expected midibend to be a number between -1 and 1');
257  }
258  timedSend(timeMs, (timeMs) => device.sendPitchBend(midibend, { channels: midichan, time: timeMs }));
259}
260
261// sends a channel aftertouch message to the given device on the given channel
262function sendAftertouch(miditouch, device, midichan, timeMs) {
263  if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
264    throw new Error('expected miditouch to be a number between 0 and 1');
265  }
266
267  timedSend(timeMs, (timeMs) => device.sendChannelAftertouch(miditouch, { channels: midichan, time: timeMs }));
268}
269
270// sends a note message to the given device on the given channel
271function sendNote(note, velocity, duration, device, midichan, timeMs) {
272  if (note == null || note === '') {
273    throw new Error('note cannot be null or empty');
274  }
275  if (velocity != null && (typeof velocity !== 'number' || velocity < 0 || velocity > 1)) {
276    throw new Error('velocity must be a number between 0 and 1');
277  }
278  if (duration != null && (typeof duration !== 'number' || duration < 0)) {
279    throw new Error('duration must be a positive number');
280  }
281  const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
282  const midiNote = new Note(midiNumber, { attack: velocity });
283
284  timedSend(timeMs, (timeMs) => device.sendNoteOn(midiNote, { channels: midichan, time: timeMs }));
285  timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs }));
286}
287
288/**
289 * MIDI output: Opens a MIDI output port.
290 * @tags external_io
291 * @param {string | number} midiport MIDI device name or index defaulting to 0
292 * @param {object} options Additional MIDI configuration options
293 * @example
294 * note("c4").midichan(1).midi('IAC Driver Bus 1')
295 */
296
297Pattern.prototype.midi = function (midiport, options = {}) {
298  if (isPattern(midiport)) {
299    throw new Error(
300      `.midi does not accept Pattern input for midiport. Make sure to pass device name with single quotes. Example: .midi('${
301        WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
302      }')`,
303    );
304  }
305
306  // For backward compatibility
307  if (typeof midiport === 'object') {
308    const { port, isController = false, ...configOptions } = midiport;
309    options = {
310      isController,
311      ...configOptions,
312      ...options, // Keep any options passed separately
313    };
314    midiport = port;
315  }
316
317  let midiConfig = {
318    // Default configuration values
319    isController: false, // Disable sending notes for midi controllers
320    noteOffsetMs: isFirefox ? 10 : 1, // Default note-off offset to prevent glitching in ms. firefox needs more slack
321    midichannel: 1, // Default MIDI channel
322    velocity: 0.9, // Default velocity
323    gain: 1, // Default gain
324    midimap: 'default', // Default MIDI map
325    midiport: midiport, // Store the port in the config
326    ...options, // Override defaults with provided options
327  };
328
329  enableWebMidi({
330    onEnabled: ({ outputs }) => {
331      const device = getDevice(midiConfig.midiport, outputs);
332      const otherOutputs = outputs.filter((o) => o.name !== device.name);
333      logger(
334        `Midi enabled! Using "${device.name}". ${
335          otherOutputs?.length ? `Also available: ${getMidiDeviceNamesString(otherOutputs)}` : ''
336        }`,
337      );
338    },
339    onDisconnected: ({ outputs }) =>
340      logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
341  });
342
343  ensureMinimalOutput();
344
345  return this.sortHapsByPart().onTrigger((hap, _currentTime, cps, targetTime) => {
346    if (!WebMidi.enabled) {
347      logger('Midi not enabled');
348      return;
349    }
350    const timeMs = getClockBridge().getPerformanceTime(targetTime);
351    if (!timeMs) {
352      logger('[midi] clockbridge not ready');
353      return;
354    }
355
356    hap.ensureObjectValue();
357
358    // midi event values from hap with configurable defaults
359    let {
360      note,
361      nrpnn,
362      nrpv,
363      ccn,
364      ccv,
365      midichan = midiConfig.midichannel,
366      midicmd,
367      midibend,
368      miditouch,
369      polyTouch,
370      gain = midiConfig.gain,
371      velocity = midiConfig.velocity,
372      progNum,
373      sysexid,
374      sysexdata,
375      midimap = midiConfig.midimap,
376      midiport = midiConfig.midiport,
377    } = hap.value;
378
379    const device = getDevice(midiport, WebMidi.outputs);
380    if (!device) {
381      logger(
382        `[midi] midiport "${midiport}" not found! available: ${WebMidi.outputs.map((output) => `'${output.name}'`).join(', ')}`,
383      );
384      return;
385    }
386
387    velocity = gain * velocity;
388
389    // Handle midimap
390    // if midimap is set, send a cc messages from defined controls
391    if (midicontrolMap.has(midimap)) {
392      const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
393      ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeMs));
394    } else if (midimap !== 'default') {
395      // Add warning when a non-existent midimap is specified
396      logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
397    }
398
399    // Handle note
400    if (note !== undefined && !midiConfig.isController) {
401      // note off time is calculated early, together with note on time
402      // when the note off is due, the clock might have drifted, and the next note on message might happen before the note off
403      // this would lead to the next note being cut off
404      // this is why we make notes shorter by noteOffsetMs, so note offs happen earlier than the note ons after
405      const hapDuration = (hap.duration.valueOf() / cps) * 1000;
406      const offset = Math.min(midiConfig.noteOffsetMs, hapDuration / 2);
407      const duration = hapDuration - offset;
408
409      sendNote(note, velocity, duration, device, midichan, timeMs);
410    }
411
412    // Handle program change
413    if (progNum !== undefined) {
414      sendProgramChange(progNum, device, midichan, timeMs);
415    }
416
417    // Handle sysex
418    // sysex data is consist of 2 arrays, first is sysexid, second is sysexdata
419    // sysexid is a manufacturer id it is either a number or an array of 3 numbers.
420    // list of manufacturer ids can be found here : https://midi.org/sysexidtable
421    // if sysexid is an array the first byte is 0x00
422
423    if (sysexid !== undefined && sysexdata !== undefined) {
424      sendSysex(sysexid, sysexdata, device, timeMs);
425    }
426
427    // Handle control change
428    if (ccv !== undefined && ccn !== undefined) {
429      sendCC(ccn, ccv, device, midichan, timeMs);
430    }
431
432    // Handle NRPN non-registered parameter number
433    if (nrpnn !== undefined && nrpv !== undefined) {
434      sendNRPN(nrpnn, nrpv, device, midichan, timeMs);
435    }
436
437    // Handle midibend
438    if (midibend !== undefined) {
439      sendPitchBend(midibend, device, midichan, timeMs);
440    }
441
442    // Handle miditouch
443    if (miditouch !== undefined) {
444      sendAftertouch(miditouch, device, midichan, timeMs);
445    }
446
447    // Handle midicmd
448    if (hap.whole.begin + 0 === 0) {
449      // we need to start here because we have the timing info
450      timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
451    }
452    if (['clock', 'midiClock'].includes(midicmd)) {
453      timedSend(timeMs, (timeMs) => device.sendClock({ time: timeMs }));
454    } else if (['start'].includes(midicmd)) {
455      timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
456    } else if (['stop'].includes(midicmd)) {
457      timedSend(timeMs, (timeMs) => device.sendStop({ time: timeMs }));
458    } else if (['continue'].includes(midicmd)) {
459      timedSend(timeMs, (timeMs) => device.sendContinue({ time: timeMs }));
460    } else if (Array.isArray(midicmd)) {
461      if (midicmd[0] === 'progNum') {
462        sendProgramChange(midicmd[1], device, midichan, timeMs);
463      } else if (midicmd[0] === 'cc') {
464        if (midicmd.length === 2) {
465          sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeMs);
466        }
467      } else if (midicmd[0] === 'sysex') {
468        if (midicmd.length === 3) {
469          const [_, id, data] = midicmd;
470          sendSysex(id, data, device, timeMs);
471        }
472      }
473    }
474  });
475};
476
477/**
478 * Initialize a midi input device
479 */
480async function _initializeInput(input) {
481  if (isPattern(input)) {
482    throw new Error(
483      `[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${
484        WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
485      }')`,
486    );
487  }
488
489  const initial = await enableWebMidi(); // only returns on first init
490
491  const instance = midiInputs[input] || new MidiInput(input);
492  midiInputs[input] = instance;
493
494  if (initial) {
495    const device = instance.initialDevice;
496
497    const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
498    logger(
499      device
500        ? `[midi] Midi enabled! Using "${device.name}". ${
501            otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
502          }`
503        : `[midi] Midi enabled! Waiting for device "${input}"... Currently connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
504    );
505  }
506
507  return instance;
508}
509
510// MIDI input wrappers, by specified input string/index
511const midiInputs = {};
512
513/**
514 * MIDI input: Opens a MIDI input port to receive MIDI control change messages.
515 *
516 * The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel
517 *
518 * @name midin
519 * @tags external_io, midi
520 * @param {string | number} input MIDI device name or index defaulting to 0
521 * @returns {function(number, number=): Pattern} A function from (cc, channel?) to a pattern.
522 *   When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1)
523 *   that came through that cc number (and channel, if provided)
524 * @example
525 * const cc = await midin('IAC Driver Bus 1')
526 * note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth")
527 * @example
528 * const allCC = await midin('IAC Driver Bus 1')
529 * const cc = (ccNum) => allCC(ccNum, 2) // just channel 2
530 * note("c a f e").s("saw")
531 *   .when(cc(0).gt(0), x => x.postgain(0))
532 */
533export async function midin(input) {
534  const instance = await _initializeInput(input);
535
536  return instance.createCC.bind(instance);
537}
538
539/**
540 * MIDI keyboard: Opens a MIDI input port to receive MIDI keyboard messages.
541 *
542 * The note length is fixed as Superdough is not currently set up for undetermined
543 * note durations
544 *
545 * The 'midichan' control value contains the number of the channel the note is coming from
546 * so it could be filtered or manipulated further in the chain.
547 *
548 * @name midikeys
549 * @tags external_io, midi
550 * @param {string | number} input MIDI device name or index defaulting to 0
551 * @returns {function((number | Pattern)=): Pattern} A function that produces a pattern.
552 *   When queried, the pattern will produces the most recently played midi notes and velocities,
553 *   lasting for the specified duration
554 * @example
555 * const kb = await midikeys('Arturia KeyStep 32')
556 * kb().s("tri").lpf(80).lpe(6).lpd(0.1).room(2).delay(0.35)
557 * @example
558 * const kb = await midikeys('Arturia KeyStep 32')
559 * kb("0.5 1")
560 *   .s("saw")
561 *   .add(note(rand.mul(0.3)))
562 *   .lpf(1000).lpe(2).room(0.5)
563 * @example
564 * // discard all notes not coming out from midi channel 2
565 * const kb = await midikeys('Arturia KeyStep 32')
566 * kb().filterValues(v=>v.midichan==2).s("tri")
567 */
568const kHaps = {};
569const kListeners = {};
570
571function _triggerKeyboard(input, cps, now, latencyCycles) {
572  const pattern = getPattern();
573  const trigger = getTriggerFunc();
574  if (!pattern || !trigger) {
575    return false;
576  }
577  const t = now + latencyCycles;
578  const eps = 1e-6;
579  const haps = pattern.queryArc(t - eps, t + eps, { _cps: cps });
580  // Only keep haps coming from `midikeys`
581  const kbHaps = haps.filter((hap) => hap.value?.midikey?.startsWith(`${input}_`));
582  const ctxNow = getAudioContext().currentTime;
583  if (!kbHaps.length) {
584    return false;
585  }
586  kbHaps.forEach((hap) => {
587    if (!hap.hasOnset()) {
588      return;
589    }
590    const t = ctxNow + (hap.whole.begin - now) / cps;
591    const duration = hap.duration / cps;
592    trigger(hap, t - ctxNow, duration, cps, t);
593  });
594
595  return true;
596}
597export async function midikeys(input) {
598  const instance = await _initializeInput(input);
599
600  // TODO: support unpluggable device usage
601  const device = instance.initialDevice;
602  if (!device) {
603    throw new Error(
604      `[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
605    );
606  }
607
608  if (!kHaps[input]) {
609    kHaps[input] = [];
610  }
611  kListeners[input] && device.removeListener('midimessage', kListeners[input]);
612  kListeners[input] = (e) => {
613    const { dataBytes, message } = e;
614    const noteon = message.command === 9;
615    let noteoff = message.command === 8;
616    // Don't enqueue or trigger midi notes if scheduler is not started
617    const notStarted = !getIsStarted();
618    // Ignore non-note messages (e.g. CC, pitchbend, modwheel, etc.)
619    const notANote = !noteon && !noteoff;
620    if (notStarted || notANote) {
621      return;
622    }
623    const [note, velocity] = dataBytes;
624    noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff
625    const key = `${input}_${note}`;
626    const cps = getCps() ?? 0.5;
627    const triggerAvailable = !!(getPattern() && getTriggerFunc());
628    const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency
629    const now = getTime();
630    const t = now + latencySeconds * cps;
631    const span = new TimeSpan(t, t);
632    let value = { midikey: key };
633    if (noteoff) {
634      /* TODO: It's a big effort, but we could modify superdough to allow for situations where
635      we don't know the hap duration in advance. This would mean, for example, that if the hap
636      is flagged as such a special note-on event, we have all effects be persistent & all ADSR
637      envelopes stop at the S stage [and store references to them by `midikey`]
638      If this is implemented, then getting full keyboard functionality should be as simple
639      as sending the corresponding note-off event below and triggering `release` on each of those
640      referenced effects/envelopes
641      
642      value = { ...value, noteoff: true };
643  
644      If this is achieved, we can remove the noteLength parameter
645      */
646      return;
647    } else {
648      value = { ...value, note: Math.round(note), velocity: velocity / 127, midichan: message.channel };
649    }
650    kHaps[input].push(new Hap(span, span, value, {}));
651    if (!noteoff && triggerAvailable) {
652      // If we have access to a trigger function, we call it to immediately
653      // dispatch to the audio engine, rather than waiting for cyclist to catch these haps
654      const triggered = _triggerKeyboard(input, cps, now, latencySeconds * cps);
655      if (triggered) {
656        kHaps[input] = [];
657      }
658    }
659  };
660  device.addListener('midimessage', kListeners[input]);
661  const kb = (noteLength = 0.5) => {
662    const nlPat = reify(noteLength);
663    const query = (state) => {
664      const haps = kHaps[input].flatMap((hap) => {
665        const lenHaps = nlPat.query(state.setSpan(hap.wholeOrPart()));
666        return lenHaps.map((lenHap) => {
667          const nl = lenHap.value ?? 0.5;
668          const whole = new TimeSpan(hap.whole.begin, hap.whole.begin.add(nl));
669          const part = new TimeSpan(hap.part.begin, hap.part.begin.add(nl));
670          const context = hap.combineContext(lenHap);
671          return new Hap(whole, part, hap.value, context);
672        });
673      });
674      if (state.controls.cyclist) {
675        // Notes have been sent; clear them
676        kHaps[input] = [];
677      }
678      return haps;
679    };
680    return new Pattern(query);
681  };
682  return kb;
683}