import type {TuneObject} from 'abcjs'; import {instruments} from './instruments'; type Note={pitch:number;start:number;end:number;volume:number;cents:number}; export type Playback={duration:number;start:(context:AudioContext,destination:AudioNode,offset:number,onEnd:()=>void)=>{stop:()=>void}}; /** Cache small reusable samples, never a rendered copy of the entire recording. */ export class SampleCache { private samples=new Map(); private pending=new Map>(); private bytes=0; async load(context:AudioContext,bank:string,pitch:number){ const key=`${bank}:${pitch}`,cached=this.samples.get(key); if(cached){this.samples.delete(key);this.samples.set(key,cached);return cached;} const pending=this.pending.get(key);if(pending)return pending; const name=['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B'][pitch%12]+(Math.floor(pitch/12)-1); const request=(async()=>{ const response=await fetch(`/soundfonts/${bank}-mp3/${name}.mp3`,{signal:AbortSignal.timeout(15000)}); if(!response.ok)throw new Error('Instrument sounds could not load. Please try again.'); const sample=await context.decodeAudioData(await response.arrayBuffer()); const size=sample.length*sample.numberOfChannels*4; while(this.bytes+size>64*1024*1024&&this.samples.size){ const oldest=this.samples.keys().next().value!,value=this.samples.get(oldest)!; this.bytes-=value.length*value.numberOfChannels*4;this.samples.delete(oldest); } this.samples.set(key,sample);this.bytes+=size;return sample; })(); this.pending.set(key,request); try{return await request;}finally{this.pending.delete(key);} } } /** `chords` adds abcjs's backing from the chord names: a bass and chord pattern that is not in the notation. */ export async function prepareScore(context:AudioContext,tune:TuneObject,program:number,speed:number,chords:boolean,cache:SampleCache,isCurrent=()=>true):Promise{ const bank=instruments.find(item=>item.program===program)?.sample; if(!bank)throw new Error('Unsupported instrument.'); const sequence=tune.setUpAudio({program,chordsOff:!chords}),meter=tune.getMeterFraction(); const seconds=tune.millisecondsPerMeasure()*100/speed/1000/(meter.den?meter.num/meter.den:1); const notes:Note[]=sequence.tracks.flatMap(track=>track.flatMap(event=>{ if(event.cmd!=='note'||event.duration<=0)return []; const gap=Math.min(event.gap||0,event.duration*2/3); 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}]; })).sort((a,b)=>a.start-b.start); const samples=new Map(); const pitches=[...new Set(notes.map(note=>note.pitch))]; let next=0; await Promise.all(Array.from({length:Math.min(8,pitches.length)},async()=>{ while(next(); let index=0,stopped=false; while(index{ if(stopped)return; const now=ctx.currentTime,position=now-origin; while(index=sample.duration)continue; const source=ctx.createBufferSource(),envelope=ctx.createGain(); source.buffer=sample;source.playbackRate.value=rate; const when=Math.max(now,origin+note.start),end=origin+note.end; envelope.gain.setValueAtTime(note.volume*Math.min(1,Math.max(0,(end+.2-when)/.2)),when); if(end>when)envelope.gain.setValueAtTime(note.volume,end); envelope.gain.linearRampToValueAtTime(0,Math.max(when+.001,end+.2)); source.connect(envelope);envelope.connect(destination);active.add(source); source.onended=()=>{active.delete(source);source.disconnect();envelope.disconnect();}; source.start(when,elapsed*rate);source.stop(Math.max(when+.001,end+.2)); } if(position>=duration){stop();onEnd();} }; const timer=setInterval(tick,100); function stop(){stopped=true;clearInterval(timer);for(const source of active){source.stop();}active.clear();} tick();return {stop}; }}; }