Update libxmp, reorganize project structure, add Makefile

This commit is contained in:
Kouya Heika
2026-04-22 19:04:22 -05:00
parent 2db7fe959b
commit 77d02142d3
17 changed files with 42 additions and 57 deletions

BIN
src/FT2.ttf Normal file

Binary file not shown.

BIN
src/FT2.woff Normal file

Binary file not shown.

28
src/background.js Normal file
View File

@@ -0,0 +1,28 @@
browser.browserAction.onClicked.addListener(() => {
browser.tabs.create({
url: browser.runtime.getURL("player.html")
});
});
browser.webRequest.onBeforeRequest.addListener(
(details) => {
const url = details.url.toLowerCase();
if (isSupportedMediaUrl(url)) {
const playerUrl = browser.runtime.getURL("player.html") + "?media=" + encodeURIComponent(details.url);
return { redirectUrl: playerUrl };
}
return {};
},
{
urls: ["<all_urls>"],
types: ["main_frame", "sub_frame"]
},
["blocking"]
);
function isSupportedMediaUrl(url) {
return url.match(/\.(669|amf|it|mod|mtm|mptm|s3m|stm|ult|uni|xm|umx)$/i) ||
url.match(/^(669|amf|it|mod|mtm|mptm|s3m|stm|ult|uni|xm|umx)\./i);
}

BIN
src/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src/icon-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

33
src/manifest.json Normal file
View File

@@ -0,0 +1,33 @@
{
"manifest_version": 2,
"name": "WaXMPlayer",
"version": "1.0",
"description": "View and play module files in a tracker",
"author": "Kouya Heika",
"browser_action": {
"default_icon": {
"48": "icon-48.png"
},
"default_title": "Open WaXMPlayer"
},
"background": {
"scripts": [ "background.js" ]
},
"permissions": [
"tabs",
"webRequest",
"webRequestBlocking",
"<all_urls>"
],
"web_accessible_resources": [
"player.html"
],
"browser_specific_settings": {
"gecko": {
"id": "waxmplayer@kouya.canithesis.org",
"data_collection_permissions": {
"required": [ "none" ]
}
}
}
}

313
src/player.css Normal file
View File

@@ -0,0 +1,313 @@
@font-face {
font-family: FT2;
src: url('FT2.woff'), url('FT2.ttf') format("truetype");
}
:root {
--background: #000000;
--text-color: #FFFFFF;
--header-background: #4D749B;
--header-background2: linear-gradient(180deg,rgba(77, 116, 155, 1) 0%, rgba(39, 59, 79, 1) 100%);
--header-border: 1px outset var(--header-background);
--box-shadow: 2px 2px 0px black;
--channel-background: #273A4E;
--channel-background2: linear-gradient(180deg,rgba(39, 58, 78, 1) 0%, rgba(26, 39, 52, 1) 100%);
--button-background: #E0E0E0;
--button-background2: linear-gradient(180deg,rgba(224, 224, 224, 1) 0%, rgba(136, 136, 136, 1) 100%);
--button-border: 1px outset var(--button-background);
--active-button-background: #A7A7A7;
--active-button-background2: linear-gradient(180deg, #A7A7A7 0%, #676767 100%);
--active-button-border: 1px outset var(--active-button-background);
--button-outline: 1px solid black;
--border-color: #406080;
--input-border: 2px inset var(--border-color);
--row-borders: 4px ridge var(--border-color);
--table-heading-background: #273A4E;
--table-heading-background2: linear-gradient(180deg,rgba(39, 58, 78, 1) 0%, rgba(26, 39, 52, 1) 100%);
--font-family: FT2;
--playhead-background: #602040;
--playhead-border-width: 2px;
--playhead-light-background: #7F2A55;
--playhead-dark-background: #301020;
}
html, body {
background: var(--background);
height: 100vh;
margin: 0px; padding: 0px;
font-family: var(--font-family);
}
div.tracker {
display: flex;
flex-direction: column;
height: 100%;
}
div.tracker .header {
background: var(--header-background);
background: var(--header-background2);
border: var(--header-border);
padding: 2px;
}
div.tracker button {
background: var(--button-background);
background: var(--button-background2);
border: var(--button-border);
outline: var(--button-outline);
box-shadow: var(----box-shadow);
font-family: var(--font-family);
font-size: 18px;
margin: 2px;
}
div.tracker input {
background: var(--background);
color: var(--text-color);
border: var(--input-border);
font-family: var(--font-family);
font-size: 16px;
}
div.tracker .header > .box { display: grid; }
div.tracker .header .holder {
overflow-x: auto;
}
div.tracker .header .controls {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
padding: 2px;
width: 580px;
min-height: 132px;
}
body.tracker div.tracker .header .controls { grid-template-columns: repeat(8, 1fr); width: 1162px; }
div.tracker .header .controls hr { margin: 2px; margin-bottom: 6px; border: 2px inset var(--header-background); }
div.tracker .header .controls .part {
background: var(--header-background);
background: var(--header-background2);
border: var(--header-border);
color: white;
}
div.tracker .header .controls .title-div { display: flex; grid-column: span 3 / span 3; }
div.tracker .header .controls .player-div { padding: 10px 0px 0px 0px; text-align: center; grid-column: span 2 / span 2; grid-column-start: 3; grid-row-start: 3; }
div.tracker .header .controls .type-div { margin: 2px; display: flex; grid-column: span 2 / span 2; grid-row-start: 4; }
div.tracker .header .controls .bpm-div { padding: 10px 0px 0px 0px; text-align: center; grid-column-start: 4; }
div.tracker .header .controls .vol-div { display:flex; min-width: 280px; gap: 4px; grid-column: span 2 / span 2; grid-column-start: 1; grid-row-start: 2; }
div.tracker .header .controls .stsep-div { display:flex; min-width: 280px; gap: 4px; grid-column: span 2 / span 2; grid-column-start: 3; grid-row-start: 2; }
div.tracker .header .controls .instruments-div,
div.tracker .header .controls .samples-div { display: none; }
body.tracker div.tracker .header .controls .instruments-div {
grid-column: span 2 / span 2;
grid-row: span 4 / span 4;
grid-column-start: 5;
grid-row-start: 1;
display: unset;
}
body.tracker div.tracker .header .controls .samples-div {
grid-column: span 2 / span 2;
grid-row: span 4 / span 4;
grid-column-start: 7;
grid-row-start: 1;
display: unset;
}
div.tracker .header .controls button#play { grid-column-start: 1; grid-row-start: 3; }
div.tracker .header .controls button#pause { grid-column-start: 1; grid-row-start: 4; }
div.tracker .header .controls button#stop { grid-column-start: 2; grid-row-start: 3; }
div.tracker .header .controls button#download { grid-column-start: 2; grid-row-start: 4; }
div.tracker .header .controls .title-div .title { display: flex; }
div.tracker .header .controls .title-div .part { flex-grow: 1; }
div.tracker .header .controls .title-div .part:not(.title) { padding: 10px 0px 0px 0px; text-align: center; }
div.tracker .header .controls .vol-div img,
div.tracker .header .controls .stsep-div img { padding: 6px; }
div.tracker .header .controls .vol-div .vol-text,
div.tracker .header .controls .stsep-div .stsep-text { margin: 4px 0px; width: 40px; padding :4px 10px; }
div.tracker .header .controls .container {
overflow-x: clip; overflow-x: auto;
height: 132px;
font-size: 14px;
}
div.tracker .header .controls .container .instruments,
div.tracker .header .controls .container .samples {
border-collapse: collapse;
background: var(--background);
width: 100%;
}
div.tracker .header .controls .container .instruments tr th:last-of-type,
div.tracker .header .controls .container .instruments tr td:last-of-type,
div.tracker .header .controls .container .samples tr th:last-of-type,
div.tracker .header .controls .container .samples tr td:last-of-type {
border-right-style: outset;
border-right-width: 2px;
}
div.tracker .header .controls .container .instruments tr,
div.tracker .header .controls .container .samples tr {
background: var(--border-color);
}
div.tracker .header .controls .container .instruments tr:nth-of-type(1),
div.tracker .header .controls .container .samples tr:nth-of-type(1) {
position: sticky;
top: 0px;
z-index: 10;
}
div.tracker .header .controls .container .instruments th,
div.tracker .header .controls .container .samples th {
background: var(--table-heading-background);
background: var(--table-heading-background2);
border-left: var(--row-borders); border-right: var(--row-borders); border-bottom: var(--row-borders);
}
div.tracker .header .controls .container .instruments td,
div.tracker .header .controls .container .samples td {
background: var(--background);
border-left: var(--row-borders); border-right: var(--row-borders);
}
input[type="range"]::-moz-range-track { background: none; }
input[type="range"]::slider-track { background: none; }
input[type="range"]::-moz-range-progress { background: none; }
input[type="range"]::slider-fill { background: none; }
input[type="range"]::-moz-range-thumb {
background: var(--button-background);
background: var(--button-background2);
border: var(--button-border);
outline: var(--button-outline);
border-radius: 0px;
}
input[type="range"]::slider-thumb {
background: var(--button-background);
background: var(--button-background2);
border: var(--button-border);
outline: var(--button-outline);
border-radius: 0px;
}
div.tracker .header .controls .title-div .title input,
div.tracker .header .controls .type-div input { flex-grow: 1; }
div.tracker .header .curtime, div.tracker .header .totaltime { }
div.tracker .header .speed, div.tracker .header .bpm { display: inline-block; margin: 2px; }
div.tracker .header .order-box {
background: var(--background);
border: var(--input-border);
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
display: none;
}
body.tracker div.tracker .header .order-box { display: block; }
div.tracker .header .order-box::-webkit-scrollbar {
width: 0px;
background: transparent;
}
div.tracker .header .order-box .orders {
display: inline flex;
}
div.tracker .header .order-box .orders > * {
background: var(--button-background);
background: var(--button-background2);
border: var(--button-border);
outline: var(--button-outline);
display: inline-block;
font-size: 16px;
width: 24px;
text-align: center;
padding-top: 4px;
}
div.tracker .header .order-box .orders > *.active {
background: var(--active-button-background);
background: var(--active-button-background);
border: var(--active-button-border);
}
div.tracker .patterns {
font-family: hack, monospace;
background: var(--background);
color: var(--text-color);
white-space: nowrap;
overflow: auto;
display: none;
}
body.tracker div.tracker .patterns { display: unset; }
div.tracker .patterns .pattern {
background: var(--background);
color: var(--text-color);
font-family: monospace;
margin: 0px;
white-space: nowrap;
border-collapse: collapse;
}
div.tracker .patterns .pattern tr {
background: var(--border-color);
}
div.tracker .patterns .pattern tr:nth-of-type(1) {
position: sticky;
top: 0px;
z-index: 10;
}
div.tracker .patterns .pattern .channel .visualizer meter::-webkit-meter-bar { background: none; }
div.tracker .patterns .pattern .channel .visualizer meter {
background: var(--background);
border: var(--input-border);
width: 30px;
}
div.tracker .patterns .pattern .channel .visualizer meter.left { border-right: none; direction: rtl; }
div.tracker .patterns .pattern .channel .visualizer meter.right { border-left: none; }
div.tracker .patterns .pattern tr.playhead td {
background: var(--playhead-background);
border-top: var(--playhead-border-width) solid var(--playhead-light-background);
border-bottom: var(--playhead-border-width) solid var(--playhead-dark-background);
font-weight: bold;
}
div.tracker .patterns .pattern tr.gap td {
height: 50vh;
}
div.tracker .patterns .pattern th {
background: var(--table-heading-background);
background: var(--table-heading-background2);
border-left: var(--row-borders); border-right: var(--row-borders); border-bottom: var(--row-borders);
}
div.tracker .patterns .pattern td {
background: var(--background);
border-left: var(--row-borders); border-right: var(--row-borders);
}
div.tracker .patterns .pattern td#no { color: yellow; }
div.tracker .patterns .pattern td a.instrument { color: #80E0FF; }
div.tracker .patterns .pattern td a.volume { color: #80FF80; }
div.tracker .patterns .pattern td a.effect_type { color: #FF80E0; }
div.tracker .patterns .pattern td a.effect_param { color: #FFE080; }

67
src/player.html Normal file
View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<title>WaXMPlayer</title>
<link rel="icon" href="icon-48.png" type="image/png" sizes="64x64" />
<link rel="stylesheet" href="player.css" />
<script type="text/javascript" src="libxmp.js"></script>
</head>
<body class='tracker'>
<div class='tracker'>
<div class='header'>
<div class='holder'>
<div class='controls'>
<div class='type-div'><input type='text' name='type' value='drop file to play' readonly='1' /></div>
<div class='title-div'>
<div class='curtime part'>00:00</div>
<div class='title part'><input type='text' name='title' value='' readonly='1' /></div>
<div class='totaltime part'>00:00</div>
</div>
<div class='bpm-div part'><a class='speed'>0</a> / <a class='bpm'>0</a> bpm</div>
<div class='vol-div part' title='Volume'><img src='volume.png' width='16' height='16' /><input type='range' name='volume' min='0' max='200' value='100' step='5' list='range-markers' /><div class='vol-text'>100%</div></div>
<div class='stsep-div part' title='Stereo Separation'><img src='stereo.png' width='16' height='16' /><input type='range' name='stereo' min='0' max='100' value='100' step='5' list='range-markers' /><div class='stsep-text'>50%</div></div>
<div class='player-div part'>WaXMPlayer 1.0</div>
<button id="play" disabled>Play Song</button>
<button id="pause" disabled>Pause</button>
<button id="stop" disabled>Stop</button>
<button id="download" disabled>Download</button>
<div class='instruments-div part'>
<div class='container'>
<table class='instruments' width='100'>
<tr><th colspan='2'>Instruments</th></tr>
</table>
</div>
</div>
<div class='samples-div part'>
<div class='container'>
<table class='samples' width='100'>
<tr><th colspan='2'>Samples</th></tr>
</table>
</div>
</div>
<datalist id="range-markers">
<option value="0"></option>
<option value="25"></option>
<option value="50"></option>
<option value="75"></option>
<option value="100"></option>
<option value="125"></option>
<option value="150"></option>
<option value="175"></option>
<option value="200"></option>
</datalist>
</div>
</div>
<div class='order-box'>
<div class='orders'></div>
</div>
</div>
<div class='patterns'><noscript>You need Javascript enabled for this to work.</noscript></div>
</div>
<script src='player.js'></script>
</body>
</html>

894
src/player.js Normal file
View File

@@ -0,0 +1,894 @@
const XMP_NAME_SIZE = 64;
const XMP_MAX_MOD_LENGTH = 256;
const XMP_MAX_ENV_POINTS = 32;
const XMP_MAX_KEYS = 121;
const XMP_MAX_CHANNELS = 64;
const SR = 44100;
const BUF = 1024;
const BYTES = BUF * 4;
const NOTES = ["C ", "C#", "D ", "D#", "E ", "F ", "F#", "G ", "G#", "A ", "A#", "B "];
const EFFECTS_MAP = new Map([
[
'669',
new Map([
[96, 'A'], [97, 'B'], [98, 'C'], [99, 'D'], [100, 'E'], [126, 'F']
])
],
[
'Farandole',
new Map([
[249, '1'], [248, '2'], [122, '3'], [251, '4'], [254, '5'], [4, '6'],
[256, '7'], [252, '8'], [123, '9'], [250, 'C'], [15, 'F']
])
],
[
'Imago Orpheus',
new Map([
[1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5'], [6, '6'],
[7, '7'], [8, '8'], [9, '9'], [10, 'A'], [11, 'B'], [12, 'C'],
[13, 'D'], [14, 'E'], [15, 'F'], [16, 'G'], [17, 'H'], [18, 'I'],
[19, 'J'], [20, 'K'], [21, 'L'], [22, 'M'], [23, 'N'], [24, 'O'],
[25, 'P'], [26, 'Q'], [27, 'R'], [28, 'S'], [29, 'T'], [30, 'U'],
[31, 'V'], [32, 'W'], [33, 'X'], [34, 'Y'], [35, 'Z']
])
],
[
'S3M',
new Map([
[163, 'A'], [11, 'B'], [13, 'C'], [10, 'D'], [2, 'E'], [1, 'F'],
[3, 'G'], [4, 'H'], [29, 'I'], [180, 'J'], [6, 'K'], [5, 'L'],
[9, 'O'], [27, 'Q'], [7, 'R'], [254, 'S'], [171, 'T'], [172, 'U'],
[16, 'V'], [8, 'X'], [141, 'X'], [14, 'S']
])
],
[
'IT',
new Map([
[163, 'A'], [11, 'B'], [142, 'C'], [10, 'D'], [2, 'E'], [1, 'F'],
[3, 'G'], [4, 'H'], [29, 'I'], [180, 'J'], [6, 'K'], [5, 'L'],
[128, 'M'], [129, 'N'], [9, 'O'], [137, 'P'], [27, 'Q'], [7, 'R'],
[254, 'S'], [135, 'T'], [172, 'U'], [16, 'V'], [17, 'W'], [8, 'X'],
[138, 'Y'], [141, 'S'], [136, 'S'], [14, 'S'], [192, 'c'], [193, 'd'],
[194, 'a'], [195, 'b'], [132, 'S'], [139, 'S'], [140, 'S']
])
],
[
'LIQ',
new Map([
[0, 'A'], [171, 'B'], [13, 'C'], [2, 'D'], [172, 'F'], [11, 'J'],
[10, 'L'], [14, 'M'], [3, 'N'], [9, 'O'], [163, 'S'], [7, 'T'],
[1, 'U'], [4, 'V'], [5, 'X'], [6, 'Y']
])
],
[
'Oktalyzer',
new Map([
[1, '1'], [2, '2'], [112, '0'], [113, '0'], [114, '0'], [115, '6'],
[116, '5'], [156, '6'], [11, 'B'], [15, 'F'], [157, '5'], [12, 'C'],
[10, 'A'], [174, 'E'], [17, 'E'], [0, '0']
])
],
[
'STX',
new Map([
[15, 'A'], [11, 'B'], [13, 'C'], [10, 'D'], [2, 'E'], [1, 'F'],
[3, 'G'], [4, 'H'], [29, 'I'], [0, 'J']
])
],
[
'Funk',
new Map([
[121, 'A'], [120, 'B'], [122, 'C'], [123, 'D'], [124, 'G'], [125, 'H'],
[0, 'L'], [12, 'N'], [127, 'O'], [14, 'O'], [15, 'O']
])
],
[
'Protracker',
new Map([
[0, '0'], [1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5'],
[6, '6'], [7, '7'], [8, '8'], [9, '9'], [10, 'A'], [11, 'B'],
[12, 'C'], [13, 'D'], [14, 'E'], [15, 'F'], [16, 'G'], [27, 'Q'],
[181, 'P'], [17, 'H'], [21, 'L'], [164, 'c'], [33, 'X'], [20, 'K'],
[25, 'P'], [29, 'T'], [146, '4'], [160, 'x'], [161, 'x'], [171, 'F']
])
],
]);
let lib = null, ctx = 0, audio = null, node = null, xmi = null;
let playing = false, paused = false;
let lastPattern = -1, lastPos = -1, lastRow = -1, lastSpeed = -1, lastBpm = -1;
let volume = 100, stereo = 50;
let efx_map = new Map();
let meters = new Array();
let active_meters = new Array();
let filename = 'unknown';
let mode = 'tracker';
/* DOM Elements */
const uiRoot = document.body.querySelector('.tracker');
const uiHeader = uiRoot.querySelector(':scope > .header');
const uiControls = uiHeader.querySelector(':scope > .holder > .controls');
const buttonPlay = uiControls.querySelector(':scope button#play');
const buttonPause = uiControls.querySelector(':scope button#pause');
const buttonStop = uiControls.querySelector(':scope button#stop');
const buttonDownload = uiControls.querySelector(':scope button#download');
const titleBar = uiControls.querySelector(':scope input[name=title]');
const curTimeText = uiControls.querySelector(':scope .curtime');
const totalTimeText = uiControls.querySelector(':scope .totaltime');
const speedText = uiControls.querySelector(':scope .speed');
const bpmText = uiControls.querySelector(':scope .bpm');
const typeText = uiControls.querySelector(':scope input[name=type]');
const volumeBar = uiControls.querySelector(':scope input[name=volume]');
const volumeText = uiControls.querySelector(':scope .vol-div .vol-text');
const stereoBar = uiControls.querySelector(':scope input[name=stereo]');
const stereoText = uiControls.querySelector(':scope .stsep-div .stsep-text');
const uiOrder = uiHeader.querySelector(':scope > .order-box > .orders');
const uiPatterns = uiRoot.querySelector(':scope > .patterns');
const uiInstruments = uiControls.querySelector(':scope .instruments-div .instruments');
const uiSamples = uiControls.querySelector(':scope .samples-div .samples');
let uiCurrentPattern = null, uiCurrentRow = null;
/* End DOM Elements */
let headerHeight = 0, gapHeight = 0, rowHeight = 0;
let orderCells = [];
let patternTables = [];
let patternMeters = [];
let patternRows = [];
let instrumentRows = [];
let sampleRows = [];
libxmp().then(m => {
lib = m;
if (!lib) throw new Error('libxmp module is null');
ctx = lib._xmp_create_context();
if (!ctx) throw new Error('Failed to create context');
console.log('libxmp ready, ctx:', ctx);
volumeBar.value = volume;
stereoBar.value = stereo;
['dragenter', 'dragover'].forEach(ev => {
document.body.addEventListener(ev, e => {
e.preventDefault();
});
});
['dragleave', 'drop'].forEach(ev => {
document.body.addEventListener(ev, e => {
e.preventDefault();
});
});
const params = new URLSearchParams(window.location.search);
let url = params.get('media');
if (url) {
loadFromUrl(url);
} else {
document.body.addEventListener('drop', e => {
const file = e.dataTransfer.files[0];
if (file) {
stop();
orderCells.forEach(e => e.remove());
patternTables.forEach(e => e.remove());
patternMeters.forEach(e => {e.forEach(f => {f.l.remove(); f.r.remove();})});
patternRows.forEach(e => e.forEach(f => f.remove()));
instrumentRows.forEach(e => e.remove());
sampleRows.forEach(e => e.remove());
orderCells = [];
patternTables = [];
patternMeters = [];
patternRows = [];
instrumentRows = [];
sampleRows = [];
meters = new Array();
active_meters = new Array();
filename = file.name;
const objectUrl = URL.createObjectURL(file);
file.arrayBuffer().then(buffer => loadModule(new Uint8Array(buffer)));
}
});
}
});
function dispatch(name, detail) {
window.dispatchEvent(new CustomEvent(name, { detail }));
}
function readString(ptr, maxLen = 0) {
if (!ptr) return null;
return maxLen ? lib.UTF8ToString(ptr, maxLen).replace(/\0.*$/, '') : lib.UTF8ToString(ptr);
}
function readEvent(ptr) {
if (!ptr) return null;
return {
note : lib.getValue(ptr + 0, 'i8'),
ins : lib.getValue(ptr + 1, 'i8'),
vol : lib.getValue(ptr + 2, 'i8'),
fxt : lib.getValue(ptr + 3, 'i8'),
fxp : lib.getValue(ptr + 4, 'i8'),
f2t : lib.getValue(ptr + 5, 'i8'),
f2p : lib.getValue(ptr + 6, 'i8'),
_flag: lib.getValue(ptr + 7, 'i8')
};
}
function readChannel(ptr) {
return {
pan : lib.getValue(ptr + 0, 'i32'),
vol : lib.getValue(ptr + 4, 'i32'),
flg : lib.getValue(ptr + 8, 'i32')
};
}
function readEnvelope(ptr) {
if (!ptr) return null;
const flg = lib.getValue(ptr + 0, 'i32');
const npt = lib.getValue(ptr + 4, 'i32');
const data = [];
for (let i = 0; i < npt * 2 && i < XMP_MAX_ENV_POINTS * 2; i++) {
data.push(lib.getValue(ptr + 28 + i * 2, 'i16'));
}
return {
flg,
npt,
scl : lib.getValue(ptr + 8, 'i32'),
sus : lib.getValue(ptr + 12, 'i32'),
sue : lib.getValue(ptr + 16, 'i32'),
lps : lib.getValue(ptr + 20, 'i32'),
lpe : lib.getValue(ptr + 24, 'i32'),
data
};
}
function readSubinstrument(ptr) {
if (!ptr) return null;
return {
vol : lib.getValue(ptr + 0, 'i32'),
gvl : lib.getValue(ptr + 4, 'i32'),
pan : lib.getValue(ptr + 8, 'i32'),
xpo : lib.getValue(ptr + 12, 'i32'),
fin : lib.getValue(ptr + 16, 'i32'),
vwf : lib.getValue(ptr + 20, 'i32'),
vde : lib.getValue(ptr + 24, 'i32'),
vra : lib.getValue(ptr + 28, 'i32'),
vsw : lib.getValue(ptr + 32, 'i32'),
rvv : lib.getValue(ptr + 36, 'i32'),
sid : lib.getValue(ptr + 40, 'i32'),
nna : lib.getValue(ptr + 44, 'i32'),
dct : lib.getValue(ptr + 48, 'i32'),
dca : lib.getValue(ptr + 52, 'i32'),
ifc : lib.getValue(ptr + 56, 'i32'),
ifr : lib.getValue(ptr + 60, 'i32')
};
}
function readInstrument(basePtr, index) {
const ptr = basePtr + index * 764;
const name = readString(ptr, 32);
const nsm = lib.getValue(ptr + 36, 'i32');
const subPtr = lib.getValue(ptr + 752, 'i32');
const map = [];
for (let k = 0; k < XMP_MAX_KEYS; k++) {
const mptr = ptr + 512 + k * 2;
map.push({
ins : lib.getValue(mptr, 'i8'),
xpo : lib.getValue(mptr + 1, 'i8')
});
}
const sub = [];
if (nsm > 0 && nsm <= 255 && subPtr) {
for (let s = 0; s < nsm; s++) {
sub.push(readSubinstrument(subPtr + s * 64));
}
}
return {
name,
vol : lib.getValue(ptr + 32, 'i32'),
nsm,
rls : lib.getValue(ptr + 40, 'i32'),
aei : readEnvelope(ptr + 44),
pei : readEnvelope(ptr + 200),
fei : readEnvelope(ptr + 356),
map,
sub
};
}
function readSample(basePtr, index) {
const ptr = basePtr + index * 52;
return {
name : readString(ptr, 32),
len : lib.getValue(ptr + 32, 'i32'),
lps : lib.getValue(ptr + 36, 'i32'),
lpe : lib.getValue(ptr + 40, 'i32'),
flg : lib.getValue(ptr + 44, 'i32'),
data : lib.getValue(ptr + 48, 'i32')
};
}
function readTrack(ptr) {
if (!ptr) return null;
const rows = lib.getValue(ptr + 0, 'i32');
const events = [];
const eventPtr = ptr + 4;
for (let r = 0; r < rows; r++) {
events.push(readEvent(eventPtr + r * 8));
}
return { rows, events };
}
function readPattern(ptr, chn) {
if (!ptr) return null;
const rows = lib.getValue(ptr + 0, 'i32');
const index = [];
for (let i = 0; i < chn; i++) {
index.push(lib.getValue(ptr + 4 + i * 4, 'i32'));
}
return { rows, index };
}
function get_module_info(context) {
if (!lib || !context) return null;
const infoSize = 36;
const infoPtr = lib._malloc(infoSize);
lib._xmp_get_module_info(context, infoPtr);
const md5 = [];
for (let i = 0; i < 16; i++) md5.push(lib.getValue(infoPtr + i, 'i8'));
const vol_base = lib.getValue(infoPtr + 16, 'i32');
const mod_ptr = lib.getValue(infoPtr + 20, 'i32');
const comment_ptr = lib.getValue(infoPtr + 24, 'i32');
const num_sequences = lib.getValue(infoPtr + 28, 'i32');
const seq_data_ptr = lib.getValue(infoPtr + 32, 'i32');
const comment = readString(comment_ptr);
const sequences = [];
if (seq_data_ptr && num_sequences > 0) {
for (let i = 0; i < num_sequences; i++) {
const s = seq_data_ptr + i * 8;
sequences.push({
start: lib.getValue(s, 'i32'),
length: lib.getValue(s + 4, 'i32')
});
}
}
let mod = null;
if (mod_ptr) {
const name = readString(mod_ptr, XMP_NAME_SIZE);
const type = readString(mod_ptr + XMP_NAME_SIZE, XMP_NAME_SIZE);
const base = mod_ptr + 2 * XMP_NAME_SIZE;
const pat = lib.getValue(base + 0, 'i32');
const trk = lib.getValue(base + 4, 'i32');
const chn = lib.getValue(base + 8, 'i32');
const ins = lib.getValue(base + 12, 'i32');
const smp = lib.getValue(base + 16, 'i32');
const spd = lib.getValue(base + 20, 'i32');
const bpm = lib.getValue(base + 24, 'i32');
const len = lib.getValue(base + 28, 'i32');
const rst = lib.getValue(base + 32, 'i32');
const gvl = lib.getValue(base + 36, 'i32');
const xxp = lib.getValue(base + 40, 'i32');
const xxt = lib.getValue(base + 44, 'i32');
const xxi = lib.getValue(base + 48, 'i32');
const xxs = lib.getValue(base + 52, 'i32');
const xxc = base + 56;
const xxo = base + 56 + 64 * 12;
const patterns = [];
if (xxp) {
for (let i = 0; i < pat; i++) {
const p = lib.getValue(xxp + i * 4, 'i32');
patterns.push(readPattern(p, chn));
}
}
const tracks = [];
if (xxt) {
for (let i = 0; i < trk; i++) {
const t = lib.getValue(xxt + i * 4, 'i32');
tracks.push(readTrack(t));
}
}
const instruments = [];
if (xxi && ins > 0) {
for (let i = 0; i < ins; i++) {
instruments.push(readInstrument(xxi, i));
}
}
const samples = [];
if (xxs && smp > 0) {
for (let i = 0; i < smp; i++) {
samples.push(readSample(xxs, i));
}
}
const channels = [];
for (let i = 0; i < chn; i++) {
channels.push(readChannel(xxc + i * 16));
}
const order = [];
for (let i = 0; i < XMP_MAX_MOD_LENGTH && i < len; i++) {
order.push(lib.getValue(xxo + i, 'i8') & 0xFF);
}
mod = {
name, type, pat, trk, chn, ins, smp, spd, bpm, len, rst, gvl,
order, channels, patterns, tracks, instruments, samples
};
}
lib._free(infoPtr);
return { md5, vol_base, mod, comment, num_sequences, sequences };
}
function get_frame_info(context) {
if (!lib || !context) return null;
const infoSize = 2048; // generous
const ptr = lib._malloc(infoSize);
lib._xmp_get_frame_info(context, ptr);
const info = {
pos: lib.getValue(ptr + 0, 'i32'),
pattern: lib.getValue(ptr + 4, 'i32'),
row: lib.getValue(ptr + 8, 'i32'),
num_rows: lib.getValue(ptr + 12, 'i32'),
frame: lib.getValue(ptr + 16, 'i32'),
speed: lib.getValue(ptr + 20, 'i32'),
bpm: lib.getValue(ptr + 24, 'i32'),
time: lib.getValue(ptr + 28, 'i32'),
total_time: lib.getValue(ptr + 32, 'i32'),
frame_time: lib.getValue(ptr + 36, 'i32'),
buffer: lib.getValue(ptr + 40, 'i32'),
buffer_size: lib.getValue(ptr + 44, 'i32'),
total_size: lib.getValue(ptr + 48, 'i32'),
volume: lib.getValue(ptr + 52, 'i32'),
loop_count: lib.getValue(ptr + 56, 'i32'),
virt_channels: lib.getValue(ptr + 60, 'i32'),
virt_used: lib.getValue(ptr + 64, 'i32'),
sequence: lib.getValue(ptr + 68, 'i32')
};
const chanInfoPtr = ptr + 72;
const chanSize = 24;
info.channel_info = [];
for (let i = 0; i < XMP_MAX_CHANNELS; i++) {
const c = chanInfoPtr + i * chanSize;
info.channel_info.push({
period: lib.getValue(c + 0, 'i32'),
position: lib.getValue(c + 4, 'i32'),
pitchbend: lib.getValue(c + 8, 'i16'),
note: lib.getValue(c + 10, 'i8'),
instrument: lib.getValue(c + 11, 'i8'),
sample: lib.getValue(c + 12, 'i8'),
volume: lib.getValue(c + 13, 'i8'),
pan: lib.getValue(c + 14, 'i8'),
reserved: lib.getValue(c + 15, 'i8'),
event: readEvent(c + 16)
});
}
lib._free(ptr);
return info;
}
async function loadFromUrl(url) {
const res = await fetch(url);
console.log(res);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
filename = res.headers.get('content-disposition');
if (filename && (filename.indexOf('attachment') !== -1 || filename.indexOf('inline') !== -1)) {
var filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
var matches = filenameRegex.exec(filename);
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
}
} else {
filename = res.url.substring(res.url.lastIndexOf('/')+1)
if (filename.indexOf('?') !== -1) {
filename.substring(0, filename.lastIndexOf('?'));
}
}
const buf = await res.arrayBuffer();
loadModule(new Uint8Array(buf));
}
async function loadModule(u8buf) {
try {
console.log('Module size:', u8buf.byteLength, 'bytes');
const ptr = lib._malloc(u8buf.byteLength);
if (!ptr) throw new Error('Malloc failed for module');
lib.HEAPU8.set(u8buf, ptr);
console.log(u8buf);
const ret = lib._xmp_load_module_from_memory(ctx, ptr, u8buf.byteLength);
lib._free(ptr);
if (ret !== 0) throw new Error(`Load failed: ${ret}`);
const blob = new Blob([u8buf], { type: 'application/octet-stream' });
buttonDownload.onclick = () => {
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
link.click();
};
buttonDownload.disabled = false;
xmi = get_module_info(ctx);
efx_map = getEffectsMap();
for(let c = 0; c < xmi.mod.chn; c++)
active_meters.push({l:null,r:null});
Promise.resolve().then(() => {
titleBar.value = xmi.mod.name;
typeText.value = xmi.mod.type;
totalTimeText.textContent = (Math.floor(((xmi.sequences[0].length / 1000) / 60) % 60)).toString().padStart(2, '0') + ':' +
(Math.floor((xmi.sequences[0].length / 1000) % 60)).toString().padStart(2, '0');
if (mode == 'tracker') {
preloadOrder();
loadInstruments();
loadSamples();
// load first pattern
let p = xmi.mod.order[0];
uiNewPattern = createPatternTable(p);
patternTables[p] = uiNewPattern;
uiPatterns.appendChild(uiNewPattern);
}
console.log('Module loaded successfully');
document.getElementById('play').disabled = false;
dispatch('moduleReady');
});
} catch (err) {
console.error('Load error:', err);
alert('An error occured attempting to load the module.');
}
}
function getEffectsMap() {
const type = xmi.mod.type;
console.log(type);
for (const entry of EFFECTS_MAP.entries()) {
if (type.includes(entry[0])) {
return entry[1];
}
}
return EFFECTS_MAP.get('Protracker');
}
async function preloadOrder() {
for (let o = 0; o < xmi.mod.len; o++) {
let index = xmi.mod.order[o];
if (index == 0xFF) continue;
const indexBox = document.createElement('div');
indexBox.textContent = toHex(index);
indexBox.width = indexBox.height = '24px';
uiOrder.appendChild(indexBox);
orderCells.push(indexBox);
}
}
function loadInstruments() {
for (let i = 0; i < xmi.mod.ins; i++) {
const row = uiInstruments.insertRow();
const index = row.insertCell();
index.width = 16;
index.textContent = toHex(i);
const name = row.insertCell();
name.textContent = xmi.mod.instruments[i].name;
instrumentRows.push(row);
}
}
function loadSamples() {
for (let s = 0; s < xmi.mod.smp; s++) {
const row = uiSamples.insertRow();
const index = row.insertCell();
index.width = 16;
index.textContent = toHex(s);
const name = row.insertCell();
name.textContent = xmi.mod.samples[s].name;
sampleRows.push(row);
}
}
function createPatternTable(p) {
const chn = xmi.mod.chn;
const pattern = xmi.mod.patterns[p];
if (!pattern) return null;
let html = `<table class="pattern" id="p${p}">`;
// Header
html += '<tr><th></th>';
for (let c = 0; c < chn; c++) {
html += `<th class="channel">Channel ${c+1}<div class="visualizer"><meter class="left" max="64"></meter><meter class="right" max="64"></meter></div></th>`;
}
html += '</tr>';
// Top gap
html += '<tr class="gap"><td></td>';
for (let c = 0; c < chn; c++) html += '<td></td>';
html += '</tr>';
// Rows
for (let r = 0; r < pattern.rows; r++) {
html += `<tr id="r${r}"><td id="no">${toHex(r)}</td>`;
for (let c = 0; c < chn; c++) {
const track = pattern.index[c];
const event = xmi.mod.tracks[track].events[r];
let noteText = '...';
if (event.note < 0) noteText = '===';
else if (event.note > 0) noteText = NOTES[event.note % 12] + Math.floor(event.note / 12);
let insText = event.ins > 0 ? toHex(event.ins) : '..';
let volText = '..';
if (event.vol != 0) volText = toHex(event.vol - 1);
else if (event.note > 0) volText = '40';
const fxp = event.fxp & 0xFF;
let efxType = '.';
let efxParam = '..';
if (efx_map.has(event.fxt) && fxp != 0) {
efxType = efx_map.get(event.fxt);
efxParam = toHex(fxp);
}
html += `<td><a class="note">${noteText}</a> <a class="instrument">${insText}</a> <a class="volume">${volText}</a> `+
`<a class="effect_type">${efxType}</a><a class="effect_param">${efxParam}</a></td>`;
}
html += '</tr>';
}
// Bottom gap
html += '<tr class="gap"><td></td>';
for (let c = 0; c < chn; c++) html += '<td></td>';
html += '</tr></table>';
const container = document.createElement('div'); // Temporary container to parse HTML
container.innerHTML = html;
const patternTable = container.firstChild;
// Extract meters
patternMeters[p] = [];
const headerRow = patternTable.rows[0];
for (let c = 0; c < chn; c++) {
const vis = headerRow.cells[c + 1].querySelector('.visualizer');
patternMeters[p][c] = {
l: vis.querySelector('.left'),
r: vis.querySelector('.right')
};
}
// Extract rows
patternRows[p] = [];
for (let r = 0; r < pattern.rows; r++) {
patternRows[p][r] = patternTable.rows[r + 2];
}
return patternTable;
}
async function play() {
if (playing && !paused) return;
let initalized = (audio != null && node != null);
if (!initalized) {
console.log('Starting playback...');
audio = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SR });
var soundBuffer = lib._malloc(BYTES+1);
await audio.resume();
console.log('AudioContext ready');
node = audio.createScriptProcessor(BUF, 0, 2);
console.log('ScriptProcessorNode created');
node.onaudioprocess = (e) => {
const leftChannel = e.outputBuffer.getChannelData(0);
const rightChannel = e.outputBuffer.getChannelData(1);
if (!paused && lib._xmp_play_buffer(ctx, soundBuffer, BYTES, 0) == 0) {
const heap = lib.HEAP16;
const base = soundBuffer >> 1;
for (let i = 0; i < BUF; i++) {
leftChannel[i] = heap[base + i*2] / 32768;
rightChannel[i] = heap[base + i*2 + 1] / 32768;
}
const xfi = get_frame_info(ctx);
if (xfi) {
if (xfi.pos !== lastPos) {
dispatch('patternChanged', {xfi});
lastPattern = xfi.pattern;
lastPos = xfi.pos;
lastRow = -1;
}
if (xfi.row !== lastRow) {
dispatch('rowChanged', {xfi});
lastRow = xfi.row;
}
if (xfi.speed !== lastSpeed) {
dispatch('speedChanged', {xfi});
lastSpeed = xfi.speed;
}
if (xfi.bpm !== lastBpm) {
dispatch('bpmChanged', {xfi});
lastBpm = xfi.bpm;
}
// set time
curTimeText.textContent = (Math.floor(((xfi.time / 1000) / 60) % 60)).toString().padStart(2, '0') + ':' +
(Math.floor((xfi.time / 1000) % 60)).toString().padStart(2, '0');
for (var c = 0; c < xmi.mod.chn; c++) {
const chan = xfi.channel_info[c];
const chanVol = chan.volume & 0xFF;
const chanPan = chan.pan & 0xFF;
const leftVol = Math.floor(chanVol * (256 - chanPan) / 256);
const rightVol = Math.floor(chanVol * chanPan / 256);
if (active_meters[c].l) active_meters[c].l.value = leftVol;
if (active_meters[c].r) active_meters[c].r.value = rightVol;
}
}
}
};
node.connect(audio.destination);
lib._xmp_start_player(ctx, SR, 0);
lib._xmp_set_player(ctx, 7, volume);
lib._xmp_set_player(ctx, 1, stereo);
}
paused = false;
playing = true;
buttonPlay.disabled = true;
buttonPause.disabled = buttonStop.disabled = false;
console.log('Playback started');
}
function pause() {
paused = true;
buttonPlay.disabled = false;
buttonPause.disabled = buttonStop.disabled = true;
}
function stop() {
if (!playing) return;
console.log('Stopping playback');
lib._xmp_end_player(ctx);
if (node) node.disconnect();
audio = null;
node = null;
playing = false;
lastPattern = lastRow = lastPos = -1;
buttonPlay.disabled = buttonPause.disabled = false;
buttonStop.disabled = true;
}
function changeVolume() {
volumeText.textContent = volumeBar.value+'%';
volume = volumeBar.value;
lib._xmp_set_player(ctx, 7, volume);
}
function changeStereoSeparation() {
stereoText.textContent = stereoBar.value+'%';
stereo = stereoBar.value;
lib._xmp_set_player(ctx, 1, stereo);
}
buttonPlay.onclick = play;
buttonPause.onclick = pause;
buttonStop.onclick = stop;
volumeBar.oninput = changeVolume;
stereoBar.oninput = changeStereoSeparation;
window.addEventListener('beforeunload', () => {
if (ctx) lib._xmp_free_context(ctx);
});
window.addEventListener('patternChanged', e => {
if (mode != 'tracker') return;
const xfi = e.detail.xfi;
if (lastPos >= 0) orderCells[lastPos].classList.remove('active');
orderCells[xfi.pos].className = 'active';
orderCells[xfi.pos].scrollIntoView();
if (xfi.pattern == lastPattern) return;
const p = xfi.pattern;
if (uiCurrentPattern != null) {
uiCurrentPattern.style.display = 'none';
}
let uiNewPattern = patternTables[p];
if (uiNewPattern == null) {
uiNewPattern = createPatternTable(p);
patternTables[p] = uiNewPattern;
uiPatterns.appendChild(uiNewPattern);
}
uiNewPattern.style.display = '';
uiCurrentPattern = uiNewPattern;
if (rowHeight === 0) {
headerHeight = uiNewPattern.rows[0].clientHeight;
gapHeight = uiNewPattern.rows[1].clientHeight;
rowHeight = uiNewPattern.rows[2].clientHeight;
}
for (let c = 0; c < xmi.mod.chn; c++) {
active_meters[c] = patternMeters[p][c];
}
});
window.addEventListener('rowChanged', e => {
if (mode != 'tracker') return;
const xfi = e.detail.xfi;
if (uiCurrentRow != null) uiCurrentRow.classList.remove('playhead');
const patternIndex = lastPattern;
let uiNewRow = patternRows[patternIndex] ? patternRows[patternIndex][xfi.row] : null;
if (uiNewRow != null) {
uiNewRow.classList.add('playhead');
uiCurrentRow = uiNewRow;
if (rowHeight > 0) {
const container = uiPatterns;
const rowPosition = headerHeight + gapHeight + xfi.row * rowHeight;
const targetScroll = rowPosition + rowHeight / 2 - container.clientHeight / 2;
container.scrollTop = Math.max(0, targetScroll);
}
}
});
window.addEventListener('speedChanged', e => {
speedText.textContent = e.detail.xfi.speed;
});
window.addEventListener('bpmChanged', e => {
bpmText.textContent = e.detail.xfi.bpm;
});
HTMLTableRowElement.prototype.insertHeadCell = function() {
var head = document.createElement('th');
this.append(head);
return head;
};
function toHex(s) {
return s.toString(16).toUpperCase().padStart(2, '0');
};

BIN
src/stereo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
src/volume.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB