mjestecko/html/xm.js

1352 lines
39 KiB
JavaScript
Raw Normal View History

2023-05-23 14:33:29 +00:00
/*
The MIT License (MIT)
Copyright (c) 2015 Andy Sloane <andy@a1k0n.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
(function (window) {
if (!window.XMPlayer) {
window.XMPlayer = {};
}
var player = window.XMPlayer;
if (!window.XMView) {
window.XMView = {};
}
var XMView = window.XMView;
player.periodForNote = periodForNote;
player.prettify_effect = prettify_effect;
player.init = init;
player.load = load;
player.play = play;
player.pause = pause;
player.stop = stop;
player.cur_songpos = -1;
player.cur_pat = -1;
player.cur_row = 64;
player.cur_ticksamp = 0;
player.cur_tick = 6;
player.xm = {}; // contains all song data
player.xm.global_volume = player.max_global_volume = 128;
// exposed for testing
player.nextTick = nextTick;
player.nextRow = nextRow;
player.Envelope = Envelope;
// for pretty-printing notes
var _note_names = [
"C-", "C#", "D-", "D#", "E-", "F-",
"F#", "G-", "G#", "A-", "A#", "B-"];
var f_smp = 44100; // updated by play callback, default value here
// per-sample exponential moving average for volume changes (to prevent pops
// and clicks); evaluated every 8 samples
var popfilter_alpha = 0.9837;
function prettify_note(note) {
if (note < 0) return "---";
if (note == 96) return "^^^";
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_volume(num) {
if (num < 0x10) return "--";
return num.toString(16);
}
function prettify_effect(t, p) {
if (t >= 10) t = String.fromCharCode(55 + t);
if (p < 16) p = '0' + p.toString(16);
else p = p.toString(16);
return t + p;
}
function prettify_notedata(data) {
return (prettify_note(data[0]) + " " + prettify_number(data[1]) + " " +
prettify_volume(data[2]) + " " +
prettify_effect(data[3], data[4]));
}
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('');
}
// Return 2-pole Butterworth lowpass filter coefficients for
// center frequncy f_c (relative to sampling frequency)
function filterCoeffs(f_c) {
if (f_c > 0.5) { // we can't lowpass above the nyquist frequency...
f_c = 0.5;
}
var wct = Math.sqrt(2) * Math.PI * f_c;
var e = Math.exp(-wct);
var c = e * Math.cos(wct);
var gain = (1 - 2*c + e*e) / 2;
return [gain, 2*c, -e*e];
}
function updateChannelPeriod(ch, period) {
var freq = 8363 * Math.pow(2, (1152.0 - period) / 192.0);
if (isNaN(freq)) {
console.log("invalid period!", period);
return;
}
ch.doff = freq / f_smp;
ch.filter = filterCoeffs(ch.doff / 2);
}
function periodForNote(ch, note) {
return 1920 - (note + ch.samp.note)*16 - ch.fine / 8.0;
}
function setCurrentPattern() {
var nextPat = player.xm.songpats[player.cur_songpos];
// check for out of range pattern index
while (nextPat >= player.xm.patterns.length) {
if (player.cur_songpos + 1 < player.xm.songpats.length) {
// first try skipping the position
player.cur_songpos++;
} else if ((player.cur_songpos === player.xm.song_looppos && player.cur_songpos !== 0)
|| player.xm.song_looppos >= player.xm.songpats.length) {
// if we allready tried song_looppos or if song_looppos
// is out of range, go to the first position
player.cur_songpos = 0;
} else {
// try going to song_looppos
player.cur_songpos = player.xm.song_looppos;
}
nextPat = player.xm.songpats[player.cur_songpos];
}
player.cur_pat = nextPat;
}
function nextRow() {
if(typeof player.next_row === "undefined") { player.next_row = player.cur_row + 1; }
player.cur_row = player.next_row;
player.next_row++;
if (player.cur_pat == -1 || player.cur_row >= player.xm.patterns[player.cur_pat].length) {
player.cur_row = 0;
player.next_row = 1;
player.cur_songpos++;
if (player.cur_songpos >= player.xm.songpats.length)
player.cur_songpos = player.xm.song_looppos;
setCurrentPattern();
}
var p = player.xm.patterns[player.cur_pat];
var r = p[player.cur_row];
for (var i = 0; i < r.length; i++) {
var ch = player.xm.channelinfo[i];
var inst = ch.inst;
var triggernote = false;
// instrument trigger
if (r[i][1] != -1) {
inst = player.xm.instruments[r[i][1] - 1];
if (inst && inst.samplemap) {
ch.inst = inst;
// retrigger unless overridden below
triggernote = true;
if (ch.note && inst.samplemap) {
ch.samp = inst.samples[inst.samplemap[ch.note]];
ch.vol = ch.samp.vol;
ch.pan = ch.samp.pan;
ch.fine = ch.samp.fine;
}
} else {
// console.log("invalid inst", r[i][1], instruments.length);
}
}
// note trigger
if (r[i][0] != -1) {
if (r[i][0] == 96) {
ch.release = 1;
triggernote = false;
} else {
if (inst && inst.samplemap) {
var note = r[i][0];
ch.note = note;
ch.samp = inst.samples[inst.samplemap[ch.note]];
if (triggernote) {
// if we were already triggering the note, reset vol/pan using
// (potentially) new sample
ch.pan = ch.samp.pan;
ch.vol = ch.samp.vol;
ch.fine = ch.samp.fine;
}
triggernote = true;
}
}
}
ch.voleffectfn = undefined;
if (r[i][2] != -1) { // volume column
var v = r[i][2];
ch.voleffectdata = v & 0x0f;
if (v < 0x10) {
console.log("channel", i, "invalid volume", v.toString(16));
} else if (v <= 0x50) {
ch.vol = v - 0x10;
} else if (v >= 0x60 && v < 0x70) { // volume slide down
ch.voleffectfn = function(ch) {
ch.vol = Math.max(0, ch.vol - ch.voleffectdata);
};
} else if (v >= 0x70 && v < 0x80) { // volume slide up
ch.voleffectfn = function(ch) {
ch.vol = Math.min(64, ch.vol + ch.voleffectdata);
};
} else if (v >= 0x80 && v < 0x90) { // fine volume slide down
ch.vol = Math.max(0, ch.vol - (v & 0x0f));
} else if (v >= 0x90 && v < 0xa0) { // fine volume slide up
ch.vol = Math.min(64, ch.vol + (v & 0x0f));
} else if (v >= 0xa0 && v < 0xb0) { // vibrato speed
ch.vibratospeed = v & 0x0f;
} else if (v >= 0xb0 && v < 0xc0) { // vibrato w/ depth
ch.vibratodepth = v & 0x0f;
ch.voleffectfn = player.effects_t1[4]; // use vibrato effect directly
player.effects_t1[4](ch); // and also call it on tick 0
} else if (v >= 0xc0 && v < 0xd0) { // set panning
ch.pan = (v & 0x0f) * 0x11;
} else if (v >= 0xf0 && v <= 0xff) { // portamento
if (v & 0x0f) {
ch.portaspeed = (v & 0x0f) << 4;
}
ch.voleffectfn = player.effects_t1[3]; // just run 3x0
} else {
console.log("channel", i, "volume effect", v.toString(16));
}
}
ch.effect = r[i][3];
ch.effectdata = r[i][4];
if (ch.effect < 36) {
ch.effectfn = player.effects_t1[ch.effect];
var eff_t0 = player.effects_t0[ch.effect];
if (eff_t0 && eff_t0(ch, ch.effectdata)) {
triggernote = false;
}
} else {
console.log("channel", i, "effect > 36", ch.effect);
}
// special handling for portamentos: don't trigger the note
if (ch.effect == 3 || ch.effect == 5 || r[i][2] >= 0xf0) {
if (r[i][0] != -1) {
ch.periodtarget = periodForNote(ch, ch.note);
}
triggernote = false;
if (inst && inst.samplemap) {
if (ch.env_vol == undefined) {
// note wasn't already playing; we basically have to ignore the
// portamento and just trigger
triggernote = true;
} else if (ch.release) {
// reset envelopes if note was released but leave offset/pitch/etc
// alone
ch.envtick = 0;
ch.release = 0;
ch.env_vol = new EnvelopeFollower(inst.env_vol);
ch.env_pan = new EnvelopeFollower(inst.env_pan);
}
}
}
if (triggernote) {
// there's gotta be a less hacky way to handle offset commands...
if (ch.effect != 9) ch.off = 0;
ch.release = 0;
ch.envtick = 0;
ch.env_vol = new EnvelopeFollower(inst.env_vol);
ch.env_pan = new EnvelopeFollower(inst.env_pan);
if (ch.note) {
ch.period = periodForNote(ch, ch.note);
}
// waveforms 0-3 are retriggered on new notes while 4-7 are continuous
if (ch.vibratotype < 4) {
ch.vibratopos = 0;
}
}
}
}
function Envelope(points, type, sustain, loopstart, loopend) {
this.points = points;
this.type = type;
this.sustain = sustain;
this.loopstart = points[loopstart*2];
this.loopend = points[loopend*2];
}
Envelope.prototype.Get = function(ticks) {
// TODO: optimize follower with ptr
// or even do binary search here
var y0;
var env = this.points;
for (var i = 0; i < env.length; i += 2) {
y0 = env[i+1];
if (ticks < env[i]) {
var x0 = env[i-2];
y0 = env[i-1];
var dx = env[i] - x0;
var dy = env[i+1] - y0;
return y0 + (ticks - x0) * dy / dx;
}
}
return y0;
};
function EnvelopeFollower(env) {
this.env = env;
this.tick = 0;
}
EnvelopeFollower.prototype.Tick = function(release) {
var value = this.env.Get(this.tick);
// if we're sustaining a note, stop advancing the tick counter
if (!release && this.tick >= this.env.points[this.env.sustain*2]) {
return this.env.points[this.env.sustain*2 + 1];
}
this.tick++;
if (this.env.type & 4) { // envelope loop?
if (!release &&
this.tick >= this.env.loopend) {
this.tick -= this.env.loopend - this.env.loopstart;
}
}
return value;
};
function nextTick() {
player.cur_tick++;
var j, ch;
for (j = 0; j < player.xm.nchan; j++) {
ch = player.xm.channelinfo[j];
ch.periodoffset = 0;
}
if (player.cur_tick >= player.xm.tempo) {
player.cur_tick = 0;
nextRow();
}
for (j = 0; j < player.xm.nchan; j++) {
ch = player.xm.channelinfo[j];
var inst = ch.inst;
if (player.cur_tick !== 0) {
if(ch.voleffectfn) ch.voleffectfn(ch);
if(ch.effectfn) ch.effectfn(ch);
}
if (isNaN(ch.period)) {
console.log(prettify_notedata(
player.xm.patterns[player.cur_pat][player.cur_row][j]),
"set channel", j, "period to NaN");
}
if (inst === undefined) continue;
if (ch.env_vol === undefined) {
console.log(prettify_notedata(
player.xm.patterns[player.cur_pat][player.cur_row][j]),
"set channel", j, "env_vol to undefined, but note is playing");
continue;
}
ch.volE = ch.env_vol.Tick(ch.release);
ch.panE = ch.env_pan.Tick(ch.release);
updateChannelPeriod(ch, ch.period + ch.periodoffset);
}
}
// This function gradually brings the channel back down to zero if it isn't
// already to avoid clicks and pops when samples end.
function MixSilenceIntoBuf(ch, start, end, dataL, dataR) {
var s = ch.filterstate[1];
if (isNaN(s)) {
console.log("NaN filterstate?", ch.filterstate, ch.filter);
return;
}
for (var i = start; i < end; i++) {
if (Math.abs(s) < 1.526e-5) { // == 1/65536.0
s = 0;
break;
}
dataL[i] += s * ch.vL;
dataR[i] += s * ch.vR;
s *= popfilter_alpha;
}
ch.filterstate[1] = s;
ch.filterstate[2] = s;
if (isNaN(s)) {
console.log("NaN filterstate after adding silence?", ch.filterstate, ch.filter, i);
return;
}
return 0;
}
function MixChannelIntoBuf(ch, start, end, dataL, dataR) {
var inst = ch.inst;
var instsamp = ch.samp;
var loop = false;
var looplen = 0, loopstart = 0;
// nothing on this channel, just filter the last dc offset back down to zero
if (instsamp == undefined || inst == undefined || ch.mute) {
return MixSilenceIntoBuf(ch, start, end, dataL, dataR);
}
var samp = instsamp.sampledata;
var sample_end = instsamp.len;
if ((instsamp.type & 3) == 1 && instsamp.looplen > 0) {
loop = true;
loopstart = instsamp.loop;
looplen = instsamp.looplen;
sample_end = loopstart + looplen;
}
var samplen = instsamp.len;
var volE = ch.volE / 64.0; // current volume envelope
var panE = 4*(ch.panE - 32); // current panning envelope
var p = panE + ch.pan - 128; // final pan
var volL = player.xm.global_volume * volE * (128 - p) * ch.vol / (64 * 128 * 128);
var volR = player.xm.global_volume * volE * (128 + p) * ch.vol / (64 * 128 * 128);
if (volL < 0) volL = 0;
if (volR < 0) volR = 0;
if (volR === 0 && volL === 0)
return;
if (isNaN(volR) || isNaN(volL)) {
console.log("NaN volume!?", ch.number, volL, volR, volE, panE, ch.vol);
return;
}
var k = ch.off;
var dk = ch.doff;
var Vrms = 0;
var f0 = ch.filter[0], f1 = ch.filter[1], f2 = ch.filter[2];
var fs0 = ch.filterstate[0], fs1 = ch.filterstate[1], fs2 = ch.filterstate[2];
// we also low-pass filter volume changes with a simple one-zero,
// one-pole filter to avoid pops and clicks when volume changes.
var vL = popfilter_alpha * ch.vL + (1 - popfilter_alpha) * (volL + ch.vLprev) * 0.5;
var vR = popfilter_alpha * ch.vR + (1 - popfilter_alpha) * (volR + ch.vRprev) * 0.5;
var pf_8 = Math.pow(popfilter_alpha, 8);
ch.vLprev = volL;
ch.vRprev = volR;
// we can mix up to this many bytes before running into a sample end/loop
var i = start;
var failsafe = 100;
while (i < end) {
if (failsafe-- === 0) {
console.log("failsafe in mixing loop! channel", ch.number, k, sample_end,
loopstart, looplen, dk);
break;
}
if (k >= sample_end) { // TODO: implement pingpong looping
if (loop) {
k = loopstart + (k - loopstart) % looplen;
} else {
// kill sample
ch.inst = undefined;
// fill rest of buf with filtered dc offset using loop above
return Vrms + MixSilenceIntoBuf(ch, i, end, dataL, dataR);
}
}
var next_event = Math.max(1, Math.min(end, i + (sample_end - k) / dk));
// this is the inner loop of the player
// unrolled 8x
var s, y;
for (; i + 7 < next_event; i+=8) {
s = samp[k|0];
y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
fs2 = fs1; fs1 = y; fs0 = s;
k += dk;
dataL[i] += vL * y;
dataR[i] += vR * y;
Vrms += (vL + vR) * y * y;
s = samp[k|0];
y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
fs2 = fs1; fs1 = y; fs0 = s;
k += dk;
dataL[i+1] += vL * y;
dataR[i+1] += vR * y;
Vrms += (vL + vR) * y * y;
s = samp[k|0];
y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
fs2 = fs1; fs1 = y; fs0 = s;
k += dk;
dataL[i+2] += vL * y;
dataR[i+2] += vR * y;
Vrms += (vL + vR) * y * y;
s = samp[k|0];
y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
fs2 = fs1; fs1 = y; fs0 = s;
k += dk;
dataL[i+3] += vL * y;
dataR[i+3] += vR * y;
Vrms += (vL + vR) * y * y;
s = samp[k|0];
y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
fs2 = fs1; fs1 = y; fs0 = s;
k += dk;
dataL[i+4] += vL * y;
dataR[i+4] += vR * y;
Vrms += (vL + vR) * y * y;
s = samp[k|0];
y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
fs2 = fs1; fs1 = y; fs0 = s;
k += dk;
dataL[i+5] += vL * y;
dataR[i+5] += vR * y;
Vrms += (vL + vR) * y * y;
s = samp[k|0];
y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
fs2 = fs1; fs1 = y; fs0 = s;
k += dk;
dataL[i+6] += vL * y;
dataR[i+6] += vR * y;
Vrms += (vL + vR) * y * y;
s = samp[k|0];
y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
fs2 = fs1; fs1 = y; fs0 = s;
k += dk;
dataL[i+7] += vL * y;
dataR[i+7] += vR * y;
Vrms += (vL + vR) * y * y;
vL = pf_8 * vL + (1 - pf_8) * volL;
vR = pf_8 * vR + (1 - pf_8) * volR;
}
for (; i < next_event; i++) {
s = samp[k|0];
// we low-pass filter here since we are resampling some arbitrary
// frequency to f_smp; this is an anti-aliasing filter and is
// implemented as an IIR butterworth filter (usually we'd use an FIR
// brick wall filter, but this is much simpler computationally and
// sounds fine)
y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
fs2 = fs1; fs1 = y; fs0 = s;
dataL[i] += vL * y;
dataR[i] += vR * y;
Vrms += (vL + vR) * y * y;
k += dk;
}
}
ch.off = k;
ch.filterstate[0] = fs0;
ch.filterstate[1] = fs1;
ch.filterstate[2] = fs2;
ch.vL = vL;
ch.vR = vR;
return Vrms * 0.5;
}
function audio_cb(e) {
f_smp = player.audioctx.sampleRate;
var time_sound_started;
var buflen = e.outputBuffer.length;
var dataL = e.outputBuffer.getChannelData(0);
var dataR = e.outputBuffer.getChannelData(1);
var i, j, k;
for (i = 0; i < buflen; i++) {
dataL[i] = 0;
dataR[i] = 0;
}
var offset = 0;
var ticklen = 0|(f_smp * 2.5 / player.xm.bpm);
var scopewidth = XMView.scope_width;
while(buflen > 0) {
if (player.cur_pat == -1 || player.cur_ticksamp >= ticklen) {
nextTick(f_smp);
player.cur_ticksamp -= ticklen;
}
var tickduration = Math.min(buflen, ticklen - player.cur_ticksamp);
var VU = new Float32Array(player.xm.nchan);
var scopes = undefined;
for (j = 0; j < player.xm.nchan; j++) {
var scope;
if (tickduration >= 4*scopewidth) {
scope = new Float32Array(scopewidth);
for (k = 0; k < scopewidth; k++) {
scope[k] = -dataL[offset+k*4] - dataR[offset+k*4];
}
}
VU[j] = MixChannelIntoBuf(
player.xm.channelinfo[j], offset, offset + tickduration, dataL, dataR) /
tickduration;
if (tickduration >= 4*scopewidth) {
for (k = 0; k < scopewidth; k++) {
scope[k] += dataL[offset+k*4] + dataR[offset+k*4];
}
if (scopes === undefined) scopes = [];
scopes.push(scope);
}
}
if (XMView.pushEvent) {
XMView.pushEvent({
t: e.playbackTime + (0.0 + offset) / f_smp,
vu: VU,
scopes: scopes,
songpos: player.cur_songpos,
pat: player.cur_pat,
row: player.cur_row
});
}
offset += tickduration;
player.cur_ticksamp += tickduration;
buflen -= tickduration;
}
}
function ConvertSample(array, bits) {
var len = array.length;
var acc = 0;
var samp, b, k;
if (bits === 0) { // 8 bit sample
samp = new Float32Array(len);
for (k = 0; k < len; k++) {
acc += array[k];
b = acc&255;
if (b & 128) b = b-256;
samp[k] = b / 128.0;
}
return samp;
} else {
len /= 2;
samp = new Float32Array(len);
for (k = 0; k < len; k++) {
b = array[k*2] + (array[k*2 + 1] << 8);
if (b & 32768) b = b-65536;
acc = Math.max(-1, Math.min(1, acc + b / 32768.0));
samp[k] = acc;
}
return samp;
}
}
// optimization: unroll short sample loops so we can run our inner mixing loop
// uninterrupted for as long as possible; this also handles pingpong loops.
function UnrollSampleLoop(samp) {
var nloops = ((2048 + samp.looplen - 1) / samp.looplen) | 0;
var pingpong = samp.type & 2;
if (pingpong) {
// make sure we have an even number of loops if we are pingponging
nloops = (nloops + 1) & (~1);
}
var samplesiz = samp.loop + nloops * samp.looplen;
var data = new Float32Array(samplesiz);
for (var i = 0; i < samp.loop; i++) {
data[i] = samp.sampledata[i];
}
for (var j = 0; j < nloops; j++) {
var k;
if ((j&1) && pingpong) {
for (k = samp.looplen - 1; k >= 0; k--) {
data[i++] = samp.sampledata[samp.loop + k];
}
} else {
for (k = 0; k < samp.looplen; k++) {
data[i++] = samp.sampledata[samp.loop + k];
}
}
}
console.log("unrolled sample loop; looplen", samp.looplen, "x", nloops, " = ", samplesiz);
samp.sampledata = data;
samp.looplen = nloops * samp.looplen;
samp.type = 1;
}
function load(arrayBuf) {
var dv = new DataView(arrayBuf);
player.xm = {};
player.xm.songname = getstring(dv, 17, 20);
var hlen = dv.getUint32(0x3c, true) + 0x3c;
var songlen = dv.getUint16(0x40, true);
player.xm.song_looppos = dv.getUint16(0x42, true);
player.xm.nchan = dv.getUint16(0x44, true);
var npat = dv.getUint16(0x46, true);
var ninst = dv.getUint16(0x48, true);
player.xm.flags = dv.getUint16(0x4a, true);
player.xm.tempo = dv.getUint16(0x4c, true);
player.xm.bpm = dv.getUint16(0x4e, true);
player.xm.channelinfo = [];
player.xm.global_volume = player.max_global_volume;
var i, j, k;
for (i = 0; i < player.xm.nchan; i++) {
player.xm.channelinfo.push({
number: i,
filterstate: new Float32Array(3),
vol: 0,
pan: 128,
period: 1920 - 48*16,
vL: 0, vR: 0, // left right volume envelope followers (changes per sample)
vLprev: 0, vRprev: 0,
mute: 0,
volE: 0, panE: 0,
retrig: 0,
vibratopos: 0,
vibratodepth: 1,
vibratospeed: 1,
vibratotype: 0,
});
}
console.log("header len " + hlen);
console.log("songlen %d, %d channels, %d patterns, %d instruments", songlen, player.xm.nchan, npat, ninst);
console.log("loop @%d", player.xm.song_looppos);
console.log("flags=%d tempo %d bpm %d", player.xm.flags, player.xm.tempo, player.xm.bpm);
player.xm.songpats = [];
for (i = 0; i < songlen; i++) {
player.xm.songpats.push(dv.getUint8(0x50 + i));
}
console.log("song patterns: ", player.xm.songpats);
var idx = hlen;
player.xm.patterns = [];
for (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 (j = 0; patsize > 0 && j < patrows; j++) {
row = [];
for (k = 0; k < player.xm.nchan; k++) {
var byte0 = dv.getUint8(idx); idx++;
var note = -1, inst = -1, vol = -1, efftype = 0, effparam = 0;
if (byte0 & 0x80) {
if (byte0 & 0x01) {
note = dv.getUint8(idx) - 1; 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++;
}
var notedata = [note, inst, vol, efftype, effparam];
row.push(notedata);
}
pattern.push(row);
}
player.xm.patterns.push(pattern);
}
player.xm.instruments = [];
// 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 inst = {
'name': instname,
'number': i,
};
if (nsamp > 0) {
var samplemap = new Uint8Array(arrayBuf, idx+33, 96);
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);
var env_npan = dv.getUint8(idx+226);
var env_pan_type = dv.getUint8(idx+234);
var env_pan_sustain = dv.getUint8(idx+230);
var env_pan_loop_start = dv.getUint8(idx+231);
var env_pan_loop_end = dv.getUint8(idx+232);
var vol_fadeout = dv.getUint16(idx+239, true);
var env_vol = [];
for (j = 0; j < env_nvol*2; j++) {
env_vol.push(dv.getUint16(idx+129+j*2, true));
}
var env_pan = [];
for (j = 0; j < env_npan*2; j++) {
env_pan.push(dv.getUint16(idx+177+j*2, true));
}
// 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 %s: '%s' %d samples, samphdrsiz %d",
hdrsiz, (i+1).toString(16), instname, nsamp, samphdrsiz);
idx += hdrsiz;
var totalsamples = 0;
var samps = [];
for (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.getInt8(idx+16);
var sampname = getstring(dv, idx+18, 22);
var sampleoffset = totalsamples;
if (samplooplen === 0) {
samptype &= ~3;
}
console.log("sample %d: len %d name '%s' loop %d/%d vol %d offset %s",
j, samplen, sampname, samploop, samplooplen, sampvol, sampleoffset.toString(16));
console.log(" type %d note %s(%d) finetune %d pan %d",
samptype, prettify_note(sampnote + 12*4), sampnote, sampfinetune, samppan);
console.log(" vol env", env_vol, env_vol_sustain,
env_vol_loop_start, env_vol_loop_end, "type", env_vol_type,
"fadeout", vol_fadeout);
console.log(" pan env", env_pan, env_pan_sustain,
env_pan_loop_start, env_pan_loop_end, "type", env_pan_type);
var samp = {
'len': samplen, 'loop': samploop,
'looplen': samplooplen, 'note': sampnote, 'fine': sampfinetune,
'pan': samppan, 'type': samptype, 'vol': sampvol,
'fileoffset': sampleoffset
};
// length / pointers are all specified in bytes; fixup for 16-bit samples
samps.push(samp);
idx += samphdrsiz;
totalsamples += samplen;
}
for (j = 0; j < nsamp; j++) {
var samp = samps[j];
samp.sampledata = ConvertSample(
new Uint8Array(arrayBuf, idx + samp.fileoffset, samp.len), samp.type & 16);
if (samp.type & 16) {
samp.len /= 2;
samp.loop /= 2;
samp.looplen /= 2;
}
// unroll short loops and any pingpong loops
if ((samp.type & 3) && (samp.looplen < 2048 || (samp.type & 2))) {
UnrollSampleLoop(samp);
}
}
idx += totalsamples;
inst.samplemap = samplemap;
inst.samples = samps;
if (env_vol_type) {
// insert an automatic fadeout to 0 at the end of the envelope
var env_end_tick = env_vol[env_vol.length-2];
if (!(env_vol_type & 2)) { // if there's no sustain point, create one
env_vol_sustain = env_vol.length / 2;
}
if (vol_fadeout > 0) {
var fadeout_ticks = 65536.0 / vol_fadeout;
env_vol.push(env_end_tick + fadeout_ticks);
env_vol.push(0);
}
inst.env_vol = new Envelope(
env_vol,
env_vol_type,
env_vol_sustain,
env_vol_loop_start,
env_vol_loop_end);
} else {
// no envelope, then just make a default full-volume envelope.
// i thought this would use fadeout, but apparently it doesn't.
inst.env_vol = new Envelope([0, 64, 1, 0], 2, 0, 0, 0);
}
if (env_pan_type) {
if (!(env_pan_type & 2)) { // if there's no sustain point, create one
env_pan_sustain = env_pan.length / 2;
}
inst.env_pan = new Envelope(
env_pan,
env_pan_type,
env_pan_sustain,
env_pan_loop_start,
env_pan_loop_end);
} else {
// create a default empty envelope
inst.env_pan = new Envelope([0, 32], 0, 0, 0, 0);
}
} else {
idx += hdrsiz;
console.log("empty instrument", i, hdrsiz, idx);
}
player.xm.instruments.push(inst);
}
console.log("loaded \"" + player.xm.songname + "\"");
return true;
}
var jsNode, gainNode;
function init() {
if (!player.audioctx) {
var audioContext = window.AudioContext || window.webkitAudioContext;
player.audioctx = new audioContext();
gainNode = player.audioctx.createGain();
gainNode.gain.value = 0.1; // master volume
}
if (player.audioctx.createScriptProcessor === undefined) {
jsNode = player.audioctx.createJavaScriptNode(16384, 0, 2);
} else {
jsNode = player.audioctx.createScriptProcessor(16384, 0, 2);
}
jsNode.onaudioprocess = audio_cb;
gainNode.connect(player.audioctx.destination);
player.effects_t0 = [ // effect functions on tick 0
eff_t1_0, // 1, arpeggio is processed on all ticks
eff_t0_1,
eff_t0_2,
eff_t0_3,
eff_t0_4, // 4
eff_t0_a, // 5, same as A on first tick
eff_t0_a, // 6, same as A on first tick
eff_unimplemented_t0, // 7
eff_t0_8, // 8
eff_t0_9, // 9
eff_t0_a, // a
eff_t0_b, // b
eff_t0_c, // c
eff_t0_d, // d
eff_t0_e, // e
eff_t0_f, // f
eff_t0_g, // g
eff_t0_h, // h
eff_unimplemented_t0, // i
eff_unimplemented_t0, // j
eff_unimplemented_t0, // k
eff_unimplemented_t0, // l
eff_unimplemented_t0, // m
eff_unimplemented_t0, // n
eff_unimplemented_t0, // o
eff_unimplemented_t0, // p
eff_unimplemented_t0, // q
eff_t0_r, // r
eff_unimplemented_t0, // s
eff_unimplemented_t0, // t
eff_unimplemented_t0, // u
eff_unimplemented_t0, // v
eff_unimplemented_t0, // w
eff_unimplemented_t0, // x
eff_unimplemented_t0, // y
eff_unimplemented_t0, // z
];
player.effects_t1 = [ // effect functions on tick 1+
eff_t1_0,
eff_t1_1,
eff_t1_2,
eff_t1_3,
eff_t1_4,
eff_t1_5, // 5
eff_t1_6, // 6
eff_unimplemented, // 7
null, // 8
null, // 9
eff_t1_a, // a
null, // b
null, // c
null, // d
eff_t1_e, // e
null, // f
null, // g
eff_t1_h, // h
eff_unimplemented, // i
eff_unimplemented, // j
eff_unimplemented, // k
eff_unimplemented, // l
eff_unimplemented, // m
eff_unimplemented, // n
eff_unimplemented, // o
eff_unimplemented, // p
eff_unimplemented, // q
eff_t1_r, // r
eff_unimplemented, // s
eff_unimplemented, // t
eff_unimplemented, // u
eff_unimplemented, // v
eff_unimplemented, // w
eff_unimplemented, // x
eff_unimplemented, // y
eff_unimplemented // z
];
}
player.playing = false;
function play() {
if (!player.playing) {
// put paused events back into action, if any
if (XMView.resume) XMView.resume();
// start playing
jsNode.connect(gainNode);
// hack to get iOS to play anything
var temp_osc = player.audioctx.createOscillator();
temp_osc.connect(player.audioctx.destination);
!!temp_osc.start ? temp_osc.start(0) : temp_osc.noteOn(0);
!!temp_osc.stop ? temp_osc.stop(0) : temp_osc.noteOff(0);
temp_osc.disconnect();
}
player.playing = true;
}
function pause() {
if (player.playing) {
jsNode.disconnect(gainNode);
if (XMView.pause) XMView.pause();
}
player.playing = false;
}
function stop() {
if (player.playing) {
jsNode.disconnect(gainNode);
player.playing = false;
}
player.cur_pat = -1;
player.cur_row = 64;
player.cur_songpos = -1;
player.cur_ticksamp = 0;
player.xm.global_volume = player.max_global_volume;
if (XMView.stop) XMView.stop();
init();
}
function eff_t1_0(ch) { // arpeggio
if (ch.effectdata !== 0 && ch.inst !== undefined) {
var arpeggio = [0, ch.effectdata>>4, ch.effectdata&15];
var note = ch.note + arpeggio[player.cur_tick % 3];
ch.period = player.periodForNote(ch, note);
}
}
function eff_t0_1(ch, data) { // pitch slide up
if (data !== 0) {
ch.slideupspeed = data;
}
}
function eff_t1_1(ch) { // pitch slide up
if (ch.slideupspeed !== undefined) {
// is this limited? it appears not
ch.period -= ch.slideupspeed;
}
}
function eff_t0_2(ch, data) { // pitch slide down
if (data !== 0) {
ch.slidedownspeed = data;
}
}
function eff_t1_2(ch) { // pitch slide down
if (ch.slidedownspeed !== undefined) {
// 1728 is the period for C-1
ch.period = Math.min(1728, ch.period + ch.slidedownspeed);
}
}
function eff_t0_3(ch, data) { // portamento
if (data !== 0) {
ch.portaspeed = data;
}
}
function eff_t1_3(ch) { // portamento
if (ch.periodtarget !== undefined && ch.portaspeed !== undefined) {
if (ch.period > ch.periodtarget) {
ch.period = Math.max(ch.periodtarget, ch.period - ch.portaspeed);
} else {
ch.period = Math.min(ch.periodtarget, ch.period + ch.portaspeed);
}
}
}
function eff_t0_4(ch, data) { // vibrato
if (data & 0x0f) {
ch.vibratodepth = (data & 0x0f) * 2;
}
if (data >> 4) {
ch.vibratospeed = data >> 4;
}
eff_t1_4(ch);
}
function eff_t1_4(ch) { // vibrato
ch.periodoffset = getVibratoDelta(ch.vibratotype, ch.vibratopos) * ch.vibratodepth;
if (isNaN(ch.periodoffset)) {
console.log("vibrato periodoffset NaN?",
ch.vibratopos, ch.vibratospeed, ch.vibratodepth);
ch.periodoffset = 0;
}
// only updates on non-first ticks
if (player.cur_tick > 0) {
ch.vibratopos += ch.vibratospeed;
ch.vibratopos &= 63;
}
}
function getVibratoDelta(type, x) {
var delta = 0;
switch (type & 0x03) {
case 1: // sawtooth (ramp-down)
delta = ((1 + x * 2 / 64) % 2) - 1;
break;
case 2: // square
case 3: // random (in FT2 these two are the same)
delta = x < 32 ? 1 : -1;
break;
case 0:
default: // sine
delta = Math.sin(x * Math.PI / 32);
break;
}
return delta;
}
function eff_t1_5(ch) { // portamento + volume slide
eff_t1_a(ch);
eff_t1_3(ch);
}
function eff_t1_6(ch) { // vibrato + volume slide
eff_t1_a(ch);
eff_t1_4(ch);
}
function eff_t0_8(ch, data) { // set panning
ch.pan = data;
}
function eff_t0_9(ch, data) { // sample offset
ch.off = data * 256;
}
function eff_t0_a(ch, data) { // volume slide
if (data) {
ch.volumeslide = -(data & 0x0f) + (data >> 4);
}
}
function eff_t1_a(ch) { // volume slide
if (ch.volumeslide !== undefined) {
ch.vol = Math.max(0, Math.min(64, ch.vol + ch.volumeslide));
}
}
function eff_t0_b(ch, data) { // song jump
if (data < player.xm.songpats.length) {
player.cur_songpos = data - 1;
player.cur_pat = -1;
player.cur_row = -1;
}
}
function eff_t0_c(ch, data) { // set volume
ch.vol = Math.min(64, data);
}
function eff_t0_d(ch, data) { // pattern jump
player.cur_songpos++;
if (player.cur_songpos >= player.xm.songpats.length)
player.cur_songpos = player.xm.song_looppos;
player.cur_pat = player.xm.songpats[player.cur_songpos];
player.next_row = (data >> 4) * 10 + (data & 0x0f);
}
function eff_t0_e(ch, data) { // extended effects!
var eff = data >> 4;
data = data & 0x0f;
switch (eff) {
case 1: // fine porta up
ch.period -= data;
break;
case 2: // fine porta down
ch.period += data;
break;
case 4: // set vibrato waveform
ch.vibratotype = data & 0x07;
break;
case 5: // finetune
ch.fine = (data<<4) + data - 128;
break;
case 6: // pattern loop
if (data == 0) {
ch.loopstart = player.cur_row
} else {
if (typeof ch.loopend === "undefined") {
ch.loopend = player.cur_row
ch.loopremaining = data
}
if(ch.loopremaining !== 0) {
ch.loopremaining--
player.next_row = ch.loopstart || 0
} else {
delete ch.loopend
delete ch.loopstart
}
}
break;
case 8: // panning
ch.pan = data * 0x11;
break;
case 0x0a: // fine vol slide up (with memory)
if (data === 0 && ch.finevolup !== undefined)
data = ch.finevolup;
ch.vol = Math.min(64, ch.vol + data);
ch.finevolup = data;
break;
case 0x0b: // fine vol slide down
if (data === 0 && ch.finevoldown !== undefined)
data = ch.finevoldown;
ch.vol = Math.max(0, ch.vol - data);
ch.finevoldown = data;
break;
case 0x0c: // note cut handled in eff_t1_e
break;
default:
console.log("unimplemented extended effect E", ch.effectdata.toString(16));
break;
}
}
function eff_t1_e(ch) { // note cut
switch (ch.effectdata >> 4) {
case 0x0c:
if (player.cur_tick == (ch.effectdata & 0x0f)) {
ch.vol = 0;
}
break;
}
}
function eff_t0_f(ch, data) { // set tempo
if (data === 0) {
console.log("tempo 0?");
return;
} else if (data < 0x20) {
player.xm.tempo = data;
} else {
player.xm.bpm = data;
}
}
function eff_t0_g(ch, data) { // set global volume
if (data <= 0x40) {
// volume gets multiplied by 2 to match
// the initial max global volume of 128
player.xm.global_volume = Math.max(0, data * 2);
} else {
player.xm.global_volume = player.max_global_volume;
}
}
function eff_t0_h(ch, data) { // global volume slide
if (data) {
// same as Axy but multiplied by 2
player.xm.global_volumeslide = (-(data & 0x0f) + (data >> 4)) * 2;
}
}
function eff_t1_h(ch) { // global volume slide
if (player.xm.global_volumeslide !== undefined) {
player.xm.global_volume = Math.max(0, Math.min(player.max_global_volume,
player.xm.global_volume + player.xm.global_volumeslide));
}
}
function eff_t0_r(ch, data) { // retrigger
if (data & 0x0f) ch.retrig = (ch.retrig & 0xf0) + (data & 0x0f);
if (data & 0xf0) ch.retrig = (ch.retrig & 0x0f) + (data & 0xf0);
// retrigger volume table
switch (ch.retrig >> 4) {
case 1: ch.vol -= 1; break;
case 2: ch.vol -= 2; break;
case 3: ch.vol -= 4; break;
case 4: ch.vol -= 8; break;
case 5: ch.vol -= 16; break;
case 6: ch.vol *= 2; ch.vol /= 3; break;
case 7: ch.vol /= 2; break;
case 9: ch.vol += 1; break;
case 0x0a: ch.vol += 2; break;
case 0x0b: ch.vol += 4; break;
case 0x0c: ch.vol += 8; break;
case 0x0d: ch.vol += 16; break;
case 0x0e: ch.vol *= 3; ch.vol /= 2; break;
case 0x0f: ch.vol *= 2; break;
}
ch.vol = Math.min(64, Math.max(0, ch.vol));
}
function eff_t1_r(ch) {
if (player.cur_tick % (ch.retrig & 0x0f) === 0) {
ch.off = 0;
}
}
function eff_unimplemented() {}
function eff_unimplemented_t0(ch, data) {
console.log("unimplemented effect", player.prettify_effect(ch.effect, data));
}
})(window);