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);