qambi
Version:
MIDI sequencer, loads MIDI files, can record and playback MIDI, uses WebMIDI and WebAudio
281 lines (240 loc) • 8.19 kB
JavaScript
/*
This code is based on https://github.com/sergi/jsmidi
info: http://www.deluge.co/?q=midi-tempo-bpm
*/
import {saveAs} from 'filesaverjs'
let PPQ = 960
let HDR_PPQ = str2Bytes(PPQ.toString(16), 2)
const HDR_CHUNKID = [
'M'.charCodeAt(0),
'T'.charCodeAt(0),
'h'.charCodeAt(0),
'd'.charCodeAt(0)
]
const HDR_CHUNK_SIZE = [0x0, 0x0, 0x0, 0x6] // Header size for SMF
const HDR_TYPE0 = [0x0, 0x0] // Midi Type 0 id
const HDR_TYPE1 = [0x0, 0x1] // Midi Type 1 id
//HDR_PPQ = [0x01, 0xE0] // Defaults to 480 ticks per beat
//HDR_PPQ = [0x00, 0x80] // Defaults to 128 ticks per beat
const TRK_CHUNKID = [
'M'.charCodeAt(0),
'T'.charCodeAt(0),
'r'.charCodeAt(0),
'k'.charCodeAt(0)
]
// Meta event codes
const META_SEQUENCE = 0x00
const META_TEXT = 0x01
const META_COPYRIGHT = 0x02
const META_TRACK_NAME = 0x03
const META_INSTRUMENT = 0x04
const META_LYRIC = 0x05
const META_MARKER = 0x06
const META_CUE_POINT = 0x07
const META_CHANNEL_PREFIX = 0x20
const META_END_OF_TRACK = 0x2f
const META_TEMPO = 0x51
const META_SMPTE = 0x54
const META_TIME_SIG = 0x58
const META_KEY_SIG = 0x59
const META_SEQ_EVENT = 0x7f
export function saveAsMIDIFile(song, fileName = song.name, ppq = 960) {
PPQ = ppq
HDR_PPQ = str2Bytes(PPQ.toString(16), 2)
let byteArray = [].concat(HDR_CHUNKID, HDR_CHUNK_SIZE, HDR_TYPE1)
let tracks = song.getTracks()
let numTracks = tracks.length + 1
let i, maxi, track, midiFile, destination, b64
let arrayBuffer, dataView, uintArray
byteArray = byteArray.concat(str2Bytes(numTracks.toString(16), 2), HDR_PPQ)
//console.log(byteArray);
byteArray = byteArray.concat(trackToBytes(song._timeEvents, song._durationTicks, 'tempo'))
for(i = 0, maxi = tracks.length; i < maxi; i++){
track = tracks[i];
let instrument
if(track._instrument !== null){
instrument = track._instrument.id
}
//console.log(track.name, track._events.length, instrument)
byteArray = byteArray.concat(trackToBytes(track._events, song._durationTicks, track.name, instrument))
//byteArray = byteArray.concat(trackToBytes(track._events, song._lastEvent.icks, track.name, instrument))
}
//b64 = btoa(codes2Str(byteArray))
//window.location.assign("data:audio/midi;base64," + b64)
//console.log(b64)// send to server
maxi = byteArray.length
arrayBuffer = new ArrayBuffer(maxi)
uintArray = new Uint8Array(arrayBuffer)
for(i = 0; i < maxi; i++){
uintArray[i] = byteArray[i]
}
midiFile = new Blob([uintArray], {type: 'application/x-midi', endings: 'transparent'})
fileName = fileName.replace(/\.midi$/, '')
//let patt = /\.mid[i]{0,1}$/
let patt = /\.mid$/
let hasExtension = patt.test(fileName)
if(hasExtension === false){
fileName += '.mid'
}
//console.log(fileName, hasExtension)
saveAs(midiFile, fileName)
//window.location.assign(window.URL.createObjectURL(midiFile))
}
function trackToBytes(events, lastEventTicks, trackName, instrumentName = 'no instrument'){
var lengthBytes,
i, maxi, event, status,
trackLength, // number of bytes in track chunk
ticks = 0,
delta = 0,
trackBytes = [];
if(trackName){
trackBytes.push(0x00);
trackBytes.push(0xFF);
trackBytes.push(0x03);
trackBytes = trackBytes.concat(convertToVLQ(trackName.length));
trackBytes = trackBytes.concat(stringToNumArray(trackName));
}
if(instrumentName){
trackBytes.push(0x00);
trackBytes.push(0xFF);
trackBytes.push(0x04);
trackBytes = trackBytes.concat(convertToVLQ(instrumentName.length));
trackBytes = trackBytes.concat(stringToNumArray(instrumentName));
}
for(i = 0, maxi = events.length; i < maxi; i++){
event = events[i];
delta = event.ticks - ticks;
delta = convertToVLQ(delta);
//console.log(delta);
trackBytes = trackBytes.concat(delta);
//trackBytes.push.apply(trackBytes, delta);
if(event.type === 0x80 || event.type === 0x90){ // note off, note on
//status = parseInt(event.type.toString(16) + event.channel.toString(16), 16);
status = event.type + (event.channel || 0)
trackBytes.push(status);
trackBytes.push(event.data1);
trackBytes.push(event.data2);
}else if(event.type === 0x51){ // tempo
trackBytes.push(0xFF);
trackBytes.push(0x51);
trackBytes.push(0x03);// length
//trackBytes = trackBytes.concat(convertToVLQ(3));// length
var microSeconds = Math.round(60000000 / event.bpm);
//console.log(event.bpm)
trackBytes = trackBytes.concat(str2Bytes(microSeconds.toString(16), 3));
}else if(event.type === 0x58){ // time signature
var denom = event.denominator;
if(denom === 2){
denom = 0x01;
}else if(denom === 4){
denom = 0x02;
}else if(denom === 8){
denom = 0x03;
}else if(denom === 16){
denom = 0x04;
}else if(denom === 32){
denom = 0x05;
}
//console.log(event.denominator, event.nominator)
trackBytes.push(0xFF);
trackBytes.push(0x58);
trackBytes.push(0x04);// length
//trackBytes = trackBytes.concat(convertToVLQ(4));// length
trackBytes.push(event.nominator);
trackBytes.push(denom);
trackBytes.push(PPQ / event.nominator);
trackBytes.push(0x08); // 32nd notes per crotchet
//console.log(trackName, event.nominator, event.denominator, denom, PPQ/event.nominator);
}
// set the new ticks reference
//console.log(status, event.ticks, ticks);
ticks = event.ticks;
}
delta = lastEventTicks - ticks;
//console.log('d', delta, 't', ticks, 'l', lastEventTicks);
delta = convertToVLQ(delta);
//console.log(trackName, ticks, delta);
trackBytes = trackBytes.concat(delta);
trackBytes.push(0xFF);
trackBytes.push(0x2F);
trackBytes.push(0x00);
//console.log(trackName, trackBytes);
trackLength = trackBytes.length;
lengthBytes = str2Bytes(trackLength.toString(16), 4);
return [].concat(TRK_CHUNKID, lengthBytes, trackBytes);
}
// Helper functions
/*
* Converts an array of bytes to a string of hexadecimal characters. Prepares
* it to be converted into a base64 string.
*
* @param byteArray {Array} array of bytes that will be converted to a string
* @returns hexadecimal string
*/
function codes2Str(byteArray) {
return String.fromCharCode.apply(null, byteArray);
}
/*
* Converts a String of hexadecimal values to an array of bytes. It can also
* add remaining '0' nibbles in order to have enough bytes in the array as the
* |finalBytes| parameter.
*
* @param str {String} string of hexadecimal values e.g. '097B8A'
* @param finalBytes {Integer} Optional. The desired number of bytes that the returned array should contain
* @returns array of nibbles.
*/
function str2Bytes(str, finalBytes) {
if (finalBytes) {
while ((str.length / 2) < finalBytes) {
str = '0' + str;
}
}
var bytes = [];
for (var i = str.length - 1; i >= 0; i = i - 2) {
var chars = i === 0 ? str[i] : str[i - 1] + str[i];
bytes.unshift(parseInt(chars, 16));
}
return bytes;
}
/**
* Translates number of ticks to MIDI timestamp format, returning an array of
* bytes with the time values. Midi has a very particular time to express time,
* take a good look at the spec before ever touching this function.
*
* @param ticks {Integer} Number of ticks to be translated
* @returns Array of bytes that form the MIDI time value
*/
function convertToVLQ(ticks) {
var buffer = ticks & 0x7F;
while(ticks = ticks >> 7) {
buffer <<= 8;
buffer |= ((ticks & 0x7F) | 0x80);
}
var bList = [];
while(true) {
bList.push(buffer & 0xff);
if (buffer & 0x80) {
buffer >>= 8;
} else {
break;
}
}
//console.log(ticks, bList);
return bList;
}
/*
* Converts a string into an array of ASCII char codes for every character of
* the string.
*
* @param str {String} String to be converted
* @returns array with the charcode values of the string
*/
const AP = Array.prototype
function stringToNumArray(str) {
// return str.split().forEach(char => {
// return char.charCodeAt(0)
// })
return AP.map.call(str, function(char) {
return char.charCodeAt(0)
})
}