Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ASCII Audio Visualizer</title> | |
| <style> | |
| /* | |
| * MODERN CSS RESET & VARIABLES | |
| */ | |
| :root { | |
| --bg-color: #050505; | |
| --text-color: #0f0; /* Default Matrix Green */ | |
| --secondary-color: #003300; | |
| --ui-bg: rgba(0, 0, 0, 0.85); | |
| --ui-border: 1px solid #333; | |
| --font-stack: 'Courier New', Courier, monospace; | |
| --scanline-color: rgba(0, 0, 0, 0.5); | |
| --glass-blur: blur(10px); | |
| /* Theme Colors */ | |
| --theme-green: #00ff41; | |
| --theme-amber: #ffb000; | |
| --theme-cyan: #00ffff; | |
| --theme-pink: #ff00de; | |
| --theme-white: #ffffff; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| } | |
| body { | |
| background-color: var(--bg-color); | |
| color: var(--text-color); | |
| font-family: var(--font-stack); | |
| height: 100vh; | |
| width: 100vw; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| transition: color 0.3s ease; | |
| } | |
| /* | |
| * HEADER & LINK | |
| */ | |
| header { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| padding: 1rem; | |
| z-index: 10; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| pointer-events: none; /* Let clicks pass through to canvas where possible */ | |
| } | |
| .brand { | |
| font-size: 1.2rem; | |
| font-weight: bold; | |
| text-shadow: 0 0 5px currentColor; | |
| pointer-events: auto; | |
| } | |
| .anycoder-link { | |
| font-size: 0.8rem; | |
| color: var(--text-color); | |
| text-decoration: none; | |
| opacity: 0.7; | |
| pointer-events: auto; | |
| border-bottom: 1px dashed currentColor; | |
| transition: opacity 0.2s; | |
| } | |
| .anycoder-link:hover { | |
| opacity: 1; | |
| text-shadow: 0 0 8px currentColor; | |
| } | |
| /* | |
| * MAIN VISUALIZER AREA | |
| */ | |
| main { | |
| flex: 1; | |
| position: relative; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* The Canvas where ASCII is drawn */ | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* | |
| * CRT EFFECT OVERLAY | |
| */ | |
| .crt-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| background: linear-gradient( | |
| rgba(18, 16, 16, 0) 50%, | |
| rgba(0, 0, 0, 0.25) 50% | |
| ), linear-gradient( | |
| 90deg, | |
| rgba(255, 0, 0, 0.06), | |
| rgba(0, 255, 0, 0.02), | |
| rgba(0, 0, 255, 0.06) | |
| ); | |
| background-size: 100% 2px, 3px 100%; | |
| z-index: 5; | |
| animation: flicker 0.15s infinite; | |
| } | |
| @keyframes flicker { | |
| 0% { opacity: 0.97; } | |
| 50% { opacity: 1; } | |
| 100% { opacity: 0.98; } | |
| } | |
| /* | |
| * UI CONTROLS | |
| */ | |
| .controls-container { | |
| position: absolute; | |
| bottom: 2rem; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 90%; | |
| max-width: 800px; | |
| background: var(--ui-bg); | |
| border: var(--ui-border); | |
| border-radius: 12px; | |
| padding: 1rem; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 1rem; | |
| justify-content: center; | |
| align-items: center; | |
| backdrop-filter: var(--glass-blur); | |
| z-index: 20; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.5); | |
| transition: opacity 0.3s, transform 0.3s; | |
| } | |
| /* Hide controls when idle (optional interaction) */ | |
| .controls-container.idle { | |
| opacity: 0.3; | |
| } | |
| .controls-container:hover { | |
| opacity: 1; | |
| } | |
| .btn { | |
| background: transparent; | |
| border: 1px solid var(--text-color); | |
| color: var(--text-color); | |
| padding: 0.5rem 1rem; | |
| font-family: inherit; | |
| cursor: pointer; | |
| border-radius: 4px; | |
| text-transform: uppercase; | |
| font-size: 0.8rem; | |
| font-weight: bold; | |
| transition: all 0.2s; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .btn:hover { | |
| background: var(--text-color); | |
| color: var(--bg-color); | |
| box-shadow: 0 0 10px currentColor; | |
| } | |
| .btn.active { | |
| background: var(--text-color); | |
| color: var(--bg-color); | |
| } | |
| .file-input-wrapper { | |
| position: relative; | |
| overflow: hidden; | |
| display: inline-block; | |
| } | |
| .file-input-wrapper input[type=file] { | |
| position: absolute; | |
| left: 0; | |
| top: 0; | |
| opacity: 0; | |
| width: 100%; | |
| height: 100%; | |
| cursor: pointer; | |
| } | |
| select { | |
| background: var(--bg-color); | |
| color: var(--text-color); | |
| border: 1px solid var(--text-color); | |
| padding: 0.5rem; | |
| font-family: inherit; | |
| border-radius: 4px; | |
| outline: none; | |
| } | |
| /* Color Pickers */ | |
| .theme-dots { | |
| display: flex; | |
| gap: 0.5rem; | |
| } | |
| .theme-dot { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| border: 2px solid transparent; | |
| transition: transform 0.2s; | |
| } | |
| .theme-dot:hover { transform: scale(1.2); } | |
| .theme-dot.selected { border-color: #fff; } | |
| /* Drag Overlay */ | |
| .drag-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0,0,0,0.8); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 50; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: opacity 0.3s; | |
| } | |
| .drag-overlay.active { | |
| opacity: 1; | |
| pointer-events: all; | |
| } | |
| .drag-message { | |
| border: 2px dashed var(--text-color); | |
| padding: 2rem; | |
| font-size: 1.5rem; | |
| color: var(--text-color); | |
| } | |
| /* Toast Notification */ | |
| .toast { | |
| position: absolute; | |
| top: 5rem; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--text-color); | |
| color: var(--bg-color); | |
| padding: 0.5rem 1rem; | |
| border-radius: 4px; | |
| font-weight: bold; | |
| opacity: 0; | |
| transition: opacity 0.5s; | |
| z-index: 30; | |
| pointer-events: none; | |
| } | |
| .toast.show { opacity: 1; } | |
| /* Responsive */ | |
| @media (max-width: 600px) { | |
| .controls-container { | |
| bottom: 0; | |
| width: 100%; | |
| border-radius: 12px 12px 0 0; | |
| padding-bottom: 1.5rem; | |
| } | |
| .btn span { display: none; } /* Hide text on small screens, show icons only */ | |
| .btn span.visible { display: inline; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Header --> | |
| <header> | |
| <div class="brand">ASCII_VISUALIZER_V1</div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="anycoder-link"> | |
| Built with anycoder | |
| </a> | |
| </header> | |
| <!-- Notification Area --> | |
| <div id="toast" class="toast">Notification</div> | |
| <!-- Drag & Drop Zone --> | |
| <div id="dragOverlay" class="drag-overlay"> | |
| <div class="drag-message">DROP AUDIO FILE HERE</div> | |
| </div> | |
| <!-- Main Canvas --> | |
| <main> | |
| <canvas id="asciiCanvas"></canvas> | |
| <div class="crt-overlay"></div> | |
| </main> | |
| <!-- Controls --> | |
| <div class="controls-container" id="controls"> | |
| <!-- Playback --> | |
| <button id="btnPlayPause" class="btn"> | |
| <span id="iconPlay">▶</span> <span class="visible">Play</span> | |
| </button> | |
| <!-- File Input --> | |
| <div class="file-input-wrapper btn"> | |
| <span>📂</span> <span class="visible">Load File</span> | |
| <input type="file" id="audioInput" accept="audio/*"> | |
| </div> | |
| <!-- Mic Input --> | |
| <button id="btnMic" class="btn"> | |
| <span>🎤</span> <span class="visible">Mic</span> | |
| </button> | |
| <!-- Visualization Mode --> | |
| <select id="modeSelect" class="btn"> | |
| <option value="spectrum">Spectrum</option> | |
| <option value="wave">Waveform</option> | |
| <option value="circle">Radial</option> | |
| <option value="matrix">Matrix Rain</option> | |
| </select> | |
| <!-- Theme Colors --> | |
| <div class="theme-dots"> | |
| <div class="theme-dot selected" style="background: var(--theme-green);" data-color="var(--theme-green)"></div> | |
| <div class="theme-dot" style="background: var(--theme-amber);" data-color="var(--theme-amber)"></div> | |
| <div class="theme-dot" style="background: var(--theme-cyan);" data-color="var(--theme-cyan)"></div> | |
| <div class="theme-dot" style="background: var(--theme-pink);" data-color="var(--theme-pink)"></div> | |
| <div class="theme-dot" style="background: var(--theme-white);" data-color="var(--theme-white)"></div> | |
| </div> | |
| </div> | |
| <!-- Hidden Audio Element --> | |
| <audio id="audioElement" crossorigin="anonymous"></audio> | |
| <script> | |
| /** | |
| * ASCII MUSIC VISUALIZER | |
| * Core Logic: Web Audio API + Canvas API | |
| */ | |
| // --- Configuration & State --- | |
| const config = { | |
| fftSize: 2048, // Resolution of audio analysis | |
| smoothing: 0.8, | |
| fontSize: 14, | |
| fontFamily: '"Courier New", monospace', | |
| chars: " .:-=+*#%@".split(""), // Density map from low to high energy | |
| matrixChars: "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ".split("") | |
| }; | |
| const state = { | |
| isPlaying: false, | |
| sourceType: null, // 'file' or 'mic' | |
| mode: 'spectrum', | |
| audioCtx: null, | |
| analyser: null, | |
| source: null, | |
| dataArray: null, | |
| animationId: null, | |
| width: 0, | |
| height: 0, | |
| cols: 0, | |
| rows: 0 | |
| }; | |
| // --- DOM Elements --- | |
| const canvas = document.getElementById('asciiCanvas'); | |
| const ctx = canvas.getContext('2d', { alpha: false }); // Optimize for no transparency | |
| const audioElement = document.getElementById('audioElement'); | |
| const btnPlayPause = document.getElementById('btnPlayPause'); | |
| const iconPlay = document.getElementById('iconPlay'); | |
| const audioInput = document.getElementById('audioInput'); | |
| const btnMic = document.getElementById('btnMic'); | |
| const modeSelect = document.getElementById('modeSelect'); | |
| const dragOverlay = document.getElementById('dragOverlay'); | |
| const toastEl = document.getElementById('toast'); | |
| const themeDots = document.querySelectorAll('.theme-dot'); | |
| // --- Audio System Initialization --- | |
| function initAudioContext() { | |
| if (!state.audioCtx) { | |
| const AudioContext = window.AudioContext || window.webkitAudioContext; | |
| state.audioCtx = new AudioContext(); | |
| } | |
| if (state.audioCtx.state === 'suspended') { | |
| state.audioCtx.resume(); | |
| } | |
| } | |
| function setupAnalyser() { | |
| state.analyser = state.audioCtx.createAnalyser(); | |
| state.analyser.fftSize = config.fftSize; | |
| state.analyser.smoothingTimeConstant = config.smoothing; | |
| const bufferLength = state.analyser.frequencyBinCount; | |
| state.dataArray = new Uint8Array(bufferLength); | |
| } | |
| function loadAudioFile(file) { | |
| initAudioContext(); | |
| // Cleanup previous source | |
| if (state.source) { | |
| state.source.disconnect(); | |
| } | |
| if (state.sourceType === 'mic') { | |
| // If switching from Mic, we handle logic differently | |
| stopMic(); | |
| } | |
| const objectUrl = URL.createObjectURL(file); | |
| audioElement.src = objectUrl; | |
| setupAnalyser(); | |
| state.source = state.audioCtx.createMediaElementSource(audioElement); | |
| state.source.connect(state.analyser); | |
| state.analyser.connect(state.audioCtx.destination); | |
| state.sourceType = 'file'; | |
| audioElement.play() | |
| .then(() => { | |
| state.isPlaying = true; | |
| updatePlayButton(); | |
| startVisualization(); | |
| showToast(`Playing: ${file.name}`); | |
| }) | |
| .catch(err => { | |
| console.error("Playback error:", err); | |
| showToast("Error playing file"); | |
| }); | |
| } | |
| async function enableMicrophone() { | |
| initAudioContext(); | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); | |
| // Stop file playback if active | |
| audioElement.pause(); | |
| if (state.source) state.source.disconnect(); | |
| setupAnalyser(); | |
| state.source = state.audioCtx.createMediaStreamSource(stream); | |
| state.source.connect(state.analyser); | |
| // Do NOT connect mic to destination (speakers) to avoid feedback loop | |
| state.sourceType = 'mic'; | |
| state.isPlaying = true; | |
| updatePlayButton(); | |
| startVisualization(); | |
| showToast("Microphone Active"); | |
| } catch (err) { | |
| console.error("Mic access denied:", err); | |
| showToast("Microphone access denied"); | |
| } | |
| } | |
| function stopMic() { | |
| if (state.sourceType === 'mic' && state.source) { | |
| state.source.mediaStream.getTracks().forEach(track => track.stop()); | |
| state.source.disconnect(); | |
| state.source = null; | |
| } | |
| } | |
| // --- Visualization Engine --- | |
| function resize() { | |
| state.width = window.innerWidth; | |
| state.height = window.innerHeight; | |
| canvas.width = state.width; | |
| canvas.height = state.height; | |
| // Calculate grid dimensions | |
| ctx.font = `${config.fontSize}px ${config.fontFamily}`; | |
| const charWidth = ctx.measureText("M").width; | |
| state.cols = Math.floor(state.width / charWidth); | |
| state.rows = Math.floor(state.height / config.fontSize); | |
| } | |
| function getChar(value, max) { | |
| // Map value (0-255) to character index | |
| const index = Math.floor((value / 255) * (config.chars.length - 1)); | |
| return config.chars[index]; | |
| } | |
| function drawSpectrum() { | |
| state.analyser.getByteFrequencyData(state.dataArray); | |
| // Clear screen with trail effect | |
| ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--bg-color'); | |
| ctx.fillRect(0, 0, state.width, state.height); | |
| ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-color'); | |
| ctx.textBaseline = 'middle'; | |
| const barWidth = state.cols / (state.dataArray.length / 4); // Use lower freqs mostly | |
| const centerY = state.height / 2; | |
| for (let i = 0; i < state.cols; i++) { | |
| // Map column to data index | |
| const dataIndex = Math.floor(i * (state.dataArray.length / 4) / state.cols); | |
| const value = state.dataArray[dataIndex]; | |
| // Calculate height of bar | |
| const barHeight = (value / 255) * (state.rows / 2); | |
| // Draw mirrored bars | |
| const char = getChar(value, 255); | |
| const charWidth = ctx.measureText(char).width; | |
| const x = i * (state.width / state.cols); | |
| // Draw Top Half | |
| for (let j = 0; j < barHeight; j++) { | |
| const y = centerY - (j * config.fontSize); | |
| ctx.fillText(char, x, y); | |
| } | |
| // Draw Bottom Half | |
| for (let j = 0; j < barHeight; j++) { | |
| const y = centerY + (j * config.fontSize); | |
| ctx.fillText(char, x, y); | |
| } | |
| } | |
| } | |
| function drawWaveform() { | |
| state.analyser.getByteTimeDomainData(state.dataArray); | |
| ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--bg-color'); | |
| ctx.fillRect(0, 0, state.width, state.height); | |
| ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-color'); | |
| ctx.textBaseline = 'middle'; | |
| const sliceWidth = state.width / state.dataArray.length; | |
| let x = 0; | |
| for(let i = 0; i < state.dataArray.length; i++) { | |
| const v = state.dataArray[i] / 128.0; // 0..2 | |
| const y = (v * state.height) / 2; | |
| const char = getChar(Math.abs(state.dataArray[i] - 128) * 2, 255); | |
| // Optimization: only draw if there is significant change or every few pixels | |
| // To make it look like a line, we draw vertical strips of characters | |
| if (i % 2 === 0) { // Downsample slightly | |
| ctx.fillText(char, x, y); | |
| } | |
| x += sliceWidth; | |
| } | |
| } | |
| function drawRadial() { | |
| state.analyser.getByteFrequencyData(state.dataArray); | |
| // Fade out effect | |
| ctx.fillStyle = 'rgba(5, 5, 5, 0.2)'; | |
| ctx.fillRect(0, 0, state.width, state.height); | |
| ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-color'); | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| const cx = state.width / 2; | |
| const cy = state.height / 2; | |
| const radius = Math.min(state.width, state.height) / 4; | |
| const maxBars = 180; // Limit number of bars for clarity | |
| const step = Math.floor(state.dataArray.length / maxBars); | |
| for (let i = 0; i < maxBars; i++) { | |
| const value = state.dataArray[i * step]; | |
| const barHeight = (value / 255) * radius; | |
| const angle = (i / maxBars) * Math.PI * 2; | |
| const char = getChar(value, 255); | |
| // Calculate position | |
| const x1 = cx + Math.cos(angle) * radius; | |
| const y1 = cy + Math.sin(angle) * radius; | |
| const x2 = cx + Math.cos(angle) * (radius + barHeight); | |
| const y2 = cy + Math.sin(angle) * (radius + barHeight); | |
| // Draw character at the tip | |
| ctx.fillText(char, x2, y2); | |
| // Optional: Draw connecting line or base | |
| if (value > 100) { | |
| ctx.fillRect(x1, y1, 2, 2); | |
| } | |
| } | |
| } | |
| // Matrix Rain Effect (Custom Logic) | |
| // We maintain a drops array | |
| const matrixDrops = []; | |
| function initMatrix() { | |
| matrixDrops.length = 0; | |
| for (let i = 0; i < state.cols; i++) { | |
| matrixDrops[i] = Math.random() * state.rows; // Random start Y | |
| } | |
| } | |
| function drawMatrix() { | |
| // Get audio data to influence color/speed or character density | |
| state.analyser.getByteFrequencyData(state.dataArray); | |
| const bass = state.dataArray[10]; // Low freq average proxy | |
| ctx.fillStyle = 'rgba(5, 5, 5, 0.1)'; // Very slow fade | |
| ctx.fillRect(0, 0, state.width, state.height); | |
| ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-color'); | |
| const charWidth = state.width / state.cols; | |
| for (let i = 0; i < matrixDrops.length; i++) { | |
| // Pick random char | |
| const text = config.matrixChars[Math.floor(Math.random() * config.matrixChars.length)]; | |
| const x = i * charWidth; | |
| const y = matrixDrops[i] * config.fontSize; | |
| // Draw | |
| ctx.fillText(text, x, y); | |
| // Reset or move drop | |
| if (y > state.height && Math.random() > 0.975) { | |
| matrixDrops[i] = 0; | |
| } | |
| // Speed up drops based on bass | |
| const speed = 0.5 + (bass / 255); | |
| matrixDrops[i] += speed; | |
| } | |
| } | |
| function animate() { | |
| if (!state.isPlaying) return; | |
| switch (state.mode) { | |
| case 'spectrum': | |
| drawSpectrum(); | |
| break; | |
| case 'wave': | |
| drawWaveform(); | |
| break; | |
| case 'circle': | |
| drawRadial(); | |
| break; | |
| case 'matrix': | |
| drawMatrix(); | |
| break; | |
| } | |
| state.animationId = requestAnimationFrame(animate); | |
| } | |
| function startVisualization() { | |
| if (state.animationId) cancelAnimationFrame(state.animationId); | |
| if (state.mode === 'matrix' && matrixDrops.length === 0) { | |
| initMatrix(); | |
| } | |
| animate(); | |
| } | |
| function stopVisualization() { | |
| if (state.animationId) cancelAnimationFrame(state.animationId); | |
| // Clear canvas | |
| ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--bg-color'); | |
| ctx.fillRect(0, 0, state.width, state.height); | |
| } | |
| // --- Event Listeners --- | |
| window.addEventListener('resize', () => { | |
| resize(); | |
| if (state.mode === 'matrix') initMatrix(); | |
| }); | |
| // File Upload | |
| audioInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| loadAudioFile(e.target.files[0]); | |
| } | |
| }); | |
| // Play/Pause Button | |
| btnPlayPause.addEventListener('click', () => { | |
| if (!state.sourceType) return; // Nothing loaded | |
| if (state.sourceType === 'file') { | |
| if (state.isPlaying) { | |
| audioElement.pause(); | |
| state.isPlaying = false; | |
| } else { | |
| initAudioContext(); | |
| audioElement.play(); | |
| state.isPlaying = true; | |
| startVisualization(); | |
| } | |
| } else if (state.sourceType === 'mic') { | |
| // Mic is always "playing" when active, this button could mute/unmute logic | |
| // For now, let's just stop the mic | |
| stopMic(); | |
| state.isPlaying = false; | |
| showToast("Microphone Stopped"); | |
| } | |
| updatePlayButton(); | |
| }); | |
| // Mic Button | |
| btnMic.addEventListener('click', enableMicrophone); | |
| // Mode Select | |
| modeSelect.addEventListener('change', (e) => { | |
| state.mode = e.target.value; | |
| if (state.mode === 'matrix') initMatrix(); | |
| if (state.isPlaying) startVisualization(); | |
| }); | |
| // Theme Switcher | |
| themeDots.forEach(dot => { | |
| dot.addEventListener('click', () => { | |
| // Update UI selection | |
| themeDots.forEach(d => d.classList.remove('selected')); | |
| dot.classList.add('selected'); | |
| // Update CSS Variables | |
| const color = dot.getAttribute('data-color'); | |
| document.documentElement.style.setProperty('--text-color', color); | |
| // Update CRT overlay border to match slightly (optional aesthetic) | |
| // document.documentElement.style.setProperty('--ui-border', `1px solid ${color}`); | |
| }); | |
| }); | |
| // Drag and Drop | |
| window.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dragOverlay.classList.add('active'); | |
| }); | |
| dragOverlay.addEventListener('dragleave', (e) => { | |
| e.preventDefault(); | |
| dragOverlay.classList.remove('active'); | |
| }); | |
| dragOverlay.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dragOverlay.classList.remove('active'); | |
| if (e.dataTransfer.files.length > 0) { | |
| const file = e.dataTransfer.files[0]; | |
| if (file.type.startsWith('audio/')) { | |
| loadAudioFile(file); | |
| } else { | |
| showToast("Please drop an audio file"); | |
| } | |
| } | |
| }); | |
| // --- Helpers --- | |
| function updatePlayButton() { | |
| if (state.isPlaying) { | |
| iconPlay.textContent = '⏸'; | |
| btnPlayPause.querySelector('.visible').textContent = 'Pause'; | |
| } else { | |
| iconPlay.textContent = '▶'; | |
| btnPlayPause.querySelector('.visible').textContent = 'Play'; | |
| } | |
| } | |
| function showToast(msg) { | |
| toastEl.textContent = msg; | |
| toastEl.classList.add('show'); | |
| setTimeout(() => { | |
| toastEl.classList.remove('show'); | |
| }, 3000); | |
| } | |
| // --- Init --- | |
| resize(); | |
| ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--bg-color'); | |
| ctx.fillRect(0, 0, state.width, state.height); | |
| // Initial welcome text | |
| ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-color'); | |
| ctx.textAlign = 'center'; | |
| ctx.font = "20px monospace"; | |
| ctx.fillText("LOAD AUDIO FILE OR ENABLE MIC TO START", state.width/2, state.height/2); | |
| </script> | |
| </body> | |
| </html> |