'use client'; import {useCallback,useEffect,useMemo,useRef,useState} from 'react'; import {ArrowDown,ArrowUp,ChevronDown,Code2,Download,Music2,Pause,Play,Printer,Redo2,Repeat2,RotateCcw,SkipBack,Undo2,Volume2,VolumeX,X} from 'lucide-react'; import type {NoteTimingEvent,TuneObject} from 'abcjs'; import type {Source,ScoreResult} from '@/lib/types'; import OriginalAudioPlayer from '@/components/OriginalAudioPlayer'; import {prepareScore,SampleCache,type Playback} from '@/lib/score-playback'; import {engrave} from '@/lib/engrave'; type AbcLibrary=typeof import('abcjs'); type Selection={start:number;end:number;token:string}; const editingEnabled=false; const clock=(seconds:number)=>`${Math.floor(seconds/60)}:${String(Math.floor(seconds%60)).padStart(2,'0')}`; function save(text:string,name:string,type='text/plain'){ const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000); } export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult;source:Source;onNew:()=>void}){ const [history,setHistory]=useState([result.abc]),[index,setIndex]=useState(0); const abc=history[index],edited=abc!==result.abc; // What is drawn and played: the same notes laid out for reading. Editing by // clicking a note needs the text's own character positions, so it uses the text. const display=useMemo(()=>{if(editingEnabled)return abc;try{return engrave(abc);}catch{return abc;}},[abc]); const [codeOpen,setCodeOpen]=useState(false),[selection,setSelection]=useState(null); const [playing,setPlaying]=useState(false),[loading,setLoading]=useState(false),[position,setPosition]=useState(0),[total,setTotal]=useState(0); const [speed,setSpeed]=useState(100),[volume,setVolume]=useState(1),[loop,setLoop]=useState(false),[follow,setFollow]=useState(true); const instrument=0; const [chords,setChords]=useState(false); const [problem,setProblem]=useState(''),[notice,setNotice]=useState(''),[ready,setReady]=useState(false); const paper=useRef(null),scoreScroll=useRef(null),textarea=useRef(null); const lib=useRef(null),tune=useRef(null),audio=useRef(null),gain=useRef(null),node=useRef<{stop:()=>void}|null>(null),buffer=useRef(null); const preparation=useRef<{revision:number;promise:Promise}|null>(null); const [starting,setStarting]=useState(false),playRequest=useRef(null); const soundCache=useRef(new SampleCache()),volumeRef=useRef(volume); volumeRef.current=volume; const timings=useRef([]),offset=useRef(0),started=useRef(0),isPlaying=useRef(false),generation=useRef(0),loopRef=useRef(loop),followRef=useRef(follow),lastEvent=useRef(-1); loopRef.current=loop;followRef.current=follow; const mark=useCallback((seconds:number)=>{ const events=timings.current,ms=seconds*1000;let lo=0,hi=events.length-1,found=-1; while(lo<=hi){const mid=(lo+hi)>>1;if(events[mid].milliseconds<=ms){found=mid;lo=mid+1;}else hi=mid-1;} if(found===lastEvent.current)return;lastEvent.current=found; paper.current?.querySelectorAll('.current-note').forEach(n=>n.classList.remove('current-note')); const event=events[found];if(!event||event.type==='end'||event.left==null)return; event.elements?.flat().forEach(el=>el?.classList.add('current-note')); const svg=paper.current?.querySelector('svg');if(!svg)return; let line=svg.querySelector('.playhead');if(!line){line=document.createElementNS('http://www.w3.org/2000/svg','line');line.setAttribute('class','playhead');svg.append(line);} line.setAttribute('x1',String(event.left-3));line.setAttribute('x2',String(event.left-3));line.setAttribute('y1',String(event.top||0));line.setAttribute('y2',String((event.top||0)+(event.height||0))); const pane=scoreScroll.current;if(followRef.current&&pane){const rect=line.getBoundingClientRect(),bound=pane.getBoundingClientRect();if(rect.topbound.bottom-30)pane.scrollBy({top:rect.top-bound.top-70,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});} },[]); const stop=useCallback(()=>{ playRequest.current=null;setStarting(false); if(isPlaying.current&&audio.current)offset.current=Math.min((buffer.current?.duration||0),offset.current+audio.current.currentTime-started.current); if(node.current){node.current.stop();node.current=null;} isPlaying.current=false;setPlaying(false); },[]); const startBuffer=useCallback((at:number)=>{ const ctx=audio.current,buf=buffer.current;if(!ctx||!buf||!gain.current)return; offset.current=Math.max(0,Math.min(at,buf.duration-.001));started.current=ctx.currentTime;isPlaying.current=true;setPlaying(true); node.current=buf.start(ctx,gain.current,offset.current,()=>{if(loopRef.current){isPlaying.current=false;node.current=null;startBuffer(0);}else{isPlaying.current=false;node.current=null;offset.current=0;setPlaying(false);setPosition(0);lastEvent.current=-1;paper.current?.querySelectorAll('.current-note,.playhead').forEach(n=>n.classList.contains('playhead')?n.remove():n.classList.remove('current-note'));}}); },[]); const commit=useCallback((next:string)=>{if(next===abc)return;stop();setHistory(h=>[...h.slice(0,index+1),next]);setIndex(index+1);setNotice('Edits applied. Playback uses this version.');},[abc,index,stop]); const undo=useCallback(()=>{if(index>0){stop();setIndex(i=>i-1);setSelection(null);}},[index,stop]); const redo=useCallback(()=>{if(indexi+1);setSelection(null);}},[index,history.length,stop]); useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo();}else if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='y'){e.preventDefault();redo();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[undo,redo]); useEffect(()=>{ const revision=++generation.current;stop();tune.current=null;buffer.current=null;offset.current=0;setPosition(0);setTotal(0);setLoading(false);setReady(false);setProblem('');lastEvent.current=-1; const timer=setTimeout(async()=>{ const ABC=await import('abcjs');if(revision!==generation.current||!paper.current)return;lib.current=ABC; try{ if(!/\nK:/.test(abc)||!abc.trim())throw new Error('The score needs a K: key field and some notes. Undo or restore the AI score.'); const visual=ABC.renderAbc(paper.current,display,{add_classes:true,selectTypes:['note'],selectionColor:'#cd7815',responsive:'resize',staffwidth:850,paddingtop:30,paddingbottom:40, clickListener:(el,_n,_classes,analysis)=>{if(el.el_type!=='note'||typeof el.startChar!=='number'||typeof el.endChar!=='number')return; if(!editingEnabled){const event=timings.current.find(event=>event.elements?.flat().some(element=>element===analysis.selectableElement||analysis.selectableElement?.contains(element)));if(event)seek(event.milliseconds/1000);return;} const selected={start:el.startChar,end:el.endChar,token:abc.slice(el.startChar,el.endChar)};setSelection(selected); paper.current?.querySelectorAll('.selected-note').forEach(n=>n.classList.remove('selected-note'));analysis.selectableElement?.classList.add('selected-note'); if(textarea.current){textarea.current.focus({preventScroll:true});textarea.current.setSelectionRange(selected.start,selected.end);} }})[0]; if(!visual||!paper.current.querySelector('.abcjs-note'))throw new Error('No playable notes found. Check the notation, undo, or restore the AI score.'); tune.current=visual; const timing=new ABC.TimingCallbacks(visual,{qpm:visual.getBpm(visual.metaText.tempo)*speed/100}); timings.current=timing.noteTimings; setTotal(Math.max(0,...timing.noteTimings.map(event=>event.milliseconds))/1000); setReady(true); setNotice(visual.warnings?.length?'Some notation could not be read. Check your edit before playing.':''); }catch(e){setProblem(e instanceof Error?e.message:'This notation could not be read.');tune.current=null;} },180);return()=>clearTimeout(timer); },[abc,display,speed,chords,stop]); useEffect(()=>{let frame=0;const tick=()=>{if(isPlaying.current&&audio.current){const time=offset.current+audio.current.currentTime-started.current;setPosition(time);mark(time);}frame=requestAnimationFrame(tick);};frame=requestAnimationFrame(tick);return()=>cancelAnimationFrame(frame);},[mark]); useEffect(()=>{if(gain.current)gain.current.gain.value=volume;},[volume]); useEffect(()=>()=>{generation.current++;stop();gain.current?.disconnect();},[stop]); const preparePlayback=useCallback(async()=>{ if(!ready||!lib.current||!tune.current)return; const rev=generation.current; if(buffer.current)return; if(preparation.current?.revision===rev)return preparation.current.promise; const originalTune=tune.current; const promise=(async()=>{try{ setLoading(true);setProblem(''); if(!audio.current){audio.current=new AudioContext();gain.current=audio.current.createGain();gain.current.gain.value=volumeRef.current;gain.current.connect(audio.current.destination);} const prepared=await prepareScore(audio.current,originalTune,instrument,speed,chords,soundCache.current,()=>rev===generation.current); if(rev!==generation.current)return; buffer.current=prepared;setTotal(prepared.duration); }catch(e){if(rev===generation.current)setProblem(e instanceof Error?e.message:'Playback failed. Please try again.');} finally{if(rev===generation.current)setLoading(false);if(preparation.current?.revision===rev)preparation.current=null;} })(); preparation.current={revision:rev,promise};return promise; },[ready,abc,speed,chords]); useEffect(()=>{if(ready)void preparePlayback();},[ready,preparePlayback]); async function toggle(){ if(playing){stop();return;}if(!ready||playRequest.current!==null)return; const rev=generation.current; playRequest.current=rev;setStarting(true); try{ if(!audio.current){audio.current=new AudioContext();gain.current=audio.current.createGain();gain.current.gain.value=volumeRef.current;gain.current.connect(audio.current.destination);} await audio.current.resume();await preparePlayback(); if(rev===generation.current&&playRequest.current===rev&&buffer.current)startBuffer(offset.current); }catch(e){if(rev===generation.current)setProblem(e instanceof Error?e.message:'Playback failed. Please try again.');} finally{if(playRequest.current===rev){playRequest.current=null;setStarting(false);}} } /** * Light the note the original recording is on. The engine's bar timing says * where each bar starts in the recording, so the score keeps up as the * performance speeds up and slows down. Pressing play on the score then * carries on from that point. */ const followRecording=useCallback((seconds:number)=>{ const timing=result.timing;if(!timing||isPlaying.current)return; const {bars,clock,bpm}=timing; if(seconds=clock[i+1])i++; const quarters=Math.min(bars[bars.length-1],bars[i]+(bars[i+1]-bars[i])*(seconds-clock[i])/(clock[i+1]-clock[i])); const at=quarters*60/bpm*100/speed; offset.current=at;setPosition(at);mark(at); },[result.timing,mark,speed]); function seek(value:number){const was=isPlaying.current;stop();offset.current=value;setPosition(value);lastEvent.current=-1;mark(value);if(was)startBuffer(value);} function noteEdit(action:'up'|'down'|'longer'|'shorter'){ if(!selection)return;const match=selection.token.match(/^(\s*(?:"[^"]*"\s*)*)([_^=]*)([A-Ga-g])([,']*)(\d*(?:\/\d*)?)(-?)(\s*)$/); if(!match){setCodeOpen(true);setNotice('Use the notation editor for this chord, rest, or decorated note.');return;} let [,prefix,acc,note,oct,length,tie,suffix]=match; if(action==='up'||action==='down'){ const names='CDEFGAB';let pitch=names.indexOf(note.toUpperCase())+(note===note.toLowerCase()?7:0)+7*([...oct].filter(x=>x==="'").length-[...oct].filter(x=>x===',').length)+(action==='up'?1:-1); const octave=Math.floor(pitch/7);note=names[((pitch%7)+7)%7];oct=octave<0?', '.trim().repeat(-octave):octave>1?"'".repeat(octave-1):'';if(octave>=1)note=note.toLowerCase(); }else{const [n,d]=length.split('/');let num=Number(n||1),den=d===undefined?1:Number(d||2);if(action==='longer')num*=2;else den*=2;while(num%2===0&&den%2===0){num/=2;den/=2;}length=den===1?(num===1?'':String(num)):`${num===1?'':num}/${den===2?'':den}`;} const token=prefix+acc+note+oct+length+tie+suffix;commit(abc.slice(0,selection.start)+token+abc.slice(selection.end));setSelection({...selection,end:selection.start+token.length,token}); } function midi(){if(!lib.current)return;try{const uri=lib.current.synth.getMidiFile(display,{midiOutputType:'encoded',chordsOff:!chords}) as string;const a=document.createElement('a');a.href=uri;a.download='edited-score.mid';a.click();}catch{setProblem('MIDI export failed. Check the notation.');}} return
YOUR MUSIC, ON PAPER

{source.title}

{source.artist||'Your recording'} · {edited?'Edited score':'Original AI score'}

{clock(position)}{source.title}{total?`−${clock(Math.max(0,total-position))}`:'SCORE'}
seek(Number(e.target.value))} aria-label="Playback position"/>
setVolume(Number(e.target.value))}/>
{loading?'Preparing piano sounds…':'Piano playback follows your written score'}
Download
{result.pdf&&Original AI score · PDF}{result.downloads.find(d=>d.name.endsWith('.zip'))&&d.name.endsWith('.zip'))!.url} target="_blank" rel="noreferrer">Original AI files · ZIP}
{selection?`Selected: ${selection.token.trim()}`:'Click a note to edit its pitch or length.'}{selection&&
}
{problem&&

{problem}

}{notice&&

{notice}

}
{codeOpen&&