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='reading '+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 song beside the plate, the drive on its clock), ⏺▶ TAKE, or ⏺ RENDER · nothing leaves this machine'; } catch(err){ stat.textContent=(typeof CleanRec!=='undefined'&&CleanRec.whyNot)?CleanRec.whyNot(err):'could not read 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=2048, 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…'; let msg='', R=null, sleep=()=>{}; const keepEars={AN,SPEC}; 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); sizeTo(P.size); freshScene(); playing=false; songEnd=0; 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; readout(); 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-Y-'+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=keepEars.AN; SPEC=keepEars.SPEC; resetEar(); playing=false; songEnd=0; rendering=false; PLATE.S.state='rest'; renderBtn.classList.remove('on'); renderBtn.textContent='⏺ RENDER'; last=performance.now()/1000; fit(); stat.textContent=msg; } } renderBtn.onclick=renderVideo; window.__PLATE=PLATE; })();