import type { SendTap } from '../graph/Graph'; import type { FxPlan, FxVoice } from '../soundfx/nodes'; import type { AutomationTarget } from './automation'; import type { BakePlayback } from './bake'; import type { MixSend } from './mixer'; import { LEAD, type ScheduledAutomation, type ScheduledClip, type ScheduledNote, type ScheduledRamp, Scheduler, type SchedulerHost, } from './Scheduler'; import type { LoopRange, Sequence } from './sequence'; import { ticksOfSeconds } from './time'; /** What a sequence is made of: loaded before the first of its notes and clips is planned. */ export interface SequenceNeeds { layers: readonly FxPlan[]; files: readonly string[]; } /** What a track of a song is set to: its bus, its level and its pan (#334). */ export interface BusLevel { name: string; gain: number; pan: number; /** The bus it goes into (#346): a pattern item's goes into its track's. The master where left out. */ into?: string; /** Where a track of a song goes instead (#350): each send on its own, `into` then left out. */ sends?: readonly BusSend[]; /** The sound FX all of it goes through as one chain (#346), before its level and pan. */ fx?: FxVoice; /** * What its automation sets while it plays (#337): `gain`, `pan` and a parameter of `fx` are then * only where it starts. */ automated?: readonly AutomationTarget[]; } /** One send of a track (#350): into another track's bus, or the output of the song where `to` is left out. */ export interface BusSend { to?: string; gain: number; tap: SendTap; } /** What a game asks to hear of a pattern or a song: its notes and clips, how fast, how long, how round. */ export interface SequencePlay { sequence: Sequence; tempo: number; /** In ticks: where it ends when it does not loop. */ length: number; /** What it runs round once the playhead gets there, from the start on; null plays it once to `length`. */ loop: LoopRange | null; needs: SequenceNeeds; /** The level of the whole playing, 1 where left out, and the seconds it rises to it from silence. */ volume?: number; fadeIn?: number; /** The tracks of a song (#336): the buses its notes and clips name, each set to its level and pan. */ buses?: readonly BusLevel[]; /** The sound FX the whole playing goes through (#346), after its tracks and before its level. */ fx?: FxVoice; /** The bus of the mix the whole playing is heard on (#325); the master where left out. */ bus?: string; /** Where the whole playing goes instead, along the links of the master routing (#351). */ sends?: readonly MixSend[]; /** Starts one note as the player was asked to, and gives back the number of its voice. */ strike(note: ScheduledNote): number; strikeClip?(clip: ScheduledClip): number; /** Ramps the level of one of its buses (#418). */ ramp?(ramp: ScheduledRamp): void; /** Takes one of its buses along its automation (#337). */ automate?(automation: ScheduledAutomation): void; /** Its bake (#340), played in place of its notes and clips where the host can. */ bake?: BakePlay; /** * Where it starts, in ticks, as heard `LEAD` from now: a composition's sound (#418). Where it first * waits for its files, it starts as much further in, to keep to the clock it was asked for by. */ from?: number; /** * The second of the audio clock it is heard from (#338), where that is still to come once its * files are there: a stinger in time with another playing (`Playbacks.landOn`). */ at?: number; } /** A bake as it is played: its file, the level it is heard at, and which stretches of it. */ export interface BakePlay { file: string; gain: number; playback: BakePlayback; } /** What a playing is driven by: a scheduler of its notes, or a bake. */ export interface Runner { readonly playing: boolean; /** The second of the audio clock it is heard from; null before it starts. */ readonly startedAt: number | null; /** Played to its end, and nothing of it is heard any more. */ readonly done: boolean; /** Plays from `from` ticks, heard from `at` on the audio clock where that is still to come (#338). */ start(from?: number, at?: number): void; stop(letRing?: boolean): void; } /** What a change a game asks of a playing goes by (#338): its time, where it started, the clock now. */ export interface PlayingClock { time: PlayingTime; /** The second of the audio clock its playing time 0 is heard at; null before it starts. */ startedAt: number | null; now: number; /** The soonest second of the audio clock the change can be heard from. */ soonest: number; } /** The second of the audio clock a change to a playing lands at, from its clock (#338). */ export type LandingAt = (clock: PlayingClock) => number; /** Seconds a hard cut between two playings takes, so it does not click. */ export const CUT = 0.005; /** * The audio side of the playings. Each is reached here by its `key`, the number its buses go by: the * same as its `id` but where it took over another one's (#338). */ export interface PlaybackHost extends Omit< SchedulerHost, 'play' | 'playClip' | 'ahead' | 'ramp' | 'automate' > { /** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */ prepare(needs: SequenceNeeds, key: number): Promise; /** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */ ahead?(key: number): number; /** The level of a playing, from where it is at `at` to `gain`, reached `seconds` later. */ level(key: number, gain: number, at: number, seconds: number): void; /** Calls `then` once the audio clock reaches `at`. */ after(at: number, then: () => void): void; /** A playing is gone: stopped, or played to its end. */ closed?(key: number): void; /** * Nothing of the playings numbered `id` is left (#373): played to their end, or `stopped`. `name` * is that of the one heard last, a Switch song's in place of the one it took over from. */ ended?(id: number, name: string, stopped: boolean): void; /** Starts the bake of that playing to be heard from `at`, as one voice; 0 where nothing plays. */ playBaked?(key: number, bake: BakePlay, at: number): number; } export interface Playback { id: number; /** What its buses go by; its `id` but where it took over from another playing (#338). */ key: number; /** The level of the whole of it, as it was asked for. */ volume: number; /** The second of the audio clock another playing takes over from it at (#338); null while it is the one. */ until: number | null; /** It takes over from another playing of its number, once it is heard (#338). */ successor: boolean; /** What stands for the pattern or song, as a file stands for a sound: `pattern:`, `song:`. */ name: string; owner: string; runner: Runner; /** The last notes and clips planned, newest last, for a check to hold against the clock. */ planned: ScheduledNote[]; clips: ScheduledClip[]; /** The second of the audio clock a fade out ends it at; null while it is not fading. */ fading: number | null; /** Played from its bake (#340) rather than its notes. */ baked: boolean; /** How fast, round what and to where it plays, and from where it started (#338). */ time: PlayingTime; /** A stop reached it (#373), rather than it playing to its end. */ stopped: boolean; } /** The time of a playing (#338): its tempo, where it started, its loop and its length, in ticks. */ export interface PlayingTime { tempo: number; from: number; loop: LoopRange | null; length: number; } /** A playing and how far into its playing time it is heard now, null before it starts (#338). */ export interface HeardTime extends PlayingTime { id: number; /** Told apart from the playing of its number it took over from (`Playback.key`). */ key: number; name: string; heard: number | null; baked: boolean; } /** Whether a playing goes by `name`: its own, every one of a kind for `kind:`, every one for `''`. */ export function goesBy(playing: string, name: string): boolean { return name === '' || playing === name || (name.endsWith(':') && playing.startsWith(name)); } const KEPT = 64; /** * The patterns and songs a game plays (#336): each on a scheduler of its own, numbered like a voice, * owned like one and stopped like one. One played to its end goes by itself. */ export class Playbacks { private list: Playback[] = []; constructor(private readonly host: PlaybackHost) {} /** Starts once its files are there; a stop that came first wins. */ start(id: number, name: string, play: SequencePlay, owner: string): void { const playback = this.make(id, id, name, play, owner, false); const asked = this.host.now(); void this.host.prepare(play.needs, id).then(() => { if (!this.list.includes(playback) || playback.fading !== null) return; const from = play.from; playback.time.from = from === undefined ? 0 : Math.round(from + ticksOfSeconds(this.host.now() - asked, play.tempo)); playback.runner.start(playback.time.from, play.at); // From the first note heard, which is later where the buses are (#346). const at = playback.runner.startedAt ?? this.host.now() + LEAD; this.host.level(id, playback.volume, at, Math.max(0, play.fadeIn ?? 0)); }); } /** * Plays `play` in place of the playing numbered `id` (#338), on its buses `key`, from `play.from` * ticks: once its files are there, it is heard from the second `at` names for the playing it takes * over from, which falls silent there – over `fade` seconds as this one rises over them, else in a * cut. It keeps the number, so whatever knows the playing goes on reaching it. Asked again before * that, the one still waiting is dropped and the playing heard now is the one to go. Where nothing * of that number plays any more, it starts as soon as it can. */ switchTo( id: number, key: number, name: string, play: SequencePlay, owner: string, at: LandingAt, fade: number, ): void { const now = this.host.now(); const waiting = this.list.filter((p) => p.id === id && this.waits(p, now)); this.close(waiting, false); for (const p of this.list) if (p.id === id && p.until !== null && p.until > now) { p.until = null; p.fading = null; this.host.level(p.key, p.volume, now, 0); } const playback = this.make(id, key, name, play, owner, true); // What a game holds of the lanes stays held: a lane of the same song is there again. const heard = this.list.find((p) => p.id === id && p !== playback && p.until === null); if (heard?.runner instanceof Scheduler && playback.runner instanceof Scheduler) playback.runner.keepHolds(heard.runner); void this.host.prepare(play.needs, key).then(() => { if (!this.list.includes(playback) || playback.fading !== null) return; const from = this.heardOne(id, playback); const now = this.host.now(); const ahead = this.host.ahead?.(key) ?? 0; const soonest = now + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0); const clock = from && { time: from.time, startedAt: from.runner.startedAt, now, soonest }; playback.time.from = Math.max(0, Math.round(play.from ?? 0)); playback.runner.start(playback.time.from, clock ? at(clock) : undefined); const start = playback.runner.startedAt ?? soonest; const over = fade > 0 ? fade : 0; if (from) this.handOver(from, start, Math.max(over, CUT)); this.host.level(key, playback.volume, start, over); }); } /** The playing of that number heard now, but `besides`: the one a switch takes over from. */ private heardOne(id: number, besides: Playback): Playback | null { return this.current().find((p) => p.id === id && p !== besides && p.until === null) ?? null; } /** Falls silent from `at` over `fade` seconds and goes then: the one taken over from (#338). */ private handOver(playback: Playback, at: number, fade: number): void { const end = at + fade; playback.until = at; playback.fading = end; this.host.level(playback.key, 0, at, fade); this.host.after(end, () => { if (this.list.includes(playback) && playback.fading === end) this.close([playback], false); }); } /** The clock of the playing of that number heard now (#338), and whether it is a bake; null where none is. */ clockOf(id: number): (PlayingClock & { baked: boolean }) | null { const playing = this.current().find((p) => p.id === id && p.until === null && !this.waits(p)); if (!playing) return null; const now = this.host.now(); const { time, baked } = playing; return { time, startedAt: playing.runner.startedAt, now, soonest: now + CUT, baked }; } /** * The second of the audio clock `at` names on the clock of the playing of that number heard now * (#338), no sooner than something started now can be heard: where a stinger in time with it * starts. Null where none is heard. */ landOn(id: number, at: LandingAt): number | null { const clock = this.clockOf(id); if (!clock) return null; const soonest = clock.now + LEAD; return Math.max(soonest, at({ ...clock, soonest })); } /** * Holds a lane of the playing of that number on `value` from the second `at` (#338), as * `Scheduler.holdLane` does: the one heard now and one waiting to take over from it alike. Whether * any of them has that lane; a bake has none. */ holdLane( id: number, bus: string, target: AutomationTarget, value: number, at: number, fade: number, ): boolean { let found = false; for (const p of this.named(id)) if (p.runner instanceof Scheduler && p.runner.holdLane(bus, target, value, at, fade)) found = true; return found; } /** Lets a held lane of the playing of that number go from the second `at` (#338), see `Scheduler.releaseLane`. */ releaseLane(id: number, bus: string, target: AutomationTarget, at: number, fade: number): boolean { let found = false; for (const p of this.named(id)) if (p.runner instanceof Scheduler && p.runner.releaseLane(bus, target, at, fade)) found = true; return found; } /** The playings of that number, the one heard now and one waiting to take over from it (#338). */ named(id: number): readonly Readonly[] { return this.current().filter((p) => p.id === id); } /** Whether the buses `key` belong to a playing still to be heard. */ holds(key: number): boolean { return this.current().some((playback) => playback.key === key); } /** A successor not heard yet: waiting on its files, or on the second it takes over at. */ private waits(playback: Playback, now = this.host.now()): boolean { const at = playback.runner.startedAt; return playback.successor && (at === null || at > now); } private make( id: number, key: number, name: string, play: SequencePlay, owner: string, successor: boolean, ): Playback { const planned: ScheduledNote[] = []; const clips: ScheduledClip[] = []; const keep = (list: T[], item: T) => { list.push(item); if (list.length > KEPT) list.shift(); }; const strikeClip = play.strikeClip; const ahead = () => this.host.ahead?.(key) ?? 0; const { bake } = play; const playBaked = this.host.playBaked?.bind(this.host); const runner: Runner = bake && playBaked ? new BakedRun(this.host, ahead, (at) => playBaked(key, bake, at), bake.playback.end !== null) : new Scheduler( { ...this.host, ahead, play: (note) => (keep(planned, note), play.strike(note)), ...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }), ...(play.ramp && { ramp: play.ramp }), ...(play.automate && { automate: play.automate }), }, () => play.sequence, { tempo: play.tempo, loop: play.loop, end: play.length }, ); const playback: Playback = { id, key, name, owner, runner, planned, clips, fading: null, baked: runner instanceof BakedRun, volume: play.volume ?? 1, until: null, successor, time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length }, stopped: false, }; this.list.push(playback); return playback; } /** Whether that one is still to be heard: waiting on its files, playing, fading, or its last notes ringing. */ has(id: number): boolean { return this.current().some((playback) => playback.id === id); } /** * Every playing with how far into its playing time it is heard now: the audio clock less the * output latency, from the moment its first note is heard (#338). With none playing the clock is * not asked: asking for it makes the audio context, which a page may make only after a gesture. */ times(): HeardTime[] { const list = this.current(); if (list.length === 0) return []; const heard = this.host.now() - this.host.latency(); // Of a switch (#338) one is heard at a time: the old one up to where the new one is. const told = list.filter( ({ until, successor, runner }) => (until === null || heard < until) && (!successor || (runner.startedAt !== null && heard >= runner.startedAt)), ); return told.map(({ id, key, name, runner, time, baked }) => { const at = runner.startedAt; return { id, key, name, ...time, heard: at === null ? null : heard - at, baked }; }); } /** Those that go by `name` (see `goesBy`); with `fade` they fall silent over that many seconds first. */ stopNamed(name: string, letRing = false, fade = 0): void { this.stopWhere((playback) => goesBy(playback.name, name), letRing, fade); } stopId(id: number, letRing = false, fade = 0): void { this.stopWhere((playback) => playback.id === id, letRing, fade); } /** An empty owner is no one, as for a voice: those were started to outlive whatever started them. */ stopOwner(owner: string): void { if (owner !== '') this.stopWhere((playback) => playback.owner === owner, false, 0); } /** What is playing, oldest first (test hook). */ current(): readonly Readonly[] { const gone = this.list.filter((playback) => playback.runner.done); if (gone.length > 0) this.close(gone, false); return this.list; } private stopWhere(going: (playback: Playback) => boolean, letRing: boolean, fade: number): void { const stopping = this.list.filter(going); for (const playback of stopping) playback.stopped = true; if (!(fade > 0)) return this.close(stopping, letRing); const now = this.host.now(); const end = now + fade; for (const playback of stopping) { if (playback.fading !== null && playback.fading <= end) continue; playback.fading = end; this.host.level(playback.key, 0, now, fade); this.host.after(end, () => { if (this.list.includes(playback) && playback.fading === end) this.close([playback], false); }); } } /** Out of the list before anyone hears of it, so whoever is told finds it gone. */ private close(going: readonly Playback[], letRing: boolean): void { if (going.length === 0) return; this.list = this.list.filter((playback) => !going.includes(playback)); const ended = this.endedOf(going); for (const playback of going) { playback.runner.stop(letRing); this.host.closed?.(playback.key); } for (const { id, name, stopped } of ended) this.host.ended?.(id, name, stopped); } /** * The numbers nothing is left of once `going` goes (#373). Of a switch (#338) the one taken over * from goes first and the number lives on in the other; where both go, it is named by the newer * one if that had started. */ private endedOf(going: readonly Playback[]): { id: number; name: string; stopped: boolean }[] { return [...new Set(going.map((playback) => playback.id))].flatMap((id) => { if (this.list.some((playback) => playback.id === id)) return []; const ones = going.filter((playback) => playback.id === id); const started = ones.filter((playback) => !playback.successor || playback.runner.startedAt !== null); const last = started[started.length - 1] ?? ones[0]!; return [{ id, name: last.name, stopped: ones.some((playback) => playback.stopped) }]; }); } } /** * A bake played in place of the notes (#340): one voice from the moment the first note would be * heard, ended as the notes would be. One that ends is done once its voice is. */ class BakedRun implements Runner { private voice = 0; private anchor: number | null = null; constructor( private readonly host: Pick, private readonly ahead: () => number, private readonly play: (at: number) => number, private readonly ends: boolean, ) {} get playing(): boolean { return this.anchor !== null; } get startedAt(): number | null { return this.anchor; } get done(): boolean { return this.anchor !== null && this.ends && !this.host.playing(this.voice); } /** From its start: a bake plays from nowhere else. Heard from `at` where that is still to come. */ start(_from?: number, at = -Infinity): void { this.stop(); const ahead = this.ahead(); this.anchor = Math.max(this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0), at); this.voice = this.play(this.anchor); } stop(letRing = false): void { if (this.voice !== 0) this.host.stop([this.voice], letRing); this.voice = 0; this.anchor = null; } }