UNPKG

midi-player-js

Version:

Midi parser & player engine for browser or Node. Works well with single or multitrack MIDI files.

1,461 lines (1,290 loc) 47.2 kB
var MidiPlayer = (function () { 'use strict'; function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; } /** * Constants used in player. */ var Constants = { VERSION: '2.0.17', NOTES: [], HEADER_CHUNK_LENGTH: 14, CIRCLE_OF_FOURTHS: ['C', 'F', 'Bb', 'Eb', 'Ab', 'Db', 'Gb', 'Cb', 'Fb', 'Bbb', 'Ebb', 'Abb'], CIRCLE_OF_FIFTHS: ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'E#'] }; // Builds notes object for reference against binary values. var allNotes = [['C'], ['C#', 'Db'], ['D'], ['D#', 'Eb'], ['E'], ['F'], ['F#', 'Gb'], ['G'], ['G#', 'Ab'], ['A'], ['A#', 'Bb'], ['B']]; var counter = 0; // All available octaves. var _loop = function _loop(i) { allNotes.forEach(function (noteGroup) { noteGroup.forEach(function (note) { return Constants.NOTES[counter] = note + i; }); counter++; }); }; for (var i = -1; i <= 9; i++) { _loop(i); } /** * Contains misc static utility methods. */ var Utils = /*#__PURE__*/function () { function Utils() { _classCallCheck(this, Utils); } _createClass(Utils, null, [{ key: "byteToHex", value: /** * Converts a single byte to a hex string. * @param {number} byte * @return {string} */ function byteToHex(_byte) { // Ensure hex string always has two chars return ('0' + _byte.toString(16)).slice(-2); } /** * Converts an array of bytes to a hex string. * @param {array} byteArray * @return {string} */ }, { key: "bytesToHex", value: function bytesToHex(byteArray) { var hex = []; byteArray.forEach(function (_byte2) { return hex.push(Utils.byteToHex(_byte2)); }); return hex.join(''); } /** * Converts a hex string to a number. * @param {string} hexString * @return {number} */ }, { key: "hexToNumber", value: function hexToNumber(hexString) { return parseInt(hexString, 16); } /** * Converts an array of bytes to a number. * @param {array} byteArray * @return {number} */ }, { key: "bytesToNumber", value: function bytesToNumber(byteArray) { return Utils.hexToNumber(Utils.bytesToHex(byteArray)); } /** * Converts an array of bytes to letters. * @param {array} byteArray * @return {string} */ }, { key: "bytesToLetters", value: function bytesToLetters(byteArray) { var letters = []; byteArray.forEach(function (_byte3) { return letters.push(String.fromCharCode(_byte3)); }); return letters.join(''); } /** * Converts a decimal to it's binary representation. * @param {number} dec * @return {string} */ }, { key: "decToBinary", value: function decToBinary(dec) { return (dec >>> 0).toString(2); } /** * Determines the length in bytes of a variable length quaantity. The first byte in given range is assumed to be beginning of var length quantity. * @param {array} byteArray * @return {number} */ }, { key: "getVarIntLength", value: function getVarIntLength(byteArray) { // Get byte count of delta VLV // http://www.ccarh.org/courses/253/handout/vlv/ // If byte is greater or equal to 80h (128 decimal) then the next byte // is also part of the VLV, // else byte is the last byte in a VLV. var currentByte = byteArray[0]; var byteCount = 1; while (currentByte >= 128) { currentByte = byteArray[byteCount]; byteCount++; } return byteCount; } /** * Reads a variable length value. * @param {array} byteArray * @return {number} */ }, { key: "readVarInt", value: function readVarInt(byteArray) { var result = 0; byteArray.forEach(function (number) { var b = number; if (b & 0x80) { result += b & 0x7f; result <<= 7; } else { /* b is the last byte */ result += b; } }); return result; } /** * Decodes base-64 encoded string * @param {string} string * @return {string} */ }, { key: "atob", value: function (_atob) { function atob(_x) { return _atob.apply(this, arguments); } atob.toString = function () { return _atob.toString(); }; return atob; }(function (string) { if (typeof atob === 'function') return atob(string); return Buffer.from(string, 'base64').toString('binary'); }) }]); return Utils; }(); /** * Class representing a track. Contains methods for parsing events and keeping track of pointer. */ var Track = /*#__PURE__*/function () { function Track(index, data) { _classCallCheck(this, Track); this.enabled = true; this.eventIndex = 0; this.pointer = 0; this.lastTick = 0; this.lastStatus = null; this.index = index; this.data = data; this.delta = 0; this.runningDelta = 0; this.events = []; // Ensure last 3 bytes of track are End of Track event var lastThreeBytes = this.data.subarray(this.data.length - 3, this.data.length); if (!(lastThreeBytes[0] === 0xff && lastThreeBytes[1] === 0x2f && lastThreeBytes[2] === 0x00)) { throw 'Invalid MIDI file; Last three bytes of track ' + this.index + 'must be FF 2F 00 to mark end of track'; } } /** * Resets all stateful track informaion used during playback. * @return {Track} */ _createClass(Track, [{ key: "reset", value: function reset() { this.enabled = true; this.eventIndex = 0; this.pointer = 0; this.lastTick = 0; this.lastStatus = null; this.delta = 0; this.runningDelta = 0; return this; } /** * Sets this track to be enabled during playback. * @return {Track} */ }, { key: "enable", value: function enable() { this.enabled = true; return this; } /** * Sets this track to be disabled during playback. * @return {Track} */ }, { key: "disable", value: function disable() { this.enabled = false; return this; } /** * Sets the track event index to the nearest event to the given tick. * @param {number} tick * @return {Track} */ }, { key: "setEventIndexByTick", value: function setEventIndexByTick(tick) { tick = tick || 0; for (var i = 0; i < this.events.length; i++) { if (this.events[i].tick >= tick) { this.eventIndex = i; return this; } } } /** * Gets byte located at pointer position. * @return {number} */ }, { key: "getCurrentByte", value: function getCurrentByte() { return this.data[this.pointer]; } /** * Gets count of delta bytes and current pointer position. * @return {number} */ }, { key: "getDeltaByteCount", value: function getDeltaByteCount() { return Utils.getVarIntLength(this.data.subarray(this.pointer)); } /** * Get delta value at current pointer position. * @return {number} */ }, { key: "getDelta", value: function getDelta() { return Utils.readVarInt(this.data.subarray(this.pointer, this.pointer + this.getDeltaByteCount())); } /** * Handles event within a given track starting at specified index * @param {number} currentTick * @param {boolean} dryRun - If true events will be parsed and returned regardless of time. */ }, { key: "handleEvent", value: function handleEvent(currentTick, dryRun) { dryRun = dryRun || false; if (dryRun) { var elapsedTicks = currentTick - this.lastTick; var delta = this.getDelta(); var eventReady = elapsedTicks >= delta; if (this.pointer < this.data.length && (dryRun || eventReady)) { var event = this.parseEvent(); if (this.enabled) return event; // Recursively call this function for each event ahead that has 0 delta time? } } else { // Let's actually play the MIDI from the generated JSON events created by the dry run. // Process all events that have passed to avoid falling behind on dense MIDI files. var events = []; while (this.events[this.eventIndex] && this.events[this.eventIndex].tick <= currentTick) { if (this.enabled) events.push(this.events[this.eventIndex]); this.eventIndex++; } if (events.length > 0) return events; } return null; } /** * Get string data from event. * @param {number} eventStartIndex * @return {string} */ }, { key: "getStringData", value: function getStringData(eventStartIndex) { var varIntLength = Utils.getVarIntLength(this.data.subarray(eventStartIndex + 2)); var varIntValue = Utils.readVarInt(this.data.subarray(eventStartIndex + 2, eventStartIndex + 2 + varIntLength)); var letters = Utils.bytesToLetters(this.data.subarray(eventStartIndex + 2 + varIntLength, eventStartIndex + 2 + varIntLength + varIntValue)); return letters; } /** * Parses event into JSON and advances pointer for the track * @return {object} */ }, { key: "parseEvent", value: function parseEvent() { var eventStartIndex = this.pointer + this.getDeltaByteCount(); var eventJson = {}; var deltaByteCount = this.getDeltaByteCount(); eventJson.track = this.index + 1; eventJson.delta = this.getDelta(); this.lastTick = this.lastTick + eventJson.delta; this.runningDelta += eventJson.delta; eventJson.tick = this.runningDelta; eventJson.byteIndex = this.pointer; //eventJson.raw = event; if (this.data[eventStartIndex] == 0xff) { // Meta Event // If this is a meta event we should emit the data and immediately move to the next event // otherwise if we let it run through the next cycle a slight delay will accumulate if multiple tracks // are being played simultaneously switch (this.data[eventStartIndex + 1]) { case 0x00: // Sequence Number eventJson.name = 'Sequence Number'; break; case 0x01: // Text Event eventJson.name = 'Text Event'; eventJson.string = this.getStringData(eventStartIndex); break; case 0x02: // Copyright Notice eventJson.name = 'Copyright Notice'; break; case 0x03: // Sequence/Track Name eventJson.name = 'Sequence/Track Name'; eventJson.string = this.getStringData(eventStartIndex); break; case 0x04: // Instrument Name eventJson.name = 'Instrument Name'; eventJson.string = this.getStringData(eventStartIndex); break; case 0x05: // Lyric eventJson.name = 'Lyric'; eventJson.string = this.getStringData(eventStartIndex); break; case 0x06: // Marker eventJson.name = 'Marker'; eventJson.string = this.getStringData(eventStartIndex); break; case 0x07: // Cue Point eventJson.name = 'Cue Point'; eventJson.string = this.getStringData(eventStartIndex); break; case 0x09: // Device Name eventJson.name = 'Device Name'; eventJson.string = this.getStringData(eventStartIndex); break; case 0x20: // MIDI Channel Prefix eventJson.name = 'MIDI Channel Prefix'; break; case 0x21: // MIDI Port eventJson.name = 'MIDI Port'; eventJson.data = Utils.bytesToNumber([this.data[eventStartIndex + 3]]); break; case 0x2F: // End of Track eventJson.name = 'End of Track'; break; case 0x51: // Set Tempo eventJson.name = 'Set Tempo'; eventJson.data = Math.round(60000000 / Utils.bytesToNumber(this.data.subarray(eventStartIndex + 3, eventStartIndex + 6))); this.tempo = eventJson.data; break; case 0x54: // SMTPE Offset eventJson.name = 'SMTPE Offset'; break; case 0x58: // Time Signature // FF 58 04 nn dd cc bb eventJson.name = 'Time Signature'; eventJson.data = this.data.subarray(eventStartIndex + 3, eventStartIndex + 7); eventJson.timeSignature = "" + eventJson.data[0] + "/" + Math.pow(2, eventJson.data[1]); break; case 0x59: // Key Signature // FF 59 02 sf mi eventJson.name = 'Key Signature'; eventJson.data = this.data.subarray(eventStartIndex + 3, eventStartIndex + 5); // sf byte is signed (-7 to 7), but Uint8Array gives unsigned values var sf = eventJson.data[0] > 127 ? eventJson.data[0] - 256 : eventJson.data[0]; if (sf >= 0) { eventJson.keySignature = Constants.CIRCLE_OF_FIFTHS[sf]; } else { eventJson.keySignature = Constants.CIRCLE_OF_FOURTHS[Math.abs(sf)]; } if (eventJson.data[1] == 0) { eventJson.keySignature += " Major"; } else if (eventJson.data[1] == 1) { eventJson.keySignature += " Minor"; } break; case 0x7F: // Sequencer-Specific Meta-event eventJson.name = 'Sequencer-Specific Meta-event'; break; default: eventJson.name = 'Unknown: ' + this.data[eventStartIndex + 1].toString(16); break; } var varIntLength = Utils.getVarIntLength(this.data.subarray(eventStartIndex + 2)); var length = Utils.readVarInt(this.data.subarray(eventStartIndex + 2, eventStartIndex + 2 + varIntLength)); //console.log(eventJson); this.pointer += deltaByteCount + 2 + varIntLength + length; //console.log(eventJson); } else if (this.data[eventStartIndex] === 0xf0) { // Sysex eventJson.name = 'Sysex'; var varQuantityByteLength = Utils.getVarIntLength(this.data.subarray(eventStartIndex + 1)); var varQuantityByteValue = Utils.readVarInt(this.data.subarray(eventStartIndex + 1, eventStartIndex + 1 + varQuantityByteLength)); eventJson.data = this.data.subarray(eventStartIndex + 1 + varQuantityByteLength, eventStartIndex + 1 + varQuantityByteLength + varQuantityByteValue); this.pointer += deltaByteCount + 1 + varQuantityByteLength + varQuantityByteValue; } else if (this.data[eventStartIndex] === 0xf7) { // Sysex (escape) // http://www.somascape.org/midi/tech/mfile.html#sysex eventJson.name = 'Sysex (escape)'; var _varQuantityByteLength = Utils.getVarIntLength(this.data.subarray(eventStartIndex + 1)); var _varQuantityByteValue = Utils.readVarInt(this.data.subarray(eventStartIndex + 1, eventStartIndex + 1 + _varQuantityByteLength)); eventJson.data = this.data.subarray(eventStartIndex + 1 + _varQuantityByteLength, eventStartIndex + 1 + _varQuantityByteLength + _varQuantityByteValue); this.pointer += deltaByteCount + 1 + _varQuantityByteLength + _varQuantityByteValue; } else { // Voice event if (this.data[eventStartIndex] < 0x80) { // Running status eventJson.running = true; eventJson.noteNumber = this.data[eventStartIndex]; eventJson.noteName = Constants.NOTES[this.data[eventStartIndex]]; eventJson.velocity = this.data[eventStartIndex + 1]; if (this.lastStatus <= 0x8f) { eventJson.name = 'Note off'; eventJson.channel = this.lastStatus - 0x80 + 1; this.pointer += deltaByteCount + 2; } else if (this.lastStatus <= 0x9f) { eventJson.name = 'Note on'; eventJson.channel = this.lastStatus - 0x90 + 1; this.pointer += deltaByteCount + 2; } else if (this.lastStatus <= 0xaf) { // Polyphonic Key Pressure eventJson.name = 'Polyphonic Key Pressure'; eventJson.channel = this.lastStatus - 0xa0 + 1; eventJson.note = Constants.NOTES[this.data[eventStartIndex]]; eventJson.pressure = this.data[eventStartIndex + 1]; this.pointer += deltaByteCount + 2; } else if (this.lastStatus <= 0xbf) { // Controller Change eventJson.name = 'Controller Change'; eventJson.channel = this.lastStatus - 0xb0 + 1; eventJson.number = this.data[eventStartIndex]; eventJson.value = this.data[eventStartIndex + 1]; this.pointer += deltaByteCount + 2; } else if (this.lastStatus <= 0xcf) { // Program Change eventJson.name = 'Program Change'; eventJson.channel = this.lastStatus - 0xc0 + 1; eventJson.value = this.data[eventStartIndex + 1]; this.pointer += deltaByteCount + 1; } else if (this.lastStatus <= 0xdf) { // Channel Key Pressure eventJson.name = 'Channel Key Pressure'; eventJson.channel = this.lastStatus - 0xd0 + 1; this.pointer += deltaByteCount + 1; } else if (this.lastStatus <= 0xef) { // Pitch Bend eventJson.name = 'Pitch Bend'; eventJson.channel = this.lastStatus - 0xe0 + 1; eventJson.value = (this.data[eventStartIndex + 1] & 0x7f) << 7 | this.data[eventStartIndex] & 0x7f; this.pointer += deltaByteCount + 2; } else { throw "Unknown event (running): ".concat(this.lastStatus); } } else { this.lastStatus = this.data[eventStartIndex]; if (this.data[eventStartIndex] <= 0x8f) { // Note off eventJson.name = 'Note off'; eventJson.channel = this.lastStatus - 0x80 + 1; eventJson.noteNumber = this.data[eventStartIndex + 1]; eventJson.noteName = Constants.NOTES[this.data[eventStartIndex + 1]]; eventJson.velocity = Math.round(this.data[eventStartIndex + 2] / 127 * 100); this.pointer += deltaByteCount + 3; } else if (this.data[eventStartIndex] <= 0x9f) { // Note on eventJson.name = 'Note on'; eventJson.channel = this.lastStatus - 0x90 + 1; eventJson.noteNumber = this.data[eventStartIndex + 1]; eventJson.noteName = Constants.NOTES[this.data[eventStartIndex + 1]]; eventJson.velocity = Math.round(this.data[eventStartIndex + 2] / 127 * 100); this.pointer += deltaByteCount + 3; } else if (this.data[eventStartIndex] <= 0xaf) { // Polyphonic Key Pressure eventJson.name = 'Polyphonic Key Pressure'; eventJson.channel = this.lastStatus - 0xa0 + 1; eventJson.note = Constants.NOTES[this.data[eventStartIndex + 1]]; eventJson.pressure = this.data[eventStartIndex + 2]; this.pointer += deltaByteCount + 3; } else if (this.data[eventStartIndex] <= 0xbf) { // Controller Change eventJson.name = 'Controller Change'; eventJson.channel = this.lastStatus - 0xb0 + 1; eventJson.number = this.data[eventStartIndex + 1]; eventJson.value = this.data[eventStartIndex + 2]; this.pointer += deltaByteCount + 3; } else if (this.data[eventStartIndex] <= 0xcf) { // Program Change eventJson.name = 'Program Change'; eventJson.channel = this.lastStatus - 0xc0 + 1; eventJson.value = this.data[eventStartIndex + 1]; this.pointer += deltaByteCount + 2; } else if (this.data[eventStartIndex] <= 0xdf) { // Channel Key Pressure eventJson.name = 'Channel Key Pressure'; eventJson.channel = this.lastStatus - 0xd0 + 1; this.pointer += deltaByteCount + 2; } else if (this.data[eventStartIndex] <= 0xef) { // Pitch Bend eventJson.name = 'Pitch Bend'; eventJson.channel = this.lastStatus - 0xe0 + 1; eventJson.value = (this.data[eventStartIndex + 2] & 0x7f) << 7 | this.data[eventStartIndex + 1] & 0x7f; this.pointer += deltaByteCount + 3; } else { throw "Unknown event: ".concat(this.data[eventStartIndex]); //eventJson.name = `Unknown. Pointer: ${this.pointer.toString()}, ${eventStartIndex.toString()}, ${this.data[eventStartIndex]}, ${this.data.length}`; } } } this.delta += eventJson.delta; this.events.push(eventJson); return eventJson; } /** * Returns true if pointer has reached the end of the track. * @param {boolean} */ }, { key: "endOfTrack", value: function endOfTrack() { if (this.data[this.pointer + 1] == 0xff && this.data[this.pointer + 2] == 0x2f && this.data[this.pointer + 3] == 0x00) { return true; } return false; } }]); return Track; }(); if (!Uint8Array.prototype.forEach) { Object.defineProperty(Uint8Array.prototype, 'forEach', { value: Array.prototype.forEach }); } /** * Main player class. Contains methods to load files, start, stop. * @param {function} - Callback to fire for each MIDI event. Can also be added with on('midiEvent', fn) * @param {array} - Array buffer of MIDI file (optional). */ var Player = /*#__PURE__*/function () { function Player(eventHandler, buffer) { _classCallCheck(this, Player); this.sampleRate = 5; // milliseconds this.startTime = 0; this.buffer = buffer || null; this.midiChunksByteLength = null; this.division; this.format; this.setTimeoutId = false; this.scheduledTime = 0; this.tracks = []; this.instruments = []; this.defaultTempo = 120; this.tempo = null; this.startTick = 0; this.tick = 0; this.lastTick = null; this.inLoop = false; this.totalTicks = 0; this.events = []; this.totalEvents = 0; this.tempoMap = []; this.eventListeners = {}; if (typeof eventHandler === 'function') this.on('midiEvent', eventHandler); } /** * Load a file into the player (Node.js only). * @param {string} path - Path of file. * @return {Player} */ _createClass(Player, [{ key: "loadFile", value: function loadFile(path) { { throw 'loadFile is only supported on Node.js'; } } /** * Load an array buffer into the player. * @param {array} arrayBuffer - Array buffer of file to be loaded. * @return {Player} */ }, { key: "loadArrayBuffer", value: function loadArrayBuffer(arrayBuffer) { this.buffer = new Uint8Array(arrayBuffer); return this.fileLoaded(); } /** * Load a data URI into the player. * @param {string} dataUri - Data URI to be loaded. * @return {Player} */ }, { key: "loadDataUri", value: function loadDataUri(dataUri) { // convert base64 to raw binary data held in a string. // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this var byteString = Utils.atob(dataUri.split(',')[1]); // write the bytes of the string to an ArrayBuffer var ia = new Uint8Array(byteString.length); for (var i = 0; i < byteString.length; i++) { ia[i] = byteString.charCodeAt(i); } this.buffer = ia; return this.fileLoaded(); } /** * Get filesize of loaded file in number of bytes. * @return {number} - The filesize. */ }, { key: "getFilesize", value: function getFilesize() { return this.buffer ? this.buffer.length : 0; } /** * Sets default tempo, parses file for necessary information, and does a dry run to calculate total length. * Populates this.events & this.totalTicks. * @return {Player} */ }, { key: "fileLoaded", value: function fileLoaded() { if (!this.validate()) throw 'Invalid MIDI file; should start with MThd'; this.defaultTempo = 120; return this.setTempo(this.defaultTempo).getDivision().getFormat().getTracks().dryRun(); } /** * Validates file using simple means - first four bytes should == MThd. * @return {boolean} */ }, { key: "validate", value: function validate() { //console.log((this.buffer.subarray(0, 15))); return Utils.bytesToLetters(this.buffer.subarray(0, 4)) === 'MThd'; } /** * Gets MIDI file format for loaded file. * @return {Player} */ }, { key: "getFormat", value: function getFormat() { /* MIDI files come in 3 variations: Format 0 which contain a single track Format 1 which contain one or more simultaneous tracks (ie all tracks are to be played simultaneously). Format 2 which contain one or more independant tracks (ie each track is to be played independantly of the others). return Utils.bytesToNumber(this.buffer.subarray(8, 10)); */ this.format = Utils.bytesToNumber(this.buffer.subarray(8, 10)); return this; } /** * Parses out tracks, places them in this.tracks and initializes this.pointers * @return {Player} */ }, { key: "getTracks", value: function getTracks() { this.tracks = []; var trackOffset = 0; while (trackOffset < this.buffer.length) { if (Utils.bytesToLetters(this.buffer.subarray(trackOffset, trackOffset + 4)) == 'MTrk') { var trackLength = Utils.bytesToNumber(this.buffer.subarray(trackOffset + 4, trackOffset + 8)); this.tracks.push(new Track(this.tracks.length, this.buffer.subarray(trackOffset + 8, trackOffset + 8 + trackLength))); } trackOffset += Utils.bytesToNumber(this.buffer.subarray(trackOffset + 4, trackOffset + 8)) + 8; } // Get sum of all MIDI chunks here while we're at it var trackChunksByteLength = 0; this.tracks.forEach(function (track) { trackChunksByteLength += 8 + track.data.length; }); this.midiChunksByteLength = Constants.HEADER_CHUNK_LENGTH + trackChunksByteLength; return this; } /** * Enables a track for playing. * @param {number} trackNumber - Track number * @return {Player} */ }, { key: "enableTrack", value: function enableTrack(trackNumber) { this.tracks[trackNumber - 1].enable(); return this; } /** * Disables a track for playing. * @param {number} - Track number * @return {Player} */ }, { key: "disableTrack", value: function disableTrack(trackNumber) { this.tracks[trackNumber - 1].disable(); return this; } /** * Gets quarter note division of loaded MIDI file. * @return {Player} */ }, { key: "getDivision", value: function getDivision() { this.division = Utils.bytesToNumber(this.buffer.subarray(12, Constants.HEADER_CHUNK_LENGTH)); return this; } /** * The main play loop. * @param {boolean} - Indicates whether or not this is being called simply for parsing purposes. Disregards timing if so. * @return {undefined} */ }, { key: "playLoop", value: function playLoop(dryRun) { if (!this.inLoop) { this.inLoop = true; this.tick = this.getCurrentTick(); this.tracks.forEach(function (track, index) { // Handle next event if (!dryRun && this.endOfFile()) { //console.log('end of file') this.stop(); this.triggerPlayerEvent('endOfFile'); } else { var result = track.handleEvent(this.tick, dryRun); if (dryRun && result) { if (result.hasOwnProperty('name') && result.name === 'Set Tempo') { // Grab tempo if available. this.setTempo(result.data); } if (result.hasOwnProperty('name') && result.name === 'Program Change') { if (!this.instruments.includes(result.value)) { this.instruments.push(result.value); } } } else if (result) { // result is an array of events during playback var events = Array.isArray(result) ? result : [result]; events.forEach(function (event) { if (event.hasOwnProperty('name') && event.name === 'Set Tempo') { // Grab tempo if available. this.setTempo(event.data); } this.emitEvent(event); }, this); } } }, this); if (!dryRun && this.isPlaying()) this.triggerPlayerEvent('playing', { tick: this.tick }); this.inLoop = false; } } /** * Setter for tempo. * @param {number} - Tempo in bpm (defaults to 120) */ }, { key: "setTempo", value: function setTempo(tempo) { this.tempo = tempo; return this; } /** * Setter for startTime. * @param {number} - UTC timestamp * @return {Player} */ }, { key: "setStartTime", value: function setStartTime(startTime) { this.startTime = startTime; return this; } /** * Start playing loaded MIDI file if not already playing. * @return {Player} */ }, { key: "play", value: function play() { if (this.isPlaying()) throw 'Already playing...'; // Initialize if (!this.startTime) this.startTime = new Date().getTime(); // Start play loop using drift-correcting setTimeout this.scheduledTime = Date.now(); this.schedulePlayLoop(this.sampleRate); return this; } /** * Schedules the next play loop iteration, correcting for timer drift. * @param {number} delay - Delay in milliseconds before next iteration. * @return {undefined} */ }, { key: "schedulePlayLoop", value: function schedulePlayLoop(delay) { var _this = this; this.setTimeoutId = setTimeout(function () { _this.playLoop(); if (_this.setTimeoutId !== false) { _this.scheduledTime += _this.sampleRate; var drift = Date.now() - _this.scheduledTime; _this.schedulePlayLoop(Math.max(0, _this.sampleRate - drift)); } }, delay); } /** * Pauses playback if playing. * @return {Player} */ }, { key: "pause", value: function pause() { clearTimeout(this.setTimeoutId); this.setTimeoutId = false; this.scheduledTime = 0; this.startTick = this.tick; this.startTime = 0; return this; } /** * Stops playback if playing. * @return {Player} */ }, { key: "stop", value: function stop() { clearTimeout(this.setTimeoutId); this.setTimeoutId = false; this.scheduledTime = 0; this.startTick = 0; this.startTime = 0; this.resetTracks(); return this; } /** * Skips player pointer to specified tick. * @param {number} - Tick to skip to. * @return {Player} */ }, { key: "skipToTick", value: function skipToTick(tick) { this.stop(); this.startTick = tick; // Set tempo to the value that applies at the target tick for (var i = this.tempoMap.length - 1; i >= 0; i--) { if (this.tempoMap[i].tick <= tick) { this.setTempo(this.tempoMap[i].tempo); break; } } // Emit intermediate state-changing events so the consumer's synth is in the correct state this.collectStateAtTick(tick).forEach(function (event) { this.emitEvent(event); }, this); // Need to set track event indexes to the nearest possible event to the specified tick. this.tracks.forEach(function (track) { track.setEventIndexByTick(tick); }); return this; } /** * Collects the last state-changing MIDI events (Program Change, Controller Change, Pitch Bend) * before the specified tick across all tracks. * @param {number} tick - Target tick to collect state up to. * @return {array} - Array of state events representing the MIDI state at the target tick. */ }, { key: "collectStateAtTick", value: function collectStateAtTick(tick) { var dominated = {}; this.events.forEach(function (trackEvents) { trackEvents.forEach(function (event) { if (event.tick >= tick) return; var key; if (event.name === 'Program Change') { key = 'pc:' + event.channel; } else if (event.name === 'Controller Change') { key = 'cc:' + event.channel + ':' + event.number; } else if (event.name === 'Pitch Bend') { key = 'pb:' + event.channel; } if (key) { dominated[key] = event; } }); }); return Object.keys(dominated).map(function (key) { return dominated[key]; }); } /** * Skips player pointer to specified percentage. * @param {number} - Percent value in integer format. * @return {Player} */ }, { key: "skipToPercent", value: function skipToPercent(percent) { if (percent < 0 || percent > 100) throw "Percent must be number between 1 and 100."; this.skipToTick(Math.round(percent / 100 * this.totalTicks)); return this; } /** * Skips player pointer to specified seconds. * @param {number} - Seconds to skip to. * @return {Player} */ }, { key: "skipToSeconds", value: function skipToSeconds(seconds) { var songTime = this.getSongTime(); if (seconds < 0 || seconds > songTime) throw seconds + " seconds not within song time of " + songTime; this.skipToTick(this.secondsToTicks(seconds)); return this; } /** * Checks if player is playing * @return {boolean} */ }, { key: "isPlaying", value: function isPlaying() { return this.setTimeoutId !== false; } /** * Plays the loaded MIDI file without regard for timing and saves events in this.events. Essentially used as a parser. * @return {Player} */ }, { key: "dryRun", value: function dryRun() { // Reset tracks first this.resetTracks(); while (!this.endOfFile()) { this.playLoop(true); //console.log(this.bytesProcessed(), this.midiChunksByteLength); } this.events = this.getEvents(); this.totalEvents = this.getTotalEvents(); this.totalTicks = this.getTotalTicks(); this.buildTempoMap(); this.startTick = 0; this.startTime = 0; // Leave tracks in pristine condish this.resetTracks(); //console.log('Song time: ' + this.getSongTime() + ' seconds / ' + this.totalTicks + ' ticks.'); this.triggerPlayerEvent('fileLoaded', this); return this; } /** * Resets play pointers for all tracks. * @return {Player} */ }, { key: "resetTracks", value: function resetTracks() { this.tracks.forEach(function (track) { return track.reset(); }); return this; } /** * Gets an array of events grouped by track. * @return {array} */ }, { key: "getEvents", value: function getEvents() { return this.tracks.map(function (track) { return track.events; }); } /** * Gets total number of ticks in the loaded MIDI file. * @return {number} */ }, { key: "getTotalTicks", value: function getTotalTicks() { return Math.max.apply(null, this.tracks.map(function (track) { return track.delta; })); } /** * Gets total number of events in the loaded MIDI file. * @return {number} */ }, { key: "getTotalEvents", value: function getTotalEvents() { return this.tracks.reduce(function (a, b) { return { events: { length: a.events.length + b.events.length } }; }, { events: { length: 0 } }).events.length; } /** * Builds a tempo map from all Set Tempo events across all tracks. * @return {Player} */ }, { key: "buildTempoMap", value: function buildTempoMap() { // Collect all Set Tempo events from all tracks var tempoEvents = []; this.events.forEach(function (trackEvents) { trackEvents.forEach(function (event) { if (event.name === 'Set Tempo') { tempoEvents.push({ tick: event.tick, tempo: event.data }); } }); }); // Sort by tick tempoEvents.sort(function (a, b) { return a.tick - b.tick; }); // Build map starting with default tempo this.tempoMap = [{ tick: 0, tempo: this.defaultTempo }]; tempoEvents.forEach(function (event) { var last = this.tempoMap[this.tempoMap.length - 1]; if (event.tick === last.tick) { // Same tick: update existing entry last.tempo = event.tempo; } else { this.tempoMap.push({ tick: event.tick, tempo: event.tempo }); } }, this); return this; } /** * Converts a tick range to seconds using the tempo map. * @param {number} startTick * @param {number} endTick * @return {number} */ }, { key: "ticksToSeconds", value: function ticksToSeconds(startTick, endTick) { var seconds = 0; var currentTick = startTick; for (var i = 0; i < this.tempoMap.length; i++) { var entry = this.tempoMap[i]; var nextTick = i + 1 < this.tempoMap.length ? this.tempoMap[i + 1].tick : endTick; // Skip entries entirely before our start if (nextTick <= startTick) continue; // Clamp segment to our range var segStart = Math.max(entry.tick, startTick); var segEnd = Math.min(nextTick, endTick); if (segStart >= endTick) break; var segmentTicks = segEnd - segStart; seconds += segmentTicks / this.division / entry.tempo * 60; currentTick = segEnd; } // Handle remaining ticks after last tempo change if (currentTick < endTick) { var lastEntry = this.tempoMap[this.tempoMap.length - 1]; seconds += (endTick - currentTick) / this.division / lastEntry.tempo * 60; } return seconds; } /** * Converts seconds to a tick position using the tempo map. * @param {number} seconds * @return {number} */ }, { key: "secondsToTicks", value: function secondsToTicks(seconds) { var remainingSeconds = seconds; var currentTick = 0; for (var i = 0; i < this.tempoMap.length; i++) { var entry = this.tempoMap[i]; var nextTick = i + 1 < this.tempoMap.length ? this.tempoMap[i + 1].tick : Infinity; var segmentTicks = nextTick - entry.tick; var segmentSeconds = segmentTicks / this.division / entry.tempo * 60; if (remainingSeconds <= segmentSeconds) { // Target is within this segment currentTick = entry.tick + Math.round(remainingSeconds / 60 * entry.tempo * this.division); return currentTick; } remainingSeconds -= segmentSeconds; currentTick = nextTick; } // Should not reach here, but return totalTicks as fallback return this.totalTicks; } /** * Gets song duration in seconds. * @return {number} */ }, { key: "getSongTime", value: function getSongTime() { return this.ticksToSeconds(0, this.totalTicks); } /** * Gets remaining number of seconds in playback. * @return {number} */ }, { key: "getSongTimeRemaining", value: function getSongTimeRemaining() { return Math.round(this.ticksToSeconds(this.getCurrentTick(), this.totalTicks)); } /** * Gets remaining percent of playback. * @return {number} */ }, { key: "getSongPercentRemaining", value: function getSongPercentRemaining() { return Math.round(this.getSongTimeRemaining() / this.getSongTime() * 100); } /** * Number of bytes processed in the loaded MIDI file. * @return {number} */ }, { key: "bytesProcessed", value: function bytesProcessed() { return Constants.HEADER_CHUNK_LENGTH + this.tracks.length * 8 + this.tracks.reduce(function (a, b) { return { pointer: a.pointer + b.pointer }; }, { pointer: 0 }).pointer; } /** * Number of events played up to this point. * @return {number} */ }, { key: "eventsPlayed", value: function eventsPlayed() { return this.tracks.reduce(function (a, b) { return { eventIndex: a.eventIndex + b.eventIndex }; }, { eventIndex: 0 }).eventIndex; } /** * Determines if the player pointer has reached the end of the loaded MIDI file. * Used in two ways: * 1. If playing result is based on loaded JSON events. * 2. If parsing (dryRun) it's based on the actual buffer length vs bytes processed. * @return {boolean} */ }, { key: "endOfFile", value: function endOfFile() { if (this.isPlaying()) { return this.totalTicks - this.tick <= 0; } return this.bytesProcessed() >= this.midiChunksByteLength; //this.buffer.length; } /** * Gets the current tick number in playback. * @return {number} */ }, { key: "getCurrentTick", value: function getCurrentTick() { if (!this.startTime) return this.startTick; var elapsedSeconds = (new Date().getTime() - this.startTime) / 1000; var startSeconds = this.ticksToSeconds(0, this.startTick); return this.secondsToTicks(startSeconds + elapsedSeconds); } /** * Sends MIDI event out to listener. * @param {object} * @return {Player} */ }, { key: "emitEvent", value: function emitEvent(event) { this.triggerPlayerEvent('midiEvent', event); return this; } /** * Subscribes events to listeners * @param {string} - Name of event to subscribe to. * @param {function} - Callback to fire when event is broadcast. * @return {Player} */ }, { key: "on", value: function on(playerEvent, fn) { if (!this.eventListeners.hasOwnProperty(playerEvent)) this.eventListeners[playerEvent] = []; this.eventListeners[playerEvent].push(fn); return this; } /** * Broadcasts event to trigger subscribed callbacks. * @param {string} - Name of event. * @param {object} - Data to be passed to subscriber callback. * @return {Player} */ }, { key: "triggerPlayerEvent", value: function triggerPlayerEvent(playerEvent, data) { if (this.eventListeners.hasOwnProperty(playerEvent)) this.eventListeners[playerEvent].forEach(function (fn) { return fn(data || {}); }); return this; } }]); return Player; }(); var index = { Player: Player, Utils: Utils, Constants: Constants }; return index; })();