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>
256 lines
9.1 KiB
TypeScript
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;
|
|
}
|
|
}
|