stat.textContent='TAKE — head 0.500s · loop '+loopDur.toFixed(3)+'s ×'+recLoops+' · tail 0.500s'; }); } function finishTake(){ playing=false; stopClock(); takeActive=false; if(recorder && recorder.state==='recording') recorder.stop(); playBtn.classList.remove('on'); takeBtn.classList.remove('on'); } takeBtn.onclick=()=>{ if(takeActive) finishTake(); else take(); }; playBtn.onclick=play; pauseBtn.onclick=pause; 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=()=>'chime-L-'+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 folioIn=document.getElementById('folioIn'), loadBtn=document.getElementById('load'), folioLbl=document.getElementById('folio'); loadBtn.onclick=()=>folioIn.click(); folioIn.onchange=e=>{ const f=e.target.files&&e.target.files[0]; if(!f) return; const rd=new FileReader(); rd.onload=()=>{ try{ const data=JSON.parse(rd.result); const c=(data&&data.chart)?data.chart:data; if(!c || !Array.isArray(c.lines)) throw new Error('no lines'); pause(); CHART={ tempo:c.tempo||110, meter:c.meter||4, bars:c.bars||1, lines:c.lines }; folioLbl.innerHTML='♪ '+((data&&data.title)||f.name.replace(/\.[^.]+$/,'')); stat.textContent='loaded · press Play'; } catch(err){ stat.textContent='could not read that file — expected a folio (.music.json)'; } }; rd.readAsText(f); e.target.value=''; }; })();