From 55342000ff64e9b9c0f6c9656b1fd3a884d40b54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:25:06 +0000 Subject: [PATCH] Render the sample soundwave from decoded audio peaks --- ventures/wavehello/app.js | 746 +++++++++++++++++++------------------- 1 file changed, 365 insertions(+), 381 deletions(-) diff --git a/ventures/wavehello/app.js b/ventures/wavehello/app.js index 472f9b9..e8a7464 100644 --- a/ventures/wavehello/app.js +++ b/ventures/wavehello/app.js @@ -1,457 +1,441 @@ -// Wavehello App Logic +// Wavehello — soundwave art, computed in the browser. +// Record, a dropped file, and the preloaded sample all go through the same +// path: decode the bytes, measure the peaks, paint those peaks. document.addEventListener('DOMContentLoaded', () => { - // DOM Elements const recordButton = document.getElementById('recordButton'); const dropArea = document.getElementById('dropArea'); + const fileInput = document.getElementById('fileInput'); const waveform = document.getElementById('waveform'); const styleSelect = document.getElementById('styleSelect'); const colorPicker = document.getElementById('colorPicker'); const captionInput = document.getElementById('captionInput'); const downloadButton = document.getElementById('downloadButton'); - - // State + const readout = document.getElementById('waveReadout'); + let audioContext = null; let audioBuffer = null; + let peaks = []; + let sourceLabel = ''; let isRecording = false; let mediaRecorder = null; let audioChunks = []; - - // Check for Time library features + checkTimeFeatures(); - - // Initialize with sample data - initializeSample(); - - // Event Listeners - recordButton.addEventListener('click', toggleRecording); - dropArea.addEventListener('dragover', handleDragOver); - dropArea.addEventListener('drop', handleDrop); - styleSelect.addEventListener('change', updateVisualization); - colorPicker.addEventListener('input', updateVisualization); - captionInput.addEventListener('input', updateVisualization); - downloadButton.addEventListener('click', downloadPNG); - - // Functions + bind(); + // First load: sample.wav is real input, decoded by the same function a + // dropped file uses. The paint is the peaks of that buffer, not a drawing. + loadSample(); + + function bind() { + recordButton.addEventListener('click', toggleRecording); + dropArea.addEventListener('dragover', handleDragOver); + dropArea.addEventListener('dragleave', () => { + dropArea.style.backgroundColor = ''; + }); + dropArea.addEventListener('drop', handleDrop); + dropArea.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', () => { + if (fileInput.files && fileInput.files[0]) processAudioFile(fileInput.files[0]); + }); + styleSelect.addEventListener('change', paint); + colorPicker.addEventListener('input', paint); + captionInput.addEventListener('input', paint); + downloadButton.addEventListener('click', downloadPNG); + } + function checkTimeFeatures() { - // Check if Time.accounts is available and show sign-in option if so - if (typeof Time !== 'undefined' && Time.accounts) { - // In a full implementation, we would add sign-in UI here - console.log('Time accounts feature is available'); - } - - // Check for shared data - if (typeof Time !== 'undefined') { - const sharedData = Time.load(); - if (sharedData) { - // Load shared creation if present - console.log('Loading shared data:', sharedData); - // In a full implementation, we would load the shared creation here + if (typeof Time === 'undefined') return; + if (Time.accounts) console.log('Time accounts feature is available'); + Promise.resolve(Time.load()).then((sharedData) => { + if (sharedData && Array.isArray(sharedData.peaks) && sharedData.peaks.length) { + peaks = sharedData.peaks.map(Number).filter((n) => Number.isFinite(n)); + sourceLabel = 'shared wave'; + if (sharedData.style) styleSelect.value = sharedData.style; + if (sharedData.color) colorPicker.value = sharedData.color; + if (sharedData.caption) captionInput.value = sharedData.caption; + audioBuffer = null; + paint(); } - } + }).catch(() => {}); } - - async function initializeSample() { - try { - // Fetch the sample audio file - const response = await fetch('sample.wav'); - if (!response.ok) { - throw new Error('Failed to load sample audio'); - } - - const arrayBuffer = await response.arrayBuffer(); - - // Create audio context if needed - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - - // Decode the audio data - audioBuffer = await audioContext.decodeAudioData(arrayBuffer); - - // Visualize the audio buffer - visualizeAudioBuffer(audioBuffer); - } catch (error) { - console.error('Error loading sample:', error); - // Fallback to sample visualization if loading fails - createSampleVisualization(); - } + + function loadSample() { + setStatus('Decoding the sample voicemail…'); + return fetch('sample.wav') + .then((response) => { + if (!response.ok) throw new Error('Failed to load sample audio (' + response.status + ')'); + return response.arrayBuffer(); + }) + .then((arrayBuffer) => ingestAudio(arrayBuffer, 'sample voicemail')) + .catch((error) => { + console.error('Error loading sample:', error); + setStatus('Could not decode the sample. Record or drop an audio file.'); + }); } - - function createSampleVisualization() { - // Clear previous content - waveform.innerHTML = ''; - - // Get selected style and color + + function setStatus(text) { + if (!peaks.length) waveform.textContent = text; + } + + // One path for the sample, a dropped file, and a recording. + async function ingestAudio(arrayBuffer, label) { + const copy = arrayBuffer.slice(0); + const ctx = context(); + let buffer = null; + if (ctx && ctx.decodeAudioData) { + try { + buffer = await ctx.decodeAudioData(copy.slice(0)); + } catch (err) { + console.warn('decodeAudioData failed, reading PCM directly', err); + } + } + if (!buffer) buffer = decodeWavPcm(copy); + if (!buffer) throw new Error('Could not decode that audio.'); + audioBuffer = buffer; + peaks = measurePeaks(buffer, 96); + sourceLabel = label; + paint(); + remember(); + return buffer; + } + + function context() { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return null; + if (!audioContext) audioContext = new Ctx(); + if (audioContext.state === 'suspended' && audioContext.resume) audioContext.resume(); + return audioContext; + } + + // PCM WAV fallback so the sample still runs where Web Audio is missing. + // Same peak measurement as a buffer from decodeAudioData. + function decodeWavPcm(arrayBuffer) { + const view = new DataView(arrayBuffer); + if (arrayBuffer.byteLength < 44) return null; + if (readStr(view, 0, 4) !== 'RIFF' || readStr(view, 8, 4) !== 'WAVE') return null; + let offset = 12; + let channels = 1; + let rate = 44100; + let bits = 16; + let dataOffset = -1; + let dataSize = 0; + while (offset + 8 <= view.byteLength) { + const id = readStr(view, offset, 4); + const size = view.getUint32(offset + 4, true); + const body = offset + 8; + if (id === 'fmt ' && body + 16 <= view.byteLength) { + channels = view.getUint16(body + 2, true) || 1; + rate = view.getUint32(body + 4, true) || 44100; + bits = view.getUint16(body + 14, true) || 16; + } else if (id === 'data') { + dataOffset = body; + dataSize = size; + break; + } + offset = body + size + (size % 2); + } + if (dataOffset < 0 || bits !== 16) return null; + const frames = Math.floor(dataSize / (2 * channels)); + const samples = new Float32Array(frames); + let p = dataOffset; + for (let i = 0; i < frames; i++) { + let sum = 0; + for (let c = 0; c < channels; c++) { + if (p + 2 > view.byteLength) break; + sum += view.getInt16(p, true) / 32768; + p += 2; + } + samples[i] = sum / channels; + } + return { + numberOfChannels: 1, + sampleRate: rate, + length: frames, + duration: frames / rate, + getChannelData: () => samples + }; + } + + function readStr(view, offset, n) { + let s = ''; + for (let i = 0; i < n; i++) s += String.fromCharCode(view.getUint8(offset + i)); + return s; + } + + function measurePeaks(buffer, count) { + const channel = buffer.getChannelData(0); + const step = Math.max(1, Math.floor(channel.length / count)); + const out = []; + for (let i = 0; i < count; i++) { + const start = i * step; + const end = Math.min(start + step, channel.length); + let max = 0; + for (let j = start; j < end; j++) { + const amp = Math.abs(channel[j]); + if (amp > max) max = amp; + } + out.push(max); + } + return out; + } + + function paint() { + if (!peaks.length) return; const style = styleSelect.value; const color = colorPicker.value; - const caption = captionInput.value; - - // Create SVG container - const svgNS = "http://www.w3.org/2000/svg"; - const svg = document.createElementNS(svgNS, "svg"); - svg.setAttribute("viewBox", "0 0 800 200"); - svg.setAttribute("width", "100%"); - svg.setAttribute("height", "100%"); - - // Add background - const bg = document.createElementNS(svgNS, "rect"); - bg.setAttribute("width", "100%"); - bg.setAttribute("height", "100%"); - bg.setAttribute("fill", "white"); - svg.appendChild(bg); - - // Generate sample waveform data - const points = []; - for (let i = 0; i <= 800; i += 10) { - // Create a more interesting wave pattern - const amplitude = Math.sin(i / 50) * 30 + Math.sin(i / 20) * 20; - const y = 100 - amplitude; - points.push(`${i},${y}`); + const caption = captionInput.value.trim(); + const width = 800; + const height = 240; + + waveform.innerHTML = ''; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', 'Soundwave of ' + sourceLabel); + const ctx = canvas.getContext('2d'); + drawWave(ctx, width, height, peaks, style, color, caption); + waveform.appendChild(canvas); + + const svg = waveSVG(width, height, peaks, style, color, caption); + svg.setAttribute('width', '100%'); + svg.setAttribute('height', '100%'); + svg.style.position = 'absolute'; + svg.style.inset = '0'; + waveform.appendChild(svg); + + if (readout) { + const loud = peaks.reduce((a, b) => a + b, 0) / peaks.length; + const seconds = audioBuffer ? audioBuffer.duration.toFixed(1) : '—'; + const rate = audioBuffer ? audioBuffer.sampleRate : '—'; + readout.textContent = sourceLabel + ' · ' + seconds + 's · ' + rate + ' Hz · ' + + peaks.length + ' peaks · mean ' + loud.toFixed(2); } - - // Draw based on selected style - if (style === 'line') { - const path = document.createElementNS(svgNS, "polyline"); - path.setAttribute("points", points.join(" ")); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", color); - path.setAttribute("stroke-width", "2"); - svg.appendChild(path); - } else if (style === 'bars') { - for (let i = 0; i < points.length; i++) { - const [x, y] = points[i].split(',').map(Number); - const height = 100 - y; - const bar = document.createElementNS(svgNS, "rect"); - bar.setAttribute("x", x - 2); - bar.setAttribute("y", y); - bar.setAttribute("width", "4"); - bar.setAttribute("height", height); - bar.setAttribute("fill", color); - svg.appendChild(bar); - } + } + + function drawWave(ctx, width, height, amps, style, color, caption) { + ctx.clearRect(0, 0, width, height); + const mid = caption ? height * 0.42 : height * 0.5; + const scale = caption ? height * 0.34 : height * 0.42; + if (style === 'bars') { + const gap = 3; + const barW = Math.max(2, (width - gap * amps.length) / amps.length); + amps.forEach((amp, i) => { + const h = Math.max(2, amp * scale * 2); + ctx.fillStyle = color; + ctx.fillRect(i * (barW + gap), mid - h / 2, barW, h); + }); } else if (style === 'ripple') { - const circle = document.createElementNS(svgNS, "circle"); - circle.setAttribute("cx", "400"); - circle.setAttribute("cy", "100"); - circle.setAttribute("r", "80"); - circle.setAttribute("fill", "none"); - circle.setAttribute("stroke", color); - circle.setAttribute("stroke-width", "2"); - svg.appendChild(circle); - - const innerCircle = document.createElementNS(svgNS, "circle"); - innerCircle.setAttribute("cx", "400"); - innerCircle.setAttribute("cy", "100"); - innerCircle.setAttribute("r", "50"); - innerCircle.setAttribute("fill", "none"); - innerCircle.setAttribute("stroke", color); - innerCircle.setAttribute("stroke-width", "1"); - innerCircle.setAttribute("stroke-dasharray", "5,5"); - svg.appendChild(innerCircle); - - const center = document.createElementNS(svgNS, "circle"); - center.setAttribute("cx", "400"); - center.setAttribute("cy", "100"); - center.setAttribute("r", "10"); - center.setAttribute("fill", color); - svg.appendChild(center); + const cx = width / 2; + const cy = mid; + const maxR = Math.min(width, height) * 0.38; + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + amps.forEach((amp, i) => { + if (i % 3 !== 0) return; + ctx.globalAlpha = 0.35 + amp * 0.65; + ctx.beginPath(); + ctx.arc(cx, cy, 8 + amp * maxR, 0, Math.PI * 2); + ctx.stroke(); + }); + ctx.globalAlpha = 1; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(cx, cy, 5, 0, Math.PI * 2); + ctx.fill(); + } else { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + amps.forEach((amp, i) => { + const x = (i / (amps.length - 1)) * width; + const y = mid - amp * scale; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + for (let i = amps.length - 1; i >= 0; i--) { + const x = (i / (amps.length - 1)) * width; + const y = mid + amps[i] * scale; + ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.globalAlpha = 0.18; + ctx.fillStyle = color; + ctx.fill(); + ctx.globalAlpha = 1; + ctx.stroke(); } - - // Add caption if provided if (caption) { - const text = document.createElementNS(svgNS, "text"); - text.setAttribute("x", "400"); - text.setAttribute("y", "180"); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("font-family", "sans-serif"); - text.setAttribute("font-size", "14"); - text.setAttribute("fill", "#333"); + ctx.fillStyle = '#0f172a'; + ctx.font = '16px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(caption, width / 2, height - 12); + } + } + + function waveSVG(width, height, amps, style, color, caption) { + const svgNS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(svgNS, 'svg'); + svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height); + const mid = caption ? height * 0.42 : height * 0.5; + const scale = caption ? height * 0.34 : height * 0.42; + if (style === 'bars') { + const gap = 3; + const barW = Math.max(2, (width - gap * amps.length) / amps.length); + amps.forEach((amp, i) => { + const h = Math.max(2, amp * scale * 2); + const bar = document.createElementNS(svgNS, 'rect'); + bar.setAttribute('x', String(i * (barW + gap))); + bar.setAttribute('y', String(mid - h / 2)); + bar.setAttribute('width', String(barW)); + bar.setAttribute('height', String(h)); + bar.setAttribute('fill', color); + svg.appendChild(bar); + }); + } else if (style === 'ripple') { + const cx = width / 2; + const cy = mid; + const maxR = Math.min(width, height) * 0.38; + amps.forEach((amp, i) => { + if (i % 3 !== 0) return; + const circle = document.createElementNS(svgNS, 'circle'); + circle.setAttribute('cx', String(cx)); + circle.setAttribute('cy', String(cy)); + circle.setAttribute('r', String(8 + amp * maxR)); + circle.setAttribute('fill', 'none'); + circle.setAttribute('stroke', color); + circle.setAttribute('stroke-width', '1.5'); + circle.setAttribute('opacity', String(0.35 + amp * 0.65)); + svg.appendChild(circle); + }); + } else { + const top = amps.map((amp, i) => { + const x = (i / (amps.length - 1)) * width; + return x.toFixed(1) + ',' + (mid - amp * scale).toFixed(1); + }); + const bottom = amps.map((amp, i) => { + const x = (i / (amps.length - 1)) * width; + return x.toFixed(1) + ',' + (mid + amp * scale).toFixed(1); + }).reverse(); + const path = document.createElementNS(svgNS, 'polygon'); + path.setAttribute('points', top.concat(bottom).join(' ')); + path.setAttribute('fill', color); + path.setAttribute('fill-opacity', '0.18'); + path.setAttribute('stroke', color); + path.setAttribute('stroke-width', '2'); + svg.appendChild(path); + } + if (caption) { + const text = document.createElementNS(svgNS, 'text'); + text.setAttribute('x', String(width / 2)); + text.setAttribute('y', String(height - 12)); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('font-family', 'sans-serif'); + text.setAttribute('font-size', '16'); + text.setAttribute('fill', '#0f172a'); text.textContent = caption; svg.appendChild(text); } - - waveform.appendChild(svg); + return svg; } - - function updateVisualization() { - if (audioBuffer) { - visualizeAudioBuffer(audioBuffer); - } else { - createSampleVisualization(); - } + + function remember() { + if (typeof Time === 'undefined' || !Time.save) return; + try { + Time.save({ + peaks: peaks, + style: styleSelect.value, + color: colorPicker.value, + caption: captionInput.value, + label: sourceLabel + }); + } catch (e) { /* share is an extra */ } } - + function toggleRecording() { - if (isRecording) { - stopRecording(); - } else { - startRecording(); - } + if (isRecording) stopRecording(); + else startRecording(); } - + async function startRecording() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); mediaRecorder = new MediaRecorder(stream); audioChunks = []; - - mediaRecorder.ondataavailable = event => { - audioChunks.push(event.data); + mediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size) audioChunks.push(event.data); }; - mediaRecorder.onstop = async () => { - const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); - await processAudioBlob(audioBlob); - stream.getTracks().forEach(track => track.stop()); + const audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' }); + stream.getTracks().forEach((track) => track.stop()); + try { + await ingestAudio(await audioBlob.arrayBuffer(), 'recording'); + } catch (error) { + console.error(error); + alert('Could not read that recording. Try dropping a file instead.'); + } }; - mediaRecorder.start(); isRecording = true; - recordButton.textContent = '⏹'; + recordButton.textContent = '■'; + recordButton.setAttribute('aria-label', 'Stop recording'); recordButton.style.backgroundColor = '#ef4444'; } catch (error) { console.error('Error accessing microphone:', error); - alert('Could not access microphone. Please ensure you have given permission.'); + alert('Could not access the microphone. Drop an audio file instead — it stays on this device.'); } } - + function stopRecording() { if (mediaRecorder && isRecording) { mediaRecorder.stop(); isRecording = false; recordButton.textContent = '●'; + recordButton.setAttribute('aria-label', 'Record'); recordButton.style.backgroundColor = ''; } } - + function handleDragOver(event) { event.preventDefault(); dropArea.style.backgroundColor = '#e2e8f0'; } - + function handleDrop(event) { event.preventDefault(); dropArea.style.backgroundColor = ''; - - const files = event.dataTransfer.files; - if (files.length > 0) { - const file = files[0]; - if (file.type.startsWith('audio/') || file.name.match(/\.(mp3|wav|ogg|m4a)$/i)) { - processAudioFile(file); - } else { - alert('Please drop an audio file.'); - } - } + const files = event.dataTransfer && event.dataTransfer.files; + if (files && files.length) processAudioFile(files[0]); } - + function processAudioFile(file) { const reader = new FileReader(); - reader.onload = async event => { + reader.onload = async (event) => { try { - const arrayBuffer = event.target.result; - await decodeAudioData(arrayBuffer); + await ingestAudio(event.target.result, file.name || 'audio file'); } catch (error) { console.error('Error processing audio file:', error); - alert('Could not process the audio file. Please try another file.'); + alert('Could not process that audio file. Try a WAV, MP3, or a short voice memo.'); } }; + reader.onerror = () => alert('Could not read that file.'); reader.readAsArrayBuffer(file); } - - async function processAudioBlob(blob) { - const arrayBuffer = await blob.arrayBuffer(); - await decodeAudioData(arrayBuffer); - } - - async function decodeAudioData(arrayBuffer) { - try { - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - audioBuffer = await audioContext.decodeAudioData(arrayBuffer); - visualizeAudioBuffer(audioBuffer); - - // Save creation using Time library if available - if (typeof Time !== 'undefined') { - const creationData = { - style: styleSelect.value, - color: colorPicker.value, - caption: captionInput.value, - // In a full implementation, we would include audio data or a reference - }; - - // Try to save the creation - const shareLink = Time.save(creationData); - if (shareLink) { - console.log('Creation saved with share link:', shareLink); - // In a full implementation, we would show the share link to the user - } - } - } catch (error) { - console.error('Error decoding audio data:', error); - alert('Could not decode the audio data. Please try another file.'); - } - } - - function visualizeAudioBuffer(buffer) { - // Clear previous visualization - waveform.innerHTML = ''; - - // Get selected style and color - const style = styleSelect.value; - const color = colorPicker.value; - const caption = captionInput.value; - - // Create SVG container - const svgNS = "http://www.w3.org/2000/svg"; - const svg = document.createElementNS(svgNS, "svg"); - svg.setAttribute("viewBox", "0 0 800 200"); - svg.setAttribute("width", "100%"); - svg.setAttribute("height", "100%"); - - // Add background - const bg = document.createElementNS(svgNS, "rect"); - bg.setAttribute("width", "100%"); - bg.setAttribute("height", "100%"); - bg.setAttribute("fill", "white"); - svg.appendChild(bg); - - // Get audio data (use left channel or mono) - const channelData = buffer.numberOfChannels > 0 ? buffer.getChannelData(0) : new Float32Array(0); - - // Reduce data points for visualization - const step = Math.ceil(channelData.length / 800); - const ampPoints = []; - - for (let i = 0; i < 800; i++) { - const start = i * step; - const end = Math.min(start + step, channelData.length); - let max = 0; - - for (let j = start; j < end; j++) { - const amplitude = Math.abs(channelData[j]); - if (amplitude > max) max = amplitude; - } - - ampPoints.push(max); - } - - // Draw based on selected style - if (style === 'line') { - const points = ampPoints.map((amp, i) => { - const y = 100 - (amp * 80); // Scale amplitude to fit in view - return `${i}, ${y}`; - }).join(" "); - - const path = document.createElementNS(svgNS, "polyline"); - path.setAttribute("points", points); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", color); - path.setAttribute("stroke-width", "2"); - svg.appendChild(path); - } else if (style === 'bars') { - for (let i = 0; i < ampPoints.length; i += 5) { - const amp = ampPoints[i]; - const height = amp * 100; // Scale amplitude - const bar = document.createElementNS(svgNS, "rect"); - bar.setAttribute("x", i); - bar.setAttribute("y", 100 - height); - bar.setAttribute("width", "3"); - bar.setAttribute("height", height); - bar.setAttribute("fill", color); - svg.appendChild(bar); - } - } else if (style === 'ripple') { - // Create concentric circles based on amplitude - const centerX = 400; - const centerY = 100; - - // Draw outer circle - const maxRadius = 80; - const circle = document.createElementNS(svgNS, "circle"); - circle.setAttribute("cx", centerX); - circle.setAttribute("cy", centerY); - circle.setAttribute("r", maxRadius); - circle.setAttribute("fill", "none"); - circle.setAttribute("stroke", color); - circle.setAttribute("stroke-width", "1"); - svg.appendChild(circle); - - // Draw inner circles based on peaks - const peakCount = 5; - for (let i = 0; i < peakCount; i++) { - const idx = Math.floor((i / peakCount) * ampPoints.length); - const radius = ampPoints[idx] * 50; - const circle = document.createElementNS(svgNS, "circle"); - circle.setAttribute("cx", centerX); - circle.setAttribute("cy", centerY); - circle.setAttribute("r", radius); - circle.setAttribute("fill", "none"); - circle.setAttribute("stroke", color); - circle.setAttribute("stroke-width", "1"); - circle.setAttribute("opacity", 0.7 - (i * 0.1)); - svg.appendChild(circle); - } - - // Draw center point - const center = document.createElementNS(svgNS, "circle"); - center.setAttribute("cx", centerX); - center.setAttribute("cy", centerY); - center.setAttribute("r", "5"); - center.setAttribute("fill", color); - svg.appendChild(center); - } - - // Add caption if provided - if (caption) { - const text = document.createElementNS(svgNS, "text"); - text.setAttribute("x", "400"); - text.setAttribute("y", "180"); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("font-family", "sans-serif"); - text.setAttribute("font-size", "14"); - text.setAttribute("fill", "#333"); - text.textContent = caption; - svg.appendChild(text); - } - - waveform.appendChild(svg); - } - + function downloadPNG() { - const svg = waveform.querySelector('svg'); - if (!svg) { - alert('No visualization to download'); + const canvas = waveform.querySelector('canvas'); + if (!canvas || !peaks.length) { + alert('No soundwave to download yet.'); return; } - - // Serialize SVG to string - const serializer = new XMLSerializer(); - const svgString = serializer.serializeToString(svg); - const svgBlob = new Blob([svgString], {type: 'image/svg+xml;charset=utf-8'}); - - // Convert SVG to canvas - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - const img = new Image(); - - img.onload = function() { - canvas.width = 800; - canvas.height = 200; - ctx.drawImage(img, 0, 0); - - // Create download link - const pngUrl = canvas.toDataURL('image/png'); - const downloadLink = document.createElement('a'); - downloadLink.href = pngUrl; - downloadLink.download = 'wavehello.png'; - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); - }; - - const url = URL.createObjectURL(svgBlob); - img.src = `data:image/svg+xml;base64,${btoa(svgString)}`; + const link = document.createElement('a'); + link.href = canvas.toDataURL('image/png'); + link.download = 'wavehello.png'; + document.body.appendChild(link); + link.click(); + link.remove(); } -}); \ No newline at end of file +});