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.
295 lines
12 KiB
HTML
295 lines
12 KiB
HTML
<!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>
|