commit 313035d85a24981333486368bc9c9f64f1042aed Author: Kouya Heika Date: Wed Apr 22 03:30:16 2026 -0500 initial commit diff --git a/FT2.ttf b/FT2.ttf new file mode 100644 index 0000000..ff19c3b Binary files /dev/null and b/FT2.ttf differ diff --git a/FT2.woff b/FT2.woff new file mode 100644 index 0000000..2598c2f Binary files /dev/null and b/FT2.woff differ diff --git a/background.js b/background.js new file mode 100644 index 0000000..610e985 --- /dev/null +++ b/background.js @@ -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: [""], + 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); +} \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..d3d060b --- /dev/null +++ b/build.sh @@ -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" \ No newline at end of file diff --git a/icon-48.png b/icon-48.png new file mode 100644 index 0000000..f52a43f Binary files /dev/null and b/icon-48.png differ diff --git a/icon-96.png b/icon-96.png new file mode 100644 index 0000000..18f75c6 Binary files /dev/null and b/icon-96.png differ diff --git a/libxmp.LICENSE b/libxmp.LICENSE new file mode 100644 index 0000000..7fc3345 --- /dev/null +++ b/libxmp.LICENSE @@ -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. \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..808bb17 --- /dev/null +++ b/manifest.json @@ -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", + "" + ], + "web_accessible_resources": [ + "player.html" + ], + "browser_specific_settings": { + "gecko": { + "id": "waxmplayer@kouya.canithesis.org", + "data_collection_permissions": { + "required": [ "none" ] + } + } + } +} \ No newline at end of file diff --git a/player.css b/player.css new file mode 100644 index 0000000..d44001d --- /dev/null +++ b/player.css @@ -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; } \ No newline at end of file diff --git a/player.html b/player.html new file mode 100644 index 0000000..cc253f5 --- /dev/null +++ b/player.html @@ -0,0 +1,67 @@ + + + + WaXMPlayer + + + + + +
+
+
+
+
+
+
00:00
+
+
00:00
+
+
0 / 0 bpm
+
100%
+
50%
+
WaXMPlayer 1.0
+ + + + + +
+
+ + +
Instruments
+
+
+
+
+ + +
Samples
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ + + + diff --git a/player.js b/player.js new file mode 100644 index 0000000..d812a9d --- /dev/null +++ b/player.js @@ -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 = ``; + + // Header + html += ''; + for (let c = 0; c < chn; c++) { + html += ``; + } + html += ''; + + // Top gap + html += ''; + for (let c = 0; c < chn; c++) html += ''; + html += ''; + + // Rows + for (let r = 0; r < pattern.rows; r++) { + html += ``; + 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 += ``; + } + html += ''; + } + + // Bottom gap + html += ''; + for (let c = 0; c < chn; c++) html += ''; + html += '
Channel ${c+1}
${toHex(r)}${noteText} ${insText} ${volText} `+ + `${efxType}${efxParam}
'; + + 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'); +}; \ No newline at end of file diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..590d87b Binary files /dev/null and b/screenshot.png differ diff --git a/stereo.png b/stereo.png new file mode 100644 index 0000000..ff36058 Binary files /dev/null and b/stereo.png differ diff --git a/volume.png b/volume.png new file mode 100644 index 0000000..beeb0d2 Binary files /dev/null and b/volume.png differ