// 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"; }