PHP Classes

File: public_html/js/retro-audio-player.js

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   public_html/js/retro-audio-player.js   Download  
File: public_html/js/retro-audio-player.js
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 23,097 bytes
 

Contents

Class file image Download
const trackerExts = ['xm', 'it', 's3m', 'mod', 'stm', 'amf', '669', 'mptm']; const sidExts = ['sid']; const midiExts = ['mid', 'midi']; const players = new Map(); let nextPlayerId = 1; function esc(value) { return String(value || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } function getExt(url, label) { const source = (label || url || '').split('?')[0].split('#')[0]; const m = source.match(/\.([a-z0-9]+)$/i); return m ? m[1].toLowerCase() : ''; } export function isRetroAudioUrl(url, label) { const ext = getExt(url, label); return trackerExts.includes(ext) || sidExts.includes(ext) || midiExts.includes(ext); } function getKind(url, label) { const ext = getExt(url, label); if (trackerExts.includes(ext)) return 'tracker'; if (sidExts.includes(ext)) return 'sid'; if (midiExts.includes(ext)) return 'midi'; return ''; } function getPlayableUrl(url) { try { const parsed = new URL(url, window.location.href); if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin !== window.location.origin) { return '/api/media/raw?url=' + encodeURIComponent(parsed.href); } } catch (e) {} return url; } function assignId(container) { if (!container.dataset.retroPlayerId) { container.dataset.retroPlayerId = String(nextPlayerId++); } return container.dataset.retroPlayerId; } function setLoading(container, label, icon) { container.classList.add('bink-retro-audio'); container.innerHTML = ` <div class="py-2 px-3"> <div class="d-flex align-items-center gap-2"> <i class="fas ${icon} text-muted"></i> <span class="text-muted fw-semibold small">${esc(label)}</span> <i class="fas fa-spinner fa-spin text-muted ms-auto"></i> </div> </div>`; } function renderError(container, message) { container.innerHTML = `<div class="alert alert-danger m-3">${esc(message)}</div>`; } function renderControls(container, options) { const subtitle = options.subtitle ? `<span class="text-muted small" data-retro-subtitle-text>${esc(options.subtitle)}</span>` : ''; const extra = options.extra || ''; const visualizer = options.visualizer ? `<div class="mt-2">${options.visualizer}</div>` : ''; const iconButtonStyle = 'width:32px;height:28px;padding:0;line-height:1;display:inline-flex;align-items:center;justify-content:center;'; container.innerHTML = ` <div class="py-1 px-2" style="max-width:560px;"> <div class="d-flex flex-wrap align-items-center gap-2"> <i class="fas ${options.icon} text-muted"></i> <span class="text-muted fw-semibold small text-truncate" data-retro-title style="max-width:min(320px, 45vw);">${esc(options.title)}</span> <span data-retro-subtitle>${subtitle}</span> ${extra} <div class="d-flex align-items-center gap-1"> <button type="button" class="btn btn-primary btn-sm" data-retro-play style="${iconButtonStyle}"> <i class="fas fa-play"></i> </button> <button type="button" class="btn btn-outline-secondary btn-sm" data-retro-stop style="${iconButtonStyle}"> <i class="fas fa-stop"></i> </button> </div> <div class="d-flex align-items-center gap-2"> <i class="fas fa-volume-low text-muted small"></i> <input type="range" class="form-range mb-0" data-retro-volume style="width:96px;" min="0" max="100" value="${options.volume}"> <i class="fas fa-volume-high text-muted small"></i> </div> </div> ${visualizer} </div>`; } function setPlayButton(container, playing) { const btn = container.querySelector('[data-retro-play]'); if (!btn) return; btn.innerHTML = playing ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>'; btn.classList.toggle('btn-primary', !playing); btn.classList.toggle('btn-warning', playing); } function registerPlayer(container, controller) { const id = assignId(container); stopRetroAudioPlayer(container); players.set(id, controller); } export function stopRetroAudioPlayer(container) { const id = container && container.dataset ? container.dataset.retroPlayerId : ''; const controller = id ? players.get(id) : null; if (controller && typeof controller.stop === 'function') { try { controller.stop(); } catch (e) {} } if (id) players.delete(id); } export function stopRetroAudioWithin(root) { if (!root || !root.querySelectorAll) return; if (root.matches && root.matches('[data-retro-player-id]')) { stopRetroAudioPlayer(root); } root.querySelectorAll('[data-retro-player-id]').forEach(stopRetroAudioPlayer); } async function renderTracker(container, url, label) { setLoading(container, label, 'fa-music'); const playableUrl = getPlayableUrl(url); const [{ ChiptuneJsPlayer }, response] = await Promise.all([ import('/vendor/chiptune3/chiptune3.js'), fetch(playableUrl, { credentials: 'same-origin' }), ]); if (!response.ok) throw new Error('HTTP ' + response.status); const buffer = await response.arrayBuffer(); const player = new ChiptuneJsPlayer({ repeatCount: -1 }); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('libopenmpt initialization timed out')), 10000); player.onInitialized(() => { clearTimeout(timer); resolve(); }); player.onError((err) => { clearTimeout(timer); reject(new Error((err && err.type) || 'libopenmpt error')); }); }); let started = false; let playing = false; player.onMetadata((meta) => { const title = meta && typeof meta.title === 'string' && meta.title ? meta.title : (meta && typeof meta.name === 'string' && meta.name ? meta.name : label); const titleEl = container.querySelector('[data-retro-title]'); const subtitleEl = container.querySelector('[data-retro-subtitle]'); if (titleEl) titleEl.textContent = title; if (subtitleEl && meta && meta.type) subtitleEl.innerHTML = `<span class="text-muted small">${esc(meta.type)}</span>`; }); player.onEnded(() => { playing = false; started = false; setPlayButton(container, false); }); function bindTrackerControls(target) { const playBtn = target.querySelector('[data-retro-play]'); const stopBtn = target.querySelector('[data-retro-stop]'); const volume = target.querySelector('[data-retro-volume]'); playBtn.addEventListener('click', () => { if (!started) { player.play(buffer); started = true; playing = true; } else if (playing) { player.pause(); playing = false; } else { player.unpause(); playing = true; } setPlayButton(target, playing); }); stopBtn.addEventListener('click', () => { player.stop(); started = false; playing = false; setPlayButton(target, false); }); volume.addEventListener('input', () => player.setVol(parseInt(volume.value, 10) / 100)); } renderControls(container, { icon: 'fa-music', title: label, subtitle: '', volume: 70 }); bindTrackerControls(container); player.setVol(0.7); registerPlayer(container, { stop: () => player.stop() }); } function loadClassicScript(src) { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${CSS.escape(src)}"], script[src="${src}"]`)) { resolve(); return; } const s = document.createElement('script'); s.src = src; s.onload = resolve; s.onerror = () => reject(new Error('Failed to load script: ' + src)); document.head.appendChild(s); }); } function parseSidHeader(data) { const dec = new TextDecoder('iso-8859-1'); const magic = dec.decode(data.slice(0, 4)); if (magic !== 'PSID' && magic !== 'RSID') { return { title: '', author: '', released: '', numSongs: 1, startSong: 1 }; } return { numSongs: (data[14] << 8) | data[15], startSong: ((data[16] << 8) | data[17]) || 1, title: dec.decode(data.slice(22, 54)).replace(/\0.*/, '').trim(), author: dec.decode(data.slice(54, 86)).replace(/\0.*/, '').trim(), released: dec.decode(data.slice(86, 118)).replace(/\0.*/, '').trim(), }; } function ensureSidPlayerReady() { if (window._sidPlayerReady) return Promise.resolve(); if (window._sidPlayerLoading) return window._sidPlayerLoading; window._sidPlayerLoading = loadClassicScript('/vendor/websid/stdlib/scriptprocessor_player.min.js') .then(() => { window.WASM_SEARCH_PATH = '/vendor/websid/'; return loadClassicScript('/vendor/websid/backend_websid.js'); }) .then(() => { return new Promise((resolve, reject) => { const backend = new SIDBackendAdapter(null, null, null); ScriptNodePlayer.initialize(backend, function () {}, [], true, undefined) .then(() => { window._sidPlayerReady = true; window._sidPlayerLoading = null; resolve(); }) .catch(reject); }); }); return window._sidPlayerLoading; } function drawSidVisualizer(canvas, state) { const numBars = 48; if (!state.levels) state.levels = new Float32Array(numBars); let data = null; try { const player = ScriptNodePlayer.getInstance(); data = state.playing && player ? player.getFreqByteData() : null; } catch (e) {} const binCount = data ? Math.floor(data.length / 3) : 0; for (let i = 0; i < numBars; i++) { if (data) { const binStart = Math.floor(i * binCount / numBars); const binEnd = Math.max(binStart + 1, Math.floor((i + 1) * binCount / numBars)); let sum = 0; for (let b = binStart; b < binEnd; b++) sum += data[b]; const target = (sum / (binEnd - binStart)) / 255; state.levels[i] = state.levels[i] * 0.7 + target * 0.3; } else { state.levels[i] *= 0.92; } } const ctx = canvas.getContext('2d'); const width = canvas.width; const height = canvas.height; ctx.clearRect(0, 0, width, height); const slotWidth = width / numBars; const barWidth = slotWidth - 1; for (let i = 0; i < numBars; i++) { const barHeight = Math.round(state.levels[i] * height); if (barHeight < 1) continue; const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight); gradient.addColorStop(0, '#00cfcf'); gradient.addColorStop(1, '#00ff41'); ctx.fillStyle = gradient; ctx.fillRect(i * slotWidth, height - barHeight, barWidth, barHeight); } } function startSidVisualizer(canvas, state) { stopSidVisualizer(state); canvas.width = canvas.offsetWidth || 320; const loop = () => { drawSidVisualizer(canvas, state); state.frameId = requestAnimationFrame(loop); }; state.frameId = requestAnimationFrame(loop); } function stopSidVisualizer(state) { if (state.frameId) { cancelAnimationFrame(state.frameId); state.frameId = null; } state.playing = false; } async function renderSid(container, url, label) { setLoading(container, label, 'fa-microchip'); const playableUrl = getPlayableUrl(url); if (window._sidPlayerReady) { try { ScriptNodePlayer.getInstance().pause(); } catch (e) {} } const response = await fetch(playableUrl, { credentials: 'same-origin' }); if (!response.ok) throw new Error('HTTP ' + response.status); const meta = parseSidHeader(new Uint8Array(await response.arrayBuffer())); await ensureSidPlayerReady(); let currentTrack = meta.startSong - 1; let playing = false; const visualizerState = { playing: false, frameId: null, levels: null }; const title = meta.title || label; const subtitle = [meta.author, meta.released].filter(Boolean).join(' - '); const trackOptions = meta.numSongs > 1 ? `<div class="d-flex align-items-center gap-1"> <label class="text-muted small mb-0">Track:</label> <select class="form-select form-select-sm" data-retro-track style="width:auto;"> ${Array.from({ length: meta.numSongs }, (_, i) => `<option value="${i}" ${i === currentTrack ? 'selected' : ''}>${i + 1}</option>`).join('')} </select> </div>` : ''; const visualizer = '<canvas data-retro-sid-visualizer height="40" style="width:100%;max-width:320px;display:block;margin:0;border-radius:4px;background:#111;"></canvas>'; renderControls(container, { icon: 'fa-microchip', title: title, subtitle: subtitle, extra: trackOptions, visualizer: visualizer, volume: 78 }); const loadTrack = (track) => ScriptNodePlayer.loadMusicFromURL(playableUrl, { track: track, timeout: -1 }, () => {}, () => {}); const setPlaying = (value) => { playing = value; visualizerState.playing = value; setPlayButton(container, playing); }; await loadTrack(currentTrack); try { ScriptNodePlayer.getInstance().pause(); } catch (e) {} setPlaying(false); const visualizerCanvas = container.querySelector('[data-retro-sid-visualizer]'); if (visualizerCanvas) startSidVisualizer(visualizerCanvas, visualizerState); container.querySelector('[data-retro-play]').addEventListener('click', () => { const p = ScriptNodePlayer.getInstance(); if (!p) return; if (playing) { p.pause(); setPlaying(false); } else { p.resume(); setPlaying(true); } }); container.querySelector('[data-retro-stop]').addEventListener('click', () => { try { ScriptNodePlayer.getInstance().pause(); } catch (e) {} setPlaying(false); }); container.querySelector('[data-retro-volume]').addEventListener('input', (e) => { try { ScriptNodePlayer.getInstance().setVolume(parseInt(e.target.value, 10) / 100); } catch (err) {} }); const trackSelect = container.querySelector('[data-retro-track]'); if (trackSelect) { trackSelect.addEventListener('change', async () => { currentTrack = parseInt(trackSelect.value, 10); await loadTrack(currentTrack); try { ScriptNodePlayer.getInstance().pause(); } catch (e) {} setPlaying(false); }); } registerPlayer(container, { stop: () => { stopSidVisualizer(visualizerState); try { ScriptNodePlayer.getInstance().pause(); } catch (e) {} }, }); } function readString(bytes, offset, length) { return String.fromCharCode(...bytes.slice(offset, offset + length)); } function readU16(bytes, offset) { return (bytes[offset] << 8) | bytes[offset + 1]; } function readU32(bytes, offset) { return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]; } function readVarLen(bytes, state) { let value = 0; let b; do { b = bytes[state.pos++]; value = (value << 7) | (b & 0x7f); } while (b & 0x80); return value; } function parseMidi(buffer) { const bytes = new Uint8Array(buffer); if (readString(bytes, 0, 4) !== 'MThd') throw new Error('Invalid MIDI header'); const headerLen = readU32(bytes, 4); const trackCount = readU16(bytes, 10); const division = readU16(bytes, 12); if (division & 0x8000) throw new Error('SMPTE MIDI timing is not supported'); let pos = 8 + headerLen; const events = []; for (let t = 0; t < trackCount; t++) { if (readString(bytes, pos, 4) !== 'MTrk') break; const len = readU32(bytes, pos + 4); const end = pos + 8 + len; const state = { pos: pos + 8 }; let tick = 0; let running = 0; while (state.pos < end) { tick += readVarLen(bytes, state); let status = bytes[state.pos++]; if (status < 0x80) { state.pos--; status = running; } else if (status < 0xf0) { running = status; } if (status === 0xff) { const type = bytes[state.pos++]; const length = readVarLen(bytes, state); if (type === 0x51 && length === 3) { events.push({ tick, type: 'tempo', tempo: (bytes[state.pos] << 16) | (bytes[state.pos + 1] << 8) | bytes[state.pos + 2] }); } state.pos += length; } else if (status === 0xf0 || status === 0xf7) { state.pos += readVarLen(bytes, state); } else { const command = status & 0xf0; const channel = status & 0x0f; const a = bytes[state.pos++]; const needsTwo = command !== 0xc0 && command !== 0xd0; const b = needsTwo ? bytes[state.pos++] : 0; if (command === 0x90 && b > 0) events.push({ tick, type: 'on', channel, note: a, velocity: b }); if (command === 0x80 || (command === 0x90 && b === 0)) events.push({ tick, type: 'off', channel, note: a }); } } pos = end; } events.sort((a, b) => a.tick - b.tick || (a.type === 'tempo' ? -1 : 1)); let tempo = 500000; let lastTick = 0; let seconds = 0; const active = new Map(); const notes = []; for (const event of events) { seconds += ((event.tick - lastTick) * tempo) / (division * 1000000); lastTick = event.tick; if (event.type === 'tempo') { tempo = event.tempo; } else if (event.type === 'on') { const key = event.channel + ':' + event.note; if (!active.has(key)) active.set(key, []); active.get(key).push({ start: seconds, velocity: event.velocity, note: event.note, channel: event.channel }); } else if (event.type === 'off') { const key = event.channel + ':' + event.note; const stack = active.get(key); const note = stack && stack.shift(); if (note) { note.duration = Math.max(0.05, seconds - note.start); notes.push(note); } } } return { notes, duration: seconds }; } function noteFrequency(note) { return 440 * Math.pow(2, (note - 69) / 12); } async function renderMidi(container, url, label) { setLoading(container, label, 'fa-music'); const playableUrl = getPlayableUrl(url); const response = await fetch(playableUrl, { credentials: 'same-origin' }); if (!response.ok) throw new Error('HTTP ' + response.status); const parsed = parseMidi(await response.arrayBuffer()); let context = null; let gain = null; let playing = false; function stopContext() { if (context) { context.close(); context = null; gain = null; } playing = false; setPlayButton(container, false); } function start() { stopContext(); context = new AudioContext(); gain = context.createGain(); gain.gain.value = parseInt(container.querySelector('[data-retro-volume]').value, 10) / 100; gain.connect(context.destination); const startAt = context.currentTime + 0.05; parsed.notes.slice(0, 4000).forEach((note) => { if (note.channel === 9) return; const osc = context.createOscillator(); const env = context.createGain(); const t0 = startAt + note.start; const t1 = t0 + Math.min(note.duration, 12); osc.type = 'sine'; osc.frequency.value = noteFrequency(note.note); env.gain.setValueAtTime(0, t0); env.gain.linearRampToValueAtTime((note.velocity / 127) * 0.16, t0 + 0.01); env.gain.setValueAtTime((note.velocity / 127) * 0.14, Math.max(t0 + 0.02, t1 - 0.04)); env.gain.linearRampToValueAtTime(0, t1); osc.connect(env).connect(gain); osc.start(t0); osc.stop(t1 + 0.02); }); playing = true; setPlayButton(container, true); setTimeout(() => { if (context && context.state !== 'closed') stopContext(); }, Math.max(1000, (parsed.duration + 0.5) * 1000)); } renderControls(container, { icon: 'fa-music', title: label, subtitle: parsed.notes.length ? `${parsed.notes.length} MIDI notes` : 'MIDI sequence', volume: 50, }); container.querySelector('[data-retro-play]').addEventListener('click', () => { if (!context) { start(); } else if (playing) { context.suspend(); playing = false; setPlayButton(container, false); } else { context.resume(); playing = true; setPlayButton(container, true); } }); container.querySelector('[data-retro-stop]').addEventListener('click', stopContext); container.querySelector('[data-retro-volume]').addEventListener('input', (e) => { if (gain) gain.gain.value = parseInt(e.target.value, 10) / 100; }); registerPlayer(container, { stop: stopContext }); } export async function renderRetroAudioPlayer(container, options) { const url = (options && options.url) || container.dataset.retroAudioUrl || ''; const label = (options && options.label) || container.dataset.retroAudioLabel || decodeURIComponent(url.split('/').pop() || 'Audio file'); const kind = (options && options.kind) || getKind(url, label); try { if (kind === 'tracker') { await renderTracker(container, url, label); } else if (kind === 'sid') { await renderSid(container, url, label); } else if (kind === 'midi') { await renderMidi(container, url, label); } else { renderError(container, 'Unsupported audio format'); } } catch (e) { console.error('Retro audio player failed:', e); renderError(container, 'Failed to load audio file'); } } window.BinkRetroAudio = { isRetroAudioUrl, render: renderRetroAudioPlayer, stop: stopRetroAudioPlayer, stopWithin: stopRetroAudioWithin, };