track.js

import {Constants} from './constants';
import {Utils} from './utils';

/**
 * Class representing a track.  Contains methods for parsing events and keeping track of pointer.
 */
class Track	{
	constructor(index, data) {
		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
		const 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}
	 */
	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}
	 */
	enable() {
		this.enabled = true;
		return this;
	}

	/**
	 * Sets this track to be disabled during playback.
	 * @return {Track}
	 */
	disable() {
		this.enabled = false;
		return this;
	}

	/**
	 * Sets the track event index to the nearest event to the given tick.
	 * @param {number} tick
	 * @return {Track}
	 */
	setEventIndexByTick(tick) {
		tick = tick || 0;

		for (var i in this.events) {
			if (this.events[i].tick >= tick) {
				this.eventIndex = i;
				return this;
			}
		}
	}

	/**
	 * Gets byte located at pointer position.
	 * @return {number}
	 */
	getCurrentByte() {
		return this.data[this.pointer];
	}

	/**
	 * Gets count of delta bytes and current pointer position.
	 * @return {number}
	 */
	getDeltaByteCount() {
		return Utils.getVarIntLength(this.data.subarray(this.pointer));
	}

	/**
	 * Get delta value at current pointer position.
	 * @return {number}
	 */
	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.
	 */
	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)) {
				let 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.
			if (this.events[this.eventIndex] && this.events[this.eventIndex].tick <= currentTick) {
				this.eventIndex++;
				if (this.enabled) return this.events[this.eventIndex - 1];
			}
		}

		return null;
	}

	/**
	 * Get string data from event.
	 * @param {number} eventStartIndex
	 * @return {string}
	 */
	getStringData(eventStartIndex) {
		const varIntLength = Utils.getVarIntLength(this.data.subarray(eventStartIndex + 2));
		const varIntValue = Utils.readVarInt(this.data.subarray(eventStartIndex + 2, eventStartIndex + 2 + varIntLength));
		const 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}
	 */
	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';
					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);

					if (eventJson.data[0] >= 0) {
						eventJson.keySignature = Constants.CIRCLE_OF_FIFTHS[eventJson.data[0]];

					} else if (eventJson.data[0] < 0) {
						eventJson.keySignature = Constants.CIRCLE_OF_FOURTHS[Math.abs(eventJson.data[0])];
					}

					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;
			}

			const varIntLength = Utils.getVarIntLength(this.data.subarray(eventStartIndex + 2));
			const length = Utils.readVarInt(this.data.subarray(eventStartIndex + 2, eventStartIndex + 2 + varIntLength));

			//console.log(eventJson);
			this.pointer += deltaByteCount + 3 + length;

			//console.log(eventJson);

		} else if (this.data[eventStartIndex] === 0xf0) {
			// Sysex
			eventJson.name = 'Sysex';
			const varQuantityByteLength = Utils.getVarIntLength(this.data.subarray(eventStartIndex + 1));
			const 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)';

			const varQuantityByteLength = Utils.getVarIntLength(this.data.subarray(eventStartIndex + 1));
			const 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 + 1]];
					eventJson.pressure = event[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 + 1];
					eventJson.value = this.data[eventStartIndex + 2];
					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;
					this.pointer += deltaByteCount + 2;

				} else {
					throw `Unknown event (running): ${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 = event[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;
					this.pointer += deltaByteCount + 3;

				} else {
					throw `Unknown event: ${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}
	 */
	endOfTrack() {
		if (this.data[this.pointer + 1] == 0xff && this.data[this.pointer + 2] == 0x2f && this.data[this.pointer + 3] == 0x00) {
			return true;
		}

		return false;
	}
}

export {Track};