Update libxmp, reorganize project structure, add Makefile
This commit is contained in:
BIN
src/FT2.ttf
Normal file
BIN
src/FT2.ttf
Normal file
Binary file not shown.
BIN
src/FT2.woff
Normal file
BIN
src/FT2.woff
Normal file
Binary file not shown.
28
src/background.js
Normal file
28
src/background.js
Normal 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
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
BIN
src/icon-96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
33
src/manifest.json
Normal file
33
src/manifest.json
Normal 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
313
src/player.css
Normal 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
67
src/player.html
Normal 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
894
src/player.js
Normal 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
BIN
src/stereo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src/volume.png
Normal file
BIN
src/volume.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Reference in New Issue
Block a user