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}