Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>StemForge | Advanced Analysis + MIDI</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; background-color: #09090b; color: #e4e4e7; } | |
| .scrollbar-hide::-webkit-scrollbar { display: none; } | |
| .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; } | |
| canvas { filter: drop-shadow(0 0 4px rgba(217, 70, 239, 0.3)); } | |
| </style> | |
| </head> | |
| <body class="min-h-screen flex flex-col items-center p-4 md:p-8 selection:bg-fuchsia-900 selection:text-white"> | |
| <div class="w-full max-w-6xl flex flex-col gap-6"> | |
| <header class="flex flex-col md:flex-row items-start md:items-center justify-between border-b border-zinc-800 pb-4"> | |
| <div> | |
| <h1 class="text-3xl font-black tracking-tighter uppercase text-white">Stem<span class="text-zinc-600">Forge</span></h1> | |
| <p class="text-xs font-mono text-zinc-500 mt-1">ENGINE: HF GRADIO QUEUE | 6-STEM + MIDI PIPELINE</p> | |
| </div> | |
| <div class="mt-4 md:mt-0 px-3 py-1 bg-zinc-900 border border-zinc-800 rounded-sm"> | |
| <span class="text-[10px] font-mono text-fuchsia-500 tracking-widest uppercase">Build While Bleeding</span> | |
| </div> | |
| </header> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| <div class="lg:col-span-1 flex flex-col gap-4"> | |
| <div class="bg-black border border-zinc-800 p-4 rounded-sm flex flex-col gap-4 relative overflow-hidden group hover:border-fuchsia-900 transition-colors duration-300"> | |
| <div id="progress-bar-container" class="absolute top-0 left-0 w-full h-1 bg-zinc-900 hidden"> | |
| <div id="progress-bar" class="h-full bg-fuchsia-600 w-0 transition-all duration-300 ease-out"></div> | |
| </div> | |
| <label class="flex flex-col items-center justify-center h-32 border-2 border-dashed border-zinc-700 bg-zinc-900/50 cursor-pointer hover:bg-zinc-800 transition-colors"> | |
| <input type="file" id="file-input" accept="audio/*" class="hidden" /> | |
| <svg class="w-8 h-8 text-zinc-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg> | |
| <span id="file-name-display" class="text-xs font-mono text-zinc-400 text-center px-2 truncate w-full">SELECT TARGET AUDIO</span> | |
| </label> | |
| <button id="process-btn" disabled class="w-full bg-white text-black font-bold uppercase tracking-widest py-3 rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:bg-fuchsia-600 hover:text-white transition-all"> | |
| Execute | |
| </button> | |
| <div id="status-badge" class="hidden absolute top-4 right-4 flex h-3 w-3"> | |
| <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-fuchsia-400 opacity-75"></span> | |
| <span class="relative inline-flex rounded-full h-3 w-3 bg-fuchsia-500"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="lg:col-span-2 bg-black border border-zinc-800 rounded-sm flex flex-col overflow-hidden h-64"> | |
| <div class="bg-zinc-900 border-b border-zinc-800 px-3 py-1.5 flex items-center justify-between"> | |
| <span class="text-[10px] font-mono text-zinc-500 uppercase tracking-widest">>> Telemetry Stream</span> | |
| <button id="clear-log-btn" class="text-[10px] text-zinc-600 hover:text-fuchsia-400 uppercase font-mono">Clear</button> | |
| </div> | |
| <div id="terminal-output" class="p-3 font-mono text-[11px] overflow-y-auto flex-1 space-y-1 scrollbar-hide text-zinc-300"> | |
| <div class="text-zinc-600">System Ready. Awaiting source injection...</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="global-transport" class="hidden bg-black border border-zinc-800 p-3 rounded-sm flex items-center gap-4 sticky top-2 z-50"> | |
| <button id="master-play-btn" class="bg-white text-black w-10 h-10 flex items-center justify-center hover:bg-fuchsia-600 hover:text-white transition-colors rounded-sm"> | |
| <svg id="play-icon" class="w-5 h-5 ml-1" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg> | |
| <svg id="pause-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg> | |
| </button> | |
| <div class="flex-1 flex items-center gap-3"> | |
| <span id="time-current" class="text-[10px] font-mono text-zinc-500 w-8">0:00</span> | |
| <input type="range" id="master-scrubber" min="0" max="100" value="0" step="0.1" class="w-full h-1 bg-zinc-800 appearance-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-sm cursor-pointer"> | |
| <span id="time-total" class="text-[10px] font-mono text-zinc-500 w-8 text-right">0:00</span> | |
| </div> | |
| </div> | |
| <div id="stems-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-12 empty:hidden"></div> | |
| </div> | |
| <script> | |
| // ENTRY POINT: HF_SPACE_URL | |
| const API_BASE = "https://ryanrealaf-stemforge.hf.space"; | |
| let stems = []; | |
| let isPlaying = false; | |
| let masterDuration = 0; | |
| let audioElements = []; | |
| let animationFrameId; | |
| const terminal = document.getElementById('terminal-output'); | |
| const fileInput = document.getElementById('file-input'); | |
| const processBtn = document.getElementById('process-btn'); | |
| const log = (msg, type = 'info') => { | |
| const time = new Date().toTimeString().split(' ')[0]; | |
| const div = document.createElement('div'); | |
| let colorClass = 'text-zinc-300'; | |
| if(type === 'error') colorClass = 'text-red-500'; | |
| if(type === 'warning') colorClass = 'text-amber-500'; | |
| if(type === 'success') colorClass = 'text-fuchsia-400'; | |
| div.innerHTML = `<span class="text-zinc-600 mr-2">[${time}]</span><span class="${colorClass}">${msg}</span>`; | |
| terminal.appendChild(div); | |
| terminal.scrollTop = terminal.scrollHeight; | |
| }; | |
| const updateProgress = (pct) => { | |
| document.getElementById('progress-bar').style.width = `${pct}%`; | |
| }; | |
| document.getElementById('clear-log-btn').addEventListener('click', () => terminal.innerHTML = ''); | |
| fileInput.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| document.getElementById('file-name-display').innerText = file.name.toUpperCase(); | |
| processBtn.disabled = false; | |
| log(`File staged: ${file.name}`); | |
| } | |
| }); | |
| processBtn.addEventListener('click', async () => { | |
| const file = fileInput.files[0]; | |
| document.getElementById('progress-bar-container').classList.remove('hidden'); | |
| document.getElementById('status-badge').classList.remove('hidden'); | |
| processBtn.disabled = true; | |
| processBtn.innerText = "Extracting..."; | |
| try { | |
| updateProgress(10); | |
| log("Initiating upload sequence..."); | |
| const formData = new FormData(); | |
| formData.append("files", file); | |
| // BWB Fix: Route through /gradio_api for v4 spaces | |
| const apiPrefix = `${API_BASE}/gradio_api`; | |
| const upRes = await fetch(`${apiPrefix}/upload`, { method: "POST", body: formData }); | |
| if (!upRes.ok) throw new Error(`Upload rejected (HTTP ${upRes.status}).`); | |
| const upData = await upRes.json(); | |
| // BWB Fix: Handle strict dictionary schemas in Gradio 4 | |
| let uploadedFile = Array.isArray(upData) ? upData[0] : upData; | |
| let path = typeof uploadedFile === 'string' ? uploadedFile : (uploadedFile.path || uploadedFile.name || uploadedFile.orig_name); | |
| if (!path) { | |
| log("Critical: Could not extract file path. Raw: " + JSON.stringify(uploadedFile), 'error'); | |
| throw new Error("Invalid upload response schema"); | |
| } | |
| updateProgress(30); | |
| log("Upload complete. Temp Path: " + path); | |
| log("Requesting compute node allocation (Queue)..."); | |
| const sessionHash = Math.random().toString(36).substring(2); | |
| // BWB Fix: Pass the exact upload dictionary back to the predictor | |
| const filePayload = typeof uploadedFile === 'object' ? uploadedFile : { path: path, orig_name: file.name }; | |
| const payload = { data: [filePayload], fn_index: 0, session_hash: sessionHash }; | |
| const joinRes = await fetch(`${apiPrefix}/queue/join`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!joinRes.ok) { | |
| log("Queue join failed. Falling back to blocking request...", "warning"); | |
| const res = await fetch(`${apiPrefix}/run/predict`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); | |
| const data = await res.json(); | |
| if (data.detail === "Not Found") throw new Error("API endpoint not found. Verify fn_index in openapi.json"); | |
| processOutputs(data); | |
| return; | |
| } | |
| log("Node allocated. Establishing EventSource stream..."); | |
| updateProgress(40); | |
| const stream = new EventSource(`${apiPrefix}/queue/data?session_hash=${sessionHash}`); | |
| stream.onmessage = (e) => { | |
| if (e.data === "close") { stream.close(); return; } | |
| const msg = JSON.parse(e.data); | |
| switch(msg.msg) { | |
| case 'estimation': | |
| log(`Queue Status: Rank ${msg.rank || 0}. ETA: ${msg.rank_eta ? Math.round(msg.rank_eta) + 's' : 'calculating...'}`, "warning"); | |
| updateProgress(45); | |
| break; | |
| case 'process_starts': | |
| log("Process executing: Neural separation initialized.", "success"); | |
| updateProgress(60); | |
| break; | |
| case 'process_generating': | |
| log("Process generating: Demultiplexing frequencies...", "success"); | |
| updateProgress(80); | |
| break; | |
| case 'process_completed': | |
| stream.close(); | |
| if (msg.success) { | |
| log("Compute finished. Extracting payload...", "success"); | |
| updateProgress(95); | |
| processOutputs(msg.output.data); | |
| } else { | |
| log("API Compute Error: " + (msg.error || "Unknown failure"), "error"); | |
| resetUIState(); | |
| } | |
| break; | |
| } | |
| }; | |
| stream.onerror = () => { | |
| log("Telemetry stream disconnected unexpectedly.", "error"); | |
| stream.close(); | |
| resetUIState(); | |
| }; | |
| function processOutputs(data) { | |
| const audioFound = []; | |
| const midiFound = []; | |
| const search = (obj) => { | |
| if (!obj) return; | |
| if (Array.isArray(obj)) obj.forEach(search); | |
| else if (typeof obj === 'object') { | |
| if (obj.orig_name && obj.path) { | |
| if (obj.orig_name.match(/\.(wav|mp3|flac|ogg)$/i)) { | |
| audioFound.push({ name: obj.orig_name.split('.')[0], url: `${API_BASE}/file=${obj.path}` }); | |
| } else if (obj.orig_name.match(/\.(mid|midi)$/i)) { | |
| midiFound.push({ name: obj.orig_name.split('.')[0], url: `${API_BASE}/file=${obj.path}` }); | |
| } | |
| } else if (obj.name && obj.is_file) { | |
| if (obj.name.match(/\.(wav|mp3|flac|ogg)$/i)) { | |
| audioFound.push({ name: obj.name.split('/').pop().split('.')[0], url: `${API_BASE}/file=${obj.name}` }); | |
| } else if (obj.name.match(/\.(mid|midi)$/i)) { | |
| midiFound.push({ name: obj.name.split('/').pop().split('.')[0], url: `${API_BASE}/file=${obj.name}` }); | |
| } | |
| } else { | |
| Object.values(obj).forEach(search); | |
| } | |
| } | |
| }; | |
| search(data); | |
| updateProgress(100); | |
| // Merge Native MIDI with Audio Stems based on matching names | |
| const mergedStems = audioFound.map(audio => { | |
| const matchingMidi = midiFound.find(m => m.name === audio.name); | |
| return { ...audio, midiUrl: matchingMidi ? matchingMidi.url : null }; | |
| }); | |
| log(`Extraction Complete. Found ${audioFound.length} audio stems, ${midiFound.length} native MIDI files.`, 'success'); | |
| if (mergedStems.length === 0) { | |
| log("Payload analysis: " + JSON.stringify(data).substring(0, 150), "warning"); | |
| } else { | |
| stems = mergedStems; | |
| renderStems(); | |
| } | |
| resetUIState(true); | |
| } | |
| function resetUIState(success = false) { | |
| processBtn.innerText = success ? "Process Another" : "Error - Retry"; | |
| processBtn.disabled = false; | |
| if(success) setTimeout(() => document.getElementById('progress-bar-container').classList.add('hidden'), 1000); | |
| } | |
| } catch(e) { | |
| log(`FATAL: ${e.message}`, 'error'); | |
| processBtn.innerText = "Error - Retry"; | |
| processBtn.disabled = false; | |
| document.getElementById('status-badge').classList.add('hidden'); | |
| } | |
| }); | |
| // Minimal Binary MIDI Generator for client-side extraction fallback | |
| function generateLocalMidi(audioElement, stemName) { | |
| log(`Executing local Audio-to-MIDI analysis for [${stemName}]...`, 'warning'); | |
| const AudioContext = window.AudioContext || window.webkitAudioContext; | |
| const ctx = new AudioContext(); | |
| fetch(audioElement.src) | |
| .then(response => response.arrayBuffer()) | |
| .then(arrayBuffer => ctx.decodeAudioData(arrayBuffer)) | |
| .then(audioBuffer => { | |
| const channelData = audioBuffer.getChannelData(0); | |
| const sampleRate = audioBuffer.sampleRate; | |
| const threshold = 0.3; // Amplitude threshold for onset | |
| const notes = []; | |
| let isNoteOn = false; | |
| // Basic onset detection | |
| for (let i = 0; i < channelData.length; i++) { | |
| if (Math.abs(channelData[i]) > threshold && !isNoteOn) { | |
| notes.push(i / sampleRate); | |
| isNoteOn = true; | |
| } else if (Math.abs(channelData[i]) < 0.05 && isNoteOn) { | |
| isNoteOn = false; // Reset for next transient | |
| } | |
| } | |
| if (notes.length === 0) { | |
| log(`Zero transients detected in [${stemName}]. MIDI export empty.`, 'error'); | |
| return; | |
| } | |
| // Build raw MIDI bytes (SMF Type 0) | |
| const header = [0x4d, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x60]; // 96 ticks per beat | |
| let trackData = []; | |
| let lastTick = 0; | |
| notes.forEach(time => { | |
| // Assuming 120 BPM, 2 beats per sec, 192 ticks per sec | |
| const absoluteTick = Math.floor(time * 192); | |
| let delta = absoluteTick - lastTick; | |
| lastTick = absoluteTick; | |
| // Write Variable Length Quantity (Delta) | |
| const deltaBytes = writeVarLen(delta); | |
| trackData.push(...deltaBytes); | |
| // Note On, Channel 0, Note 60 (C4), Velocity 100 | |
| trackData.push(0x90, 0x3C, 0x64); | |
| // Note Off immediately (percussion style) | |
| trackData.push(0x00); // 0 delta | |
| trackData.push(0x80, 0x3C, 0x00); | |
| }); | |
| // End of Track | |
| trackData.push(0x00, 0xFF, 0x2F, 0x00); | |
| // Length of track chunk | |
| const trkLen = trackData.length; | |
| const trackHeader = [ | |
| 0x4d, 0x54, 0x72, 0x6b, | |
| (trkLen >> 24) & 0xff, (trkLen >> 16) & 0xff, (trkLen >> 8) & 0xff, trkLen & 0xff | |
| ]; | |
| const midiBytes = new Uint8Array([...header, ...trackHeader, ...trackData]); | |
| const blob = new Blob([midiBytes], {type: "audio/midi"}); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = `${stemName}_generated.mid`; | |
| a.click(); | |
| log(`MIDI generated and downloaded for [${stemName}]. Events: ${notes.length}`, 'success'); | |
| }) | |
| .catch(err => log(`MIDI processing failed: ${err.message}`, 'error')); | |
| } | |
| function writeVarLen(val) { | |
| let buffer = [val & 0x7f]; | |
| while ((val >>= 7) > 0) buffer.unshift((val & 0x7f) | 0x80); | |
| return buffer; | |
| } | |
| function renderStems() { | |
| const container = document.getElementById('stems-container'); | |
| container.innerHTML = ''; | |
| audioElements = []; | |
| document.getElementById('global-transport').classList.remove('hidden'); | |
| const AudioContext = window.AudioContext || window.webkitAudioContext; | |
| const audioCtx = new AudioContext(); | |
| stems.forEach((stem, i) => { | |
| const card = document.createElement('div'); | |
| card.className = "bg-black border border-zinc-800 p-4 rounded-sm flex flex-col gap-3 group relative"; | |
| const id = `stem-${i}`; | |
| card.innerHTML = ` | |
| <div class="flex items-center justify-between"> | |
| <span class="text-xs font-bold uppercase tracking-widest text-zinc-300 group-hover:text-fuchsia-500 transition-colors">${stem.name}</span> | |
| <div class="flex gap-2"> | |
| ${stem.midiUrl | |
| ? `<a href="${stem.midiUrl}" download="${stem.name}.mid" class="text-[10px] font-mono bg-fuchsia-900/30 text-fuchsia-400 border border-fuchsia-900/50 px-2 py-0.5 rounded hover:bg-fuchsia-900 transition-colors">NATIVE MIDI</a>` | |
| : `<button class="gen-midi-btn text-[10px] font-mono bg-zinc-900 text-zinc-400 border border-zinc-800 px-2 py-0.5 rounded hover:border-zinc-600 transition-colors" data-id="${id}" data-name="${stem.name}">GEN MIDI</button>` | |
| } | |
| <a href="${stem.url}" download="${stem.name}.wav" class="text-[10px] font-mono bg-zinc-900 text-zinc-300 border border-zinc-800 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors">WAV</a> | |
| </div> | |
| </div> | |
| <div class="relative h-20 w-full bg-zinc-900 rounded-sm overflow-hidden border border-zinc-800"> | |
| <canvas id="canvas-${id}" class="absolute inset-0 w-full h-full"></canvas> | |
| <div class="absolute bottom-1 right-1 text-[9px] font-mono text-zinc-600 bg-black/50 px-1">FFT + DATA</div> | |
| <div id="stats-${id}" class="absolute top-1 left-1 flex flex-col gap-0.5"></div> | |
| </div> | |
| <audio id="audio-${id}" src="${stem.url}" crossorigin="anonymous" preload="auto"></audio> | |
| <div class="flex items-center gap-2"> | |
| <button class="mute-btn text-zinc-500 hover:text-red-500 transition-colors" data-id="${id}"> | |
| <svg class="w-4 h-4 icon-vol" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5 10c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h2l5 5V5L7 10H5z"></path></svg> | |
| <svg class="w-4 h-4 icon-mute hidden text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h2.586l-1.293-1.293a1 1 0 011.414-1.414L15.414 14l-8.121 8.121a1 1 0 01-1.414-1.414L5.586 15z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"></path></svg> | |
| </button> | |
| <input type="range" class="vol-slider w-full h-1 bg-zinc-800 appearance-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:bg-zinc-500 [&::-webkit-slider-thumb]:rounded-sm cursor-pointer" min="0" max="1" step="0.01" value="1" data-id="${id}"> | |
| </div> | |
| `; | |
| container.appendChild(card); | |
| const audioEl = document.getElementById(`audio-${id}`); | |
| const canvasEl = document.getElementById(`canvas-${id}`); | |
| audioElements.push(audioEl); | |
| if (i === 0) { | |
| audioEl.addEventListener('loadedmetadata', () => { | |
| masterDuration = audioEl.duration; | |
| document.getElementById('master-scrubber').max = masterDuration; | |
| document.getElementById('time-total').innerText = formatTime(masterDuration); | |
| }); | |
| } | |
| initVisualizer(audioCtx, audioEl, canvasEl, document.getElementById(`stats-${id}`)); | |
| card.querySelector('.mute-btn').addEventListener('click', (e) => { | |
| const btn = e.currentTarget; | |
| audioEl.muted = !audioEl.muted; | |
| btn.querySelector('.icon-vol').classList.toggle('hidden'); | |
| btn.querySelector('.icon-mute').classList.toggle('hidden'); | |
| }); | |
| card.querySelector('.vol-slider').addEventListener('input', (e) => audioEl.volume = e.target.value); | |
| const genMidiBtn = card.querySelector('.gen-midi-btn'); | |
| if(genMidiBtn) { | |
| genMidiBtn.addEventListener('click', (e) => generateLocalMidi(audioEl, e.target.dataset.name)); | |
| } | |
| }); | |
| } | |
| document.getElementById('master-play-btn').addEventListener('click', () => { | |
| isPlaying = !isPlaying; | |
| document.getElementById('play-icon').classList.toggle('hidden'); | |
| document.getElementById('pause-icon').classList.toggle('hidden'); | |
| if (isPlaying) { | |
| audioElements.forEach(a => a.play().catch(e => log(`Playback blocked: ${e.message}`, 'error'))); | |
| syncScrubber(); | |
| } else { | |
| audioElements.forEach(a => a.pause()); | |
| cancelAnimationFrame(animationFrameId); | |
| } | |
| }); | |
| document.getElementById('master-scrubber').addEventListener('input', (e) => { | |
| const time = parseFloat(e.target.value); | |
| audioElements.forEach(a => a.currentTime = time); | |
| document.getElementById('time-current').innerText = formatTime(time); | |
| }); | |
| function syncScrubber() { | |
| if (!isPlaying || !audioElements[0]) return; | |
| const current = audioElements[0].currentTime; | |
| document.getElementById('master-scrubber').value = current; | |
| document.getElementById('time-current').innerText = formatTime(current); | |
| animationFrameId = requestAnimationFrame(syncScrubber); | |
| } | |
| function formatTime(sec) { | |
| if (isNaN(sec)) return "0:00"; | |
| const m = Math.floor(sec / 60); | |
| const s = Math.floor(sec % 60).toString().padStart(2, '0'); | |
| return `${m}:${s}`; | |
| } | |
| function initVisualizer(ctx, audio, canvas, statsEl) { | |
| const canvasCtx = canvas.getContext("2d"); | |
| let source = null; | |
| let analyser = null; | |
| let dataArray = null; | |
| // Generate initial stats | |
| fetch(audio.src, {method: 'HEAD'}).then(res => { | |
| const size = (res.headers.get('content-length') / 1024 / 1024).toFixed(2); | |
| statsEl.innerHTML = `<span class="text-[8px] font-mono text-zinc-500 bg-black/80 px-1 w-max">SIZE: ${size} MB</span>`; | |
| }).catch(() => {}); | |
| audio.addEventListener('play', () => { | |
| if(ctx.state === 'suspended') ctx.resume(); | |
| if(!source) { | |
| source = ctx.createMediaElementSource(audio); | |
| analyser = ctx.createAnalyser(); | |
| analyser.fftSize = 256; | |
| source.connect(analyser); | |
| analyser.connect(ctx.destination); | |
| dataArray = new Uint8Array(analyser.frequencyBinCount); | |
| } | |
| draw(); | |
| }); | |
| function draw() { | |
| if (audio.paused) return; | |
| requestAnimationFrame(draw); | |
| const width = canvas.width = canvas.clientWidth; | |
| const height = canvas.height = canvas.clientHeight; | |
| analyser.getByteFrequencyData(dataArray); | |
| canvasCtx.clearRect(0, 0, width, height); | |
| const barWidth = (width / analyser.frequencyBinCount) * 2.5; | |
| let x = 0; | |
| let peak = 0; | |
| for(let i = 0; i < analyser.frequencyBinCount; i++) { | |
| if (dataArray[i] > peak) peak = dataArray[i]; | |
| const barHeight = (dataArray[i] / 255) * height; | |
| canvasCtx.fillStyle = `rgb(${dataArray[i]}, 70, 239)`; | |
| canvasCtx.fillRect(x, height - barHeight, barWidth, barHeight); | |
| x += barWidth + 1; | |
| } | |
| // Live Peak UI | |
| if(statsEl.children.length > 1) statsEl.children[1].remove(); | |
| statsEl.innerHTML += `<span class="text-[8px] font-mono ${peak > 250 ? 'text-red-500' : 'text-zinc-400'} bg-black/80 px-1 w-max">PEAK: ${(peak/255).toFixed(2)}</span>`; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> |