diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/package.json b/package.json index 96863a7..05c84f0 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,19 @@ { "name": "jsxm", - "version": "1", + "version": "1.0.0", + "description": "WebAudio FastTracker 2 .XM module player", + "keywords": "fasttracker xm webaudio", + "uri": "http://www.a1k0n.net/code/jsxm/", + "repository": "a1k0n/jsxm", + "author": "Andy Sloane (http://www.a1k0n.net/)", + "main": "index.html", "window": { "frame": true, "toolbar": false, "position": "center", "resizable": true - } + }, + "scripts": { "test": "node test/all.js" }, + "devDependencies": { "test": ">=0.0.5" } } diff --git a/test/all.js b/test/all.js new file mode 100644 index 0000000..c12d2a8 --- /dev/null +++ b/test/all.js @@ -0,0 +1,82 @@ +window = {}; + +require('../xm.js', window); +require('../xmeffects.js', window); + +var XMPlayer = window.XMPlayer; + +// set up basic blank single-channel, single-pattern XM +function ResetXMData() { + var xm = {}; + window.XMPlayer.xm = xm; + xm.channelinfo = []; + xm.songname = "test song"; + xm.song_looppos = 0; + xm.nchan = 1; + xm.flags = 1; + xm.tempo = 3; + xm.bpm = 125; + xm.channelinfo.push({ + number: 0, + filterstate: new Float32Array(3), + vol: 0, + pan: 128, + period: 1920 - 48*16, + vL: 0, vR: 0, + vLprev: 0, vRprev: 0, + mute: 0, + volE: 0, panE: 0, + retrig: 0, + vibratodepth: 1, + vibratospeed: 1, + }); + xm.songpats = [0]; + // 1 channel, 2 row blank pattern + xm.patterns = [ + [[[-1, -1, -1, 0, 0]]], + [[[-1, -1, -1, 0, 0]]]]; + xm.instruments = []; + xm.instruments.push({ + 'name': "test instrument", + 'number': 0, + 'samples': [ + { 'len': 256, 'loop': 0, + 'looplen': 256, 'note': 0, 'fine': 0, + 'pan': 128, 'type': 0, 'vol': 64, + 'sampledata': new Float32Array(256) + } + ], + 'samplemap': new Uint8Array(96), + 'env_vol': new XMPlayer.Envelope([0, 64, 1, 0], 2, 0, 0, 0), + 'env_pan': new XMPlayer.Envelope([0, 32], 0, 0, 0, 0) + }); + // reset song position + XMPlayer.cur_songpos = -1; + XMPlayer.cur_pat = -1; + XMPlayer.cur_tick = xm.tempo; + return xm; +} + +// using assert passed to the test function that just logs failures +exports['test XM startup'] = function(assert) { + ResetXMData(); + XMPlayer.nextTick(); + assert.equal(XMPlayer.cur_songpos, 0, 'advance to initial song position'); + assert.equal(XMPlayer.cur_pat, 0, 'advance to pattern 0'); + assert.equal(XMPlayer.cur_tick, 0, 'advance to tick 0'); + assert.equal(XMPlayer.cur_row, 1, 'advance to row 1'); +}; + +exports['test note on'] = function(assert) { + var xm = ResetXMData(); + // [row][column][channel] + xm.patterns[0][0][0] = [48, 1, 0x10 + 33, 0, 0]; // C-4 1 40 000 + XMPlayer.nextTick(); + var ch = xm.channelinfo[0]; + assert.equal(ch.note, 48, 'set note'); + assert.equal(ch.period, 1152, 'channel period'); + assert.equal(ch.vol, 33, 'channel volume'); + assert.equal(ch.samp, xm.instruments[0].samples[0], 'set sample'); +}; + +if (module == require.main) require('test').run(exports); diff --git a/xm.js b/xm.js index 787b788..576e68f 100644 --- a/xm.js +++ b/xm.js @@ -9,7 +9,7 @@ if (!window.XMView) { } var XMView = window.XMView; -player.PeriodForNote = PeriodForNote; +player.periodForNote = periodForNote; player.prettify_effect = prettify_effect; player.initAudio = initAudio; player.loadXM = loadXM; @@ -23,6 +23,10 @@ player.cur_ticksamp = 0; player.cur_tick = 6; player.xm = {}; // contains all song data +// exposed for testing +player.nextTick = nextTick; +player.Envelope = Envelope; + // for pretty-printing notes var _note_names = [ "C-", "C#", "D-", "D#", "E-", "F-", @@ -30,6 +34,10 @@ var _note_names = [ 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 "^^^"; @@ -72,7 +80,7 @@ function getstring(dv, offset, len) { // Return 2-pole Butterworth lowpass filter coefficients for // center frequncy f_c (relative to sampling frequency) -function FilterCoeffs(f_c) { +function filterCoeffs(f_c) { if (f_c > 0.5) { // we can't lowpass above the nyquist frequency... f_c = 0.5; } @@ -84,24 +92,21 @@ function FilterCoeffs(f_c) { return [gain, 2*c, -c*c - s*s]; } -popfilter = FilterCoeffs(200.0 / 44100.0); -popfilter_alpha = 0.9837; - -function UpdateChannelPeriod(ch, period) { +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); + ch.filter = filterCoeffs(ch.doff / 2); } -function PeriodForNote(ch, note) { +function periodForNote(ch, note) { return 1920 - (note + ch.samp.note)*16 - ch.samp.fine / 8.0; } -function next_row() { +function nextRow() { if (player.cur_pat == -1 || player.cur_row >= player.xm.patterns[player.cur_pat].length) { player.cur_row = 0; player.cur_songpos++; @@ -196,7 +201,7 @@ function next_row() { // special handling for portamentos: don't trigger the note if (ch.effect == 3 || ch.effect == 5) { if (r[i][0] != -1) { - ch.periodtarget = PeriodForNote(ch, ch.note); + ch.periodtarget = periodForNote(ch, ch.note); } triggernote = false; if (inst && inst.samplemap) { @@ -224,7 +229,7 @@ function next_row() { ch.env_vol = new EnvelopeFollower(inst.env_vol); ch.env_pan = new EnvelopeFollower(inst.env_pan); if (ch.note != undefined) { - ch.period = PeriodForNote(ch, ch.note); + ch.period = periodForNote(ch, ch.note); } } } @@ -279,11 +284,11 @@ EnvelopeFollower.prototype.Tick = function(release) { return value; }; -function next_tick() { +function nextTick() { player.cur_tick++; if (player.cur_tick >= player.xm.tempo) { player.cur_tick = 0; - next_row(); + nextRow(); } for (var j = 0; j < player.xm.nchan; j++) { var ch = player.xm.channelinfo[j]; @@ -305,7 +310,7 @@ function next_tick() { } ch.volE = ch.env_vol.Tick(ch.release); ch.panE = ch.env_pan.Tick(ch.release); - UpdateChannelPeriod(ch, ch.period + ch.periodoffset); + updateChannelPeriod(ch, ch.period + ch.periodoffset); } } @@ -386,7 +391,7 @@ function MixChannelIntoBuf(ch, start, end, dataL, dataR) { var i = start; var failsafe = 100; while (i < end) { - if (failsafe-- == 0) { + if (failsafe-- === 0) { console.log("failsafe in mixing loop! channel", ch.number, k, sample_end, loopstart, looplen, dk); break; @@ -518,7 +523,7 @@ function audio_cb(e) { while(buflen > 0) { if (player.cur_pat == -1 || player.cur_ticksamp >= ticklen) { - next_tick(f_smp); + nextTick(f_smp); player.cur_ticksamp -= ticklen; } var tickduration = Math.min(buflen, ticklen - player.cur_ticksamp); diff --git a/xmeffects.js b/xmeffects.js index b6139f6..ef429fa 100644 --- a/xmeffects.js +++ b/xmeffects.js @@ -8,7 +8,7 @@ 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); + ch.period = player.periodForNote(ch, note); } } @@ -294,4 +294,4 @@ player.effects_t1 = [ // effect functions on tick 1+ eff_unimplemented // z ]; -})(window || {}); +})(window);