@uttori/audio-midi
Version:
Utility to parse and manipulate MIDI files.
1,075 lines (1,010 loc) • 79.3 kB
JavaScript
/* eslint-disable no-bitwise */
import { DataBuffer, DataStream } from '@uttori/data-tools';
/* c8 ignore next */
let debug = (..._) => {};
if (process.env.UTTORI_AUDIOMIDI_DEBUG) { try { const { default: d } = await import('debug'); debug = d('Uttori.AudioMIDI'); } catch {} }
/**
* @typedef {object} WritableNote
* @property {number} ticks The delay in ticks until the next track.
* @property {number} midiNote The MIDI note value.
* @property {number} velocity The velocity of the note (0-127).
* @property {number} length The length of the note in ticks.
*/
/**
* @typedef {object} WritableTrack
* @property {number} [bpm] The BPM of the track, when blank no tempo event will be added.
* @property {Record<number, string>} [metaStringEvents] A key value collection of meta events to add where they key is the event type and the value is the data to add.
* @property {WritableNote[]} [notes] A collection of notes to write on the track.
*/
/**
* @typedef {object} NoteData
* @property {string} note A note value.
* @property {number} velocity The velocity of the note (0-127).
* @property {number} length The length of the note in ticks.
*/
/**
* @typedef {object} SysExData
* @property {number} manufacturerId The manufacturer's ID code.
* @property {string} manufacturerLabel The manufacturer's label based on the ID.
* @property {number[]} data The SysEx data bytes.
*/
/**
* @typedef {string | number | Uint8Array | NoteData | SysExData} EventData
*/
/**
* @typedef {object} MidiTrackEvent
* @property {number} deltaTime The delta time of the MIDI event.
* @property {number} type The type of the event (e.g., meta event, regular event).
* @property {string} label A human-readable label describing the event.
* @property {EventData} data The data associated with the event.
* @property {number} [metaType] The subtype of the meta event.
* @property {number} [metaEventLength] The length of the meta event data.
* @property {number} [channel] The MIDI channel the event is for.
* @property {number} [tag] The tag for the M-Live Tag event.
*/
/**
* @typedef {object} Header
* @property {string} type The type of the chunk (e.g., MThd, MTrk).
* @property {number} format The format of the MIDI file (header only).
* @property {number} trackCount The number of tracks in the MIDI file (header only).
* @property {number} timeDivision The time division of the MIDI file (header only).
* @property {number} chunkLength The length of the chunk data.
*/
/**
* @typedef {object} Track
* @property {string} type The type of the chunk (e.g., MThd, MTrk).
* @property {number} chunkLength The length of the chunk data.
* @property {MidiTrackEvent[]} events The collection of events in the track.
*/
/**
* @typedef {object} UsedNote
* @property {number} noteNumber The numeric value of the note.
* @property {string} noteString The human-readable note string.
*/
/**
* AudioMIDI - MIDI Utility
* MIDI File Format Parser & Generator
* @example <caption>AudioMIDI</caption>
* const data = fs.readFileSync('./song.mid');
* const file = new AudioMIDI(data);
* file.parse();
* console.log('Chunks:', file.chunks);
* @class
* @augments DataBuffer
*/
class AudioMIDI extends DataBuffer {
/**
* Creates a new AudioMIDI.
* @param {number[]|ArrayBuffer|Buffer|DataBuffer|Int8Array|Int16Array|Int32Array|number|string|Uint8Array|Uint16Array|Uint32Array|undefined} [input] The data to process.
* @param {object} [options] Options for this AudioMIDI instance.
* @param {number} [options.format] The MIDI format: 0, 1, or 2, default is 0.
* @param {number} [options.timeDivision] The indication of how MIDI ticks should be translated into time, default is 128.
* @class
*/
constructor(input, options = {}) {
super(input);
/** @type {number} The MIDI format: 0, 1, or 2 */
this.format = options.format ?? 0;
/** @type {number} The internal track count. */
this.trackCount = 0;
/** @type {number} The indication of how MIDI ticks should be translated into time. */
this.timeDivision = options.timeDivision ?? 480;
/** @type {Track[]} */
this.chunks = [];
this.options = {
...options,
};
}
/**
* Several different values in events are expressed as variable length quantities (e.g. delta time values).
* A variable length value uses a minimum number of bytes to hold the value, and in most circumstances this leads to some degree of data compresssion.
*
* A variable length value uses the low order 7 bits of a byte to represent the value or part of the value.
* The high order bit is an "escape" or "continuation" bit.
* All but the last byte of a variable length value have the high order bit set.
* The last byte has the high order bit cleared.
* The bytes always appear most significant byte first.
* @returns {number} The length of the next chunk.
*/
readVariableLengthValues = () => {
let value = 0;
let byte;
// By shifting the current value left by 7 bits and adding the 7 least significant bits of the current byte,
// we handle both single and multi-byte scenarios with minimal code.
do {
byte = this.readUInt8();
value = (value << 7) + (byte & 0x7F);
} while (byte & 0x80 && this.remainingBytes() > 0);
return value;
};
/**
* Parse a MIDI file from a Uint8Array.
* @see {@link https://midi.org/expanded-midi-1-0-messages-list | Expanded MIDI 1.0 Messages List (Status Bytes)}
* @see {@link https://midi.org/midi-1-0-universal-system-exclusive-messages | MIDI 1.0 Universal System Exclusive Messages}
* @see {@link https://midi.org/dls-proprietary-chunk-ids | DLS Proprietary Chunk IDs}
*/
parse() {
debug('parse');
const chunk = this.read(14);
const header = AudioMIDI.decodeHeader(chunk);
this.format = header.format;
this.trackCount = header.trackCount;
this.timeDivision = header.timeDivision;
/** @type {Map<number, { startTime: number, velocity: number, noteOnEvent: MidiTrackEvent }>} Store active notes and their start times. */
const activeNotes = new Map();
/** @type {number} Track the current time in ticks. */
let currentTime = 0;
// Parse the remaining tracks
debug(`parse: Reading ${header.trackCount} Tracks`);
for (let t = 0; t < header.trackCount; t++) {
debug('parse: Reading Track:', t);
if (this.remainingBytes() === 0) {
debug(`parse: No more data to read, but ony read ${t} of ${header.trackCount} expected tracks.`);
break;
}
/** @type {Track} */
const track = {
type: this.readString(4),
chunkLength: this.readUInt32(),
events: [],
};
if (track.type !== 'MTrk') {
debug('parse: Invalid Track Header:', track.type);
break;
}
let laststatusByte;
while (this.remainingBytes() > 0) {
/** @type {MidiTrackEvent} */
const event = {};
// Read the delta time.
event.deltaTime = this.readVariableLengthValues();
// Update current time based on delta time
currentTime += event.deltaTime;
// Read the event type
let eventType = this.readUInt8();
if (eventType >= 0x80) {
// Next event type
laststatusByte = eventType;
} else {
// Not an event, go back one.
eventType = laststatusByte;
// move back the pointer (cause readed byte is not status byte)
this.rewind(1);
}
// debug('parse: Event:', { eventType: eventType.toString(16), remainingBytes: this.remainingBytes(), offset: this.offset.toString(16) });
switch (eventType) {
// System Exclusive Events
case 0xF0: {
const manufacturerId = this.readUInt8();
// Get the manufacturer's label using the static method
const manufacturerLabel = AudioMIDI.getManufacturerLabel(manufacturerId);
// Initialize an array to store the SysEx data bytes
const data = [];
// Initialize the first byte
let byte = this.readUInt8();
// Read all data bytes until the End of Exclusive (EOX) marker (0xF7)
while (byte !== 0xF7) {
data.push(byte);
byte = this.readUInt8(); // Read the next byte
}
event.data = {
// The Manufacturer's ID code
manufacturerId,
// Manufacturer's label based on the ID
manufacturerLabel,
// Array of SysEx data bytes
data,
};
break;
}
// Song Position Pointer
case 0xF2: {
const msb = this.readUInt8();
const lsb = this.readUInt8();
event.data = { msb, lsb };
event.label = 'Song Position Pointer';
break;
}
// System Common Messages - Song Select
// The Song Select message is used with MIDI equipment, such as sequencers or drum machines, which can store and recall a number of different songs.
// The Song Position Pointer is used to set a sequencer to start playback of a song at some point other than at the beginning.
// The Song Position Pointer value is related to the number of MIDI clocks which would have elapsed between the beginning of the song and the desired point in the song.
// This message can only be used with equipment which recognizes MIDI System Real Time Messages (MIDI Sync).
case 0xF3: {
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Common Messages - Song Select';
break;
}
// System Real Time Messages - Undefined (Reserved)
case 0xF4: {
debug('⚠️ System Real Time Messages - Undefined 0xF4 (Reserved)');
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Real Time Messages - Undefined 0xF4 (Reserved)';
break;
}
// System Real Time Messages - Undefined (Reserved)
case 0xF5: {
debug('⚠️ System Real Time Messages - Undefined 0xF5 (Reserved)');
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Real Time Messages - Undefined 0xF5 (Reserved)';
break;
}
// System Common Messages - Tune Request
// The Tune Request message is generally used to request an analog synthesizer to retune its' internal oscillators.
// This message is generally not needed with digital synthesizers.
case 0xF6: {
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Common Messages - Tune Request';
break;
}
// System Common Messages - EOX
// The EOX message is used to flag the end of a System Exclusive message, which can include a variable number of data bytes.
case 0xF7: {
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Common Messages - EOX';
break;
}
// System Real Time Messages - MIDI Clock / Timing Clock
// The Timing Clock message is the master clock which sets the tempo for playback of a sequence.
// The Timing Clock message is sent 24 times per quarter note.
// The Start, Continue, and Stop messages are used to control playback of the sequence.
case 0xF8: {
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Real Time Messages - MIDI Clock';
break;
}
// System Real Time Messages - Undefined (Reserved)
case 0xF9: {
debug('⚠️ System Real Time Messages - Undefined 0xF9 (Reserved)');
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Real Time Messages - Undefined 0xF9 (Reserved)';
break;
}
// System Real Time Messages - Start
case 0xFA: {
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Real Time Messages - Start';
break;
}
// System Real Time Messages - Continue
case 0xFB: {
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Real Time Messages - Continue';
break;
}
// System Real Time Messages - Stop
case 0xFC: {
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Real Time Messages - Stop';
break;
}
// System Real Time Messages - Undefined (Reserved)
case 0xFD: {
debug('⚠️ System Real Time Messages - Undefined 0xFD (Reserved)');
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Real Time Messages - Undefined 0xFD (Reserved)';
break;
}
// System Real Time Messages - Active Sensing
// The Active Sensing signal is used to help eliminate "stuck notes" which may occur if a MIDI cable is disconnected during playback of a MIDI sequence.
// Without Active Sensing, if a cable is disconnected during playback, then some notes may be left playing indefinitely because they have been activated by a Note On message, but the corresponding Note Off message will never be received.
case 0xFE: {
const length = this.readVariableLengthValues();
event.data = this.read(length);
event.label = 'System Real Time Messages - Active Sensing';
break;
}
// Meta Event
case 0xFF: {
// assign metaEvent code to array
event.type = 0xFF;
event.metaType = this.readUInt8();
// get the metaEvent length
event.metaEventLength = this.readVariableLengthValues();
switch (event.metaType) {
// Sequence Number
// This optional event must occur at the beginning of a track (ie, before any non-zero time and before any midi events).
// It specifies the sequence number.
// The two data bytes ss ss, are that number which corresponds to the MIDI Cue message.
// In a format 2 MIDI file, this number identifies each "pattern" (ie, track) so that a "song" sequence can use the MIDI Cue message to refer to patterns.
// If the length is 0, then the track's location in the file is used. (ie, The first track chunk is sequence number 0.
// The second track is sequence number 1. Etc).
// In format 0 or 1, which contain only one "pattern" (even though format 1 contains several tracks), this event is placed in only the track.
// So, a group of format 0 or 1 files with different sequence numbers can comprise a "song collection".
// There can be only one of these events per track chunk in a Format 2.
// There can be only one of these events in a Format 0 or 1, and it must be in the first track.
case 0x00: {
let sequenceNumber;
// Check if the event contains two data bytes (ss ss)
let type;
if (event.metaEventLength === 2) {
const byte1 = this.readUInt8();
const byte2 = this.readUInt8();
// Combine the two bytes into the sequence number
sequenceNumber = (byte1 << 8) + byte2;
type = 'Provided';
} else {
debug('parse: Sequence Number has an invalid length:', event.metaEventLength, this.offset.toString(16));
this.advance(1);
// If no sequence number is provided, use the track's location in the file
sequenceNumber = this.trackCount || 0; // Assuming `this.trackCount` keeps track of the current track's index
type = 'Next Track Index';
}
event.data = {
// The sequence number (either provided or based on track index)
sequenceNumber,
type,
};
event.label = 'Sequence Number';
break;
}
// Text Event
// This meta-event supplies an arbitrary Text string tagged to the Track and Time.
case 0x01: {
event.data = this.readString(event.metaEventLength);
event.label = 'Text Event';
break;
}
// Copyright Notice
// The Text specifies copyright information for the sequence.
// This is usually placed at time 0 of the first track in the sequence.
case 0x02: {
event.data = this.readString(event.metaEventLength);
event.label = 'Copyright Notice';
break;
}
// Sequence / Track Name
// The Text specifies the title of the track or sequence.
// The first Title meta-event in a type 0 MIDI file, or in the first track of a type 1 file gives the name of the work.
// Subsequent Title meta-events in other tracks give the names of those tracks.
case 0x03: {
event.data = this.readString(event.metaEventLength);
event.label = 'Sequence / Track Name';
break;
}
// Instrument Name
// The Text names the instrument intended to play the contents of this track.
// This is usually placed at time 0 of the track.
// Note that this meta-event is simply a description; MIDI synthesisers are not required (and rarely if ever) respond to it.
// This meta-event is particularly useful in sequences prepared for synthesisers which do not conform to the General MIDI patch set, as it documents the intended instrument for the track when the sequence is used on a synthesiser with a different patch set.
case 0x04: {
event.data = this.readString(event.metaEventLength);
event.label = 'Instrument Name';
break;
}
// Lyrics
// The Text gives a lyric intended to be sung at the given Time.
// Lyrics are often broken down into separate syllables to time-align them more precisely with the sequence.
case 0x05: {
event.data = this.readString(event.metaEventLength);
event.label = 'Lyrics';
break;
}
// Marker
// The Text marks a point in the sequence which occurs at the given Time, for example "Third Movement".
case 0x06: {
event.data = this.readString(event.metaEventLength);
event.label = 'Marker';
break;
}
// Cue Point
// The Text identifies synchronisation point which occurs at the specified Time, for example, "Door slams".
case 0x07: {
event.data = this.readString(event.metaEventLength);
event.label = 'Cue Point';
break;
}
// Program Name
// The name of the program (ie, patch) used to play the track.
// This may be different than the Sequence / Track Name.
// For example, maybe the name of your sequence (ie, track) is "Butterfly", but since the track is played upon an electric piano patch, you may also include a Program Name of "ELECTRIC PIANO".
case 0x08: {
event.data = this.readString(event.metaEventLength);
event.label = 'Program Name';
break;
}
// Device (Port) Name
// The name of the MIDI device (port) where the track is routed.
// This replaces the "MIDI Port" Meta-Event which some sequencers formally used to route MIDI tracks to various MIDI ports (in order to support more than 16 MIDI channels).
// For example, assume that you have a MIDI interface that has 4 MIDI output ports.
// They are listed as "MIDI Out 1", "MIDI Out 2", "MIDI Out 3", and "MIDI Out 4".
// If you wished a particular track to use "MIDI Out 1" then you would put a Port Name Meta-event at the beginning of the track, with "MIDI Out 1" as the text.
// All MIDI events that occur in the track, after a given Port Name event, will be routed to that port.
// In a format 0 MIDI file, it would be permissible to have numerous Port Name events intermixed with MIDI events, so that the one track could address numerous ports.
// But that would likely make the MIDI file much larger than it need be.
// The Port Name event is useful primarily in format 1 MIDI files, where each track gets routed to one particular port.
case 0x09: {
event.data = this.readString(event.metaEventLength);
event.label = 'Device (Port) Name';
break;
}
// Channel Prefix
// This event is considered obsolete and should not be used.
// The MIDI channel (0-15) contained in this event may be used to associate a MIDI channel with all events which follow, including System exclusive and meta-events.
// This channel is "effective" until the next normal MIDI event (which contains a channel) or the next MIDI Channel Prefix meta-event.
// If MIDI channels refer to "tracks", this message may be put into a format 0 file, keeping their non-MIDI data associated with a track.
case 0x20: {
event.data = this.readUInt8();
event.label = 'Channel Prefix';
break;
}
// MIDI Port
// This event is considered obsolete and should not be used.
// This optional event which normally occurs at the beginning of a track (ie, before any non-zero time and before any midi events) specifies out of which MIDI Port (ie, buss) the MIDI events in the track go.
// The data byte pp, is the port number, where 0 would be the first MIDI buss in the system.
// The MIDI spec has a limit of 16 MIDI channels per MIDI input/output (ie, port, buss, jack, or whatever terminology you use to describe the hardware for a single MIDI input/output).
// The MIDI channel number for a given event is encoded into the lowest 4 bits of the event's Status byte.
// Therefore, the channel number is always 0 to 15.
// Many MIDI interfaces have multiple MIDI input/output busses in order to work around limitations in the MIDI bandwidth (ie, allow the MIDI data to be sent/received more efficiently to/from several external modules), and to give the musician more than 16 MIDI Channels.
// Also, some sequencers support more than one MIDI interface used for simultaneous input/output.
// Unfortunately, there is no way to encode more than 16 MIDI channels into a MIDI status byte, so a method was needed to identify events that would be output on, for example, channel 1 of the second MIDI port versus channel 1 of the first MIDI port.
// This MetaEvent allows a sequencer to identify which track events get sent out of which MIDI port.
// The MIDI events following a MIDI Port MetaEvent get sent out that specified port.
case 0x21: {
event.data = this.readUInt8();
event.label = 'MIDI Port';
break;
}
// End of Track
// This event is not optional.
// It must be the last event in every track.
// It's used as a definitive marking of the end of a track.
// Only 1 per track.
case 0x2F: {
if (event.metaEventLength !== 0) {
debug('parse: End of Track has an invalid length:', event.metaEventLength, this.offset.toString(16));
}
event.data = '';
event.label = 'End of Track';
break;
}
// M-Live Tag (non-standard)
// The text specifies meta tag information for the sequence. This is usually placed at time 0 of the first track in the sequence. The data byte tt specifies the tag:
case 0x4B: {
const tag = this.readUInt8();
let tagLabel = '';
switch (tag) {
case 0x01: tagLabel = 'Genre'; break;
case 0x02: tagLabel = 'Artist'; break;
case 0x03: tagLabel = 'Composer'; break;
case 0x04: tagLabel = 'Duration (seconds)'; break;
case 0x05: tagLabel = 'BPM (Tempo)'; break;
default: tagLabel = `Unknown Tag: ${event.tag}`;
}
const tagValue = event.data = this.read(event.metaEventLength);
event.data = {
tag,
tagLabel,
tagValue,
}
event.label = 'M-Live Tag';
break;
}
// Tempo
case 0x51: {
if (event.metaEventLength !== 3) {
debug('parse: Tempo has an invalid length:', event.metaEventLength);
event.data = this.read(event.metaEventLength);
break;
}
const byte1 = this.readUInt8();
const byte2 = this.readUInt8();
const byte3 = this.readUInt8();
// Combine the three bytes to get the tempo in microseconds per quarter note
const tempo = (byte1 << 16) + (byte2 << 8) + byte3;
// Convert the tempo to beats per minute (BPM)
const bpm = Math.round(60000000 / tempo);
event.data = {
byte1,
byte2,
byte3,
// Microseconds per quarter note
tempo,
// Beats Per Minute
bpm,
};
event.label = 'Set Tempo';
break;
}
// SMPTE Offset
// This meta event is used to specify the SMPTE starting point offset from the beginning of the track.
// It is defined in terms of hours, minutes, seconds, frames and sub-frames (always 100 sub-frames per frame, no matter what sub-division is specified in the MIDI header chunk).
// In a format 1 file, the SMPTE OFFSET must be stored with the tempo map (ie, the first track), and has no meaning in any other track.
// The hourByte is used to specify the hour offset also specifies the frame rate in the following format: 0rrhhhhh where rr is two bits for the frame rate where 00=24 fps, 01=25 fps, 10=30 fps (drop frame), 11=30 fps and hhhhh is five bits for the hour (0-23).
// The hourByte's top bit is always 0.
// The frame byte's possible range depends on the encoded frame rate in the hour byte.
// A 25 fps frame rate means that a maximum value of 24 may be set for the frame byte.
// The subFrame byte contains fractional frames in 100ths of a frame.
case 0x54: {
const hourByte = this.readUInt8(); // Read the hour byte (includes frame rate and hour)
const minute = this.readUInt8(); // Read the minute byte
const second = this.readUInt8(); // Read the second byte
const frame = this.readUInt8(); // Read the frame byte
const subFrame = this.readUInt8(); // Read the sub-frame byte
// Extract frame rate from the hour byte (bits 6 and 7)
// 0rrhhhhh -> rr = (hr >> 5) & 0x03
const frameRateBits = (hourByte >> 5) & 0x03;
/** @type {Record<number, number>} */
const frameRates = {
0: 24, // 00 = 24 fps
1: 25, // 01 = 25 fps
2: 29.97, // 10 = 30 fps (drop frame)
3: 30, // 11 = 30 fps
};
const frameRate = frameRates[frameRateBits] || `Unknown Frame Rate: ${frameRateBits}`;
// Extract the hour from the remaining 5 bits (bits 0 to 4)
const hour = hourByte & 0x1F; // 0rrhhhhh -> hhhhh = hr & 0x1F
// Event data
event.data = {
// The raw hour byte
hourByte,
// Hour (0-23)
hour,
// Minute (0-59)
minute,
// Second (0-59)
second,
// Frame (depends on frame rate)
frame,
// Sub-frame (0-99)
subFrame,
// Frame rate (24, 25, 29.97, 30)
frameRate,
};
event.label = 'SMPTE Offset';
break;
}
// Time Signature
// If there are no time signature events in a MIDI file, then the time signature is assumed to be 4/4.
case 0x58: {
event.data = {
// The numerator of the time signature, the 3 in 3/4.
numerator: this.readUInt8(),
// The denominator of the time signature, the 4 in 3/4.
denominator: this.readUInt8(),
// The number of MIDI clocks in a metronome click.
metronome: this.readUInt8(),
// The number of notated 32nd notes in a MIDI quarter note (24 MIDI clocks).
// This event allows a program to relate what MIDI thinks of as a quarter, to something entirely different.
thirtySecondNotes: this.readUInt8(),
};
event.label = 'Time Signature';
break;
}
// Key Signature
// The key signature is specified by the numeric 1st byte Key value, which is 0 for the key of C, a positive value for each sharp above C, or a negative value for each flat below C, thus in the inclusive range -7 to 7.
// The Major/Minor 2nd byte is a number value which will be 0 for a major key and 1 for a minor key.
case 0x59: {
if (event.metaEventLength !== 2) {
debug('parse: Key Signature has an invalid length:', event.metaEventLength);
event.data = this.read(event.metaEventLength);
break;
}
// Read the sharps / flats byte
const keySignature = this.readUInt8();
// Read the major / minor byte
const majorOrMinor = this.readUInt8();
// Map the keySignature values to their respective key signatures
/** @type {Record<string | number, string>} */
const keys = {
'-7': 'C♭',
'-6': 'G♭',
'-5': 'D♭',
'-4': 'A♭',
'-3': 'E♭',
'-2': 'B♭',
'-1': 'F',
0: 'C',
1: 'G',
2: 'D',
3: 'A',
4: 'E',
5: 'B',
6: 'F♯',
7: 'C♯',
};
event.data = {
// The raw keySignature byte
keySignature,
// The raw majorOrMinor byte
majorOrMinor,
// The name of the key (e.g., "C♯")
keyName: keys[`${keySignature}`] || 'Unknown Key',
// The mode (Major or Minor)
mode: majorOrMinor === 0 ? 'Major' : 'Minor',
};
event.label = 'Key Signature';
break;
}
// Sequencer Specific
case 0x7F: {
debug('Sequencer Specific is unimplemented');
event.data = this.read(event.metaEventLength);
event.label = 'Sequencer Specific';
break;
}
default: {
debug('Unimplemented 0xFF Meta Event', event.metaType.toString(16).toUpperCase(), this.offset.toString(16).toUpperCase());
event.data = this.read(event.metaEventLength);
}
}
break;
}
default: {
// MIDI Control Events OR System Exclusive Events
// Extract the event type (upper 4 bits)
event.type = eventType;
// Extract the channel (lower 4 bits)
event.channel = eventType & 0x0F;
const type = (eventType >> 4) & 0x0F;
switch (type) {
// Note Off
// The Note Off Event is used to signal when a MIDI key is released.
// These events have two parameters identical to a Note On event.
// The note number specifies which of the 128 MIDI keys is being played and the velocity determines how fast/hard the key was released.
// The note number is normally used to specify which previously pressed key is being released and the velocity is usually ignored, but is sometimes used to adjust the slope of an instrument's release phase.
case 0x8: {
const note = this.readUInt8();
const velocity = this.readUInt8(); // Read and ignore velocity byte for Note Off
if (activeNotes.has(note)) {
// Calculate the note length using the time since Note On
const noteOnData = activeNotes.get(note);
// Calculate note length
const noteLength = currentTime - noteOnData.startTime;
// Update the Note On event with the calculated length
noteOnData.noteOnEvent.data.length = noteLength;
event.data = {
note: `${note}`,
velocity,
length: noteLength,
};
// Remove the note from active notes after processing
activeNotes.delete(note);
} else {
event.data = {
note: `${note}`,
velocity,
length: 0,
};
debug('Missing Note On Event for:', note)
}
event.label = 'Note Off';
break;
}
// Note On
// The Note On Event is used to signal when a MIDI key is pressed.
// This type of event has two parameters.
// The note number that specifies which of the 128 MIDI keys is being played and the velocity determines how fast/hard the key is pressed.
// The note number is normally used to specify the instruments musical pitch and the velocity is usually used to specify the instruments playback volume and intensity.
case 0x9: {
const note = this.readUInt8();
const velocity = this.readUInt8();
event.data = {
note,
velocity,
};
event.label = 'Note On';
// Track the note start time and velocity in the activeNotes map
activeNotes.set(note, { startTime: currentTime, velocity, noteOnEvent: event });
break;
}
// Note Aftertouch
// The Note Aftertouch Event is used to indicate a pressure change on one of the currently pressed MIDI keys.
// It has two parameters.
// The note number of which key's pressure is changing and the aftertouch value which specifies amount of pressure being applied (0 = no pressure, 127 = full pressure).
// Note Aftertouch is used for extra expression of particular notes, often introducing or increasing some type of modulation during the instrument's sustain phase
case 0xA: {
event.data = {
note: this.readUInt8(),
velocity: this.readUInt8(),
};
event.label = 'Note Aftertouch';
break;
}
// Controller
// The Controller Event signals the change in a MIDI channels state.
// There are 128 controllers which define different attributes of the channel including volume, pan, modulation, effects, and more.
// This event type has two parameters.
// The controller number specifies which control is changing and the controller value defines it's new setting.
case 0xB: {
const controller = this.readUInt8();
const value = this.readUInt8();
event.data = {
controller,
value,
label: AudioMIDI.getControllerLabel(controller),
};
event.label = 'Controller';
break;
}
// Program Change
// The Program Change Event is used to change which program (instrument/patch) should be played on the MIDI channel.
// This type of event takes only one parameter, the program number of the new instrument / patch.
case 0xC: {
event.data = this.readUInt8();
event.label = 'Program Change';
break;
}
// Channel Aftertouch
// The Channel Aftertouch Event is similar to the Note Aftertouch message, except it effects all keys currently pressed on the specific MIDI channel.
// This type of event takes only one parameter, the aftertouch amount (0 = no pressure, 127 = full pressure).
case 0xD: {
event.data = this.readUInt8();
event.label = 'Channel Aftertouch';
break;
}
// Pitch Bend
// The Pitch Bend Event is similar to a controller event, except that it is a unique MIDI Channel Event that has two bytes to describe it's value.
// The pitch value is defined by both parameters of the MIDI Channel Event by joining them in the format of yyyyyyyxxxxxxx where the y characters represent the last 7 bits of the second parameter and the x characters represent the last 7 bits of the first parameter.
// The combining of both parameters enables high accuracy values (0 - 16383).
// The pitch value affects all playing notes on the current channel.
// Values below 8192 decrease the pitch, while values above 8192 increase the pitch.
// The pitch range may vary from instrument to instrument, but is usually +/-2 semi-tones.
case 0xE: {
// Read the first parameter byte (xxxxxxx)
const firstByte = this.readUInt8();
// Read the second parameter byte (yyyyyyy)
const secondByte = this.readUInt8();
// Combine the two bytes into a 14-bit pitch value
const pitchValue = (secondByte << 7) + firstByte;
event.data = {
// The combined pitch value (0 - 16383)
pitchValue,
// The raw first parameter byte
firstByte,
// The raw second parameter byte
secondByte,
};
event.label = 'Pitch Bend Event';
break;
}
// System Exclusive Events
case 0xF: {
debug('Unimplemented 0xFx Exclusive Events:', event.type.toString(16));
const length = this.readVariableLengthValues();
event.data = this.read(length);
break;
}
default: {
debug('Unknown Exclusive Events:', event.type);
break;
}
}
}
}
// Useful for debugging uncommon events.
// if (!['Note On', 'Note Off', 'End of Track', 'Controller'].includes(event.label)) {
// debug('Event:', event);
// }
track.events.push(event);
}
debug('Track Events:', track.events.length);
this.chunks.push(track);
}
debug('Chunks:', this.chunks);
}
/**
* Adds a new track to the MIDI file.
* @returns {Track} The new track.
*/
addTrack() {
const track = {
type: 'MTrk',
chunkLength: 0,
events: [],
};
this.chunks.push(track);
return track;
}
/**
* Adds an event to a track.
* @param {Track} track - The track to add the event to.
* @param {Event | Event[]} event - The event to add.
*/
addEvent(track, event) {
if (Array.isArray(event)) {
track.events = [...track.events, ...event]
} else {
track.events.push(event);
}
}
/**
* Writes the MIDI data to a binary file.
* @returns {DataBuffer} The binary data buffer.
*/
saveToDataBuffer() {
debug('saveToDataBuffer: chunks', this.chunks.length);
const dataBuffer = new DataBuffer();
// Write the header
dataBuffer.writeString('MThd');
// Header length is always 6
dataBuffer.writeUInt32(6);
dataBuffer.writeUInt16(this.format);
dataBuffer.writeUInt16(this.trackCount);
dataBuffer.writeUInt16(this.timeDivision);
// Write each track
for (const chunk of this.chunks) {
this.writeChunk(dataBuffer, chunk);
}
dataBuffer.commit();
return dataBuffer;
}
/**
* Write a track chunk to the data buffer.
* @param {DataBuffer} dataBuffer The data buffer to write to.
* @param {Track} chunk The track chunk to write.
*/
writeChunk(dataBuffer, chunk) {
// Convert the chunk into binary data and write it to the buffer
if (chunk.type === 'MTrk') {
// Write the track chunk (MTrk)
dataBuffer.writeString('MTrk');
// Placeholder for chunk length
const chunkLengthPosition = dataBuffer.offset;
dataBuffer.writeUInt32(0);
// Remember the start position of the events
const startPosition = dataBuffer.offset;
// Write each event and calculate the total size
chunk.events.forEach((event) => {
this.writeEvent(dataBuffer, event);
});
// Calculate the chunk length
const endPosition = dataBuffer.offset;
const chunkLength = endPosition - startPosition;
debug('writeChunk: track size', chunkLength);
// Move back to where the chunk length was initially written
dataBuffer.seek(chunkLengthPosition);
// Write the correct chunk length
dataBuffer.writeUInt32(chunkLength);
// Write the chunk length to the chunk object
chunk.chunkLength = chunkLength;
// Move back to the end of the buffer to continue writing
dataBuffer.seek(endPosition);
} else {
debug('skipping unknown chunk type:', chunk.type)
}
}
/**
* Helper function to write an event to the data buffer.
* @param {DataBuffer} dataBuffer The data buffer to write to.
* @param {MidiTrackEvent} event The event to write.
*/
writeEvent(dataBuffer, event) {
const { type, deltaTime, metaType, metaEventLength, data, channel } = event;
// Calculate the status byte for channel-specific events
const statusByte = channel !== undefined ? (type | (channel & 0x0F)) : type;
if (!statusByte) {
throw new Error(`Invalid status byte ${statusByte} for event: ${JSON.stringify(event)}`);
}
if (deltaTime === undefined) {
throw new Error(`Invalid delta time ${deltaTime} for event: ${JSON.stringify(event)}`);
}
// Write the delta time as a variable length value
AudioMIDI.writeVariableLengthValue(dataBuffer, deltaTime);
// Write the status byte
dataBuffer.writeUInt8(statusByte);
// debug('writeEvent:', event);
switch (type) {
// Note Off
case 0x80:
// Note On
case 0x90:
// Polyphonic Key Pressure
case 0xA0: {
// These events have two data bytes: key and velocity / pressure
if (typeof data !== 'object' || !('note' in data) || data.note === undefined) {
throw new Error(`Invalid note value`);
}
if (typeof data !== 'object' || !('velocity' in data) || data.velocity === undefined) {
throw new Error(`Invalid velocity / pressure value`);
}
AudioMIDI.writeEventData(dataBuffer, [data.note, data.velocity]);
break;
}
case 0xB0: { // Control Change
// Control Change events have two data bytes: controller number and value
if (!data.controllerNumber || !data.value) {
throw new Error(`Invalid controller number or value: ${JSON.stringify(data)}`);
}
AudioMIDI.writeEventData(dataBuffer, [data.controllerNumber, data.value]);
break;
}
case 0xC0: { // Program Change
if (!data.programNumber) {
throw new Error(`Invalid programNumber ${data.programNumber} for event ${JSON.stringify(data)}`);
}
// Program Change events have one data byte: the program number
AudioMIDI.writeEventData(dataBuffer, [data.programNumber]);
break;
}
case 0xD0: { // Channel Pressure
if (!data.pressureAmount) {
throw new Error(`Invalid pressureAmount ${data.pressureAmount} for event ${JSON.stringify(data)}`);
}
// Channel Pressure events have one data byte: the pressure amount
AudioMIDI.writeEventData(dataBuffer, [data.pressureAmount]);
break;
}
case 0xE0: { // Pitch Bend
// Pitch Bend events have two data bytes: least significant byte and most significant byte
const { lsb, msb } = data;
if (!data.lsb || !data.msb) {
throw new Error(`Invalid lsb ${lsb} or msb ${msb} for event ${JSON.stringify(data)}`);
}
AudioMIDI.writeEventData(dataBuffer, [lsb, msb]);
break;
}
case 0xF0: { // SysEx Event
if (typeof data !== 'object' || !('manufacturerId' in data) || !data.manufacturerId || !('data' in data) || !data.data) {
throw new Error(`Invalid manufacturerId ${data.manufacturerId} or data ${data.data} for event ${JSON.stringify(data)}`);
}
dataBuffer.writeUInt8(data.manufacturerId);
AudioMIDI.writeEventData(dataBuffer, data.data);
dataBuffer.writeUInt8(0xF7); // EOX
break;
}
case 0xF3: { // Song Select
if (!data.songNumber) {
throw new Error(`Invalid songNumber ${data.songNumber} for event ${JSON.stringify(data)}`);
}
AudioMIDI.writeEventData(dataBuffer, [data.songNumber]);
break;
}
case 0xF6: { // Tune Request
// No additional data for Tune Request
break;
}
case 0xF7: { // End of SysEx
// No additional data for End of SysEx
break;
}
case 0xF8: // MIDI Clock
case 0xFA: // Start
case 0xFB: // Continue
case 0xFC: // Stop
case 0xFE: { // Active Sensing
// No additional data for these real-time messages
break;
}
case 0xFF: { // Meta Event
dataBuffer.writeUInt8(metaType); // Write the metaType
AudioMIDI.writeVariableLengthValue(dataBuffer, metaEventLength); // Write the length
switch (metaType) {
// Sequence Number
case 0x00: {
if (!data.sequenceNumber) {
throw new Error(`Invalid sequenceNumber ${data.sequenceNumber} for event ${JSON.stringify(data)}`);
}
AudioMIDI.writeEventData(dataBuffer, [data.sequenceNumber >> 8, data.sequenceNumber & 0xFF]);
break;
}
case 0x01: // Text Event
case 0x02: // Copyright Notice
case 0x03: // Sequence / Track Name
case 0x04: // Instrument Name
case 0x05: // Lyrics
case 0x06: // Marker
case 0x07: // Cue Point
case 0x08: // Program Name
case 0x09: { // Device (Port) Name
if (!data) {
throw new Error(`Invalid text data ${data} for event ${JSON.stringify(data)}`);
}
AudioMIDI.writeEventData(dataBuffer, data);
break;
}
case 0x20: // MIDI Channel Prefix
case 0x21: { // MIDI Port
if (!data) {
throw new Error(`Invalid data ${data} for event ${JSON.stringify(data)}`);