'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({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()).current; const history=useHistory(EMPTY); const {project,commit,preview,checkpoint}=history; const latest=useRef(project);latest.current=project; const [selection,setSelection]=useState({clips:[],range:null}); const [zoom,setZoom]=useState(36),[tool,setTool]=useState('pointer'),[division,setDivision]=useState(1); const [playing,setPlaying]=useState(false),[loop,setLoop]=useState(null); const [master,setMaster]=useState(1),[mixerOpen,setMixerOpen]=useState(true); const [create,setCreate]=useState({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(null),bars=useRef(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)=> preview(p=>({...p,tracks:p.tracks.map(t=>t.id===id?{...t,...patch}:t)})),[preview]); const renameTrack=useCallback((id:string,patch:Partial)=> 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=

Start with your voice.

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.

; return
KIKUSTUDIO
0:00.0001.1.00
BPM{project.bpm}
{project.key&&
KEY{project.key}
}
Sheet music {user.name}
{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'}
{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}))}/> void importFiles(files,at)} empty={empty}/> void download(t)}/> {mixerOpen&&} {notice&&

{notice}

} {expired&&
Your sign-in expired. Your project is still here.
}
; } 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); }