Implements #1354. Complete interactive audio-visual playground with: Apps: - Playground v3 (full instrument with piano, 6 modes, 5 palettes) - Synesthesia (paint with sound) - Ambient (evolving soundscape) - Interactive (26 key-shape mappings) - Visualizer (WAV frequency visualization) Features: - Visual piano keyboard (2 octaves, mouse/touch/keyboard) - 6 visualization modes: Waveform, Particles, Bars, Spiral, Gravity Well, Strobe - 5 color palettes: Cosmic, Sunset, Ocean, Forest, Neon - Ambient beat with chord progressions - Chord detection - Recording to WAV - Export as PNG - Touch support - Zero dependencies, pure browser All apps are standalone HTML files — just open in a browser.
372 lines
14 KiB
HTML
372 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Visualizer — WAV Frequency</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
|
|
canvas { display: block; width: 100%; height: 100%; }
|
|
.title {
|
|
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
|
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
|
|
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
|
|
}
|
|
.controls {
|
|
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
|
display: flex; gap: 10px; z-index: 10;
|
|
}
|
|
button, select {
|
|
background: rgba(30,30,40,0.9); border: 1px solid rgba(100,100,120,0.3);
|
|
color: #e0e0e0; padding: 10px 20px; border-radius: 8px; font-size: 14px;
|
|
cursor: pointer;
|
|
}
|
|
button:hover, select:hover { background: rgba(50,50,70,0.9); }
|
|
.drop-zone {
|
|
position: fixed; inset: 0; display: flex; align-items: center;
|
|
justify-content: center; pointer-events: none; z-index: 5;
|
|
}
|
|
.drop-zone.active {
|
|
background: rgba(100,50,150,0.2);
|
|
border: 3px dashed rgba(150,100,200,0.5);
|
|
}
|
|
.drop-text {
|
|
color: rgba(255,255,255,0.3); font-size: 24px; font-family: sans-serif;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="title">Visualizer</div>
|
|
<div class="drop-zone" id="drop-zone">
|
|
<span class="drop-text">Drop WAV file here or use controls</span>
|
|
</div>
|
|
<canvas id="canvas"></canvas>
|
|
<div class="controls">
|
|
<input type="file" id="file-input" accept="audio/*" style="display: none;">
|
|
<button id="load-btn">Load Audio</button>
|
|
<button id="play-btn" disabled>Play</button>
|
|
<button id="stop-btn" disabled>Stop</button>
|
|
<select id="viz-mode">
|
|
<option value="spectrum">Spectrum</option>
|
|
<option value="waveform">Waveform</option>
|
|
<option value="spectrogram">Spectrogram</option>
|
|
<option value="circular">Circular</option>
|
|
</select>
|
|
</div>
|
|
<script>
|
|
class AudioVisualizer {
|
|
constructor() {
|
|
this.canvas = document.getElementById('canvas');
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.audioCtx = null;
|
|
this.analyser = null;
|
|
this.source = null;
|
|
this.audioBuffer = null;
|
|
this.isPlaying = false;
|
|
this.mode = 'spectrum';
|
|
this.spectrogramData = [];
|
|
this.startOffset = 0;
|
|
|
|
this.resize();
|
|
window.addEventListener('resize', () => this.resize());
|
|
this.bindEvents();
|
|
this.animate();
|
|
}
|
|
|
|
resize() {
|
|
this.canvas.width = window.innerWidth;
|
|
this.canvas.height = window.innerHeight;
|
|
}
|
|
|
|
async initAudio() {
|
|
if (this.audioCtx) return;
|
|
|
|
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
this.analyser = this.audioCtx.createAnalyser();
|
|
this.analyser.fftSize = 2048;
|
|
this.analyser.smoothingTimeConstant = 0.8;
|
|
this.analyser.connect(this.audioCtx.destination);
|
|
}
|
|
|
|
async loadAudio(file) {
|
|
await this.initAudio();
|
|
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
this.audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer);
|
|
|
|
document.getElementById('play-btn').disabled = false;
|
|
document.getElementById('stop-btn').disabled = false;
|
|
document.querySelector('.drop-text').textContent = file.name;
|
|
}
|
|
|
|
play() {
|
|
if (!this.audioBuffer || this.isPlaying) return;
|
|
|
|
this.source = this.audioCtx.createBufferSource();
|
|
this.source.buffer = this.audioBuffer;
|
|
this.source.connect(this.analyser);
|
|
this.source.start(0, this.startOffset);
|
|
this.isPlaying = true;
|
|
|
|
this.source.onended = () => {
|
|
this.isPlaying = false;
|
|
this.startOffset = 0;
|
|
document.getElementById('play-btn').textContent = 'Play';
|
|
};
|
|
|
|
document.getElementById('play-btn').textContent = 'Pause';
|
|
}
|
|
|
|
stop() {
|
|
if (this.source && this.isPlaying) {
|
|
this.source.stop();
|
|
this.isPlaying = false;
|
|
this.startOffset = 0;
|
|
document.getElementById('play-btn').textContent = 'Play';
|
|
}
|
|
}
|
|
|
|
animate() {
|
|
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.15)';
|
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
if (!this.analyser) {
|
|
requestAnimationFrame(() => this.animate());
|
|
return;
|
|
}
|
|
|
|
const freqData = new Uint8Array(this.analyser.frequencyBinCount);
|
|
const timeData = new Uint8Array(this.analyser.frequencyBinCount);
|
|
this.analyser.getByteFrequencyData(freqData);
|
|
this.analyser.getByteTimeDomainData(timeData);
|
|
|
|
switch (this.mode) {
|
|
case 'spectrum': this.drawSpectrum(freqData); break;
|
|
case 'waveform': this.drawWaveform(timeData); break;
|
|
case 'spectrogram': this.drawSpectrogram(freqData); break;
|
|
case 'circular': this.drawCircular(freqData, timeData); break;
|
|
}
|
|
|
|
requestAnimationFrame(() => this.animate());
|
|
}
|
|
|
|
drawSpectrum(data) {
|
|
const w = this.canvas.width;
|
|
const h = this.canvas.height;
|
|
const barCount = 128;
|
|
const barWidth = w / barCount;
|
|
const step = Math.floor(data.length / barCount);
|
|
|
|
for (let i = 0; i < barCount; i++) {
|
|
const value = data[i * step] / 255;
|
|
const barHeight = value * h * 0.8;
|
|
|
|
const hue = (i / barCount) * 300;
|
|
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
|
|
|
|
this.ctx.fillRect(
|
|
i * barWidth,
|
|
h - barHeight,
|
|
barWidth - 1,
|
|
barHeight
|
|
);
|
|
|
|
// Mirror
|
|
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.2)`;
|
|
this.ctx.fillRect(
|
|
i * barWidth,
|
|
0,
|
|
barWidth - 1,
|
|
barHeight * 0.3
|
|
);
|
|
}
|
|
}
|
|
|
|
drawWaveform(data) {
|
|
const w = this.canvas.width;
|
|
const h = this.canvas.height;
|
|
|
|
this.ctx.beginPath();
|
|
this.ctx.strokeStyle = '#a78bfa';
|
|
this.ctx.lineWidth = 2;
|
|
|
|
const sliceWidth = w / data.length;
|
|
let x = 0;
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
|
const v = data[i] / 128.0;
|
|
const y = (v * h) / 2;
|
|
|
|
if (i === 0) {
|
|
this.ctx.moveTo(x, y);
|
|
} else {
|
|
this.ctx.lineTo(x, y);
|
|
}
|
|
x += sliceWidth;
|
|
}
|
|
|
|
this.ctx.stroke();
|
|
|
|
// Glow
|
|
this.ctx.strokeStyle = 'rgba(167,139,250,0.3)';
|
|
this.ctx.lineWidth = 6;
|
|
this.ctx.stroke();
|
|
}
|
|
|
|
drawSpectrogram(data) {
|
|
const w = this.canvas.width;
|
|
const h = this.canvas.height;
|
|
|
|
// Add new line
|
|
this.spectrogramData.push([...data]);
|
|
|
|
// Keep last N lines
|
|
const maxLines = Math.floor(w / 2);
|
|
if (this.spectrogramData.length > maxLines) {
|
|
this.spectrogramData.shift();
|
|
}
|
|
|
|
// Draw
|
|
const line_width = w / maxLines;
|
|
|
|
this.spectrogramData.forEach((line, x) => {
|
|
const step = Math.floor(line.length / h);
|
|
for (let y = 0; y < h; y++) {
|
|
const value = line[y * step] || 0;
|
|
const hue = 270 - (value / 255) * 200;
|
|
const lightness = 20 + (value / 255) * 50;
|
|
|
|
this.ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
|
|
this.ctx.fillRect(x * line_width, h - y, line_width, 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
drawCircular(freqData, timeData) {
|
|
const w = this.canvas.width;
|
|
const h = this.canvas.height;
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
const maxRadius = Math.min(w, h) * 0.35;
|
|
|
|
// Frequency ring
|
|
const freqStep = Math.floor(freqData.length / 180);
|
|
for (let i = 0; i < 180; i++) {
|
|
const value = freqData[i * freqStep] / 255;
|
|
const angle = (i / 180) * Math.PI * 2;
|
|
const radius = maxRadius * 0.5 + value * maxRadius * 0.5;
|
|
|
|
const x = cx + Math.cos(angle) * radius;
|
|
const y = cy + Math.sin(angle) * radius;
|
|
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(x, y, 3, 0, Math.PI * 2);
|
|
|
|
const hue = (i / 180) * 360;
|
|
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
|
|
this.ctx.fill();
|
|
}
|
|
|
|
// Waveform circle
|
|
this.ctx.beginPath();
|
|
this.ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
|
this.ctx.lineWidth = 1;
|
|
|
|
for (let i = 0; i < timeData.length; i++) {
|
|
const v = timeData[i] / 128.0;
|
|
const angle = (i / timeData.length) * Math.PI * 2;
|
|
const radius = maxRadius * 0.3 * v;
|
|
|
|
const x = cx + Math.cos(angle) * radius;
|
|
const y = cy + Math.sin(angle) * radius;
|
|
|
|
if (i === 0) {
|
|
this.ctx.moveTo(x, y);
|
|
} else {
|
|
this.ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
|
|
this.ctx.closePath();
|
|
this.ctx.stroke();
|
|
|
|
// Center glow
|
|
const gradient = this.ctx.createRadialGradient(cx, cy, 0, cx, cy, maxRadius * 0.3);
|
|
gradient.addColorStop(0, 'rgba(167,139,250,0.3)');
|
|
gradient.addColorStop(1, 'rgba(167,139,250,0)');
|
|
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(cx, cy, maxRadius * 0.3, 0, Math.PI * 2);
|
|
this.ctx.fillStyle = gradient;
|
|
this.ctx.fill();
|
|
}
|
|
|
|
bindEvents() {
|
|
document.getElementById('load-btn').addEventListener('click', () => {
|
|
document.getElementById('file-input').click();
|
|
});
|
|
|
|
document.getElementById('file-input').addEventListener('change', async (e) => {
|
|
if (e.target.files[0]) {
|
|
await this.loadAudio(e.target.files[0]);
|
|
}
|
|
});
|
|
|
|
document.getElementById('play-btn').addEventListener('click', () => {
|
|
if (this.isPlaying) {
|
|
this.stop();
|
|
} else {
|
|
this.play();
|
|
}
|
|
});
|
|
|
|
document.getElementById('stop-btn').addEventListener('click', () => {
|
|
this.stop();
|
|
});
|
|
|
|
document.getElementById('viz-mode').addEventListener('change', (e) => {
|
|
this.mode = e.target.value;
|
|
this.spectrogramData = [];
|
|
});
|
|
|
|
// Drag and drop
|
|
const dropZone = document.getElementById('drop-zone');
|
|
|
|
document.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.add('active');
|
|
});
|
|
|
|
document.addEventListener('dragleave', () => {
|
|
dropZone.classList.remove('active');
|
|
});
|
|
|
|
document.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove('active');
|
|
|
|
const file = e.dataTransfer.files[0];
|
|
if (file && file.type.startsWith('audio/')) {
|
|
await this.loadAudio(file);
|
|
}
|
|
});
|
|
|
|
// Touch support
|
|
this.canvas.addEventListener('click', async () => {
|
|
if (!this.audioCtx) {
|
|
await this.initAudio();
|
|
}
|
|
|
|
if (this.audioCtx.state === 'suspended') {
|
|
this.audioCtx.resume();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
new AudioVisualizer();
|
|
</script>
|
|
</body>
|
|
</html>
|