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