if(S.cmode!=='note'&&now-D.cmSince>10) S.cmode='note'; if(E.level<0.4){ if(!D.quietSince) D.quietSince=now; if(now-D.quietSince>2) S.cmode='note'; } else D.quietSince=0; const pure=!wheel&&held>1.2; S.sweep=wheel?(D.spin||1)*PLATE.ARM.follow:pure?0.03*K_AUD:((0.06+1.2*lv*lv)*K_AUD); return pure; } const BN=9, FIFTHS=[0,7,2,9,4,11,6,1,8,3,10,5]; const cv=$('cv'), ctx=cv.getContext('2d'); let BW=900, TL=100, last=performance.now()/1000; const plateC=document.createElement('canvas'), pctx=plateC.getContext('2d'); const RING=[]; for(let gy=0;gy=3) RING.push({gx,gy,row:d===3?'inner':'outer'}); } { const inner=RING.filter(t=>t.row==='inner'), ang=t=>Math.atan2(t.gy-4,t.gx-4); inner.sort((a,b)=>((ang(a)+Math.PI*0.75+2*Math.PI)%(2*Math.PI))-((ang(b)+Math.PI*0.75+2*Math.PI)%(2*Math.PI))); inner.forEach((t,k)=>{ t.pc=FIFTHS[k%12]; }); } const FIG=[]; const TAU=6.283185307179586; function missNear(pc,x,y,openK){ const ex=x-0.5, ey=y-0.5, rad=Math.hypot(ex,ey), ang=Math.atan2(ey,ex); const seam=(pc/12)*TAU, k=pc%12, wrap=a=>Math.atan2(Math.sin(a),Math.cos(a)); if(k===0){ const bite=0.55+0.7*openK; return Math.abs(wrap(ang-seam))0.15?Math.abs(rad-(0.26+0.06*openK)):1; if(k===5){ let best=1; for(let s=0;s<3;s++){ const rel=wrap(ang-seam-s*TAU/3); if(Math.abs(rel)<0.18&&rad>0.08&&rad<0.34+0.06*openK) best=Math.min(best, Math.abs(rel)*rad); } return best; } if(k===6){ const e=Math.hypot(ex/0.34, ey/0.22); return Math.abs(wrap(ang-seam))<0.6+0.4*openK?1:Math.abs(e-1)*0.28; } if(k===7){ const arc=Math.abs(wrap(ang-seam))<0.7?Math.abs(rad-0.30):1; const r1=Math.abs(wrap(ang-seam-0.7))<0.12&&rad<0.30?Math.abs(wrap(ang-seam-0.7))*rad:1; const r2=Math.abs(wrap(ang-seam+0.7))<0.12&&rad<0.30?Math.abs(wrap(ang-seam+0.7))*rad:1; return Math.min(arc,r1,r2); } if(k===8){ let u=wrap(ang-seam); if(u<0) u+=TAU; const max=(0.55+0.45*openK)*TAU; return u>max?1:Math.abs(rad-(0.08+0.26*u/max)); } if(k===9){ const stem=Math.abs(wrap(ang-seam))<0.12&&rad>0.06&&rad<0.28?Math.abs(wrap(ang-seam))*rad:1; return Math.min(stem, Math.abs(rad-0.30)<0.04&&Math.abs(wrap(ang-seam))<0.8+0.3*openK?Math.abs(rad-0.30):1); } if(k===10) return Math.abs(wrap(ang-seam))<0.45?1:Math.abs(Math.abs(ex)+Math.abs(ey)-0.32); let best=1; for(let s=0;s<2;s++){ let u=wrap(ang-seam-s*Math.PI); if(u<0) u+=TAU; const max=(0.4+0.35*openK)*TAU; if(u<=max) best=Math.min(best, Math.abs(rad-(0.10+0.22*u/max))); } return best; } function missCloud(pc, openK){ let sd=1000+pc*97+Math.round(openK*13); const rnd=()=>{ sd=(sd*1664525+1013904223)>>>0; return sd/4294967296; }; const pts=[]; for(let k=0;k<26000&&pts.length<900;k++){ const x=rnd(), y=rnd(); if(missNear(pc,x,y,openK)<0.03) pts.push([x,y]); } pts.sort((p,q)=>Math.atan2(p[1]-0.5,p[0]-0.5)-Math.atan2(q[1]-0.5,q[0]-0.5)); return pts; } const SEATS=[]; function tileSeats(pc){ return {rest:missCloud(pc,0), heard:missCloud(pc,1)}; } function figureCanvas(pc,size){ const c=document.createElement('canvas'); c.width=c.height=size; const g=c.getContext('2d'); const col=PLATE.PAL[Math.round(NOTEHUE[pc]*255)]; g.fillStyle='rgb('+(col&255)+','+(col>>8&255)+','+(col>>16&255)+')'; if(!SEATS[pc]) SEATS[pc]=tileSeats(pc); const dot=Math.max(1,size/90); for(const p of SEATS[pc].rest){ const ink=(((p[0]*1000|0)*2246822519)>>>0)%1000/1000; g.globalAlpha=ink<0.34?1:0.35+0.25*ink; g.fillRect(p[0]*size,p[1]*size,dot,dot); } g.globalAlpha=1; return c; } const WALK=[]; function tileWalk(pc,wake,size){ if(!SEATS[pc]) SEATS[pc]=tileSeats(pc); let c=WALK[pc]; if(!c||c.width!==size){ c=document.createElement('canvas'); c.width=c.height=size; WALK[pc]=c; } const g=c.getContext('2d'); g.clearRect(0,0,size,size); const col=PLATE.PAL[Math.round(NOTEHUE[pc]*255)]; g.fillStyle='rgb('+(col&255)+','+(col>>8&255)+','+(col>>16&255)+')'; const {rest,heard}=SEATS[pc], dot=Math.max(1,size/90), n=Math.min(rest.length,heard.length); const trem=Math.sin(Math.PI*Math.max(0,Math.min(1,wake)))/size, w=Math.max(0,Math.min(1,wake)); for(let i=0;i>>0)%1000)/1000; g.globalAlpha=ink<0.34?1:0.32+0.28*ink; const tx=((((i*2246822519)>>>0)%1000)/1000-0.5)*trem, ty=((((i*1597334677)>>>0)%1000)/1000-0.5)*trem; g.fillRect(((1-w)*rest[i][0]+w*heard[i][0]+tx)*size, ((1-w)*rest[i][1]+w*heard[i][1]+ty)*size, dot, dot); } g.globalAlpha=1; return c; } function sizeTo(px){ BW=px; cv.width=cv.height=BW; TL=BW/BN; plateC.width=plateC.height=Math.round(TL*5); const fs=Math.max(24,Math.round(TL*0.78)); for(let pc=0;pc<12;pc++) FIG[pc]=figureCanvas(pc,fs); } function fit(){ if(rendering) return; const st=$('wrap').getBoundingClientRect(), css=Math.max(300,Math.min(st.width-16,st.height-8)), D2=Math.min(2,devicePixelRatio||1); cv.style.width=cv.style.height=css+'px'; sizeTo(Math.round(css*D2)); } addEventListener('resize',fit); fit(); const ACT=new Float32Array(12); let cam=1; const SHOTS={wide:{z:0.86},medium:{z:1.0},close:{z:1.22},focus:{z:1.45}}; const SHIFTS=['drag','bloom','whirl','shear','ripple']; const CAM={z:1,x:0,y:0,r:0,rb:0,shot:'medium',since:0,on:0,lastK:'',want:{z:1,t:0},ease:1.6,hold:6,tr:null,sd:20261007}; const camDie=()=>{ CAM.sd=(Math.imul(CAM.sd,1664525)+1013904223)>>>0; return CAM.sd/4294967296; }; function shiftTo(now){ const S=PLATE.S; let kind=SHIFTS[Math.floor(camDie()*5)]; if(kind===CAM.lastK) kind=SHIFTS[(SHIFTS.indexOf(kind)+1+Math.floor(camDie()*4))%5]; CAM.lastK=kind; const dir=camDie()<0.5?-1:1, u=camDie(); let shot=u<0.2?'wide':u<0.45?'medium':u<0.75?'close':'focus'; if(shot===CAM.shot) shot=shot==='medium'?'close':'medium'; if(kind==='drag'&&shot==='wide') shot='close'; if(kind==='bloom'&&shot==='focus') shot='wide'; if((kind==='whirl'||kind==='shear')&&shot==='wide') shot='medium'; let at=LADDER.findIndex(([a,b])=>S.cur.n%a===0&&S.cur.m%b===0&&S.cur.n/a===S.cur.m/b); if(at<0) at=0; const [a,b]=LADDER[(at+4+Math.floor(camDie()*5))%12]; let k=D.level||1; while(k>1&&Math.max(a,b)*k>12) k--; PLATE.morphTo(a*k,b*k,kind==='ripple'?1.8:1.3); S.fxK=kind; S.fxE=1; S.fxDir=dir; S.fxPh=0; S.fxTau={drag:0.55,bloom:0.5,whirl:0.7,shear:0.6,ripple:0.9}[kind]; D.figSince=now; D.twistUntil=now+6; camDie(); const t=0; CAM.tr={t0:now, dur:kind==='ripple'?2.2:1.7, z0:CAM.z, r0:CAM.r, z1:SHOTS[shot].z, r1:CAM.rb+t, bz:{drag:0.3,bloom:-0.16,whirl:0.14,shear:0.05,ripple:0.05}[kind], br:0}; CAM.shot=shot; CAM.since=now; CAM.hold=7+3*camDie(); CAM.want={z:SHOTS[shot].z,t}; } function stepCam(dt,now){ const S=PLATE.S, chl=S.cur.geo!=='wheel'&&S.state==='play'; if(CAM.tr){ const T=CAM.tr, u=Math.min(1,(now-T.t0)/T.dur), e=u*u*(3-2*u), bump=Math.sin(Math.PI*u); CAM.z=Math.max(0.74,T.z0+(T.z1-T.z0)*e+T.bz*bump); CAM.r=T.r0+(T.r1-T.r0)*(T.z1<1?Math.min(1,u*3.5):e)+T.br*bump; if(u>=1) CAM.tr=null; return; } if(chl){ if(!CAM.on){ CAM.on=1; CAM.since=now; CAM.hold=5+2*camDie(); } if(now-CAM.since>CAM.hold){ shiftTo(now); return; } } else { CAM.on=0; CAM.shot='medium'; CAM.want={z:1,t:0}; CAM.ease=1.6; } const w=CAM.want, dr=chl?1:0, k=1-Math.exp(-dt/CAM.ease); CAM.z+=(w.z+0.025*Math.sin(now*0.21)*dr-CAM.z)*k; CAM.r+=(CAM.rb+w.t-CAM.r)*k; } function drawBoard(t){ const S=PLATE.S, P=plateC.width; ctx.fillStyle='#000'; ctx.fillRect(0,0,BW,BW); if(!playing){ const glow=ctx.createRadialGradient(BW*0.5,BW*0.5,BW*0.08,BW*0.5,BW*0.5,BW*0.78); glow.addColorStop(0,'rgba(56,32,92,0.09)'); glow.addColorStop(0.55,'rgba(28,16,52,0.045)'); glow.addColorStop(1,'rgba(0,0,0,0)'); ctx.fillStyle=glow; ctx.fillRect(0,0,BW,BW); } PLATE.draw(pctx,P,t); const pcx=TL*4.5, pcy=TL*4.5, dq=CAM.r-Math.round(CAM.r/(Math.PI/2))*(Math.PI/2), z=(CAM.z<1?CAM.z+(1-CAM.z)*Math.min(1,Math.abs(dq)/0.01):CAM.z)*(Math.abs(Math.cos(dq))+1.6*Math.abs(Math.sin(dq))), lim=0; ctx.save(); ctx.beginPath(); ctx.rect(TL*2,TL*2,TL*5,TL*5); ctx.clip(); ctx.translate(pcx+CAM.x*lim,pcy+CAM.y*lim); ctx.rotate(CAM.r); ctx.scale(z,z); ctx.translate(-pcx,-pcy); ctx.drawImage(plateC,Math.round(TL*2),Math.round(TL*2)); ctx.restore(); if(!playing){ const breath=0.045+0.02*Math.sin(t*0.35); const dim=ctx.createRadialGradient(BW*0.5,BW*0.5,TL,BW*0.5,BW*0.5,TL*3.2); dim.addColorStop(0,'rgba(36,20,68,'+breath.toFixed(3)+')'); dim.addColorStop(1,'rgba(36,20,68,0)'); ctx.fillStyle=dim; ctx.fillRect(TL*2,TL*2,TL*5,TL*5); } const bcol=PLATE.PAL[Math.round(Math.max(0,Math.min(1,S.bgHue))*255)], br=bcol&255, bg=bcol>>8&255, bb=bcol>>16&255; const spill=Math.max(playing?0:0.16,Math.min(1,0.03+0.07*S.light+0.22*(S.strobe||0)+0.08*S.flash)); const heard=E.pc, fifth=heard>=0?(heard+7)%12:-1, tri=heard>=0?(heard+6)%12:-1; for(const tl of RING){ const x=tl.gx*TL, y=tl.gy*TL, inset=Math.max(1,TL*0.04); ctx.fillStyle='#030406'; ctx.fillRect(x+inset,y+inset,TL-2*inset,TL-2*inset); { const ang=Math.atan2(tl.gy-4,tl.gx-4); const flow=Math.pow(Math.max(0,Math.sin(ang-t*0.32)),2); const flow2=Math.pow(Math.max(0,Math.sin(ang*0.5-t*0.17+1.2)),2); const music=playing?Math.min(1,0.4+0.6*(S.light||0)):0.5; const a=(0.07+0.1*flow+0.04*flow2)*music; ctx.fillStyle='rgba(42,18,78,'+(tl.row==='inner'?a*0.35:a).toFixed(3)+')'; ctx.fillRect(x+inset,y+inset,TL-2*inset,TL-2*inset); if(tl.row==='inner'){ ctx.fillStyle='rgba(1,1,4,0.45)'; ctx.fillRect(x+inset,y+inset,TL-2*inset,TL-2*inset); } } if(tl.row==='outer'){ const ang=Math.atan2(tl.gy-4,tl.gx-4), wave=Math.pow(Math.sin(ang-t*K_AUD*0.7),2); const grain=0.55+0.45*(((tl.gx*13+tl.gy*29)*2654435761>>>0)%1000)/1000; const travel=Math.pow(Math.max(0,Math.sin(ang-t*0.22)),2); const a=Math.max(playing?0:0.05+0.07*wave*grain, spill*(0.2+0.6*wave)*grain*(0.35+0.65*travel)); ctx.fillStyle='rgba(52,20,98,'+Math.min(0.38,a).toFixed(3)+')'; ctx.fillRect(x+inset,y+inset,TL-2*inset,TL-2*inset); } else { let act=ACT[tl.pc]; if(tl.pc===tri) act*=0.28; if(tl.pc===fifth) act=Math.max(act,0.34*(ACT[heard]||0)); if(!playing) act=Math.max(act,0.3+0.12*Math.sin(t*0.6+tl.pc*0.5)); const fc=act<0.02?FIG[tl.pc]:tileWalk(tl.pc,act,FIG[tl.pc].width), fsz=fc.width, ox=x+(TL-fsz)/2, oy=y+(TL-fsz)/2; const mx=tl.gx>4?-1:1, my=tl.gy>4?-1:1, gl=Math.sin(t*2+tl.pc)>0.82?2:0; ctx.save(); ctx.translate(ox+fsz/2,oy+fsz/2); ctx.scale(mx,my); ctx.globalAlpha=0.16+0.62*act; ctx.drawImage(fc,-fsz/2,-fsz/2+gl); ctx.globalAlpha=0.28*act; ctx.drawImage(fc,-fsz/2+1,-fsz/2); ctx.restore(); if(act>0.04){ ctx.save(); ctx.translate(ox+fsz/2,oy+fsz/2); ctx.scale(mx,my); ctx.globalCompositeOperation='lighter'; ctx.globalAlpha=Math.min(1,0.55*act); ctx.filter='blur('+Math.max(1.5,TL/40).toFixed(1)+'px)'; ctx.drawImage(fc,-fsz/2,-fsz/2); ctx.restore(); ctx.filter='none'; } ctx.globalAlpha=tl.pc===fifth?0.34+0.32*act:0.14+0.38*act; ctx.fillStyle=tl.pc===fifth?'#b9a6ff':'#cfe9de'; ctx.font=Math.round(TL*0.12)+'px Consolas, Menlo, monospace'; ctx.textAlign='left'; ctx.textBaseline='top'; ctx.fillText(NOTE[tl.pc],x+inset*2.2,y+inset*1.8); ctx.globalAlpha=1; } ctx.strokeStyle='rgba(120,150,190,0.12)'; ctx.lineWidth=1; ctx.strokeRect(x+inset+0.5,y+inset+0.5,TL-2*inset-1,TL-2*inset-1); } const lift=0.06+0.14*(S.lamp||0); const gx=TL*2, gy=TL*2, gs=TL*5, cx=gx+gs*0.5, cy=gy+gs*0.5; const src=ctx.createRadialGradient(cx,cy,gs*0.12,cx,cy,gs*0.55+TL*0.9); src.addColorStop(0,'rgba(42,22,84,'+lift.toFixed(3)+')'); src.addColorStop(0.58,'rgba(32,16,64,'+(lift*0.45).toFixed(3)+')'); src.addColorStop(1,'rgba(18,8,32,0)'); ctx.save(); ctx.globalCompositeOperation='lighter'; ctx.fillStyle=src; ctx.fillRect(gx-TL,gy-TL,gs+TL*2,gs+TL*2); ctx.restore(); const pulse=0.5+0.5*Math.sin(t*2.1), gold=Math.min(1,S.flash||0); ctx.strokeStyle='rgba('+(Math.round(28+40*pulse))+','+(Math.round(48+20*pulse))+','+(Math.round(120+60*pulse))+','+(0.16+0.14*pulse).toFixed(3)+')'; ctx.lineWidth=1; ctx.strokeRect(TL*2+0.5,TL*2+0.5,TL*5-1,TL*5-1); if(gold>0.05){ ctx.strokeStyle='rgba(196,154,92,'+(0.28*gold).toFixed(3)+')'; ctx.strokeRect(TL*2+0.5,TL*2+0.5,TL*5-1,TL*5-1); } } function advance(dt,tw,now){ const S=PLATE.S; let pure=false; let inp={loud:0,hue:S.hue,onset:false,pourT:0}; for(let k=0;k<12;k++) ACT[k]*=Math.exp(-dt/0.7); if(playing){ const on=hear(now); pure=conduct(now,on); inp={loud:E.loud,level:E.level,special:E.special,hue:E.pc>=0?NOTEHUE[E.pc]:S.hue,onset:on,pourT:(now-startT)/2.4}; if(E.pc>=0&&S.state==='play'){ ACT[E.pc]=Math.max(ACT[E.pc],0.5+0.5*E.level*(pure?1:0.85)); ACT[(E.pc+7)%12]=Math.max(ACT[(E.pc+7)%12],0.28+0.22*E.level); ACT[(E.pc+6)%12]*=0.4; } } if(!playing||S.state==='rest'){ for(let k=0;k<12;k++){ const w=0.5+0.5*Math.sin(tw*2*Math.PI/(4.5*K_AUD)-k*2*Math.PI/12); ACT[FIFTHS[k]]=Math.max(ACT[FIFTHS[k]],0.16*w*w); } } PLATE.step(dt,tw,inp); stepCam(dt,now); return pure; } const drawScene=t=>drawBoard(t); function freshScene(){ PLATE.fresh(); ACT.fill(0); Object.assign(CAM,{z:1,x:0,y:0,r:0,rb:0,shot:'medium',since:0,on:0,lastK:'',want:{z:1,t:0},ease:1.6,hold:6,tr:null,sd:20261007}); D.twistUntil=0; PLATE.S.fxE=0; } function readout(now,pure){ const S=PLATE.S; read(S.state==='outro'?'THE ASKING · the sand comes home' :S.cur.geo==='wheel'&&D.intro&&nowTHE ASKING · the opening · '+(E.pc>=0?NOTE[E.pc]:'—') :''+(S.cur.geo==='wheel'?'THE ASKING':pure?'PURE':'MORPH')+' · '+(E.pc>=0?NOTE[E.pc]:'—')+' · figure ('+S.cur.n+','+S.cur.m+') · λ '+(S.cur.n*S.cur.n+S.cur.m*S.cur.m)+' · colour: '+S.cmode); } let paceAvg=1/60, paceSaid=false; function frame(){ requestAnimationFrame(frame); if(rendering) return; const nowW=performance.now()/1000, raw=nowW-last, dt=Math.min(0.05,raw); last=nowW; if(playing&&raw<1){ paceAvg+=(raw-paceAvg)*0.05; if(!paceSaid&&paceAvg>1/20&&AC&&AC.currentTime-startT>4){ paceSaid=true; stat.textContent='this device draws about '+Math.max(1,Math.round(1/paceAvg))+' pictures a second here, so the sand moves slowly, and a ⏺▶ TAKE would record it that way. ⏺ RENDER makes the video at full speed (it takes longer to make).'; } } const now=AC?AC.currentTime:0, was=playing, pure=advance(dt,nowW,now); drawScene(nowW); if(was&&playing) readout(now,pure); else if(!playing) read('THE ASKING · at rest'); if(takeActive&&SONG&&anchor&&now>anchor+SONG.dur+TAIL) finishTake(); if(recording){ rcx.drawImage(cv,0,0,recCv.width,recCv.height); recTick(); } } requestAnimationFrame(frame); let hooked=false; const playBtn=$('play'), pauseBtn=$('pause'), takeBtn=$('take'), renderBtn=$('render'); function ensureAudio(){ if(AC) return; AC=new (window.AudioContext||window.webkitAudioContext)(); AN=AC.createAnalyser(); AN.fftSize=8192; AN.smoothingTimeConstant=0.45; SPEC=new Float32Array(AN.frequencyBinCount); TD=new Float32Array(AN.fftSize); AN.connect(AC.destination); try{ recDest=AC.createMediaStreamDestination(); AN.connect(recDest); }catch(e){} } function hookAudio(){ ensureAudio(); if(hooked||!SONG) return; try{ const src=AC.createMediaElementSource(SONG.aud); src.connect(AN); hooked=true; }catch(e){} } const PLAYED=new Set(); function begin(len,now,first){ const S=PLATE.S; startT=now; songEnd=now+len; playing=true; if(S.state!=='rest'&&S.state!=='outro') PLATE.rest(true); S.state='play'; S.mu=1; S.prev=null; S.cur={n:2,m:4,th:S.cur.th,geo:'wheel'}; Object.assign(E,{beatAvg:0,special:false,levelLow:0.5,tl:0,lastSpecial:-9,t:0,level:0.5,agc:0.05,loud:0,kickPrev:0,kickAvg:0,broadPrev:0,broadAvg:0,lastOn:-9,dom:0,pc:-1,pcSince:0}); Object.assign(D,{loudSlow:0.5,level:2,lastNow:0,figSince:0,cm:0,cmSince:now,quietSince:0,wheelUntil:now+(first?7*K_AUD:0.8*K_AUD),spin:1,spinNow:0.4,beats:0,omega:0.4,opened:false,outroAt:0,rs:20261003,phLen:0,phBeats:0,rushAt:0,letGo:-9,nextAsk:0,dwellSet:0,onRate:0,dense:0,lastAskAt:now}); S.lock=0; S.cmode='prism'; D.intro=first; } function stopAudio(){ playing=false; if(rendering) return; playBtn.classList.remove('on'); if(SONG&&SONG.aud.ended&&!takeActive) stat.textContent='played through — press Play to hear it again'; } function play(){ if(rendering) return; if(!SONG){ stat.textContent='LOAD a song first (mp4 · m4a · wav · mp3)'; return; } ensureAudio(); if(AC.state==='suspended') AC.resume(); hookAudio(); if(playing&&PLATE.S.state==='play') return; takeActive=false; let off=SONG.aud.currentTime; if(SONG.aud.ended||off>=SONG.dur-0.05){ SONG.aud.currentTime=0; off=0; } SONG.aud.play(); const first=!PLAYED.has(SONG.id); PLAYED.add(SONG.id); begin(SONG.dur-off,AC.currentTime,first); playBtn.classList.add('on'); pauseBtn.classList.remove('on'); stat.textContent=first?'hearing · the wheel opens, then the sand follows the music':'hearing · the sand follows the music'; } function pauseAll(msg){ takeActive=false; if(SONG&&!SONG.aud.paused) SONG.aud.pause(); if(playing&&PLATE.S.state==='play') songEnd=0; if(recorder&&recorder.state==='recording') recorder.stop(); playBtn.classList.remove('on'); pauseBtn.classList.add('on'); takeBtn.classList.remove('on'); stat.textContent=msg||'paused · the sand comes home'; } function take(){ if(rendering) return; if(!SONG){ stat.textContent='LOAD a song first'; return; } ensureAudio(); if(AC.state==='suspended') AC.resume(); hookAudio(); if(recorder&&recorder.state==='recording'){ stat.textContent='already recording — stop it first'; return; } takeActive=true; playBtn.classList.add('on'); takeBtn.classList.add('on'); pauseBtn.classList.remove('on'); stat.textContent='TAKE — waiting for the tape to truly roll…'; SONG.aud.pause(); SONG.aud.currentTime=0; playing=false; PLATE.rest(true); startRec(()=>{ anchor=AC.currentTime+HEAD; setTimeout(()=>{ if(takeActive&&SONG){ SONG.aud.play(); PLAYED.add(SONG.id); begin(SONG.dur,AC.currentTime,true); } }, Math.max(0,(anchor-AC.currentTime)*1000)); stat.textContent='TAKE — the wheel '+HEAD.toFixed(1)+'s · the song '+SONG.dur.toFixed(1)+'s · the sand comes home '+TAIL+'s'; }); } function finishTake(){ takeActive=false; if(SONG&&!SONG.aud.paused) SONG.aud.pause(); if(playing&&PLATE.S.state==='play') songEnd=0; if(recorder&&recorder.state==='recording') recorder.stop(); playBtn.classList.remove('on'); takeBtn.classList.remove('on'); } playBtn.onclick=play; pauseBtn.onclick=()=>{ if(!rendering) pauseAll(); }; takeBtn.onclick=()=>{ takeActive?finishTake():take(); }; const RS={"size":1080,"fps":{"computer":60,"phone":30},"video_mbps":{"computer_60":16,"computer_30":10,"phone_60":12,"phone_30":8},"light":{"size":720,"video_mbps":{"60":6,"30":4},"parts":[30,60]},"sound":"aac","audio_kbps":192,"opus_kbps":192,"tail_seconds":0.3,"keyframe_every_seconds":2,"clean_take":true}; const recCv=document.createElement('canvas'); recCv.width=RS.size; recCv.height=RS.size; const rcx=recCv.getContext('2d'); let recorder=null, chunks=[], recording=false, REQ=null, NEXT=0, WC=null; const RECMIME=['video/mp4;codecs=avc1.640028,mp4a.40.2','video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4', 'video/webm;codecs=h264,opus','video/x-matroska;codecs=avc1,opus', 'video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm']; const smallDev=/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent||'')||(navigator.maxTouchPoints>1&&/Mac/i.test(navigator.platform||'')); let FPS=smallDev?RS.fps.phone:RS.fps.computer; const fpsBtn=document.getElementById('fps'); const mbps=()=>RS.video_mbps[(smallDev?'phone':'computer')+'_'+FPS]||(FPS>30?40:20); const showFps=()=>{ if(fpsBtn) fpsBtn.textContent=FPS+' FPS'; }; showFps(); if(fpsBtn) fpsBtn.onclick=()=>{ if(recording){ stat.textContent='the frame rate changes between takes, not during one'; return; } FPS=FPS===60?30:60; showFps(); stat.textContent=FPS===60?'takes at 60 frames a second: as smooth as the screen, about twice the size':'takes at 30 frames a second: lighter, for a phone or a tablet'; }; const recName=()=>'hear-Z-'+new Date().toISOString().replace(/[:.]/g,'-'); async function startWC(onRoll,mode){ recording=true; REQ=null; stat.textContent=mode==='song'?'TAKE — reading the song for a clean file…':'getting a clean recording ready…'; let audio=null; if(mode==='song'){ let pcm=null; try{ pcm=await CleanRec.decode(AC,await (await fetch(SONG.url)).arrayBuffer()); }catch(e){} if(pcm){ const ch=Math.min(2,pcm.numberOfChannels); audio={pcm:[...Array(ch)].map((_,c)=>pcm.getChannelData(c)),sr:pcm.sampleRate,ch}; } } else audio={track:recDest.stream.getAudioTracks()[0],sr:AC.sampleRate,ch:2,ctx:AC}; rcx.imageSmoothingEnabled=true; rcx.imageSmoothingQuality='high'; rcx.drawImage(cv,0,0,recCv.width,recCv.height); const R=audio?await CleanRec.start({canvas:recCv,w:RS.size,h:RS.size,fps:FPS,mbps:mbps(),clock:()=>AC.currentTime,audio}):null; if(!R){ recording=false; startRecMR(onRoll); return; } WC=R; R.mode=mode; recorder={state:'recording',mimeType:'video/mp4',stop:()=>{ wcFinish(); }}; recBtn.classList.add('on'); recBtn.textContent='\u23F9 STOP'; if(mode!=='song') stat.textContent='recording a clean take · '+FPS+' fps… click STOP to save'; if(onRoll) onRoll(); if(mode==='song') R.head=Math.max(0,anchor-R.t0); R.frame(true); } async function wcFinish(){ const R=WC; if(!R||R.finishing) return; R.finishing=true; if(recorder) recorder.state='inactive'; let end=AC.currentTime-R.t0; if(R.mode==='song'&&typeof SONG!=='undefined'&&SONG&&SONG.aud&&SONG.aud.ended) end=Math.max(end,R.head+R.len+RS.tail_seconds); stat.textContent='finishing the file…'; const res=await R.finish(end); WC=null; recording=false; recBtn.classList.remove('on'); recBtn.textContent='\u23FA REC'; if(!res||!res.blob){ stat.textContent=res&&res.err?'the MP4 could not be closed: '+res.err:'no picture arrived, so nothing was made · keep the tab in front and try again'; return; } const url=URL.createObjectURL(res.blob), link=document.createElement('a'); link.href=url; link.download=recName()+'.mp4'; link.click(); setTimeout(()=>URL.revokeObjectURL(url),4000); stat.textContent='saved a clean take · MP4 · '+res.fps+' fps · '+res.frames+' frames · colours for every player'+(res.aerr?' · ⚠ the sound encoder complained':'')+' · nothing uploaded'; } function startRecMR(onRoll){ ensureAudio(); rcx.imageSmoothingEnabled=true; rcx.imageSmoothingQuality='high'; rcx.drawImage(cv,0,0,recCv.width,recCv.height); recording=true; const cs0=recCv.captureStream(0), vt0=cs0.getVideoTracks(); REQ=(vt0[0]&&typeof vt0[0].requestFrame==='function')?vt0[0]:null; NEXT=0; const vtr=REQ?vt0:recCv.captureStream(FPS).getVideoTracks(), atr=recDest?recDest.stream.getAudioTracks():[]; const stream=new MediaStream([...vtr, ...atr]); const mime=RECMIME.find(m=>window.MediaRecorder&&MediaRecorder.isTypeSupported(m))||''; chunks=[]; try{ recorder=new MediaRecorder(stream, mime?{mimeType:mime, videoBitsPerSecond:Math.round(mbps()*1e6), audioBitsPerSecond:RS.audio_kbps*1000}:undefined); } catch(e){ recording=false; stat.textContent='recording is not supported in this browser'; return; } recorder.ondataavailable=e=>{ if(e.data&&e.data.size) chunks.push(e.data); }; recorder.onstop=()=>{ recording=false; REQ=null; const mt=recorder.mimeType||mime||'video/webm'; const blob=new Blob(chunks,{type:mt}); const url=URL.createObjectURL(blob), link=document.createElement('a'); link.href=url; link.download=recName()+(mt.indexOf('mp4')>=0?'.mp4':'.webm'); link.click(); setTimeout(()=>URL.revokeObjectURL(url),2000); recBtn.classList.remove('on'); recBtn.textContent='⏺ REC'; stat.textContent='saved a HD take '+(mt.indexOf('mp4')>=0?'(.mp4)':'(.webm)')+' · nothing uploaded'; }; recorder.onstart=()=>{ if(onRoll) onRoll(); }; recorder.start(1000); recBtn.classList.add('on'); recBtn.textContent='⏹ STOP'; stat.textContent='recording HD '+RS.size+'² · '+FPS+' fps… click STOP to save'; } function startRec(onRoll){ ensureAudio(); if(typeof CleanRec!=='undefined'&&CleanRec.ok()&&AC){ if(onRoll&&typeof SONG!=='undefined'&&SONG&&SONG.url) return startWC(onRoll,'song'); if(recDest&&CleanRec.okLive()) return startWC(onRoll,'live'); } startRecMR(onRoll); } function recTick(){ if(WC) WC.frame(false); else if(REQ){ const n=performance.now(); if(n>=NEXT-4){ REQ.requestFrame(); NEXT+=1000/FPS; if(NEXT{ if(recorder && recorder.state==='recording') recorder.stop(); else startRec(); }; const songIn=$('songIn'), loadBtn=$('load'), songLbl=$('song'); { const recClick=recBtn.onclick; recBtn.onclick=()=>{ if(rendering){ stat.textContent='a render is running: REC waits until it is done'; return; } recClick(); }; } loadBtn.onclick=()=>{ if(!rendering) songIn.click(); }; async function loadSong(f){ if(rendering) return; try{ stat.textContent='listening through '+f.name+' …'; if(SONG) pauseAll(''); ensureAudio(); const buf=await ((typeof CleanRec!=='undefined'&&CleanRec.decode)?CleanRec.decode(AC,await f.arrayBuffer()):AC.decodeAudioData(await f.arrayBuffer())); const url=URL.createObjectURL(f), aud=new Audio(url); aud.preload='auto'; SONG={name:f.name, dur:buf.duration, url, aud, buf, id:f.name+'|'+f.size}; hooked=false; songLbl.textContent='♪ '+f.name.replace(/\.[^.]+$/,'')+' · '+buf.duration.toFixed(1)+'s'; stat.textContent='ready · ▶ Play (the wheel opens on the first play), ⏺▶ TAKE, or ⏺ RENDER'; } catch(err){ stat.textContent=(typeof CleanRec!=='undefined'&&CleanRec.whyNot)?CleanRec.whyNot(err):'could not hear that file: '+(err&&err.message||err); } } songIn.onchange=e=>{ const f=e.target.files&&e.target.files[0]; e.target.value=''; if(f) loadSong(f); }; addEventListener('dragover',e=>e.preventDefault()); addEventListener('drop',e=>{ e.preventDefault(); const f=e.dataTransfer.files[0]; if(f) loadSong(f); }); function makeEars(buf){ const n=8192, half=n/2, sr=buf.sampleRate, L=buf.getChannelData(0), Rc=buf.numberOfChannels>1?buf.getChannelData(1):L, len=L.length; const win=new Float64Array(n); for(let i=0;i>=1; } rev[i]=r; } const cs=new Float64Array(half), sn=new Float64Array(half); for(let i=0;i(j>=0&&j>1, st=n/size; for(let s=0;s0?20*Math.log10(sm[k]):-1000; } } }; } const breathe=()=>new Promise(r=>{ const c=new MessageChannel(); c.port1.onmessage=()=>{ c.port1.close(); r(); }; c.port2.postMessage(0); }); const mmss=s=>{ s=Math.max(0,Math.round(s)); return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); }; let renderStop=false; async function renderVideo(){ if(rendering){ renderStop=true; stat.textContent='stopping the render…'; return; } if(!SONG||!SONG.buf){ stat.textContent='LOAD a song first'; return; } if(recording){ stat.textContent='a take is recording: stop it first'; return; } if(typeof CleanRec==='undefined'||!CleanRec.render||!CleanRec.ok()){ stat.textContent='this browser cannot render (it needs WebCodecs: Chrome or Edge on a computer)'; return; } const P=CleanRec.plan?await CleanRec.plan({dur:SONG.buf.duration,pos:(SONG.aud&&SONG.aud.currentTime)||0,fps:FPS,head:HEAD,tail:TAIL,say:t=>{ stat.textContent=t; }}) :{light:false,size:RS.size,mbps:mbps(),from:0,len:SONG.buf.duration,whole:true,slow:''}; if(!P) return; const slow=P.slow||''; pauseAll(''); const fps=FPS, per=Math.max(1,Math.round(SIM/fps)), buf=SONG.buf, len=buf.duration, sr=buf.sampleRate; const ch=Math.min(2,buf.numberOfChannels), pcm=[...Array(ch)].map((_,c)=>buf.getChannelData(c)); rendering=true; renderStop=false; renderBtn.classList.add('on'); renderBtn.textContent='■ STOP RENDER'; stat.textContent='⏺ RENDER — getting the encoder ready…'; const keep={AN,SPEC,TD}; let msg='', R=null, sleep=()=>{}; try{ if(CleanRec.awake) sleep=await CleanRec.awake(); }catch(e){} try{ const STEPS=Math.ceil((HEAD+len+TAIL)*SIM), K0=P.whole||!(P.from>0)?0:Math.round((HEAD+P.from)*SIM/per)*per, KEND=P.whole?STEPS:Math.min(STEPS,K0+Math.round((P.len+(K0?0:HEAD))*SIM)); R=await CleanRec.render({canvas:cv,w:P.size,h:P.size,fps,mbps:P.mbps,head:HEAD-K0/SIM,audio:{pcm,sr,ch}}); if(!R){ msg='this browser cannot render '+P.size+'² at '+fps+' fps'; return; } const ears=makeEars(buf); AN=ears; SPEC=new Float32Array(ears.frequencyBinCount); TD=new Float32Array(ears.fftSize); sizeTo(P.size); freshScene(); PLATE.S.D2=RENDER_D2; playing=false; const steps=Math.ceil((HEAD+len+TAIL)*SIM), hk=Math.round(HEAD*SIM), frames=Math.ceil((KEND-K0)/per), w0=performance.now(); let f=0, ok=true, pure=false; for(let k=0;k=K0){ ok=await R.frame(f++); if(!ok) break; } } if(k%30===0){ const done=f/frames, el=(performance.now()-w0)/1000; stat.textContent=k0.02?' · about '+mmss(el/done*(1-done))+' left':'')+slow; if(playing) readout(t,pure); else read('THE ASKING · at rest'); await breathe(); if(renderStop) break; } } if(renderStop||!ok){ R.cancel(); msg=renderStop?'render stopped · nothing was saved':'the encoder stopped at frame '+f+' · nothing was saved'; return; } stat.textContent='⏺ RENDER — closing the file…'; const res=await R.finish(f/fps); if(!res||!res.blob){ msg='the MP4 could not be closed'+(res&&res.err?': '+res.err:''); return; } const url=URL.createObjectURL(res.blob), a=document.createElement('a'); a.href=url; a.download='render-Z-'+new Date().toISOString().replace(/[:.]/g,'-')+(P.light?'-light-'+P.size:'')+'.mp4'; a.click(); setTimeout(()=>URL.revokeObjectURL(url),4000); msg='rendered · MP4 · '+res.fps+' fps · '+res.frames+' frames · '+mmss(res.frames/res.fps)+' · in '+mmss((performance.now()-w0)/1000)+' · nothing skipped'+(res.aerr?' · ⚠ the sound encoder complained':'')+' · nothing uploaded'; }catch(e){ try{ if(R) R.cancel(); }catch(_){} msg='the render stopped: '+(e&&e.message||e); } finally{ sleep(); AN=keep.AN; SPEC=keep.SPEC; TD=keep.TD; delete PLATE.S.D2; playing=false; rendering=false; PLATE.rest(false); renderBtn.classList.remove('on'); renderBtn.textContent='⏺ RENDER'; last=performance.now()/1000; fit(); stat.textContent=msg; } } renderBtn.onclick=renderVideo; window.__PLATE=PLATE; })();