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