Spaces:
Running
Running
Engraved score display, chord backing toggle, follow the recording
Browse files- app/api/transcribe/route.ts +62 -54
- components/OriginalAudioPlayer.tsx +25 -18
- components/ScoreWorkspace.tsx +28 -8
- lib/engrave.ts +242 -0
- lib/score-playback.ts +79 -78
- lib/types.ts +3 -1
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 |
-
|
| 9 |
-
|
| 10 |
-
if(
|
| 11 |
-
const
|
| 12 |
-
const
|
| 13 |
-
if(!(
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
const
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 6 |
-
|
| 7 |
-
const
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
</
|
| 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,
|
| 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(
|
| 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&¬e.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 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
const
|
| 38 |
-
const
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
const
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
const
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
source
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
envelope.gain.
|
| 68 |
-
|
| 69 |
-
source.
|
| 70 |
-
source.
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 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&¬es[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&¬es[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 |
-
|
|
|
|
|
|
|
| 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;
|