Spaces:
Running
Running
Download components/studio/Studio.tsx from Reubencf/Score-Studio: direct link, hf CLI and curl.
- Browser
- Download file 19.2 kB
-
https://huggingface.co/spaces/Reubencf/Score-Studio/resolve/main/components/studio/Studio.tsx
- Command line
-
hf download hf://spaces/Reubencf/Score-Studio/components/studio/Studio.tsx
-
curl -L -o Studio.tsx https://huggingface.co/spaces/Reubencf/Score-Studio/resolve/main/components/studio/Studio.tsx
19.2 kB
| 'use client'; | |
| import {useCallback,useEffect,useMemo,useRef,useState} from 'react'; | |
| import {Play,Pause,Square,SkipBack,Repeat,Circle,MousePointer2,TextCursor,Undo2,Redo2,ZoomIn,ZoomOut, | |
| SlidersHorizontal,Magnet,Scissors,LogOut,Mic,FolderOpen,FileMusic} from 'lucide-react'; | |
| import ThemeToggle from '@/components/ThemeToggle'; | |
| import Arrangement,{type Tool} from './Arrangement'; | |
| import Mixer from './Mixer'; | |
| import AIPanel,{type Create} from './AIPanel'; | |
| import Browser,{INSTRUMENTS} from './Browser'; | |
| import {useJobs,type Target} from './useJobs'; | |
| import {type Project,type Range,type Selection,type Track,EMPTY,addTrack,clipEnd,formatBars,formatTime,projectEnd,splitClip,uid} from '@/lib/studio/model'; | |
| import {Player,encodeWav,renderMix} from '@/lib/studio/audio'; | |
| type History={past:Project[];present:Project;future:Project[]}; | |
| const DEPTH=100; | |
| const DIVISIONS=[{value:0,label:'Off'},{value:0.25,label:'Bar'},{value:1,label:'Beat'},{value:2,label:'1/8'},{value:4,label:'1/16'}]; | |
| /** | |
| * Undo history over immutable project snapshots. Drags and fader moves call | |
| * `checkpoint` once when the gesture starts and `preview` while it moves, so a | |
| * whole gesture is one undo step. | |
| */ | |
| function useHistory(initial:Project){ | |
| const [state,setState]=useState<History>({past:[],present:initial,future:[]}); | |
| const commit=useCallback((fn:(p:Project)=>Project)=>setState(s=>{ | |
| const next=fn(s.present); | |
| return next===s.present?s:{past:[...s.past.slice(-DEPTH),s.present],present:next,future:[]}; | |
| }),[]); | |
| const preview=useCallback((fn:(p:Project)=>Project)=>setState(s=>({...s,present:fn(s.present)})),[]); | |
| const checkpoint=useCallback(()=>setState(s=>({past:[...s.past.slice(-DEPTH),s.present],present:s.present,future:[]})),[]); | |
| const undo=useCallback(()=>setState(s=>s.past.length?{past:s.past.slice(0,-1),present:s.past[s.past.length-1],future:[s.present,...s.future]}:s),[]); | |
| const redo=useCallback(()=>setState(s=>s.future.length?{past:[...s.past,s.present],present:s.future[0],future:s.future.slice(1)}:s),[]); | |
| return {project:state.present,canUndo:state.past.length>0,canRedo:state.future.length>0,commit,preview,checkpoint,undo,redo}; | |
| } | |
| export default function Studio({user,signOut}:{user:{name:string};signOut:()=>void}){ | |
| const player=useMemo(()=>new Player(),[]); | |
| const buffers=useRef(new Map<string,AudioBuffer>()).current; | |
| const history=useHistory(EMPTY); | |
| const {project,commit,preview,checkpoint}=history; | |
| const latest=useRef(project);latest.current=project; | |
| const [selection,setSelection]=useState<Selection>({clips:[],range:null}); | |
| const [zoom,setZoom]=useState(36),[tool,setTool]=useState<Tool>('pointer'),[division,setDivision]=useState(1); | |
| const [playing,setPlaying]=useState(false),[loop,setLoop]=useState<Range|null>(null); | |
| const [master,setMaster]=useState(1),[mixerOpen,setMixerOpen]=useState(true); | |
| const [create,setCreate]=useState<Create>({kind:'part',instrument:INSTRUMENTS[0].id,label:INSTRUMENTS[0].label}); | |
| const [recording,setRecording]=useState(false),[recordSeconds,setRecordSeconds]=useState(0); | |
| const [exporting,setExporting]=useState(false),[notice,setNotice]=useState(''),[expired,setExpired]=useState(false); | |
| const recorder=useRef<{media:MediaRecorder;stream:MediaStream;from:number;latency:number}|null>(null); | |
| const clock=useRef<HTMLSpanElement>(null),bars=useRef<HTMLSpanElement>(null); | |
| const position=useCallback(()=>player.position(),[player]); | |
| const jobs=useJobs({player,buffers,project:()=>latest.current,playhead:position,commit,signedOut:()=>setExpired(true)}); | |
| useEffect(()=>()=>{player.close();recorder.current?.stream.getTracks().forEach(t=>t.stop());},[player]); | |
| useEffect(()=>{ | |
| const warn=(event:BeforeUnloadEvent)=>{if(latest.current.clips.length){event.preventDefault();}}; | |
| window.addEventListener('beforeunload',warn);return()=>window.removeEventListener('beforeunload',warn); | |
| },[]); | |
| const play=useCallback(async(from:number)=>{ | |
| await player.play(latest.current,buffers,from,master);setPlaying(true); | |
| },[buffers,master,player]); | |
| const stop=useCallback(()=>{player.stop();setPlaying(false);},[player]); | |
| const toggle=useCallback(()=>{if(player.playing)stop();else void play(player.position());},[play,player,stop]); | |
| const seek=useCallback((time:number)=>{if(player.playing)void play(time);else player.seek(time);},[play,player]); | |
| // Mixer moves apply live; clip edits reschedule playback once they settle. | |
| useEffect(()=>{if(player.ctx)player.mix(project,master);},[player,project.tracks,master]); | |
| useEffect(()=>{ | |
| if(!player.playing)return; | |
| const timer=setTimeout(()=>{if(player.playing)void player.play(latest.current,buffers,player.position(),master);},120); | |
| return()=>clearTimeout(timer); | |
| },[buffers,master,player,project.clips]); | |
| // The clock is drawn every frame outside React. | |
| useEffect(()=>{ | |
| let frame=0; | |
| const tick=()=>{ | |
| const at=player.position(); | |
| if(clock.current)clock.current.textContent=formatTime(at); | |
| if(bars.current)bars.current.textContent=formatBars(at,latest.current.bpm); | |
| frame=requestAnimationFrame(tick); | |
| }; | |
| frame=requestAnimationFrame(tick); | |
| return()=>cancelAnimationFrame(frame); | |
| },[player]); | |
| // Looping and stopping at the end run on a timer, because animation frames | |
| // pause in a background tab while the audio keeps playing. | |
| useEffect(()=>{ | |
| const timer=setInterval(()=>{ | |
| if(!player.playing)return; | |
| const at=player.position(); | |
| if(loop&&at>=loop.end)void player.play(latest.current,buffers,loop.start,master); | |
| else if(!loop&&!recorder.current&&at>projectEnd(latest.current)+1&&latest.current.clips.length){player.stop();player.seek(0);setPlaying(false);} | |
| },25); | |
| return()=>clearInterval(timer); | |
| },[buffers,loop,master,player]); | |
| useEffect(()=>{if(!recording)return;const t=setInterval(()=>setRecordSeconds(s=>s+1),1000);return()=>clearInterval(t);},[recording]); | |
| useEffect(()=>{if(!notice)return;const t=setTimeout(()=>setNotice(''),5000);return()=>clearTimeout(t);},[notice]); | |
| const addAudio=useCallback((name:string,buffer:AudioBuffer,start:number)=>{ | |
| const id=uid();buffers.set(id,buffer); | |
| commit(p=>{ | |
| const [next,track]=addTrack(p,name); | |
| return {...next,clips:[...next.clips,{id:uid(),trackId:track.id,buffer:id,start:Math.max(0,start),offset:0,duration:buffer.duration,name}]}; | |
| }); | |
| },[buffers,commit]); | |
| const importFiles=useCallback(async(files:File[],at=player.position())=>{ | |
| for(const file of files){ | |
| if(file.size>150*1024*1024){setNotice(`${file.name} is over 150 MB.`);continue;} | |
| try{addAudio(file.name.replace(/\.[^.]+$/,'').slice(0,40)||'Audio',await player.decode(await file.arrayBuffer()),at);} | |
| catch{setNotice(`${file.name} could not be read. Try MP3, WAV, M4A, OGG or FLAC.`);} | |
| } | |
| },[addAudio,player]); | |
| /** | |
| * Record over the project. Playback starts with the recorder so the singer | |
| * hears the band, and the take is moved earlier by the output and input | |
| * latency so it lands where it was sung rather than where it was captured. | |
| */ | |
| const record=useCallback(async()=>{ | |
| if(recorder.current){recorder.current.media.stop();return;} | |
| if(!navigator.mediaDevices?.getUserMedia){setNotice('Recording needs a supported browser and a secure connection.');return;} | |
| try{ | |
| const stream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:false,noiseSuppression:false,autoGainControl:false}}); | |
| const media=new MediaRecorder(stream),parts:Blob[]=[]; | |
| const from=player.position(); | |
| const input=(stream.getAudioTracks()[0]?.getSettings() as {latency?:number}).latency||0; | |
| recorder.current={media,stream,from,latency:0}; | |
| media.ondataavailable=e=>{if(e.data.size)parts.push(e.data);}; | |
| media.onstart=()=>{ | |
| if(!recorder.current)return; | |
| recorder.current.latency=player.latency()+input; | |
| if(latest.current.clips.some(c=>!c.muted))void play(from); | |
| }; | |
| media.onstop=async()=>{ | |
| const take=recorder.current;recorder.current=null; | |
| stream.getTracks().forEach(t=>t.stop());setRecording(false);stop(); | |
| try{ | |
| const buffer=await player.decode(await new Blob(parts,{type:media.mimeType}).arrayBuffer()); | |
| const count=latest.current.tracks.filter(t=>t.name.startsWith('Take')).length+1; | |
| addAudio(`Take ${count}`,buffer,take?take.from-take.latency:from); | |
| player.seek(from); | |
| }catch{setNotice('The recording could not be decoded. Try again, or import a file.');} | |
| }; | |
| media.start(500);setRecordSeconds(0);setRecording(true); | |
| }catch{setNotice('Microphone access was not available. Allow it in your browser, or import a file.');} | |
| },[addAudio,play,player,stop]); | |
| const exportMix=useCallback(async()=>{ | |
| setExporting(true); | |
| try{ | |
| const buffer=await renderMix(latest.current,buffers,{start:0,end:projectEnd(latest.current),rate:48000,gain:master}); | |
| save(encodeWav(buffer),'kiku-mix.wav'); | |
| }catch{setNotice('The mix could not be rendered.');} | |
| finally{setExporting(false);} | |
| },[buffers,master]); | |
| const download=useCallback(async(target:Target)=>{ | |
| const buffer=await renderMix(latest.current,buffers,{start:target.start,end:target.end,clips:target.clips.map(c=>c.id),mixer:target.clips.length>1}); | |
| save(encodeWav(buffer),'kiku-selection.wav'); | |
| },[buffers]); | |
| const changeTrack=useCallback((id:string,patch:Partial<Track>)=> | |
| preview(p=>({...p,tracks:p.tracks.map(t=>t.id===id?{...t,...patch}:t)})),[preview]); | |
| const renameTrack=useCallback((id:string,patch:Partial<Track>)=> | |
| commit(p=>({...p,tracks:p.tracks.map(t=>t.id===id?{...t,...patch}:t)})),[commit]); | |
| const removeTrack=useCallback((id:string)=>{ | |
| commit(p=>({...p,tracks:p.tracks.filter(t=>t.id!==id),clips:p.clips.filter(c=>c.trackId!==id)})); | |
| setSelection({clips:[],range:null}); | |
| },[commit]); | |
| /** Split selected clips at the playhead, or every clip a range touches at its edges. */ | |
| const split=useCallback(()=>{ | |
| const range=selection.range,at=player.position(); | |
| commit(p=>{ | |
| let next=p; | |
| const ids=range?p.clips.filter(c=>range.tracks.includes(c.trackId)).map(c=>c.id):selection.clips; | |
| for(const id of ids){ | |
| if(range){let parts:string[];[next,parts]=splitClip(next,id,range.start);for(const part of parts)[next]=splitClip(next,part,range.end);} | |
| else [next]=splitClip(next,id,at); | |
| } | |
| return next; | |
| }); | |
| },[commit,player,selection]); | |
| const remove=useCallback(()=>{ | |
| const range=selection.range; | |
| if(range){ | |
| commit(p=>{ | |
| let next=p; | |
| for(const clip of p.clips.filter(c=>range.tracks.includes(c.trackId))){ | |
| let parts:string[];[next,parts]=splitClip(next,clip.id,range.start); | |
| for(const part of parts)[next]=splitClip(next,part,range.end); | |
| } | |
| return {...next,clips:next.clips.filter(c=>!(range.tracks.includes(c.trackId)&&c.start>=range.start-1e-6&&clipEnd(c)<=range.end+1e-6))}; | |
| }); | |
| }else if(selection.clips.length)commit(p=>({...p,clips:p.clips.filter(c=>!selection.clips.includes(c.id))})); | |
| setSelection({clips:[],range:null}); | |
| },[commit,selection]); | |
| const duplicate=useCallback(()=>{ | |
| const chosen=latest.current.clips.filter(c=>selection.clips.includes(c.id)); | |
| if(!chosen.length)return; | |
| const span=Math.max(...chosen.map(clipEnd))-Math.min(...chosen.map(c=>c.start)); | |
| const copies=chosen.map(c=>({...c,id:uid(),start:c.start+span})); | |
| commit(p=>({...p,clips:[...p.clips,...copies]})); | |
| setSelection({clips:copies.map(c=>c.id),range:null}); | |
| },[commit,selection]); | |
| const toggleMute=useCallback(()=>{ | |
| if(!selection.clips.length)return; | |
| commit(p=>{const all=p.clips.filter(c=>selection.clips.includes(c.id)).every(c=>c.muted); | |
| return {...p,clips:p.clips.map(c=>selection.clips.includes(c.id)?{...c,muted:!all}:c)};}); | |
| },[commit,selection]); | |
| const toggleLoop=useCallback(()=>{ | |
| if(loop){setLoop(null);return;} | |
| const range=selection.range; | |
| const chosen=latest.current.clips.filter(c=>selection.clips.includes(c.id)); | |
| const start=range?range.start:chosen.length?Math.min(...chosen.map(c=>c.start)):player.position(); | |
| const end=range?range.end:chosen.length?Math.max(...chosen.map(clipEnd)):start+4*60/latest.current.bpm*4; | |
| setLoop({start,end,tracks:[]}); | |
| },[loop,player,selection]); | |
| useEffect(()=>{ | |
| const key=(event:KeyboardEvent)=>{ | |
| const target=event.target; | |
| if(target instanceof Element&&target.closest('input,textarea,select,[contenteditable=true]'))return; | |
| const mod=event.ctrlKey||event.metaKey,k=event.key.toLowerCase(); | |
| const act=(fn:()=>void)=>{event.preventDefault();fn();}; | |
| if(k===' ')act(toggle); | |
| else if(mod&&k==='z')act(event.shiftKey?history.redo:history.undo); | |
| else if(mod&&k==='y')act(history.redo); | |
| else if(mod&&k==='d')act(duplicate); | |
| else if(mod&&k==='a')act(()=>setSelection({clips:latest.current.clips.map(c=>c.id),range:null})); | |
| else if(mod)return; | |
| else if(k==='delete'||k==='backspace')act(remove); | |
| else if(k==='s')act(split); | |
| else if(k==='m')act(toggleMute); | |
| else if(k==='l')act(toggleLoop); | |
| else if(k==='v')act(()=>setTool('pointer')); | |
| else if(k==='r')act(()=>setTool('range')); | |
| else if(k==='home'||k==='enter')act(()=>seek(0)); | |
| else if(k==='escape')act(()=>setSelection({clips:[],range:null})); | |
| else if(k==='='||k==='+')act(()=>setZoom(z=>Math.min(240,z*1.25))); | |
| else if(k==='-')act(()=>setZoom(z=>Math.max(4,z/1.25))); | |
| }; | |
| window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key); | |
| },[duplicate,history.redo,history.undo,remove,seek,split,toggle,toggleLoop,toggleMute]); | |
| const ghosts=jobs.jobs.filter(j=>!j.error).map(j=>({id:j.id,label:`${j.label}: ${j.status}`,start:j.start,end:j.end})); | |
| const empty=<div className="empty-state"> | |
| <h2>Start with your voice.</h2> | |
| <p>Record a take or drop audio here. Then drag across any part of a clip to restyle or rewrite just that stretch, select several clips to remix them together, or build a band that follows your beat and chords.</p> | |
| <div><button className="run-button" onClick={record}><Mic size={15}/> Record a take</button> | |
| <label className="tool-button wide"><FolderOpen size={15}/> Import audio | |
| <input type="file" accept="audio/*" multiple className="sr-only" onChange={e=>{void importFiles([...(e.target.files||[])]);e.target.value='';}}/></label></div> | |
| </div>; | |
| return <div className={'studio'+(mixerOpen?'':' no-mixer')}> | |
| <header className="studio-bar"> | |
| <span className="studio-logo">KIKU<span>STUDIO</span></span> | |
| <div className="transport" role="group" aria-label="Transport"> | |
| <button onClick={()=>seek(0)} title="To start (Home)"><SkipBack size={16}/></button> | |
| <button className={'play'+(playing?' on':'')} onClick={toggle} title="Play / pause (Space)">{playing?<Pause size={17}/>:<Play size={17}/>}</button> | |
| <button onClick={()=>{stop();player.seek(0);}} title="Stop"><Square size={15}/></button> | |
| <button className={'rec'+(recording?' on':'')} onClick={record} title="Record a take"><Circle size={15}/></button> | |
| <button className={loop?'on':''} onClick={toggleLoop} title="Loop the selection (L)" aria-pressed={!!loop}><Repeat size={16}/></button> | |
| </div> | |
| <div className="lcd" aria-label="Position"><span ref={clock} className="lcd-time">0:00.000</span><span ref={bars} className="lcd-bars">1.1.00</span></div> | |
| <div className="lcd small"><span className="lcd-label">BPM</span><span>{project.bpm}</span></div> | |
| {project.key&&<div className="lcd small"><span className="lcd-label">KEY</span><span>{project.key}</span></div>} | |
| <div className="bar-spacer"/> | |
| <a className="tool-button" href="/score" title="Turn a recording into sheet music"><FileMusic size={15}/> Sheet music</a> | |
| <span className="studio-user">{user.name}</span> | |
| <button className="tool-button" onClick={signOut} title="Sign out" aria-label="Sign out"><LogOut size={15}/></button> | |
| <ThemeToggle className="inline-toggle"/> | |
| </header> | |
| <div className="studio-tools" role="toolbar" aria-label="Editing tools"> | |
| <div className="tool-group"> | |
| <button className={'tool-button'+(tool==='pointer'?' on':'')} onClick={()=>setTool('pointer')} title="Select and move clips (V)"><MousePointer2 size={15}/></button> | |
| <button className={'tool-button'+(tool==='range'?' on':'')} onClick={()=>setTool('range')} title="Select a stretch of time (R), or drag on empty space"><TextCursor size={15}/></button> | |
| <button className="tool-button" onClick={split} title="Split at the playhead (S)"><Scissors size={15}/></button> | |
| </div> | |
| <div className="tool-group"> | |
| <button className="tool-button" onClick={history.undo} disabled={!history.canUndo} title="Undo (Ctrl+Z)"><Undo2 size={15}/></button> | |
| <button className="tool-button" onClick={history.redo} disabled={!history.canRedo} title="Redo (Ctrl+Shift+Z)"><Redo2 size={15}/></button> | |
| </div> | |
| <label className="tool-group snap" title="Snap to grid"><Magnet size={14}/> | |
| <select value={division} onChange={e=>setDivision(Number(e.target.value))} aria-label="Snap"> | |
| {DIVISIONS.map(d=><option key={d.value} value={d.value}>{d.label}</option>)}</select></label> | |
| <div className="tool-group"> | |
| <button className="tool-button" onClick={()=>setZoom(z=>Math.max(4,z/1.25))} title="Zoom out (-)"><ZoomOut size={15}/></button> | |
| <button className="tool-button" onClick={()=>setZoom(z=>Math.min(240,z*1.25))} title="Zoom in (+)"><ZoomIn size={15}/></button> | |
| </div> | |
| <span className="tool-hint">{tool==='range'?'Drag across clips to select a stretch of time':'Click clips to select · Shift-click for several · Drag empty space for a range'}</span> | |
| <button className={'tool-button'+(mixerOpen?' on':'')} onClick={()=>setMixerOpen(o=>!o)} title="Show the mixer"><SlidersHorizontal size={15}/> Mixer</button> | |
| </div> | |
| <Browser project={project} create={selection.clips.length||selection.range?null:create} | |
| choose={next=>{setCreate(next);setSelection({clips:[],range:null});}} | |
| recording={recording} recordSeconds={recordSeconds} record={record} | |
| importFiles={files=>void importFiles(files)} exportMix={exportMix} exporting={exporting} | |
| setBpm={bpm=>commit(p=>({...p,bpm}))}/> | |
| <Arrangement project={project} buffers={buffers} selection={selection} select={setSelection} | |
| zoom={zoom} setZoom={setZoom} tool={tool} division={division} | |
| position={position} playing={playing} seek={seek} | |
| edit={{checkpoint,preview,commit}} ghosts={ghosts} loop={loop} | |
| changeTrack={renameTrack} removeTrack={removeTrack} dropFiles={(files,at)=>void importFiles(files,at)} empty={empty}/> | |
| <AIPanel project={project} selection={selection} create={create} jobs={jobs} download={t=>void download(t)}/> | |
| {mixerOpen&&<Mixer project={project} player={player} master={master} setMaster={setMaster} | |
| change={changeTrack} commitMix={checkpoint}/>} | |
| {notice&&<p className="studio-notice" role="status">{notice}</p>} | |
| {expired&&<div className="studio-notice expired" role="alert">Your sign-in expired. Your project is still here. | |
| <button onClick={()=>{window.open('/auth/login?popup=1','kiku-sign-in','width=520,height=730');setExpired(false);}}>Sign in again</button></div>} | |
| </div>; | |
| } | |
| function save(blob:Blob,name:string){ | |
| const url=URL.createObjectURL(blob),link=document.createElement('a'); | |
| link.href=url;link.download=name;link.click(); | |
| setTimeout(()=>URL.revokeObjectURL(url),10000); | |
| } | |