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 +[![Build Status](https://travis-ci.org/a1k0n/jsxm.svg?branch=master)](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 3784657..3c6535e 100644 --- a/index.html +++ b/index.html @@ -1,29 +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
- - oscilloscopes
- - fix bugs!
-   - envelope loops - are they right or not now?
- - multi-sample instruments
- - missing XM effects (in rough order of priority):
-   - Rxy - retrigger
-   - E3x, E4x, E5x, E6x, E7x, E9x, EDx, EEx
+ - missing XM effects:
+   - 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 322322f..5f815b5 100644 --- a/xm.js +++ b/xm.js @@ -1,83 +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 _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 - 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 { - vol -= 0x10; - ctx.drawImage(fontimg, 8*(vol>>4), 4*8, 4, 8, dx, dy, 4, 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 "---"; @@ -100,154 +61,104 @@ 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; - -// compute coefficients for a pole pair biquad section (with a single implied -// zero at z=-1); pole_r and _i are the real and imaginary components of the -// pole in the s (Laplace) plane, which are then scaled by w_c and projected -// into the z domain. -function SetBiquadCoeffs(s_pole_r, s_pole_i, f_c, poleno, filter) { - var w = 2 * Math.PI * f_c; - var e = Math.exp(s_pole_r * w); - var c = e * Math.cos(s_pole_i * w); - var s = e * Math.sin(s_pole_i * w); - var gain = (1 - 2*c + c*c + s*s) / 2; - filter[3*poleno + 0] = gain; - filter[3*poleno + 1] = 2*c; - filter[3*poleno + 2] = -c*c - s*s; -} - -// Return 4-pole lowpass filter coefficients for center frequncy f_c (relative -// to sampling frequency) -function SetFilterCoeffs(f_c, filter) { +// Return 2-pole Butterworth lowpass filter coefficients for +// center frequncy f_c (relative to sampling frequency) +function filterCoeffs(f_c) { if (f_c > 0.5) { // we can't lowpass above the nyquist frequency... f_c = 0.5; } - - // This is the pole pair in the s plane we're going to use. - // It's a fairly resonant filter but it is designed to work together with the - // comb filter implied by using nearest-neighbor downsampling first, so the - // combination of the two has a nearly flat passband and a decently sharp - // cutoff for a 2-pole filter. - SetBiquadCoeffs(-0.47143406, 0.46783542, f_c, 0, filter); - SetBiquadCoeffs(-0.10882119, 0.91627743, f_c, 1, filter); + var wct = Math.sqrt(2) * Math.PI * f_c; + var e = Math.exp(-wct); + var c = e * Math.cos(wct); + var gain = (1 - 2*c + e*e) / 2; + return [gain, 2*c, -e*e]; } -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; - SetFilterCoeffs(ch.doff / 2, ch.filter); + ch.filter = filterCoeffs(ch.doff / 2); } -function PeriodForNote(ch, note) { - return 1920 - note*16 - ch.inst.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; +function setCurrentPattern() { + var nextPat = player.xm.songpats[player.cur_songpos]; - // update VU meters - var canvas = document.getElementById("vu"); - var ctx = canvas.getContext("2d"); - ctx.fillStyle = '#000'; - ctx.fillRect(0, 0, 16 * nchan, 64); - ctx.fillStyle = '#0f0'; - for (var j = 0; j < nchan; j++) { - var y = -Math.log(VU[j])*10; - ctx.fillRect(j*16, y, 15, 64-y); - } - - 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; - } + // 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; } - 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; + + nextPat = player.xm.songpats[player.cur_songpos]; } - 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; - ch.update = false; 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; - ch.pan = inst.pan; - ch.vol = inst.vol; + 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); } @@ -259,11 +170,17 @@ function next_row() { ch.release = 1; triggernote = false; } else { - // assume linear frequency table (flags header & 1 == 1) - // is this true in kamel.xm? - if (inst != undefined) { - var note = r[i][0] + inst.note; + if (inst && inst.samplemap) { + var note = r[i][0]; ch.note = note; + ch.samp = inst.samples[inst.samplemap[ch.note]]; + if (triggernote) { + // if we were already triggering the note, reset vol/pan using + // (potentially) new sample + ch.pan = ch.samp.pan; + ch.vol = ch.samp.vol; + ch.fine = ch.samp.fine; + } triggernote = true; } } @@ -278,19 +195,30 @@ function next_row() { } else if (v <= 0x50) { ch.vol = v - 0x10; } else if (v >= 0x60 && v < 0x70) { // volume slide down - ch.voleffectfn = function() { + 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.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)); } @@ -298,22 +226,23 @@ function next_row() { ch.effect = r[i][3]; ch.effectdata = r[i][4]; - if (ch.effect < 16) { - ch.effectfn = effects_t1[ch.effect]; - if (effects_t0[ch.effect](ch, ch.effectdata)) { + if (ch.effect < 36) { + ch.effectfn = player.effects_t1[ch.effect]; + var eff_t0 = player.effects_t0[ch.effect]; + if (eff_t0 && eff_t0(ch, ch.effectdata)) { triggernote = false; } } else { - console.log("channel", i, "effect > 16", ch.effect); + console.log("channel", i, "effect > 36", ch.effect); } // 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 @@ -323,20 +252,26 @@ function next_row() { // alone ch.envtick = 0; ch.release = 0; - ch.env_vol = new EnvelopeFollower(inst.env_vol, inst.fadeout); - ch.env_pan = new EnvelopeFollower(inst.env_pan, 0); + ch.env_vol = new EnvelopeFollower(inst.env_vol); + ch.env_pan = new EnvelopeFollower(inst.env_pan); } } } 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); - 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; + } } } } @@ -358,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; @@ -388,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); } } @@ -448,44 +389,42 @@ function MixSilenceIntoBuf(ch, start, end, dataL, dataR) { function MixChannelIntoBuf(ch, start, end, dataL, dataR) { var inst = ch.inst; - var samp, sample_end; + var instsamp = ch.samp; var loop = false; var looplen = 0, loopstart = 0; // nothing on this channel, just filter the last dc offset back down to zero - if (inst == undefined || ch.mute) { + if (instsamp == undefined || inst == undefined || ch.mute) { return MixSilenceIntoBuf(ch, start, end, dataL, dataR); } - samp = inst.sampledata; - sample_end = inst.len; - if ((inst.type & 3) == 1) { // todo: support pingpong + var samp = instsamp.sampledata; + var sample_end = instsamp.len; + if ((instsamp.type & 3) == 1 && instsamp.looplen > 0) { loop = true; - loopstart = inst.loop; - looplen = inst.looplen; + loopstart = instsamp.loop; + looplen = instsamp.looplen; sample_end = loopstart + looplen; } - var samplen = inst.len; + 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!?", volL, volR, volE, panE, ch.vol); + console.log("NaN volume!?", ch.number, volL, volR, volE, panE, ch.vol); return; } var k = ch.off; var dk = ch.doff; var Vrms = 0; - var f0g = ch.filter[0], f01 = ch.filter[1], f02 = ch.filter[2]; - var f1g = ch.filter[3], f11 = ch.filter[4], f12 = ch.filter[5]; - var f0x = ch.filterstate[0], f0y1 = ch.filterstate[1], f0y2 = ch.filterstate[2]; - var f1x = ch.filterstate[3], f1y1 = ch.filterstate[4], f1y2 = ch.filterstate[5]; + var f0 = ch.filter[0], f1 = ch.filter[1], f2 = ch.filter[2]; + var fs0 = ch.filterstate[0], fs1 = ch.filterstate[1], fs2 = ch.filterstate[2]; // we also low-pass filter volume changes with a simple one-zero, // one-pole filter to avoid pops and clicks when volume changes. @@ -499,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 @@ -516,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 - /* TODO after new filter // 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; @@ -586,324 +526,114 @@ 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 s = samp[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 filter (usually we'd use an FIR brick wall - // filter, but this is much simpler computationally and sounds fine) - // the filter is a cascade of two biquad sections here - var y0 = f0g * (s + f0x) + f01*f0y1 + f02*f0y2; - f0y2 = f0y1; f0y1 = y0; f0x = s; - var y1 = f1g * (y0 + f1x) + f11*f1y1 + f12*f1y2; - f1y2 = f1y1; f1y1 = y1; f1x = y0; - dataL[i] += vL * y1; - dataR[i] += vR * y1; - Vrms += (vL + vR) * y1 * y1; + // implemented as an IIR butterworth filter (usually we'd use an FIR + // brick wall filter, but this is much simpler computationally and + // sounds fine) + y = f0 * (s + fs0) + f1*fs1 + f2*fs2; + fs2 = fs1; fs1 = y; fs0 = s; + dataL[i] += vL * y; + dataR[i] += vR * y; + Vrms += (vL + vR) * y * y; k += dk; - - vL = popfilter_alpha * vL + (1 - popfilter_alpha) * volL; - vR = popfilter_alpha * vR + (1 - popfilter_alpha) * volR; } } ch.off = k; - ch.filterstate[0] = f0x; - ch.filterstate[1] = f0y1; - ch.filterstate[2] = f0y2; - ch.filterstate[3] = f1x; - ch.filterstate[4] = f1y1; - ch.filterstate[5] = f1y2; + ch.filterstate[0] = fs0; + ch.filterstate[1] = fs1; + ch.filterstate[2] = fs2; ch.vL = vL; ch.vR = vR; return Vrms * 0.5; } function audio_cb(e) { - f_smp = 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); - for (var j = 0; j < nchan; j++) { - VU[j] = MixChannelIntoBuf( - channelinfo[j], offset, offset + tickduration, dataL, dataR) / - tickduration; - } - audio_events.push({ - t: e.playbackTime + (0.0 + offset) / f_smp, - vu: VU, - songpos: cur_songpos, - pat: cur_pat, - row: cur_row - 1 - }); - if (audio_events.length == 1) { - requestAnimationFrame(RedrawScreen); - } - offset += tickduration; - cur_ticksamp += tickduration; - buflen -= tickduration; - } -} - -function eff_t0_1(ch, data) { // pitch slide up - if (data != 0) { - ch.slideupspeed = data; - } -} - -function eff_t0_2(ch, data) { // pitch slide down - if (data != 0) { - ch.slidedownspeed = data; - } -} - -function eff_t0_3(ch, data) { // portamento - if (data != 0) { - ch.portaspeed = data; - } -} - -function eff_t0_4(ch, data) { // vibrato - if (data & 0x0f) { - ch.vibratodepth = data & 0x0f; - } - if (data >> 4) { - ch.vibratospeed = data >> 4; - } - eff_t1_4(ch, data); -} - -function eff_t0_8(ch, data) { // set panning - ch.pan = data; -} - -function eff_t0_9(ch, data) { // sample offset - ch.off = data * 256; -} - -function eff_t0_a(ch, data) { // volume slide - if (data) { - if (data & 0x0f) { - ch.volumeslide = -(data & 0x0f); - } else { - ch.volumeslide = data >> 4; - } - } -} - -function eff_t0_b(ch, data) { // song jump (untested) - if (data < songpats.length) { - cur_songpos = data - cur_pat = songpats[cur_songpos]; - } -} - -function eff_t0_c(ch, data) { // set volume - ch.vol = data & 0x3f; -} - -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; -} - -function eff_t0_e(ch, data) { // extended effects! - var eff = data >> 4; - data = data & 0x0f; - switch (eff) { - case 1: // fine porta up - ch.period -= data; - break; - case 2: // fine porta down - ch.period += data; - break; - case 8: // panning - ch.pan = data * 0x11; - break; - case 0x0a: // fine vol slide up (with memory) - if (data == 0 && ch.finevolup != undefined) - data = ch.finevolup; - ch.vol = Math.min(64, ch.vol + data); - ch.finevolup = data; - break; - case 0x0b: // fine vol slide down - if (data == 0 && ch.finevoldown != undefined) - data = ch.finevoldown; - ch.vol = Math.max(0, ch.vol - data); - ch.finevoldown = data; - break; - case 0x0c: // note cut handled in eff_t1_e - break; - default: - console.log("unimplemented extended effect E", ch.effect.toString(16)); - break; - } -} - -function eff_t0_f(ch, data) { // set tempo - if (data == 0) { - console.log("tempo 0?"); - return; - } else if(data < 0x20) { - tempo = data; - } else { - bpm = data; - } -} - -function eff_unimplemented_t0(ch, data) { - console.log("unimplemented effect", ch.effect.toString(16), data.toString(16)); -} - -var effects_t0 = [ // effect functions on tick 0 - eff_t1_0, // 1, arpeggio is processed on all ticks - eff_t0_1, - eff_t0_2, - eff_t0_3, - eff_t0_4, // 4 - eff_t0_a, // 5, same as A on first tick - eff_t0_a, // 6, same as A on first tick - eff_unimplemented_t0, // 7 - eff_t0_8, // 8 - eff_t0_9, // 9 - eff_t0_a, // a - eff_t0_b, // b - eff_t0_c, // c - eff_t0_d, // d - eff_t0_e, // e - eff_t0_f, // f -]; - -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[cur_tick % 3]; - ch.period = PeriodForNote(ch, note); - } -} - -function eff_t1_1(ch) { // pitch slide up - if (ch.slideupspeed !== undefined) { - // is this limited? it appears not - ch.period -= ch.slideupspeed; - } -} - -function eff_t1_2(ch) { // pitch slide down - if (ch.slidedownspeed !== undefined) { - // 1728 is the period for C-1 - ch.period = Math.min(1728, ch.period + ch.slidedownspeed); - } -} - -function eff_t1_3(ch) { // portamento - if (ch.periodtarget !== undefined && ch.portaspeed !== undefined) { - if (ch.period > ch.periodtarget) { - ch.period = Math.max(ch.periodtarget, ch.period - ch.portaspeed); - } else { - ch.period = Math.min(ch.periodtarget, ch.period + ch.portaspeed); - } - } -} - -function eff_t1_4(ch) { // vibrato - ch.periodoffset = Math.sin(ch.vibratopos * Math.PI / 32) * ch.vibratodepth; - if (isNaN(ch.periodoffset)) { - console.log("vibrato periodoffset NaN?", ch.vibratopos, ch.vibratodepth); - ch.periodoffset = 0; - } - ch.vibratopos += ch.vibratospeed; - ch.vibratopos &= 63; -} - -function eff_t1_5(ch) { // portamento + volume slide - eff_t1_a(ch); - eff_t1_3(ch); -} - -function eff_t1_6(ch) { // vibrato + volume slide - eff_t1_a(ch); - eff_t1_4(ch); -} - -function eff_t1_a(ch) { // volume slide - if (ch.volumeslide !== undefined) { - ch.vol = Math.max(0, Math.min(64, ch.vol + ch.volumeslide)); - } -} - -function eff_t1_e(ch) { // note cut - switch (ch.effectdata >> 4) { - case 0x0c: - if (cur_tick == (ch.effectdata & 0x0f)) { - ch.vol = 0; + var tickduration = Math.min(buflen, ticklen - player.cur_ticksamp); + var VU = new Float32Array(player.xm.nchan); + var scopes = undefined; + for (j = 0; j < player.xm.nchan; j++) { + var scope; + if (tickduration >= 4*scopewidth) { + scope = new Float32Array(scopewidth); + for (k = 0; k < scopewidth; k++) { + scope[k] = -dataL[offset+k*4] - dataR[offset+k*4]; + } } - break; + + VU[j] = MixChannelIntoBuf( + player.xm.channelinfo[j], offset, offset + tickduration, dataL, dataR) / + tickduration; + + if (tickduration >= 4*scopewidth) { + for (k = 0; k < scopewidth; k++) { + scope[k] += dataL[offset+k*4] + dataR[offset+k*4]; + } + if (scopes === undefined) scopes = []; + scopes.push(scope); + } + } + if (XMView.pushEvent) { + XMView.pushEvent({ + t: e.playbackTime + (0.0 + offset) / f_smp, + vu: VU, + scopes: scopes, + songpos: player.cur_songpos, + pat: player.cur_pat, + row: player.cur_row + }); + offset += tickduration; + player.cur_ticksamp += tickduration; + buflen -= tickduration; + } } } -function eff_nop() {} -function eff_unimplemented() {} -var effects_t1 = [ // effect functions on tick 1+ - eff_t1_0, - eff_t1_1, - eff_t1_2, - eff_t1_3, - eff_t1_4, - eff_t1_5, // 5 - eff_t1_6, // 6 - eff_unimplemented, // 7 - eff_nop, // 8 - eff_nop, // 9 - eff_t1_a, // a - eff_nop, // b - eff_nop, // c - eff_nop, // d - eff_t1_e, // e - eff_nop, // f -]; - function ConvertSample(array, bits) { var len = array.length; var acc = 0; - if (bits == 0) { // 8 bit sample - var samp = new Float32Array(len); - 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; - var samp = new Float32Array(len); - 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; } @@ -911,54 +641,59 @@ function ConvertSample(array, bits) { // 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(inst) { - var nloops = ((2048 + inst.looplen - 1) / inst.looplen) | 0; - var pingpong = inst.type & 2; +function UnrollSampleLoop(samp) { + var nloops = ((2048 + samp.looplen - 1) / samp.looplen) | 0; + var pingpong = samp.type & 2; if (pingpong) { // make sure we have an even number of loops if we are pingponging nloops = (nloops + 1) & (~1); } - var samplesiz = inst.loop + nloops * inst.looplen; - var samp = new Float32Array(samplesiz); - for (var i = 0; i < inst.loop; i++) { - samp[i] = inst.sampledata[i]; + var samplesiz = samp.loop + nloops * samp.looplen; + var data = new Float32Array(samplesiz); + for (var i = 0; i < samp.loop; i++) { + data[i] = samp.sampledata[i]; } for (var j = 0; j < nloops; j++) { + var k; if ((j&1) && pingpong) { - for (var k = inst.looplen - 1; k >= 0; k--) { - samp[i++] = inst.sampledata[inst.loop + k]; + for (k = samp.looplen - 1; k >= 0; k--) { + data[i++] = samp.sampledata[samp.loop + k]; } } else { - for (var k = 0; k < inst.looplen; k++) { - samp[i++] = inst.sampledata[inst.loop + k]; + for (k = 0; k < samp.looplen; k++) { + data[i++] = samp.sampledata[samp.loop + k]; } } } - console.log("unrolled sample loop", inst.number, "looplen", inst.looplen, "x", nloops, " = ", samplesiz); - inst.sampledata = samp; - inst.looplen = nloops * inst.looplen; - inst.type = 1; + console.log("unrolled sample loop; looplen", samp.looplen, "x", nloops, " = ", samplesiz); + samp.sampledata = data; + samp.looplen = nloops * samp.looplen; + 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 = 16 * nchan; - for (var i = 0; i < nchan; i++) { - channelinfo.push({ - filter: new Float32Array(6), - filterstate: new Float32Array(6), + player.xm.flags = dv.getUint16(0x4a, true); + player.xm.tempo = dv.getUint16(0x4c, true); + player.xm.bpm = dv.getUint16(0x4e, true); + player.xm.channelinfo = []; + player.xm.global_volume = player.max_global_volume; + + var i, j, k; + + for (i = 0; i < player.xm.nchan; i++) { + player.xm.channelinfo.push({ + number: i, + filterstate: new Float32Array(3), vol: 0, pan: 128, period: 1920 - 48*16, @@ -966,34 +701,37 @@ function playXM(arrayBuf) { vLprev: 0, vRprev: 0, mute: 0, volE: 0, panE: 0, + retrig: 0, + vibratopos: 0, vibratodepth: 1, vibratospeed: 1, - }) + vibratotype: 0, + }); } console.log("header len " + hlen); - console.log("songlen %d, %d channels, %d patterns, %d instruments", songlen, 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) { @@ -1026,15 +764,22 @@ function playXM(arrayBuf) { } pattern.push(row); } - patterns.push(pattern); + player.xm.patterns.push(pattern); } + player.xm.instruments = []; // now load instruments for (i = 0; i < ninst; i++) { var hdrsiz = dv.getUint32(idx, true); var instname = getstring(dv, idx+0x4, 22); var nsamp = dv.getUint16(idx+0x1b, true); + var inst = { + 'name': instname, + 'number': i, + }; if (nsamp > 0) { + var samplemap = new Uint8Array(arrayBuf, idx+33, 96); + var env_nvol = dv.getUint8(idx+225); var env_vol_type = dv.getUint8(idx+233); var env_vol_sustain = dv.getUint8(idx+227); @@ -1047,77 +792,83 @@ 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; - for (var j = 0; j < nsamp; j++) { + var samps = []; + for (j = 0; j < nsamp; j++) { var samplen = dv.getUint32(idx, true); - if (j == 0) { - var samplen0 = samplen; // FIXME HACK HACK HACK - var samploop = dv.getUint32(idx+4, true); - var samplooplen = dv.getUint32(idx+8, true); - var sampvol = dv.getUint8(idx+12); - var sampfinetune = dv.getInt8(idx+13); - var samptype = dv.getUint8(idx+14); - var samppan = dv.getUint8(idx+15); - var sampnote = dv.getInt8(idx+16); - var sampname = getstring(dv, idx+18, 22); - var sampleoffset = idx + samphdrsiz; - console.log("sample %d: len %d name '%s' loop %d/%d vol %d", - j, samplen, sampname, samploop, samplooplen, sampvol); - console.log(" type %d note %s(%d) finetune %d pan %d", - samptype, prettify_note(sampnote + 12*4), sampnote, sampfinetune, samppan); - console.log(" vol env", env_vol, env_vol_sustain, - env_vol_loop_start, env_vol_loop_end, "type", env_vol_type, - "fadeout", vol_fadeout); - console.log(" pan env", env_pan, env_pan_sustain, - env_pan_loop_start, env_pan_loop_end, "type", env_pan_type); + var samploop = dv.getUint32(idx+4, true); + var samplooplen = dv.getUint32(idx+8, true); + var sampvol = dv.getUint8(idx+12); + var sampfinetune = dv.getInt8(idx+13); + var samptype = dv.getUint8(idx+14); + var samppan = dv.getUint8(idx+15); + var sampnote = dv.getInt8(idx+16); + var sampname = getstring(dv, idx+18, 22); + var sampleoffset = totalsamples; + if (samplooplen === 0) { + samptype &= ~3; } + console.log("sample %d: len %d name '%s' loop %d/%d vol %d offset %s", + j, samplen, sampname, samploop, samplooplen, sampvol, sampleoffset.toString(16)); + console.log(" type %d note %s(%d) finetune %d pan %d", + samptype, prettify_note(sampnote + 12*4), sampnote, sampfinetune, samppan); + console.log(" vol env", env_vol, env_vol_sustain, + env_vol_loop_start, env_vol_loop_end, "type", env_vol_type, + "fadeout", vol_fadeout); + console.log(" pan env", env_pan, env_pan_sustain, + env_pan_loop_start, env_pan_loop_end, "type", env_pan_type); + var samp = { + 'len': samplen, 'loop': samploop, + 'looplen': samplooplen, 'note': sampnote, 'fine': sampfinetune, + 'pan': samppan, 'type': samptype, 'vol': sampvol, + 'fileoffset': sampleoffset + }; + // length / pointers are all specified in bytes; fixup for 16-bit samples + samps.push(samp); idx += samphdrsiz; totalsamples += samplen; } + for (j = 0; j < nsamp; j++) { + var samp = samps[j]; + samp.sampledata = ConvertSample( + new Uint8Array(arrayBuf, idx + samp.fileoffset, samp.len), samp.type & 16); + if (samp.type & 16) { + samp.len /= 2; + samp.loop /= 2; + samp.looplen /= 2; + } + // unroll short loops and any pingpong loops + if ((samp.type & 3) && (samp.looplen < 2048 || (samp.type & 2))) { + UnrollSampleLoop(samp); + } + } idx += totalsamples; - inst = { - 'name': instname, - 'number': i, - 'len': samplen0, 'loop': samploop, - 'looplen': samplooplen, 'note': sampnote, 'fine': sampfinetune, - 'pan': samppan, 'type': samptype, 'vol': sampvol, - 'fine': sampfinetune, - 'fadeout': vol_fadeout, - 'sampledata': ConvertSample(new Uint8Array(arrayBuf, sampleoffset, samplen0), samptype & 16), - }; - if (samptype & 16) { - inst.len /= 2; - inst.loop /= 2; - inst.looplen /= 2; - } - - // unroll short loops and any pingpong loops - if ((inst.type & 3) && (inst.looplen < 2048 || (inst.type & 2))) { - UnrollSampleLoop(inst); - } - + 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]; - var fadeout_ticks = 65536.0 / inst.fadeout; if (!(env_vol_type & 2)) { // if there's no sustain point, create one env_vol_sustain = env_vol.length / 2; } - env_vol.push(env_end_tick + fadeout_ticks); - env_vol.push(0); + if (vol_fadeout > 0) { + var fadeout_ticks = 65536.0 / vol_fadeout; + env_vol.push(env_end_tick + fadeout_ticks); + env_vol.push(0); + } inst.env_vol = new Envelope( env_vol, env_vol_type, @@ -1125,8 +876,9 @@ function playXM(arrayBuf) { env_vol_loop_start, env_vol_loop_end); } else { - // create a default envelope w/ fadeout - inst.env_vol = new Envelope([0, 64, fadeout_ticks, 0], 2, 0, 0, 0); + // no envelope, then just make a default full-volume envelope. + // i thought this would use fadeout, but apparently it doesn't. + inst.env_vol = new Envelope([0, 64, 1, 0], 2, 0, 0, 0); } if (env_pan_type) { if (!(env_pan_type & 2)) { // if there's no sustain point, create one @@ -1142,42 +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); } + 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; - - // 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 new file mode 100644 index 0000000..27e906c --- /dev/null +++ b/xmeffects.js @@ -0,0 +1,346 @@ +(function (window) { +if (!window.XMPlayer) { + window.XMPlayer = {}; +} +var player = window.XMPlayer; + +function eff_t1_0(ch) { // arpeggio + if (ch.effectdata !== 0 && ch.inst !== undefined) { + var arpeggio = [0, ch.effectdata>>4, ch.effectdata&15]; + var note = ch.note + arpeggio[player.cur_tick % 3]; + ch.period = player.periodForNote(ch, note); + } +} + +function eff_t0_1(ch, data) { // pitch slide up + if (data !== 0) { + ch.slideupspeed = data; + } +} + +function eff_t1_1(ch) { // pitch slide up + if (ch.slideupspeed !== undefined) { + // is this limited? it appears not + ch.period -= ch.slideupspeed; + } +} + +function eff_t0_2(ch, data) { // pitch slide down + if (data !== 0) { + ch.slidedownspeed = data; + } +} + +function eff_t1_2(ch) { // pitch slide down + if (ch.slidedownspeed !== undefined) { + // 1728 is the period for C-1 + ch.period = Math.min(1728, ch.period + ch.slidedownspeed); + } +} + +function eff_t0_3(ch, data) { // portamento + if (data !== 0) { + ch.portaspeed = data; + } +} + +function eff_t1_3(ch) { // portamento + if (ch.periodtarget !== undefined && ch.portaspeed !== undefined) { + if (ch.period > ch.periodtarget) { + ch.period = Math.max(ch.periodtarget, ch.period - ch.portaspeed); + } else { + ch.period = Math.min(ch.periodtarget, ch.period + ch.portaspeed); + } + } +} + +function eff_t0_4(ch, data) { // vibrato + if (data & 0x0f) { + ch.vibratodepth = (data & 0x0f) * 2; + } + if (data >> 4) { + ch.vibratospeed = data >> 4; + } + eff_t1_4(ch); +} + +function eff_t1_4(ch) { // vibrato + ch.periodoffset = getVibratoDelta(ch.vibratotype, ch.vibratopos) * ch.vibratodepth; + if (isNaN(ch.periodoffset)) { + console.log("vibrato periodoffset NaN?", + ch.vibratopos, ch.vibratospeed, ch.vibratodepth); + ch.periodoffset = 0; + } + // only updates on non-first ticks + if (player.cur_tick > 0) { + ch.vibratopos += ch.vibratospeed; + ch.vibratopos &= 63; + } +} + +function getVibratoDelta(type, x) { + var delta = 0; + switch (type & 0x03) { + case 1: // sawtooth (ramp-down) + delta = ((1 + x * 2 / 64) % 2) - 1; + break; + case 2: // square + case 3: // random (in FT2 these two are the same) + delta = x < 32 ? 1 : -1; + break; + case 0: + default: // sine + delta = Math.sin(x * Math.PI / 32); + break; + } + return delta; +} + +function eff_t1_5(ch) { // portamento + volume slide + eff_t1_a(ch); + eff_t1_3(ch); +} + +function eff_t1_6(ch) { // vibrato + volume slide + eff_t1_a(ch); + eff_t1_4(ch); +} + +function eff_t0_8(ch, data) { // set panning + ch.pan = data; +} + +function eff_t0_9(ch, data) { // sample offset + ch.off = data * 256; +} + +function eff_t0_a(ch, data) { // volume slide + if (data) { + ch.volumeslide = -(data & 0x0f) + (data >> 4); + } +} + +function eff_t1_a(ch) { // volume slide + if (ch.volumeslide !== undefined) { + ch.vol = Math.max(0, Math.min(64, ch.vol + ch.volumeslide)); + } +} + +function eff_t0_b(ch, data) { // song jump (untested) + if (data < player.xm.songpats.length) { + player.cur_songpos = data; + player.cur_pat = player.xm.songpats[player.cur_songpos]; + player.cur_row = -1; + } +} + +function eff_t0_c(ch, data) { // set volume + ch.vol = Math.min(64, data); +} + +function eff_t0_d(ch, data) { // pattern jump + player.cur_songpos++; + if (player.cur_songpos >= player.xm.songpats.length) + player.cur_songpos = player.xm.song_looppos; + player.cur_pat = player.xm.songpats[player.cur_songpos]; + player.cur_row = (data >> 4) * 10 + (data & 0x0f) - 1; +} + +function eff_t0_e(ch, data) { // extended effects! + var eff = data >> 4; + data = data & 0x0f; + switch (eff) { + case 1: // fine porta up + ch.period -= data; + break; + case 2: // fine porta down + ch.period += data; + break; + case 4: // set vibrato waveform + ch.vibratotype = data & 0x07; + break; + case 5: // finetune + ch.fine = (data<<4) + data - 128; + break; + case 8: // panning + ch.pan = data * 0x11; + break; + case 0x0a: // fine vol slide up (with memory) + if (data === 0 && ch.finevolup !== undefined) + data = ch.finevolup; + ch.vol = Math.min(64, ch.vol + data); + ch.finevolup = data; + break; + case 0x0b: // fine vol slide down + if (data === 0 && ch.finevoldown !== undefined) + data = ch.finevoldown; + ch.vol = Math.max(0, ch.vol - data); + ch.finevoldown = data; + break; + case 0x0c: // note cut handled in eff_t1_e + break; + default: + console.log("unimplemented extended effect E", ch.effectdata.toString(16)); + break; + } +} + +function eff_t1_e(ch) { // note cut + switch (ch.effectdata >> 4) { + case 0x0c: + if (player.cur_tick == (ch.effectdata & 0x0f)) { + ch.vol = 0; + } + break; + } +} + +function eff_t0_f(ch, data) { // set tempo + if (data === 0) { + console.log("tempo 0?"); + return; + } else if (data < 0x20) { + player.xm.tempo = data; + } else { + player.xm.bpm = data; + } +} + +function eff_t0_g(ch, data) { // set global volume + if (data <= 0x40) { + // volume gets multiplied by 2 to match + // the initial max global volume of 128 + player.xm.global_volume = Math.max(0, data * 2); + } else { + player.xm.global_volume = player.max_global_volume; + } +} + +function eff_t0_h(ch, data) { // global volume slide + if (data) { + // same as Axy but multiplied by 2 + player.xm.global_volumeslide = (-(data & 0x0f) + (data >> 4)) * 2; + } +} + +function eff_t1_h(ch) { // global volume slide + if (player.xm.global_volumeslide !== undefined) { + player.xm.global_volume = Math.max(0, Math.min(player.max_global_volume, + player.xm.global_volume + player.xm.global_volumeslide)); + } +} + +function eff_t0_r(ch, data) { // retrigger + if (data & 0x0f) ch.retrig = (ch.retrig & 0xf0) + (data & 0x0f); + if (data & 0xf0) ch.retrig = (ch.retrig & 0x0f) + (data & 0xf0); + + // retrigger volume table + switch (ch.retrig >> 4) { + case 1: ch.vol -= 1; break; + case 2: ch.vol -= 2; break; + case 3: ch.vol -= 4; break; + case 4: ch.vol -= 8; break; + case 5: ch.vol -= 16; break; + case 6: ch.vol *= 2; ch.vol /= 3; break; + case 7: ch.vol /= 2; break; + case 9: ch.vol += 1; break; + case 0x0a: ch.vol += 2; break; + case 0x0b: ch.vol += 4; break; + case 0x0c: ch.vol += 8; break; + case 0x0d: ch.vol += 16; break; + case 0x0e: ch.vol *= 3; ch.vol /= 2; break; + case 0x0f: ch.vol *= 2; break; + } + ch.vol = Math.min(64, Math.max(0, ch.vol)); +} + +function eff_t1_r(ch) { + if (player.cur_tick % (ch.retrig & 0x0f) === 0) { + ch.off = 0; + } +} + +function eff_unimplemented() {} +function eff_unimplemented_t0(ch, data) { + console.log("unimplemented effect", player.prettify_effect(ch.effect, data)); +} + +player.effects_t0 = [ // effect functions on tick 0 + eff_t1_0, // 1, arpeggio is processed on all ticks + eff_t0_1, + eff_t0_2, + eff_t0_3, + eff_t0_4, // 4 + eff_t0_a, // 5, same as A on first tick + eff_t0_a, // 6, same as A on first tick + eff_unimplemented_t0, // 7 + eff_t0_8, // 8 + eff_t0_9, // 9 + eff_t0_a, // a + eff_t0_b, // b + eff_t0_c, // c + eff_t0_d, // d + eff_t0_e, // e + eff_t0_f, // f + eff_t0_g, // g + eff_t0_h, // h + eff_unimplemented_t0, // i + eff_unimplemented_t0, // j + eff_unimplemented_t0, // k + eff_unimplemented_t0, // l + eff_unimplemented_t0, // m + eff_unimplemented_t0, // n + eff_unimplemented_t0, // o + eff_unimplemented_t0, // p + eff_unimplemented_t0, // q + eff_t0_r, // r + eff_unimplemented_t0, // s + eff_unimplemented_t0, // t + eff_unimplemented_t0, // u + eff_unimplemented_t0, // v + eff_unimplemented_t0, // w + eff_unimplemented_t0, // x + eff_unimplemented_t0, // y + eff_unimplemented_t0, // z +]; + +player.effects_t1 = [ // effect functions on tick 1+ + eff_t1_0, + eff_t1_1, + eff_t1_2, + eff_t1_3, + eff_t1_4, + eff_t1_5, // 5 + eff_t1_6, // 6 + eff_unimplemented, // 7 + null, // 8 + null, // 9 + eff_t1_a, // a + null, // b + null, // c + null, // d + eff_t1_e, // e + null, // f + null, // g + eff_t1_h, // h + eff_unimplemented, // i + eff_unimplemented, // j + eff_unimplemented, // k + eff_unimplemented, // l + eff_unimplemented, // m + eff_unimplemented, // n + eff_unimplemented, // o + eff_unimplemented, // p + eff_unimplemented, // q + eff_t1_r, // r + eff_unimplemented, // s + eff_unimplemented, // t + eff_unimplemented, // u + eff_unimplemented, // v + eff_unimplemented, // w + eff_unimplemented, // x + eff_unimplemented, // y + eff_unimplemented // z +]; + +})(window);