jsxm/xm.js
Andy Sloane fe99e1a3b2 IT WORKS!
fixed many, many bugs, implemented playing (should be relatively
efficient), volume envelopes (but not panning envelopes). found that
instruments start at 1, not 0, and note C-0 is 1, not 0... so it is now
in tune with FT2!

no effects are supported at all yet, and there's a bug where it stops
playing entirely for a while, but "my dirty old kamel" sounds
recognizable and in tune!
2015-10-23 10:12:29 -07:00

414 lines
13 KiB
JavaScript

var _note_names = ["C-", "C#", "D-", "D#", "E-", "F-", "F#", "G-", "G#", "A-", "A#", "B-"];
function prettify_note(note) {
if (note < 0) return "---";
if (note == 96) return "^^^";
note += 11;
return _note_names[note%12] + ~~(note/12);
}
function prettify_number(num) {
if (num == -1) return "--";
if (num < 10) return "0" + num;
return num;
}
function prettify_effect(t, p) {
if (t == -1) t = "-"; else t = t.toString(16);
if (p == -1) p = "--";
else if (p < 16) p = '0' + p.toString(16);
else p = p.toString(16);
return "" + t + p
}
function prettify_notedata(note, inst, vol, efftype, effparam) {
return (prettify_note(note) + " " + prettify_number(inst) + " "
+ prettify_number(vol) + " "
+ prettify_effect(efftype, effparam));
}
function getstring(dv, offset, len) {
var str = []
for (var i = offset; i < offset+len; i++) {
var c = dv.getUint8(i);
if (c == 0) break;
str.push(String.fromCharCode(c));
}
return str.join('');
}
var channelinfo = [];
var instruments = [];
var tempo = 4;
function GetEnvelope(env, ticks) {
// TODO: optimize, maybe
var y0;
for (var i = 0; i < env.length; i += 2) {
y0 = env[i+1];
if (ticks < env[i]) {
var x0 = env[i-2];
var y0 = env[i-1];
var dx = env[i] - x0;
var dy = env[i+1] - y0;
return y0 + (ticks - x0) * dy / dx;
}
}
return y0;
}
var cur_songpos = -1, cur_pat = -1, cur_row = 64, cur_ticksamp = 0;
var cur_tick = 6;
function next_row(f_smp) {
if (cur_row >= 64) {
cur_row = 0;
cur_songpos++;
if (cur_songpos >= songpats.length)
cur_songpos = song_looppos;
cur_pat = songpats[cur_songpos];
}
var p = patterns[cur_pat];
var r = p[cur_row];
cur_row++;
pretty_row = [];
for (var i = 0; i < r.length; i++) {
pretty_row.push(prettify_notedata(r[i][0], r[i][1], r[i][2], r[i][3], r[i][4]));
// instrument trigger
if (r[i][1] != -1) {
var inst = instruments[r[i][1] - 1];
if (inst !== undefined) {
channelinfo[i].inst = inst;
// retrigger?
channelinfo[i].off = 0;
channelinfo[i].release = 0;
channelinfo[i].envtick = 0;
// new instrument doesn ot reset volume!
} else {
// console.log("invalid inst", r[i][1], instruments.length);
}
}
// note trigger
if (r[i][0] != -1) {
if (r[i][0] == 96) {
// release note, FIXME once envelopes are implemented
channelinfo[i].release = 1;
} else {
// assume linear frequency table (flags header & 1 == 1)
// is this true in kamel.xm?
var inst = channelinfo[i].inst;
if (inst === undefined) {
continue;
}
// wtf is this?!
// var period = 7680 - r[i][0]*64 - inst.fine*0.5;
// var freq = 8363 * Math.pow(2, (4608 - period) / 768);
var note = r[i][0] + inst.note;
var freq = 8363 * Math.pow(2, note/12.0 + inst.fine/(12*128.0) - 4);
channelinfo[i].doff = freq / f_smp;
channelinfo[i].off = 0;
channelinfo[i].release = 0;
channelinfo[i].envtick = 0;
// console.log("channel", i, r[i][0], channelinfo[i]);
// if there's an instrument and a note, set the volume
if (r[i][0] != -1) {
channelinfo[i].volL = inst.vol;
channelinfo[i].volR = inst.vol;
}
}
}
if (r[i][2] != -1) {
// FIXME: panning
var v = r[i][2];
if (v < 0x10) {
console.log("channel", i, "invalid volume", v.toString(16));
} else if (v <= 0x50) {
channelinfo[i].volL = v - 0x10;
channelinfo[i].volR = v - 0x10;
} else {
console.log("channel", i, "volume effect", v.toString(16));
}
}
}
console.log(pretty_row.join(" "));
var debug = document.getElementById("debug");
debug.innerHTML = 'pat ' + cur_pat + ' row ' + (cur_row-1);
}
function next_tick(f_smp) {
cur_tick++;
if (cur_tick >= tempo) {
cur_tick = 0;
next_row(f_smp);
}
for (var j = 0; j < nchan; j++) {
var inst = channelinfo[j].inst;
if (inst === undefined) continue;
if (inst.env_vol !== undefined) {
channelinfo[j].env_vol = GetEnvelope(inst.env_vol, channelinfo[j].envtick);
if (inst.env_vol_type & 2) { // sustain loop?
// if we're sustaining a note, leave env_vol alone
if (channelinfo[j].release == 0) {
if (channelinfo[j].envtick >= inst.env_vol[inst.env_vol_sustain*2]) {
continue;
}
}
}
channelinfo[j].envtick++;
} else {
channelinfo[j].env_vol = 64;
}
}
}
function audio_cb(e) {
var f_smp = audioctx.sampleRate;
var buflen = e.outputBuffer.length;
var dataL = e.outputBuffer.getChannelData(0);
var dataR = e.outputBuffer.getChannelData(1);
dataL.fill(0);
dataR.fill(0);
var offset = 0;
var ticklen = 0|(f_smp * 2.5 / bpm);
while(buflen > 0) {
if (cur_ticksamp >= ticklen) {
next_tick(f_smp);
cur_ticksamp -= ticklen;
}
var tickduration = Math.min(buflen, ticklen - cur_ticksamp);
for (var j = 0; j < nchan; j++) {
var inst = channelinfo[j].inst;
if (inst === undefined) continue;
var samp = inst.sampledata;
var sample_end = inst.len;
var looplen = 0;
var loop = false;
if (inst.type & 3) {
loop = true;
looplen = inst.looplen;
sample_end = looplen + inst.loop;
}
var samplen = inst.len;
var env_vol = channelinfo[j].env_vol / 64.0;
var volL = env_vol * channelinfo[j].volL / 64.0;
var volR = env_vol * channelinfo[j].volR / 64.0;
if (volL < 0) volL = 0;
if (volR < 0) volR = 0;
if (volR == 0 && volL == 0)
break;
var k = channelinfo[j].off;
var dk = channelinfo[j].doff;
// console.log(j, offset, channelinfo[j]);
for (var i = offset; i < offset+tickduration; i++) {
var si = samp[k|0]; // TODO: bilinear filtering
dataL[i] += volL * si;
dataR[i] += volR * si;
k += dk;
if (k >= sample_end) { // TODO: implement pingpong looping
if (loop) {
k -= looplen;
} else {
// kill sample
channelinfo[j].inst = undefined;
break;
}
}
}
channelinfo[j].off = k;
channelinfo[j].doff = dk;
}
offset += tickduration;
cur_ticksamp += tickduration;
buflen -= tickduration;
}
}
function ConvertSample(array, bits) {
var len = array.length;
var acc = 0;
if (bits == 0) { // 8 bit sample
var samp = new Float32Array(len);
for (var k = 0; k < len; k++) {
acc += array[k];
var b = acc&255;
if (b & 128) b = b-256;
samp[k] = (b - 128) / 128.0;
}
return samp;
} else {
len /= 2;
var samp = new Float32Array(len);
for (var k = 0; k < len; k++) {
acc += array[k*2] + (array[k*2 + 1] << 8);
var b = acc&65535;
if (b & 32768) b = b-65536;
samp[k] = b / 32768.0;
}
return samp;
}
}
function playXM(arrayBuf) {
var dv = new DataView(arrayBuf);
window.dv = dv;
var name = getstring(dv, 17, 20);
var hlen = dv.getUint32(0x3c, true) + 0x3c;
var songlen = dv.getUint16(0x40, true);
song_looppos = dv.getUint16(0x42, true);
nchan = dv.getUint16(0x44, true);
var npat = dv.getUint16(0x46, true);
var ninst = dv.getUint16(0x48, true);
var flags = dv.getUint16(0x4a, true);
tempo = dv.getUint16(0x4c, true);
bpm = dv.getUint16(0x4e, true);
for (var i = 0; i < nchan; i++) {
channelinfo.push({})
}
console.log("header len " + hlen);
console.log("songlen %d, %d channels, %d patterns, %d instruments", songlen, nchan, npat, ninst);
console.log("loop @%d", song_looppos);
console.log("flags=%d tempo %d bpm %d", flags, tempo, bpm);
songpats = [];
for (var i = 0; i < songlen; i++) {
songpats.push(dv.getUint8(0x50 + i));
}
console.log("song patterns: ", songpats);
var idx = hlen;
patterns = [];
for (var i = 0; i < npat; i++) {
var pattern = []
var patheaderlen = dv.getUint32(idx, true);
var patrows = dv.getUint16(idx + 5, true);
var patsize = dv.getUint16(idx + 7, true);
console.log("pattern %d: %d bytes, %d rows", i, patsize, patrows);
idx += 9;
for (var j = 0; j < patrows; j++) {
row = [];
pretty_row = [];
for (var k = 0; k < nchan; k++) {
var byte0 = dv.getUint8(idx); idx++;
var note = -1, inst = -1, vol = -1, efftype = -1, effparam = -1;
if (byte0 & 0x80) {
if (byte0 & 0x01) {
note = dv.getUint8(idx); idx++;
}
if (byte0 & 0x02) {
inst = dv.getUint8(idx); idx++;
}
if (byte0 & 0x04) {
vol = dv.getUint8(idx); idx++;
}
if (byte0 & 0x08) {
efftype = dv.getUint8(idx); idx++;
}
if (byte0 & 0x10) {
effparam = dv.getUint8(idx); idx++;
}
} else {
// byte0 is note from 1..96 or 0 for nothing or 97 for release
// so we subtract 1 so that C-0 is stored as 0
note = byte0 - 1;
inst = dv.getUint8(idx); idx++;
vol = dv.getUint8(idx); idx++;
efftype = dv.getUint8(idx); idx++;
effparam = dv.getUint8(idx); idx++;
}
pretty_row.push(prettify_notedata(note, inst, vol, efftype, effparam));
row.push([note, inst, vol, efftype, effparam]);
}
if (i == 0)
console.log(pretty_row.join(" "));
pattern.push(row);
}
patterns.push(pattern);
}
// now load instruments
for (i = 0; i < ninst; i++) {
var hdrsiz = dv.getUint32(idx, true);
var instname = getstring(dv, idx+0x4, 22);
var nsamp = dv.getUint16(idx+0x1b, true);
var env_nvol = dv.getUint8(idx+225);
var env_vol_type = dv.getUint8(idx+233);
var env_vol_sustain = dv.getUint8(idx+227);
var env_vol_loop_start = dv.getUint8(idx+228);
var env_vol_loop_end = dv.getUint8(idx+229);
env_vol = [];
for (var j = 0; j < env_nvol*2; j++) {
env_vol.push(dv.getUint16(idx+129+j*2, true));
}
if (nsamp > 0) {
// FIXME: ignoring keymaps for now and assuming 1 sample / instrument
// var keymap = getarray(dv, idx+0x21);
var samphdrsiz = dv.getUint32(idx+0x1d, true);
console.log("hdrsiz %d; instrument %d: '%s' %d samples, samphdrsiz %d",
hdrsiz, i, instname, nsamp, samphdrsiz);
idx += hdrsiz;
for (var j = 0; j < nsamp; j++) {
var samplen = dv.getUint32(idx, true);
var samploop = dv.getUint32(idx+4, true);
var samplooplen = dv.getUint32(idx+8, true);
var sampvol = dv.getUint8(idx+12);
var sampfinetune = dv.getInt8(idx+13);
var samptype = dv.getUint8(idx+14);
var samppan = dv.getUint8(idx+15);
var sampnote = dv.getUint8(idx+16);
var sampname = getstring(dv, idx+18, 22);
var sampleoffset = idx + samphdrsiz;
console.log("sample %d: len %d name '%s' loop %d/%d vol %d",
j, samplen, sampname, samploop, samplooplen, sampvol);
console.log(" type %d note %s finetune %d pan %d",
samptype, prettify_note(sampnote), sampfinetune, samppan);
console.log(" vol env", env_vol, env_vol_sustain,
env_vol_loop_start, env_vol_loop_end, "type", env_vol_type);
idx += samplen + samphdrsiz;
}
inst = {
'name': instname,
'len': samplen, 'loop': samploop,
'looplen': samplooplen, 'note': sampnote, 'fine': sampfinetune,
'pan': samppan, 'type': samptype, 'vol': sampvol,
'fine': sampfinetune,
'sampledata': ConvertSample(new Uint8Array(arrayBuf, sampleoffset, samplen), samptype & 4),
};
if (env_vol_type) {
inst.env_vol = env_vol;
inst.env_vol_type = env_vol_type;
inst.env_vol_sustain = env_vol_sustain;
inst.env_vol_loop_start = env_vol_loop_start;
inst.env_vol_loop_end = env_vol_loop_end;
}
instruments.push(inst);
} else {
instruments.push(null);
}
}
audioctx = new AudioContext();
gainNode = audioctx.createGain();
gainNode.gain.value = 0.2; // master volume
jsNode = audioctx.createScriptProcessor(4096, 0, 2);
jsNode.onaudioprocess = audio_cb;
jsNode.connect(gainNode);
var debug = document.getElementById("debug");
console.log("loaded \"" + name + "\"");
debug.innerHTML = name;
// start playing
gainNode.connect(audioctx.destination);
}
var xmReq = new XMLHttpRequest();
xmReq.open("GET", "kamel.xm", true);
xmReq.responseType = "arraybuffer";
xmReq.onload = function (xmEvent) {
var arrayBuffer = xmReq.response;
if (arrayBuffer) {
playXM(arrayBuffer);
}
}
xmReq.send(null);