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>
181 lines
7.4 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|