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/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..796d98f
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,3 @@
+language: node_js
+node_js:
+ - "0.12"
diff --git a/README.md b/README.md
index e559534..b6609c9 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,40 @@
# xm.js
+[](https://travis-ci.org/a1k0n/jsxm)
+
FastTracker 2 .XM player, written for fun.
-[Demo](http://www.a1k0n.net/code/jsxm/) [warning: plays sound immediately on
-load]
+[Demo](http://www.a1k0n.net/code/jsxm/)
-By default it plays "my dirty old kamel" by Alexander Bulér aka
-[Zalza](http://zalza.bandcamp.com/). I love this tune, and wrote this code just
-to play it. It can also load other .XMs by loading index.html?_tune-uri.xm_,
-though it either needs to be local to your domain or cross-origin resource
-sharing must be enabled for that to work.
+There is an XM player and a visualizer which are separate components. The
+player API looks like this:
-It is fairly feature-complete, but is missing a bunch of effects.
+ - `XMPlayer.init` -> starts up audio context; it's available as
+ `XMPlayer.audioctx`
+ - `XMPlayer.load(ArrayBuffer)` -> returns `true` if loaded, otherwise
+ barfs randomly
+ - `XMPlayer.play()` -> starts playing
+ - `XMPlayer.pause()` -> obvious
+ - `XMPlayer.stop()` -> obvious; call this before loading a new one
+
+Loading trackview.js is optional; without it, the player won't do any
+visualizations. Or, you can override the following to get callbacks:
+
+ - `XMView.pushEvent(e)` -> push an audio event onto the queue. Called
+ once per tick (about 50Hz, controlled by song). `e` contains fields:
+ - `t` - audio timestamp
+ - `vu` - Float32Array of RMS power (volume) for each channel
+ - `scopes` - [Float32Array] of oscilloscope data, one array per
+ channel; `XMView.scope_width` contains # of samples to produce here
+ - `songpos` - position in the song (# patterns played)
+ - `pat` - pattern number currently playing
+ - `row` - row within pattern
+ - `XMView.pause()` - pause visualization
+ - `XMView.stop()` - stop/reset visualization
+
+The code which defines what the buttons do and downloads songs and so
+forth is in `shell.js`.
+
+The player is fairly feature-complete, but is missing a bunch of effects.
MIT license.
diff --git a/index.html b/index.html
index 3bc0bb7..3c6535e 100644
--- a/index.html
+++ b/index.html
@@ -1,26 +1,82 @@
js xm
-
+
+
+
+
+
-
-
-
-
-
-
+
+
code: github.com/a1k0n/jsxm
todo:
- - file picker, upload more xms to a host we can access cross-origin
- - make display even more faithfully FT2-like
- - render instrument list in ft2 font
- - oscilloscope trigger on zero crossing or something
- missing XM effects:
- - E3x, E4x, E5x, E6x, E7x, E9x, EDx, EEx
+ - E3x, E6x, E7x, E9x, EDx, EEx
- 7xy - tremolo
- - Gxx, Hxy, Kxx, Lxx, Pxy, Txy
+ - Kxx, Lxx, Pxy, Txy
+ - render pattern with the wider fonts for fewer channels
+ - fix occasional rendering/audio hiccups when switching songs
diff --git a/kamel.xm b/kamel.xm
deleted file mode 100644
index 8766dff..0000000
Binary files a/kamel.xm and /dev/null differ
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e21fffb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "jsxm",
+ "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/)",
+ "license": "MIT",
+ "contributors": [{
+ "name": "Johan Hillerström",
+ "email": "progr@mmer.nu"
+ }],
+ "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/shell.js b/shell.js
new file mode 100644
index 0000000..8a96b70
--- /dev/null
+++ b/shell.js
@@ -0,0 +1,111 @@
+(function (window, document) {
+if (!window.XMPlayer) {
+ window.XMPlayer = {};
+}
+var XMPlayer = window.XMPlayer;
+if (!window.XMView) {
+ window.XMView = {};
+}
+var XMView = window.XMView;
+
+function loadXMAndInit(xmdata) {
+ if (!XMPlayer.load(xmdata)) {
+ return;
+ }
+
+ XMView.init();
+
+ var playbutton = document.getElementById("playpause");
+ playbutton.innerHTML='Play';
+ playbutton.onclick = function() {
+ if (XMPlayer.playing) {
+ XMPlayer.pause();
+ playbutton.innerHTML='Play';
+ } else {
+ XMPlayer.play();
+ playbutton.innerHTML='Pause';
+ }
+ };
+ playbutton.disabled = false;
+
+ return XMPlayer.xm;
+}
+
+function downloadXM(uri) {
+ var xmReq = new XMLHttpRequest();
+ xmReq.open("GET", uri, true);
+ xmReq.responseType = "arraybuffer";
+ xmReq.onload = function (xmEvent) {
+ var arrayBuffer = xmReq.response;
+ if (arrayBuffer) {
+ loadXMAndInit(arrayBuffer);
+ } else {
+ console.log("unable to load", uri);
+ }
+ };
+ xmReq.send(null);
+}
+
+XMPlayer.allowDrop = function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var elem = document.getElementById("playercontainer");
+ elem.className = (ev.type == "dragover" ? "draghover" : "playercontainer");
+ return false;
+};
+
+XMPlayer.handleDrop = function(e) {
+ console.log(e);
+ e.preventDefault();
+ var elem = document.getElementById("playercontainer");
+ elem.className = "playercontainer";
+ var files = e.target.files || e.dataTransfer.files;
+ if (files.length < 1) return false;
+ var reader = new FileReader();
+ reader.onload = function(e) {
+ XMPlayer.stop();
+ loadXMAndInit(e.target.result);
+ };
+ reader.readAsArrayBuffer(files[0]);
+ return false;
+};
+
+function initFilelist() {
+ var el = document.getElementById('filelist');
+ xmuris.forEach(function(entry) {
+ var a = document.createElement('a');
+ a.text = entry[0];
+ a.href = '#'+entry[1];
+ a.onclick = function() {
+ el.style.display = "none";
+ XMPlayer.stop();
+ downloadXM(baseuri + entry[1]);
+ };
+ el.appendChild(a);
+ el.appendChild(document.createElement('br'));
+ });
+ var loadbutton = document.getElementById('loadbutton');
+ loadbutton.onclick = function() {
+ if (el.style.display == "none") {
+ el.style.display = "block";
+ } else {
+ el.style.display = "none";
+ }
+ };
+}
+
+window.onload = function() {
+ XMPlayer.init();
+ initFilelist();
+
+ var uri = location.hash.substr(1);
+ if (uri === "") {
+ uri = "kamel.xm";
+ }
+ if (!uri.startsWith("http")) {
+ uri = baseuri + uri;
+ }
+ downloadXM(uri);
+};
+
+})(window, document);
diff --git a/test/all.js b/test/all.js
new file mode 100644
index 0000000..e84bc0c
--- /dev/null
+++ b/test/all.js
@@ -0,0 +1,130 @@
+window = {};
+
+require('../xm.js');
+require('../xmeffects.js');
+var XMPlayer = window.XMPlayer;
+
+testdata = require('./testdata.js');
+
+// test TODO:
+// - audio output sample positions after ticks
+// - unit test for envelopes
+//
+// using assert passed to the test function that just logs failures
+exports['test XM startup'] = function(assert) {
+ testdata.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, 0, 'advance to row 0');
+};
+
+exports['test non-existing song position'] = function(assert) {
+ testdata.resetXMData();
+ // 2 existing patterns, 1 non-existing
+ XMPlayer.xm.songpats = [0, 1, 2]
+ // 1 channel, 1 row, 2 blank patterns
+ XMPlayer.xm.patterns = [
+ [[[-1, -1, -1, 0, 0]]],
+ [[[-1, -1, -1, 0, 0]]]
+ ];
+ XMPlayer.xm.tempo = 1;
+ 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_row, 0, 'advance to row 0');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 1, 'advance to song position 1');
+ assert.equal(XMPlayer.cur_pat, 1, 'advance to pattern 1');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 0, 'return to song position 0');
+ assert.equal(XMPlayer.cur_pat, 0, 'return to pattern 0');
+};
+
+exports['test non-existing first song position'] = function(assert) {
+ testdata.resetXMData();
+ // 2 existing patterns, 1 non-existing
+ XMPlayer.xm.songpats = [2, 0, 1]
+ // 1 channel, 1 row, 2 blank patterns
+ XMPlayer.xm.patterns = [
+ [[[-1, -1, -1, 0, 0]]],
+ [[[-1, -1, -1, 0, 0]]]
+ ];
+ XMPlayer.xm.tempo = 1;
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 1, 'advance to song position 1');
+ assert.equal(XMPlayer.cur_pat, 0, 'advance to pattern 0');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 2, 'advance to song position 2');
+ assert.equal(XMPlayer.cur_pat, 1, 'advance to pattern 1');
+};
+
+exports['test multiple non-existing song positions'] = function(assert) {
+ testdata.resetXMData();
+ // 2 existing patterns, 2 non-existing
+ XMPlayer.xm.songpats = [2, 0, 3, 1]
+ // 1 channel, 1 row, 2 blank patterns
+ XMPlayer.xm.patterns = [
+ [[[-1, -1, -1, 0, 0]]],
+ [[[-1, -1, -1, 0, 0]]]
+ ];
+ XMPlayer.xm.tempo = 1;
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 1, 'advance to song position 1');
+ assert.equal(XMPlayer.cur_pat, 0, 'advance to pattern 0');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 3, 'advance to song position 3');
+ assert.equal(XMPlayer.cur_pat, 1, 'advance to pattern 1');
+};
+
+exports['test non-existing song position with loop'] = function(assert) {
+ testdata.resetXMData();
+ // 2 existing patterns, 1 non-existing
+ XMPlayer.xm.songpats = [0, 1, 2]
+ // 1 channel, 1 row, 2 blank patterns
+ XMPlayer.xm.patterns = [
+ [[[-1, -1, -1, 0, 0]]],
+ [[[-1, -1, -1, 0, 0]]]
+ ];
+ XMPlayer.xm.song_looppos = 1;
+ XMPlayer.xm.tempo = 1;
+ 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_row, 0, 'advance to row 0');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 1, 'advance to song position 1');
+ assert.equal(XMPlayer.cur_pat, 1, 'advance to pattern 1');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 1, 'replay song position 1');
+ assert.equal(XMPlayer.cur_pat, 1, 'replay pattern 1');
+};
+
+exports['test non-existing song position with invalid loop'] = function(assert) {
+ testdata.resetXMData();
+ // 2 existing patterns, 1 non-existing
+ XMPlayer.xm.songpats = [0, 1, 2]
+ // 1 channel, 1 row, 2 blank patterns
+ XMPlayer.xm.patterns = [
+ [[[-1, -1, -1, 0, 0]]],
+ [[[-1, -1, -1, 0, 0]]]
+ ];
+ XMPlayer.xm.song_looppos = 4;
+ XMPlayer.xm.tempo = 1;
+ 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_row, 0, 'advance to row 0');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 1, 'advance to song position 1');
+ assert.equal(XMPlayer.cur_pat, 1, 'advance to pattern 1');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 0, 'return to song position 0');
+ assert.equal(XMPlayer.cur_pat, 0, 'return to pattern 0');
+};
+
+exports['test instruments'] = require('./instrument.js');
+exports['test effects'] = require('./effects.js');
+
+if (module == require.main) require('test').run(exports);
diff --git a/test/effects.js b/test/effects.js
new file mode 100644
index 0000000..4a84f07
--- /dev/null
+++ b/test/effects.js
@@ -0,0 +1,506 @@
+var XMPlayer = window.XMPlayer;
+
+// tests TODO:
+// - bxx: song jump
+// - rxy: retrigger w/ volume changes
+// low priority TODO (trivial or already covered by other tests):
+// - 5xy: porta+vol
+// - 6xy: vibrato+vol
+// - 8xx: panning
+// - 9xx: sample offset
+
+exports['test 0xy arpeggio'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // [pat][row][channel]
+ xm.patterns[0][0][0] = [48, 1, -1, 0, 0x4f]; // C-4 1 -- 04f
+ XMPlayer.xm.tempo = 4;
+ XMPlayer.nextTick();
+ var ch = xm.channelinfo[0];
+ assert.equal(ch.note, 48, 'note 0');
+ assert.equal(ch.period, 1152, 'row 0 tick 0 period 0');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 16*4, 'row 0 tick 1 period 4');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 16*15, 'row 0 tick 2 period f');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 16*0, 'row 0 tick 3 period 0');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 16*0, 'row 1 tick 0 period 0');
+};
+
+exports['test 1xx slide up'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // [pat][row][channel]
+ xm.patterns[0][0][0] = [48, 1, -1, 1, 0x01]; // C-4 1 -- 101
+ xm.patterns[0][1][0] = [-1, -1, -1, 1, 0x00]; // --- -- -- 100
+ XMPlayer.xm.tempo = 3;
+ XMPlayer.nextTick();
+ var ch = xm.channelinfo[0];
+ assert.equal(ch.period, 1152, 'row 0 tick 0 period 0');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 1, 'row 0 tick 1 period -1');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 2, 'row 0 tick 2 period -2');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 2, 'row 1 tick 0 period -2');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 3, 'row 1 tick 1 period -3');
+};
+
+exports['test 2xx slide down'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // [pat][row][channel]
+ xm.patterns[0][0][0] = [48, 1, -1, 2, 0x01]; // C-4 1 -- 201
+ xm.patterns[0][1][0] = [-1, -1, -1, 2, 0x00]; // --- -- -- 200
+ XMPlayer.xm.tempo = 3;
+ XMPlayer.nextTick();
+ var ch = xm.channelinfo[0];
+ assert.equal(ch.period, 1152, 'row 0 tick 0 period 0');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 + 1, 'row 0 tick 1 period +1');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 + 2, 'row 0 tick 2 period +2');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 + 2, 'row 1 tick 0 period +2');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 + 3, 'row 1 tick 1 period +3');
+};
+
+exports['test 3xx portamento'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // [pat][row][channel]
+ xm.patterns[0][0][0] = [48, 1, -1, 0, 0x00]; // C-4 1 -- 000
+ xm.patterns[0][1][0] = [49, 1, -1, 3, 0x09]; // C#4 1 -- 309
+ XMPlayer.xm.tempo = 3;
+ XMPlayer.nextTick(); // row 0 tick 0
+ var ch = xm.channelinfo[0];
+ assert.equal(ch.period, 1152, 'row 0 tick 0 period 0');
+ XMPlayer.nextTick(); // row 0 tick 1
+ XMPlayer.nextTick(); // row 0 tick 2
+ XMPlayer.nextTick(); // row 1 tick 0
+ assert.equal(ch.period, 1152, 'row 1 tick 0 period 0');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 9, 'row 1 tick 1 period -9');
+ XMPlayer.nextTick();
+ assert.equal(ch.period, 1152 - 16, 'row 1 tick 2 period -16');
+};
+
+exports['test 4xy vibrato - sine'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // vibrato 4xy: speed x, depth y
+ // full cycle is 64/speed
+ xm.patterns[0] = [
+ [[48, 1, -1, 4, 0x81]], // C-4 1 -- 481
+ [[-1, -1, -1, 4, 0x02]], // --- -- -- 402
+ [[-1, -1, -1, 4, 0x10]], // --- -- -- 410
+ [[-1, -1, -1, 4, 0x00]], // --- -- -- 400
+ [[-1, -1, -1, 0, 0x00]], // --- -- -- --- - no vibrato
+ [[-1, -1, -1, 4, 0x00]], // --- -- -- 400 - resume vibrato @ pos 0
+ [[-1, -1, 0xb2, 0, 0x00]], // --- -- V2 --- - volume effect vibrato
+ ];
+ // I should really be testing ch.doff directly
+ XMPlayer.xm.tempo = 3;
+ var ch = xm.channelinfo[0];
+ XMPlayer.nextTick(); // row 0 tick 0
+ var p0 = ch.doff;
+ assert.equal(ch.periodoffset, 0, 'row 0 tick 0 periodoffset=0');
+ XMPlayer.nextTick(); // row 0 tick 1
+ // compute logical period p from actual play frequency
+ var p = 16*12 * Math.log(p0 / ch.doff) / Math.log(2);
+ assert.equal(p.toFixed(3), "0.000", 'row 0 tick 1 period +0');
+ XMPlayer.nextTick(); // row 0 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "1.414", 'row 0 tick 2 period +1.414');
+ XMPlayer.nextTick(); // row 1 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "4.000", 'row 1 tick 0 period +4.000');
+ XMPlayer.nextTick(); // row 1 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "4.000", 'row 1 tick 1 period +4.000');
+ XMPlayer.nextTick(); // row 1 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "2.828", 'row 1 tick 2 period 2.828');
+ XMPlayer.nextTick(); // row 2 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "0.000", 'row 2 tick 0 period +0');
+ XMPlayer.nextTick(); // row 2 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "0.000", 'row 2 tick 1 period +0');
+ XMPlayer.nextTick(); // row 2 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-0.392", 'row 2 tick 2 period -0.392');
+ XMPlayer.nextTick(); // row 3 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-0.780", 'row 3 tick 0 period -0.780');
+ XMPlayer.nextTick(); // row 3 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-0.780", 'row 3 tick 1 period -0.780');
+ XMPlayer.nextTick(); // row 3 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-1.161", 'row 3 tick 2 period -1.161');
+ XMPlayer.nextTick(); // row 4 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "0.000", 'row 4 tick 0 period 0 - no vibrato');
+ XMPlayer.nextTick(); // row 4 tick 1
+ XMPlayer.nextTick(); // row 4 tick 2
+ // I actually don't know whether vibrato is supposed to reset when the effect
+ // goes away or whether it should resume. Resuming is simpler to implement so
+ // that's what I'm assuming here...
+ XMPlayer.nextTick(); // row 5 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-1.531", 'row 5 tick 0 period -1.531 - vibrato resume');
+ XMPlayer.nextTick(); // row 5 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-1.531", 'row 5 tick 1 period -1.531');
+ XMPlayer.nextTick(); // row 5 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-1.886", 'row 5 tick 2 period -1.886');
+ XMPlayer.nextTick(); // row 6 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-1.111", 'row 6 tick 0 period -1.111');
+};
+
+exports['test 4xy vibrato - saw'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // vibrato 4xy: speed x, depth y
+ // full cycle is 64/speed
+ xm.patterns[0] = [
+ [[48, 1, -1, 14, 0x41]], // C-4 1 -- E41 - 1 = saw (ramp-down)
+ [[-1, -1, -1, 4, 0x81]], // --- -- -- 481
+ [[-1, -1, -1, 4, 0x00]], // --- -- -- 400
+ [[-1, -1, -1, 0, 0x00]], // --- -- -- --- - no vibrato
+ [[-1, -1, -1, 4, 0x00]], // --- -- -- 400 - resume vibrato @ pos 0
+ ];
+ XMPlayer.xm.tempo = 4;
+ var ch = xm.channelinfo[0];
+ assert.equal(ch.vibratotype, 0, 'initial vibratotype 0');
+ XMPlayer.nextTick(); // row 0 tick 0
+ var p0 = ch.doff;
+ assert.equal(ch.vibratotype, 1, 'row 0 tick 0 vibratotype=1');
+ XMPlayer.nextTick(); // row 0 tick 1
+ XMPlayer.nextTick(); // row 0 tick 2
+ XMPlayer.nextTick(); // row 0 tick 3
+ XMPlayer.nextTick(); // row 1 tick 0
+ assert.equal(ch.periodoffset, 0, 'row 1 tick 0 periodoffset=0');
+ XMPlayer.nextTick(); // row 1 tick 1
+ // compute logical period p from actual play frequency
+ var p = 16*12 * Math.log(p0 / ch.doff) / Math.log(2);
+ assert.equal(p.toFixed(3), "0.000", 'row 1 tick 1 period +0');
+ XMPlayer.nextTick(); // row 1 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "0.500", 'row 1 tick 2 period +0.500');
+ XMPlayer.nextTick(); // row 1 tick 3
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "1.000", 'row 1 tick 3 period +1.000');
+ XMPlayer.nextTick(); // row 2 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "1.500", 'row 2 tick 0 period +1.500');
+ XMPlayer.nextTick(); // row 2 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "1.500", 'row 2 tick 1 period +1.500');
+ XMPlayer.nextTick(); // row 2 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-2.000", 'row 2 tick 2 period -2.000');
+ XMPlayer.nextTick(); // row 2 tick 3
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-1.500", 'row 2 tick 3 period -1.500');
+ XMPlayer.nextTick(); // row 3 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "0.000", 'row 3 tick 0 period 0 - no vibrato');
+ XMPlayer.nextTick(); // row 3 tick 1
+ XMPlayer.nextTick(); // row 3 tick 2
+ XMPlayer.nextTick(); // row 3 tick 3
+ XMPlayer.nextTick(); // row 4 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-1.000", 'row 4 tick 0 period -1.000 - vibrato resume');
+ XMPlayer.nextTick(); // row 4 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-1.000", 'row 4 tick 1 period -1.000');
+ XMPlayer.nextTick(); // row 4 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-0.500", 'row 4 tick 2 period -0.500');
+ XMPlayer.nextTick(); // row 4 tick 3
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "0.000", 'row 4 tick 3 period +0');
+};
+
+exports['test 4xy vibrato - square'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // vibrato 4xy: speed x, depth y
+ // full cycle is 64/speed
+ xm.patterns[0] = [
+ [[48, 1, -1, 14, 0x42]], // C-4 1 -- E42 - 2 = square
+ [[-1, -1, -1, 4, 0x81]], // --- -- -- 481
+ [[-1, -1, -1, 4, 0x02]], // --- -- -- 402
+ [[-1, -1, -1, 4, 0x10]], // --- -- -- 410
+ [[-1, -1, -1, 4, 0x03]], // --- -- -- 403
+ [[-1, -1, -1, 0, 0x00]], // --- -- -- --- - no vibrato
+ [[-1, -1, -1, 4, 0x00]], // --- -- -- 400 - resume vibrato @ pos 0
+ ];
+ XMPlayer.xm.tempo = 3;
+ var ch = xm.channelinfo[0];
+ assert.equal(ch.vibratotype, 0, 'initial vibratotype 0');
+ XMPlayer.nextTick(); // row 0 tick 0
+ var p0 = ch.doff;
+ assert.equal(ch.vibratotype, 2, 'row 0 tick 0 vibratotype=2');
+ XMPlayer.nextTick(); // row 0 tick 1
+ XMPlayer.nextTick(); // row 0 tick 2
+ XMPlayer.nextTick(); // row 1 tick 0
+ assert.equal(ch.periodoffset, 2, 'row 1 tick 0 periodoffset=2');
+ XMPlayer.nextTick(); // row 1 tick 1
+ // compute logical period p from actual play frequency
+ var p = 16*12 * Math.log(p0 / ch.doff) / Math.log(2);
+ assert.equal(p.toFixed(3), "2.000", 'row 1 tick 1 period +2.000');
+ XMPlayer.nextTick(); // row 1 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "2.000", 'row 1 tick 2 period +2.000');
+ XMPlayer.nextTick(); // row 2 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "4.000", 'row 2 tick 0 period +4.000');
+ XMPlayer.nextTick(); // row 2 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "4.000", 'row 2 tick 1 period +4.000');
+ XMPlayer.nextTick(); // row 2 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "4.000", 'row 2 tick 2 period +4.000');
+ XMPlayer.nextTick(); // row 3 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-4.000", 'row 3 tick 0 period -4.000');
+ XMPlayer.nextTick(); // row 3 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-4.000", 'row 3 tick 1 period -4.000');
+ XMPlayer.nextTick(); // row 3 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-4.000", 'row 3 tick 2 period -4.000');
+ XMPlayer.nextTick(); // row 4 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-6.000", 'row 4 tick 0 period -6.000');
+ XMPlayer.nextTick(); // row 4 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-6.000", 'row 4 tick 1 period -6.000');
+ XMPlayer.nextTick(); // row 4 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-6.000", 'row 4 tick 2 period -6.000');
+ XMPlayer.nextTick(); // row 4 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "0.000", 'row 4 tick 0 period 0 - no vibrato');
+ XMPlayer.nextTick(); // row 4 tick 1
+ XMPlayer.nextTick(); // row 4 tick 2
+ XMPlayer.nextTick(); // row 5 tick 0
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-6.000", 'row 5 tick 0 period -6.000 - vibrato resume');
+ XMPlayer.nextTick(); // row 5 tick 1
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-6.000", 'row 5 tick 1 period -6.000');
+ XMPlayer.nextTick(); // row 5 tick 2
+ p = -16*12 * Math.log(ch.doff / p0) / Math.log(2);
+ assert.equal(p.toFixed(3), "-6.000", 'row 5 tick 2 period -6.000');
+};
+
+exports['test Axy volume slide'] = function(assert) {
+ var xm = testdata.resetXMData();
+ XMPlayer.xm.tempo = 6;
+
+ xm.patterns[0] = [
+ [[48, 1, -1, 10, 0x0f]], // C-4 1 -- A0f (slide down)
+ [[-1, -1, -1, 10, 0x90]], // --- -- -- A90 (slide up)
+ [[-1, -1, -1, 10, 0x00]], // --- -- -- A00 (continue same)
+ [[-1, -1, 0x30, 10, 0x11]], // --- -- 20 A11 (invalid, do nothing)
+ ];
+ var ch = xm.channelinfo[0];
+ XMPlayer.nextTick();
+ assert.equal(ch.vol, 64, 'row 0 tick 0 vol 64');
+ XMPlayer.nextTick();
+ assert.equal(ch.vol, 49, 'row 0 tick 1 vol 49');
+ XMPlayer.nextTick();
+ assert.equal(ch.vol, 34, 'row 0 tick 2 vol 34');
+ XMPlayer.nextTick();
+ assert.equal(ch.vol, 19, 'row 0 tick 3 vol 19');
+ XMPlayer.nextTick();
+ assert.equal(ch.vol, 4, 'row 0 tick 4 vol 4');
+ XMPlayer.nextTick();
+ assert.equal(ch.vol, 0, 'row 0 tick 5 vol 0');
+ XMPlayer.nextTick();
+ assert.equal(ch.vol, 0, 'row 1 tick 0 vol 0');
+ XMPlayer.nextTick();
+ assert.equal(ch.vol, 9, 'row 1 tick 1 vol 9');
+ XMPlayer.nextTick(); // row 1 tick 2
+ XMPlayer.nextTick(); // tick 3
+ XMPlayer.nextTick(); // tick 4
+ XMPlayer.nextTick(); // tick 5
+ assert.equal(ch.vol, 45, 'row 1 tick 5 vol 45');
+ XMPlayer.nextTick(); // row 2 tick 0
+ assert.equal(ch.vol, 45, 'row 2 tick 0 vol 45');
+ XMPlayer.nextTick(); // row 2 tick 1
+ assert.equal(ch.vol, 54, 'row 2 tick 1 vol 54');
+ XMPlayer.nextTick(); // tick 2
+ XMPlayer.nextTick(); // tick 3
+ assert.equal(ch.vol, 64, 'row 2 tick 3 vol 64');
+ XMPlayer.nextTick(); // tick 4
+ XMPlayer.nextTick(); // tick 5
+ XMPlayer.nextTick(); // row 3 tick 0
+ assert.equal(ch.vol, 32, 'row 3 tick 0 vol 32');
+ XMPlayer.nextTick(); // tick 1
+ assert.equal(ch.vol, 32, 'row 3 tick 1 vol 32');
+ XMPlayer.nextTick(); // tick 2
+ assert.equal(ch.vol, 32, 'row 3 tick 2 vol 32');
+};
+
+exports['test Dxx pattern jump'] = function(assert) {
+ var xm = testdata.resetXMData();
+ xm.tempo = 2;
+ xm.patterns[0] = [
+ [[-1, -1, -1, 0x0, 0x00]], // 00 --- -- -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 01 --- -- -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 02 --- -- -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 03 --- -- -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 04 --- -- -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 05 --- -- -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 06 --- -- -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 07 --- -- -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 08 --- -- -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 09 --- -- -- 000
+ [[49, 1, -1, 0x0, 0x00]], // 0a C#4 1 -- 000
+ [[-1, -1, -1, 0x0, 0x00]], // 0b --- -- -- 000
+ ];
+ // argument is BCD, so 0x10 actually means 10 (decimal)
+ xm.patterns[1] = [
+ [[-1, -1, -1, 0x0, 0x00]], // --- -- -- 000
+ [[48, 1, -1, 0xd, 0x10]], // C-4 1 -- D10
+ [[-1, -1, -1, 0x0, 0x00]], // --- -- -- 000
+ ];
+ xm.songpats = [1, 0];
+ XMPlayer.nextTick(); // play first, empty row (pattern 1)
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.cur_songpos, 0, "songpos 0");
+ assert.equal(XMPlayer.cur_pat, 1, "pattern 1");
+ assert.equal(XMPlayer.cur_row, 0, "row 0");
+
+ XMPlayer.nextTick(); // play C-4 1 -- D01 in pattern 1
+ XMPlayer.nextTick(); // we will jump to pattern 0 row 1 after this
+
+ assert.equal(xm.channelinfo[0].period, 1152, "play C-4 on Dxx row");
+
+ XMPlayer.nextTick(); // play row 0x0a C#4 1 -- 000 in pattern 0
+
+ assert.equal(xm.channelinfo[0].period, 1136, "play C#4 on following row");
+ assert.equal(XMPlayer.cur_songpos, 1, "songpos 0");
+ assert.equal(XMPlayer.cur_pat, 0, "pattern 1");
+ assert.equal(XMPlayer.cur_row, 10, "row 10");
+};
+
+exports['test Gxx global volume'] = function(assert) {
+ var xm = testdata.resetXMData();
+ xm.patterns = [
+ [
+ [[48, 1, -1, 16, 0x40]], // C-4 1 -- G40
+ [[48, 1, -1, 16, 0x2B]], // C-4 1 -- G2B
+ // test out of bounds volume
+ [[48, 1, -1, 16, 0x80]] // C-4 1 -- G80
+ ]
+ ];
+ XMPlayer.xm.tempo = 1;
+ XMPlayer.nextTick();
+ // volume gets multiplied by 2 to match
+ // the initial max global volume of 128
+ assert.equal(XMPlayer.xm.global_volume, 0x40*2, 'global volume set to 0x40');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.xm.global_volume, 0x2B*2, 'global volume set to 0x2B');
+ XMPlayer.nextTick();
+ assert.equal(XMPlayer.xm.global_volume, 0x40*2, 'global volume set to 0x40');
+};
+
+exports['test Hxy global volume slide'] = function(assert) {
+ var xm = testdata.resetXMData();
+ xm.tempo = 6;
+ xm.global_volume = 128;
+
+ xm.patterns[0] = [
+ [[48, 1, -1, 17, 0x0f]], // C-4 1 -- H0f (slide down)
+ [[-1, -1, -1, 17, 0x90]], // --- -- -- H90 (slide up)
+ [[-1, -1, -1, 17, 0x00]], // --- -- -- H00 (continue same)
+ [[-1, -1, 0x30, 17, 0x11]], // --- -- 30 H11 (invalid, do nothing)
+ ];
+ XMPlayer.nextTick();
+ assert.equal(xm.global_volume, 128, 'row 0 tick 0 vol 128');
+ XMPlayer.nextTick();
+ assert.equal(xm.global_volume, 98, 'row 0 tick 1 vol 98');
+ XMPlayer.nextTick();
+ assert.equal(xm.global_volume, 68, 'row 0 tick 2 vol 68');
+ XMPlayer.nextTick();
+ assert.equal(xm.global_volume, 38, 'row 0 tick 3 vol 38');
+ XMPlayer.nextTick();
+ assert.equal(xm.global_volume, 8, 'row 0 tick 4 vol 8');
+ XMPlayer.nextTick();
+ assert.equal(xm.global_volume, 0, 'row 0 tick 5 vol 0');
+ XMPlayer.nextTick();
+ assert.equal(xm.global_volume, 0, 'row 1 tick 0 vol 0');
+ XMPlayer.nextTick();
+ assert.equal(xm.global_volume, 18, 'row 1 tick 1 vol 18');
+ XMPlayer.nextTick(); // row 1 tick 2
+ XMPlayer.nextTick(); // tick 3
+ XMPlayer.nextTick(); // tick 4
+ XMPlayer.nextTick(); // tick 5
+ assert.equal(xm.global_volume, 90, 'row 1 tick 5 vol 90');
+ XMPlayer.nextTick(); // row 2 tick 0
+ assert.equal(xm.global_volume, 90, 'row 2 tick 0 vol 90');
+ XMPlayer.nextTick(); // row 2 tick 1
+ assert.equal(xm.global_volume, 108, 'row 2 tick 1 vol 108');
+ XMPlayer.nextTick(); // tick 2
+ XMPlayer.nextTick(); // tick 3
+ assert.equal(xm.global_volume, 128, 'row 2 tick 3 vol 128');
+ XMPlayer.nextTick(); // tick 4
+ XMPlayer.nextTick(); // tick 5
+ XMPlayer.nextTick(); // row 3 tick 0
+ assert.equal(xm.global_volume, 128, 'row 3 tick 0 vol 128');
+ XMPlayer.nextTick(); // tick 1
+ assert.equal(xm.global_volume, 128, 'row 3 tick 1 vol 128');
+ XMPlayer.nextTick(); // tick 2
+ assert.equal(xm.global_volume, 128, 'row 3 tick 2 vol 128');
+};
+
+exports['test E4x set vibrato waveform'] = function(assert) {
+ var xm = testdata.resetXMData();
+ xm.patterns = [
+ [
+ [[48, 1, -1, 0, 0x00]], // C-4 1 -- --- (default waveform - sine)
+ [[48, 1, -1, 14, 0x41]], // C-4 1 -- E41 (saw, ramp-down)
+ [[48, 1, -1, 14, 0x42]], // C-4 1 -- E42 (square)
+ [[48, 1, -1, 14, 0x43]] // C-4 1 -- E43 (random)
+ ]
+ ];
+ xm.tempo = 1;
+ var ch = xm.channelinfo[0];
+ XMPlayer.nextTick();
+ assert.equal(ch.vibratotype, 0, 'row 0 tick 0 vibratotype=0');
+ XMPlayer.nextTick();
+ assert.equal(ch.vibratotype, 1, 'row 1 tick 0 vibratotype=1');
+ XMPlayer.nextTick();
+ assert.equal(ch.vibratotype, 2, 'row 2 tick 0 vibratotype=2');
+ XMPlayer.nextTick();
+ assert.equal(ch.vibratotype, 3, 'row 3 tick 0 vibratotype=3');
+};
+
+exports['test E5x finetune override'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // set an initial finetune so we know we're overriding it...
+ xm.instruments[0].samples[0].fine = -4;
+ xm.patterns = [
+ [
+ [[48, 1, -1, 0, 0x00]], // C-4 1 -- 000 (sample finetune -4)
+ [[48, 1, -1, 14, 0x50]], // C-4 1 -- E50 (finetune -128)
+ [[48, 1, -1, 14, 0x5f]] // C-4 1 -- E5f (finetune +127)
+ ]
+ ];
+ xm.tempo = 1;
+ var ch = xm.channelinfo[0];
+ XMPlayer.nextTick();
+ var f0 = ch.doff;
+ XMPlayer.nextTick();
+ // compare frequency relative to original finetune -4 note
+ var f1 = 12 * 128 * Math.log(ch.doff / f0) / Math.log(2);
+ assert.equal(f1.toFixed(2), "-124.00", "E50 finetune -128");
+ XMPlayer.nextTick();
+ var f2 = 12 * 128 * Math.log(ch.doff / f0) / Math.log(2);
+ assert.equal(f2.toFixed(2), "131.00", "E5f finetune +127");
+};
diff --git a/test/instrument.js b/test/instrument.js
new file mode 100644
index 0000000..433b2a4
--- /dev/null
+++ b/test/instrument.js
@@ -0,0 +1,57 @@
+var XMPlayer = window.XMPlayer;
+
+// TODO:
+// - test volume envelope (sustain, release)
+// - test panning envelope
+
+exports['test note on'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // [pat][row][channel]
+ xm.patterns[0][0][0] = [48, 1, 0x10 + 0x33, 0, 0]; // C-4 1 33 000
+ XMPlayer.nextRow();
+ var ch = xm.channelinfo[0];
+ assert.equal(ch.note, 48, 'set note');
+ assert.equal(ch.period, 1152, 'channel period');
+ assert.equal(ch.vol, 0x33, 'channel volume');
+ assert.equal(ch.samp, xm.instruments[0].samples[0], 'set sample');
+ assert.equal(ch.off, 0, 'trigger offset');
+};
+
+exports['test instrument trigger'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // [pat][row][channel]
+ xm.patterns[0][0][0] = [48, 1, 0x10 + 0x33, 0, 0]; // C-4 1 33 000
+ xm.patterns[0][1][0] = [-1, 1, -1, 0, 0]; // --- 1 -- 000
+ var ch = xm.channelinfo[0];
+ XMPlayer.nextRow();
+ ch.pan = 1; // forcibly override panning
+ ch.off = 100; // and sample offset
+ assert.equal(XMPlayer.cur_row, 0, 'row 0');
+ XMPlayer.nextRow();
+ assert.equal(XMPlayer.cur_row, 1, 'row 1');
+ assert.equal(ch.note, 48, 'note same after inst trigger');
+ assert.equal(ch.period, 1152, 'period same after inst trigger');
+ assert.equal(ch.vol, 64, 'vol reset after inst trigger');
+ assert.equal(ch.pan, 128, 'pan reset after inst trigger');
+ assert.equal(ch.samp, xm.instruments[0].samples[0], 'set sample');
+ assert.equal(ch.off, 0, 'set offset=0');
+};
+
+exports['test note trigger'] = function(assert) {
+ var xm = testdata.resetXMData();
+ // [pat][row][channel]
+ xm.patterns[0][0][0] = [48, 1, 0x10 + 0x33, 0, 0]; // C-4 1 33 000
+ xm.patterns[0][1][0] = [49, -1, -1, 0, 0]; // C#4 -- -- 000
+ var ch = xm.channelinfo[0];
+ XMPlayer.nextRow();
+ ch.pan = 1; // forcibly override panning
+ ch.off = 100; // and sample offset
+ ch.vibratopos = 1; // and vibrato position
+ XMPlayer.nextRow();
+ assert.equal(ch.note, 49, 'note updated after inst trigger');
+ assert.equal(ch.period, 1136, 'period updated after note trigger');
+ assert.equal(ch.vol, 0x33, 'vol not reset after note trigger');
+ assert.equal(ch.pan, 1, 'pan not reset after note trigger');
+ assert.equal(ch.off, 0, 'set offset=0');
+ assert.equal(ch.vibratopos, 0, 'set vibrato pos=0');
+};
diff --git a/test/testdata.js b/test/testdata.js
new file mode 100644
index 0000000..c8dd629
--- /dev/null
+++ b/test/testdata.js
@@ -0,0 +1,58 @@
+var XMPlayer = window.XMPlayer;
+
+// set up basic blank single-channel, single-pattern XM
+exports.resetXMData = function() {
+ 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,
+ vibratopos: 0,
+ vibratodepth: 1,
+ vibratospeed: 1,
+ vibratotype: 0,
+ });
+ 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 = 64;
+ return xm;
+};
diff --git a/trackview.js b/trackview.js
new file mode 100644
index 0000000..52046bf
--- /dev/null
+++ b/trackview.js
@@ -0,0 +1,348 @@
+(function (window, document) {
+if (!window.XMPlayer) {
+ window.XMPlayer = {};
+}
+var player = window.XMPlayer;
+
+if (!window.XMView) {
+ window.XMView = {};
+}
+var view = window.XMView;
+
+var _pattern_cellwidth = 16 + 4 + 8 + 4 + 8 + 16 + 4;
+var _scope_width = _pattern_cellwidth - 1;
+var _pattern_border = 20;
+
+view.init = init;
+view.pushEvent = pushEvent;
+view.pause = pause;
+view.resume = resume;
+view.stop = stop;
+view.scope_width = _scope_width;
+
+// Load font (ripped from FastTracker 2)
+var fontimg = new window.Image();
+fontimg.src = "ft2font.png";
+
+var _fontmap_notes = [8*5, 8*22, 8*28];
+var pat_canvas_patnum;
+
+var audio_events;
+var paused_events;
+var shown_row;
+
+// canvas to render patterns onto
+var pat_canvas = document.createElement('canvas');
+
+// pixel widths of each character in the proportional font
+var _fontwidths = [
+ 4, 7, 3, 6, 6, 6, 6, 5, 4, 5, 5, 5, 5, 5, 7, 7,
+ 5, 5, 5, 6, 6, 6, 6, 6, 6, 7, 6, 7, 7, 7, 7, 7,
+ 4, 2, 5, 7, 7, 7, 7, 3, 4, 4, 6, 6, 3, 6, 2, 7,
+ 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 2, 3, 6, 6, 6, 7,
+ 7, 7, 7, 7, 7, 7, 7, 7, 7, 2, 7, 7, 7, 8, 8, 7,
+ 7, 7, 7, 7, 8, 7, 7, 8, 8, 8, 7, 4, 7, 4, 4, 5,
+ 3, 6, 6, 6, 6, 6, 4, 6, 6, 2, 4, 6, 2, 8, 6, 6,
+ 6, 6, 4, 6, 4, 6, 7, 8, 7, 6, 6, 4, 2, 4, 4, 4];
+
+var _bigfontwidths = [
+ 4, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
+ 15, 15, 15, 15, 15, 15, 15, 15, 13, 13, 13, 15, 15, 15, 15, 15,
+ 4, 5, 12, 16, 15, 15, 16, 5, 8, 8, 13, 10, 6, 10, 5, 12,
+ 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 5, 6, 12, 10, 12, 15,
+ 14, 15, 15, 15, 15, 15, 15, 15, 15, 5, 13, 15, 14, 15, 15, 15,
+ 15, 16, 15, 15, 15, 15, 15, 15, 15, 15, 16, 7, 12, 7, 13, 15,
+ 5, 13, 13, 13, 13, 13, 11, 13, 13, 5, 9, 13, 5, 16, 13, 13,
+ 13, 13, 12, 13, 11, 13, 13, 16, 15, 13, 15, 9, 2, 9, 15, 15];
+
+// draw FT2 proportional font text to a drawing context
+// returns width rendered
+function drawText(text, dx, dy, ctx) {
+ var dx0 = dx;
+ for (var i = 0; i < text.length; i++) {
+ var n = text.charCodeAt(i);
+ var sx = (n&63)*8;
+ var sy = (n>>6)*10 + 56;
+ var width = _fontwidths[n];
+ ctx.drawImage(fontimg, sx, sy, width, 10, dx, dy, width, 10);
+ dx += width + 1;
+ }
+ return dx - dx0;
+}
+
+function getTextSize(text, widthtable) {
+ var width = 0;
+ for (var i = 0; i < text.length; i++) {
+ var n = text.charCodeAt(i);
+ width += widthtable[n] + 1;
+ }
+ return width;
+}
+
+function drawBigText(text, dx, dy, ctx) {
+ var dx0 = dx;
+ for (var i = 0; i < text.length; i++) {
+ var n = text.charCodeAt(i);
+ var sx = (n&31)*16;
+ var sy = (n>>5)*20 + 96;
+ var width = _bigfontwidths[n];
+ ctx.drawImage(fontimg, sx, sy, width, 20, dx, dy, width, 20);
+ dx += width + 1;
+ }
+ return dx - dx0;
+}
+
+function RenderPattern(canv, pattern) {
+ // a pattern consists of NxM cells which look like
+ // N-O II VV EFF
+ var cellwidth = _pattern_cellwidth;
+ canv.width = pattern[0].length * cellwidth + _pattern_border;
+ canv.height = pattern.length * 8;
+ var ctx = canv.getContext('2d');
+ ctx.fillcolor='#000';
+ ctx.fillRect(0, 0, canv.width, canv.height);
+ for (var j = 0; j < pattern.length; j++) {
+ var row = pattern[j];
+ var dy = j * 8;
+ // render row number
+ ctx.drawImage(fontimg, 8*(j>>4), 0, 8, 8, 2, dy, 8, 8);
+ ctx.drawImage(fontimg, 8*(j&15), 0, 8, 8, 10, dy, 8, 8);
+
+ for (var i = 0; i < row.length; i++) {
+ var dx = i*cellwidth + 2 + _pattern_border;
+ var data = row[i];
+
+ // render note
+ var note = data[0];
+ if (note < 0) {
+ // no note = ...
+ ctx.drawImage(fontimg, 0, 8*5, 16, 8, dx, dy, 16, 8);
+ } else {
+ var octave = (note/12)|0;
+ var note_fontrow = _fontmap_notes[(octave/3)|0];
+ note = (note % (12*3))|0;
+ ctx.drawImage(fontimg, 16+16*note, note_fontrow, 16, 8, dx, dy, 16, 8);
+ }
+ dx += 20;
+
+ // render instrument
+ var inst = data[1];
+ if (inst != -1) { // no instrument = render nothing
+ if (inst > 15) {
+ ctx.drawImage(fontimg, 8*(inst>>4), 4*8, 4, 8, dx, dy, 4, 8);
+ }
+ ctx.drawImage(fontimg, 8*(inst&15), 4*8, 4, 8, dx+4, dy, 4, 8);
+ }
+ dx += 12;
+
+ // render volume
+ var vol = data[2];
+ if (vol < 0x10) {
+ // no volume = ..
+ ctx.drawImage(fontimg, 312, 0, 8, 8, dx, dy, 8, 8);
+ } else {
+ ctx.drawImage(fontimg, 8*(vol>>4) + 56*8, 4*8, 8, 8, dx, dy, 8, 8);
+ ctx.drawImage(fontimg, 8*(vol&15), 4*8, 4, 8, dx+4, dy, 4, 8);
+ }
+ dx += 8;
+
+ // render effect
+ var eff = data[3];
+ var effdata = data[4];
+ if (eff !== 0 || effdata !== 0) {
+ // draw effect with tiny font (4px space + effect type 0..9a..z)
+ ctx.drawImage(fontimg, 8*eff + 16*8, 4*8, 8, 8, dx, dy, 8, 8);
+ dx += 8;
+ // (hexadecimal 4-width font)
+ ctx.drawImage(fontimg, 8*(effdata>>4), 4*8, 4, 8, dx, dy, 4, 8);
+ ctx.drawImage(fontimg, 8*(effdata&15), 4*8, 4, 8, dx+4, dy, 4, 8);
+ } else {
+ // no effect = ...
+ ctx.drawImage(fontimg, 0, 8*5, 16, 8, dx+2, dy, 16, 8);
+ }
+ }
+ }
+}
+
+function redrawScreen() {
+ var e;
+ var t = player.audioctx.currentTime;
+ while (audio_events.length > 0 && audio_events[0].t < t) {
+ e = audio_events.shift();
+ }
+ if (!e) {
+ if (player.playing) {
+ window.requestAnimationFrame(redrawScreen);
+ }
+ return;
+ }
+ var VU = e.vu;
+ var scopes = e.scopes;
+ var ctx;
+
+ if (e.scopes !== undefined) {
+ // update VU meters & oscilliscopes
+ var canvas = document.getElementById("vu");
+ ctx = canvas.getContext("2d");
+ ctx.fillStyle = '#000';
+ ctx.fillRect(0, 0, canvas.width, 64);
+ ctx.fillStyle = '#0f0';
+ ctx.strokeStyle = '#55acff';
+ for (var j = 0; j < player.xm.nchan; j++) {
+ var x = _pattern_border + j * _pattern_cellwidth;
+ // render channel number
+ drawText(''+j, x, 1, ctx);
+
+ // volume in dB as a green bar
+ var vu_y = -Math.log(VU[j])*10;
+ ctx.fillRect(x, vu_y, 2, 64-vu_y);
+
+ // oscilloscope
+ var scope = scopes[j];
+ if (scope) {
+ ctx.beginPath();
+ for (var k = 0; k < _scope_width; k++) {
+ ctx.lineTo(x + 1 + k, 32 - 16 * scope[k]);
+ }
+ ctx.stroke();
+ }
+ }
+ }
+
+ if (e.row != shown_row || e.pat != pat_canvas_patnum) {
+ if (e.pat != pat_canvas_patnum) {
+ var p = player.xm.patterns[e.pat];
+ if (p) {
+ RenderPattern(pat_canvas, player.xm.patterns[e.pat]);
+ pat_canvas_patnum = e.pat;
+ }
+ }
+
+ var gfx = document.getElementById("gfxpattern");
+ ctx = gfx.getContext('2d');
+ ctx.fillStyle = '#000';
+ ctx.fillRect(0, 0, gfx.width, gfx.height);
+ ctx.fillStyle = '#2a5684';
+ ctx.fillRect(0, gfx.height/2 - 4, gfx.width, 8);
+ ctx.globalCompositeOperation = 'lighten';
+ ctx.drawImage(pat_canvas, 0, gfx.height / 2 - 4 - 8*(e.row));
+ ctx.globalCompositeOperation = 'source-over';
+ shown_row = e.row;
+ }
+
+ if (player.playing) {
+ window.requestAnimationFrame(redrawScreen);
+ }
+}
+
+function init() {
+ var title = document.getElementById("title");
+ // make title element fit text exactly, then render it
+ title.width = getTextSize(player.xm.songname, _bigfontwidths);
+ var ctx = title.getContext('2d');
+ drawBigText(player.xm.songname, 0, 1, ctx);
+
+ var instrlist = document.getElementById("instruments");
+ // clear instrument list if not already clear
+ while (instrlist.childNodes.length) {
+ instrlist.removeChild(instrlist.childNodes[0]);
+ }
+ var instrcols = ((player.xm.instruments.length + 7) / 8) | 0;
+ for (var i = 0; i < instrcols; i++) {
+ var canvas = document.createElement('canvas');
+ ctx = canvas.getContext('2d');
+ var instrcolumnwidth = 8*22;
+ canvas.width = instrcolumnwidth;
+ canvas.height = 8 * 10;
+ ctx.fillStyle = '#000';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ var hasname = 0, hasdata = 0;
+ for (var j = 8*i; j < Math.min(8*i+8, player.xm.instruments.length); j++) {
+ var y = 10*(j - 8*i);
+ var n = (j+1).toString(16);
+ if (j < 15) n = '0' + n;
+ var data = player.xm.instruments[j].samples;
+ if (data) {
+ var len = data[0].len;
+ data = data[0].sampledata;
+ var scale = Math.ceil(len / instrcolumnwidth);
+ ctx.strokeStyle = '#55acff';
+ ctx.beginPath();
+ for (var k = 0; k < Math.min(len / scale, instrcolumnwidth - 20); k++) {
+ ctx.lineTo(k + 20, y + data[k*scale] * 4 + 4);
+ }
+ ctx.stroke();
+ hasdata++;
+ }
+ var name = player.xm.instruments[j].name;
+ ctx.globalCompositeOperation = 'lighten';
+ drawText(n, 1, y, ctx);
+ if (name !== '') {
+ drawText(player.xm.instruments[j].name, 20, y, ctx);
+ hasname++;
+ }
+ ctx.globalCompositeOperation = 'source-over';
+ }
+ if (hasname || hasdata) {
+ instrlist.appendChild(canvas);
+ }
+ }
+
+ document.getElementById('vu').width = _pattern_border +
+ _pattern_cellwidth * player.xm.nchan;
+ var gfxpattern = document.getElementById("gfxpattern");
+ gfxpattern.width = _pattern_cellwidth * player.xm.nchan + _pattern_border;
+
+ // generate a fake audio event to render the initial paused screen
+ var scopes = [];
+ for (i = 0; i < player.xm.nchan; i++) {
+ scopes.push(new Float32Array(_scope_width));
+ }
+
+ // reset display
+ shown_row = undefined;
+ pat_canvas_patnum = undefined;
+
+ audio_events = [];
+ paused_events = [];
+ audio_events.push({
+ t: 0, row: 0, pat: player.xm.songpats[0],
+ vu: new Float32Array(player.xm.nchan),
+ scopes: scopes
+ });
+ redrawScreen();
+}
+
+function pushEvent(e) {
+ audio_events.push(e);
+ if (audio_events.length == 1) {
+ requestAnimationFrame(redrawScreen);
+ }
+}
+
+function pause() {
+ // grab all the audio events
+ var t = player.audioctx.currentTime;
+ while (audio_events.length > 0) {
+ var e = audio_events.shift();
+ e.t -= t;
+ paused_events.push(e);
+ }
+}
+
+function resume() {
+ var t = player.audioctx.currentTime;
+ while (paused_events.length > 0) {
+ var e = paused_events.shift();
+ e.t += t;
+ audio_events.push(e);
+ }
+ requestAnimationFrame(redrawScreen);
+}
+
+function stop() {
+ audio_events = [];
+ paused_events = [];
+}
+
+})(window, document);
diff --git a/xm.js b/xm.js
index cc943e0..5f815b5 100644
--- a/xm.js
+++ b/xm.js
@@ -1,85 +1,44 @@
-var audioctx;
-var songname = '';
-var fontimg = new Image();
-var pat_canvas = document.createElement('canvas');
-fontimg.onload = function() {
- // FIXME: don't attempt to render until this loads
-};
+(function (window) {
+if (!window.XMPlayer) {
+ window.XMPlayer = {};
+}
+var player = window.XMPlayer;
-fontimg.src = "ft2font.png";
+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 _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
-audioContext = window.AudioContext || window.webkitAudioContext;
-
-var _fontmap_notes = [8*5, 8*22, 8*28];
-var _pattern_cellwidth = 16 + 4 + 8 + 4 + 8 + 16 + 4;
-var _scope_width = _pattern_cellwidth - 1;
-var _pattern_border = 20;
-var pat_canvas_patnum;
-function RenderPattern(canv, pattern) {
- // a pattern consists of NxM cells which look like
- // N-O II VV EFF
- var cellwidth = _pattern_cellwidth;
- canv.width = pattern[0].length * cellwidth + _pattern_border;
- canv.height = pattern.length * 8;
- var ctx = canv.getContext('2d');
- ctx.fillcolor='#000';
- ctx.fillRect(0, 0, canv.width, canv.height);
- for (var j = 0; j < pattern.length; j++) {
- var row = pattern[j];
- var dy = j * 8;
- // render row number
- ctx.drawImage(fontimg, 8*(j>>4), 0, 8, 8, 2, dy, 8, 8);
- ctx.drawImage(fontimg, 8*(j&15), 0, 8, 8, 10, dy, 8, 8);
-
- for (var i = 0; i < row.length; i++) {
- var dx = i*cellwidth + 2 + _pattern_border;
- var data = row[i];
-
- // render note
- var note = data[0];
- if (note < 0) {
- ctx.drawImage(fontimg, 0, 8*5, 16, 8, dx, dy, 16, 8);
- } else {
- var octave = (note/12)|0;
- var note_fontrow = _fontmap_notes[(octave/3)|0];
- note = (note % (12*3))|0;
- ctx.drawImage(fontimg, 16+16*note, note_fontrow, 16, 8, dx, dy, 16, 8);
- }
- dx += 20;
-
- // render instrument
- var inst = data[1];
- if (inst != -1) { // no instrument = render nothing
- if (inst > 15) {
- ctx.drawImage(fontimg, 8*(inst>>4), 4*8, 4, 8, dx, dy, 4, 8);
- }
- ctx.drawImage(fontimg, 8*(inst&15), 4*8, 4, 8, dx+4, dy, 4, 8);
- }
- dx += 12;
-
- // render volume
- var vol = data[2];
- if (vol < 0x10) {
- ctx.drawImage(fontimg, 312, 0, 8, 8, dx, dy, 8, 8);
- } else {
- ctx.drawImage(fontimg, 8*(vol>>4) + 56*8, 4*8, 8, 8, dx, dy, 8, 8);
- ctx.drawImage(fontimg, 8*(vol&15), 4*8, 4, 8, dx+4, dy, 4, 8);
- }
- dx += 8;
-
- // render effect
- var eff = data[3];
- var effdata = data[4];
- ctx.drawImage(fontimg, 8*eff + 16*8, 4*8, 8, 8, dx, dy, 8, 8);
- dx += 8;
- ctx.drawImage(fontimg, 8*(effdata>>4), 4*8, 4, 8, dx, dy, 4, 8);
- ctx.drawImage(fontimg, 8*(effdata&15), 4*8, 4, 8, dx+4, dy, 4, 8);
- }
- }
-}
+// 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 "---";
@@ -102,159 +61,103 @@ 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
+ 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]));
+ 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;
+ if (c === 0) break;
str.push(String.fromCharCode(c));
}
return str.join('');
}
-var channelinfo = [];
-var instruments = [];
-var tempo = 4;
-
// 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;
}
var wct = Math.sqrt(2) * Math.PI * f_c;
var e = Math.exp(-wct);
var c = e * Math.cos(wct);
- var s = e * Math.sin(wct);
- var gain = (1 - 2*c + c*c + s*s);
- return [gain, 2*c, -c*c - s*s];
+ var gain = (1 - 2*c + e*e) / 2;
+ return [gain, 2*c, -e*e];
}
-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) {
- return 1920 - (note + ch.samp.note)*16 - ch.samp.fine / 8.0;
+function periodForNote(ch, note) {
+ return 1920 - (note + ch.samp.note)*16 - ch.fine / 8.0;
}
-var audio_events = [];
-var shown_row = undefined;
-function RedrawScreen() {
- var e;
- var t = audioctx.currentTime;
- do {
- e = audio_events.shift();
- } while(e.t < t && audio_events.length > 0);
- if (e == undefined) return;
- var VU = e.vu;
- var scopes = e.scopes;
+function setCurrentPattern() {
+ var nextPat = player.xm.songpats[player.cur_songpos];
- if (e.scopes != undefined) {
- // update VU meters & oscilliscopes
- var canvas = document.getElementById("vu");
- var ctx = canvas.getContext("2d");
- ctx.fillStyle = '#000';
- ctx.fillRect(0, 0, canvas.width, 64);
- ctx.fillStyle = '#0f0';
- ctx.strokeStyle = '#55acff';
- for (var j = 0; j < nchan; j++) {
- var x = _pattern_border + j * _pattern_cellwidth;
- // render channel number
- if (j >= 10) {
- ctx.drawImage(fontimg, 8*((j/10)|0), 4*8, 4, 8, x, 1, 4, 8);
- }
- ctx.drawImage(fontimg, 8*(j%10), 4*8, 4, 8, x+4, 1, 4, 8);
-
- // volume in dB as a green bar
- var vu_y = -Math.log(VU[j])*10;
- ctx.fillRect(x, vu_y, 2, 64-vu_y);
-
- // oscilloscope
- var scope = scopes[j];
- ctx.beginPath();
- for (var k = 0; k < _scope_width; k++) {
- ctx.lineTo(x + 1 + k, 32 - 16 * scope[k]);
- }
- ctx.stroke();
+ // 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];
}
- var debug = document.getElementById("debug");
- debug.innerHTML = songname + '
pat ' + e.pat + ' row ' + (e.row);
-
- if (e.row != shown_row) {
- var gfx = document.getElementById("gfxpattern");
- if (e.pat != pat_canvas_patnum) {
- var p = patterns[e.pat];
- if (p != undefined) {
- RenderPattern(pat_canvas, patterns[e.pat]);
- pat_canvas_patnum = e.pat;
- }
- }
- var ctx = gfx.getContext('2d');
- ctx.fillStyle = '#000';
- ctx.fillRect(0, 0, gfx.width, gfx.height);
- ctx.fillStyle = '#2a5684';
- ctx.fillRect(0, gfx.height/2 - 4, gfx.width, 8);
- ctx.globalCompositeOperation = 'lighten';
- ctx.drawImage(pat_canvas, 0, gfx.height / 2 - 4 - 8*(e.row));
- ctx.globalCompositeOperation = 'source-over';
- shown_row = e.row;
- }
-
- if (audio_events.length > 0) {
- var next_event = audio_events[0].t;
- setTimeout(RedrawScreen, 1000*(next_event - audioctx.currentTime));
- }
+ player.cur_pat = nextPat;
}
-var cur_songpos = -1, cur_pat = -1, cur_row = 64, cur_ticksamp = 0;
-var cur_tick = 6;
-function next_row() {
- if (cur_pat == -1 || cur_row >= patterns[cur_pat].length) {
- cur_row = 0;
- cur_songpos++;
- if (cur_songpos >= songpats.length)
- cur_songpos = song_looppos;
- cur_pat = songpats[cur_songpos];
+function nextRow() {
+ player.cur_row++;
+ if (player.cur_pat == -1 || player.cur_row >= player.xm.patterns[player.cur_pat].length) {
+ player.cur_row = 0;
+ player.cur_songpos++;
+ if (player.cur_songpos >= player.xm.songpats.length)
+ player.cur_songpos = player.xm.song_looppos;
+ setCurrentPattern();
}
- var p = patterns[cur_pat];
- var r = p[cur_row];
- cur_row++;
+ var p = player.xm.patterns[player.cur_pat];
+ var r = p[player.cur_row];
for (var i = 0; i < r.length; i++) {
- var ch = channelinfo[i];
+ var ch = player.xm.channelinfo[i];
var inst = ch.inst;
var triggernote = false;
// instrument trigger
if (r[i][1] != -1) {
- inst = instruments[r[i][1] - 1];
- if (inst != undefined) {
+ inst = player.xm.instruments[r[i][1] - 1];
+ if (inst && inst.samplemap) {
ch.inst = inst;
// retrigger unless overridden below
triggernote = true;
- if (ch.note) {
+ 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);
@@ -267,7 +170,7 @@ function next_row() {
ch.release = 1;
triggernote = false;
} else {
- if (inst != undefined) {
+ if (inst && inst.samplemap) {
var note = r[i][0];
ch.note = note;
ch.samp = inst.samples[inst.samplemap[ch.note]];
@@ -276,6 +179,7 @@ function next_row() {
// (potentially) new sample
ch.pan = ch.samp.pan;
ch.vol = ch.samp.vol;
+ ch.fine = ch.samp.fine;
}
triggernote = true;
}
@@ -293,17 +197,28 @@ function next_row() {
} 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));
}
@@ -312,8 +227,8 @@ function next_row() {
ch.effect = r[i][3];
ch.effectdata = r[i][4];
if (ch.effect < 36) {
- ch.effectfn = effects_t1[ch.effect];
- var eff_t0 = effects_t0[ch.effect];
+ 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;
}
@@ -322,12 +237,12 @@ function next_row() {
}
// special handling for portamentos: don't trigger the note
- if (ch.effect == 3 || ch.effect == 5) {
+ if (ch.effect == 3 || ch.effect == 5 || r[i][2] >= 0xf0) {
if (r[i][0] != -1) {
- ch.periodtarget = PeriodForNote(ch, ch.note);
+ ch.periodtarget = periodForNote(ch, ch.note);
}
triggernote = false;
- if (inst != undefined) {
+ if (inst && inst.samplemap) {
if (ch.env_vol == undefined) {
// note wasn't already playing; we basically have to ignore the
// portamento and just trigger
@@ -344,14 +259,18 @@ function next_row() {
}
if (triggernote) {
- ch.off = 0;
+ // 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.vibratopos = 0;
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);
+ 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;
}
}
}
@@ -374,14 +293,14 @@ Envelope.prototype.Get = function(ticks) {
y0 = env[i+1];
if (ticks < env[i]) {
var x0 = env[i-2];
- var y0 = env[i-1];
+ 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;
@@ -404,35 +323,41 @@ EnvelopeFollower.prototype.Tick = function(release) {
}
}
return value;
-}
+};
-function next_tick() {
- cur_tick++;
- if (cur_tick >= tempo) {
- cur_tick = 0;
- next_row();
- }
- for (var j = 0; j < nchan; j++) {
- var ch = channelinfo[j];
- var inst = ch.inst;
+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 (cur_tick != 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(patterns[cur_pat][cur_row-1][j]),
+ 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(patterns[cur_pat][cur_row-1][j]),
+ 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);
+ updateChannelPeriod(ch, ch.period + ch.periodoffset);
}
}
@@ -475,20 +400,21 @@ function MixChannelIntoBuf(ch, start, end, dataL, dataR) {
var samp = instsamp.sampledata;
var sample_end = instsamp.len;
- if ((instsamp.type & 3) == 1) { // todo: support pingpong
+ 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 = volE * (128 - p) * ch.vol / 8192.0;
- var volR = volE * (128 + p) * ch.vol / 8192.0;
+ 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)
+ if (volR === 0 && volL === 0)
return;
if (isNaN(volR) || isNaN(volL)) {
console.log("NaN volume!?", ch.number, volL, volR, volE, panE, ch.vol);
@@ -512,8 +438,9 @@ function MixChannelIntoBuf(ch, start, end, dataL, dataR) {
var i = start;
var failsafe = 100;
while (i < end) {
- if (failsafe-- == 0) {
- console.log("failsafe in mixing loop!", k, sample_end, loopstart, looplen, dk);
+ 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
@@ -529,67 +456,67 @@ function MixChannelIntoBuf(ch, start, 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) {
- var s = samp[k|0];
- var y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
+ 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;
- var s = samp[k|0];
- var y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
+ 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;
- var s = samp[k|0];
- var y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
+ 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;
- var s = samp[k|0];
- var y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
+ 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;
- var s = samp[k|0];
- var y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
+ 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;
- var s = samp[k|0];
- var y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
+ 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;
- var s = samp[k|0];
- var y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
+ 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;
- var s = samp[k|0];
- var y = f0 * (s + fs0) + f1*fs1 + f2*fs2;
+ 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;
@@ -599,25 +526,20 @@ function MixChannelIntoBuf(ch, start, end, dataL, dataR) {
vL = pf_8 * vL + (1 - pf_8) * volL;
vR = pf_8 * vR + (1 - pf_8) * volR;
}
- */
for (; i < next_event; i++) {
- var s0 = samp[k|0];
- var s1 = samp[1+k|0];
- var t = k - (k|0);
+ 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)
- var y = f0 * (t*s1 + (1-t) * s0) + f1*fs1 + f2*fs2;
- fs2 = fs1; fs1 = y;
+ 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;
- vL = popfilter_alpha * vL + (1 - popfilter_alpha) * volL;
- vR = popfilter_alpha * vR + (1 - popfilter_alpha) * volR;
}
}
ch.off = k;
@@ -630,112 +552,115 @@ function MixChannelIntoBuf(ch, start, end, dataL, dataR) {
}
function audio_cb(e) {
- f_smp = audioctx.sampleRate;
- var time_sound_started = undefined;
+ 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 (var i = 0; i < buflen; i++) {
+ for (i = 0; i < buflen; i++) {
dataL[i] = 0;
dataR[i] = 0;
}
var offset = 0;
- var ticklen = 0|(f_smp * 2.5 / bpm);
+ var ticklen = 0|(f_smp * 2.5 / player.xm.bpm);
+ var scopewidth = XMView.scope_width;
while(buflen > 0) {
- if (cur_pat == -1 || cur_ticksamp >= ticklen) {
- next_tick(f_smp);
- cur_ticksamp -= ticklen;
+ if (player.cur_pat == -1 || player.cur_ticksamp >= ticklen) {
+ nextTick(f_smp);
+ player.cur_ticksamp -= ticklen;
}
- var tickduration = Math.min(buflen, ticklen - cur_ticksamp);
- var VU = new Float32Array(nchan);
+ var tickduration = Math.min(buflen, ticklen - player.cur_ticksamp);
+ var VU = new Float32Array(player.xm.nchan);
var scopes = undefined;
- for (var j = 0; j < nchan; j++) {
+ for (j = 0; j < player.xm.nchan; j++) {
var scope;
- if (tickduration >= 4*_scope_width) {
- scope = new Float32Array(_scope_width);
- for (var k = 0; k < _scope_width; k++) {
+ 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(
- channelinfo[j], offset, offset + tickduration, dataL, dataR) /
+ player.xm.channelinfo[j], offset, offset + tickduration, dataL, dataR) /
tickduration;
- if (tickduration >= 4*_scope_width) {
- for (var k = 0; k < _scope_width; k++) {
+ if (tickduration >= 4*scopewidth) {
+ for (k = 0; k < scopewidth; k++) {
scope[k] += dataL[offset+k*4] + dataR[offset+k*4];
}
- if (scopes == undefined) scopes = [];
+ if (scopes === undefined) scopes = [];
scopes.push(scope);
}
}
- audio_events.push({
- t: e.playbackTime + (0.0 + offset) / f_smp,
- vu: VU,
- scopes: scopes,
- songpos: cur_songpos,
- pat: cur_pat,
- row: cur_row - 1
- });
- if (audio_events.length == 1) {
- requestAnimationFrame(RedrawScreen);
+ 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;
}
- offset += tickduration;
- cur_ticksamp += tickduration;
- buflen -= tickduration;
}
}
function ConvertSample(array, bits) {
var len = array.length;
var acc = 0;
- var samp = new Float32Array(len+1);
- if (bits == 0) { // 8 bit sample
- for (var k = 0; k < len; k++) {
+ var samp, b, k;
+ if (bits === 0) { // 8 bit sample
+ samp = new Float32Array(len);
+ for (k = 0; k < len; k++) {
acc += array[k];
- var b = acc&255;
+ b = acc&255;
if (b & 128) b = b-256;
samp[k] = b / 128.0;
}
+ return samp;
} else {
len /= 2;
- for (var k = 0; k < len; k++) {
- acc += array[k*2] + (array[k*2 + 1] << 8);
- var b = acc&65535;
+ 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;
- samp[k] = b / 32768.0;
+ acc = Math.max(-1, Math.min(1, acc + b / 32768.0));
+ samp[k] = acc;
}
+ return samp;
}
- samp[len] = samp[len-1];
- 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;
- console.log(samp.looplen, nloops);
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 + 1);
+ 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 (var k = samp.looplen - 1; k >= 0; k--) {
+ for (k = samp.looplen - 1; k >= 0; k--) {
data[i++] = samp.sampledata[samp.loop + k];
}
} else {
- for (var k = 0; k < samp.looplen; k++) {
+ for (k = 0; k < samp.looplen; k++) {
data[i++] = samp.sampledata[samp.loop + k];
}
}
@@ -746,23 +671,27 @@ function UnrollSampleLoop(samp) {
samp.type = 1;
}
-function playXM(arrayBuf) {
+function load(arrayBuf) {
var dv = new DataView(arrayBuf);
- window.dv = dv;
+ player.xm = {};
- songname = getstring(dv, 17, 20);
+ player.xm.songname = getstring(dv, 17, 20);
var hlen = dv.getUint32(0x3c, true) + 0x3c;
var songlen = dv.getUint16(0x40, true);
- song_looppos = dv.getUint16(0x42, true);
- nchan = dv.getUint16(0x44, 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);
- var flags = dv.getUint16(0x4a, true);
- tempo = dv.getUint16(0x4c, true);
- bpm = dv.getUint16(0x4e, true);
- document.getElementById('vu').width = _pattern_border + _pattern_cellwidth * nchan;
- for (var i = 0; i < nchan; i++) {
- channelinfo.push({
+ 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,
@@ -773,34 +702,36 @@ function playXM(arrayBuf) {
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, nchan, npat, ninst);
- console.log("loop @%d", song_looppos);
- console.log("flags=%d tempo %d bpm %d", flags, tempo, bpm);
+ 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);
- songpats = [];
- for (var i = 0; i < songlen; i++) {
- songpats.push(dv.getUint8(0x50 + i));
+ player.xm.songpats = [];
+ for (i = 0; i < songlen; i++) {
+ player.xm.songpats.push(dv.getUint8(0x50 + i));
}
- console.log("song patterns: ", songpats);
+ console.log("song patterns: ", player.xm.songpats);
var idx = hlen;
- patterns = [];
- for (var i = 0; i < npat; i++) {
+ 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 (var j = 0; patsize > 0 && j < patrows; j++) {
+ for (j = 0; patsize > 0 && j < patrows; j++) {
row = [];
- for (var k = 0; k < nchan; k++) {
+ 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) {
@@ -833,19 +764,21 @@ function playXM(arrayBuf) {
}
pattern.push(row);
}
- patterns.push(pattern);
+ player.xm.patterns.push(pattern);
}
- var namelist = [];
+ 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) {
- // return a slice so we have a fresh copy and don't retain pointers to
- // the original xm file arraybuf forever
- var samplemap = new Uint8Array(arrayBuf, idx+33, 96).slice();
+ var samplemap = new Uint8Array(arrayBuf, idx+33, 96);
var env_nvol = dv.getUint8(idx+225);
var env_vol_type = dv.getUint8(idx+233);
@@ -859,22 +792,22 @@ function playXM(arrayBuf) {
var env_pan_loop_end = dv.getUint8(idx+232);
var vol_fadeout = dv.getUint16(idx+239, true);
var env_vol = [];
- for (var j = 0; j < env_nvol*2; j++) {
+ for (j = 0; j < env_nvol*2; j++) {
env_vol.push(dv.getUint16(idx+129+j*2, true));
}
var env_pan = [];
- for (var j = 0; j < env_npan*2; j++) {
+ 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 %d: '%s' %d samples, samphdrsiz %d",
- hdrsiz, i, instname, nsamp, samphdrsiz);
+ 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 (var j = 0; j < nsamp; j++) {
+ 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);
@@ -884,9 +817,12 @@ function playXM(arrayBuf) {
var samppan = dv.getUint8(idx+15);
var sampnote = dv.getInt8(idx+16);
var sampname = getstring(dv, idx+18, 22);
- var sampleoffset = idx + samphdrsiz + totalsamples;
- console.log("sample %d: len %d name '%s' loop %d/%d vol %d",
- j, samplen, sampname, samploop, samplooplen, sampvol);
+ 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,
@@ -898,48 +834,38 @@ function playXM(arrayBuf) {
'len': samplen, 'loop': samploop,
'looplen': samplooplen, 'note': sampnote, 'fine': sampfinetune,
'pan': samppan, 'type': samptype, 'vol': sampvol,
- 'fine': sampfinetune,
- 'sampledata': ConvertSample(
- new Uint8Array(arrayBuf, sampleoffset, samplen),
- samptype & 16),
+ 'fileoffset': sampleoffset
};
// length / pointers are all specified in bytes; fixup for 16-bit samples
- if (samptype & 16) {
+ 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) {
- if ((samp.looplen < 2048 || (samp.type & 2))) {
- UnrollSampleLoop(samp);
- }
- // overwrite loop boundary sample (we always have at least one
- // extra sample for interpolation)
- samp.sampledata[samp.loop + samp.looplen] = samp.sampledata[samp.loop];
+ if ((samp.type & 3) && (samp.looplen < 2048 || (samp.type & 2))) {
+ UnrollSampleLoop(samp);
}
- samps.push(samp);
-
- idx += samphdrsiz;
- totalsamples += samplen;
}
idx += totalsamples;
- inst = {
- 'name': instname,
- 'number': i,
- 'fadeout': vol_fadeout,
- 'samplemap': samplemap,
- 'samples': samps,
- };
+ 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 (inst.fadeout > 0) {
- var fadeout_ticks = 65536.0 / inst.fadeout;
+ if (vol_fadeout > 0) {
+ var fadeout_ticks = 65536.0 / vol_fadeout;
env_vol.push(env_end_tick + fadeout_ticks);
env_vol.push(0);
}
@@ -968,49 +894,72 @@ function playXM(arrayBuf) {
// create a default empty envelope
inst.env_pan = new Envelope([0, 32], 0, 0, 0, 0);
}
- instruments.push(inst);
} else {
idx += hdrsiz;
console.log("empty instrument", i, hdrsiz, idx);
- instruments.push(null);
}
-
- var n = (i+1).toString(16);
- if (i < 15) n = ' ' + n;
- namelist.push(n + " " + instname);
+ player.xm.instruments.push(inst);
}
- audioctx = new audioContext();
- gainNode = audioctx.createGain();
- gainNode.gain.value = 0.1; // master volume
- jsNode = audioctx.createScriptProcessor(16384, 0, 2);
+ 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;
- jsNode.connect(gainNode);
-
- var debug = document.getElementById("debug");
- console.log("loaded \"" + songname + "\"");
- debug.innerHTML = songname;
- var gfxpattern = document.getElementById("gfxpattern");
- gfxpattern.width = _pattern_cellwidth * nchan + _pattern_border;
-
- var instrlist = document.getElementById("instruments");
- instrlist.innerHTML = namelist.join("\n");
-
- // start playing
- gainNode.connect(audioctx.destination);
+ gainNode.connect(player.audioctx.destination);
}
-var xmReq = new XMLHttpRequest();
-var uri = location.search.substr(1);
-if (uri == "") {
- uri = "kamel.xm";
-}
-xmReq.open("GET", uri, true);
-xmReq.responseType = "arraybuffer";
-xmReq.onload = function (xmEvent) {
- var arrayBuffer = xmReq.response;
- if (arrayBuffer) {
- playXM(arrayBuffer);
+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);
+ if (temp_osc.noteOn) temp_osc.start = temp_osc.noteOn;
+ temp_osc.start(0);
+ temp_osc.stop();
+ temp_osc.disconnect();
}
+ player.playing = true;
}
-xmReq.send(null);
+
+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();
+}
+})(window);
diff --git a/xmeffects.js b/xmeffects.js
index 4c25716..27e906c 100644
--- a/xmeffects.js
+++ b/xmeffects.js
@@ -1,13 +1,19 @@
+(function (window) {
+if (!window.XMPlayer) {
+ window.XMPlayer = {};
+}
+var player = window.XMPlayer;
+
function eff_t1_0(ch) { // arpeggio
- if (ch.effectdata != 0 && ch.inst != undefined) {
+ if (ch.effectdata !== 0 && ch.inst !== undefined) {
var arpeggio = [0, ch.effectdata>>4, ch.effectdata&15];
- var note = ch.note + arpeggio[cur_tick % 3];
- ch.period = PeriodForNote(ch, note);
+ 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) {
+ if (data !== 0) {
ch.slideupspeed = data;
}
}
@@ -20,7 +26,7 @@ function eff_t1_1(ch) { // pitch slide up
}
function eff_t0_2(ch, data) { // pitch slide down
- if (data != 0) {
+ if (data !== 0) {
ch.slidedownspeed = data;
}
}
@@ -33,7 +39,7 @@ function eff_t1_2(ch) { // pitch slide down
}
function eff_t0_3(ch, data) { // portamento
- if (data != 0) {
+ if (data !== 0) {
ch.portaspeed = data;
}
}
@@ -50,22 +56,44 @@ function eff_t1_3(ch) { // portamento
function eff_t0_4(ch, data) { // vibrato
if (data & 0x0f) {
- ch.vibratodepth = data & 0x0f;
+ ch.vibratodepth = (data & 0x0f) * 2;
}
if (data >> 4) {
ch.vibratospeed = data >> 4;
}
- eff_t1_4(ch, data);
+ eff_t1_4(ch);
}
function eff_t1_4(ch) { // vibrato
- ch.periodoffset = Math.sin(ch.vibratopos * Math.PI / 32) * ch.vibratodepth;
+ ch.periodoffset = getVibratoDelta(ch.vibratotype, ch.vibratopos) * ch.vibratodepth;
if (isNaN(ch.periodoffset)) {
- console.log("vibrato periodoffset NaN?", ch.vibratopos, ch.vibratodepth);
+ console.log("vibrato periodoffset NaN?",
+ ch.vibratopos, ch.vibratospeed, ch.vibratodepth);
ch.periodoffset = 0;
}
- ch.vibratopos += ch.vibratospeed;
- ch.vibratopos &= 63;
+ // 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
@@ -88,11 +116,7 @@ function eff_t0_9(ch, data) { // sample offset
function eff_t0_a(ch, data) { // volume slide
if (data) {
- if (data & 0x0f) {
- ch.volumeslide = -(data & 0x0f);
- } else {
- ch.volumeslide = data >> 4;
- }
+ ch.volumeslide = -(data & 0x0f) + (data >> 4);
}
}
@@ -103,9 +127,10 @@ function eff_t1_a(ch) { // volume slide
}
function eff_t0_b(ch, data) { // song jump (untested)
- if (data < songpats.length) {
- cur_songpos = data
- cur_pat = songpats[cur_songpos];
+ if (data < player.xm.songpats.length) {
+ player.cur_songpos = data;
+ player.cur_pat = player.xm.songpats[player.cur_songpos];
+ player.cur_row = -1;
}
}
@@ -114,11 +139,11 @@ function eff_t0_c(ch, data) { // set volume
}
function eff_t0_d(ch, data) { // pattern jump
- cur_songpos++;
- if (cur_songpos >= songpats.length)
- cur_songpos = song_looppos;
- cur_pat = songpats[cur_songpos];
- cur_row = data;
+ 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.cur_row = (data >> 4) * 10 + (data & 0x0f) - 1;
}
function eff_t0_e(ch, data) { // extended effects!
@@ -131,17 +156,23 @@ function eff_t0_e(ch, data) { // extended effects!
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 8: // panning
ch.pan = data * 0x11;
break;
case 0x0a: // fine vol slide up (with memory)
- if (data == 0 && ch.finevolup != undefined)
+ 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)
+ if (data === 0 && ch.finevoldown !== undefined)
data = ch.finevoldown;
ch.vol = Math.max(0, ch.vol - data);
ch.finevoldown = data;
@@ -149,7 +180,7 @@ function eff_t0_e(ch, data) { // extended effects!
case 0x0c: // note cut handled in eff_t1_e
break;
default:
- console.log("unimplemented extended effect E", ch.effect.toString(16));
+ console.log("unimplemented extended effect E", ch.effectdata.toString(16));
break;
}
}
@@ -157,7 +188,7 @@ function eff_t0_e(ch, data) { // extended effects!
function eff_t1_e(ch) { // note cut
switch (ch.effectdata >> 4) {
case 0x0c:
- if (cur_tick == (ch.effectdata & 0x0f)) {
+ if (player.cur_tick == (ch.effectdata & 0x0f)) {
ch.vol = 0;
}
break;
@@ -165,13 +196,37 @@ function eff_t1_e(ch) { // note cut
}
function eff_t0_f(ch, data) { // set tempo
- if (data == 0) {
+ if (data === 0) {
console.log("tempo 0?");
return;
- } else if(data < 0x20) {
- tempo = data;
+ } else if (data < 0x20) {
+ player.xm.tempo = data;
} else {
- bpm = data;
+ 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));
}
}
@@ -200,17 +255,17 @@ function eff_t0_r(ch, data) { // retrigger
}
function eff_t1_r(ch) {
- if (cur_tick % (ch.retrig & 0x0f) == 0) {
+ if (player.cur_tick % (ch.retrig & 0x0f) === 0) {
ch.off = 0;
}
}
function eff_unimplemented() {}
function eff_unimplemented_t0(ch, data) {
- console.log("unimplemented effect", prettify_effect(ch.effect, data));
+ console.log("unimplemented effect", player.prettify_effect(ch.effect, data));
}
-var effects_t0 = [ // effect functions on tick 0
+player.effects_t0 = [ // effect functions on tick 0
eff_t1_0, // 1, arpeggio is processed on all ticks
eff_t0_1,
eff_t0_2,
@@ -227,8 +282,8 @@ var effects_t0 = [ // effect functions on tick 0
eff_t0_d, // d
eff_t0_e, // e
eff_t0_f, // f
- eff_unimplemented_t0, // g
- eff_unimplemented_t0, // h
+ eff_t0_g, // g
+ eff_t0_h, // h
eff_unimplemented_t0, // i
eff_unimplemented_t0, // j
eff_unimplemented_t0, // k
@@ -249,7 +304,7 @@ var effects_t0 = [ // effect functions on tick 0
eff_unimplemented_t0, // z
];
-var effects_t1 = [ // effect functions on tick 1+
+player.effects_t1 = [ // effect functions on tick 1+
eff_t1_0,
eff_t1_1,
eff_t1_2,
@@ -266,8 +321,8 @@ var effects_t1 = [ // effect functions on tick 1+
null, // d
eff_t1_e, // e
null, // f
- eff_unimplemented, // g
- eff_unimplemented, // h
+ null, // g
+ eff_t1_h, // h
eff_unimplemented, // i
eff_unimplemented, // j
eff_unimplemented, // k
@@ -287,3 +342,5 @@ var effects_t1 = [ // effect functions on tick 1+
eff_unimplemented, // y
eff_unimplemented // z
];
+
+})(window);