| <!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 & 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"}, |
| ]; |
| |
| |
| const NOTE_NAMES=["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]; |
| const PENT=[0,3,5,7,10], ROOT=57; |
| const REG=3; |
| 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(); |
| |
| |
| |
| |
| |
| 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; |
| let openL=0, openR=0, intensity=0; |
| let curNoteIdx=0, curNote=NOTES[0], notePulse=0; |
| let prevGestL="None", prevGestR="None"; |
| let strokePulse=0, pinchPulse=0; |
| let drop=false; |
| let bankVer=0, curBankOp=null; |
| let flash=null, tick=0; |
| let pitchPulse=0; |
| let prevVibeGesture="None"; |
| const INST_LABEL="Right"; |
| let lastHandsT=now(); |
| |
| |
| |
| |
| 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; |
| 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); |
| |
| |
| function pushConditioning(force){ |
| if(!ws||ws.readyState!==1) return; |
| const t=now(); if(!force && t-lastPush<55) return; |
| |
| 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; |
| const drumless=drop; |
| 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, |
| 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}; } |
| |
| |
| |
| |
| 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]; |
| 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; |
| 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, |
| hand:res.handedness?.[i]?.[0]?.categoryName||"", |
| gesture:res.gestures?.[i]?.[0]?.categoryName||"None" }); } |
| |
| 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]; } |
| else if(out.length===1 && !HL && !HR){ HR=out[0]; } |
| vibeHand=HL; instHand=HR; |
| |
| 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; |
| } |
| |
| function handTurn(h,side){ |
| if(!h) return; const g=h.gesture, prev=(side==="L"?prevGestL:prevGestR); |
| if(g!==prev){ |
| if(g==="Closed_Fist"){ |
| 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"){ |
| 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"){ |
| const w=clamp01(h.open); |
| if(side==="L") openL=lerp(openL,w,0.2); else openR=lerp(openR,w,0.2); |
| } |
| } |
| |
| |
| |
| |
| 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(","); } |
| |
| 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); } |
| |
| 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; } |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| const emph = inst ? openR : openL, sIdx = inst ? styleIdxR : styleIdxL; |
| const lvl = 0.16+0.84*clamp01(emph); |
| 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(); |
| |
| 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}); |
| } |
| |
| |
| 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(); }); |
| |
| 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> |
|
|