func-midi-parser
Version:
A functional-based midi parsing library
487 lines (401 loc) • 17.6 kB
JavaScript
/* jshint loopfunc: true */
'use strict';
/**
* @module midiParser
*/
const utils = require('funtils');
const partial = utils.partial;
const dispatch = utils.dispatch;
const midiUtils = require('./midi-utils');
const toHex = midiUtils.toHex;
const toArr = midiUtils.toArr;
const parseStringFromRawChars = midiUtils.parseStringFromRawChars;
const parseByteArrayToNumber = midiUtils.parseByteArrayToNumber;
const parseNextVariableChunk= midiUtils.parseNextVariableChunk;
const generateMatcher = midiUtils.generateMatcher;
const generateMaskMatcher = midiUtils.generateMaskMatcher;
const isValidEventCode = midiUtils.isValidEventCode;
const isMetaEvent = midiUtils.isMetaEvent;
const isSysexEvent = midiUtils.isSysexEvent;
const isNoteEvent = midiUtils.isNoteEvent;
const isPolyphonicAftertouchEvent = midiUtils.isPolyphonicAftertouchEvent;
const isControlChangeEvent = midiUtils.isControlChangeEvent;
const isProgramChangeEvent = midiUtils.isProgramChangeEvent;
const isChannelAftertouchEvent = midiUtils.isChannelAftertouchEvent;
const isPitchWheelEvent = midiUtils.isPitchWheelEvent;
const types = require('./data-types');
const Midi = types.Midi;
const MidiHeader = types.MidiHeader;
const MidiTrack = types.MidiTrack;
const MidiMetaSequenceEvent = types.MidiMetaSequenceEvent;
const MidiMetaTextEvent = types.MidiMetaTextEvent;
const MidiMetaCopyrightEvent = types.MidiMetaCopyrightEvent;
const MidiMetaTrackNameEvent = types.MidiMetaTrackNameEvent;
const MidiMetaInstrumentNameEvent = types.MidiMetaInstrumentNameEvent;
const MidiMetaLyricTextEvent = types.MidiMetaLyricTextEvent;
const MidiMetaMarkerEvent = types.MidiMetaMarkerEvent;
const MidiMetaCuePointEvent = types.MidiMetaCuePointEvent;
const MidiMetaChannelPrefixEvent = types.MidiMetaChannelPrefixEvent;
const MidiMetaEndOfTrackEvent = types.MidiMetaEndOfTrackEvent;
const MidiMetaTempoEvent = types.MidiMetaTempoEvent;
const MidiMetaSmpteOffsetEvent = types.MidiMetaSmpteOffsetEvent;
const MidiMetaTimeSignatureEvent = types.MidiMetaTimeSignatureEvent;
const MidiMetaKeySignatureEvent = types.MidiMetaKeySignatureEvent;
const MidiMetaSequencerSpecificEvent = types.MidiMetaSequencerSpecificEvent;
const MidiNoteOnEvent = types.MidiNoteOnEvent;
const MidiNoteOffEvent = types.MidiNoteOffEvent;
const MidiPolyphonicAftertouchEvent = types.MidiPolyphonicAftertouchEvent;
const MidiControlChangeEvent = types.MidiControlChangeEvent;
const MidiProgramChangeEvent = types.MidiProgramChangeEvent;
const MidiChannelAftertouchEvent = types.MidiChannelAftertouchEvent;
const MidiPitchWheelEvent = types.MidiPitchWheelEvent;
const constants = require('./midi-constants');
const NOTE_ON_MASK = constants.NOTE_ON_MASK;
const NOTE_OFF_MASK = constants.NOTE_OFF_MASK;
const SEQUENCE_META_EVENT = constants.SEQUENCE_META_EVENT;
const TEXT_META_EVENT = constants.TEXT_META_EVENT;
const COPYRIGHT_META_EVENT = constants.COPYRIGHT_META_EVENT;
const TRACK_NAME_META_EVENT = constants.TRACK_NAME_META_EVENT;
const INST_NAME_META_EVENT = constants.INST_NAME_META_EVENT;
const LYRIC_TEXT_META_EVENT = constants.LYRIC_TEXT_META_EVENT;
const MARKER_TEXT_META_EVENT = constants.MARKER_TEXT_META_EVENT;
const CUE_POINT_META_EVENT = constants.CUE_POINT_META_EVENT;
const CHANNEL_PREFIX_ASSIGNMENT_META_EVENT = constants.CHANNEL_PREFIX_ASSIGNMENT_META_EVENT;
const END_OF_TRACK_META_EVENT = constants.END_OF_TRACK_META_EVENT;
const TEMPO_META_EVENT = constants.TEMPO_META_EVENT;
const SMPTE_OFFSET_META_EVENT = constants.SMPTE_OFFSET_META_EVENT;
const TIME_SIG_META_EVENT = constants.TIME_SIG_META_EVENT;
const KEY_SIGNATURE_META_EVENT = constants.KEY_SIGNATURE_META_EVENT;
const SEQUENCER_SPECIFIC_META_EVENT = constants.SEQUENCER_SPECIFIC_META_EVENT;
/* UTILITIES */
/* meta event processing */
const generateMetaEventGuard = partial(function _generateEventGuard(metaEventSubtype, processEvent) {
const eventMatches = generateMatcher(metaEventSubtype);
return function _testEvent(_eventCode, subtype/*, deltaTime, dataBytes */) {
if (eventMatches(subtype)) return processEvent.apply(null, arguments);
};
});
function generateMetaEvent(paramsMapper, MetaEventClass, eventCode, subtype, deltaTime, dataBytes, track) {
const params = {
code: eventCode,
subtype: subtype,
delta: deltaTime,
track: track
};
return new MetaEventClass(paramsMapper(params, dataBytes));
}
function mapSequenceParams(/* params, dataBytes */) {
throw new Error('TODO: map sequence params');
// return params;
}
function mapTextMetaParams(params, dataBytes) {
params.name = parseStringFromRawChars(dataBytes);
return params;
}
function mapCopyrightParams(params, dataBytes) {
params.copyright = parseStringFromRawChars(dataBytes);
return params;
}
function mapTrackNameParams(params, dataBytes) {
params.name = parseStringFromRawChars(dataBytes);
return params;
}
function mapInstNameParams(params, dataBytes) {
params.name = parseStringFromRawChars(dataBytes);
return params;
}
function mapLyricParams(params, dataBytes) {
params.lyrics = parseStringFromRawChars(dataBytes);
return params;
}
function mapMarkerParams(params, dataBytes) {
params.name = parseStringFromRawChars(dataBytes);
return params;
}
function mapCueParams(/* params, dataBytes */) {
throw new Error('TODO: map cue params');
// return params;
}
function mapChannelPrefixParams(params, dataBytes) {
params.channel = dataBytes; // The last byte is the MIDI channel and has values from 0x00 to 0x0F (0 to 15 decimal).
return params;
}
function mapEndOfTrackParams(params/* , dataBytes */) {
return params;
}
function mapTempoParams(params, dataBytes) {
params.microsecPerQn = parseByteArrayToNumber(dataBytes);
return params;
}
function mapSmpteOffsetParams(params, dataBytes) {
params.frameRate = dataBytes[0];
params.min = dataBytes[1];
params.sec = dataBytes[2];
params.frames = dataBytes[3];
params.subframes = dataBytes[4];
return params;
}
function mapTimeSigParams(params, dataBytes) {
params.numerator = dataBytes[0];
params.denominator = Math.pow(2, dataBytes[1]);
params.metronomeClicksPerTick = dataBytes[2];
params.thirtySecondNotesPerBeat = dataBytes[3];
return params;
}
function mapKeySigparams(params, dataBytes) {
params.sf = dataBytes[1];
params.mi = dataBytes[2];
return params;
}
function mapSequencerParams(/* params, dataBytes */) {
throw new Error('TODO: map sequencer params');
// return params;
}
const processMetaEvent = dispatch(
generateMetaEventGuard(SEQUENCE_META_EVENT, partial(generateMetaEvent, mapSequenceParams, MidiMetaSequenceEvent)),
generateMetaEventGuard(TEXT_META_EVENT, partial(generateMetaEvent, mapTextMetaParams, MidiMetaTextEvent)),
generateMetaEventGuard(COPYRIGHT_META_EVENT, partial(generateMetaEvent, mapCopyrightParams, MidiMetaCopyrightEvent)),
generateMetaEventGuard(TRACK_NAME_META_EVENT, partial(generateMetaEvent, mapTrackNameParams, MidiMetaTrackNameEvent)),
generateMetaEventGuard(INST_NAME_META_EVENT, partial(generateMetaEvent, mapInstNameParams, MidiMetaInstrumentNameEvent)),
generateMetaEventGuard(LYRIC_TEXT_META_EVENT, partial(generateMetaEvent, mapLyricParams, MidiMetaLyricTextEvent)),
generateMetaEventGuard(MARKER_TEXT_META_EVENT, partial(generateMetaEvent, mapMarkerParams, MidiMetaMarkerEvent)),
generateMetaEventGuard(CUE_POINT_META_EVENT, partial(generateMetaEvent, mapCueParams, MidiMetaCuePointEvent)),
generateMetaEventGuard(CHANNEL_PREFIX_ASSIGNMENT_META_EVENT, partial(generateMetaEvent, mapChannelPrefixParams, MidiMetaChannelPrefixEvent)),
generateMetaEventGuard(END_OF_TRACK_META_EVENT, partial(generateMetaEvent, mapEndOfTrackParams, MidiMetaEndOfTrackEvent)),
generateMetaEventGuard(TEMPO_META_EVENT, partial(generateMetaEvent, mapTempoParams, MidiMetaTempoEvent)),
generateMetaEventGuard(SMPTE_OFFSET_META_EVENT, partial(generateMetaEvent, mapSmpteOffsetParams, MidiMetaSmpteOffsetEvent)),
generateMetaEventGuard(TIME_SIG_META_EVENT, partial(generateMetaEvent, mapTimeSigParams, MidiMetaTimeSignatureEvent)),
generateMetaEventGuard(KEY_SIGNATURE_META_EVENT, partial(generateMetaEvent, mapKeySigparams, MidiMetaKeySignatureEvent)),
generateMetaEventGuard(SEQUENCER_SPECIFIC_META_EVENT, partial(generateMetaEvent, mapSequencerParams, MidiMetaSequencerSpecificEvent)),
// TODO: should we throw an exception?
function _noMatch(eventCode, subtype/*, deltaTime, dataBytes */) {
throw new Error('unknown meta event "' + toHex(subtype) + '"');
}
);
/* note event processing */
const generateNoteEventGuard = partial(function _generateNoteEventGuard(noteEventMask, processEvent) {
const eventMatches = generateMaskMatcher(noteEventMask);
return function _testEvent(eventCode/*, subtype, deltaTime, dataBytes */) {
if (eventMatches(eventCode)) return processEvent.apply(null, arguments);
};
});
const processNoteEvent = dispatch(
generateNoteEventGuard(NOTE_ON_MASK, partial(generateNote, MidiNoteOnEvent)),
generateNoteEventGuard(NOTE_OFF_MASK, partial(generateNote, MidiNoteOffEvent)),
// TODO: should an exception be thrown?
function _noMatch(eventCode, subtype/*, deltaTime, dataBytes */) {
throw new Error('unknown note event "' + toHex(subtype) + '"');
}
);
function generateNote(NoteClass, eventCode, deltaTime, dataBytes, track) {
const noteNumber = dataBytes[0];
const noteVelocity = dataBytes[1];
return new NoteClass({
code: eventCode,
delta: deltaTime,
channel: eventCode & 0xf,
note: noteNumber,
velocity: noteVelocity,
track: track
});
}
/* channel event processing */
function processPolyphonicAtertouchEvent(eventCode, deltaTime, dataBytes, track) {
return new MidiPolyphonicAftertouchEvent({
code: eventCode,
delta: deltaTime,
track: track,
note: dataBytes[0],
pressure: dataBytes[1]
});
}
function processControlChangeEvent(eventCode, deltaTime, dataBytes, track) {
return new MidiControlChangeEvent({
code: eventCode,
delta: deltaTime,
track: track
});
}
function processProgramChangeEvent(eventCode, deltaTime, dataBytes, track) {
return new MidiProgramChangeEvent({
code: eventCode,
delta: deltaTime,
track: track
});
}
function processChannelAftertouchEvent(eventCode, deltaTime, dataBytes, track) {
return new MidiChannelAftertouchEvent({
code: eventCode,
delta: deltaTime,
track: track
});
}
function processPitchWheelEvent(eventCode, deltaTime, dataBytes, track) {
return new MidiPitchWheelEvent({
code: eventCode,
delta: deltaTime,
track: track
});
}
/* parsing */
/**
* @name parse
* @function
* @description parses given array of unsigned 8-bit integers into Midi data type
* @param {array} midiBytes - array of unsinged 8-bit integers representing raw midi data
* @return {Midi}
*/
function parse(midiBytes) {
const headerOffset = 14;
// NOTE: I would like to use UInt8Array, but it's a pain to use,
// so I convert it to a regular array
const header = parseHeader(toArr(midiBytes, 0, headerOffset));
const tracks = parseTracks(toArr(midiBytes, headerOffset, midiBytes.length), 1);
// TODO: test this error case (need a malformed midi file)
if (tracks.length !== header.trackCount) throw new Error('Parsed wrong number of tracks: expected (' + header.trackCount + '), but got (' + tracks.length + ')');
return new Midi({ header: header, tracks: tracks });
}
function parseHeader(midiBytes) {
const chunkId = parseStringFromRawChars(midiBytes.slice(0, 4));
if (chunkId !== 'MThd') throw new Error('malformed midi: could not find "MThd"');
const size = parseByteArrayToNumber(midiBytes.slice(4, 8));
if (size !== 6) throw new Error('malformed midi: unexpected header size (' + size + ')');
const format = parseByteArrayToNumber(midiBytes.slice(8, 10));
if (format < 0 || format > 2) throw new Error('malformed midi: unknown format (' + format + ')');
const trackCount = parseByteArrayToNumber(midiBytes.slice(10, 12));
const timeDivision = parseByteArrayToNumber(midiBytes.slice(12, 14));
return new MidiHeader({
format: format,
trackCount: trackCount,
timeDivision: timeDivision
});
}
function parseTracks(midiBytes, track) {
if (midiBytes.length === 0) return [];
const chunkIdOffset = 4;
const chunkIdBytes = midiBytes.slice(0, chunkIdOffset);
const chunkId = parseStringFromRawChars(chunkIdBytes);
// TODO: test this (need invalid midi file)
if (chunkId !== 'MTrk') throw new Error('Invalid header chunkId "' + chunkId + '"');
const trackSizeOffset = chunkIdOffset + 4;
const trackSizeBytes = midiBytes.slice(chunkIdOffset, trackSizeOffset);
const trackSize = parseByteArrayToNumber(trackSizeBytes);
const eventsOffset = trackSizeOffset + trackSize;
const eventsBytes = midiBytes.slice(trackSizeOffset, eventsOffset);
const events = parseEvents(eventsBytes, track);
const nameEvents = events.filter(function(event) {
return event instanceof MidiMetaInstrumentNameEvent;
});
const trackName = nameEvents.length <= 0 ? '' : nameEvents[0].instrumentName;
return [new MidiTrack({ events: events, name: trackName })].concat(parseTracks(midiBytes.slice(eventsOffset), track + 1));
}
function parseEvents(midiBytes, track) {
// NOTE: we cannot use recusion here as the stack can get too deep
// when parsing long tracks
const events = [];
const onEventsCache = {};
let lastEventCode = 0;
while (midiBytes.length) {
const deltaBytes = parseNextVariableChunk(midiBytes);
const deltaTime = parseByteArrayToNumber(deltaBytes, true);
let eventCode = 0;
midiBytes = midiBytes.slice(deltaBytes.length);
if (!isValidEventCode(midiBytes[0])) {
// NOTE: we assume we have a running status if the event code is invalid
// in that case, reuse the last event and process the rest of the
// information as if it were for that type of event
eventCode = lastEventCode;
} else {
eventCode = midiBytes[0];
midiBytes = midiBytes.slice(1);
}
let dataBytes = [];
let midiEvent = {};
if (isMetaEvent(eventCode)) {
const subtype = midiBytes[0];
midiBytes = midiBytes.slice(1);
const sizeBytes = parseNextVariableChunk(midiBytes);
const size = parseByteArrayToNumber(sizeBytes, true);
dataBytes = midiBytes.slice(sizeBytes.length, sizeBytes.length + size);
midiEvent = processMetaEvent(eventCode, subtype, deltaTime, dataBytes, track);
midiBytes = midiBytes.slice(sizeBytes.length + size);
} else if (isSysexEvent(eventCode)) {
throw new Error('TODO: sysex event processing...');
} else if (isNoteEvent(eventCode)) {
dataBytes = midiBytes.slice(0, 2);
midiBytes = midiBytes.slice(dataBytes.length);
midiEvent = processNoteEvent(eventCode, deltaTime, dataBytes, track);
if (midiEvent instanceof MidiNoteOnEvent && midiEvent.velocity === 0 && onEventsCache[midiEvent.note]) {
// NOTE: some programs (at least Logic Pro X) appear to sometimes use note-on events with a
// zero velocity rather than a true note-off event...
midiEvent = new MidiNoteOffEvent({
code: eventCode - 0x10, // convert the code to be a "note off" code (ie 0x80-0x8f)
delta: midiEvent.delta,
channel: midiEvent.channel,
note: midiEvent.note,
velocity: midiEvent.velocity,
track: midiEvent.track
});
}
if (midiEvent instanceof MidiNoteOnEvent) {
onEventsCache[midiEvent.note] = onEventsCache[midiEvent.note] || [];
onEventsCache[midiEvent.note].push({
index: events.length,
event: midiEvent
});
} else if (midiEvent instanceof MidiNoteOffEvent) {
const onEvents = onEventsCache[midiEvent.note];
if (onEvents && onEvents.length > 0) {
const onEvent = onEvents.shift();
const origOnEvent = onEvent.event;
let endNoteFound = false;
let noteLength = events.slice(onEvent.index + 1).reduce(function(sum, event) {
if (endNoteFound) return sum;
if (event.note === origOnEvent.note && event instanceof MidiNoteOffEvent) endNoteFound = true;
return sum + event.delta;
}, 0);
noteLength += midiEvent.delta;
const updatedMidiOnEvent = new MidiNoteOnEvent({
code: origOnEvent.code,
subtype: origOnEvent.subtype,
channel: origOnEvent.channel,
note: origOnEvent.note,
velocity: origOnEvent.velocity,
delta: origOnEvent.delta,
track: origOnEvent.track,
length: noteLength
});
events.splice(onEvent.index, 1, updatedMidiOnEvent);
if (onEvents.length === 0) delete onEventsCache[midiEvent.note];
} else {
throw new Error('No starting event for note "' + midiEvent.note + '"');
}
}
} else if (isPolyphonicAftertouchEvent(eventCode)) {
midiEvent = processPolyphonicAtertouchEvent(eventCode, deltaTime, dataBytes, track);
midiBytes = midiBytes.slice(2);
} else if (isControlChangeEvent(eventCode)) {
midiEvent = processControlChangeEvent(eventCode, deltaTime, dataBytes, track);
midiBytes = midiBytes.slice(2);
} else if (isProgramChangeEvent(eventCode)) {
midiEvent = processProgramChangeEvent(eventCode, deltaTime, dataBytes, track);
midiBytes = midiBytes.slice(1);
} else if (isChannelAftertouchEvent(eventCode)) {
midiEvent = processChannelAftertouchEvent(eventCode, deltaTime, dataBytes, track);
midiBytes = midiBytes.slice(1);
} else if (isPitchWheelEvent(eventCode)) {
midiEvent = processPitchWheelEvent(eventCode, deltaTime, dataBytes, track);
midiBytes = midiBytes.slice(2);
} else {
throw new TypeError('unknown event code "' + toHex(eventCode) + '"');
}
lastEventCode = eventCode;
events.push(midiEvent);
}
return events;
}
module.exports = {
parse: parse,
types: types,
constants: constants
};