UNPKG

midi-player-js

Version:

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

579 lines (523 loc) 26 kB
var assert = require('assert'); var sinon = require('sinon'); var MidiPlayer = require('..'); var zelda = 'data:audio/midi;base64,TVRoZAAAAAYAAQACAIBNVHJrAAADoQDAAQCQRn+CAIBGfwCQRn9AgEZ/AJBBf0CAQX8AkEF/QIBBfwCQRn9AgEZ/AJBEfyCARH8AkEJ/IIBCfwCQRH+DAIBEf0CQRn+CAIBGfwCQRn9AgEZ/AJBBf0CAQX8AkEF/QIBBfwCQRn9AgEZ/AJBFfyCARX8AkEN/IIBDfwCQRX+BQIBFf4IAkEUBhACARQGEAJBGf4EAgEZ/AJBBf4FAgEF/AJBGf0CARn8AkEZ/IIBGfwCQSH8ggEh/AJBKfyCASn8AkEt/IIBLfwCQTX+CAIBNf0CQTX9AgE1/AJBNf0CATX8AkE5/IIBOfwCQUH8ggFB/AJBSf4IAgFJ/QJBSf0CAUn8AkFJ/QIBSfwCQUH8ggFB/AJBOfyCATn8AkFB/YIBQfwCQTn8ggE5/AJBNf4IAgE1/AJBNf4EAgE1/AJBLf2CAS38AkE1/IIBNfwCQTn+CAIBOfwCQTX9AgE1/AJBLf0CAS38AkEl/YIBJfwCQS38ggEt/AJBNf4IAgE1/AJBLf0CAS38AkEl/QIBJfwCQSH9ggEh/AJBKfyCASn8AkEx/ggCATH8AkE9/gQCAT38AkE1/QIBNfwCQQX8ggEF/AJBBfyCAQX8AkEF/QIBBfwCQQX8ggEF/AJBBfyCAQX8AkEF/QIBBfwCQQX8ggEF/AJBBfyCAQX8AkEF/QIBBfwCQQX9AgEF/AJBGf4EAgEZ/AJBBf4FAgEF/AJBGf0CARn8AkEZ/IIBGfwCQSH8ggEh/AJBKfyCASn8AkEt/IIBLfwCQTX+CAIBNf0CQTX9AgE1/AJBNf0CATX8AkE5/IIBOfwCQUH8ggFB/AJBSf4MAgFJ/AJBVf4EAgFV/AJBUf4EAgFR/AJBRf4IAgFF/AJBNf4EAgE1/AJBOf4MAgE5/AJBSf4EAgFJ/AJBRf4EAgFF/AJBNf4IAgE1/AJBNf4EAgE1/AJBOf4MAgE5/AJBSf4EAgFJ/AJBRf4EAgFF/AJBNf4IAgE1/AJBKf4EAgEp/AJBLf4MAgEt/AJBOf4EAgE5/AJBNf4EAgE1/AJBJf4IAgEl/AJBGf4EAgEZ/AJBIf2CASH8AkEp/IIBKfwCQTH+CAIBMfwCQT3+BAIBPfwCQTX9AgE1/AJBBfyCAQX8AkEF/IIBBfwCQQX9AgEF/AJBBfyCAQX8AkEF/IIBBfwCQQX9AgEF/AJBBfyCAQX8AkEF/IIBBfwCQQX9AgEF/AJBBf0CAQX8A/y8ATVRyawAACUMAwAEAkCpAgQCAKkAAkDVAgQCANUAAkDpAggCAOkAAkClAgQCAKUAAkDNAgQCAM0AAkDhAggCAOEAAkCdAgQCAJ0AAkDFAgQCAMUAAkDpAggCAOkAAkCpAgQCAKkAAkDVAgQCANUAAkDpAggCAOkAAkC5AQIAuQACQLkAggC5AAJApQCCAKUAAkC5AQIAuQACQLkAggC5AAJApQCCAKUAAkC5AQIAuQACQLkAggC5AAJApQCCAKUAAkC5AIIAuQACQKUAggClAAJAuQCCALkAAkClAIIApQACQLkBAgC5AAJAuQCCALkAAkClAIIApQACQLkBAgC5AAJAuQCCALkAAkClAIIApQACQLkBAgC5AAJAuQCCALkAAkClAIIApQACQLkAggC5AAJApQCCAKUAAkC5AIIAuQACQKUAggClAAJAuQECALkAAkC5AIIAuQACQKUAggClAAJAuQECALkAAkC5AIIAuQACQKUAggClAAJAuQECALkAAkC5AIIAuQACQKUAggClAAJAuQCCALkAAkClAIIApQACQLkAggC5AAJApQCCAKUAAkCxAQIAsQACQLEAggCxAAJAnQCCAJ0AAkCxAQIAsQACQLEAggCxAAJAnQCCAJ0AAkCxAQIAsQACQLEAggCxAAJAnQCCAJ0AAkCxAIIAsQACQJ0AggCdAAJAsQCCALEAAkCdAIIAnQACQKkBAgCpAAJAqQCCAKkAAkCVAIIAlQACQKkBAgCpAAJAqQCCAKkAAkCVAIIAlQACQKkBAgCpAAJAqQCCAKkAAkCVAIIAlQACQKkAggCpAAJAlQCCAJUAAkCpAIIAqQACQJUAggCVAAJAxQECAMUAAkDFAIIAxQACQLEAggCxAAJAxQECAMUAAkDFAIIAxQACQLEAggCxAAJAxQECAMUAAkDFAIIAxQACQLEAggCxAAJAxQCCAMUAAkCxAIIAsQACQMUAggDFAAJAsQCCALEAAkC9AQIAvQACQL0AggC9AAJAqQCCAKkAAkC9AQIAvQACQL0AggC9AAJAqQCCAKkAAkC9AQIAvQACQL0AggC9AAJAqQCCAKkAAkC9AIIAvQACQKkAggCpAAJAvQCCAL0AAkCpAIIAqQACQLkBAgC5AAJAuQCCALkAAkClAIIApQACQLkBAgC5AAJAuQCCALkAAkClAIIApQACQLkBAgC5AAJAuQCCALkAAkClAIIApQACQLkAggC5AAJApQCCAKUAAkC5AIIAuQACQKUAggClAAJAwQECAMEAAkDBAIIAwQACQK0AggCtAAJAwQECAMEAAkDBAIIAwQACQK0AggCtAAJAwQECAMEAAkDBAIIAwQACQK0AggCtAAJAwQCCAMEAAkCtAIIArQACQMEAggDBAAJArQCCAK0AAkClAQIApQACQOUAggDlAAJA5QCCAOUAAkDhAQIA4QACQOEAggDhAAJA4QCCAOEAAkDdAQIA3QACQN0AggDdAAJA3QCCAN0AAkDZAQIA2QACQKUBAgClAAJAuQECALkAAkC5AIIAuQACQKUAggClAAJAuQECALkAAkC5AIIAuQACQKUAggClAAJAuQECALkAAkC5AIIAuQACQKUAggClAAJAuQCCALkAAkClAIIApQACQLkAggC5AAJApQCCAKUAAkCxAQIAsQACQLEAggCxAAJAnQCCAJ0AAkCxAQIAsQACQLEAggCxAAJAnQCCAJ0AAkCxAQIAsQACQLEAggCxAAJAnQCCAJ0AAkCxAIIAsQACQJ0AggCdAAJAsQCCALEAAkCdAIIAnQACQKkBAgCpAAJAqQCCAKkAAkCpAIIAqQACQKkBAgCpAAJAqQCCAKkAAkCpAIIAqQACQKkBAgCpAAJAqQCCAKkAAkCpAIIAqQACQKkAggCpAAJAqQCCAKkAAkCpAIIAqQACQKkAggCpAAJApQECAKUAAkClAIIApQACQKUAggClAAJApQECAKUAAkClAIIApQACQKUAggClAAJApQECAKUAAkClAIIApQACQKUAggClAAJApQCCAKUAAkClAIIApQACQKUAggClAAJApQCCAKUAAkChAQIAoQACQKEAggChAAJAoQCCAKEAAkChAQIAoQACQKEAggChAAJAoQCCAKEAAkChAQIAoQACQKEAggChAAJAoQCCAKEAAkChAIIAoQACQKEAggChAAJAoQCCAKEAAkChAIIAoQACQKUBAgClAAJApQCCAKUAAkClAIIApQACQKUBAgClAAJApQCCAKUAAkClAIIApQACQKUBAgClAAJApQCCAKUAAkClAIIApQACQKUAggClAAJApQCCAKUAAkClAIIApQACQKUAggClAAJAoQECAKEAAkChAIIAoQACQKEAggChAAJAoQECAKEAAkChAIIAoQACQKEAggChAAJAoQECAKEAAkChAIIAoQACQKEAggChAAJAoQCCAKEAAkChAIIAoQACQKEAggChAAJAoQCCAKEAAkClAQIApQACQKUAggClAAJApQCCAKUAAkClAQIApQACQKUAggClAAJApQCCAKUAAkClAQIApQACQKUAggClAAJApQCCAKUAAkClAIIApQACQKUAggClAAJApQCCAKUAAkClAIIApQACQL0BAgC9AAJAvQCCAL0AAkC9AIIAvQACQL0BAgC9AAJAvQCCAL0AAkC9AIIAvQACQL0BAgC9AAJAvQCCAL0AAkC9AIIAvQACQL0AggC9AAJAvQCCAL0AAkC9AIIAvQACQL0AggC9AAJAuQECALkAAkC5AIIAuQACQLkAggC5AAJAuQECALkAAkC5AIIAuQACQLkAggC5AAJAuQECALkAAkC5AIIAuQACQLkAggC5AAJAuQCCALkAAkC5AIIAuQACQLkAggC5AAJAuQCCALkAAkDBAQIAwQACQMEAggDBAAJAwQCCAMEAAkDBAQIAwQACQMEAggDBAAJAwQCCAMEAAkDBAQIAwQACQMEAggDBAAJAwQCCAMEAAkDBAIIAwQACQMEAggDBAAJAwQCCAMEAAkDBAIIAwQACQKUBAgClAAJA5QCCAOUAAkDlAIIA5QACQOEBAgDhAAJA4QCCAOEAAkDhAIIA4QACQN0BAgDdAAJA3QCCAN0AAkDdAIIA3QACQNkBAgDZAAJApQECAKUAA/y8A'; // Helper to build a minimal single-track MIDI file (format 0, division 96) function buildMidi(trackBytes) { var header = [ 0x4D, 0x54, 0x68, 0x64, // MThd 0x00, 0x00, 0x00, 0x06, // Header length 0x00, 0x00, // Format 0 0x00, 0x01, // 1 track 0x00, 0x60, // Division = 96 0x4D, 0x54, 0x72, 0x6B, // MTrk ]; // Track length as 4 bytes var len = trackBytes.length; header.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF); return new Uint8Array(header.concat(trackBytes)); } // End of Track event (delta=0) var EOT = [0x00, 0xFF, 0x2F, 0x00]; describe('MidiPlayerJS', function() { describe('#Utils', function () { describe('#byteToHex()', function () { it('should return hex value from byte.', function () { assert.equal('7f', MidiPlayer.Utils.byteToHex(127)); }); }); describe('#bytesToHex()', function () { it('should return hex value from array of bytes.', function () { assert.equal('7f3a', MidiPlayer.Utils.bytesToHex([127, 58])); }); }); describe('#hexToNumber()', function () { it('should return base 10 value from hex string.', function () { assert.equal(254, MidiPlayer.Utils.hexToNumber('fe')); }); }); describe('#bytesToNumber()', function () { it('should return base 10 value from array of bytes.', function () { assert.equal(923139, MidiPlayer.Utils.bytesToNumber([14, 22, 3])); }); }); describe('#bytesToLetters()', function () { it('should return string from array of bytes.', function () { assert.equal('Mthd', MidiPlayer.Utils.bytesToLetters([77, 116, 104, 100])); }); }); describe('#decToBinary()', function () { it('should return binary value from decimal.', function () { assert.equal('10110', MidiPlayer.Utils.decToBinary(22)); }); }); describe('#readVarInt()', function () { it('should return binary value from decimal.', function () { assert.equal(42, MidiPlayer.Utils.readVarInt([128, 42])); }); }); }); describe('#Player', function () { describe('#loadFile()', function () { it('should load file correctly.', function () { var Player = new MidiPlayer.Player(); Player.loadFile('demo/midi/zelda.mid'); assert.equal(3330, Player.buffer.length); assert.equal(2, Player.tracks.length); }); it('should load 500_Miles.mid file correctly.', function () { var Player = new MidiPlayer.Player(); Player.loadFile('demo/midi/500_Miles.mid'); //assert.equal(3330, Player.buffer.length); //assert.equal(2, Player.tracks.length); }); it('should load O-Zone_-_Dragostea_Din_Tei.mid file correctly.', function () { var Player = new MidiPlayer.Player(); Player.loadFile('demo/midi/O-Zone_-_Dragostea_Din_Tei.mid'); //assert.equal(3330, Player.buffer.length); //assert.equal(2, Player.tracks.length); }); }); describe('#loadDataUri()', function () { it('should load data correctly from data uri.', function () { var Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); assert.equal(3330, Player.buffer.length); assert.equal(2, Player.tracks.length); }); }); describe('#getCurrentTime', function () { let Player; beforeEach(function() { this.clock = sinon.useFakeTimers(); this.clock.tick(5000); //set start time Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); }); afterEach(function() { this.clock = sinon.restore(); }); it('should return 0 after init', function () { assert.equal(Player.getCurrentTick(), 0); }); it('should return last known tick after pause', function () { const skipTicks = 123456; Player.skipToTick(skipTicks); Player.play(); this.clock.tick(6); //run 1 tick Player.pause(); assert.equal(Player.getCurrentTick(), skipTicks + 1); }); it('should return 0 after stop', function () { const skipTicks = 123456; Player.skipToTick(skipTicks); Player.play(); this.clock.tick(6); //run 1 tick Player.stop(); assert.equal(Player.getCurrentTick(), 0); }) }); describe('#getCurrentTick', function () { it('should return correct tick when skipping without playing.', function () { const skipTicks = 500; const Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); Player.skipToTick(skipTicks); assert.equal(Player.getCurrentTick(), skipTicks); }); it('should return accurate tick across a tempo change boundary', function () { this.clock = sinon.useFakeTimers(); this.clock.tick(5000); // set start time // Build a format-1 MIDI with 100 BPM at tick 0, 200 BPM at tick 480 (division=480) var midi = new Uint8Array([ 0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, // Format 1 0x00, 0x02, // 2 tracks 0x01, 0xE0, // Division = 480 // Track 1 (tempo track) 0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x13, // 19 bytes 0x00, 0xFF, 0x51, 0x03, 0x09, 0x27, 0xC0, // Set Tempo 100 BPM at tick 0 0x83, 0x60, 0xFF, 0x51, 0x03, 0x04, 0x93, 0xE0, // Set Tempo 200 BPM at tick 480 0x00, 0xFF, 0x2F, 0x00, // Track 2 (note track with enough ticks) 0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x09, // 9 bytes 0x00, 0x90, 0x3C, 0x7F, // Note On C4 0x87, 0x40, 0xFF, 0x2F, 0x00, // delta=960, End of Track ]); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); // At 100 BPM with division=480: 480 ticks = 0.6s // After tick 480, tempo is 200 BPM: 480 ticks = 0.3s // Total song = 960 ticks over 0.9s Player.play(); // Advance 600ms (should be right at tick 480) this.clock.tick(600); var tickAt600ms = Player.getCurrentTick(); assert.ok(Math.abs(tickAt600ms - 480) <= 1, 'At 600ms should be ~480 ticks, got ' + tickAt600ms); // Advance another 150ms (total 750ms) - in 200 BPM zone, 150ms = 240 ticks this.clock.tick(150); var tickAt750ms = Player.getCurrentTick(); assert.ok(Math.abs(tickAt750ms - 720) <= 1, 'At 750ms should be ~720 ticks, got ' + tickAt750ms); Player.stop(); sinon.restore(); }); }); describe('#playLoop tempo handling', function () { it('should not call pause().play() on Set Tempo events during playback', function () { this.clock = sinon.useFakeTimers(); this.clock.tick(5000); // Build MIDI with a Set Tempo event at tick 0 var midi = new Uint8Array([ 0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x02, 0x01, 0xE0, // Division = 480 // Track 1 (tempo track) 0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x0B, // 11 bytes 0x00, 0xFF, 0x51, 0x03, 0x09, 0x27, 0xC0, // Set Tempo 100 BPM at tick 0 0x00, 0xFF, 0x2F, 0x00, // Track 2 0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x09, // 9 bytes 0x00, 0x90, 0x3C, 0x7F, 0x83, 0x60, 0xFF, 0x2F, 0x00, ]); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); Player.play(); var pauseSpy = sinon.spy(Player, 'pause'); // Tick forward to process events this.clock.tick(100); // pause should not have been called by the playLoop for tempo changes assert.equal(pauseSpy.callCount, 0, 'pause() should not be called on Set Tempo events'); pauseSpy.restore(); Player.stop(); sinon.restore(); }); }); describe('#tempoMap', function () { it('should build a tempo map with at least one entry', function () { var Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); assert.ok(Player.tempoMap.length >= 1); assert.equal(Player.tempoMap[0].tick, 0); }); it('should have a sorted tempo map', function () { var Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); for (var i = 1; i < Player.tempoMap.length; i++) { assert.ok(Player.tempoMap[i].tick >= Player.tempoMap[i - 1].tick); } }); it('getSongTime should equal ticksToSeconds(0, totalTicks)', function () { var Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); assert.equal(Player.getSongTime(), Player.ticksToSeconds(0, Player.totalTicks)); }); it('ticksToSeconds and secondsToTicks should be inverse operations', function () { var Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); var songTime = Player.getSongTime(); var halfTime = songTime / 2; var tick = Player.secondsToTicks(halfTime); var seconds = Player.ticksToSeconds(0, tick); // Allow 1 second tolerance due to rounding assert.ok(Math.abs(seconds - halfTime) < 1); }); it('skipToSeconds should navigate to correct tick', function () { var Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); var targetSeconds = Player.getSongTime() / 2; Player.skipToSeconds(targetSeconds); var expectedTick = Player.secondsToTicks(targetSeconds); assert.equal(Player.startTick, expectedTick); }); }); describe('#getSongTimeRemaining', function () { let Player; beforeEach(function () { this.clock = sinon.useFakeTimers(); this.clock.tick(5000); //set start time Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); }); afterEach(function () { this.clock = sinon.restore(); }); it('should return totalTime after stop', function () { const skipTicks = 123456; Player.skipToTick(skipTicks); Player.play(); this.clock.tick(6); //run 1 tick Player.stop(); assert.equal(Player.getSongTimeRemaining(), Player.getSongTime()); }) }); }); describe('#Event Parsing', function () { describe('Pitch Bend', function () { it('should parse pitch bend value in non-running-status', function () { // Pitch Bend ch1: status=0xE0, LSB=0x00, MSB=0x40 => value = (0x40 << 7) | 0x00 = 8192 var midi = buildMidi([0x00, 0xE0, 0x00, 0x40].concat(EOT)); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); var events = Player.events[0]; var pb = events.find(function(e) { return e.name === 'Pitch Bend'; }); assert.ok(pb, 'Pitch Bend event should exist'); assert.equal(pb.value, 8192); assert.equal(pb.channel, 1); }); it('should parse pitch bend value in running status', function () { // First: normal Pitch Bend, then running status with different value var midi = buildMidi([ 0x00, 0xE0, 0x00, 0x40, // Pitch Bend ch1, value=8192 0x00, 0x7F, 0x7F, // Running status: LSB=0x7F, MSB=0x7F => (0x7F << 7) | 0x7F = 16383 ].concat(EOT)); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); var events = Player.events[0]; var pbEvents = events.filter(function(e) { return e.name === 'Pitch Bend'; }); assert.equal(pbEvents.length, 2); assert.equal(pbEvents[0].value, 8192); assert.equal(pbEvents[1].value, 16383); assert.equal(pbEvents[1].running, true); }); }); describe('Key Signature', function () { it('should parse flat key signatures correctly', function () { // Key Signature: FF 59 02 FE 00 => sf=-2 (Bb), mi=0 (Major) var midi = buildMidi([0x00, 0xFF, 0x59, 0x02, 0xFE, 0x00].concat(EOT)); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); var events = Player.events[0]; var ks = events.find(function(e) { return e.name === 'Key Signature'; }); assert.ok(ks, 'Key Signature event should exist'); assert.equal(ks.keySignature, 'Bb Major'); }); it('should parse sharp key signatures correctly', function () { // Key Signature: FF 59 02 02 00 => sf=2 (D), mi=0 (Major) var midi = buildMidi([0x00, 0xFF, 0x59, 0x02, 0x02, 0x00].concat(EOT)); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); var events = Player.events[0]; var ks = events.find(function(e) { return e.name === 'Key Signature'; }); assert.ok(ks, 'Key Signature event should exist'); assert.equal(ks.keySignature, 'D Major'); }); it('should parse minor key signatures correctly', function () { // Key Signature: FF 59 02 FC 01 => sf=-4 (Ab), mi=1 (Minor) var midi = buildMidi([0x00, 0xFF, 0x59, 0x02, 0xFC, 0x01].concat(EOT)); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); var events = Player.events[0]; var ks = events.find(function(e) { return e.name === 'Key Signature'; }); assert.ok(ks, 'Key Signature event should exist'); assert.equal(ks.keySignature, 'Ab Minor'); }); }); describe('Controller Change (running status)', function () { it('should parse correct controller number and value under running status', function () { var midi = buildMidi([ // Controller Change ch1: status=0xB0, controller=7 (volume), value=127 0x00, 0xB0, 0x07, 0x7F, // Running status: controller=10 (pan), value=64 0x00, 0x0A, 0x40, ].concat(EOT)); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); var events = Player.events[0]; var ccEvents = events.filter(function(e) { return e.name === 'Controller Change'; }); assert.equal(ccEvents.length, 2); // First event (normal status) assert.equal(ccEvents[0].number, 7); assert.equal(ccEvents[0].value, 127); assert.equal(ccEvents[0].channel, 1); // Second event (running status) assert.equal(ccEvents[1].number, 10); assert.equal(ccEvents[1].value, 64); assert.equal(ccEvents[1].channel, 1); assert.equal(ccEvents[1].running, true); }); }); describe('Marker meta event', function () { it('should include string data on Marker events', function () { // Marker: FF 06 06 "Chorus" var midi = buildMidi([ 0x00, 0xFF, 0x06, 0x06, 0x43, 0x68, 0x6F, 0x72, 0x75, 0x73, // "Chorus" ].concat(EOT)); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); var events = Player.events[0]; var marker = events.find(function(e) { return e.name === 'Marker'; }); assert.ok(marker, 'Marker event should exist'); assert.equal(marker.string, 'Chorus'); }); }); describe('Meta event with multi-byte VarInt length', function () { it('should correctly parse text events longer than 127 bytes', function () { // Text Event with 200 bytes of 'A': FF 01 81 48 [200x 0x41] var textData = []; for (var i = 0; i < 200; i++) textData.push(0x41); var trackBytes = [0x00, 0xFF, 0x01, 0x81, 0x48].concat(textData).concat(EOT); var midi = buildMidi(trackBytes); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); var events = Player.events[0]; var textEvent = events.find(function(e) { return e.name === 'Text Event'; }); assert.ok(textEvent, 'Text Event should exist'); assert.equal(textEvent.string.length, 200); // Should also find End of Track (proves pointer advanced correctly) var eot = events.find(function(e) { return e.name === 'End of Track'; }); assert.ok(eot, 'End of Track should be parsed after long text event'); }); }); describe('setEventIndexByTick', function () { it('should set eventIndex as a number, not a string', function () { var Player = new MidiPlayer.Player(); Player.loadDataUri(zelda); Player.tracks[0].setEventIndexByTick(500); assert.strictEqual(typeof Player.tracks[0].eventIndex, 'number'); }); }); }); describe('#skipToTick state events', function () { it('should emit Program Change when skipping past one', function () { // Program Change ch1 at tick 0: status=0xC0, program=5 var midi = buildMidi([ 0x00, 0xC0, 0x05, // Program Change ch1, program 5 at tick 0 0x60, 0x90, 0x3C, 0x7F, // Note On at tick 96 ].concat(EOT)); var events = []; var Player = new MidiPlayer.Player(function(e) { events.push(e); }); Player.loadArrayBuffer(midi.buffer); Player.skipToTick(96); var pc = events.find(function(e) { return e.name === 'Program Change'; }); assert.ok(pc, 'Program Change should be emitted'); assert.equal(pc.value, 5); assert.equal(pc.channel, 1); }); it('should emit last Controller Change value per channel+number', function () { // Two CC events on ch1, controller 7: first value=100, then value=80 var midi = buildMidi([ 0x00, 0xB0, 0x07, 0x64, // CC ch1, ctrl 7, val 100 at tick 0 0x30, 0xB0, 0x07, 0x50, // CC ch1, ctrl 7, val 80 at tick 48 0x30, 0x90, 0x3C, 0x7F, // Note On at tick 96 ].concat(EOT)); var events = []; var Player = new MidiPlayer.Player(function(e) { events.push(e); }); Player.loadArrayBuffer(midi.buffer); Player.skipToTick(96); var ccEvents = events.filter(function(e) { return e.name === 'Controller Change' && e.number === 7; }); assert.equal(ccEvents.length, 1, 'Should only emit last CC value'); assert.equal(ccEvents[0].value, 80); }); it('should emit Pitch Bend when skipping past one', function () { // Pitch Bend ch1: status=0xE0, LSB=0x00, MSB=0x60 => value = (0x60 << 7) | 0x00 = 12288 var midi = buildMidi([ 0x00, 0xE0, 0x00, 0x60, // Pitch Bend ch1 at tick 0 0x60, 0x90, 0x3C, 0x7F, // Note On at tick 96 ].concat(EOT)); var events = []; var Player = new MidiPlayer.Player(function(e) { events.push(e); }); Player.loadArrayBuffer(midi.buffer); Player.skipToTick(96); var pb = events.find(function(e) { return e.name === 'Pitch Bend'; }); assert.ok(pb, 'Pitch Bend should be emitted'); assert.equal(pb.value, 12288); }); it('should NOT emit Note On/Off events during skip', function () { var midi = buildMidi([ 0x00, 0x90, 0x3C, 0x7F, // Note On at tick 0 0x30, 0x80, 0x3C, 0x00, // Note Off at tick 48 0x30, 0xC0, 0x05, // Program Change at tick 96 ].concat(EOT)); var events = []; var Player = new MidiPlayer.Player(function(e) { events.push(e); }); Player.loadArrayBuffer(midi.buffer); Player.skipToTick(100); var noteEvents = events.filter(function(e) { return e.name === 'Note on' || e.name === 'Note off'; }); assert.equal(noteEvents.length, 0, 'Note On/Off should not be emitted during skip'); var pc = events.find(function(e) { return e.name === 'Program Change'; }); assert.ok(pc, 'Program Change should still be emitted'); }); it('should emit state events when using skipToPercent()', function () { // Program Change at tick 0, total ticks ~ 96 (from Note On delta) var midi = buildMidi([ 0x00, 0xC0, 0x0A, // Program Change ch1, program 10 at tick 0 0x60, 0x90, 0x3C, 0x7F, // Note On at tick 96 ].concat(EOT)); var events = []; var Player = new MidiPlayer.Player(function(e) { events.push(e); }); Player.loadArrayBuffer(midi.buffer); Player.skipToPercent(100); var pc = events.find(function(e) { return e.name === 'Program Change'; }); assert.ok(pc, 'Program Change should be emitted via skipToPercent'); assert.equal(pc.value, 10); }); }); describe('#Tempo Map', function () { it('should seed tempo map with default 120 BPM, not last-seen tempo', function () { // Format 1 MIDI with Set Tempo at tick 0 (100 BPM) and tick 480 (200 BPM) var midi = new Uint8Array([ // MThd 0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, // Format 1 0x00, 0x02, // 2 tracks 0x01, 0xE0, // Division = 480 // Track 1 (tempo track) 0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x13, // 19 bytes 0x00, 0xFF, 0x51, 0x03, 0x09, 0x27, 0xC0, // Set Tempo 100 BPM at tick 0 0x83, 0x60, 0xFF, 0x51, 0x03, 0x04, 0x93, 0xE0, // Set Tempo 200 BPM at tick 480 0x00, 0xFF, 0x2F, 0x00, // Track 2 (empty) 0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x04, 0x00, 0xFF, 0x2F, 0x00, ]); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); // Tempo map should have default 120 at tick 0, overridden by 100 BPM at tick 0, then 200 at tick 480 assert.equal(Player.tempoMap.length, 2); assert.equal(Player.tempoMap[0].tick, 0); assert.equal(Player.tempoMap[0].tempo, 100); assert.equal(Player.tempoMap[1].tick, 480); assert.equal(Player.tempoMap[1].tempo, 200); }); it('should use default 120 BPM at tick 0 when no Set Tempo at tick 0 exists', function () { // Format 1 MIDI with Set Tempo only at tick 480 (200 BPM) var midi = new Uint8Array([ // MThd 0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, // Format 1 0x00, 0x02, // 2 tracks 0x01, 0xE0, // Division = 480 // Track 1 (tempo track) 0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x0C, // 12 bytes 0x83, 0x60, 0xFF, 0x51, 0x03, 0x04, 0x93, 0xE0, // Set Tempo 200 BPM at tick 480 0x00, 0xFF, 0x2F, 0x00, // Track 2 (empty) 0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x04, 0x00, 0xFF, 0x2F, 0x00, ]); var Player = new MidiPlayer.Player(); Player.loadArrayBuffer(midi.buffer); // Should have default 120 at tick 0, then 200 at tick 480 assert.equal(Player.tempoMap.length, 2); assert.equal(Player.tempoMap[0].tick, 0); assert.equal(Player.tempoMap[0].tempo, 120); assert.equal(Player.tempoMap[1].tick, 480); assert.equal(Player.tempoMap[1].tempo, 200); }); }); });