xen-midi
Version:
Free-pitch polyphonic MIDI I/O based on webmidi.js using multi-channel pitch-bend
305 lines • 11.2 kB
JavaScript
"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