diff --git a/src/js/smf.js b/src/js/smf.js index 2b575e0..3e44f9a 100644 --- a/src/js/smf.js +++ b/src/js/smf.js @@ -112,8 +112,15 @@ }; // TODO: This seems to work for the first example we used, but we need to verify this with a wider range of examples. + /** + * + * Parse a "raw" division byte into it a data structure. + * + * @param {number} rawDivision - An single unsigned 32-bit integer. + * @return {{type: string, resolution: string}} - The data structure that corresponds to the raw division byte. + */ youme.smf.parseDivision = function (rawDivision) { - var divisionObject = { type: "Unknown", resolution: "Unknown" }; + var divisionObject = { type: "Unknown" }; // The fifteenth bit indicates which broad scheme (FPS or ticks per quarter note) is being used. If it's set // we're using frames per second. If not, we're using ticks per quarter note. @@ -206,119 +213,10 @@ var payloadLength = metaEventLengthPayload.value; index += metaEventLengthPayload.numBytes; - var payloadData = byteArray.slice(index, index + payloadLength); + var metaEventBytes = byteArray.slice(index, index + payloadLength); index += payloadLength; - var metaEventObject = {}; - eventObject.metaEvent = metaEventObject; - - switch (metaEventType) { - // FF 00 02 Sequence Number - case 0x00: - metaEventObject.type = "sequenceNumber"; - metaEventObject.value = youme.smf.combineBytes(payloadData); - break; - - // FF 01 len text Text Event - case 0x01: - metaEventObject.type = "text"; - metaEventObject.value = String.fromCharCode.apply(null, payloadData); - break; - - // FF 02 len text Copyright Notice - case 0x02: - metaEventObject.type = "copyright"; - metaEventObject.value = String.fromCharCode.apply(null, payloadData); - break; - - // FF 03 len text Sequence/Track Name - case 0x03: - metaEventObject.type = "name"; - metaEventObject.value = String.fromCharCode.apply(null, payloadData); - break; - - // FF 04 len text Instrument Name - case 0x04: - metaEventObject.type = "instrumentName"; - metaEventObject.value = String.fromCharCode.apply(null, payloadData); - break; - - // FF 05 len text Lyric - case 0x05: - metaEventObject.type = "lyric"; - metaEventObject.value = String.fromCharCode.apply(null, payloadData); - break; - - // FF 06 len text Marker - case 0x06: - metaEventObject.type = "marker"; - metaEventObject.value = String.fromCharCode.apply(null, payloadData); - break; - - // FF 07 len text Cue Point - case 0x07: - metaEventObject.type = "cuePoint"; - metaEventObject.value = String.fromCharCode.apply(null, payloadData); - break; - - // FF 20 01 cc MIDI Channel Prefix - case 0x20: - metaEventObject.type = "channelPrefix"; - metaEventObject.value = youme.smf.combineBytes(payloadData); - break; - - // FF 2F 00 End of Track - case 0x2F: - metaEventObject.type = "endOfTrack"; - break; - - // FF 51 03 tttttt Set Tempo (in microseconds per MIDI quarter-note) - case 0x51: - metaEventObject.type = "tempo"; - // TODO: This seems nonsensical with our existing examples. Investigate. - metaEventObject.value = youme.smf.combineBytes(payloadData); - break; - - // FF 54 05 hr mn se fr ff SMPTE Offset - case 0x54: - metaEventObject.type = "smpteOffset"; - metaEventObject.hour = payloadData[0]; - metaEventObject.minute = payloadData[1]; - metaEventObject.second = payloadData[2]; - metaEventObject.frame = payloadData[3]; - metaEventObject.fractionalFrame = payloadData[4]; - break; - - // FF 58 04 nn dd cc bb Time Signature - case 0x58: - metaEventObject.type = "timeSignature"; - metaEventObject.nn = payloadData[0]; - metaEventObject.dd = payloadData[1]; - metaEventObject.cc = payloadData[2]; - metaEventObject.bb = payloadData[3]; - break; - - // FF 59 02 sf mi Key Signature - case 0x59: - // Need to read up more on key signature. - metaEventObject.type = "keySignature"; - metaEventObject.sf = payloadData[0]; - metaEventObject.mi = payloadData[1] ? "minor" : "major"; - break; - - // FF 7F len data Sequencer Specific Meta-Event - case 0x7F: - metaEventObject.type = "sequencerSpecificMetaEvent"; - var sequencerSpecificMetaEventLength = youme.smf.extractVariableLengthValue(byteArray, index); - var startPos = index + sequencerSpecificMetaEventLength.numBytes; - var endPos = startPos + sequencerSpecificMetaEventLength.value; - var sequencerSpecificMetaEventData = byteArray.slice(startPos, endPos); - metaEventObject.value = sequencerSpecificMetaEventData; - break; - default: - metaEventObject.type = "Unknown (0x" + (metaEventType).toString(16).padStart(2, 0) + ")"; - metaEventObject.value = payloadData; - } + eventObject.metaEvent = youme.smf.readMetaEvent(metaEventType, metaEventBytes); } // Handle "F0" sysex messages. else if (eventFirstByte === 0xF0) { @@ -472,5 +370,124 @@ return { value: combinedValue, numBytes: numBytes }; }; + /** + * + * Parse a single "meta event". + * + * @param {number} metaEventType - The type of meta event. + * @param {Uint8Array} metaEventBytes - The bytes that compose the event, not including those indicating the length. + * @return {{}} - An object representing the event. + */ + youme.smf.readMetaEvent = function (metaEventType, metaEventBytes) { + var metaEventObject = {}; + switch (metaEventType) { + // FF 00 02 Sequence Number + case 0x00: + metaEventObject.type = "sequenceNumber"; + metaEventObject.value = youme.smf.combineBytes(metaEventBytes); + break; + + // FF 01 len text Text Event + case 0x01: + metaEventObject.type = "text"; + metaEventObject.value = String.fromCharCode.apply(null, metaEventBytes); + break; + + // FF 02 len text Copyright Notice + case 0x02: + metaEventObject.type = "copyright"; + metaEventObject.value = String.fromCharCode.apply(null, metaEventBytes); + break; + + // FF 03 len text Sequence/Track Name + case 0x03: + metaEventObject.type = "name"; + metaEventObject.value = String.fromCharCode.apply(null, metaEventBytes); + break; + + // FF 04 len text Instrument Name + case 0x04: + metaEventObject.type = "instrumentName"; + metaEventObject.value = String.fromCharCode.apply(null, metaEventBytes); + break; + + // FF 05 len text Lyric + case 0x05: + metaEventObject.type = "lyric"; + metaEventObject.value = String.fromCharCode.apply(null, metaEventBytes); + break; + + // FF 06 len text Marker + case 0x06: + metaEventObject.type = "marker"; + metaEventObject.value = String.fromCharCode.apply(null, metaEventBytes); + break; + + // FF 07 len text Cue Point + case 0x07: + metaEventObject.type = "cuePoint"; + metaEventObject.value = String.fromCharCode.apply(null, metaEventBytes); + break; + + // FF 20 01 cc MIDI Channel Prefix + case 0x20: + metaEventObject.type = "channelPrefix"; + metaEventObject.value = youme.smf.combineBytes(metaEventBytes); + break; + + // FF 2F 00 End of Track + case 0x2F: + metaEventObject.type = "endOfTrack"; + break; + + // FF 51 03 tttttt Set Tempo (in microseconds per MIDI quarter-note) + case 0x51: + metaEventObject.type = "tempo"; + // TODO: This seems nonsensical with our existing examples. Investigate. + metaEventObject.value = youme.smf.combineBytes(metaEventBytes); + break; + + // FF 54 05 hr mn se fr ff SMPTE Offset + case 0x54: + metaEventObject.type = "smpteOffset"; + metaEventObject.hour = metaEventBytes[0]; + metaEventObject.minute = metaEventBytes[1]; + metaEventObject.second = metaEventBytes[2]; + metaEventObject.frame = metaEventBytes[3]; + metaEventObject.fractionalFrame = metaEventBytes[4]; + break; + + // FF 58 04 nn dd cc bb Time Signature + case 0x58: + metaEventObject.type = "timeSignature"; + metaEventObject.numerator = metaEventBytes[0]; + metaEventObject.denominator = metaEventBytes[1]; + metaEventObject.midiClocksPerMetronomeClick = metaEventBytes[2]; + metaEventObject.thirtySecondNotesPerMidiQuarterNote = metaEventBytes[3]; + break; + + // FF 59 02 sf mi Key Signature + case 0x59: + // Thanks to this page for clarifying how this value is encoded: + // https://www.recordingblogs.com/wiki/midi-key-signature-meta-message + metaEventObject.type = "keySignature"; + var sign = (metaEventBytes[0] & 128) ? -1 : 1; + var value = metaEventBytes[0] & 127; + metaEventObject.sf = sign * value; + metaEventObject.mi = metaEventBytes[1] ? "minor" : "major"; + break; + + // FF 7F len data Sequencer Specific Meta-Event + case 0x7F: + metaEventObject.type = "sequencerSpecificMetaEvent"; + metaEventObject.value = metaEventBytes; + break; + default: + metaEventObject.type = "Unknown (0x" + (metaEventType).toString(16).padStart(2, 0) + ")"; + metaEventObject.value = metaEventBytes; + } + return metaEventObject; + }; + // TODO: Encoding methods })(fluid, youme); diff --git a/tests/all-tests.html b/tests/all-tests.html index 5a930f4..f25bfe2 100644 --- a/tests/all-tests.html +++ b/tests/all-tests.html @@ -29,6 +29,7 @@ "/webMidiMock-tests.html", "/multiPortConnector-tests.html", "/smf-functional-tests.html", + "/smf-unit-tests.html", // TODO: These have to be run last or they will cause problems with subsequent tests. // I suspect additional listener calls or faulty start/stop logic are to blame. "/portConnector-tests.html" diff --git a/tests/html/smf-unit-tests.html b/tests/html/smf-unit-tests.html new file mode 100644 index 0000000..27959e3 --- /dev/null +++ b/tests/html/smf-unit-tests.html @@ -0,0 +1,38 @@ + + + + + + "Standard" MIDI File Unit Tests + + + + + + + + + + + + + + + + + +

"Standard" MIDI File Unit Tests

+

+
+

+
    + + + + + diff --git a/tests/js/smf-unit-tests.js b/tests/js/smf-unit-tests.js new file mode 100644 index 0000000..dcaaa2b --- /dev/null +++ b/tests/js/smf-unit-tests.js @@ -0,0 +1,143 @@ +/* + * Copyright 2023, Tony Atkins + * + * Licensed under the MIT license, see LICENSE for details. + */ +/* globals jqUnit */ +(function (fluid, jqUnit) { + "use strict"; + // TODO: unit tests for helper functions used in parsing / encoding MIDI files. + var youme = fluid.registerNamespace("youme"); + + fluid.registerNamespace("youme.test.smf"); + + jqUnit.test("Confirm parseSMFByteArray correctly handles non-byte-array data.", function () { + var midiObject = youme.smf.parseSMFByteArray({ byteLength: 5}); + jqUnit.assertEquals("There should be a single top-level error.", 1, midiObject.errors.length); + }); + + // TODO: Reuse for encoding tests. + // All sample values and expected output taken from: https://www.mobilefish.com/tutorials/midi/midi_quickguide_specification.html + youme.test.smf.divisionExamples = [ + // FPS + { rawDivision: 0xE878, divisionObject: { type: "framesPerSecond", fps: 24, unitsPerFrame: 120}}, + { rawDivision: 0xE764, divisionObject: { type: "framesPerSecond", fps: 25, unitsPerFrame: 100}}, + // The examples mistakenly said this should be 50 unitsPerFrame, but the bits clearly indicate 80. + { rawDivision: 0xE350, divisionObject: { type: "framesPerSecond", fps: 29, unitsPerFrame: 80}}, + { rawDivision: 0xE250, divisionObject: { type: "framesPerSecond", fps: 30, unitsPerFrame: 80}}, + // Ticks per quarter note. + { rawDivision: 0x0080, divisionObject: { type: "ticksPerQuarterNote", resolution: 128}}, + { rawDivision: 0x0050, divisionObject: { type: "ticksPerQuarterNote", resolution: 80}} + ]; + + jqUnit.test("Unit tests for parseDivision.", function () { + jqUnit.expect(youme.test.smf.divisionExamples.length); + fluid.each(youme.test.smf.divisionExamples, function (divisionExample) { + var divisionObject = youme.smf.parseDivision(divisionExample.rawDivision); + jqUnit.assertDeepEq("The output should be as expected for '0x" + (divisionExample.rawDivision).toString(16) + "'", divisionExample.divisionObject, divisionObject); + }); + }); + + youme.test.smf.metaEventExamples = { + // FF 00 02 Sequence Number + "sequence number": { + bytes: [0x00, 0x02, 0x00, 0x20], + object: { type: "sequenceNumber", value: 0x20} + }, + // FF 01 len text Text Event + "text event": { + bytes: [0x01, 0x02, 0x3A, 0x29], + object: { type: "text", value: ":)"} + }, + // FF 02 len text Copyright Notice + "copyright notice": { + bytes: [0x02, 0x02, 0x3B, 0x29], + object: { type: "copyright", value: ";)"} + }, + // FF 03 len text Sequence/Track Name + "sequence/track name": { + bytes: [0x03, 0x02, 0x3A, 0x50], + object: { type: "name", value: ":P"} + }, + // FF 04 len text Instrument Name + "instrument name": { + bytes: [0x04, 0x01, 0x3F], + object: { type: "instrumentName", value: "?"} + }, + // FF 05 len text Lyric + "lyric": { + bytes: [0x05, 0x02, 0x3F, 0x3F], + object: { type: "lyric", value: "??"} + }, + // FF 06 len text Marker + "marker": { + bytes: [0x06, 0x03, 0x3F, 0x3F, 0x3F], + object: { type: "marker", value: "???"} + }, + // FF 07 len text Cue Point + "cue point": { + bytes: [0x07, 0x04, 0x3F, 0x3F, 0x3F, 0x3F], + object: { type: "cuePoint", value: "????"} + }, + // FF 20 01 cc MIDI Channel Prefix + "channel prefix": { + bytes: [0x20, 0x01, 0x09], + object: { type: "channelPrefix", value: 9 } + }, + // FF 2F 00 End of Track + "end of track": { + bytes: [0x2F, 0x00], + object: { type: "endOfTrack" } + }, + // FF 51 03 tttttt Set Tempo (in microseconds per MIDI quarter-note) + "tempo": { + bytes: [0x51, 0x03, 0x00, 0x00, 0xFF], + object: { type: "tempo", value: 255 } + }, + // FF 54 05 hr mn se fr ff SMPTE Offset + "SMPTE offset": { + bytes: [0x54, 0x05, 0x0B, 0x13, 0x19, 0x05, 0x32], + object: { type: "smpteOffset", hour: 11, minute: 19, second: 25, frame: 5, fractionalFrame: 50 } + }, + // FF 58 04 nn dd cc bb Time Signature + "time signature": { + bytes: [0x58, 0x04, 0x03, 0x04, 0x18, 0x04], + object: { type: "timeSignature", numerator: 3, denominator: 4, midiClocksPerMetronomeClick: 24, thirtySecondNotesPerMidiQuarterNote: 4 } + }, + // FF 59 02 sf mi Key Signature + "key signature, negative sf, minor": { + bytes: [0x59, 0x02, 0x83, 0x1], + object: {type: "keySignature", sf: -3, mi: "minor"} + }, + "key signature, positive sf, major": { + bytes: [0x59, 0x02, 0x04, 0x0], + object: {type: "keySignature", sf: 4, mi: "major"} + }, + + // FF 7F len data Sequencer Specific Meta-Event + "sequencer specific meta event": { + bytes: [0x7F, 0x02, 0x0B, 0x13], + object: {type: "sequencerSpecificMetaEvent", value: [0x0B, 0x13] } + }, + + "unknown event": { + bytes: [0x8F, 0x02, 0x0B, 0x13], + object: {type: "Unknown (0x8f)", value: [0x0B, 0x13] } + } + }; + + jqUnit.test("Unit tests for readMetaEvent", function () { + jqUnit.expect(Object.keys(youme.test.smf.metaEventExamples).length); + fluid.each(youme.test.smf.metaEventExamples, function (metaEventExample, exampleKey) { + // Break down the example bytes into + + var metaEventType = metaEventExample.bytes[0]; + var metaEventLengthPayload = youme.smf.extractVariableLengthValue(metaEventExample.bytes, 1); + var payloadLength = metaEventLengthPayload.value; + var startPos = metaEventLengthPayload.numBytes + 1; + var metaEventBytes = metaEventExample.bytes.slice(startPos, startPos + payloadLength); + + var metaEventObject = youme.smf.readMetaEvent(metaEventType, metaEventBytes); + jqUnit.assertDeepEq("The output should be as expected for example '" + exampleKey + "'.", metaEventExample.object, metaEventObject); + }); + }); +})(fluid, jqUnit);