motionjam / index.html
linoyts's picture
linoyts HF Staff
rename to MotionJam
d4fbd00 verified
Raw
History Blame Contribute Delete
26.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MotionJam</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Shrikhand&family=Righteous&family=Rubik:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg1:#2a1607; --bg2:#3e1c10;
--cream:#f2e6cb; --cream2:#e7d6b2; --ink:#3a2414;
--melody:#e8662a; --melody2:#f2a73c; --groove:#2fa68f; --groove2:#5fc9b3;
--rust:#b5371f; --gold:#f2a73c;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:radial-gradient(80% 90% at 50% 30%,var(--bg2),var(--bg1) 70%,#1a0d04);
color:var(--cream);font-family:"Rubik",ui-sans-serif,system-ui,sans-serif;overflow:hidden}
#stage{position:fixed;inset:0}
video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
transform:scaleX(-1);filter:saturate(.5) sepia(.35) brightness(.42);opacity:.32}
canvas{position:absolute;inset:0;width:100%;height:100%}
#stage::after{content:"";position:absolute;inset:0;pointer-events:none;z-index:3;
background:radial-gradient(120% 100% at 50% 50%,transparent 55%,rgba(26,13,4,.65) 100%)}
#title{position:absolute;top:16px;left:24px;z-index:6;pointer-events:none}
#title h1{margin:0;font-family:"Shrikhand",serif;font-size:32px;line-height:1;letter-spacing:.5px;
color:var(--gold);text-shadow:2px 3px 0 var(--rust),4px 6px 0 rgba(0,0,0,.25)}
#title h1 span{color:var(--melody)}
#title p{margin:7px 0 0;font-size:12px;color:var(--cream2);font-family:"Righteous";letter-spacing:.6px}
#title p b{color:var(--gold)}
#gate{position:absolute;inset:0;z-index:20;display:flex;flex-direction:column;
align-items:center;justify-content:center;text-align:center;
background:radial-gradient(70% 70% at 50% 38%,#5a2a14 0%,#2a1607 70%,#150a03)}
#gate h2{font-family:"Shrikhand";font-size:62px;margin:0;color:var(--gold);line-height:.95;
text-shadow:3px 4px 0 var(--rust),6px 8px 0 rgba(0,0,0,.3)}
#gate h2 span{color:var(--melody)}
#gate .sub{font-family:"Righteous";color:var(--melody2);letter-spacing:3px;margin:6px 0 20px;font-size:13px}
#gate .hands{display:flex;gap:22px;margin:0 0 22px;flex-wrap:wrap;justify-content:center;max-width:760px}
#gate .card{background:rgba(16,9,4,.5);border:2px solid #caa873;border-radius:16px;padding:16px 20px;
width:330px;text-align:left}
#gate .card h4{margin:0 0 8px;font-family:"Righteous";font-size:15px;letter-spacing:.6px}
#gate .card.r h4{color:var(--melody2)} #gate .card.l h4{color:var(--groove2)}
#gate .card ul{margin:0;padding-left:18px;line-height:1.7;font-size:13px;color:var(--cream2)}
#gate .card b{color:var(--gold)}
#go{margin-top:4px;border:0;border-radius:40px;padding:16px 40px;font-family:"Righteous";font-size:17px;
letter-spacing:1px;cursor:pointer;color:#3a1405;
background:linear-gradient(90deg,var(--gold),var(--melody) 55%,var(--rust));
box-shadow:0 10px 30px rgba(232,102,42,.45),inset 0 2px 0 rgba(255,255,255,.4)}
#go:active{transform:translateY(2px)}
#err{color:#ffb38a;margin-top:16px;font-size:13px;min-height:18px}
.hint{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);z-index:6;
font:600 12px "Righteous";letter-spacing:.5px;color:var(--melody2);text-align:center;line-height:1.7;
text-shadow:0 1px 2px rgba(0,0,0,.6);pointer-events:none;white-space:nowrap}
.hint b{color:var(--gold)}
.hint{white-space:normal;max-width:92vw}
#status{position:absolute;top:18px;left:50%;transform:translateX(-50%);z-index:7;display:none;
align-items:center;gap:8px;font:700 13px "Righteous";letter-spacing:1.2px;padding:8px 18px;border-radius:30px;
border:2px solid #caa873;background:linear-gradient(180deg,var(--cream),var(--cream2));color:var(--ink);
box-shadow:0 6px 20px rgba(0,0,0,.35)}
#status.obtaining{color:var(--rust);animation:pulse 1s infinite}
#status.live{color:#1c7d6a}
#status.ended{color:var(--rust)}
@keyframes pulse{50%{opacity:.45}}
#restart{position:absolute;inset:0;z-index:18;display:none;flex-direction:column;align-items:center;
justify-content:center;text-align:center;background:radial-gradient(60% 60% at 50% 45%,rgba(42,22,7,.86),rgba(21,10,3,.93))}
#restart h3{font-family:"Shrikhand";font-size:42px;color:var(--gold);margin:0 0 6px;text-shadow:2px 3px 0 var(--rust)}
#restart p{color:var(--cream2);font-size:14px;margin:0 0 20px;max-width:440px;line-height:1.5}
#restartBtn{border:0;border-radius:40px;padding:14px 36px;font-family:"Righteous";font-size:16px;letter-spacing:1px;
cursor:pointer;color:#3a1405;background:linear-gradient(90deg,var(--gold),var(--melody) 55%,var(--rust));
box-shadow:0 10px 30px rgba(232,102,42,.45)}
#restartBtn:active{transform:translateY(2px)}
</style>
</head>
<body>
<div id="stage">
<video id="cam" playsinline muted></video>
<canvas id="viz"></canvas>
<div id="title">
<h1>Motion<span>Jam</span></h1>
<p><b>BLEND</b> · each hand is a style — open ✋ to add it, pinch 🤏 to drop it</p>
</div>
<div id="status"></div>
<div class="hint">
LEFT & RIGHT hands are <b>two styles</b><b>open ✋</b> to bring a style up, <b>pinch 🤏</b> to fade it<br>
<b>✊ fist</b> swaps that hand's style · <b>✌️</b> changes the note · <b>👍</b> save / <b>👎</b> recall
</div>
<div id="restart">
<h3>session ended</h3>
<p>ZeroGPU time is metered to protect the shared quota. Jump back in whenever you're ready.</p>
<button id="restartBtn">▶ JAM AGAIN</button>
</div>
<div id="gate">
<h2>Motion<span>Jam</span></h2>
<div class="sub">★ PLAY MAGENTA RT WITH YOUR HANDS ★</div>
<div class="hands">
<div class="card r">
<h4>🎚 EACH HAND = A STYLE</h4>
<ul>
<li>Your <b>left &amp; right hands are two different styles</b>, mixed live</li>
<li><b>Open ✋</b> a hand to bring its style up; <b>pinch 🤏</b> to fade it out</li>
<li>Open both = full blend · pinch both = breakdown</li>
</ul>
</div>
<div class="card l">
<h4>✊ SWAP · ✌️ NOTE</h4>
<ul>
<li><b>✊ fist</b> a hand to <b>swap that hand's style</b> for the next one</li>
<li><b>✌️ victory</b> changes the note the music is built on</li>
<li><b>👍</b> save the loop · <b>👎</b> recall</li>
</ul>
</div>
</div>
<button id="go">▶ START JAMMING</button>
<div id="err"></div>
</div>
</div>
<script type="module">
import { GestureRecognizer, FilesetResolver }
from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.18";
const V = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.18/wasm";
const MODEL = "https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task";
const cam=document.getElementById("cam"), cv=document.getElementById("viz");
const ctx=cv.getContext("2d"), $=id=>document.getElementById(id);
const C={ melody:"232,102,42", melody2:"242,167,60", groove:"47,166,143", groove2:"95,201,179",
rust:"181,55,31", gold:"242,167,60", cream:"242,230,203" };
const STRIPES=["122,31,18","181,55,31","232,102,42","242,167,60","246,214,138"];
const BANDS=[
{name:"deep house", prompt:"deep house, four-on-the-floor kick, warm sub bass, hypnotic groove"},
{name:"lofi hip-hop", prompt:"lofi hip-hop, dusty mellow keys, laid-back boom-bap drums, vinyl crackle"},
{name:"drum & bass", prompt:"liquid drum and bass, fast breakbeats, deep rolling bassline"},
{name:"funk", prompt:"funky groove, slap bass, wah guitar, tight live drums"},
{name:"techno", prompt:"driving techno, pulsing analog synth, relentless kick"},
{name:"ambient", prompt:"warm ambient pads, slow evolving textures"},
{name:"jazz trio", prompt:"jazz trio, walking upright bass, brushed drums, soulful piano"},
];
// ---- the right hand plays a pentatonic: 5 notes (height) x 3 octave registers (horizontal) ----
const NOTE_NAMES=["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
const PENT=[0,3,5,7,10], ROOT=57; // A minor pentatonic, A3 = MIDI 57
const REG=3; // octave register chosen by horizontal position
const noteName=m=>NOTE_NAMES[((m%12)+12)%12]+(Math.floor(m/12)-1);
const lerp=(a,b,t)=>a+(b-a)*t, clamp01=v=>v<0?0:v>1?1:v, now=()=>performance.now();
// =========================================================================
// PLAY STATE — what the hands set; what we push to the model
// =========================================================================
// BLEND: each hand IS a style; how open the hand is = that style's weight in the mix
const NOTES=(()=>{const a=[];for(let o=0;o<2;o++)for(const k of PENT)a.push(ROOT+12*o+k);return a;})();
let styleIdxL=0, styleIdxR=2; // BANDS index controlled by the left / right hand
let openL=0, openR=0, intensity=0; // smoothed openness per hand = its weight; sum = drum intensity
let curNoteIdx=0, curNote=NOTES[0], notePulse=0;// ✌️ victory cycles the played note
let prevGestL="None", prevGestR="None";
let strokePulse=0, pinchPulse=0; // (kept for the shared draw scaffold)
let drop=false;
let bankVer=0, curBankOp=null; // save/recall generation state
let flash=null, tick=0; // {text,sub,color,life}
let pitchPulse=0; // bumps each time the aimed note changes
let prevVibeGesture="None";
const INST_LABEL="Right"; // MediaPipe handedness of the melody hand (flip to "Left" if swapped)
let lastHandsT=now();
// =========================================================================
// MAGENTA RT CLIENT
// =========================================================================
const SR=48000;
const SID=(crypto.randomUUID?crypto.randomUUID():"s"+Math.random().toString(36).slice(2));
let AC=null,analyser=null,freqData=null,gainNode=null,ws=null,gclient=null,Client=null;
let playing=false,acquired=false,sessionEnded=false;
let nextTime=0,lastPush=0,lastSig=null,sessionLen=120,deadline=0,volume=0.9;
import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js").then(m=>{Client=m.Client;}).catch(()=>{});
function buildAudioCtx(){
AC=new (window.AudioContext||window.webkitAudioContext)({sampleRate:SR});
analyser=AC.createAnalyser(); analyser.fftSize=512; freqData=new Uint8Array(analyser.frequencyBinCount);
gainNode=AC.createGain(); gainNode.gain.value=volume;
analyser.connect(gainNode); gainNode.connect(AC.destination);
}
function applyGain(){ if(AC&&gainNode) gainNode.gain.setTargetAtTime(volume, AC.currentTime, 0.06); }
function playPCM(i16){ const n=i16.length>>1; if(n<1||!AC)return;
const buf=AC.createBuffer(2,n,SR),L=buf.getChannelData(0),R=buf.getChannelData(1);
for(let i=0;i<n;i++){ L[i]=i16[2*i]/32768; R[i]=i16[2*i+1]/32768; }
const s=AC.createBufferSource(); s.buffer=buf; s.connect(analyser);
if(nextTime<AC.currentTime+0.03) nextTime=AC.currentTime+0.08;
if(nextTime>AC.currentTime+0.45) nextTime=AC.currentTime+0.18; // keep the queue SHORT -> reactive (the real latency fix)
s.start(nextTime); nextTime+=n/SR;
const lead=((nextTime-AC.currentTime)*1000)|0;
if(ws&&ws.readyState===1){ try{ ws.send(JSON.stringify({type:"buffer",session_id:SID,lead})); }catch(e){} }
}
function startWS(){ if(ws){ try{ws.close();}catch(e){} }
ws=new WebSocket(location.origin.replace(/^http/,"ws")+"/ws?session_id="+encodeURIComponent(SID));
ws.binaryType="arraybuffer";
ws.onopen=()=>pushConditioning(true);
ws.onmessage=(e)=>{
if(typeof e.data==="string"){ try{ const m=JSON.parse(e.data); if(m.type==="ended") endSession(); }catch(_){} return; }
if(!playing) return; const b=e.data; if(!b||b.byteLength<4) return;
if(!acquired){ acquired=true; setStatus("live"); }
playPCM(new Int16Array(b,4));
};
ws.onclose=()=>{ if(playing&&!sessionEnded) setTimeout(startWS,400); };
}
async function startSession(){
if(!AC) buildAudioCtx();
await AC.resume();
nextTime=AC.currentTime+0.12; playing=true; acquired=false; sessionEnded=false; lastSig=null;
applyGain(); deadline=now()+sessionLen*1000; setStatus("obtaining");
$("restart").style.display="none";
try{
if(!gclient){ if(!Client){ const m=await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"); Client=m.Client; }
gclient=await Client.connect(location.origin); }
await gclient.predict("/start",{session_id:SID});
}catch(e){ console.error("start",e); }
startWS();
}
function endSession(){ playing=false; sessionEnded=true;
if(ws){ try{ws.close();}catch(e){} ws=null; }
if(AC) AC.suspend(); setStatus("ended"); $("restart").style.display="flex"; }
const fmt=s=>{ s=Math.max(0,s|0); return Math.floor(s/60)+":"+String(s%60).padStart(2,"0"); };
function setStatus(kind,rem){ const el=$("status"); el.style.display="inline-flex";
if(kind==="obtaining"){ el.className="obtaining"; el.textContent="◉ obtaining ZeroGPU…"; }
else if(kind==="live"){ el.className="live"; el.textContent="◉ LIVE · "+fmt(rem!=null?rem:(deadline-now())/1000); }
else if(kind==="ended"){ el.className="ended"; el.textContent="◌ session ended"; }
}
function tickBudget(){ if(!playing) return; const rem=(deadline-now())/1000;
if(rem<=0){ endSession(); return; } if(acquired) setStatus("live",rem); }
setInterval(tickBudget,500);
// build + push the conditioning. melody (notes) + energy(drums) + genre + bank ops.
function pushConditioning(force){
if(!ws||ws.readyState!==1) return;
const t=now(); if(!force && t-lastPush<55) return;
// two hands = two styles; each hand's openness is that style's MusicCoCa weight (open ✋ louder, pinch 🤏 quieter)
const sL=clamp01(openL), sR=clamp01(openR), sum=sL+sR;
const wL = sum<0.05 ? 0.5 : Math.round(sL/sum*100)/100, wR = Math.round((1-wL)*100)/100;
const prompts=[BANDS[styleIdxL].prompt, BANDS[styleIdxR].prompt];
const weights=[wL, wR];
const cfgNotes=2.6;
const cfgDrums=Math.round((2.0+intensity*4.0)*10)/10; // both hands open -> fuller; both pinched -> sparse
const drumless=drop; // both pinched shut -> breakdown
const notes=[curNote];
const sig=styleIdxL+","+styleIdxR+"|"+wL+"|"+cfgDrums+"|"+drumless+"|"+curNote+"|"+(notePulse>0.5?1:0)+"|"+(curBankOp?curBankOp.ver:0);
if(!force && sig===lastSig) return;
lastSig=sig; lastPush=t;
const payload={ type:"set", session_id:SID,
prompts, weights, audio:prompts.map(()=>false),
notes, onsetmode:notePulse>0.5, unmaskwidth:0, // re-articulate when ✌️ switches the note
temperature:1.1, top_k:50, cfg:1.6, cfg_notes:cfgNotes, cfg_drums:cfgDrums,
drumless, model:"mrt2_base", buffer:0 };
if(curBankOp) payload.bank_op=curBankOp;
try{ ws.send(JSON.stringify(payload)); }catch(e){}
}
function fireFlash(text,sub,color){ flash={text,sub,color,life:1}; }
// =========================================================================
// GESTURE INPUT — right hand = instrument, left hand = vibe + triggers
// =========================================================================
const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
const handSize=lm=>dist(lm[0],lm[9])+1e-6;
const openness=lm=>{ const s=handSize(lm),tips=[8,12,16,20]; // 0 = fist, 1 = spread open
const m=tips.reduce((a,t)=>a+dist(lm[t],lm[0]),0)/tips.length/s; return clamp01((m-1.0)/1.1); };
const palmY=lm=>lm[9].y, palmX=lm=>lm[9].x;
let recognizer=null,lastVideoTs=-1;
let instHand=null, vibeHand=null; // parsed hands for drawing
async function initRecognizer(){
const fs=await FilesetResolver.forVisionTasks(V);
recognizer=await GestureRecognizer.createFromOptions(fs,{
baseOptions:{modelAssetPath:MODEL,delegate:"GPU"}, runningMode:"VIDEO", numHands:2});
}
function readHands(res){
const out=[]; const n=res.landmarks?.length||0;
for(let i=0;i<n;i++){ const lm=res.landmarks[i];
out.push({ lm, hx:(1-palmX(lm))*innerWidth, hy:palmY(lm)*innerHeight,
nx:1-palmX(lm), ny:palmY(lm),
open:openness(lm), pinch: dist(lm[4],lm[8])/handSize(lm) < 0.42, // thumb-index together
hand:res.handedness?.[i]?.[0]?.categoryName||"",
gesture:res.gestures?.[i]?.[0]?.categoryName||"None" }); }
// assign by handedness: LEFT hand drives style A, RIGHT hand drives style B
let HL=null, HR=null;
for(const h of out){ if(h.hand==="Left"&&!HL) HL=h; else if(h.hand==="Right"&&!HR) HR=h; }
if(out.length>=2 && (!HL||!HR)){ out.sort((a,b)=>a.hx-b.hx); HL=out[0]; HR=out[1]; } // fallback by screen side
else if(out.length===1 && !HL && !HR){ HR=out[0]; }
vibeHand=HL; instHand=HR; // reuse for drawing
lastHandsT=now();
handTurn(HL,"L"); handTurn(HR,"R");
if(!HL){ openL=lerp(openL,0,0.12); prevGestL="None"; }
if(!HR){ openR=lerp(openR,0,0.12); prevGestR="None"; }
intensity=(openL+openR)/2;
drop = intensity<0.07; // both hands pinched shut -> breakdown
}
// one hand: openness = its style's weight; ✊ swaps its style; ✌️ switches the note; 👍/👎 banks
function handTurn(h,side){
if(!h) return; const g=h.gesture, prev=(side==="L"?prevGestL:prevGestR);
if(g!==prev){
if(g==="Closed_Fist"){ // ✊ -> cycle THIS hand's style
if(side==="L"){ styleIdxL=(styleIdxL+1)%BANDS.length; fireFlash(BANDS[styleIdxL].name.toUpperCase(),"left style",bandColor(styleIdxL)); }
else { styleIdxR=(styleIdxR+1)%BANDS.length; fireFlash(BANDS[styleIdxR].name.toUpperCase(),"right style",bandColor(styleIdxR)); }
pushConditioning(true);
} else if(g==="Victory"){ // ✌️ -> switch the note
curNoteIdx=(curNoteIdx+1)%NOTES.length; curNote=NOTES[curNoteIdx]; notePulse=1;
fireFlash("♪ "+noteName(curNote),"note",C.melody2); pushConditioning(true);
} else if(g==="Thumb_Up"){ curBankOp={action:"save",idx:0,ver:++bankVer}; fireFlash("● SAVED","loop captured",C.groove2); pushConditioning(true); }
else if(g==="Thumb_Down"){ curBankOp={action:"load",idx:0,ver:++bankVer}; fireFlash("◀ RECALL","loop restored",C.groove2); pushConditioning(true); }
if(side==="L") prevGestL=g; else prevGestR=g;
}
if(g!=="Victory" && g!=="Thumb_Up" && g!=="Thumb_Down"){ // openness -> this style's weight (frozen during those poses)
const w=clamp01(h.open);
if(side==="L") openL=lerp(openL,w,0.2); else openR=lerp(openR,w,0.2);
}
}
// =========================================================================
// VISUALS
// =========================================================================
const HCONN=[[0,1],[1,2],[2,3],[3,4],[0,5],[5,6],[6,7],[7,8],[5,9],[9,10],[10,11],[11,12],
[9,13],[13,14],[14,15],[15,16],[13,17],[17,18],[18,19],[19,20],[0,17]];
let trail=[],wavePhase=0;
function resize(){cv.width=innerWidth*devicePixelRatio;cv.height=innerHeight*devicePixelRatio;
ctx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0);}
addEventListener("resize",resize); resize();
function glow(x,y,r,c,a=1){const g=ctx.createRadialGradient(x,y,0,x,y,r);
g.addColorStop(0,c); g.addColorStop(1,"transparent");
ctx.globalAlpha=a; ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,r,0,7); ctx.fill(); ctx.globalAlpha=1;}
function roundRect(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
function palmCenter(pts){ const ids=[0,5,9,13,17]; let x=0,y=0;
ids.forEach(i=>{x+=pts[i].x;y+=pts[i].y;}); return {x:x/ids.length,y:y/ids.length}; }
function mix(a,b,t){ a=a.split(",").map(Number); b=b.split(",").map(Number);
return a.map((v,i)=>Math.round(lerp(v,b[i],clamp01(t)))).join(","); }
// travel across the retro palette as the style morphs (closed -> open)
function styleColor(t){ t=clamp01(t)*(STRIPES.length-1); const i=Math.floor(t);
return mix(STRIPES[i], STRIPES[Math.min(STRIPES.length-1,i+1)], t-i); }
function bandColor(i){ return styleColor(BANDS.length>1 ? i/(BANDS.length-1) : 0); } // a colour per style
function draw(){
const W=innerWidth,H=innerHeight; tick++; ctx.clearRect(0,0,W,H);
let level=0;
if(analyser){ analyser.getByteFrequencyData(freqData);
for(let i=0;i<freqData.length;i++) level+=freqData[i]; level/=freqData.length*255; }
// reactive waveform stripes (audio level + how open the hands are)
wavePhase+=0.015+level*0.05;
const cy=H*0.80, amp=14+level*120+intensity*70, gap=11+level*8, drv=drop?0.3:1;
STRIPES.forEach((col,bi)=>{
ctx.beginPath();
for(let x=-20;x<=W+20;x+=10){
const y=cy+(bi-2)*gap+Math.sin(x*0.011+wavePhase+bi*0.35)*amp*drv
+Math.sin(x*0.028-wavePhase*1.3)*(8+level*30)*drv;
x<0?ctx.moveTo(x,y):ctx.lineTo(x,y);
}
ctx.strokeStyle=`rgba(${col},.8)`; ctx.lineWidth=9; ctx.lineCap="round";
ctx.shadowColor=`rgba(${col},.5)`; ctx.shadowBlur=14; ctx.stroke(); ctx.shadowBlur=0;
});
drawGenre(W,H);
// hands
drawHand(instHand,C.melody2,true);
drawHand(vibeHand,C.groove2,false);
trail=trail.filter(p=>p.life>0);
trail.forEach(p=>{glow(p.x,p.y,10*p.life,`rgba(${p.col},${p.life*.5})`,1);p.life-=0.05;});
drawFlash(W,H);
if(pitchPulse>0) pitchPulse-=0.06;
if(strokePulse>0) strokePulse-=0.08;
if(pinchPulse>0) pinchPulse-=0.06;
if(notePulse>0) notePulse-=0.05;
requestAnimationFrame(draw);
}
function drawHand(h,col,inst){
if(!h) return; const W=innerWidth,H=innerHeight;
const pts=h.lm.map(p=>({x:(1-p.x)*W,y:p.y*H}));
const pc=palmCenter(pts);
// glowing palm blob — inst: the note/strum; vibe: conducting energy
const emph = inst ? openR : openL, sIdx = inst ? styleIdxR : styleIdxL;
const lvl = 0.16+0.84*clamp01(emph); // blob size = how much THIS style is in the mix
const bcol = bandColor(sIdx);
const br = 18+lvl*52;
glow(pc.x,pc.y, 42+lvl*104, `rgba(${bcol},${0.16+lvl*0.5})`,1);
ctx.fillStyle=`rgba(${bcol},${0.22+lvl*0.4})`; ctx.beginPath(); ctx.arc(pc.x,pc.y,br,0,7); ctx.fill();
ctx.strokeStyle=`rgba(${bcol},${0.5+lvl*0.5})`; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.arc(pc.x,pc.y,br,0,7); ctx.stroke();
// skeleton (subtle, sits inside the blob)
const lc=bcol;
ctx.strokeStyle=`rgba(${lc},.5)`; ctx.lineWidth=2;
HCONN.forEach(([a,b])=>{ctx.beginPath();ctx.moveTo(pts[a].x,pts[a].y);ctx.lineTo(pts[b].x,pts[b].y);ctx.stroke();});
pts.forEach((p,i)=>{const big=[4,8,12,16,20,0].includes(i);
ctx.fillStyle=`rgba(${lc},.95)`;ctx.beginPath();ctx.arc(p.x,p.y,big?3.5:2,0,7);ctx.fill();});
ctx.textAlign="center";
const sum=openL+openR||1, pct=Math.round(emph/sum*100);
ctx.fillStyle=`rgba(${bcol},.97)`; ctx.font="700 15px Righteous";
ctx.fillText(BANDS[sIdx].name.toUpperCase()+" "+pct+"%", pc.x, pc.y-br-10);
ctx.fillStyle=`rgba(${C.cream},.55)`; ctx.font="600 10px Righteous";
ctx.fillText("✊ swap", pc.x, pc.y+br+18);
trail.push({x:pc.x,y:pc.y,life:1,col:bcol});
}
// top-center — the played note + a blend bar splitting the two hands' styles by their weights
function drawGenre(W,H){
const y=H*0.085; ctx.textAlign="center";
ctx.font="700 32px Shrikhand"; ctx.shadowColor=`rgba(${C.rust},.7)`; ctx.shadowBlur=14;
ctx.fillStyle=`rgba(${C.gold},${0.8+0.2*notePulse})`; ctx.fillText("♪ "+noteName(curNote), W/2, y);
ctx.shadowBlur=0;
const sum=openL+openR||1, wl=openL/sum;
const bw=Math.min(560,W*0.66), x0=W/2-bw/2, by=y+22, h=10;
const cL=bandColor(styleIdxL), cR=bandColor(styleIdxR);
ctx.fillStyle=`rgba(${cL},.85)`; roundRect(x0,by,Math.max(0,bw*wl),h,5); ctx.fill();
ctx.fillStyle=`rgba(${cR},.85)`; roundRect(x0+bw*wl,by,Math.max(0,bw*(1-wl)),h,5); ctx.fill();
ctx.textAlign="left"; ctx.fillStyle=`rgba(${cL},1)`; ctx.font="700 13px Righteous";
ctx.fillText(BANDS[styleIdxL].name.toUpperCase()+" "+Math.round(wl*100)+"%", x0, by-7);
ctx.textAlign="right"; ctx.fillStyle=`rgba(${cR},1)`;
ctx.fillText(Math.round((1-wl)*100)+"% "+BANDS[styleIdxR].name.toUpperCase(), x0+bw, by-7);
ctx.textAlign="center"; ctx.fillStyle=`rgba(${C.melody2},.7)`; ctx.font="600 11px Righteous";
ctx.fillText("open ✋ louder · pinch 🤏 quieter · ✊ swap a style · ✌️ change note", W/2, by+26);
}
function drawFlash(W,H){
if(!flash) return; const f=flash, e=f.life;
ctx.globalAlpha=Math.min(1,e*1.5);
ctx.textAlign="center";
ctx.fillStyle=`rgba(${f.color},1)`; ctx.font="700 54px Shrikhand";
ctx.shadowColor="rgba(0,0,0,.5)"; ctx.shadowBlur=18;
ctx.fillText(f.text, W/2, H*0.42+ (1-e)*-20); ctx.shadowBlur=0;
if(f.sub){ ctx.fillStyle=`rgba(${C.cream},.85)`; ctx.font="600 14px Righteous";
ctx.fillText(f.sub, W/2, H*0.42+24); }
ctx.globalAlpha=1;
f.life-=0.022; if(f.life<=0) flash=null;
}
// =========================================================================
async function loop(){
if(recognizer&&cam.readyState>=2&&cam.currentTime!==lastVideoTs){
lastVideoTs=cam.currentTime;
try{ readHands(recognizer.recognizeForVideo(cam,now())); }catch(e){}
if(playing){ applyGain(); pushConditioning(false); }
}
requestAnimationFrame(loop);
}
async function initVisuals(){
await initRecognizer();
try{ await document.fonts.ready; }catch(e){}
$("go").textContent="opening camera…";
const stream=await navigator.mediaDevices.getUserMedia({video:{width:1280,height:720},audio:false});
cam.srcObject=stream; await cam.play();
$("gate").style.display="none";
draw(); loop();
}
$("go").addEventListener("click", async ()=>{
const err=$("err"); err.textContent="";
try{
$("go").textContent="loading model…"; $("go").disabled=true;
await initVisuals();
await startSession();
}catch(e){
console.error(e);
err.textContent="⚠ "+(e.message||e)+" — needs a webcam + secure context (https or localhost).";
$("go").textContent="▶ START JAMMING"; $("go").disabled=false;
}
});
$("restartBtn").addEventListener("click", ()=>{ startSession(); });
// volume on the keyboard (per the design: volume = keyboard/OS, not a gesture)
addEventListener("keydown",e=>{
if(e.key==="ArrowUp"){ volume=Math.min(1,volume+0.07); applyGain(); }
else if(e.key==="ArrowDown"){ volume=Math.max(0,volume-0.07); applyGain(); }
});
</script>
</body>
</html>