Files
feuerwerk/src/core/music/Scheduler.ts
T
adminandClaude Opus 5.5 77ac9d1aaf
feuerwerk / gate (push) Successful in 4m31s
feuerwerk / suite (push) Successful in 18m21s
Play sounds, patterns and songs on the tracks of a composition (#418)
A trailer needs its music and effects on the same timeline as its
pictures. Until now a composition could only place objects, so sound
had to be started by hand from a behavior and drifted against the
picture.

- Audio items: a sound, a clip of a sound, a pattern or a song dragged
  from the asset panel onto a track becomes an item as long as one
  pass of it. It may share a track with object and video items and
  overlap them.
- Editing works as for other items: move, resize, trim on the left
  (moves the offset), fit to media, delete, copy and paste, box
  selection. Sounds draw their waveform, the colour tells the kind.
- The inspector shows what it plays, start, length, offset, volume
  (-60 to +12 dB) and bus. The bus is "From asset", the master or one
  of the buses, as asked for in the issue.
- An item longer than its material plays in rounds. Sounds and
  patterns start over, a song runs on in its loop. Loop marks on the
  item follow the same rule, a song's first mark sits at its loop end.
- compositionAudio.ts turns the items into sequences, each with its
  own tempo. CompositionSound plays them, in the tab and in a game.
- In the tab the items are heard from the playhead on, straight into
  the master like every tab. A scrub is silent. Playing a song or
  pattern in its own tab stops the composition, and the other way
  round.
- In a game they are planned on the audio clock for the moment the
  composition clock reaches them, with the scheduler's LEAD made up.
  While the clock stands (waiting for a video, paused, placement off)
  they are silent and resume at the right spot.

Found along the way:

- The asset panel never let a song out of its list, so a song could
  not be dragged anywhere. Song and scene tab reject it as they reject
  a pattern.
- The asset panel scrolled to the selected asset on every rebuild of
  its list, so folding a group far below the selection jumped back up.
  Only a new selection scrolls now.
- At the end of the list, folding a group pulled every row down by the
  rows that went away. ScrollContainer has a new opt-in holdOffset,
  set only in the asset panel, that keeps the offset until the user
  scrolls up.

Limits: a time scale other than 1 or a frame longer than 0.1 s moves
the picture and not the sound. A baked song plays its notes here, not
the bake.

Tests:
- tsc, eslint, Prettier clean. Gate on melektaus-atx green,
  309 files, 4788 tests (4772 plus 16 new).
- Unit: composition-audio.test.ts 13 new, scroll-container 3 new.
  Counter-checks: without catching up on load time or without the
  silence on pause the unit tests go red.
- e2e 68h: the sound is planned 1.2 ms after the composition reaches
  0.5 s. Without the LEAD make-up it is 54 ms late and red.
- e2e 04 and 69 ALL PASSED, 86 checks. 69o-ii drags a pattern and a
  song onto a track, 20 frames each.
- e2e 04: folding Sound at the end of the list keeps its header at
  y 234. With the old panel it jumps to 984 and the check is red.
- Not run in a browser: the loop marks of a song item, copy and paste
  of audio items, the bus choice.
- README and wiki count 2072 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:28:36 +02:00

256 lines
9.1 KiB
TypeScript

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;
}
}