Since #351 the tabs played their asset along its links in the Master routing, as the game does. A song linked into a reverb bus was heard through that bus while it was edited. A preview should sound like the asset itself, so the tabs leave the Master routing out again. - The Sound, Sound FX, Instrument, Pattern and Song tab play into the master. The sound FX of the asset still applies, and so does the master's own volume and FX. - Inside a song its own routing of tracks and sends still counts. - The game is unchanged: Play nodes go along the routing as before. Tests: - e2e 34 (melektaus-atx): ALL PASSED, 96 checks. The routing check now holds the mix of the two buses as before, and the Sound tab on the master with the bus silent. - Counter-probe e2e: the Sound tab along the routing again, the routing check red. - Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4230 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
import {
|
|
type ScheduledClip,
|
|
type ScheduledNote,
|
|
Scheduler,
|
|
type SchedulerOptions,
|
|
} from '../core/music/Scheduler';
|
|
import { sequenceNeeds } from '../core/music/needs';
|
|
import { MASTER_MIX, type MixRoute } from '../core/music/mixer';
|
|
import type { BusLevel } from '../core/music/playbacks';
|
|
import type { Sequence } from '../core/music/sequence';
|
|
import type { Project } from '../core/model/Project';
|
|
import type { FxVoice } from '../core/soundfx/nodes';
|
|
import { playClip, strikeNote } from '../runtime/systems/behaviorContext';
|
|
import { soundLibrary } from './SoundLibrary';
|
|
import { wake } from './wake';
|
|
|
|
let musicCount = 0;
|
|
|
|
/** One planned note as the player was given it, and where the audio clock stood then (test hook). */
|
|
export interface PlannedNote extends ScheduledNote {
|
|
voice: number;
|
|
planned: number;
|
|
}
|
|
|
|
/** One planned clip of a song (#334), the same way (test hook). */
|
|
export interface PlannedClip extends ScheduledClip {
|
|
voice: number;
|
|
planned: number;
|
|
}
|
|
|
|
/**
|
|
* The scheduler on the sound library's clock (#332), its notes played by the instruments of the
|
|
* project: what the Pattern tab (#333) and the Song tab (#334) play, and what the check scripts drive
|
|
* through `__gm.music`.
|
|
*/
|
|
export class Music {
|
|
private scheduler: Scheduler | null = null;
|
|
private sequence: Sequence = { notes: [] };
|
|
/** Counts starts and stops: a start still loading its files gives up once another came after it. */
|
|
private run = 0;
|
|
/** The tracks last set, and when the chains of their sound FX are there (#346). */
|
|
private buses: readonly BusLevel[] = [];
|
|
private busesReady: Promise<void> = Promise.resolve();
|
|
/**
|
|
* The bus all the pattern or song gives goes through, into `output`, one per tab: its sound FX is
|
|
* on it (#352), and the Out meter of the Song tab measures it (#359).
|
|
*/
|
|
readonly outBus = `music:${++musicCount}`;
|
|
private fxReady: Promise<void> = Promise.resolve();
|
|
/** The last notes and clips planned, newest last, for a check to hold against the clock. */
|
|
readonly planned: PlannedNote[] = [];
|
|
readonly clips: PlannedClip[] = [];
|
|
|
|
/**
|
|
* `output` is where what it plays goes (#351): the master where left out, as for the preview of
|
|
* a tab, which leaves the routing of its pattern or song out (#368). `fx` is the sound FX of
|
|
* that pattern or song (#352), on the way there.
|
|
*/
|
|
constructor(
|
|
private readonly project: () => Project,
|
|
private readonly output: () => MixRoute = () => MASTER_MIX,
|
|
private readonly fx: () => FxVoice | null = () => null,
|
|
) {}
|
|
|
|
/** Loads what the notes and clips need, then plays from `from` ticks. */
|
|
async play(sequence: Sequence, options: SchedulerOptions & { from?: number } = {}): Promise<void> {
|
|
this.halt();
|
|
const run = this.run;
|
|
this.sequence = sequence;
|
|
this.planned.length = 0;
|
|
this.clips.length = 0;
|
|
this.heardOn();
|
|
await Promise.all([this.prepare(sequence), this.busesReady, this.fxReady]);
|
|
if (run !== this.run) return;
|
|
this.scheduler = new Scheduler(
|
|
{
|
|
now: () => soundLibrary.time(),
|
|
latency: () => soundLibrary.latency(),
|
|
ahead: () =>
|
|
Math.max(
|
|
soundLibrary.busLatency(this.outBus),
|
|
...this.buses.map((bus) => soundLibrary.busLatency(bus.name)),
|
|
),
|
|
play: (note) => this.playOne(note),
|
|
playClip: (clip) => this.playClip(clip),
|
|
playing: (voice) => soundLibrary.playing(voice),
|
|
stop: (voices) => {
|
|
for (const voice of voices) soundLibrary.stopVoice(voice);
|
|
},
|
|
wake,
|
|
},
|
|
() => this.sequence,
|
|
options,
|
|
);
|
|
this.scheduler.start(options.from ?? 0);
|
|
}
|
|
|
|
/** A change heard from the next window on; a note new to it loads what it needs meanwhile. */
|
|
edit(sequence: Sequence): void {
|
|
this.sequence = sequence;
|
|
this.heardOn();
|
|
void this.prepare(sequence);
|
|
}
|
|
|
|
/**
|
|
* Where what goes to the output of the pattern or song is heard: its own bus, set to its sound FX
|
|
* as it is now and on along `output`. A key of the roll is played there too.
|
|
*/
|
|
heardOn(): string {
|
|
const fx = this.fx();
|
|
const output = this.output();
|
|
const route = typeof output === 'string' ? { into: output } : { sends: output };
|
|
this.fxReady = soundLibrary.setBuses([
|
|
{ name: this.outBus, gain: 1, pan: 0, ...route, ...(fx && { fx }) },
|
|
]);
|
|
return this.outBus;
|
|
}
|
|
|
|
/** Lets its own bus go while nothing plays: after a key of the roll. */
|
|
idle(): void {
|
|
if (!this.scheduler) soundLibrary.releaseBuses([this.outBus]);
|
|
}
|
|
|
|
/**
|
|
* The tracks of a song set to their level and pan, heard at once (#334), and to their sound FX
|
|
* (#346). What goes to the output of the song goes to `output` (#350). A start waits for the chains.
|
|
*/
|
|
setBuses(levels: readonly BusLevel[]): void {
|
|
const output = this.heardOn();
|
|
this.buses = levels.map(({ sends, ...level }) =>
|
|
sends
|
|
? { ...level, sends: sends.map((send) => ({ ...send, to: send.to ?? output })) }
|
|
: { ...level, into: level.into ?? output },
|
|
);
|
|
this.busesReady = soundLibrary.setBuses(this.buses);
|
|
}
|
|
|
|
/** Its buses go once what is on them has faded, unless the next start uses them again. */
|
|
stop(): void {
|
|
this.halt();
|
|
soundLibrary.releaseBuses([...this.buses.map((bus) => bus.name), this.outBus]);
|
|
}
|
|
|
|
private halt(): void {
|
|
this.run += 1;
|
|
this.scheduler?.stop();
|
|
this.scheduler = null;
|
|
}
|
|
|
|
get current(): Scheduler | null {
|
|
return this.scheduler;
|
|
}
|
|
|
|
private playOne(note: ScheduledNote): number {
|
|
const heard = { ...note, bus: note.bus || this.outBus };
|
|
const voice = strikeNote({ project: this.project(), audio: soundLibrary }, heard, '');
|
|
this.planned.push({ ...note, voice, planned: soundLibrary.clock() });
|
|
if (this.planned.length > 256) this.planned.shift();
|
|
return voice;
|
|
}
|
|
|
|
private playClip(clip: ScheduledClip): number {
|
|
const heard = { ...clip, bus: clip.bus || this.outBus };
|
|
const voice = playClip({ project: this.project(), audio: soundLibrary }, heard);
|
|
this.clips.push({ ...clip, voice, planned: soundLibrary.clock() });
|
|
if (this.clips.length > 256) this.clips.shift();
|
|
return voice;
|
|
}
|
|
|
|
/** Every file and worklet the sequence needs, so they are there before the first note is due. */
|
|
private async prepare(sequence: Sequence): Promise<void> {
|
|
const { layers, files } = sequenceNeeds(this.project(), sequence);
|
|
await soundLibrary.prepare(layers, files);
|
|
}
|
|
}
|