Spaces:
Sleeping
Sleeping
| // AIRWAVES — wire the camera/hands to the audio engine, draw the spectrum + skeleton. | |
| import { HandEngine, drawHand } from "./hands.js"; | |
| import { controls } from "./gestures.js"; | |
| import { AudioEngine } from "./engine.js"; | |
| import { HypeMan } from "./hype.js"; | |
| import { ema, clamp } from "./math.js"; | |
| const $ = (id) => document.getElementById(id); | |
| const video = $("cam"), canvas = $("draw"), ctx = canvas.getContext("2d"); | |
| const audio = new AudioEngine(); | |
| const hand = new HandEngine(2); | |
| let hype = null; | |
| // smoothed control state (EMA kills jitter before it reaches the audio) | |
| const sm = { height: 0, pinch: 0, fist: 0, roll: 0.5, xfade: 0 }; | |
| const A = 0.55; | |
| let prevFist = 0, prevHeight = 0, hadTwo = false, dropFlash = 0, freq = new Uint8Array(128); | |
| $("go").addEventListener("click", async () => { | |
| $("go").textContent = "loading the deck…"; $("go").disabled = true; | |
| try { | |
| await audio.init(); // renders the stems (~1s) | |
| audio.play(); | |
| hype = new HypeMan(audio); | |
| hype.load(); // VoxCPM2 bank fills in the background (non-blocking) | |
| $("start").classList.add("hidden"); | |
| $("session").classList.remove("hidden"); | |
| await hand.start(video, onFrame); | |
| $("badge").textContent = "tracking · " + (hand.delegate || ""); | |
| setTimeout(() => hype && hype.fire("hello", { cooldown: 0, duck: 0.45 }), 900); | |
| } catch (e) { | |
| $("go").disabled = false; $("go").textContent = "Start the deck"; | |
| $("hint").textContent = "⚠ " + (e && e.message ? e.message : "camera/audio blocked — allow the camera and try again"); | |
| } | |
| }); | |
| // input source: built-in beat (full control) vs a captured browser tab | |
| $("srcBeat").addEventListener("click", () => { if (audio.ready) { audio.useBeat(); setSrc("beat"); } }); | |
| $("srcTab").addEventListener("click", async () => { | |
| if (!audio.ready) return; | |
| $("srcNote").textContent = "pick a Chrome tab + tick “Share tab audio”…"; | |
| try { await audio.captureTab(); setSrc("stream"); } | |
| catch (e) { setSrc("beat"); $("srcNote").textContent = "⚠ " + (e && e.message ? e.message : "couldn’t capture the tab"); } | |
| }); | |
| function setSrc(mode) { | |
| $("srcBeat").classList.toggle("on", mode === "beat"); | |
| $("srcTab").classList.toggle("on", mode === "stream"); | |
| $("srcNote").textContent = mode === "stream" | |
| ? "bending the tab — filter · reverb · echo · stutter · volume (speed n/a on live audio)" | |
| : ""; | |
| } | |
| function onFrame(hands, now, fps) { | |
| const vw = video.videoWidth || 960, vh = video.videoHeight || 720; | |
| if (canvas.width !== vw) { canvas.width = vw; canvas.height = vh; } | |
| drawSpectrum(vw, vh); | |
| // primary hand drives tone/time/space/stutter; a 2nd hand crossfades the mix. | |
| const primary = hands[0]?.lm || null; | |
| const second = hands[1]?.lm || null; | |
| if (primary) { | |
| const c = controls(primary); | |
| sm.height = ema(sm.height, c.height, A); | |
| sm.pinch = ema(sm.pinch, c.pinch, A); | |
| sm.fist = ema(sm.fist, c.fist, A); | |
| sm.roll = ema(sm.roll, c.roll, A); | |
| audio.setFilter(sm.height); | |
| audio.setSpace(sm.pinch); | |
| audio.setWarp(sm.roll); | |
| audio.setStutter(sm.fist); | |
| if (prevFist > 0.55 && sm.fist < 0.25) { audio.drop(); dropFlash = 1; hype && hype.fire("drop", { cooldown: 3, duck: 1 }); } | |
| if (hype && sm.fist > 0.62) hype.fire("build", { cooldown: 7, duck: 0.45 }); | |
| if (hype && prevHeight < 0.55 && sm.height > 0.82) hype.fire("sweep", { cooldown: 9, duck: 0.5 }); | |
| prevFist = sm.fist; prevHeight = sm.height; | |
| drawHand(ctx, primary, vw, vh, "rgba(80,220,255,"); | |
| } else { | |
| audio.setStutter(0); prevFist = 0; | |
| } | |
| if (second) { | |
| const c2 = controls(second); | |
| sm.xfade = ema(sm.xfade, c2.height, A); | |
| audio.crossfade(sm.xfade); | |
| if (hype && !hadTwo) hype.fire("mix", { cooldown: 9, duck: 0.5 }); | |
| hadTwo = true; | |
| drawHand(ctx, second, vw, vh, "rgba(255,90,170,"); | |
| } else { | |
| hadTwo = false; | |
| } | |
| if (dropFlash > 0) { drawFlash(vw, vh, dropFlash); dropFlash = Math.max(0, dropFlash - 0.06); } | |
| updateHUD(fps, !!primary, !!second); | |
| } | |
| function drawSpectrum(w, h) { | |
| ctx.clearRect(0, 0, w, h); | |
| if (!audio.analyser) return; | |
| audio.analyser.getByteFrequencyData(freq); | |
| const n = freq.length, bw = w / n; | |
| let bass = 0; for (let i = 0; i < 12; i++) bass += freq[i]; bass /= 12 * 255; | |
| for (let i = 0; i < n; i++) { | |
| const v = freq[i] / 255, bh = v * h * 0.7; | |
| const hue = 185 + (i / n) * 130; // cyan -> magenta | |
| ctx.fillStyle = `hsla(${hue},90%,${45 + v * 25}%,0.85)`; | |
| ctx.shadowColor = `hsla(${hue},90%,60%,0.9)`; ctx.shadowBlur = 8 + bass * 26; | |
| ctx.fillRect(i * bw, h - bh, bw * 0.82, bh); | |
| } | |
| ctx.shadowBlur = 0; | |
| } | |
| function drawFlash(w, h, a) { | |
| ctx.fillStyle = `rgba(255,255,255,${a * 0.5})`; | |
| ctx.fillRect(0, 0, w, h); | |
| } | |
| const meter = (id, v) => { const el = $(id); if (el) el.style.setProperty("--v", clamp(v, 0, 1)); }; | |
| function updateHUD(fps, hasHand, hasTwo) { | |
| meter("mHeight", sm.height); meter("mSpace", sm.pinch); | |
| meter("mWarp", Math.abs(sm.roll - 0.5) * 2); meter("mFist", sm.fist); | |
| $("fps").textContent = fps ? fps.toFixed(0) + " fps" : "…"; | |
| $("state").textContent = !hasHand ? "raise a hand ✋" | |
| : sm.fist > 0.55 ? "BUILDING…" : hasTwo ? "two decks 🎚" : "live"; | |
| } | |