A game should be able to start the next piece of music when one is over, or wait for a sound before it goes on. Until now a behavior could start and stop music and sounds, but never learn that they had ended. - Two new events: On music finished with the fields Song and Pattern and the input playback, and On sound finished with the field Sound and the input voice. Both give out what ended and stopped. - Two new data nodes, Music playing and Sound playing, ask whether something is still heard. - Over means nothing is heard any more: no tail of the effects, and a fade-out has run down. - stopped is on for Stop song, pattern or sound, when the behavior that owns it ends and on a scene change. For a sound also when a newer voice needs its place or the file does not load. - Only what the Play nodes of this run started is heard, across scene changes too. Previews in the tabs, compositions and notes of instruments do not count. - Switch song ends nothing. The number goes on, and the end is told under the song heard last. - Playbacks and the SoundLibrary tell each end once to a new PlayedEnds per run. The AudioSwitch passes them on from both sinks. Found and fixed: a pattern whose last bar ends in silence counted as done up to one planning window before its end. In e2e 65 the next pattern started after 1.962 s instead of 2 s. Scheduler.done now also wants the playhead to have reached the end. Cleaning up done playbacks therefore comes a few tenths of a second later. One test that expected the early end is adjusted. Tests: - Unit: 23 new (played-ends 12, sound-library 5, node catalog 5, audio-switch 1), all green. - Counter-probes: 19 bends, all red. One was green at first for want of a case, which is added now. - e2e 65 on atx: ALL PASSED, 78 checks. New 65k6: the event came once with stopped off, and the second pattern started 2.051 s after the stinger. Stop song gave exactly one end with stopped on, and the switch from 65k3 gave none. - Gate on atx: CHECK EXIT 0, 337 files, 5222 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
541 lines
21 KiB
TypeScript
541 lines
21 KiB
TypeScript
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<void>;
|
||
/** 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:<id>`, `song:<id>`. */
|
||
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<Playback>[] {
|
||
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 = <T>(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<Playback>[] {
|
||
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<PlaybackHost, 'now' | 'playing' | 'stop'>,
|
||
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;
|
||
}
|
||
}
|