Skip to content

Instantly share code, notes, and snippets.

@mrdoob
Last active February 10, 2026 02:06
Show Gist options
  • Select an option

  • Save mrdoob/ac671582e5a9bc71248ba69e2cfa49ca to your computer and use it in GitHub Desktop.

Select an option

Save mrdoob/ac671582e5a9bc71248ba69e2cfa49ca to your computer and use it in GitHub Desktop.
XMP
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>XMP</title>
<script src="jsxm/xmeffects.js"></script>
<script src="jsxm/xm.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
color: #fff;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* Header */
#header {
padding: 16px 20px 12px;
border-bottom: 1px solid #222;
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
#header h1 {
font-size: 14px;
font-weight: normal;
color: #666;
letter-spacing: 4px;
text-transform: uppercase;
}
#now-playing {
color: #0f0;
flex: 1;
}
#song-title {
color: #555;
font-size: 11px;
}
/* Controls */
#controls {
padding: 10px 20px;
border-bottom: 1px solid #222;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
#controls button {
background: none;
border: 1px solid #333;
color: #aaa;
font-family: inherit;
font-size: 11px;
padding: 3px 14px;
cursor: pointer;
}
#controls button:hover {
border-color: #666;
color: #fff;
}
#btn-repeat.active {
border-color: #0a0;
color: #0f0;
}
#controls button.flash {
border-color: #666;
color: #fff;
}
#position {
color: #555;
font-size: 11px;
margin-left: 8px;
}
#volume-wrap {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
color: #555;
font-size: 11px;
}
#volume-slider {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
background: #333;
border-radius: 2px;
outline: none;
cursor: pointer;
}
#volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
background: #888;
border-radius: 50%;
}
#volume-slider::-moz-range-thumb {
width: 10px;
height: 10px;
background: #888;
border-radius: 50%;
border: none;
}
/* Main grid: 2x2 */
#main {
flex: 1;
display: grid;
grid-template-columns: 1fr 360px;
grid-template-rows: auto 1fr;
overflow: hidden;
}
/* Top-left: Scopes */
#scopes {
border-bottom: 1px solid #222;
border-right: 1px solid #222;
display: flex;
flex-wrap: wrap;
overflow: hidden;
}
.scope-box {
position: relative;
cursor: pointer;
}
.scope-box.muted {
opacity: 0.2;
}
.scope-box canvas {
display: block;
}
.scope-label {
position: absolute;
top: 2px;
left: 4px;
font-size: 9px;
color: #333;
pointer-events: none;
}
.scope-box.muted .scope-label {
color: #600;
}
/* Top-right: File list */
#list {
border-bottom: 1px solid #222;
overflow-y: auto;
max-height: 40vh;
}
.track {
padding: 4px 16px;
cursor: pointer;
display: flex;
color: #666;
font-size: 11px;
}
.track:hover {
background: #0a0a0a;
color: #aaa;
}
.track.active {
color: #0f0;
background: #001800;
}
.track-number {
width: 28px;
text-align: right;
margin-right: 12px;
color: #333;
}
.track.active .track-number {
color: #060;
}
.track-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-header {
padding: 6px 16px;
color: #444;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.folder-header:hover {
color: #666;
}
.folder-toggle {
font-size: 8px;
transition: transform 0.15s;
display: inline-block;
}
.folder-header.collapsed .folder-toggle {
transform: rotate(-90deg);
}
/* Bottom-left: Pattern */
#pattern-view {
border-right: 1px solid #222;
overflow-x: auto;
overflow-y: hidden;
position: relative;
}
#pattern-canvas {
display: block;
}
/* Bottom-right: Instruments */
#instruments-panel {
overflow-y: auto;
padding: 8px 0;
}
#instruments-panel .section-title {
padding: 0 12px 6px;
color: #444;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 2px;
}
.instrument {
padding: 1px 12px;
color: #555;
font-size: 11px;
display: flex;
}
.instrument-num {
width: 24px;
text-align: right;
margin-right: 10px;
color: #333;
}
.instrument-name {
color: #777;
}
</style>
</head>
<body>
<div id="header">
<h1>XMP</h1>
<span id="now-playing"></span>
<span id="song-title"></span>
</div>
<div id="controls">
<button id="btn-prev" onclick="prevTrack()">Prev</button>
<button id="btn-playpause" onclick="togglePlay()">Play</button>
<button id="btn-next" onclick="nextTrack()">Next</button>
<button onclick="stopTrack()">Stop</button>
<span id="position"></span>
<button id="btn-prevpat" onclick="prevPattern()">|&lt;</button>
<button id="btn-nextpat" onclick="nextPattern()">&gt;|</button>
<button id="btn-repeat" onclick="toggleRepeat()">Repeat</button>
<div id="volume-wrap">
<span>vol</span>
<input type="range" id="volume-slider" min="0" max="100" value="100">
</div>
</div>
<div id="main">
<div id="scopes"></div>
<div id="list"></div>
<div id="pattern-view">
<canvas id="pattern-canvas"></canvas>
</div>
<div id="instruments-panel">
<div class="section-title">Instruments</div>
<div id="instruments-list"></div>
</div>
</div>
<script>
// Override buffer size for lower latency (16384 → 2048 = ~46ms instead of ~370ms)
var _origCreateScriptProcessor = AudioContext.prototype.createScriptProcessor;
AudioContext.prototype.createScriptProcessor = function(size, inp, out) {
return _origCreateScriptProcessor.call(this, 2048, inp, out);
};
// files[] holds { name, folder, handle } objects
var files = [];
var currentIndex = -1;
var isPlaying = false;
var animFrame;
var scopeCanvases = [];
var scopeContexts = [];
var mutedChannels = {};
var initTempo = 6;
var initBpm = 125;
// -- Note names for pattern view --
var noteNames = ["C-","C#","D-","D#","E-","F-","F#","G-","G#","A-","A#","B-"];
function noteName(n) {
if (n < 0) return "...";
if (n == 96) return "^^^";
return noteNames[n % 12] + ~~(n / 12);
}
function hex2(v) {
if (v < 0) return "..";
var s = v.toString(16).toUpperCase();
return s.length < 2 ? "0" + s : s;
}
// -- Scan directory for .xm files --
async function scanDirectory(dirHandle, path) {
var results = [];
for await (var entry of dirHandle.values()) {
if (entry.kind === 'file' && entry.name.match(/\.xm$/i)) {
results.push({ name: entry.name.replace(/\.xm$/i, ''), folder: path, handle: entry });
} else if (entry.kind === 'directory') {
var subPath = path ? path + '/' + entry.name : entry.name;
var subResults = await scanDirectory(entry, subPath);
results = results.concat(subResults);
}
}
results.sort(function(a, b) { return a.name.localeCompare(b.name); });
return results;
}
async function openFolder() {
var dirHandle = await window.showDirectoryPicker();
files = await scanDirectory(dirHandle, '');
buildTrackList(dirHandle.name);
}
function buildTrackList(rootName) {
var listEl = document.getElementById('list');
listEl.innerHTML = '';
// Add select folder button at top
var btn = document.createElement('div');
btn.className = 'folder-header';
btn.textContent = 'Select Folder...';
btn.onclick = openFolder;
listEl.appendChild(btn);
// Group by folder
var folders = {};
var folderOrder = [];
files.forEach(function(f) {
var folder = f.folder || rootName;
if (!folders[folder]) {
folders[folder] = [];
folderOrder.push(folder);
}
folders[folder].push(f);
});
// Reorder files[] to match the grouped display order
var reordered = [];
folderOrder.forEach(function(folderName) {
reordered = reordered.concat(folders[folderName]);
});
files = reordered;
var globalIndex = 0;
folderOrder.forEach(function(folderName) {
var folderFiles = folders[folderName];
var header = document.createElement('div');
header.className = 'folder-header';
header.innerHTML = '<span class="folder-toggle">&#9660;</span> ' + folderName + ' <span style="color:#333">(' + folderFiles.length + ')</span>';
var tracksContainer = document.createElement('div');
tracksContainer.className = 'folder-tracks';
header.onclick = function() {
header.classList.toggle('collapsed');
tracksContainer.style.display = header.classList.contains('collapsed') ? 'none' : '';
};
listEl.appendChild(header);
folderFiles.forEach(function(file) {
var div = document.createElement('div');
div.className = 'track';
div.dataset.index = globalIndex;
var idx = globalIndex;
div.innerHTML =
'<span class="track-number">' + (globalIndex + 1) + '</span>' +
'<span class="track-name">' + file.name + '</span>';
div.onclick = function() { loadAndPlay(idx); };
tracksContainer.appendChild(div);
globalIndex++;
});
listEl.appendChild(tracksContainer);
});
}
// -- Scope / VU data --
var latestScopes = null;
var vuLevels = new Float32Array(32);
var latestRow = -1;
var latestPat = -1;
var latestSongpos = -1;
var songLooped = false;
XMView.scope_width = 128;
XMView.pushEvent = function(e) {
if (e.vu) {
for (var i = 0; i < e.vu.length; i++) {
vuLevels[i] = Math.max(vuLevels[i] * 0.85, e.vu[i] || 0);
}
}
if (e.scopes) latestScopes = e.scopes;
latestRow = e.row;
latestPat = e.pat;
if (latestSongpos > 0 && e.songpos < latestSongpos) songLooped = true;
latestSongpos = e.songpos;
};
// -- Create scope boxes --
function createScopes(nchan) {
var container = document.getElementById('scopes');
container.innerHTML = '';
scopeCanvases = [];
scopeContexts = [];
var totalWidth = container.offsetWidth || 800;
var cols = Math.min(nchan, 8);
var w = Math.floor(totalWidth / cols);
var h = 48;
for (var i = 0; i < nchan; i++) {
var box = document.createElement('div');
box.className = 'scope-box';
box.dataset.ch = i;
if (mutedChannels[i]) box.classList.add('muted');
var canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
box.appendChild(canvas);
var label = document.createElement('div');
label.className = 'scope-label';
label.textContent = (i + 1);
box.appendChild(label);
box.onclick = function(idx) {
return function() {
mutedChannels[idx] = !mutedChannels[idx];
var mute = mutedChannels[idx] ? 1 : 0;
XMPlayer.xm.channelinfo[idx].mute = mute;
XMPlayer.setMute(idx, mute);
this.classList.toggle('muted');
};
}(i);
container.appendChild(box);
scopeCanvases.push(canvas);
scopeContexts.push(canvas.getContext('2d'));
}
}
// -- Draw scopes --
function drawScopes() {
for (var i = 0; i < scopeCanvases.length; i++) {
var canvas = scopeCanvases[i];
var ctx = scopeContexts[i];
var w = canvas.width;
var h = canvas.height;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
// center line
ctx.strokeStyle = '#1a1a1a';
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
if (latestScopes && latestScopes[i]) {
var scope = latestScopes[i];
var level = Math.min(1, Math.sqrt(vuLevels[i] || 0) * 6);
var g = Math.floor(80 + level * 175);
ctx.strokeStyle = 'rgb(0,' + g + ',0)';
ctx.lineWidth = 1.5;
ctx.beginPath();
var step = scope.length / w;
for (var x = 0; x < w; x++) {
var si = (x * step) | 0;
var y = (h / 2) - scope[si] * h * 2;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.lineWidth = 1;
}
}
}
// -- Pattern view --
var patCanvas = document.getElementById('pattern-canvas');
var patCtx = patCanvas.getContext('2d');
function resizePatternCanvas() {
var container = document.getElementById('pattern-view');
var nchan = (XMPlayer.xm && XMPlayer.xm.nchan) || 0;
var rowNumWidth = 30;
var channelWidth = 100;
var neededWidth = rowNumWidth + nchan * channelWidth;
patCanvas.width = Math.max(container.offsetWidth, neededWidth);
patCanvas.height = container.offsetHeight;
}
function drawPattern() {
var w = patCanvas.width;
var h = patCanvas.height;
if (w === 0 || h === 0) return;
patCtx.fillStyle = '#000';
patCtx.fillRect(0, 0, w, h);
if (!XMPlayer.xm || !XMPlayer.xm.patterns || latestPat < 0) return;
if (latestPat >= XMPlayer.xm.patterns.length) return;
var pat = XMPlayer.xm.patterns[latestPat];
if (!pat) return;
var nchan = XMPlayer.xm.nchan;
var lineHeight = 14;
var visibleRows = Math.floor(h / lineHeight);
var centerY = Math.floor(visibleRows / 2);
var curRow = latestRow;
patCtx.font = '11px "SF Mono", Monaco, Menlo, Consolas, monospace';
// Calculate column layout
var rowNumWidth = 30;
var channelWidth = 100;
for (var vi = 0; vi < visibleRows; vi++) {
var row = curRow - centerY + vi;
var y = vi * lineHeight + 12;
if (row < 0 || row >= pat.length) continue;
var isCurrent = (row === curRow);
// Highlight current row
if (isCurrent) {
patCtx.fillStyle = '#001800';
patCtx.fillRect(0, vi * lineHeight, w, lineHeight);
}
// Row number
patCtx.fillStyle = isCurrent ? '#0a0' : '#333';
var rowStr = row < 10 ? '0' + row : '' + row;
patCtx.fillText(rowStr, 6, y);
// Channel data
for (var ch = 0; ch < nchan; ch++) {
var d = pat[row][ch];
var x = rowNumWidth + ch * channelWidth;
if (mutedChannels[ch]) {
patCtx.fillStyle = '#1a1a1a';
patCtx.fillText('--- -- --', x, y);
continue;
}
// Note
var note = noteName(d[0]);
patCtx.fillStyle = isCurrent ? (d[0] >= 0 ? '#0f0' : '#060') : (d[0] >= 0 ? '#aaa' : '#2a2a2a');
patCtx.fillText(note, x, y);
// Instrument
var inst = d[1] >= 0 ? hex2(d[1]) : '..';
patCtx.fillStyle = isCurrent ? '#080' : (d[1] >= 0 ? '#666' : '#1a1a1a');
patCtx.fillText(inst, x + 30, y);
// Effect
if (d[3] > 0 || d[4] > 0) {
var eff = XMPlayer.prettify_effect(d[3], d[4]);
patCtx.fillStyle = isCurrent ? '#0a0' : '#555';
patCtx.fillText(eff, x + 52, y);
}
}
}
}
// -- Instruments panel --
function showInstruments() {
var container = document.getElementById('instruments-list');
container.innerHTML = '';
if (!XMPlayer.xm || !XMPlayer.xm.instruments) return;
XMPlayer.xm.instruments.forEach(function(inst, i) {
var name = inst.name ? inst.name.trim() : '';
if (!name && !inst.samples) return;
var div = document.createElement('div');
div.className = 'instrument';
div.innerHTML =
'<span class="instrument-num">' + hex2(i + 1) + '</span>' +
'<span class="instrument-name">' + escapeHtml(name || '---') + '</span>';
container.appendChild(div);
if (inst.samples) {
inst.samples.forEach(function(samp, j) {
if (samp.name && samp.name.trim() && samp.name.trim() !== name) {
var sdiv = document.createElement('div');
sdiv.className = 'instrument';
sdiv.innerHTML =
'<span class="instrument-num" style="color:#222">&nbsp;&nbsp;</span>' +
'<span class="instrument-name" style="color:#444">' + escapeHtml(samp.name.trim()) + '</span>';
container.appendChild(sdiv);
}
});
}
});
}
function escapeHtml(s) {
var div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
// -- Animation loop --
function animate() {
drawScopes();
drawPattern();
if (songLooped && isPlaying && !XMPlayer.repeat && currentIndex < files.length - 1) {
songLooped = false;
nextTrack();
return;
}
songLooped = false;
if (isPlaying) {
document.getElementById('position').innerHTML =
'BPM ' + (XMPlayer.xm ? XMPlayer.xm.bpm : 0) +
'&nbsp;&nbsp;SPD ' + (XMPlayer.xm ? XMPlayer.xm.tempo : 0) +
'&nbsp;&nbsp;POS ' + (latestSongpos + 1) + '/' + (XMPlayer.xm.songpats ? XMPlayer.xm.songpats.length : 0) +
'&nbsp;&nbsp;PAT ' + latestPat +
'&nbsp;&nbsp;ROW ' + latestRow + '&nbsp;&nbsp;';
}
animFrame = requestAnimationFrame(animate);
}
// -- Player controls --
async function loadAndPlay(index) {
currentIndex = index;
songLooped = false;
latestSongpos = -1;
var entry = files[index];
document.querySelectorAll('.track').forEach(function(el) {
el.classList.remove('active');
});
var activeEl = document.querySelector('.track[data-index="' + index + '"]');
activeEl.classList.add('active');
activeEl.scrollIntoView({ block: 'nearest' });
document.getElementById('now-playing').textContent = entry.name;
document.getElementById('song-title').textContent = 'Loading...';
document.getElementById('btn-playpause').textContent = 'Pause';
if (XMPlayer.playing) {
XMPlayer.stop();
}
var file = await entry.handle.getFile();
var buffer = await file.arrayBuffer();
XMPlayer.init();
updateVolume();
if (XMPlayer.load(buffer)) {
initTempo = XMPlayer.xm.tempo;
initBpm = XMPlayer.xm.bpm;
document.getElementById('song-title').textContent = XMPlayer.xm.songname || '';
mutedChannels = {};
createScopes(XMPlayer.xm.nchan);
vuLevels = new Float32Array(XMPlayer.xm.nchan);
latestScopes = null;
showInstruments();
resizePatternCanvas();
XMPlayer.play();
isPlaying = true;
cancelAnimationFrame(animFrame);
animate();
} else {
document.getElementById('song-title').textContent = 'Error loading file';
}
}
function togglePlay() {
if (currentIndex === -1) { loadAndPlay(0); return; }
if (isPlaying) {
XMPlayer.pause();
isPlaying = false;
document.getElementById('btn-playpause').textContent = 'Play';
} else {
XMPlayer.play();
isPlaying = true;
document.getElementById('btn-playpause').textContent = 'Pause';
}
}
function stopTrack() {
if (currentIndex === -1) return;
XMPlayer.stop();
isPlaying = false;
document.getElementById('btn-playpause').textContent = 'Play';
document.getElementById('position').textContent = '';
}
function nextTrack() {
if (currentIndex < files.length - 1) loadAndPlay(currentIndex + 1);
}
function prevTrack() {
if (currentIndex > 0) loadAndPlay(currentIndex - 1);
}
function jumpToSongPos(pos) {
if (!XMPlayer.xm || !isPlaying) return;
var len = XMPlayer.xm.songpats.length;
pos = Math.max(0, Math.min(pos, len - 1));
// Scan all patterns from start to target to reconstruct correct BPM/tempo
var tempo = initTempo;
var bpm = initBpm;
for (var s = 0; s < pos; s++) {
var patIdx = XMPlayer.xm.songpats[s];
if (patIdx >= XMPlayer.xm.patterns.length) continue;
var pat = XMPlayer.xm.patterns[patIdx];
for (var row = 0; row < pat.length; row++) {
for (var ch = 0; ch < pat[row].length; ch++) {
if (pat[row][ch][3] === 0xf && pat[row][ch][4] > 0) {
if (pat[row][ch][4] < 0x20) {
tempo = pat[row][ch][4];
} else {
bpm = pat[row][ch][4];
}
}
}
}
}
XMPlayer.xm.tempo = tempo;
XMPlayer.xm.bpm = bpm;
XMPlayer.cur_songpos = pos;
XMPlayer.cur_pat = XMPlayer.xm.songpats[pos];
XMPlayer.cur_row = 0;
XMPlayer.next_row = 0;
XMPlayer.cur_tick = 0;
}
function prevPattern() { jumpToSongPos(XMPlayer.cur_songpos - 1); }
function nextPattern() { jumpToSongPos(XMPlayer.cur_songpos + 1); }
function toggleRepeat() {
XMPlayer.repeat = !XMPlayer.repeat;
document.getElementById('btn-repeat').classList.toggle('active', XMPlayer.repeat);
}
// -- Keyboard --
function flashButton(id) {
var btn = document.getElementById(id);
btn.classList.add('flash');
setTimeout(function() { btn.classList.remove('flash'); }, 100);
}
document.addEventListener('keydown', function(e) {
if (e.code === 'Space') {
e.preventDefault();
flashButton('btn-playpause');
togglePlay();
} else if (e.code === 'ArrowDown' || e.code === 'KeyN') {
e.preventDefault();
flashButton('btn-next');
nextTrack();
} else if (e.code === 'ArrowUp' || e.code === 'KeyP') {
e.preventDefault();
flashButton('btn-prev');
prevTrack();
} else if (e.code === 'ArrowLeft') {
e.preventDefault();
flashButton('btn-prevpat');
prevPattern();
} else if (e.code === 'ArrowRight') {
e.preventDefault();
flashButton('btn-nextpat');
nextPattern();
}
});
// -- Resize --
window.addEventListener('resize', function() {
resizePatternCanvas();
if (XMPlayer.xm && XMPlayer.xm.nchan) {
createScopes(XMPlayer.xm.nchan);
}
});
resizePatternCanvas();
// -- Volume slider --
var volumeSlider = document.getElementById('volume-slider');
function updateVolume() {
if (!XMPlayer.gainNode) return;
var v = volumeSlider.value / 100;
XMPlayer.gainNode.gain.value = v * v * 0.5;
}
volumeSlider.addEventListener('input', updateVolume);
buildTrackList('');
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment