Files
feuerwerk/src/core/music/playbacks.ts
T
adminandClaude Opus 5.5 0e73fd85b9
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
Route sounds, songs and buses in one Master routing graph (#351)
A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.

- Menu -> Master routing opens a tab with one graph per project. It has
  a node per sound, instrument, pattern, song and bus, named after its
  asset, and one Master. Embedded assets have none, their song routes
  them.
- The nodes follow the assets. Every edit settles the routing in the
  same undo step (KeepRoutingCommand), so an undone delete brings the
  links of its asset back. A new asset gets a node and a link to the
  master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
  master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
  song. Out of a sound, an instrument or a pattern a link always takes
  the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
  overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
  the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
  project is read, but nothing is added there. The editor adds the
  nodes when it opens a project. An asset without a node plays on the
  master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
  A sound or instrument with more gets a bus of its own with the sends.
  A pattern or song sends from the master of its playing, which is
  where its Mode taps. Buses of the mix can have sends now too.

Found:

- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
  failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
  this change. HEAD passed it 2 of 2. Not fixed here.

Tests:

- Unit master-routing (new, 10), sound-library (2 new), buses
  rewritten onto the routing, 5 lists without the Bus row. 5 counter
  checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
  Counter check with the Sound tab ignoring the routing: the routing
  check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:18:46 +02:00

181 lines
7.4 KiB
TypeScript

import type { SendTap } from '../graph/Graph';
import type { FxPlan, FxVoice } from '../soundfx/nodes';
import type { MixSend } from './mixer';
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
import type { LoopRange, Sequence } from './sequence';
/** 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;
}
/** 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;
}
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' | 'ahead'> {
/** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */
prepare(needs: SequenceNeeds, id: number): Promise<void>;
/** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */
ahead?(id: number): number;
/** The level of a playing, from where it is at `at` to `gain`, reached `seconds` later. */
level(id: 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?(id: number): void;
}
export interface Playback {
id: number;
/** What stands for the pattern or song, as a file stands for a sound: `pattern:<id>`, `song:<id>`. */
name: string;
owner: string;
scheduler: Scheduler;
/** 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;
}
/** 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 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 scheduler = new Scheduler(
{
...this.host,
ahead: () => this.host.ahead?.(id) ?? 0,
play: (note) => (keep(planned, note), play.strike(note)),
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
},
() => play.sequence,
{ tempo: play.tempo, loop: play.loop, end: play.length },
);
const playback: Playback = { id, name, owner, scheduler, planned, clips, fading: null };
this.list.push(playback);
void this.host.prepare(play.needs, id).then(() => {
if (!this.list.includes(playback) || playback.fading !== null) return;
scheduler.start(0);
// From the first note heard, which is later where the buses are (#346).
const at = scheduler.startedAt ?? this.host.now() + LEAD;
this.host.level(id, play.volume ?? 1, at, Math.max(0, play.fadeIn ?? 0));
});
}
/** 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);
}
/** 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.scheduler.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);
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.id, 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));
for (const playback of going) {
playback.scheduler.stop(letRing);
this.host.closed?.(playback.id);
}
}
}