Spaces:
Running
Running
File size: 18,824 Bytes
026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a 5fdfb72 026f92a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | '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<Selection|null>(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<HTMLDivElement>(null),scoreScroll=useRef<HTMLDivElement>(null),textarea=useRef<HTMLTextAreaElement>(null);
const lib=useRef<AbcLibrary|null>(null),tune=useRef<TuneObject|null>(null),audio=useRef<AudioContext|null>(null),gain=useRef<GainNode|null>(null),node=useRef<{stop:()=>void}|null>(null),buffer=useRef<Playback|null>(null);
const preparation=useRef<{revision:number;promise:Promise<void>}|null>(null);
const [starting,setStarting]=useState(false),playRequest=useRef<number|null>(null);
const soundCache=useRef(new SampleCache()),volumeRef=useRef(volume);
volumeRef.current=volume;
const timings=useRef<NoteTimingEvent[]>([]),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.top<bound.top+30||rect.bottom>bound.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(index<history.length-1){stop();setIndex(i=>i+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[0]){mark(-1);return;}
let i=0;while(i<clock.length-2&&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 <section className={`workspace view-enter ${editingEnabled?'':'read-only-score'}`}>
<div className="workspace-heading"><div><div className="eyebrow">YOUR MUSIC, ON PAPER</div><h1>{source.title}</h1><p>{source.artist||'Your recording'} <span>·</span> {edited?'Edited score':'Original AI score'}</p></div><button className="new-score-button" onClick={onNew}><span aria-hidden="true">+</span> New score</button></div>
<div className="transport" aria-label="Score playback controls">
<button className="transport-icon" onClick={()=>seek(0)} title="Restart" aria-label="Restart playback"><SkipBack size={18}/></button>
<button className="transport-icon play-button" onClick={toggle} disabled={!ready||starting} aria-busy={starting} aria-label={playing?'Pause score':'Play score'}>{starting?<span className="spinner"/>:playing?<Pause size={22} fill="currentColor"/>:<Play size={22} fill="currentColor"/>}</button>
<div className="track-progress"><div className="progress-fill" style={{width:total?`${Math.min(100,position/total*100)}%`:'0%'}}/><div className="track-label"><span>{clock(position)}</span><strong>{source.title}</strong><span>{total?`−${clock(Math.max(0,total-position))}`:'SCORE'}</span></div><input type="range" min="0" max={total||1} step=".05" value={Math.min(position,total||1)} disabled={!total} onChange={e=>seek(Number(e.target.value))} aria-label="Playback position"/></div>
<button className={`transport-icon ${loop?'active':''}`} onClick={()=>setLoop(!loop)} aria-label="Loop score" aria-pressed={loop} title="Loop"><Repeat2 size={20}/></button>
<div className="volume"><button className="transport-icon" onClick={()=>setVolume(volume?0:.7)} aria-label={volume?'Mute':'Unmute'}>{volume?<Volume2 size={18}/>:<VolumeX size={18}/>}</button><input aria-label="Volume" type="range" min="0" max="1" step=".05" value={volume} onChange={e=>setVolume(Number(e.target.value))}/></div>
</div>
<div className="under-player"><span>{loading?'Preparing piano sounds…':'Piano playback follows your written score'}</span><div className="playback-options"><label title="Adds a bass and chord pattern from the chord names. It is not written in the score."><input type="checkbox" checked={chords} onChange={e=>{stop();setChords(e.target.checked);}}/> Chord backing</label><label>Speed <select aria-label="Playback speed" value={speed} onChange={e=>{stop();setSpeed(Number(e.target.value));}}><option value="50">0.5×</option><option value="75">0.75×</option><option value="100">1×</option><option value="125">1.25×</option><option value="150">1.5×</option></select></label></div></div>
<div className="score-toolbar"><div className="toolbar-group"><button onClick={undo} disabled={index===0} title="Undo (Ctrl Z)" aria-label="Undo"><Undo2 size={18}/></button><button onClick={redo} disabled={index===history.length-1} title="Redo (Ctrl Shift Z)" aria-label="Redo"><Redo2 size={18}/></button><span className="divider"/><button onClick={()=>{commit(result.abc);setSelection(null);}} disabled={!edited}><RotateCcw size={16}/><span>Restore AI score</span></button></div><div className="toolbar-group"><label className="follow"><input type="checkbox" checked={follow} onChange={e=>setFollow(e.target.checked)}/> Follow notes</label><button className={codeOpen?'chosen':''} onClick={()=>setCodeOpen(!codeOpen)} aria-expanded={codeOpen}><Code2 size={17}/><span>Edit notation</span></button><button onClick={()=>window.print()} title="Print current score" aria-label="Print current score"><Printer size={17}/></button><details className="download-menu"><summary><Download size={17}/><span>Download</span><ChevronDown size={13}/></summary><div><button onClick={()=>save(abc,'score.abc')}>Current score · ABC</button><button onClick={midi}>Current score · MIDI</button>{result.pdf&&<a href={result.pdf} target="_blank" rel="noreferrer">Original AI score · PDF</a>}{result.downloads.find(d=>d.name.endsWith('.zip'))&&<a href={result.downloads.find(d=>d.name.endsWith('.zip'))!.url} target="_blank" rel="noreferrer">Original AI files · ZIP</a>}</div></details></div></div>
<div className="edit-help"><span>{selection?`Selected: ${selection.token.trim()}`:'Click a note to edit its pitch or length.'}</span>{selection&&<div className="note-actions"><button onClick={()=>noteEdit('down')} aria-label="Lower selected note"><ArrowDown size={14}/></button><button onClick={()=>noteEdit('up')} aria-label="Raise selected note"><ArrowUp size={14}/></button><button onClick={()=>noteEdit('shorter')}>½ length</button><button onClick={()=>noteEdit('longer')}>2× length</button></div>}</div>
{problem&&<p role="alert" className="error-message">{problem}</p>}{notice&&<p role="status" className="edit-notice">{notice}</p>}
<div className={`score-layout ${codeOpen?'with-editor':''}`}><div className="score-scroll" ref={scoreScroll}><div className="paper" ref={paper}/></div>{codeOpen&&<aside className="notation-editor"><div><strong>ABC notation</strong><button onClick={()=>setCodeOpen(false)} aria-label="Close notation editor"><X size={16}/></button></div><p>Change notes, rhythm, chords, title, or tempo. Playback updates with your edits.</p><textarea ref={textarea} aria-label="Edit ABC notation" spellCheck={false} value={abc} onChange={e=>commit(e.target.value)}/><span>Undo and redo work here too. Print to save an edited PDF.</span></aside>}</div>
{source.audioUrl&&<OriginalAudioPlayer src={source.audioUrl} onPlay={stop} scorePlaying={playing} onPosition={result.timing?followRecording:undefined}/>}
</section>;
}
|