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 WHEEL · 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-X-'+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; })();