Files
the-nexus/playground/visualizer.html
Alexander Whitestone 7924fa3b10
Some checks failed
CI / test (pull_request) Failing after 51s
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 55s
feat: Sovereign Sound Playground — interactive audio-visual experience
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.
2026-04-13 18:25:31 -04:00

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>