/** A renderer-independent timeline; an audio study may supply its own audio clock. */ export class ScoreTransport { constructor({duration, adapter, pace=32, now, onChange=()=>{}, lookahead=.12, directionalPlayback=false}) { this.directionalPlayback=directionalPlayback;this.duration=duration;this.adapter=adapter;this.now=now||(()=>adapter.clock?.()??performance.now()/1000);this.onChange=onChange;this.lookahead=lookahead; this.state={beat:0,unwrappedBeat:0,pace,direction:1,loop:true,mode:'paused',error:'',tempo:0,sensitivity:1,activated:false};this.serial=0;this.disposed=false;this.last=this.now(); } publish(){this.onChange({...this.state});} stopTimer(){clearInterval(this.timer);this.timer=null;} begin(reconcile=false){ this.adapter.configureTimeline?.({loop:this.state.loop,lookahead:this.lookahead,direction:this.state.direction}); // Reverse traversal at zero begins with the final chord of the previous cycle. this.epochBeat=this.state.direction<0&&this.state.loop&&this.state.beat===0?this.duration:this.state.beat;this.epochUnwrapped=this.state.unwrappedBeat;this.epochTime=this.now();this.last=this.epochTime;this.scheduledThrough=this.epochBeat; this.adapter.position(this.state.beat/this.duration,this.state.unwrappedBeat/this.duration); if(reconcile&&this.adapter.retime)this.adapter.retime(this.epochBeat,this.state.pace,this.epochTime);else {if(reconcile)this.adapter.cancel();this.adapter.startAt(this.epochBeat,this.state.pace,this.epochTime);} this.schedule();this.runTimer(); } runTimer(){this.stopTimer();this.timer=setInterval(()=>this.tick(),20);this.timer.unref?.();} async play(){ if(this.disposed||['playing','enabling'].includes(this.state.mode))return; const serial=++this.serial;this.state.error='';this.state.mode='enabling';this.publish(); try{await this.adapter.enable();if(this.disposed||serial!==this.serial)return; if(this.state.direction>0&&this.state.beat>=this.duration){this.state.beat=0;if(!this.state.loop)this.state.unwrappedBeat=0;} else if(this.state.direction<0&&this.state.beat<=0&&!this.state.loop){this.state.beat=this.duration;this.state.unwrappedBeat=this.duration;} const resume=this.state.activated;this.state.activated=true;this.inputTime=null;if(!resume)this.adapter.cancel();this.state.mode='playing';this.state.tempo=this.state.pace;this.begin(resume);this.publish(); }catch(error){if(this.disposed||serial!==this.serial)return;this.adapter.cancel();this.state.mode='paused';this.state.error=error instanceof Error?error.message:'Sound could not start. Try Play again.';this.publish();} } // True silence boundary: tab hiding, navigation, disposal and cancelled activation. pause(mode='paused'){ if(this.disposed)return;if(this.state.mode==='playing')this.tick(); ++this.serial;this.stopTimer();this.adapter.cancel();this.state.activated=false;this.state.tempo=0;this.state.mode=mode;this.publish(); } hold(){ if(this.disposed)return; if(!this.state.activated){this.pause();return;} if(this.state.mode==='playing')this.tick(); this.state.mode='hold';this.state.tempo=0; this.adapter.perform?.(this.state.beat,0);this.runTimer();this.publish(); } seek(beat,unwrapped=null){ if(this.disposed)return; // A manual gesture during activation invalidates that pending start. if(this.state.mode==='enabling')this.pause(); const target=Math.max(0,Math.min(this.duration,Number(beat)||0)), now=this.now(); if(this.directionalPlayback){ const movement=unwrapped===null?target-this.state.beat:unwrapped-this.state.unwrappedBeat; this.state.unwrappedBeat+=movement;this.state.beat=target;this.inputTime=now; this.adapter.position(target/this.duration,this.state.unwrappedBeat/this.duration); if(!this.state.activated){this.state.mode='paused';this.state.tempo=0;this.stopTimer();} else if(movement!==0){this.state.direction=movement<0?-1:1;this.state.mode='playing';this.state.tempo=this.state.pace;this.begin(true);} this.publish();return; } const dt=Math.max(.04,Math.min(.25,now-(this.inputTime??now-.1))); const raw=Math.min(180,Math.abs((unwrapped??target)-this.state.unwrappedBeat)/dt*60); this.state.tempo=this.state.mode==='manual'?this.state.tempo+(raw-this.state.tempo)*.3:raw*.3; this.inputTime=now;this.state.unwrappedBeat=unwrapped??target;this.state.beat=target;this.state.mode='manual'; this.adapter.position(target/this.duration,this.state.unwrappedBeat/this.duration); if(this.state.activated)this.adapter.perform?.(target,this.state.tempo);this.runTimer(); this.publish(); } advance(delta){ const movement=Number(delta)||0;if(!movement)return; if(this.state.mode==='playing')this.tick(); const target=this.state.unwrappedBeat+movement; if(this.state.loop)this.seek(((target%this.duration)+this.duration)%this.duration,target); else this.seek(this.state.beat+movement); } scroll(delta,deltaMode=0,pageHeight=800){ const pixels=Number(delta)*(deltaMode===1?16:deltaMode===2?pageHeight:1); // Bound a single device event to avoid a high-resolution trackpad spike. this.advance(Math.max(-240,Math.min(240,pixels))*.022*this.state.sensitivity); } setSensitivity(value){this.state.sensitivity=Math.max(.25,Math.min(3,Number(value)||1));this.publish();} setLoop(loop){ if(this.disposed)return;if(this.state.mode==='playing')this.tick();this.state.loop=Boolean(loop); if(this.state.mode==='playing'){this.begin(true);}this.publish(); } setPace(pace){ if(this.disposed)return;const next=Math.max(1,Number(pace)||this.state.pace);if(next===this.state.pace)return;if(this.state.mode==='playing')this.tick();this.state.pace=next;if(this.state.mode==='playing')this.state.tempo=this.state.pace; if(this.state.mode==='playing'){this.begin(true);}this.publish(); } schedule(){ const direction=this.state.direction,rate=direction*this.state.pace/60; const now=this.now(),absolute=this.epochBeat+(now-this.epochTime)*rate; const horizon=absolute+this.lookahead*rate; const end=this.state.loop?horizon:Math.max(0,Math.min(this.duration,horizon)); let from=this.scheduledThrough; while(direction*(end-from)>1e-10){ // At a reverse seam the next segment begins at the previous cycle's far end. const cycle=direction>0?Math.floor(from/this.duration):Math.ceil(from/this.duration)-1,local=from-cycle*this.duration; const boundary=(direction>0?cycle+1:cycle)*this.duration; const to=direction>0?Math.min(end,boundary):Math.max(end,boundary),clock=this.epochTime+(from-this.epochBeat)/rate; const includeStart=direction>0?local===0&&cycle>0:local===this.duration&&from.22){this.state.tempo*=.78;if(this.state.tempo<1||this.now()-this.inputTime>.55){this.state.tempo=0;this.state.mode=this.state.activated?'hold':'paused';}} this.adapter.position(this.state.beat/this.duration,this.state.unwrappedBeat/this.duration);if(this.state.activated)this.adapter.perform?.(this.state.beat,this.state.tempo);else if(this.state.mode==='paused')this.stopTimer();this.publish();return; } if(this.state.mode!=='playing')return; const now=this.now(),elapsed=Math.max(0,now-this.last);this.last=now; const absolute=this.epochBeat+Math.max(0,now-this.epochTime)*this.state.pace/60*this.state.direction; if(!this.state.loop&&(this.state.direction>0?absolute>=this.duration:absolute<=0)){this.state.beat=this.state.direction>0?this.duration:0;this.state.unwrappedBeat=this.epochUnwrapped+this.state.beat-this.epochBeat;this.state.mode='ended';this.state.activated=false;this.state.tempo=0;++this.serial;this.stopTimer();this.adapter.cancel();} else{this.state.unwrappedBeat=this.epochUnwrapped+(absolute-this.epochBeat);const local=absolute%this.duration;this.state.beat=local<0?local+this.duration:local;if(elapsed>.25){this.begin(true);}else this.schedule();} this.adapter.position(this.state.beat/this.duration,this.state.unwrappedBeat/this.duration);this.publish(); } dispose(){if(this.disposed)return;this.disposed=true;++this.serial;this.stopTimer();this.adapter.cancel();this.adapter.dispose();} } export function notesAt(notes,beat,direction=1){return notes.filter(n=>direction>0?n[0]<=beat&&n[0]+n[1]>beat:n[0]=beat).map(note=>({note,remaining:direction>0?note[0]+note[1]-beat:beat-note[0]}));}