Reubencf commited on
Commit
5fdfb72
·
verified ·
1 Parent(s): 026f92a

Engraved score display, chord backing toggle, follow the recording

Browse files
app/api/transcribe/route.ts CHANGED
@@ -1,54 +1,62 @@
1
- import {type Client,handle_file} from '@gradio/client';
2
- import type {ScoreResult,Download} from '@/lib/types';
3
- import {prepareAudio} from '@/lib/audio';
4
- import {authorize} from '@/lib/auth';
5
- import {ENGINE,asFile,connect,explain} from '@/lib/kiku';
6
- export const runtime='nodejs';
7
- const file=(value:unknown)=>asFile(ENGINE,value);
8
- export async function POST(req:Request){
9
- const auth=await authorize(req);if(auth.error)return auth.error;
10
- if(Number(req.headers.get('content-length')||0)>102*1024*1024)return Response.json({error:'Choose an audio file under 100 MB.'},{status:413});
11
- const form=await req.formData().catch(()=>null);
12
- const input=form?.get('audio');
13
- if(!(input instanceof File)||input.size>100*1024*1024||!input.size)
14
- return Response.json({error:'Choose an audio file under 100 MB.'},{status:400});
15
- const enc=new TextEncoder();let cancelJob:(()=>void)|undefined,closed=false,client:Client|undefined;
16
- const preparation=new AbortController();
17
- const stream=new ReadableStream({
18
- async start(controller){
19
- const send=(type:string,data:unknown)=>{if(!closed)controller.enqueue(enc.encode(JSON.stringify({type,data})+'\n'));};
20
- const abort=()=>{closed=true;preparation.abort();cancelJob?.();client?.close();};req.signal.addEventListener('abort',abort,{once:true});
21
- try{
22
- if(req.signal.aborted){abort();return;}
23
- send('status','Reading the full recording…');
24
- const {audio,duration}=await prepareAudio(input,preparation.signal);
25
- if(closed)return;
26
- send('status','Connecting to the transcription studio…');
27
- client=await connect(ENGINE,auth.session.token,send,preparation.signal);
28
- if(req.signal.aborted||closed){client.close();return;}
29
- const job=client.submit('/transcribe',[handle_file(audio),0,duration,false,true]);
30
- cancelJob=()=>{job.cancel();};let result:ScoreResult|null=null;
31
- for await(const event of job){
32
- if(closed)break;
33
- if(event.type==='status'){
34
- if(event.stage==='error')throw new Error(typeof event.message==='string'?event.message:'Conversion failed. Please try again.');
35
- const details=event as unknown as {progress_data?:{desc?:string}[];position?:number};
36
- send('status',details.progress_data?.find(p=>p.desc)?.desc|| (event.stage==='pending'?'Waiting for an available GPU…':'Listening and writing your score…'));
37
- }
38
- if(event.type==='data'){
39
- const data=event.data as unknown[];
40
- if(typeof data[5]==='string'&&data[5].trim()){
41
- result={abc:data[5],status:String(data[0]),downloads:Array.isArray(data[4])?data[4].map(file).filter((d):d is Download=>!!d):[],pdf:file(data[2])?.url,audio:file(data[3])?.url};
42
- }
43
- }
44
- }
45
- if(!closed){if(!result)throw new Error('No editable score was returned. Try another recording.');send('result',result);}
46
- }catch(error){
47
- let message=error instanceof Error?error.message:'Conversion could not finish. Please try again.';
48
- if(/quota|GPU.*duration|exceeded/i.test(message))message='Your Hugging Face processing allowance is unavailable for this recording. Try a shorter recording or wait for your allowance to reset.';
49
- send('error',explain(message).slice(0,600));
50
- }finally{client?.close();req.signal.removeEventListener('abort',abort);if(!closed)controller.close();}
51
- },cancel(){closed=true;preparation.abort();cancelJob?.();client?.close();}
52
- });
53
- return new Response(stream,{headers:{'Content-Type':'application/x-ndjson','Cache-Control':'no-store','X-Accel-Buffering':'no'}});
54
- }
 
 
 
 
 
 
 
 
 
1
+ import {type Client,handle_file} from '@gradio/client';
2
+ import type {ScoreResult,ScoreTiming,Download} from '@/lib/types';
3
+ import {prepareAudio} from '@/lib/audio';
4
+ import {authorize} from '@/lib/auth';
5
+ import {ENGINE,asFile,connect,explain} from '@/lib/kiku';
6
+ export const runtime='nodejs';
7
+ const file=(value:unknown)=>asFile(ENGINE,value);
8
+ /** The engine's bar timing, if it sent a usable one: rising times, one per bar line. */
9
+ function timing(value:unknown):ScoreTiming|undefined{
10
+ if(!value||typeof value!=='object')return;
11
+ const {bpm,bars,clock}=value as Partial<ScoreTiming>;
12
+ const rising=(list:unknown)=>Array.isArray(list)&&list.length>1&&list.every((v,i)=>Number.isFinite(v)&&(!i||v>list[i-1]));
13
+ if(typeof bpm!=='number'||!rising(bars)||!rising(clock)||bars!.length!==clock!.length)return;
14
+ return {bpm,bars:bars!,clock:clock!};
15
+ }
16
+ export async function POST(req:Request){
17
+ const auth=await authorize(req);if(auth.error)return auth.error;
18
+ if(Number(req.headers.get('content-length')||0)>102*1024*1024)return Response.json({error:'Choose an audio file under 100 MB.'},{status:413});
19
+ const form=await req.formData().catch(()=>null);
20
+ const input=form?.get('audio');
21
+ if(!(input instanceof File)||input.size>100*1024*1024||!input.size)
22
+ return Response.json({error:'Choose an audio file under 100 MB.'},{status:400});
23
+ const enc=new TextEncoder();let cancelJob:(()=>void)|undefined,closed=false,client:Client|undefined;
24
+ const preparation=new AbortController();
25
+ const stream=new ReadableStream({
26
+ async start(controller){
27
+ const send=(type:string,data:unknown)=>{if(!closed)controller.enqueue(enc.encode(JSON.stringify({type,data})+'\n'));};
28
+ const abort=()=>{closed=true;preparation.abort();cancelJob?.();client?.close();};req.signal.addEventListener('abort',abort,{once:true});
29
+ try{
30
+ if(req.signal.aborted){abort();return;}
31
+ send('status','Reading the full recording…');
32
+ const {audio,duration}=await prepareAudio(input,preparation.signal);
33
+ if(closed)return;
34
+ send('status','Connecting to the transcription studio…');
35
+ client=await connect(ENGINE,auth.session.token,send,preparation.signal);
36
+ if(req.signal.aborted||closed){client.close();return;}
37
+ const job=client.submit('/transcribe',[handle_file(audio),0,duration,false,false]);
38
+ cancelJob=()=>{job.cancel();};let result:ScoreResult|null=null;
39
+ for await(const event of job){
40
+ if(closed)break;
41
+ if(event.type==='status'){
42
+ if(event.stage==='error')throw new Error(typeof event.message==='string'?event.message:'Conversion failed. Please try again.');
43
+ const details=event as unknown as {progress_data?:{desc?:string}[];position?:number};
44
+ send('status',details.progress_data?.find(p=>p.desc)?.desc|| (event.stage==='pending'?'Waiting for an available GPU…':'Listening and writing your score…'));
45
+ }
46
+ if(event.type==='data'){
47
+ const data=event.data as unknown[];
48
+ if(typeof data[5]==='string'&&data[5].trim()){
49
+ result={abc:data[5],status:String(data[0]),downloads:Array.isArray(data[4])?data[4].map(file).filter((d):d is Download=>!!d):[],pdf:file(data[2])?.url,audio:file(data[3])?.url,timing:timing(data[6])};
50
+ }
51
+ }
52
+ }
53
+ if(!closed){if(!result)throw new Error('No editable score was returned. Try another recording.');send('result',result);}
54
+ }catch(error){
55
+ let message=error instanceof Error?error.message:'Conversion could not finish. Please try again.';
56
+ if(/quota|GPU.*duration|exceeded/i.test(message))message='Your Hugging Face processing allowance is unavailable for this recording. Try a shorter recording or wait for your allowance to reset.';
57
+ send('error',explain(message).slice(0,600));
58
+ }finally{client?.close();req.signal.removeEventListener('abort',abort);if(!closed)controller.close();}
59
+ },cancel(){closed=true;preparation.abort();cancelJob?.();client?.close();}
60
+ });
61
+ return new Response(stream,{headers:{'Content-Type':'application/x-ndjson','Cache-Control':'no-store','X-Accel-Buffering':'no'}});
62
+ }
components/OriginalAudioPlayer.tsx CHANGED
@@ -1,18 +1,25 @@
1
- 'use client';
2
- import {useEffect,useRef,useState} from 'react';
3
- import {Pause,Play,Volume2} from 'lucide-react';
4
- const clock=(s:number)=>`${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`;
5
- export default function OriginalAudioPlayer({src,onPlay,scorePlaying}:{src:string;onPlay:()=>void;scorePlaying:boolean}){
6
- const ref=useRef<HTMLAudioElement>(null);
7
- const [playing,setPlaying]=useState(false),[time,setTime]=useState(0),[duration,setDuration]=useState(0),[volume,setVolume]=useState(1),[error,setError]=useState('');
8
- useEffect(()=>{if(scorePlaying)ref.current?.pause();},[scorePlaying]);
9
- return <section className="original-audio original-player" aria-label="Original recording">
10
- <div className="original-player-heading"><strong>Original recording</strong><span>Your uploaded audio</span></div>
11
- <audio ref={ref} src={src} preload="metadata" onLoadedMetadata={()=>{const d=ref.current?.duration||0;setDuration(Number.isFinite(d)?d:0);if(ref.current)ref.current.volume=volume;}} onDurationChange={()=>{const d=ref.current?.duration||0;if(Number.isFinite(d))setDuration(d);}} onTimeUpdate={()=>setTime(ref.current?.currentTime||0)} onPlay={()=>{setPlaying(true);onPlay();}} onPause={()=>setPlaying(false)} onEnded={()=>setPlaying(false)} onError={()=>setError('The original audio could not be loaded.')}/>
12
- <div className="original-player-controls">
13
- <button className="transport-icon" aria-label={playing?'Pause original recording':'Play original recording'} onClick={async()=>{if(!ref.current)return;if(playing)ref.current.pause();else try{await ref.current.play();}catch{setError('The original audio could not be played.');}}}>{playing?<Pause size={19}/>:<Play size={19}/>}</button>
14
- <span className="audio-time">{clock(time)}</span><input type="range" aria-label="Original recording position" aria-valuetext={`${clock(time)} of ${clock(duration)}`} min="0" max={duration||1} step=".1" value={Math.min(time,duration||1)} disabled={!duration} onChange={e=>{const value=Number(e.target.value);if(ref.current)ref.current.currentTime=value;setTime(value);}}/><span className="audio-time">{clock(duration)}</span>
15
- <label className="original-volume"><Volume2 size={17}/><input aria-label="Original recording volume" type="range" min="0" max="1" step=".05" value={volume} onChange={e=>{const v=Number(e.target.value);setVolume(v);if(ref.current)ref.current.volume=v;}}/></label>
16
- </div>{error&&<p role="alert">{error}</p>}
17
- </section>;
18
- }
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import {useEffect,useRef,useState} from 'react';
3
+ import {Pause,Play,Volume2} from 'lucide-react';
4
+ const clock=(s:number)=>`${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`;
5
+ /** `onPosition` gets the playing time every frame, and on seeks, so the score can follow along. */
6
+ export default function OriginalAudioPlayer({src,onPlay,scorePlaying,onPosition}:{src:string;onPlay:()=>void;scorePlaying:boolean;onPosition?:(seconds:number)=>void}){
7
+ const ref=useRef<HTMLAudioElement>(null);
8
+ const [playing,setPlaying]=useState(false),[time,setTime]=useState(0),[duration,setDuration]=useState(0),[volume,setVolume]=useState(1),[error,setError]=useState('');
9
+ useEffect(()=>{if(scorePlaying)ref.current?.pause();},[scorePlaying]);
10
+ const follow=useRef(onPosition);follow.current=onPosition;
11
+ useEffect(()=>{
12
+ if(!playing)return;
13
+ let frame=0;const tick=()=>{if(ref.current)follow.current?.(ref.current.currentTime);frame=requestAnimationFrame(tick);};
14
+ frame=requestAnimationFrame(tick);return()=>cancelAnimationFrame(frame);
15
+ },[playing]);
16
+ return <section className="original-audio original-player" aria-label="Original recording">
17
+ <div className="original-player-heading"><strong>Original recording</strong><span>{onPosition?'The score follows along as it plays':'Your uploaded audio'}</span></div>
18
+ <audio ref={ref} src={src} preload="metadata" onLoadedMetadata={()=>{const d=ref.current?.duration||0;setDuration(Number.isFinite(d)?d:0);if(ref.current)ref.current.volume=volume;}} onDurationChange={()=>{const d=ref.current?.duration||0;if(Number.isFinite(d))setDuration(d);}} onTimeUpdate={()=>setTime(ref.current?.currentTime||0)} onPlay={()=>{setPlaying(true);onPlay();}} onPause={()=>setPlaying(false)} onEnded={()=>setPlaying(false)} onError={()=>setError('The original audio could not be loaded.')}/>
19
+ <div className="original-player-controls">
20
+ <button className="transport-icon" aria-label={playing?'Pause original recording':'Play original recording'} onClick={async()=>{if(!ref.current)return;if(playing)ref.current.pause();else try{await ref.current.play();}catch{setError('The original audio could not be played.');}}}>{playing?<Pause size={19}/>:<Play size={19}/>}</button>
21
+ <span className="audio-time">{clock(time)}</span><input type="range" aria-label="Original recording position" aria-valuetext={`${clock(time)} of ${clock(duration)}`} min="0" max={duration||1} step=".1" value={Math.min(time,duration||1)} disabled={!duration} onChange={e=>{const value=Number(e.target.value);if(ref.current)ref.current.currentTime=value;setTime(value);follow.current?.(value);}}/><span className="audio-time">{clock(duration)}</span>
22
+ <label className="original-volume"><Volume2 size={17}/><input aria-label="Original recording volume" type="range" min="0" max="1" step=".05" value={volume} onChange={e=>{const v=Number(e.target.value);setVolume(v);if(ref.current)ref.current.volume=v;}}/></label>
23
+ </div>{error&&<p role="alert">{error}</p>}
24
+ </section>;
25
+ }
components/ScoreWorkspace.tsx CHANGED
@@ -1,10 +1,11 @@
1
  'use client';
2
- import {useCallback,useEffect,useRef,useState} from 'react';
3
  import {ArrowDown,ArrowUp,ChevronDown,Code2,Download,Music2,Pause,Play,Printer,Redo2,Repeat2,RotateCcw,SkipBack,Undo2,Volume2,VolumeX,X} from 'lucide-react';
4
  import type {NoteTimingEvent,TuneObject} from 'abcjs';
5
  import type {Source,ScoreResult} from '@/lib/types';
6
  import OriginalAudioPlayer from '@/components/OriginalAudioPlayer';
7
  import {prepareScore,SampleCache,type Playback} from '@/lib/score-playback';
 
8
 
9
  type AbcLibrary=typeof import('abcjs');
10
  type Selection={start:number;end:number;token:string};
@@ -17,10 +18,14 @@ function save(text:string,name:string,type='text/plain'){
17
  export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult;source:Source;onNew:()=>void}){
18
  const [history,setHistory]=useState([result.abc]),[index,setIndex]=useState(0);
19
  const abc=history[index],edited=abc!==result.abc;
 
 
 
20
  const [codeOpen,setCodeOpen]=useState(false),[selection,setSelection]=useState<Selection|null>(null);
21
  const [playing,setPlaying]=useState(false),[loading,setLoading]=useState(false),[position,setPosition]=useState(0),[total,setTotal]=useState(0);
22
  const [speed,setSpeed]=useState(100),[volume,setVolume]=useState(1),[loop,setLoop]=useState(false),[follow,setFollow]=useState(true);
23
  const instrument=0;
 
24
  const [problem,setProblem]=useState(''),[notice,setNotice]=useState(''),[ready,setReady]=useState(false);
25
  const paper=useRef<HTMLDivElement>(null),scoreScroll=useRef<HTMLDivElement>(null),textarea=useRef<HTMLTextAreaElement>(null);
26
  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);
@@ -63,7 +68,7 @@ export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult
63
  const ABC=await import('abcjs');if(revision!==generation.current||!paper.current)return;lib.current=ABC;
64
  try{
65
  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.');
66
- const visual=ABC.renderAbc(paper.current,abc,{add_classes:true,selectTypes:['note'],selectionColor:'#cd7815',responsive:'resize',staffwidth:850,paddingtop:30,paddingbottom:40,
67
  clickListener:(el,_n,_classes,analysis)=>{if(el.el_type!=='note'||typeof el.startChar!=='number'||typeof el.endChar!=='number')return;
68
  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;}
69
  const selected={start:el.startChar,end:el.endChar,token:abc.slice(el.startChar,el.endChar)};setSelection(selected);
@@ -79,7 +84,7 @@ export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult
79
  setNotice(visual.warnings?.length?'Some notation could not be read. Check your edit before playing.':'');
80
  }catch(e){setProblem(e instanceof Error?e.message:'This notation could not be read.');tune.current=null;}
81
  },180);return()=>clearTimeout(timer);
82
- },[abc,speed,stop]);
83
  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]);
84
  useEffect(()=>{if(gain.current)gain.current.gain.value=volume;},[volume]);
85
  useEffect(()=>()=>{generation.current++;stop();gain.current?.disconnect();},[stop]);
@@ -92,14 +97,14 @@ export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult
92
  const promise=(async()=>{try{
93
  setLoading(true);setProblem('');
94
  if(!audio.current){audio.current=new AudioContext();gain.current=audio.current.createGain();gain.current.gain.value=volumeRef.current;gain.current.connect(audio.current.destination);}
95
- const prepared=await prepareScore(audio.current,originalTune,instrument,speed,soundCache.current,()=>rev===generation.current);
96
  if(rev!==generation.current)return;
97
  buffer.current=prepared;setTotal(prepared.duration);
98
  }catch(e){if(rev===generation.current)setProblem(e instanceof Error?e.message:'Playback failed. Please try again.');}
99
  finally{if(rev===generation.current)setLoading(false);if(preparation.current?.revision===rev)preparation.current=null;}
100
  })();
101
  preparation.current={revision:rev,promise};return promise;
102
- },[ready,abc,speed]);
103
  useEffect(()=>{if(ready)void preparePlayback();},[ready,preparePlayback]);
104
  async function toggle(){
105
  if(playing){stop();return;}if(!ready||playRequest.current!==null)return;
@@ -112,6 +117,21 @@ export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult
112
  }catch(e){if(rev===generation.current)setProblem(e instanceof Error?e.message:'Playback failed. Please try again.');}
113
  finally{if(playRequest.current===rev){playRequest.current=null;setStarting(false);}}
114
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  function seek(value:number){const was=isPlaying.current;stop();offset.current=value;setPosition(value);lastEvent.current=-1;mark(value);if(was)startBuffer(value);}
116
  function noteEdit(action:'up'|'down'|'longer'|'shorter'){
117
  if(!selection)return;const match=selection.token.match(/^(\s*(?:"[^"]*"\s*)*)([_^=]*)([A-Ga-g])([,']*)(\d*(?:\/\d*)?)(-?)(\s*)$/);
@@ -123,7 +143,7 @@ export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult
123
  }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}`;}
124
  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});
125
  }
126
- function midi(){if(!lib.current)return;try{const uri=lib.current.synth.getMidiFile(abc,{midiOutputType:'encoded'}) 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.');}}
127
  return <section className={`workspace view-enter ${editingEnabled?'':'read-only-score'}`}>
128
  <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>
129
  <div className="transport" aria-label="Score playback controls">
@@ -133,11 +153,11 @@ export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult
133
  <button className={`transport-icon ${loop?'active':''}`} onClick={()=>setLoop(!loop)} aria-label="Loop score" aria-pressed={loop} title="Loop"><Repeat2 size={20}/></button>
134
  <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>
135
  </div>
136
- <div className="under-player"><span>{loading?'Preparing piano sounds…':'Piano playback follows your written score'}</span><div className="playback-options"><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>
137
  <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>
138
  <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>
139
  {problem&&<p role="alert" className="error-message">{problem}</p>}{notice&&<p role="status" className="edit-notice">{notice}</p>}
140
  <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>
141
- {source.audioUrl&&<OriginalAudioPlayer src={source.audioUrl} onPlay={stop} scorePlaying={playing}/>}
142
  </section>;
143
  }
 
1
  'use client';
2
+ import {useCallback,useEffect,useMemo,useRef,useState} from 'react';
3
  import {ArrowDown,ArrowUp,ChevronDown,Code2,Download,Music2,Pause,Play,Printer,Redo2,Repeat2,RotateCcw,SkipBack,Undo2,Volume2,VolumeX,X} from 'lucide-react';
4
  import type {NoteTimingEvent,TuneObject} from 'abcjs';
5
  import type {Source,ScoreResult} from '@/lib/types';
6
  import OriginalAudioPlayer from '@/components/OriginalAudioPlayer';
7
  import {prepareScore,SampleCache,type Playback} from '@/lib/score-playback';
8
+ import {engrave} from '@/lib/engrave';
9
 
10
  type AbcLibrary=typeof import('abcjs');
11
  type Selection={start:number;end:number;token:string};
 
18
  export default function ScoreWorkspace({result,source,onNew}:{result:ScoreResult;source:Source;onNew:()=>void}){
19
  const [history,setHistory]=useState([result.abc]),[index,setIndex]=useState(0);
20
  const abc=history[index],edited=abc!==result.abc;
21
+ // What is drawn and played: the same notes laid out for reading. Editing by
22
+ // clicking a note needs the text's own character positions, so it uses the text.
23
+ const display=useMemo(()=>{if(editingEnabled)return abc;try{return engrave(abc);}catch{return abc;}},[abc]);
24
  const [codeOpen,setCodeOpen]=useState(false),[selection,setSelection]=useState<Selection|null>(null);
25
  const [playing,setPlaying]=useState(false),[loading,setLoading]=useState(false),[position,setPosition]=useState(0),[total,setTotal]=useState(0);
26
  const [speed,setSpeed]=useState(100),[volume,setVolume]=useState(1),[loop,setLoop]=useState(false),[follow,setFollow]=useState(true);
27
  const instrument=0;
28
+ const [chords,setChords]=useState(false);
29
  const [problem,setProblem]=useState(''),[notice,setNotice]=useState(''),[ready,setReady]=useState(false);
30
  const paper=useRef<HTMLDivElement>(null),scoreScroll=useRef<HTMLDivElement>(null),textarea=useRef<HTMLTextAreaElement>(null);
31
  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);
 
68
  const ABC=await import('abcjs');if(revision!==generation.current||!paper.current)return;lib.current=ABC;
69
  try{
70
  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.');
71
+ const visual=ABC.renderAbc(paper.current,display,{add_classes:true,selectTypes:['note'],selectionColor:'#cd7815',responsive:'resize',staffwidth:850,paddingtop:30,paddingbottom:40,
72
  clickListener:(el,_n,_classes,analysis)=>{if(el.el_type!=='note'||typeof el.startChar!=='number'||typeof el.endChar!=='number')return;
73
  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;}
74
  const selected={start:el.startChar,end:el.endChar,token:abc.slice(el.startChar,el.endChar)};setSelection(selected);
 
84
  setNotice(visual.warnings?.length?'Some notation could not be read. Check your edit before playing.':'');
85
  }catch(e){setProblem(e instanceof Error?e.message:'This notation could not be read.');tune.current=null;}
86
  },180);return()=>clearTimeout(timer);
87
+ },[abc,display,speed,chords,stop]);
88
  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]);
89
  useEffect(()=>{if(gain.current)gain.current.gain.value=volume;},[volume]);
90
  useEffect(()=>()=>{generation.current++;stop();gain.current?.disconnect();},[stop]);
 
97
  const promise=(async()=>{try{
98
  setLoading(true);setProblem('');
99
  if(!audio.current){audio.current=new AudioContext();gain.current=audio.current.createGain();gain.current.gain.value=volumeRef.current;gain.current.connect(audio.current.destination);}
100
+ const prepared=await prepareScore(audio.current,originalTune,instrument,speed,chords,soundCache.current,()=>rev===generation.current);
101
  if(rev!==generation.current)return;
102
  buffer.current=prepared;setTotal(prepared.duration);
103
  }catch(e){if(rev===generation.current)setProblem(e instanceof Error?e.message:'Playback failed. Please try again.');}
104
  finally{if(rev===generation.current)setLoading(false);if(preparation.current?.revision===rev)preparation.current=null;}
105
  })();
106
  preparation.current={revision:rev,promise};return promise;
107
+ },[ready,abc,speed,chords]);
108
  useEffect(()=>{if(ready)void preparePlayback();},[ready,preparePlayback]);
109
  async function toggle(){
110
  if(playing){stop();return;}if(!ready||playRequest.current!==null)return;
 
117
  }catch(e){if(rev===generation.current)setProblem(e instanceof Error?e.message:'Playback failed. Please try again.');}
118
  finally{if(playRequest.current===rev){playRequest.current=null;setStarting(false);}}
119
  }
120
+ /**
121
+ * Light the note the original recording is on. The engine's bar timing says
122
+ * where each bar starts in the recording, so the score keeps up as the
123
+ * performance speeds up and slows down. Pressing play on the score then
124
+ * carries on from that point.
125
+ */
126
+ const followRecording=useCallback((seconds:number)=>{
127
+ const timing=result.timing;if(!timing||isPlaying.current)return;
128
+ const {bars,clock,bpm}=timing;
129
+ if(seconds<clock[0]){mark(-1);return;}
130
+ let i=0;while(i<clock.length-2&&seconds>=clock[i+1])i++;
131
+ const quarters=Math.min(bars[bars.length-1],bars[i]+(bars[i+1]-bars[i])*(seconds-clock[i])/(clock[i+1]-clock[i]));
132
+ const at=quarters*60/bpm*100/speed;
133
+ offset.current=at;setPosition(at);mark(at);
134
+ },[result.timing,mark,speed]);
135
  function seek(value:number){const was=isPlaying.current;stop();offset.current=value;setPosition(value);lastEvent.current=-1;mark(value);if(was)startBuffer(value);}
136
  function noteEdit(action:'up'|'down'|'longer'|'shorter'){
137
  if(!selection)return;const match=selection.token.match(/^(\s*(?:"[^"]*"\s*)*)([_^=]*)([A-Ga-g])([,']*)(\d*(?:\/\d*)?)(-?)(\s*)$/);
 
143
  }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}`;}
144
  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});
145
  }
146
+ 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.');}}
147
  return <section className={`workspace view-enter ${editingEnabled?'':'read-only-score'}`}>
148
  <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>
149
  <div className="transport" aria-label="Score playback controls">
 
153
  <button className={`transport-icon ${loop?'active':''}`} onClick={()=>setLoop(!loop)} aria-label="Loop score" aria-pressed={loop} title="Loop"><Repeat2 size={20}/></button>
154
  <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>
155
  </div>
156
+ <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>
157
  <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>
158
  <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>
159
  {problem&&<p role="alert" className="error-message">{problem}</p>}{notice&&<p role="status" className="edit-notice">{notice}</p>}
160
  <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>
161
+ {source.audioUrl&&<OriginalAudioPlayer src={source.audioUrl} onPlay={stop} scorePlaying={playing} onPosition={result.timing?followRecording:undefined}/>}
162
  </section>;
163
  }
lib/engrave.ts ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Lay out SheetSage2's native score for reading, without changing a note.
3
+ *
4
+ * The native dialect writes each bar as one unbroken run, so abcjs beams a
5
+ * whole bar together and a note that crosses a beat is not split. It always
6
+ * uses a treble clef, even for a low voice, keeps an Ins staff that is often
7
+ * all rests, and carries an accidental to that letter in every octave of the
8
+ * bar, where abcjs reads it for one octave only, so such a note is drawn and
9
+ * played a semitone off.
10
+ *
11
+ * `engrave` reads the native score into notes with the rules of the engine's
12
+ * abc_tools.py and writes it again grouped by beat, with a clef that fits each
13
+ * part and accidentals abcjs reads the same way. The result is only for
14
+ * display and playback; downloads and the editor keep the native text. A score
15
+ * that is not in the native dialect, such as one edited by hand, throws, and
16
+ * the caller shows it as written.
17
+ */
18
+
19
+ type Meter=[number,number];
20
+ type Note={start:number;units:number;letter:string;octave:number;alt:number;pitch:number};
21
+ type Voice={meter:Meter;key:string;time:number;notes:Note[];chords:[number,string][];keys:[number,string][];pending:{pitch:number;written:number}|null};
22
+ type Bar={start:number;length:number;meter:Meter;key:string;sections:string[]};
23
+
24
+ const TOKEN=/"([^"\n]*)"|\[K:([^\]\n]+)\]|(\^\^|__|\^|_|=)?([A-Ga-gz])([,']*)(\d*)(-?)/y;
25
+ const NATURAL:Record<string,number>={C:0,D:2,E:4,F:5,G:7,A:9,B:11};
26
+ const ALTER:Record<string,number>={'=':0,'_':-1,'__':-2,'^':1,'^^':2};
27
+ const MARK:Record<number,string>={[-2]:'__',[-1]:'_',0:'=',1:'^',2:'^^'};
28
+ const MAJOR=['Cb','Gb','Db','Ab','Eb','Bb','F','C','G','D','A','E','B','F#','C#'];
29
+ const MINOR=['Abm','Ebm','Bbm','Fm','Cm','Gm','Dm','Am','Em','Bm','F#m','C#m','G#m','D#m','A#m'];
30
+ const VOICE_DEFS=['V: Vocal clef=treble name="Vocal Melody" snm="Vocal"','V: Ins clef=treble name="Ins Melody" snm="Inst."'];
31
+
32
+ function fail(message:string):never{throw new Error('Not a native score: '+message);}
33
+
34
+ function signature(key:string){
35
+ const index=Math.max(MAJOR.indexOf(key),MINOR.indexOf(key));
36
+ if(index<0)fail(`key ${key}`);
37
+ const count=index-7,result:Record<string,number>={C:0,D:0,E:0,F:0,G:0,A:0,B:0};
38
+ for(const letter of (count>0?'FCGDAEB':'BEADGCF').slice(0,Math.abs(count)))result[letter]=count>0?1:-1;
39
+ return result;
40
+ }
41
+
42
+ function meterOf(text:string):Meter{
43
+ const match=/^([1-9]\d*)\/([1-9]\d*)$/.exec(text.trim());
44
+ if(!match)fail(`meter ${text}`);
45
+ return [Number(match[1]),Number(match[2])];
46
+ }
47
+
48
+ function parseBar(body:string,voice:Voice,grid:number,bars:Bar[]|null){
49
+ const [n,d]=voice.meter,length=n*grid/d;
50
+ if(!Number.isInteger(length))fail('meter does not fit the grid');
51
+ const start=voice.time,local:Record<string,number>={};
52
+ if(bars)bars.push({start,length,meter:voice.meter,key:voice.key,sections:[]});
53
+ let offset=0;
54
+ if(body==='Z'){if(voice.pending)fail('tie into a rest');offset=length;}
55
+ else for(let cursor=0;cursor<body.length;){
56
+ if(/\s/.test(body[cursor])){cursor++;continue;}
57
+ TOKEN.lastIndex=cursor;
58
+ const match=TOKEN.exec(body);
59
+ if(!match)fail(`token at ${body.slice(cursor,cursor+12)}`);
60
+ cursor=TOKEN.lastIndex;
61
+ const [,chord,key,acc,name,marks,count,tie]=match;
62
+ if(chord!==undefined){voice.chords.push([start+offset,chord]);continue;}
63
+ if(key!==undefined){signature(key);voice.key=key;voice.keys.push([start+offset,key]);for(const k in local)delete local[k];continue;}
64
+ const units=Number(count||1);
65
+ if(offset+units>length)fail('bar too long');
66
+ if(name==='z'){if(voice.pending)fail('tie into a rest');offset+=units;continue;}
67
+ const letter=name.toUpperCase(),octave=(name===letter?0:1)+[...marks].filter(c=>c==="'").length-[...marks].filter(c=>c===',').length;
68
+ const written=60+NATURAL[letter]+12*octave;
69
+ let alt=local[letter]??signature(voice.key)[letter];
70
+ if(acc){alt=ALTER[acc];local[letter]=alt;}
71
+ let pitch=written+alt;
72
+ if(voice.pending){
73
+ // An unmarked continuation keeps its tied accidental across a bar line.
74
+ if(!acc&&written===voice.pending.written)pitch=voice.pending.pitch;
75
+ if(pitch!==voice.pending.pitch)fail('tie changes pitch');
76
+ voice.notes[voice.notes.length-1].units+=units;
77
+ }else voice.notes.push({start:start+offset,units,letter,octave,alt:pitch-written,pitch});
78
+ voice.pending=tie?{pitch,written}:null;
79
+ offset+=units;
80
+ }
81
+ if(offset!==length)fail('bar too short');
82
+ voice.time+=length;
83
+ }
84
+
85
+ function parse(text:string){
86
+ const lines=text.split(/\r?\n/).filter(line=>line.trim());
87
+ if(lines.length<12||lines[0]!=='X:1'||!lines[1].startsWith('T:')||!lines[2].startsWith('M:'))fail('header');
88
+ const unit=/^L:1\/(\d+)$/.exec(lines[3]),tempo=/^Q:1\/4=(\d+)$/.exec(lines[4]);
89
+ if(!unit||!tempo||lines[5]!==VOICE_DEFS[0]||lines[6]!==VOICE_DEFS[1]||!lines[7].startsWith('K:'))fail('header');
90
+ const grid=Number(unit[1]),meter=meterOf(lines[2].slice(2)),key=lines[7].slice(2).trim();
91
+ signature(key);
92
+ const make=():Voice=>({meter,key,time:0,notes:[],chords:[],keys:[[0,key]],pending:null});
93
+ const vocal=make(),ins=make(),bars:Bar[]=[];
94
+ let cursor=8;
95
+ while(cursor<lines.length){
96
+ const sections:string[]=[];
97
+ while(cursor<lines.length&&lines[cursor].startsWith('%')){sections.push(lines[cursor].replace(/^%\s*/,''));cursor++;}
98
+ if(cursor>=lines.length)fail('section without music');
99
+ const first=bars.length;
100
+ for(const [name,voice] of [['Vocal',vocal],['Ins',ins]] as const){
101
+ if(lines[cursor]?.trim()!=='V: '+name)fail(`expected V: ${name}`);
102
+ cursor++;
103
+ while(cursor<lines.length&&/^[MK]:/.test(lines[cursor])){
104
+ const value=lines[cursor].slice(2).trim();
105
+ if(lines[cursor][0]==='M')voice.meter=meterOf(value);
106
+ else{signature(value);voice.key=value;voice.keys.push([voice.time,value]);}
107
+ cursor++;
108
+ }
109
+ const line=lines[cursor++]?.trim();
110
+ if(!line?.endsWith('|'))fail('music line');
111
+ for(const raw of line.slice(0,-1).split('|')){
112
+ const body=raw.trim(),rest=/^Z([2-4])?$/.exec(body);
113
+ if(!body)fail('empty bar');
114
+ for(let i=0;i<(rest?Number(rest[1]||1):1);i++)parseBar(rest?'Z':body,voice,grid,name==='Vocal'?bars:null);
115
+ }
116
+ }
117
+ if(vocal.time!==ins.time)fail('voices differ in length');
118
+ if(bars[first])bars[first].sections=sections;
119
+ }
120
+ if(vocal.pending||ins.pending)fail('unresolved tie');
121
+ // A key change written at the very start of a bar belongs to that bar.
122
+ for(const bar of bars)bar.key=vocal.keys.filter(([t])=>t<=bar.start).pop()![1];
123
+ return {header:lines.slice(0,5),grid,bars,vocal,ins};
124
+ }
125
+
126
+ /** A length abcjs draws as one note: plain or dotted. */
127
+ function drawable(units:number){while(units%2===0)units/=2;return units===1||units===3;}
128
+
129
+ /**
130
+ * Split a note or rest so it reads by the beat: one that starts off the beat
131
+ * stops at the next beat, and in 4/4 nothing but a note from the downbeat
132
+ * crosses the middle of the bar. Each piece is a plain or dotted value.
133
+ */
134
+ function pieces(position:number,units:number,beat:number,middle:number){
135
+ const out:number[]=[];
136
+ while(units>0){
137
+ let take=units;
138
+ if(position%beat)take=Math.min(take,beat-position%beat);
139
+ else if(middle&&position&&position<middle&&position+take>middle)take=middle-position;
140
+ while(!drawable(take))take--;
141
+ out.push(take);position+=take;units-=take;
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** Treble for most parts, treble-8 (written an octave up) for low voices, bass below that. */
147
+ function clefFor(notes:Note[]){
148
+ if(!notes.length)return {clef:'treble',shift:0};
149
+ const sorted=notes.map(note=>note.pitch).sort((a,b)=>a-b),middle=sorted[sorted.length>>1];
150
+ return middle>=59?{clef:'treble',shift:0}:middle>=47?{clef:'treble-8',shift:1}:{clef:'bass',shift:0};
151
+ }
152
+
153
+ const noteName=(letter:string,octave:number)=>octave<0?letter+','.repeat(-octave):octave===0?letter:letter.toLowerCase()+"'".repeat(octave-1);
154
+ const count=(units:number)=>units===1?'':String(units);
155
+ const label=(text:string)=>text.charAt(0).toUpperCase()+text.slice(1);
156
+
157
+ function renderBar(bar:Bar,voice:Voice,grid:number,shift:number,lead:boolean){
158
+ const end=bar.start+bar.length;
159
+ const inside=voice.notes.filter(note=>note.start<end&&note.start+note.units>bar.start);
160
+ const chords=lead?voice.chords.filter(([t])=>t>=bar.start&&t<end):[];
161
+ const keys=voice.keys.filter(([t])=>t>bar.start&&t<end);
162
+ const sections=lead?bar.sections:[];
163
+ if(!inside.length&&!chords.length&&!keys.length&&!sections.length)return 'Z';
164
+ const [n,d]=bar.meter,compound=d===8&&n%3===0;
165
+ let beat=compound?3*grid/8:grid/d;
166
+ if(!Number.isInteger(beat)||beat<1)beat=bar.length;
167
+ const middle=n===4&&d===4?2*beat:0;
168
+ const points=new Set([bar.start,end,...chords.map(([t])=>t),...keys.map(([t])=>t)]);
169
+ for(const note of inside){points.add(Math.max(bar.start,note.start));points.add(Math.min(end,note.start+note.units));}
170
+ const times=[...points].sort((a,b)=>a-b);
171
+ // Accidentals are written whenever abcjs (by octave) or the native dialect
172
+ // (by letter) would otherwise read the note differently.
173
+ let sig=signature(bar.key);
174
+ const byOctave=new Map<string,number>(),byLetter=new Map<string,number>();
175
+ const accidental=(note:Note)=>{
176
+ const natural=sig[note.letter],id=note.letter+note.octave;
177
+ const needed=note.alt!==(byOctave.get(id)??natural)||note.alt!==(byLetter.get(note.letter)??natural);
178
+ byOctave.set(id,note.alt);byLetter.set(note.letter,note.alt);
179
+ return needed?MARK[note.alt]:'';
180
+ };
181
+ const gap=(at:number)=>at>bar.start&&(at-bar.start)%beat===0?' ':'';
182
+ let out='';
183
+ for(let i=0;i<times.length-1;i++){
184
+ const a=times[i],b=times[i+1];
185
+ out+=gap(a);
186
+ for(const [t,key] of keys)if(t===a){out+=`[K:${key}]`;sig=signature(key);byOctave.clear();byLetter.clear();}
187
+ // Chord names first, so a section label stacks above them, not between them and the staff.
188
+ for(const [t,symbol] of chords)if(t===a)out+=`"${symbol}"`;
189
+ if(a===bar.start)out+=sections.map(text=>`"^${label(text)}"`).join('');
190
+ const note=inside.find(item=>item.start<=a&&a<item.start+item.units);
191
+ let at=a;
192
+ pieces(a-bar.start,b-a,beat,middle).forEach((units,k)=>{
193
+ if(k)out+=gap(at);
194
+ if(!note)out+='z'+count(units);
195
+ else{
196
+ // Only a note's first piece carries its accidental. A tied piece keeps the
197
+ // pitch it is tied from, and abcjs ignores any accidental written on it
198
+ // when it reads the rest of the bar.
199
+ const mark=at>note.start?'':accidental(note);
200
+ const tie=at+units<note.start+note.units?'-':'';
201
+ out+=mark+noteName(note.letter,note.octave+shift)+count(units)+tie;
202
+ }
203
+ at+=units;
204
+ });
205
+ }
206
+ return out;
207
+ }
208
+
209
+ export function engrave(text:string){
210
+ const {header,grid,bars,vocal,ins}=parse(text);
211
+ // Chords and section names ride on the first staff shown.
212
+ const parts=[vocal.notes.length||!ins.notes.length?{name:'Vocal',voice:vocal}:null,ins.notes.length?{name:'Ins',voice:ins}:null]
213
+ .filter((part):part is {name:string;voice:Voice}=>!!part);
214
+ if(parts[0].voice===ins){ins.chords=vocal.chords;}
215
+ const named=parts.length>1;
216
+ const defs=parts.map(({name,voice})=>{
217
+ const {clef}=clefFor(voice.notes);
218
+ return `V:${name} clef=${clef}`+(named?(name==='Vocal'?' name="Vocal" snm="Vo."':' name="Instrument" snm="Ins."'):'');
219
+ });
220
+ const lines=[...header.slice(0,2),`M:${bars[0].meter.join('/')}`,header[3],header[4],...defs,`K:${bars[0].key}`];
221
+ const rendered=parts.map(({voice},index)=>{
222
+ const {shift}=clefFor(voice.notes);
223
+ return bars.map(bar=>renderBar(bar,voice,grid,shift,index===0));
224
+ });
225
+ // abcjs lets a key change in one voice's line leak into the next voice's
226
+ // line, so a line breaks wherever the key or meter changes on a bar line and
227
+ // every voice's line states its key.
228
+ for(let first=0;first<bars.length;){
229
+ let last=first+1;
230
+ while(last<bars.length&&last-first<4&&!bars[last].sections.length
231
+ &&bars[last].meter.join()===bars[first].meter.join()&&bars[last].key===bars[first].key)last++;
232
+ const meterChanged=first>0&&bars[first-1].meter.join()!==bars[first].meter.join();
233
+ parts.forEach(({name},index)=>{
234
+ lines.push(`V:${name}`);
235
+ if(meterChanged)lines.push(`M:${bars[first].meter.join('/')}`);
236
+ lines.push(`K:${bars[first].key}`);
237
+ lines.push(rendered[index].slice(first,last).join(' | ')+(last===bars.length?' |]':' |'));
238
+ });
239
+ first=last;
240
+ }
241
+ return lines.join('\n')+'\n';
242
+ }
lib/score-playback.ts CHANGED
@@ -1,78 +1,79 @@
1
- import type {TuneObject} from 'abcjs';
2
- import {instruments} from './instruments';
3
-
4
- type Note={pitch:number;start:number;end:number;volume:number;cents:number};
5
- export type Playback={duration:number;start:(context:AudioContext,destination:AudioNode,offset:number,onEnd:()=>void)=>{stop:()=>void}};
6
-
7
- /** Cache small reusable samples, never a rendered copy of the entire recording. */
8
- export class SampleCache {
9
- private samples=new Map<string,AudioBuffer>();
10
- private pending=new Map<string,Promise<AudioBuffer>>();
11
- private bytes=0;
12
- async load(context:AudioContext,bank:string,pitch:number){
13
- const key=`${bank}:${pitch}`,cached=this.samples.get(key);
14
- if(cached){this.samples.delete(key);this.samples.set(key,cached);return cached;}
15
- const pending=this.pending.get(key);if(pending)return pending;
16
- const name=['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B'][pitch%12]+(Math.floor(pitch/12)-1);
17
- const request=(async()=>{
18
- const response=await fetch(`/soundfonts/${bank}-mp3/${name}.mp3`,{signal:AbortSignal.timeout(15000)});
19
- if(!response.ok)throw new Error('Instrument sounds could not load. Please try again.');
20
- const sample=await context.decodeAudioData(await response.arrayBuffer());
21
- const size=sample.length*sample.numberOfChannels*4;
22
- while(this.bytes+size>64*1024*1024&&this.samples.size){
23
- const oldest=this.samples.keys().next().value!,value=this.samples.get(oldest)!;
24
- this.bytes-=value.length*value.numberOfChannels*4;this.samples.delete(oldest);
25
- }
26
- this.samples.set(key,sample);this.bytes+=size;return sample;
27
- })();
28
- this.pending.set(key,request);
29
- try{return await request;}finally{this.pending.delete(key);}
30
- }
31
- }
32
-
33
- export async function prepareScore(context:AudioContext,tune:TuneObject,program:number,speed:number,cache:SampleCache,isCurrent=()=>true):Promise<Playback>{
34
- const bank=instruments.find(item=>item.program===program)?.sample;
35
- if(!bank)throw new Error('Unsupported instrument.');
36
- const sequence=tune.setUpAudio({program}),meter=tune.getMeterFraction();
37
- const seconds=tune.millisecondsPerMeasure()*100/speed/1000/(meter.den?meter.num/meter.den:1);
38
- const notes:Note[]=sequence.tracks.flatMap(track=>track.flatMap(event=>{
39
- if(event.cmd!=='note'||event.duration<=0)return [];
40
- const gap=Math.min(event.gap||0,event.duration*2/3);
41
- return [{pitch:event.pitch,start:event.start*seconds,end:(event.start+event.duration-gap)*seconds,volume:event.volume/96,cents:('cents' in event?Number(event.cents):0)||0}];
42
- })).sort((a,b)=>a.start-b.start);
43
- const samples=new Map<number,AudioBuffer>();
44
- const pitches=[...new Set(notes.map(note=>note.pitch))];
45
- let next=0;
46
- await Promise.all(Array.from({length:Math.min(8,pitches.length)},async()=>{
47
- while(next<pitches.length&&isCurrent()){const pitch=pitches[next++];samples.set(pitch,await cache.load(context,bank,pitch));}
48
- }));
49
- if(!isCurrent())throw new Error('Playback selection changed.');
50
- const duration=sequence.totalDuration*seconds+.2;
51
- return {duration,start(ctx,destination,offset,onEnd){
52
- const origin=ctx.currentTime-offset,active=new Set<AudioBufferSourceNode>();
53
- let index=0,stopped=false;
54
- while(index<notes.length&&notes[index].end+.2<=offset)index++;
55
- const tick=()=>{
56
- if(stopped)return;
57
- const now=ctx.currentTime,position=now-origin;
58
- while(index<notes.length&&notes[index].start<position+1){
59
- const note=notes[index++],sample=samples.get(note.pitch)!;
60
- const elapsed=Math.max(0,position-note.start),rate=2**(note.cents/1200);
61
- if(note.end+.2<=position||elapsed*rate>=sample.duration)continue;
62
- const source=ctx.createBufferSource(),envelope=ctx.createGain();
63
- source.buffer=sample;source.playbackRate.value=rate;
64
- const when=Math.max(now,origin+note.start),end=origin+note.end;
65
- envelope.gain.setValueAtTime(note.volume*Math.min(1,Math.max(0,(end+.2-when)/.2)),when);
66
- if(end>when)envelope.gain.setValueAtTime(note.volume,end);
67
- envelope.gain.linearRampToValueAtTime(0,Math.max(when+.001,end+.2));
68
- source.connect(envelope);envelope.connect(destination);active.add(source);
69
- source.onended=()=>{active.delete(source);source.disconnect();envelope.disconnect();};
70
- source.start(when,elapsed*rate);source.stop(Math.max(when+.001,end+.2));
71
- }
72
- if(position>=duration){stop();onEnd();}
73
- };
74
- const timer=setInterval(tick,100);
75
- function stop(){stopped=true;clearInterval(timer);for(const source of active){source.stop();}active.clear();}
76
- tick();return {stop};
77
- }};
78
- }
 
 
1
+ import type {TuneObject} from 'abcjs';
2
+ import {instruments} from './instruments';
3
+
4
+ type Note={pitch:number;start:number;end:number;volume:number;cents:number};
5
+ export type Playback={duration:number;start:(context:AudioContext,destination:AudioNode,offset:number,onEnd:()=>void)=>{stop:()=>void}};
6
+
7
+ /** Cache small reusable samples, never a rendered copy of the entire recording. */
8
+ export class SampleCache {
9
+ private samples=new Map<string,AudioBuffer>();
10
+ private pending=new Map<string,Promise<AudioBuffer>>();
11
+ private bytes=0;
12
+ async load(context:AudioContext,bank:string,pitch:number){
13
+ const key=`${bank}:${pitch}`,cached=this.samples.get(key);
14
+ if(cached){this.samples.delete(key);this.samples.set(key,cached);return cached;}
15
+ const pending=this.pending.get(key);if(pending)return pending;
16
+ const name=['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B'][pitch%12]+(Math.floor(pitch/12)-1);
17
+ const request=(async()=>{
18
+ const response=await fetch(`/soundfonts/${bank}-mp3/${name}.mp3`,{signal:AbortSignal.timeout(15000)});
19
+ if(!response.ok)throw new Error('Instrument sounds could not load. Please try again.');
20
+ const sample=await context.decodeAudioData(await response.arrayBuffer());
21
+ const size=sample.length*sample.numberOfChannels*4;
22
+ while(this.bytes+size>64*1024*1024&&this.samples.size){
23
+ const oldest=this.samples.keys().next().value!,value=this.samples.get(oldest)!;
24
+ this.bytes-=value.length*value.numberOfChannels*4;this.samples.delete(oldest);
25
+ }
26
+ this.samples.set(key,sample);this.bytes+=size;return sample;
27
+ })();
28
+ this.pending.set(key,request);
29
+ try{return await request;}finally{this.pending.delete(key);}
30
+ }
31
+ }
32
+
33
+ /** `chords` adds abcjs's backing from the chord names: a bass and chord pattern that is not in the notation. */
34
+ export async function prepareScore(context:AudioContext,tune:TuneObject,program:number,speed:number,chords:boolean,cache:SampleCache,isCurrent=()=>true):Promise<Playback>{
35
+ const bank=instruments.find(item=>item.program===program)?.sample;
36
+ if(!bank)throw new Error('Unsupported instrument.');
37
+ const sequence=tune.setUpAudio({program,chordsOff:!chords}),meter=tune.getMeterFraction();
38
+ const seconds=tune.millisecondsPerMeasure()*100/speed/1000/(meter.den?meter.num/meter.den:1);
39
+ const notes:Note[]=sequence.tracks.flatMap(track=>track.flatMap(event=>{
40
+ if(event.cmd!=='note'||event.duration<=0)return [];
41
+ const gap=Math.min(event.gap||0,event.duration*2/3);
42
+ return [{pitch:event.pitch,start:event.start*seconds,end:(event.start+event.duration-gap)*seconds,volume:event.volume/96,cents:('cents' in event?Number(event.cents):0)||0}];
43
+ })).sort((a,b)=>a.start-b.start);
44
+ const samples=new Map<number,AudioBuffer>();
45
+ const pitches=[...new Set(notes.map(note=>note.pitch))];
46
+ let next=0;
47
+ await Promise.all(Array.from({length:Math.min(8,pitches.length)},async()=>{
48
+ while(next<pitches.length&&isCurrent()){const pitch=pitches[next++];samples.set(pitch,await cache.load(context,bank,pitch));}
49
+ }));
50
+ if(!isCurrent())throw new Error('Playback selection changed.');
51
+ const duration=sequence.totalDuration*seconds+.2;
52
+ return {duration,start(ctx,destination,offset,onEnd){
53
+ const origin=ctx.currentTime-offset,active=new Set<AudioBufferSourceNode>();
54
+ let index=0,stopped=false;
55
+ while(index<notes.length&&notes[index].end+.2<=offset)index++;
56
+ const tick=()=>{
57
+ if(stopped)return;
58
+ const now=ctx.currentTime,position=now-origin;
59
+ while(index<notes.length&&notes[index].start<position+1){
60
+ const note=notes[index++],sample=samples.get(note.pitch)!;
61
+ const elapsed=Math.max(0,position-note.start),rate=2**(note.cents/1200);
62
+ if(note.end+.2<=position||elapsed*rate>=sample.duration)continue;
63
+ const source=ctx.createBufferSource(),envelope=ctx.createGain();
64
+ source.buffer=sample;source.playbackRate.value=rate;
65
+ const when=Math.max(now,origin+note.start),end=origin+note.end;
66
+ envelope.gain.setValueAtTime(note.volume*Math.min(1,Math.max(0,(end+.2-when)/.2)),when);
67
+ if(end>when)envelope.gain.setValueAtTime(note.volume,end);
68
+ envelope.gain.linearRampToValueAtTime(0,Math.max(when+.001,end+.2));
69
+ source.connect(envelope);envelope.connect(destination);active.add(source);
70
+ source.onended=()=>{active.delete(source);source.disconnect();envelope.disconnect();};
71
+ source.start(when,elapsed*rate);source.stop(Math.max(when+.001,end+.2));
72
+ }
73
+ if(position>=duration){stop();onEnd();}
74
+ };
75
+ const timer=setInterval(tick,100);
76
+ function stop(){stopped=true;clearInterval(timer);for(const source of active){source.stop();}active.clear();}
77
+ tick();return {stop};
78
+ }};
79
+ }
lib/types.ts CHANGED
@@ -1,6 +1,8 @@
1
  export type Download = {name:string;url:string};
2
  export type Source = {title:string;artist?:string;thumbnail?:string;url?:string;audioUrl?:string};
3
- export type ScoreResult = {abc:string;status:string;downloads:Download[];pdf?:string;audio?:string};
 
 
4
  export type SongResult = {status:string;audio?:Download;artifacts?:Download;abc:string;seed?:number};
5
  /** What the engine measured in a take: the grid and chords a band was written to. */
6
  export type Analysis = {tempo:number;key:string;duration?:number;offset?:number;beats?:number[];chords:{time:number;name:string}[];separated?:boolean;
 
1
  export type Download = {name:string;url:string};
2
  export type Source = {title:string;artist?:string;thumbnail?:string;url?:string;audioUrl?:string};
3
+ /** Where each score bar starts: `bars` in quarter notes, `clock` in seconds of the recording. */
4
+ export type ScoreTiming = {bpm:number;bars:number[];clock:number[]};
5
+ export type ScoreResult = {abc:string;status:string;downloads:Download[];pdf?:string;audio?:string;timing?:ScoreTiming};
6
  export type SongResult = {status:string;audio?:Download;artifacts?:Download;abc:string;seed?:number};
7
  /** What the engine measured in a take: the grid and chords a band was written to. */
8
  export type Analysis = {tempo:number;key:string;duration?:number;offset?:number;beats?:number[];chords:{time:number;name:string}[];separated?:boolean;