UNPKG

xen-midi

Version:

Free-pitch polyphonic MIDI I/O based on webmidi.js using multi-channel pitch-bend

305 lines 11.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.midiKeyInfo = exports.MidiIn = exports.MidiOut = exports.BEND_RANGE_IN_SEMITONES = void 0; const webmidi_1 = require("webmidi"); const xen_dev_utils_1 = require("xen-dev-utils"); /** * Pitch bend range measured in semitones (+-). */ exports.BEND_RANGE_IN_SEMITONES = 2; // Large but finite number to signify voices that are off const EXPIRED = 10000; // Cents offset tolerance for channel reuse. const EPSILON = 1e-6; // eslint-disable-next-line @typescript-eslint/no-unused-vars function emptyNoteOff(rawRelease, time) { } /** * Wrapper for a webmidi.js output. * Uses multiple channels to achieve polyphonic microtuning. */ class MidiOut { /** * Constuct a new wrapper for a webmidi.js output. * @param output Output device or `null` if you need a dummy out. * @param channels Channels to use for sending pitch bent MIDI notes. Number of channels determines maximum microtonal polyphony. * @param log Logging function. */ constructor(output, channels, log) { this.output = output; this.channels = channels; if (log === undefined) { // eslint-disable-next-line @typescript-eslint/no-unused-vars this.log = msg => { }; } else { this.log = log; } this.voices = []; this.channels.forEach(channel => { this.voices.push({ age: EXPIRED, centsOffset: NaN, channel, }); }); this.lastEventTime = webmidi_1.WebMidi.time; this.sendPitchBendRange(); } sendPitchBendRange() { if (this.output !== null) { this.channels.forEach(channel => { this.output.channels[channel].sendPitchBendRange(exports.BEND_RANGE_IN_SEMITONES, 0); }); } } /** * Select a voice that's using a cents offset compatible channel or the oldest voice if nothing can be re-used. * @param centsOffset Cents offset (pitch-bend) from 12edo. * @returns A voice for the next note-on event. */ selectVoice(centsOffset) { // Age signifies how many note ons have occured after voice intialization this.voices.forEach(voice => voice.age++); // Re-use a channel that already has the correct pitch bend for (let i = 0; i < this.voices.length; ++i) { if (Math.abs(this.voices[i].centsOffset - centsOffset) < EPSILON) { this.log(`Re-using channel ${this.voices[i].channel}`); this.voices[i].age = 0; return this.voices[i]; } } // Nothing re-usable found. Use the oldest voice. let oldestVoice = this.voices[0]; this.voices.forEach(voice => { if (voice.age > oldestVoice.age) { oldestVoice = voice; } }); oldestVoice.age = 0; oldestVoice.centsOffset = centsOffset; return oldestVoice; } /** * Send a note-on event and pitch-bend to the output device on one of the available channels. * @param frequency Frequency of the note in Hertz (Hz). * @param rawAttack Attack velocity of the note from 0 to 127. * @returns A callback for sending a corresponding note off on the correct channel. */ sendNoteOn(frequency, rawAttack, time) { if (time === undefined) { time = webmidi_1.WebMidi.time; } if (time < this.lastEventTime) { throw new Error(`Events must be triggered in causal order: ${time} < ${this.lastEventTime} (note on)`); } this.lastEventTime = time; if (this.output === null) { return emptyNoteOff; } if (!this.channels.size) { return emptyNoteOff; } const [noteNumber, centsOffset] = (0, xen_dev_utils_1.ftom)(frequency); if (noteNumber < 0 || noteNumber >= 128) { return emptyNoteOff; } const voice = this.selectVoice(centsOffset); this.log(`Sending note on ${noteNumber} at velocity ${(rawAttack || 64) / 127} on channel ${voice.channel} with bend ${centsOffset} resulting from frequency ${frequency}`); const bendRange = exports.BEND_RANGE_IN_SEMITONES * 100; this.output.channels[voice.channel].sendPitchBend(centsOffset / bendRange); this.output.channels[voice.channel].sendNoteOn(noteNumber, { rawAttack, time, }); const noteOff = (rawRelease, time) => { if (time === undefined) { time = webmidi_1.WebMidi.time; } if (time < this.lastEventTime) { throw new Error(`Events must be triggered in causal order: ${time} < ${this.lastEventTime} (note off)`); } this.lastEventTime = time; this.log(`Sending note off ${noteNumber} at velocity ${(rawRelease || 64) / 127} on channel ${voice.channel}`); voice.age = EXPIRED; this.output.channels[voice.channel].sendNoteOff(noteNumber, { rawRelease, time, }); }; return noteOff; } /** * Schedule a series of notes to be played at a later time. * Please note that this reserves the channels until all notes have finished playing. * @param notes Notes to be played. */ playNotes(notes) { // Break notes into events. const now = webmidi_1.WebMidi.time; const events = []; for (const note of notes) { let time; if (typeof note.time === 'string') { if (note.time.startsWith('+')) { time = now + parseFloat(note.time.slice(1)); } else { time = parseFloat(note.time); } } else { time = note.time; } const off = { type: 'off', rawRelease: note.rawRelease, time: time + note.duration, callback: emptyNoteOff, }; events.push({ type: 'on', frequency: note.frequency, rawAttack: note.rawAttack, time, off, }); events.push(off); } // Sort events in causal order. events.sort((a, b) => a.time - b.time); // Trigger events in causal order. for (const event of events) { if (event.type === 'on') { event.off.callback = this.sendNoteOn(event.frequency, event.rawAttack, event.time); } else if (event.type === 'off') { event.callback(event.rawRelease, event.time); } } } /** * Clear scheduled notes that have not yet been played. * Will start working once the Chrome bug is fixed: https://bugs.chromium.org/p/chromium/issues/detail?id=471798 */ clear() { if (this.output !== null) { this.output.clear(); this.output.sendAllNotesOff(); } this.lastEventTime = webmidi_1.WebMidi.time; } } exports.MidiOut = MidiOut; /** * Unique identifier for a note message in a specific channel. */ function noteIdentifier(event) { return event.note.number + 128 * (event.message.channel - 1); // webmidi sends channels 1-16, but identifier only needs to range between 0 and (16 * 128) - 1 = 2047 } /** * Wrapper for webmidi.js input. * Listens on multiple channels. */ class MidiIn { /** * Construct a new wrapper for a webmidi.js input device. * @param callback Function to call when a note-on event is received on any of the available channels. * @param channels Channels to listen on. * @param log Logging function. */ constructor(callback, channels, log) { this.callback = callback; this.channels = channels; this.noteOffMap = new Map(); this._noteOn = this.noteOn.bind(this); this._noteOff = this.noteOff.bind(this); if (log === undefined) { // eslint-disable-next-line @typescript-eslint/no-unused-vars this.log = msg => { }; } else { this.log = log; } } /** * Make this wrapper (and your callback) respond to note-on/off events from this MIDI input. * @param input MIDI input to listen to. */ listen(input) { input.addListener('noteon', this._noteOn); input.addListener('noteoff', this._noteOff); } /** * Make this wrapper (and your callback) stop responding to note-on/off events from this MIDI input. * @param input MIDI input that was listened to. */ unlisten(input) { input.removeListener('noteon', this._noteOn); input.removeListener('noteoff', this._noteOff); } noteOn(event) { const channel = event.message.channel; if (!this.channels.has(channel)) { return; } const noteNumber = event.note.number; const attack = event.note.attack; const rawAttack = event.note.rawAttack; this.log(`Midi note on ${noteNumber} at velocity ${attack} on channel ${channel}`); const noteOff = this.callback(noteNumber, rawAttack, channel); this.noteOffMap.set(noteIdentifier(event), noteOff); } noteOff(event) { const channel = event.message.channel; if (!this.channels.has(channel)) { return; } const noteNumber = event.note.number; const release = event.note.release; const rawRelease = event.note.rawRelease; this.log(`Midi note off ${noteNumber} at velocity ${release} on channel ${channel}`); const id = noteIdentifier(event); const noteOff = this.noteOffMap.get(id); if (noteOff !== undefined) { this.noteOffMap.delete(id); noteOff(rawRelease); } } /** * Fire global note-off. */ deactivate() { for (const [id, noteOff] of this.noteOffMap) { this.noteOffMap.delete(id); noteOff(80); } } } exports.MidiIn = MidiIn; const WHITES = [0, 2, 4, 5, 7, 9, 11]; /** * Get information about a MIDI key. * @param chromaticNumber Contiguous chromatic index of the MIDI key * @returns Information about the MIDI key. */ function midiKeyInfo(chromaticNumber) { const octave = Math.floor(chromaticNumber / 12); const index = chromaticNumber - 12 * octave; if (WHITES.includes(index)) { return { whiteNumber: Math.floor((index + 1) / 2) + 7 * octave, }; } if (index === 1 || index === 3) { return { sharpOf: (index - 1) / 2 + 7 * octave, flatOf: (index + 1) / 2 + 7 * octave, }; } return { sharpOf: index / 2 + 7 * octave, flatOf: (index + 2) / 2 + 7 * octave, }; } exports.midiKeyInfo = midiKeyInfo; //# sourceMappingURL=index.js.map