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/.
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';
if you use WebMidi from outside of this package, make sure to import that instance:
30export const { WebMidi } = _WebMidi;
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}
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}
registry for midi mappings, converting control names to cc messages
87export const midicontrolMap = new Map();
takes midimap and converts each control key to the main control name
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}
configures the default midimap, which is used when no "midimap" port is set @tags external_io, midi @example defaultmidimap({ lpf: 74 }) $: note("c a f e").midi(); $: lpf(sine.slow(4).segment(16)).midi();
126let loadCache = {};
Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers. @tags external_io, midi @example midimaps({ mymap: { lpf: 74 } }) $: note("c a f e") .lpf(sine.slow(4)) .midimap('mymap') .midi() @example midimaps({ mymap: { lpf: { ccn: 74, min: 0, max: 20000, exp: 0.5 } }}) $: note("c a f e") .lpf(sine.slow(2).range(400,2000)) .midimap('mymap') .midi()
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}
registry for midi sounds, converting sound names to controls
162export const midisoundMap = new Map();
normalizes the given value from the given range and exponent
174const isFirefox = navigator?.userAgent?.includes('Firefox');
call fn either directly with given time (non-firefox) or after scheduleAtTime with undefined (firefox) the scheduleAtTime approach is still jittery, but the best we can be on firefox 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}
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}
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}
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}
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}
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}
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}
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}
MIDI output: Opens a MIDI output port. @tags external_io @param {string | number} midiport MIDI device name or index defaulting to 0 @param {object} options Additional MIDI configuration options @example note("c4").midichan(1).midi('IAC Driver Bus 1')
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 }
For backward compatibility
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();
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;
Handle midimap 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 }
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 }
Handle program change
Handle sysex sysex data is consist of 2 arrays, first is sysexid, second is sysexdata sysexid is a manufacturer id it is either a number or an array of 3 numbers. list of manufacturer ids can be found here : https://midi.org/sysexidtable if sysexid is an array the first byte is 0x00
Handle control change
Handle NRPN non-registered parameter number
Handle midibend
Handle miditouch
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};
Initialize a midi input device
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}
MIDI input wrappers, by specified input string/index
511const midiInputs = {};
MIDI input: Opens a MIDI input port to receive MIDI control change messages.
The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel
@name midin @tags external_io, midi @param {string | number} input MIDI device name or index defaulting to 0 @returns {function(number, number=): Pattern} A function from (cc, channel?) to a pattern. When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1) that came through that cc number (and channel, if provided) @example const cc = await midin('IAC Driver Bus 1') note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth") @example const allCC = await midin('IAC Driver Bus 1') const cc = (ccNum) => allCC(ccNum, 2) // just channel 2 note("c a f e").s("saw") .when(cc(0).gt(0), x => x.postgain(0))
MIDI keyboard: Opens a MIDI input port to receive MIDI keyboard messages.
The note length is fixed as Superdough is not currently set up for undetermined note durations
The 'midichan' control value contains the number of the channel the note is coming from so it could be filtered or manipulated further in the chain.
@name midikeys @tags external_io, midi @param {string | number} input MIDI device name or index defaulting to 0 @returns {function((number | Pattern)=): Pattern} A function that produces a pattern. When queried, the pattern will produces the most recently played midi notes and velocities, lasting for the specified duration @example const kb = await midikeys('Arturia KeyStep 32') kb().s("tri").lpf(80).lpe(6).lpd(0.1).room(2).delay(0.35) @example const kb = await midikeys('Arturia KeyStep 32') kb("0.5 1") .s("saw") .add(note(rand.mul(0.3))) .lpf(1000).lpe(2).room(0.5) @example // discard all notes not coming out from midi channel 2 const kb = await midikeys('Arturia KeyStep 32') kb().filterValues(v=>v.midichan==2).s("tri")
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);
TODO: support unpluggable device usage
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
value = { ...value, noteoff: true };
If this is achieved, we can remove the noteLength parameter
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}