initial commit

This commit is contained in:
Kouya Heika
2026-04-22 03:30:16 -05:00
commit 313035d85a
14 changed files with 1391 additions and 0 deletions

BIN
FT2.ttf Normal file

Binary file not shown.

BIN
FT2.woff Normal file

Binary file not shown.

28
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);
}

36
build.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Change this if it differs
EMHOME=${EMHOME:=/usr/lib/emscripten/}
cd libxmp
mkdir -p build
cd build
echo "_malloc
_free
_xmp_create_context
_xmp_free_context
_xmp_load_module
_xmp_release_module
_xmp_play_buffer
_xmp_play_frame
_xmp_get_format_list
_xmp_get_module_info
_xmp_set_player
_xmp_get_player
_xmp_get_frame_info
_xmp_start_player
_xmp_end_player
_xmp_restart_module
_xmp_seek_time
_xmp_channel_mute
_xmp_channel_vol
_xmp_inject_event
_xmp_test_module_from_memory
_xmp_load_module_from_memory" > exported.txt
$EMHOME/emcmake cmake ..
$EMHOME/emmake make -j4
$EMHOME/emcc -O3 libxmp.a -o libxmp.js -s WASM=1 -s EXPORTED_FUNCTIONS=@exported.txt -s EXPORT_ALL=1 -s BINARYEN_METHOD="native-wasm" -s EXPORTED_RUNTIME_METHODS='["cwrap","UTF8ToString","HEAPU8","HEAPF32","HEAP16"]' -s ALLOW_MEMORY_GROWTH=1 -s MODULARIZE=1 -s JSPI=1 -s EXPORT_NAME='libxmp' -s ENVIRONMENT='web,worker' -s SINGLE_FILE=0
cd ..
cd ..
cp libxmp/build/libxmp.js libxmp/build/libxmp.wasm .
zip -r waxmplayer.xpi . -x "libxmp/*" "waxmplayer.xpi"

BIN
icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
icon-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

20
libxmp.LICENSE Normal file
View File

@@ -0,0 +1,20 @@
Extended Module Player
Copyright (C) 1996-2026 Claudio Matsuoka and Hipolito Carraro Jr
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

33
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
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
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
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
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

BIN
stereo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
volume.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB