diff --git a/html/xm.js b/html/xm.js
new file mode 100644
index 0000000..bf53651
--- /dev/null
+++ b/html/xm.js
@@ -0,0 +1,1351 @@
+/*
+
+The MIT License (MIT)
+
+Copyright (c) 2015 Andy Sloane
+
+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);