import { clipsIn, heldLoop, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence'; import { COMMON_TIME, DEFAULT_TEMPO, heldMeter, heldTempo, type Meter, type Position, positionOf, secondsOfTicks, ticksOfSeconds, } from './time'; /** Seconds ahead of the audio clock the notes are planned, and how often that is done. */ export const LOOKAHEAD = 0.1; export const INTERVAL = 0.025; /** Seconds between a start and its first note: the page has to get the note to the audio thread. */ export const LEAD = 0.05; /** One note handed to the player: when it starts on the audio clock and how long it is held. */ export interface ScheduledNote { at: number; duration: number; note: number; velocity: number; instrument: string; /** What a Delay synced to beats counts by. */ tempo: number; /** A factor on its loudness, and the track of a song it is heard on (#334). */ volume?: number; bus?: string; } /** One clip handed to the player (#334): when it starts, how long it plays and from where in the sound. */ export interface ScheduledClip { at: number; duration: number; /** Seconds into the sound, however often it has gone round by then. */ offset: number; sound: string; fx: string; volume: number; bus: string; tempo: number; } /** What the scheduler needs of the audio side; a test hands in a clock of its own. */ export interface SchedulerHost { /** Seconds on the audio clock. */ now(): number; /** Seconds between the audio clock and what is heard. */ latency(): number; /** * Seconds ahead of its time the player may start a note, and so how much further ahead it is * planned: the latency of the buses it is heard through, made up by starting it that much earlier * (#346). None where left out. */ ahead?(): number; /** Starts the note, gives back the number of its voice; 0 where nothing plays. */ play(note: ScheduledNote): number; /** Starts a clip the same way; a host that plays no clips leaves it out. */ playClip?(clip: ScheduledClip): number; playing(voice: number): boolean; /** Ends these voices; with `letRing` a note falls away in the release of its Envelope. */ stop(voices: readonly number[], letRing?: boolean): void; /** Calls `pump` every `INTERVAL` seconds, also in a hidden tab, until the returned function is called. */ wake(pump: () => void): () => void; } export interface SchedulerOptions { tempo?: number; meter?: Meter; loop?: LoopRange | null; /** Where it ends, in ticks of the sequence, when it does not loop: it plans nothing after that. */ end?: number; } /** * Plays a sequence on the audio clock (#332): every `INTERVAL` it plans the notes of the next * `LOOKAHEAD` seconds with their exact start, so a slow frame or a busy page never moves a note. * Playing time is counted in whole ticks from an anchor on the audio clock. The sequence is read * afresh for every window, so a change is heard from the next one on; what is planned stays. */ export class Scheduler { private tempo: number; private meter: Meter; private loop: LoopRange | null; private readonly end: number; /** Planned up to its end: it wakes no more, and is over once its last notes are. */ private ended = false; /** Where the playhead started, in ticks of the sequence. */ private from = 0; /** Where on the audio clock playing time 0 is; null while stopped. */ private anchor: number | null = null; /** Playing time planned so far, in whole ticks. */ private until = 0; /** Started and not planned yet: a clip the playhead starts in the middle of is taken up. */ private fresh = false; /** How it played before the last change of tempo or loop, for the time until that is reached. */ private before: { anchor: number; from: number; tempo: number; loop: LoopRange | null } | null = null; private voices: number[] = []; private sleep: (() => void) | null = null; constructor( private readonly host: SchedulerHost, private readonly sequence: () => Sequence, options: SchedulerOptions = {}, ) { this.tempo = heldTempo(options.tempo ?? DEFAULT_TEMPO); this.meter = heldMeter(options.meter ?? COMMON_TIME); this.loop = heldLoop(options.loop ?? null); this.end = options.end ?? Infinity; } get playing(): boolean { return this.anchor !== null; } /** The second of the audio clock where it plays from (before any change of tempo); null while stopped. */ get startedAt(): number | null { return this.anchor; } /** Played to its end, and none of its notes is heard any more. */ get done(): boolean { return this.ended && this.voices.every((voice) => !this.host.playing(voice)); } /** Plays from `from` ticks of the sequence, or from where it was last started or sent. */ start(from = this.from): void { this.stop(); this.from = Math.max(0, Math.round(from)); this.anchor = this.host.now() + LEAD + this.ahead(); this.until = 0; this.before = null; this.ended = false; this.fresh = true; this.sleep = this.host.wake(() => this.pump()); this.pump(); } /** Every note it started fades out, those still to come with them; with `letRing` in their release. */ stop(letRing = false): void { this.sleep?.(); this.sleep = null; if (this.anchor !== null) this.from = Math.floor(this.headAt(this.host.now())); this.anchor = null; this.before = null; if (this.voices.length > 0) this.host.stop(this.voices, letRing); this.voices = []; } /** A jump: playing on from there, cut off where it was, or only where the next start begins. */ seek(tick: number): void { if (this.playing) this.start(tick); else this.from = Math.max(0, Math.round(tick)); } /** Counted from what is planned already: a note in the next window keeps the tempo it had. */ setTempo(tempo: number): void { this.rebase(); this.tempo = heldTempo(tempo); } setLoop(loop: LoopRange | null): void { this.rebase(); this.loop = heldLoop(loop); } setMeter(meter: Meter): void { this.meter = heldMeter(meter); } /** * Where what is heard right now stands: the audio clock less the output latency, so a playhead * drawn from it is not ahead of the sound. Before the first note it is where it starts. */ position(): Position & { ticks: number } { const ticks = this.anchor === null ? this.from : this.headAt(this.host.now() - this.host.latency()); return { ...positionOf(ticks, this.meter), ticks }; } /** Plans the notes of the window ahead that are not planned yet. */ pump(): void { if (this.anchor === null) return; const to = Math.floor( ticksOfSeconds(this.host.now() + LOOKAHEAD + this.ahead() - this.anchor, this.tempo), ); if (to <= this.until) return; this.voices = this.voices.filter((voice) => this.host.playing(voice)); const sequence = this.sequence(); for (const due of notesIn(sequence, this.from, this.loop, this.until, to)) { const voice = this.host.play({ at: this.anchor + secondsOfTicks(due.at, this.tempo), duration: secondsOfTicks(due.length, this.tempo), note: due.note, velocity: due.velocity, instrument: due.instrument, tempo: due.tempo ?? this.tempo, ...(due.volume !== undefined && { volume: due.volume }), ...(due.bus !== undefined && { bus: due.bus }), }); if (voice !== 0) this.voices.push(voice); } if (this.host.playClip) for (const due of clipsIn(sequence, this.from, this.loop, this.until, to, this.fresh)) { const voice = this.host.playClip({ at: this.anchor + secondsOfTicks(due.at, this.tempo), duration: secondsOfTicks(due.length, this.tempo), offset: due.offset + secondsOfTicks(due.skip, this.tempo), sound: due.sound, fx: due.fx, volume: due.volume, bus: due.bus, tempo: due.tempo ?? this.tempo, }); if (voice !== 0) this.voices.push(voice); } this.fresh = false; this.until = to; if (!this.loop && playheadAt(to, this.from, null) >= this.end) { this.ended = true; this.sleep?.(); this.sleep = null; } } private ahead(): number { const ahead = this.host.ahead?.() ?? 0; return Number.isFinite(ahead) && ahead > 0 ? ahead : 0; } /** The playhead at a second of the audio clock; before the anchor, where it starts. */ private headAt(time: number): number { if (this.anchor === null) return this.from; const span = this.before && time < this.anchor ? this.before : { anchor: this.anchor, from: this.from, tempo: this.tempo, loop: this.loop }; const elapsed = ticksOfSeconds(time - span.anchor, span.tempo); return playheadAt(Math.max(0, elapsed), span.from, span.loop); } /** * Moves the anchor to the end of what is planned, so a new tempo or loop starts there. Until the * clock gets there, the playhead goes on as it did. */ private rebase(): void { if (this.anchor === null) return; this.before = { anchor: this.anchor, from: this.from, tempo: this.tempo, loop: this.loop }; this.anchor += secondsOfTicks(this.until, this.tempo); this.from = playheadAt(this.until, this.from, this.loop); this.until = 0; } }