Files
feuerwerk/src/viewport/music.ts
T
adminandClaude Opus 5.5 af2312a5e7 Play every tab's preview straight into the master (#368)
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>
2026-10-09 13:09:36 +02:00

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