Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
7924fa3b10 feat: Sovereign Sound Playground — interactive audio-visual experience
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
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
6 changed files with 2402 additions and 0 deletions

80
playground/README.md Normal file
View File

@@ -0,0 +1,80 @@
# Sovereign Sound Playground
Interactive audio-visual experience — no servers, no dependencies, pure browser.
## Apps
### Playground v3 (Full Instrument)
`playground.html` — The complete experience:
- Visual piano keyboard (2 octaves)
- 6 visualization modes: Waveform, Particles, Bars, Spiral, Gravity Well, Strobe
- 5 color palettes: Cosmic, Sunset, Ocean, Forest, Neon
- Ambient beat with chord progressions
- Mouse/touch playback on visualizer
- Chord detection
- Recording to WAV
- Export as PNG
### Synesthesia
`synesthesia.html` — Paint with sound:
- Click and drag to create colors and shapes
- Each position generates a unique tone
- Particles respond to your movement
- Touch supported
### Ambient
`ambient.html` — Evolving soundscape:
- Automatic chord progressions
- Floating orbs respond to audio
- Reverb-drenched textures
- Click to enter, let it wash over you
### Interactive
`interactive.html` — 26 key-shape mappings:
- Press A-Z to play notes
- Each key has a unique shape and color
- Shapes animate and fade
- Visual keyboard at bottom
- Touch supported
### Visualizer
`visualizer.html` — WAV frequency visualization:
- Load any audio file
- 4 modes: Spectrum, Waveform, Spectrogram, Circular
- Drag and drop support
- Real-time frequency analysis
## Features
- Zero dependencies — just open in a browser
- Local-first — no network requests
- Touch support on all apps
- Keyboard support
- Recording and export
- Multiple visualization modes
- Color palettes
## Usage
Open any HTML file in a browser. That's it.
```bash
# Quick start
open playground/playground.html
# Or serve locally
python3 -m http.server 8080 --directory playground
```
## Keyboard Shortcuts (Playground v3)
- A-; (lower row): Play piano notes
- Mouse drag on visualizer: Create sound
- Click piano keys: Play notes
## Technical
- Web Audio API for sound generation
- Canvas 2D for visualization
- MediaRecorder for recording
- No build step, no framework

243
playground/ambient.html Normal file
View File

@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ambient — Evolving Soundscape</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
canvas { display: block; width: 100%; height: 100%; }
.overlay {
position: fixed; inset: 0; display: flex; align-items: center;
justify-content: center; background: rgba(0,0,0,0.7);
transition: opacity 0.5s; z-index: 10;
}
.overlay.hidden { opacity: 0; pointer-events: none; }
.start-btn {
background: rgba(100,50,150,0.5); border: 2px solid rgba(150,100,200,0.7);
color: #e0e0e0; padding: 20px 40px; font-size: 18px; border-radius: 30px;
cursor: pointer; font-family: sans-serif; letter-spacing: 2px;
transition: all 0.3s;
}
.start-btn:hover { background: rgba(150,100,200,0.5); transform: scale(1.05); }
.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;
}
</style>
</head>
<body>
<div class="title">Ambient</div>
<canvas id="canvas"></canvas>
<div class="overlay" id="overlay">
<button class="start-btn" id="start-btn">Enter Soundscape</button>
</div>
<script>
class AmbientSoundscape {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.masterGain = null;
this.analyser = null;
this.isPlaying = false;
this.currentChord = 0;
this.time = 0;
this.orbs = [];
this.resize();
window.addEventListener('resize', () => this.resize());
// Create orbs
for (let i = 0; i < 15; i++) {
this.orbs.push({
x: Math.random() * this.canvas.width,
y: Math.random() * this.canvas.height,
radius: Math.random() * 50 + 20,
speed: Math.random() * 0.5 + 0.2,
angle: Math.random() * Math.PI * 2,
color: [
[167, 139, 250], [129, 140, 248], [99, 102, 241],
[139, 92, 246], [124, 58, 237]
][i % 5]
});
}
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.masterGain = this.audioCtx.createGain();
this.masterGain.gain.value = 0.2;
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.smoothingTimeConstant = 0.95;
this.masterGain.connect(this.analyser);
this.analyser.connect(this.audioCtx.destination);
// Create reverb
const convolver = this.audioCtx.createConvolver();
const reverbTime = 3;
const sampleRate = this.audioCtx.sampleRate;
const length = sampleRate * reverbTime;
const impulse = this.audioCtx.createBuffer(2, length, sampleRate);
for (let channel = 0; channel < 2; channel++) {
const channelData = impulse.getChannelData(channel);
for (let i = 0; i < length; i++) {
channelData[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2);
}
}
convolver.buffer = impulse;
convolver.connect(this.masterGain);
this.reverb = convolver;
}
playChord() {
if (!this.audioCtx || !this.isPlaying) return;
const progressions = [
[[261.63, 329.63, 392.00], [392.00, 493.88, 587.33],
[440.00, 523.25, 659.25], [349.23, 440.00, 523.25]],
[[220.00, 277.18, 329.63], [329.63, 415.30, 493.88],
[369.99, 466.16, 554.37], [293.66, 369.99, 440.00]]
];
const prog = progressions[this.currentChord % 2];
const chord = prog[Math.floor(this.time / 4) % prog.length];
chord.forEach((freq, i) => {
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
const filter = this.audioCtx.createBiquadFilter();
osc.type = 'sine';
osc.frequency.value = freq * (1 + (Math.random() - 0.5) * 0.01);
filter.type = 'lowpass';
filter.frequency.value = 800 + Math.sin(this.time * 0.1) * 400;
gain.gain.setValueAtTime(0, this.audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0.15, this.audioCtx.currentTime + 0.5);
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 4);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.reverb);
osc.start();
osc.stop(this.audioCtx.currentTime + 4);
});
// High texture
const highOsc = this.audioCtx.createOscillator();
const highGain = this.audioCtx.createGain();
highOsc.type = 'sine';
highOsc.frequency.value = chord[0] * 4 + Math.random() * 50;
highGain.gain.setValueAtTime(0, this.audioCtx.currentTime);
highGain.gain.linearRampToValueAtTime(0.03, this.audioCtx.currentTime + 1);
highGain.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + 5);
highOsc.connect(highGain);
highGain.connect(this.reverb);
highOsc.start();
highOsc.stop(this.audioCtx.currentTime + 5);
this.time += 4;
}
start() {
this.isPlaying = true;
document.getElementById('overlay').classList.add('hidden');
const loop = () => {
if (!this.isPlaying) return;
this.playChord();
setTimeout(loop, 4000);
};
loop();
}
stop() {
this.isPlaying = false;
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.03)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const freqData = this.analyser ? new Uint8Array(this.analyser.frequencyBinCount) : [];
if (this.analyser) this.analyser.getByteFrequencyData(freqData);
const energy = freqData.length > 0 ? freqData[10] / 255 : 0;
// Move and draw orbs
this.orbs.forEach((orb, i) => {
orb.angle += orb.speed * 0.01;
orb.x += Math.cos(orb.angle) * (1 + energy * 3);
orb.y += Math.sin(orb.angle * 0.7) * (1 + energy * 3);
// Wrap around
if (orb.x < -orb.radius) orb.x = this.canvas.width + orb.radius;
if (orb.x > this.canvas.width + orb.radius) orb.x = -orb.radius;
if (orb.y < -orb.radius) orb.y = this.canvas.height + orb.radius;
if (orb.y > this.canvas.height + orb.radius) orb.y = -orb.radius;
// Draw glow
const gradient = this.ctx.createRadialGradient(
orb.x, orb.y, 0,
orb.x, orb.y, orb.radius * (1 + energy * 0.5)
);
gradient.addColorStop(0, `rgba(${orb.color.join(',')}, 0.3)`);
gradient.addColorStop(1, 'rgba(0,0,0,0)');
this.ctx.beginPath();
this.ctx.arc(orb.x, orb.y, orb.radius * (1 + energy * 0.5), 0, Math.PI * 2);
this.ctx.fillStyle = gradient;
this.ctx.fill();
// Inner glow
this.ctx.beginPath();
this.ctx.arc(orb.x, orb.y, orb.radius * 0.3, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(${orb.color.join(',')}, 0.5)`;
this.ctx.fill();
});
requestAnimationFrame(() => this.animate());
}
bindEvents() {
document.getElementById('start-btn').addEventListener('click', async () => {
await this.initAudio();
this.start();
});
document.addEventListener('click', async () => {
if (!this.audioCtx) {
await this.initAudio();
}
}, { once: true });
}
}
new AmbientSoundscape();
</script>
</body>
</html>

294
playground/interactive.html Normal file
View File

@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive — 26 Key Shapes</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;
}
.key-hint {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.3); font-size: 14px; font-family: monospace;
pointer-events: none;
}
</style>
</head>
<body>
<div class="title">Interactive</div>
<div class="key-hint">Press A-Z to play</div>
<canvas id="canvas"></canvas>
<script>
class Interactive {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.analyser = null;
this.shapes = [];
this.activeKeys = new Set();
this.resize();
window.addEventListener('resize', () => this.resize());
// 26 key mappings with unique shapes and frequencies
this.keyMap = {
'a': { freq: 261.63, shape: 'circle', color: [255,100,100] },
'b': { freq: 277.18, shape: 'square', color: [255,150,100] },
'c': { freq: 293.66, shape: 'triangle', color: [255,200,100] },
'd': { freq: 311.13, shape: 'diamond', color: [255,255,100] },
'e': { freq: 329.63, shape: 'star', color: [200,255,100] },
'f': { freq: 349.23, shape: 'hexagon', color: [150,255,100] },
'g': { freq: 369.99, shape: 'cross', color: [100,255,100] },
'h': { freq: 392.00, shape: 'circle', color: [100,255,150] },
'i': { freq: 415.30, shape: 'square', color: [100,255,200] },
'j': { freq: 440.00, shape: 'triangle', color: [100,255,255] },
'k': { freq: 466.16, shape: 'diamond', color: [100,200,255] },
'l': { freq: 493.88, shape: 'star', color: [100,150,255] },
'm': { freq: 523.25, shape: 'hexagon', color: [100,100,255] },
'n': { freq: 554.37, shape: 'cross', color: [150,100,255] },
'o': { freq: 587.33, shape: 'circle', color: [200,100,255] },
'p': { freq: 622.25, shape: 'square', color: [255,100,255] },
'q': { freq: 659.25, shape: 'triangle', color: [255,100,200] },
'r': { freq: 698.46, shape: 'diamond', color: [255,100,150] },
's': { freq: 739.99, shape: 'star', color: [255,120,120] },
't': { freq: 783.99, shape: 'hexagon', color: [255,170,120] },
'u': { freq: 830.61, shape: 'cross', color: [255,220,120] },
'v': { freq: 880.00, shape: 'circle', color: [220,255,120] },
'w': { freq: 932.33, shape: 'square', color: [170,255,120] },
'x': { freq: 987.77, shape: 'triangle', color: [120,255,120] },
'y': { freq: 1046.50, shape: 'diamond', color: [120,255,170] },
'z': { freq: 1108.73, shape: 'star', color: [120,255,220] }
};
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 = 256;
this.analyser.connect(this.audioCtx.destination);
}
playKey(key) {
if (!this.audioCtx || !this.keyMap[key]) return;
const { freq, shape, color } = this.keyMap[key];
// Play sound
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.3, this.audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 1);
osc.connect(gain);
gain.connect(this.analyser);
osc.start();
osc.stop(this.audioCtx.currentTime + 1);
// Create shape
const x = Math.random() * (this.canvas.width - 200) + 100;
const y = Math.random() * (this.canvas.height - 200) + 100;
this.shapes.push({
key, x, y, shape, color,
size: 50,
maxSize: 150,
life: 1,
rotation: 0
});
}
drawShape(shape) {
const { x, y, size, rotation, shape: shapeType, color, life } = shape;
this.ctx.save();
this.ctx.translate(x, y);
this.ctx.rotate(rotation);
this.ctx.globalAlpha = life;
this.ctx.strokeStyle = `rgb(${color.join(',')})`;
this.ctx.lineWidth = 3;
this.ctx.fillStyle = `rgba(${color.join(',')}, 0.2)`;
this.ctx.beginPath();
switch (shapeType) {
case 'circle':
this.ctx.arc(0, 0, size, 0, Math.PI * 2);
break;
case 'square':
this.ctx.rect(-size, -size, size * 2, size * 2);
break;
case 'triangle':
this.ctx.moveTo(0, -size);
this.ctx.lineTo(size * 0.866, size * 0.5);
this.ctx.lineTo(-size * 0.866, size * 0.5);
this.ctx.closePath();
break;
case 'diamond':
this.ctx.moveTo(0, -size);
this.ctx.lineTo(size * 0.7, 0);
this.ctx.lineTo(0, size);
this.ctx.lineTo(-size * 0.7, 0);
this.ctx.closePath();
break;
case 'star':
for (let i = 0; i < 5; i++) {
const angle = (i * 4 * Math.PI) / 5 - Math.PI / 2;
const method = i === 0 ? 'moveTo' : 'lineTo';
this.ctx[method](Math.cos(angle) * size, Math.sin(angle) * size);
}
this.ctx.closePath();
break;
case 'hexagon':
for (let i = 0; i < 6; i++) {
const angle = (i * Math.PI) / 3;
const method = i === 0 ? 'moveTo' : 'lineTo';
this.ctx[method](Math.cos(angle) * size, Math.sin(angle) * size);
}
this.ctx.closePath();
break;
case 'cross':
const w = size * 0.3;
this.ctx.moveTo(-w, -size);
this.ctx.lineTo(w, -size);
this.ctx.lineTo(w, -w);
this.ctx.lineTo(size, -w);
this.ctx.lineTo(size, w);
this.ctx.lineTo(w, w);
this.ctx.lineTo(w, size);
this.ctx.lineTo(-w, size);
this.ctx.lineTo(-w, w);
this.ctx.lineTo(-size, w);
this.ctx.lineTo(-size, -w);
this.ctx.lineTo(-w, -w);
this.ctx.closePath();
break;
}
this.ctx.fill();
this.ctx.stroke();
// Draw key label
this.ctx.fillStyle = `rgba(${color.join(',')}, ${life})`;
this.ctx.font = `${size * 0.4}px monospace`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(shape.key.toUpperCase(), 0, 0);
this.ctx.restore();
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.1)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Update and draw shapes
this.shapes = this.shapes.filter(shape => {
shape.size += (shape.maxSize - shape.size) * 0.05;
shape.rotation += 0.02;
shape.life -= 0.01;
if (shape.life <= 0) return false;
this.drawShape(shape);
return true;
});
// Draw active key indicators
const keyWidth = this.canvas.width / 13;
const keyHeight = 40;
const startY = this.canvas.height - 60;
let col = 0;
for (const key of 'abcdefghijklmnopqrstuvwxyz') {
const x = col * keyWidth + keyWidth / 2;
const isActive = this.activeKeys.has(key);
this.ctx.fillStyle = isActive
? `rgba(${this.keyMap[key].color.join(',')}, 0.5)`
: 'rgba(255,255,255,0.05)';
this.ctx.beginPath();
this.ctx.roundRect(x - 15, startY, 30, keyHeight, 5);
this.ctx.fill();
this.ctx.fillStyle = isActive ? '#fff' : 'rgba(255,255,255,0.3)';
this.ctx.font = '12px monospace';
this.ctx.textAlign = 'center';
this.ctx.fillText(key.toUpperCase(), x, startY + 25);
col++;
}
requestAnimationFrame(() => this.animate());
}
bindEvents() {
document.addEventListener('keydown', async (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (this.keyMap[key] && !this.activeKeys.has(key)) {
await this.initAudio();
this.activeKeys.add(key);
this.playKey(key);
}
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
this.activeKeys.delete(key);
});
// Touch support
this.canvas.addEventListener('touchstart', async (e) => {
e.preventDefault();
await this.initAudio();
// Map touch area to key
for (const touch of e.changedTouches) {
const keyIndex = Math.floor(touch.clientX / (this.canvas.width / 26));
const key = 'abcdefghijklmnopqrstuvwxyz'[keyIndex];
if (key) {
this.activeKeys.add(key);
this.playKey(key);
}
}
});
this.canvas.addEventListener('touchend', (e) => {
e.preventDefault();
this.activeKeys.clear();
});
}
}
new Interactive();
</script>
</body>
</html>

1216
playground/playground.html Normal file

File diff suppressed because it is too large Load Diff

198
playground/synesthesia.html Normal file
View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synesthesia — Paint with Sound</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; cursor: crosshair; }
canvas { display: block; width: 100%; height: 100%; }
.title {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.15); font-size: 24px; font-family: sans-serif;
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
}
</style>
</head>
<body>
<div class="title">Synesthesia</div>
<canvas id="canvas"></canvas>
<script>
class Synesthesia {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.analyser = null;
this.particles = [];
this.trails = [];
this.mouse = { x: 0, y: 0, down: false };
this.lastNote = null;
this.colors = [
[255, 100, 150], [100, 200, 255], [150, 255, 150],
[255, 200, 100], [200, 150, 255]
];
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 AudioContext();
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.connect(this.audioCtx.destination);
}
playTone(x, y) {
if (!this.audioCtx) return;
const freq = 200 + (x / this.canvas.width) * 600;
const noteId = Math.round(freq / 20);
if (noteId === this.lastNote) return;
this.lastNote = noteId;
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.2, this.audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 0.5);
osc.connect(gain);
gain.connect(this.analyser);
osc.start();
osc.stop(this.audioCtx.currentTime + 0.5);
// Spawn particles
const color = this.colors[Math.floor(Math.random() * this.colors.length)];
for (let i = 0; i < 8; i++) {
this.particles.push({
x, y,
vx: (Math.random() - 0.5) * 8,
vy: (Math.random() - 0.5) * 8,
life: 1,
color,
size: Math.random() * 10 + 5
});
}
// Add trail
this.trails.push({ x, y, color, size: 30, alpha: 0.5 });
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.05)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Draw trails
this.trails = this.trails.filter(t => {
t.alpha -= 0.005;
if (t.alpha <= 0) return false;
this.ctx.beginPath();
this.ctx.arc(t.x, t.y, t.size, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(${t.color.join(',')}, ${t.alpha})`;
this.ctx.fill();
return true;
});
// Draw particles
this.particles = this.particles.filter(p => {
p.x += p.vx;
p.y += p.vy;
p.life -= 0.02;
p.vy += 0.1;
if (p.life <= 0) return false;
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(${p.color.join(',')}, ${p.life * 0.8})`;
this.ctx.fill();
return true;
});
// Draw mouse trail
if (this.mouse.down) {
this.ctx.beginPath();
this.ctx.arc(this.mouse.x, this.mouse.y, 20, 0, Math.PI * 2);
const color = this.colors[Date.now() % 5];
this.ctx.fillStyle = `rgba(${color.join(',')}, 0.3)`;
this.ctx.fill();
}
requestAnimationFrame(() => this.animate());
}
bindEvents() {
this.canvas.addEventListener('mousedown', async (e) => {
await this.initAudio();
this.mouse.down = true;
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
this.playTone(e.clientX, e.clientY);
});
this.canvas.addEventListener('mousemove', (e) => {
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
if (this.mouse.down) {
this.playTone(e.clientX, e.clientY);
}
});
this.canvas.addEventListener('mouseup', () => {
this.mouse.down = false;
this.lastNote = null;
});
this.canvas.addEventListener('mouseleave', () => {
this.mouse.down = false;
this.lastNote = null;
});
this.canvas.addEventListener('touchstart', async (e) => {
e.preventDefault();
await this.initAudio();
this.mouse.down = true;
const touch = e.touches[0];
this.mouse.x = touch.clientX;
this.mouse.y = touch.clientY;
this.playTone(touch.clientX, touch.clientY);
});
this.canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
this.mouse.x = touch.clientX;
this.mouse.y = touch.clientY;
if (this.mouse.down) {
this.playTone(touch.clientX, touch.clientY);
}
});
this.canvas.addEventListener('touchend', () => {
this.mouse.down = false;
this.lastNote = null;
});
}
}
new Synesthesia();
</script>
</body>
</html>

371
playground/visualizer.html Normal file
View File

@@ -0,0 +1,371 @@
<!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>