Render the sample soundwave from decoded audio peaks
This commit is contained in:
1 parent
a306bf5189
commit
55342000ff
1 file changed
+365
-381
+365
-381
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user