snow=22; nextCh='ovni'; enc.until=t; fly.force=false; enc.left=true; lastSwitch=t; } if(enc.kind==='home'&&planet&&planet.home&&planet.z<.45&&!enc.cheered){ enc.cheered=true; cheerSome(1); } } } function drawEncounter(c,t){ if(!enc||NOW>enc.until) return; const p=enc.p, sd=enc.side; if(enc.kind==='saucer'){ const z=.24+p*.95, x=VX+sd*WW*.075/z, y=VY+WW*.012/z, sw=WW*.075/z, hh=(NOTEHUE[(F.pc+6)%12]); c.save(); c.globalAlpha=Math.min(1,(1-p)*3); saucer(c,x,y,sw,t,.6); c.fillStyle=hsl(hh,60,50); c.beginPath(); c.arc(x,y-sw*.12,sw*.09,0,7); c.fill(); c.strokeStyle=hsl(hh,70,60); c.lineWidth=Math.max(1,sw*.025); c.beginPath(); c.moveTo(x-sw*.04,y-sw*.2); c.lineTo(x-sw*.07,y-sw*.3); c.moveTo(x+sw*.04,y-sw*.2); c.lineTo(x+sw*.07,y-sw*.3); c.stroke(); const wv=Math.sin(t*14)*sw*.05; c.beginPath(); c.moveTo(x-sd*sw*.08,y-sw*.1); c.lineTo(x-sd*sw*.16+wv,y-sw*.26); c.stroke(); c.restore(); } if(enc.kind==='comet'){ const hx=WW*(sd>0?1.1-p*1.3:-.1+p*1.3), hy=WW*(.08+p*.36), tx=hx+sd*70, ty=hy-26; const g2=c.createLinearGradient(hx,hy,tx,ty); g2.addColorStop(0,'rgba(220,250,255,.95)'); g2.addColorStop(1,'rgba(120,200,255,0)'); c.globalCompositeOperation='lighter'; c.strokeStyle=g2; c.lineWidth=4; c.beginPath(); c.moveTo(hx,hy); c.lineTo(tx,ty); c.stroke(); c.lineWidth=1.5; c.beginPath(); c.moveTo(hx,hy); c.lineTo(tx+sd*10,ty+14); c.stroke(); c.fillStyle='rgba(255,255,255,.95)'; c.beginPath(); c.arc(hx,hy,2.6,0,7); c.fill(); for(let i=0;i<5;i++){ c.fillStyle=`rgba(200,240,255,${.6-i*.1})`; c.fillRect(hx+sd*(8+i*9)+Math.sin(t*9+i)*2,hy-3-i*3,1.3,1.3); } c.globalCompositeOperation='source-over'; } if(enc.kind==='station'){ const z=1-p*.9, x=VX+sd*WW*.06/z, y=VY-WW*.01/z, rx=WW*.055/z, ry=rx*.38, rot=t*.6; c.save(); c.globalAlpha=Math.min(1,(1-p)*5); c.strokeStyle='#5c6672'; c.lineWidth=Math.max(1.5,rx*.14); c.beginPath(); c.ellipse(x,y,rx,ry,0,0,7); c.stroke(); c.strokeStyle='#3a424d'; c.lineWidth=Math.max(1,rx*.04); for(let i=0;i<4;i++){ const a=rot+i*Math.PI/2; c.beginPath(); c.moveTo(x,y); c.lineTo(x+Math.cos(a)*rx,y+Math.sin(a)*ry); c.stroke(); } c.fillStyle='#7a8592'; c.beginPath(); c.arc(x,y,rx*.16,0,7); c.fill(); for(let i=0;i<12;i++){ const a=rot*1.2+i*Math.PI/6; if(Math.sin(t*3+i*1.7)>.2){ c.fillStyle=i%3?'rgba(255,220,140,.9)':'rgba(140,255,200,.9)'; c.fillRect(x+Math.cos(a)*rx-1,y+Math.sin(a)*ry-1,1.8,1.8); } } if(Math.sin(t*4)>.5){ c.fillStyle='rgb(255,80,70)'; c.fillRect(x-rx-2,y-1,2,2); c.fillStyle='rgb(80,255,120)'; c.fillRect(x+rx,y-1,2,2); } c.restore(); } } let DST=null, DSTU=null; let ch='cockpit', snow=0, lastSwitch=-99; const sc=document.createElement('canvas'); sc.width=sc.height=Math.round(WW*RES); const s=sc.getContext('2d'); function drawSnow(c){ c.fillStyle='#0b0e0c'; c.fillRect(0,0,WW,WW); for(let i=0;i<900;i++){ const v=rnd()*200|0; c.fillStyle=`rgb(${v},${v+20>255?255:v+20},${v})`; c.fillRect(rnd()*WW|0,rnd()*WW|0,2,1); } c.fillStyle='rgba(255,255,255,.08)'; c.fillRect(0,(snow*13)%WW,WW,18); } function step(t,dt){ NOW=t; drive(t); while(taps.length){ const [x,y]=taps.shift(); applyTap(x,y); } for(let k=0;k<12;k++) ACT[k]*=Math.exp(-dt/.5); ACT[F.pc]=Math.max(ACT[F.pc],.55+.45*F.level); for(let k=0;k<12;k++){ const w=.5+.5*Math.sin(t*1.1-k*Math.PI/6); ACT[FIFTHS[k]]=Math.max(ACT[FIFTHS[k]],.10*w*w); } patrol+=dt*(.6+F.level*2.4); flash=Math.max(0,flash-dt*1.5); journey(t); if(F.special&&snow<=0&&t-lastSwitch>12&&!(enc&&t0){ snow--; if(snow===11){ ch=nextCh; cheerSome(1); } } stepOvni(dt); stepRain(dt); stepCockpit(dt); stepCrowd(t,dt); } function picture(t){ b.setTransform(RES,0,0,RES,0,0); s.setTransform(RES,0,0,RES,0,0); b.fillStyle='#04050a'; b.fillRect(0,0,N,N); drawTiles(t); if(snow>0) drawSnow(s); else if(ch==='ovni') drawOvni(s,t); else if(ch==='rain') drawRain(s,t); else drawCockpit(s,t); b.drawImage(sc,WO,WO,WW,WW); b.strokeStyle='rgba(120,255,210,.35)'; b.lineWidth=1; b.strokeRect(WO-1.5,WO-1.5,WW+3,WW+3); b.strokeStyle='rgba(226,255,240,.75)'; for(const [x,y,dx,dy] of [[WO,WO,1,1],[WO+WW,WO,-1,1],[WO,WO+WW,1,-1],[WO+WW,WO+WW,-1,-1]]){ b.beginPath(); b.moveTo(x+dx*10,y); b.lineTo(x,y); b.lineTo(x,y+dy*10); b.stroke(); } b.font='500 8px ui-monospace,Menlo,monospace'; b.fillStyle='rgba(183,255,208,.85)'; b.fillText(snow>0?'· · ·':(ch==='ovni'?'CH 2 · OVNI':ch==='rain'?'CH 5 · RAIN':'CH 3 · COCKPIT')+' '+NOTE[F.pc],WO+6,WO+12); if(!DST||DST.width!==OUT){ DST=g.createImageData(OUT,OUT); DSTU=new Uint32Array(DST.data.buffer); } const src=b.getImageData(0,0,NR,NR), dst=DST, S=new Uint32Array(src.data.buffer), D=DSTU; for(let i=0;i0&&snow<8){ ctx.globalCompositeOperation='lighter'; ctx.globalAlpha=.35; ctx.drawImage(gl,3,0); ctx.globalAlpha=1; ctx.globalCompositeOperation='source-over'; } ctx.drawImage(lines,0,0); } function applyTap(bx,by){ const wx=bx-WO, wy=by-WO; if(wx>=0&&wy>=0&&wxi===ti&&j===tj); if(k>=0){ for(const n of [k-1,k,k+1]){ const m=CROWD[(n+24)%24]; m.tap={x:bx,y:by,until:NOW+1.6}; m.blink=0; } CROWD[k].cheer=1.7; return; } if(ti===0||tj===0||ti===8||tj===8) patrol+=4; } buildGlass(); function resize(px){ OUT=px; RES=OUT/N; NR=OUT; cv.width=cv.height=OUT; bd.width=bd.height=NR; gl.width=gl.height=OUT; sc.width=sc.height=Math.round(WW*RES); buildGlass(); } function tapAt(x,y){ x=Math.floor(x); y=Math.floor(y); if(x<0||y<0||x>=OUT||y>=OUT) return; const m=MAP[y*OUT+x]; if(m<0) return; taps.push([(m%NR)/RES, Math.floor(m/NR)/RES]); } return {step,picture,resize,tapAt,startSong:planJourney,endSong:endJourney, get ch(){return ch}, get snow(){return snow}, get fly(){return fly.mode}, get enc(){return enc}, get F(){return F}, get NOW(){return NOW}, get px(){return OUT}}; } const NOTE=['C','C♯','D','E♭','E','F','F♯','G','A♭','A','B♭','B']; let E; function resetEars(){ E={t:-1,agc:.05,level:0,slow:.3,peak:-70,k:0,m:0,h:0,kA:0,mA:0,hA:0,lk:-9,lm:-9,lh:-9,pcAt:-9,lock:-9,special:-99,hotAt:-9}; } resetEars(); const EDGES=[]; for(let i=0;i<=14;i++) EDGES.push(40*Math.pow(12000/40,i/14)); function quiet(F){ F.level*=.96; F.on[0]=F.on[1]=F.on[2]=false; F.hot=F.lock=F.special=false; for(let i=0;i<14;i++) F.bands[i]*=.9; } function EARS(F,t){ if(!playing||!AN||!SPEC) return quiet(F); AN.getFloatFrequencyData(SPEC); AN.getFloatTimeDomainData(TD); const hz=AN.context.sampleRate/AN.fftSize, top=SPEC.length-2, bin=f=>Math.max(1,Math.min(top,Math.round(f/hz))); const dt=Math.max(0,Math.min(.25,E.t<0?0:t-E.t)); E.t=t; let rms=0; for(let i=0;imx) mx=db; } E.peak=Math.max(mx,E.peak-dt*6); for(let i=0;i<14;i++){ const v=Math.max(0,Math.min(1,(raw[i]-(E.peak-45))/45)); F.bands[i]=Math.max(v,F.bands[i]*Math.exp(-dt/.18)); } const sum=(a,b2)=>{ let s=0; for(let j=bin(a);j<=bin(b2);j++) s+=Math.pow(10,SPEC[j]/10); return s; }; const k=sum(40,160), m=sum(160,1500), h=sum(1500,9000), fk=Math.max(0,k-E.k), fm=Math.max(0,m-E.m), fh=Math.max(0,h-E.h); E.k=k; E.m=m; E.h=h; F.on[0]=fk>E.kA*3+1e-9&&k>1e-7&&t-E.lk>.16; if(F.on[0]) E.lk=t; F.on[1]=fm>E.mA*3+1e-9&&m>1e-7&&t-E.lm>.12; if(F.on[1]) E.lm=t; F.on[2]=fh>E.hA*3+1e-9&&h>1e-8&&t-E.lh>.09; if(F.on[2]) E.lh=t; F.hot=F.on[0]&&fk>E.kA*6&&t-E.hotAt>1.8; if(F.hot) E.hotAt=t; const bigHit=F.on[0]&&fk>E.kA*10&&E.level>.6; E.kA=E.kA*.96+fk*.04; E.mA=E.mA*.96+fm*.04; E.hA=E.hA*.96+fh*.04; let pb=bin(70), pv=-1e9; for(let j=bin(70);j<=bin(1000);j++) if(SPEC[j]>pv){ pv=SPEC[j]; pb=j; } if(pv>E.peak-30){ const a=SPEC[pb-1], c0=SPEC[pb], c=SPEC[pb+1], den=a-2*c0+c, d=den?Math.max(-.5,Math.min(.5,.5*(a-c)/den)):0; const f=(pb+d)*hz, pc=((Math.round(69+12*Math.log2(f/440))%12)+12)%12; if(pc!==F.pc&&t-E.pcAt>.08){ F.pc=pc; E.pcAt=t; } } F.lock=false; { let bb=bin(40), bvv=-1e9, mb=bin(160), mvv=-1e9; for(let j=bin(40);j<=bin(160);j++) if(SPEC[j]>bvv){ bvv=SPEC[j]; bb=j; } for(let j=bin(160);j<=bin(1200);j++) if(SPEC[j]>mvv){ mvv=SPEC[j]; mb=j; } if(bvv>E.peak-24&&mvv>E.peak-24){ const r=mb/bb, kk=Math.round(r); if(kk>=2&&kk<=6&&Math.abs(r-kk)/kk<.02&&t-E.lock>2.5){ F.lock=true; E.lock=t; } } } E.slow+=(E.level-E.slow)*(1-Math.exp(-dt/6)); F.special=false; if((bigHit||(E.level>.7&&E.slow<.42))&&t-E.special>10){ F.special=true; E.special=t; } } function makeEars(buf){ const n=FFT, half=n/2, sr=buf.sampleRate, L=buf.getChannelData(0), Rc=buf.numberOfChannels>1?buf.getChannelData(1):L, len=L.length, bits=Math.round(Math.log2(n)); 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 cv=$('cv'); let world=null; function fit(){ if(rendering) return; const st=$('wrap').getBoundingClientRect(), css=Math.max(280,Math.min(st.width-12,st.height-8)), D2=Math.min(2,devicePixelRatio||1); const px=Math.min(1000,Math.round(css*D2)); cv.style.width=cv.style.height=css+'px'; if(world) world.resize(px); else world=World(cv,px); } addEventListener('resize',fit); fit(); cv.addEventListener('pointerdown',e=>{ if(rendering||!world) return; const r=cv.getBoundingClientRect(); world.tapAt((e.clientX-r.left)/r.width*cv.width,(e.clientY-r.top)/r.height*cv.height); }); const CHN={ovni:'CH 2 · OVNI',rain:'CH 5 · RAIN',cockpit:'CH 3 · COCKPIT'}, STOP={launch:'the launch',earth:'Earth',saucer:'a friend passing',comet:'a comet',station:'a station',home:'home'}; function readout(){ const w=world, en=w.enc&&w.NOW0?'· · · changing channel':''+CHN[w.ch]+' · '+(playing?NOTE[w.F.pc]+en:'the pilot waits')); } let simT=0, acc=0, last=performance.now()/1000, songAt=0, paceAvg=1/60, paceSaid=false; function frame(){ requestAnimationFrame(frame); if(rendering) return; const nowW=performance.now()/1000, raw=nowW-last, dt=Math.min(.1,raw); last=nowW; if(playing&&raw<1){ paceAvg+=(raw-paceAvg)*.05; if(!paceSaid&&paceAvg>1/20&&simT-songAt>4){ paceSaid=true; stat.textContent='this device draws about '+Math.max(1,Math.round(1/paceAvg))+' pictures a second here, so the arcade moves in jumps, and a ⏺▶ TAKE would record it that way. ⏺ RENDER makes the video at full speed (it takes longer to make).'; } } acc+=dt; while(acc>=STEP){ acc-=STEP; simT+=STEP; world.step(simT,STEP); } if(playing&&SONG&&!takeActive&&simT>songAt+SONG.dur+TAIL&&SONG.aud.ended) songDone(); world.picture(simT); readout(); if(takeActive&&SONG&&anchor&&AC.currentTime>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=FFT; AN.smoothingTimeConstant=SMOOTH; 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){} } function begin(off){ playing=true; resetEars(); songAt=simT-off; world.startSong(SONG.dur,songAt); } function songDone(){ playing=false; world.endSong(); playBtn.classList.remove('on'); stat.textContent='played through — home, and the pilot waits · ▶ Play to fly 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) 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(); begin(off); playBtn.classList.add('on'); pauseBtn.classList.remove('on'); stat.textContent=off>0?'hearing · the journey goes on':'hearing · the launch: the pilot is off'; } function pauseAll(msg){ takeActive=false; if(SONG&&!SONG.aud.paused) SONG.aud.pause(); if(playing){ playing=false; world.endSong(); } if(recorder&&recorder.state==='recording') recorder.stop(); playBtn.classList.remove('on'); pauseBtn.classList.add('on'); takeBtn.classList.remove('on'); stat.textContent=msg||'paused · the pilot waits'; } 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; if(playing){ playing=false; world.endSong(); } startRec(()=>{ anchor=AC.currentTime+HEAD; setTimeout(()=>{ if(takeActive&&SONG){ SONG.aud.play(); begin(0); } }, Math.max(0,(anchor-AC.currentTime)*1000)); stat.textContent='TAKE — rest '+HEAD.toFixed(1)+'s · the song '+SONG.dur.toFixed(1)+'s · the last jump settling '+TAIL+'s'; }); } function finishTake(){ takeActive=false; if(SONG&&!SONG.aud.paused) SONG.aud.pause(); if(playing){ playing=false; world.endSong(); } 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,"keyway_mbps":{"computer_60":40,"computer_30":20,"phone_60":16,"phone_30":8}}; 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-AC-'+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':'')+(res.skipped?' · '+res.skipped+' frame'+(res.skipped>1?'s':'')+' held (the device was busy · ⏺ RENDER never holds one)':'')+' · 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 pilot launches), ⏺▶ 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); }); 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 nch=Math.min(2,buf.numberOfChannels), pcm=[...Array(nch)].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 rw=World(cv,P.size); 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:nch}}); 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); playing=false; resetEars(); 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; for(let k=0;khk+(len+.05)*SIM&&playing){ playing=false; } ears.seek(t-HEAD); rw.step(t,STEP); if(k%per===0){ rw.picture(t); if(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; read('⏺ RENDER · '+CHN[rw.ch]+(rw.enc&&rw.NOWURL.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; playing=false; rendering=false; resetEars(); renderBtn.classList.remove('on'); renderBtn.textContent='⏺ RENDER'; last=performance.now()/1000; { const px=world.px; world.resize(px); } fit(); stat.textContent=msg; } } renderBtn.onclick=renderVideo; window.__AC={get world(){return world}, World, makeEars, EARS, get playing(){return playing}, set playing(v){playing=v}, resetEars, setEars(an){ AN=an; SPEC=new Float32Array(an.frequencyBinCount); TD=new Float32Array(an.fftSize); }}; })();