Spaces:
Running
Running
| <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> |