ChiralSynth / index.html
Aluode's picture
Update index.html
a35fa26 verified
Raw
History Blame Contribute Delete
16.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chiral Synth | Powered by Geometric Neuron V5</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Major+Mono+Display&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a12; --pan: #12121e; --line: #23233a;
--cyan: #2ec5ff; --magenta: #ff3b6b; --txt: #e7e7f5; --dim: #6b6b85; --green: #42f5a1;
--font: "IBM Plex Mono", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 20px; background: var(--bg); color: var(--txt);
font-family: var(--font); display: flex; flex-direction: column; align-items: center;
overflow-x: hidden;
}
header { text-align: center; margin-bottom: 20px; max-width: 900px; }
h1 { font-family: "Major Mono Display", monospace; color: var(--cyan); margin: 0 0 10px 0; font-size: 2.5rem; text-shadow: 0 0 10px rgba(46,197,255,0.3); }
.subtitle { color: var(--green); font-size: 0.9rem; letter-spacing: 1px; margin-bottom: 15px; }
.description { color: var(--dim); font-size: 0.85rem; line-height: 1.5; }
.description a { color: var(--magenta); text-decoration: none; }
.description a:hover { text-decoration: underline; }
.layout { display: flex; gap: 20px; max-width: 1200px; width: 100%; align-items: flex-start; flex-wrap: wrap; }
.video-wrapper { position: relative; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #000; box-shadow: 0 0 20px rgba(0,0,0,0.5); }
video { display: none; } /* Hidden, we draw to canvas */
canvas { display: block; cursor: crosshair; }
/* INITIALIZE OVERLAY (Fixes Audio Context Security) */
#startOverlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(10, 10, 18, 0.85); display: flex; flex-direction: column;
justify-content: center; align-items: center; z-index: 10; backdrop-filter: blur(4px);
}
#btnStart {
background: transparent; color: var(--cyan); border: 2px solid var(--cyan);
padding: 15px 30px; font-family: var(--font); font-weight: bold; font-size: 1.2rem;
border-radius: 4px; cursor: pointer; transition: 0.2s; text-transform: uppercase;
box-shadow: 0 0 15px rgba(46,197,255,0.2);
}
#btnStart:hover { background: var(--cyan); color: #000; box-shadow: 0 0 25px rgba(46,197,255,0.6); }
.sidebar { flex: 1; min-width: 300px; display: flex; flex-direction: column; gap: 15px; }
.controls-header { display: flex; justify-content: space-between; align-items: center; }
.btn-action {
background: var(--pan); color: var(--txt); border: 1px solid var(--line);
padding: 8px 12px; font-family: var(--font); cursor: pointer; border-radius: 4px; transition: 0.2s;
}
.btn-action:hover { border-color: var(--magenta); color: var(--magenta); }
.synth-panel { background: var(--pan); border: 1px solid var(--line); border-radius: 8px; padding: 15px; transition: 0.2s; }
.synth-panel h3 { margin: 0 0 10px 0; font-size: 0.9rem; text-transform: uppercase; display: flex; justify-content: space-between; }
.remove-btn { color: var(--magenta); cursor: pointer; font-size: 0.8rem; background: none; border: none; font-family: var(--font); }
.slider-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.slider-row label { width: 60px; font-size: 0.75rem; color: var(--dim); }
input[type=range] { flex: 1; margin: 0 10px; cursor: pointer; accent-color: var(--cyan); }
.tech-note {
margin-top: 20px; padding: 15px; border-left: 3px solid var(--green); background: var(--pan);
font-size: 0.8rem; color: var(--dim); line-height: 1.5;
}
.tech-note strong { color: var(--txt); }
</style>
</head>
<body>
<header>
<h1>CHIRAL SYNTH</h1>
<div class="subtitle">Real-time Phase Velocity via Koopman Angular Momentum</div>
<div class="description">
Draw bounding boxes on the camera feed and wave your hands. Instead of heavy OpenCV optical flow, this uses the
<a href="https://github.com/anttiluode/GeometricNeuronV5" target="_blank">Geometric Neuron V5</a> math: collapsing frames into 1D spatial vectors and applying a bilinear cross-time product <code>L = Im(z(t) · z*(t-lag))</code>.
It computes direction instantly, scaling pitch to phase velocity.
</div>
</header>
<div class="layout">
<!-- VIDEO & CANVAS -->
<div class="video-wrapper">
<div id="startOverlay">
<button id="btnStart">Initialize System</button>
<p style="margin-top: 15px; font-size: 0.8rem; color: var(--dim);">*Requires camera access and Web Audio API</p>
</div>
<video id="video" width="640" height="480" autoplay playsinline muted></video>
<canvas id="canvas" width="640" height="480"></canvas>
</div>
<!-- CONTROLS -->
<div class="sidebar">
<div class="controls-header">
<div style="font-size: 0.9rem; color: var(--cyan);">ACTIVE ZONES (<span id="boxCount">2</span>)</div>
<button class="btn-action" id="btnClear">Clear All Boxes</button>
</div>
<div style="font-size: 0.75rem; color: var(--dim); margin-bottom: 10px;">
Draw a box on the video feed to create a new synth zone.
</div>
<div id="panelsContainer">
<!-- Synth panels injected here by JS -->
</div>
<div class="tech-note">
<strong>Why is this faster than OpenCV?</strong><br>
Standard dense optical flow solves polynomial equations for every pixel (O(N*M)).
Chiral Synth averages activity into vertical columns, creating a 1D complex observable. It then calculates the phase rotation between frames using pure arithmetic. It tracks movement direction at a fraction of the computational cost.
</div>
</div>
</div>
<script>
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const panelsContainer = document.getElementById('panelsContainer');
const btnStart = document.getElementById('btnStart');
let audioCtx;
let isRunning = false;
let prevFrame = null;
// App State
let boxes = [];
let boxIdCounter = 0;
// Drawing state
let isDrawing = false;
let startX, startY;
const COLORS = ["#2ec5ff", "#ff3b6b", "#42f5a1", "#f5c542", "#a98bff"];
const BUFFER_LEN = 10;
// --- AUDIO SYNTHESIS CLASS ---
class SynthZone {
constructor(id, color) {
this.id = id;
this.color = color;
// Setup Web Audio
this.sine = audioCtx.createOscillator();
this.saw = audioCtx.createOscillator();
this.sq = audioCtx.createOscillator();
this.gSine = audioCtx.createGain();
this.gSaw = audioCtx.createGain();
this.gSq = audioCtx.createGain();
this.master = audioCtx.createGain();
this.filter = audioCtx.createBiquadFilter();
this.sine.type = 'sine'; this.saw.type = 'sawtooth'; this.sq.type = 'square';
this.filter.type = 'lowpass'; this.filter.frequency.value = 1000;
this.master.gain.value = 0; // Start silenced
this.sine.connect(this.gSine); this.saw.connect(this.gSaw); this.sq.connect(this.gSq);
this.gSine.connect(this.filter); this.gSaw.connect(this.filter); this.gSq.connect(this.filter);
this.filter.connect(this.master); this.master.connect(audioCtx.destination);
this.sine.start(); this.saw.start(); this.sq.start();
// UI Parameters (defaults)
this.params = { sine: 0.8, saw: 0.2, sq: 0.0 };
// GNv5 Math Buffers
this.history = [];
this.intensityHistory = [];
}
updateMix() {
this.gSine.gain.value = this.params.sine;
this.gSaw.gain.value = this.params.saw;
this.gSq.gain.value = this.params.sq;
}
destroy() {
this.master.gain.setTargetAtTime(0, audioCtx.currentTime, 0.05);
setTimeout(() => {
this.sine.stop(); this.saw.stop(); this.sq.stop();
this.sine.disconnect(); this.saw.disconnect(); this.sq.disconnect();
this.filter.disconnect(); this.master.disconnect();
}, 500);
}
}
// --- INITIALIZATION ---
btnStart.addEventListener('click', async () => {
if (isRunning) return;
// Resume/Create AudioContext on user gesture (fixes security warning)
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if(audioCtx.state === 'suspended') await audioCtx.resume();
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } });
video.srcObject = stream;
document.getElementById('startOverlay').style.display = 'none';
isRunning = true;
// Create Default Boxes
addBox(50, 100, 200, 300);
addBox(390, 100, 200, 300);
requestAnimationFrame(processVideo);
} catch (err) {
alert("Camera access denied or unavailable.");
}
});
// --- UI CONTROLS ---
function addBox(x, y, w, h) {
const id = boxIdCounter++;
const color = COLORS[id % COLORS.length];
const synth = new SynthZone(id, color);
boxes.push({ id, x, y, w, h, synth, color });
renderUI();
return id;
}
function removeBox(id) {
const idx = boxes.findIndex(b => b.id === id);
if (idx > -1) {
boxes[idx].synth.destroy();
boxes.splice(idx, 1);
renderUI();
}
}
document.getElementById('btnClear').addEventListener('click', () => {
boxes.forEach(b => b.synth.destroy());
boxes = [];
renderUI();
});
function renderUI() {
document.getElementById('boxCount').innerText = boxes.length;
panelsContainer.innerHTML = '';
boxes.forEach(box => {
const p = document.createElement('div');
p.className = 'synth-panel';
p.style.borderTop = `3px solid ${box.color}`;
p.innerHTML = `
<h3>ZONE ${box.id + 1}
<button class="remove-btn" onclick="removeBox(${box.id})">[X]</button>
</h3>
<div class="slider-row"><label>Sine</label><input type="range" min="0" max="1" step="0.01" value="${box.synth.params.sine}" oninput="updateParam(${box.id}, 'sine', this.value)"></div>
<div class="slider-row"><label>Saw</label><input type="range" min="0" max="1" step="0.01" value="${box.synth.params.saw}" oninput="updateParam(${box.id}, 'saw', this.value)"></div>
<div class="slider-row"><label>Square</label><input type="range" min="0" max="1" step="0.01" value="${box.synth.params.sq}" oninput="updateParam(${box.id}, 'sq', this.value)"></div>
`;
panelsContainer.appendChild(p);
box.synth.updateMix();
});
}
window.updateParam = function(id, param, val) {
const box = boxes.find(b => b.id === id);
if (box) { box.synth.params[param] = parseFloat(val); box.synth.updateMix(); }
}
// --- DRAWING LOGIC ---
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
startX = e.clientX - rect.left; startY = e.clientY - rect.top;
isDrawing = true;
});
canvas.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
const currX = e.clientX - rect.left; const currY = e.clientY - rect.top;
// Draw temporary rectangle
ctx.putImageData(prevFrame, 0, 0); // Restore frame
ctx.strokeStyle = "#fff"; ctx.setLineDash([5, 5]); ctx.strokeRect(startX, startY, currX - startX, currY - startY); ctx.setLineDash([]);
});
canvas.addEventListener('mouseup', (e) => {
if (!isDrawing) return;
isDrawing = false;
const rect = canvas.getBoundingClientRect();
const endX = e.clientX - rect.left; const endY = e.clientY - rect.top;
const w = Math.abs(endX - startX); const h = Math.abs(endY - startY);
if (w > 20 && h > 20) addBox(Math.min(startX, endX), Math.min(startY, endY), w, h);
});
// --- THE CORE GNv5 ENGINE ---
function processVideo() {
if (video.readyState !== video.HAVE_ENOUGH_DATA) {
requestAnimationFrame(processVideo); return;
}
ctx.save();
ctx.scale(-1, 1); // Mirror horizontally
ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height);
ctx.restore();
const currFrame = ctx.getImageData(0, 0, canvas.width, canvas.height);
if (prevFrame && !isDrawing) {
for (let i = 0; i < boxes.length; i++) {
analyzeGN5(boxes[i], currFrame.data, prevFrame.data);
}
}
// Draw Box overlays
if (!isDrawing) {
boxes.forEach(box => {
ctx.strokeStyle = box.color; ctx.lineWidth = 2; ctx.strokeRect(box.x, box.y, box.w, box.h);
ctx.fillStyle = box.color; ctx.font = "12px monospace";
ctx.fillText(`Z${box.id+1}`, box.x + 4, box.y + 14);
});
}
prevFrame = currFrame;
requestAnimationFrame(processVideo);
}
function analyzeGN5(box, curr, prev) {
const synth = box.synth;
let motionSum = 0, sumY = 0, activePixels = 0;
const step = 4; // Downsampling for speed
const left = Math.floor(box.x), right = Math.floor(box.x + box.w);
const top = Math.floor(box.y), bottom = Math.floor(box.y + box.h);
const colCount = Math.floor((right - left) / step);
let currentCols = new Float32Array(colCount);
// 1. Extract differences and project to 1D Spatial Vector (Columns)
for (let i = 0; i < colCount; i++) {
let colSum = 0, count = 0;
const x = left + i * step;
for (let y = top; y < bottom; y += step) {
const idx = (y * canvas.width + x) * 4;
const bright = (curr[idx] + curr[idx+1] + curr[idx+2]) / 3;
const pbright = (prev[idx] + prev[idx+1] + prev[idx+2]) / 3;
const diff = Math.abs(bright - pbright);
if (diff > 15) { // Noise gate
colSum += bright; count++;
motionSum += diff; sumY += y; activePixels++;
}
}
currentCols[i] = count > 0 ? colSum / count / 255 : 0;
}
// Temporal Buffering
synth.history.push(currentCols);
synth.intensityHistory.push(motionSum);
if (synth.history.length > BUFFER_LEN) {
synth.history.shift();
synth.intensityHistory.shift();
}
if (synth.history.length < 4) return;
// Activity Gate (Solves the "Stuck Sound" issue)
const activity = Math.min(1.0, motionSum / ((box.w * box.h) * 0.5));
if (activity < 0.05) {
synth.master.gain.setTargetAtTime(0, audioCtx.currentTime, 0.1); // Fast fade out
return;
}
// 2. The GNv5 Koopman Angular Momentum (Chirality)
const lag = 2; // Frame lag
const T = synth.history.length;
const currVector = synth.history[T - 1];
const lagVector = synth.history[T - 1 - lag];
let Lsum = 0;
// Cross-time product across the spatial edge (k to k+1)
// L_k = Im( z_k(t) * conj(z_k(t-lag)) )
for (let c = 0; c < colCount - 1; c++) {
const cross = (currVector[c] * lagVector[c+1]) - (currVector[c+1] * lagVector[c]);
Lsum += cross;
}
const chiralL = Lsum / (colCount - 1);
const phaseVelocity = chiralL * 40; // Scaling factor for audible pitch bend
// 3. Audio Mapping
// Base pitch from centroid Y position
const avgY = sumY / (activePixels || 1);
const normY = 1.0 - ((avgY - box.y) / box.h); // 0 to 1 (bottom to top)
const baseFreq = 150 + (normY * 800);
// Chiral bend = frequency modulation based on left/right movement direction
const finalFreq = Math.max(50, baseFreq + (phaseVelocity * 100));
// Smooth envelope and pitch targets
synth.sine.frequency.setTargetAtTime(finalFreq, audioCtx.currentTime, 0.05);
synth.saw.frequency.setTargetAtTime(finalFreq, audioCtx.currentTime, 0.05);
synth.sq.frequency.setTargetAtTime(finalFreq, audioCtx.currentTime, 0.05);
const vol = Math.min(0.8, activity * 2.0); // Compress dynamic range
synth.master.gain.setTargetAtTime(vol, audioCtx.currentTime, 0.05);
// Map coherence/speed to filter cutoff
const filterFreq = 600 + (activity * 3000) + (Math.abs(chiralL) * 2000);
synth.filter.frequency.setTargetAtTime(filterFreq, audioCtx.currentTime, 0.08);
// Render diagnostic text
ctx.fillStyle = box.color; ctx.font = "12px monospace";
ctx.fillText(`L:${chiralL.toFixed(3)}`, box.x + 4, box.y + box.h - 6);
}
</script>
</body>
</html>