airwaves / web /main.js
AndresCarreon's picture
AIRWAVES v0 — air-DJ (MediaPipe + Web Audio) + VoxCPM2 hype-man
860eb59 verified
Raw
History Blame Contribute Delete
5.24 kB
// 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";
}