894 lines
25 KiB
JavaScript
894 lines
25 KiB
JavaScript
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');
|
|
}; |