Closes #3. The code which defines what the buttons do and downloads songs and so forth are now completely separated from the player, which has the following API: - `XMPlayer.initAudio` -> starts up audio context; it's available as `XMPlayer.audioctx` - `XMPlayer.loadXM(ArrayBuffer)` -> returns `true` if loaded, otherwise barfs randomly - `XMPlayer.playXM()` -> starts playing - `XMPlayer.pauseXM()` -> obvious - `XMPlayer.stopXM()` -> obvious; call this before loading a new one Loading trackview.js is now 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
338 lines
9.6 KiB
JavaScript
338 lines
9.6 KiB
JavaScript
(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) {
|
|
ctx.drawImage(fontimg, 0, 8*5, 16, 8, dx, dy, 16, 8);
|
|
} else {
|
|
var octave = (note/12)|0;
|
|
var note_fontrow = _fontmap_notes[(octave/3)|0];
|
|
note = (note % (12*3))|0;
|
|
ctx.drawImage(fontimg, 16+16*note, note_fontrow, 16, 8, dx, dy, 16, 8);
|
|
}
|
|
dx += 20;
|
|
|
|
// render instrument
|
|
var inst = data[1];
|
|
if (inst != -1) { // no instrument = render nothing
|
|
if (inst > 15) {
|
|
ctx.drawImage(fontimg, 8*(inst>>4), 4*8, 4, 8, dx, dy, 4, 8);
|
|
}
|
|
ctx.drawImage(fontimg, 8*(inst&15), 4*8, 4, 8, dx+4, dy, 4, 8);
|
|
}
|
|
dx += 12;
|
|
|
|
// render volume
|
|
var vol = data[2];
|
|
if (vol < 0x10) {
|
|
ctx.drawImage(fontimg, 312, 0, 8, 8, dx, dy, 8, 8);
|
|
} else {
|
|
ctx.drawImage(fontimg, 8*(vol>>4) + 56*8, 4*8, 8, 8, dx, dy, 8, 8);
|
|
ctx.drawImage(fontimg, 8*(vol&15), 4*8, 4, 8, dx+4, dy, 4, 8);
|
|
}
|
|
dx += 8;
|
|
|
|
// render effect
|
|
var eff = data[3];
|
|
var effdata = data[4];
|
|
ctx.drawImage(fontimg, 8*eff + 16*8, 4*8, 8, 8, dx, dy, 8, 8);
|
|
dx += 8;
|
|
ctx.drawImage(fontimg, 8*(effdata>>4), 4*8, 4, 8, dx, dy, 4, 8);
|
|
ctx.drawImage(fontimg, 8*(effdata&15), 4*8, 4, 8, dx+4, dy, 4, 8);
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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);
|