Files
feuerwerk/tests/core/song-play.test.ts
T
adminandClaude Opus 5.5 daeffe0f9d
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Failing after 14m20s
Route the tracks of a song through a graph (#350)
Every track of a song went straight into its output. Now the Song tab
has a second view, Routing: a graph with a node per track and one
Output, the links between them sends. A track can go into other
tracks, into several at once, and a link has its own volume and the
point of the track it taps.

- The song asset has a graph like an instrument (SongTrack, SongOutput).
  heldSongGraph keeps it in step with the tracks on load and in
  SetSongCommand: a node per track named after it, one output, a new
  track and every track of an old song linked to the output. Links of
  gone tracks, doubled links and links closing a loop are dropped.
- A link carries an optional send (volume in dB, tap). The inspector
  shows Volume and Mode for a picked link of a song: Post volume (Post
  pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand.
- NodeDef has two new flags. `fixed` nodes are never added or deleted
  by hand. Links between `acyclic` nodes may not close a loop, checked
  in canConnect, dropTargetPort and ConnectCommand.
- songBuses gives each track its sends instead of one `into`, and a
  bus `song:<song>:<track>:#items` for its own items. That bus is what
  solo closes on a track that only passes a soloed one on.
- Mute and solo now act on the sends (trackGates). A muted track passes
  nothing on, on any tap. A soloed track is heard with what goes into
  it, through the tracks it goes through, their own items silent.
  Only sends on a way through a soloed track stay open, so a track
  going into it and into the output is heard through it only.
- SoundLibrary: a bus has either `into` or sends. Each send is tap,
  delay, level, target. busLatency takes the latest way (arrivalOf),
  and each send is held back so all ways of a track arrive together
  (sendDelays). Taps are wired again whenever a chain comes or goes.
- The Song tab has Timeline and Routing in its header. The routing
  uses GraphCanvas with an empty palette. A track linked nowhere shows
  "Goes nowhere". Delete takes links, never a track node.
- Game playings route the same way, the output being the master of
  the playing.

Decided without asking: a send ranges -60 to +12 dB like a track,
default 0 dB Post. Mute closes the sends, the track's own level stays.

Found on the way: the inspector built the Mode row with the send as it
was when the rows were made. Setting Volume and then Mode wrote the old
volume back. Both rows now read the current send when they change.

Tests:
- Unit song-routing (new): 13 green. Sound library: 4 new, 94 green.
  11 counter-probes (loop on load, canConnect, solo, solo past the
  soloed track, mute, preFx latency, graph sync, delete guard, delays,
  taps, glide), each red.
- Existing tests adjusted to the new bus names and send shape: song,
  bus-fx, song-play, graph registry, edge description.
- e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first
  run caught the inspector bug. Counter-probe with the old closure:
  2 FAILED.
- Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red
  (registry rule, edge shape), fixed and rerun locally: 56 green.
- Not rerun after the per-send solo fix: e2e 65 and the gate.

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

349 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A game plays a song through Play song and ends it through Stop song (#336): from its start, then
* round the loop of its ruler, its tracks on buses of their own, the whole of it faded in and out.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateSoundCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/songCommands';
import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset';
import { goesBy, Playbacks, type SequencePlay } from '../../src/core/music/playbacks';
import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler';
import { newItem, newTrack, trackBus } from '../../src/core/music/song';
import { type AudioSink, type ClipTiming, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
const FILE = `${'b2'.repeat(32)}.wav`;
const Q = 480;
const BAR = 4 * Q;
/** An audio clock moved by hand, its timers run as it passes them. */
function fakeHost() {
const host = {
time: 0,
sounding: new Set<number>(),
pumps: new Set<() => void>(),
loading: [] as (() => void)[],
levels: [] as { id: number; gain: number; at: number; seconds: number }[],
timers: [] as { at: number; then: () => void }[],
closed: [] as number[],
};
const playbacks = new Playbacks({
now: () => host.time,
latency: () => 0,
playing: (voice) => host.sounding.has(voice),
stop: (voices) => {
for (const voice of voices) host.sounding.delete(voice);
},
wake: (pump) => {
host.pumps.add(pump);
return () => host.pumps.delete(pump);
},
prepare: () => new Promise<void>((resolve) => host.loading.push(resolve)),
level: (id, gain, at, seconds) => host.levels.push({ id, gain, at, seconds }),
after: (at, then) => host.timers.push({ at, then }),
closed: (id) => host.closed.push(id),
});
const run = (seconds: number) => {
const end = host.time + seconds;
while (host.time + INTERVAL <= end + 1e-9) {
host.time += INTERVAL;
for (const pump of [...host.pumps]) pump();
for (const timer of host.timers.filter((t) => t.at <= host.time + 1e-9)) {
host.timers.splice(host.timers.indexOf(timer), 1);
timer.then();
}
}
};
const loaded = async () => {
for (const resolve of host.loading.splice(0)) resolve();
await Promise.resolve();
};
return { host, playbacks, run, loaded };
}
/**
* Two bars at 120 BPM, two seconds each: a note on the first beat of each and a clip on the second
* bar. Looped, the second bar is its loop.
*/
function song(struck: (ScheduledNote | ScheduledClip)[], options: Partial<SequencePlay> = {}): SequencePlay {
let next = 100;
const voice = () => next++;
return {
sequence: {
notes: [0, BAR].map((tick, i) => ({ tick, length: Q, note: 60 + i, velocity: 1, instrument: 'piano' })),
clips: [{ tick: BAR, length: Q, sound: 'drum', fx: '', offset: 0, volume: 1, bus: 'track' }],
},
tempo: 120,
length: 2 * BAR,
loop: { start: BAR, end: 2 * BAR },
needs: { layers: [], files: [] },
strike: (note) => (struck.push(note), voice()),
strikeClip: (clip) => (struck.push(clip), voice()),
...options,
};
}
const what = (struck: (ScheduledNote | ScheduledClip)[]) =>
struck.map((s) => ('note' in s ? `note ${s.note}` : `clip ${s.sound}`));
describe('the songs a game plays', () => {
it('plays the intro once, then runs round the loop, clips with the notes', async () => {
const { playbacks, run, loaded } = fakeHost();
const struck: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(5, 'song:a', song(struck), '');
await loaded();
run(9);
expect(what(struck).slice(0, 6)).toEqual([
'note 60',
'note 61',
'clip drum',
'note 61',
'clip drum',
'note 61',
]);
const notes = struck.filter((s): s is ScheduledNote => 'note' in s);
expect(notes[1]!.at - notes[0]!.at).toBeCloseTo(2, 9);
expect(notes[2]!.at - notes[1]!.at).toBeCloseTo(2, 9);
expect(playbacks.current()[0]?.clips[0]).toMatchObject({ sound: 'drum', bus: 'track' });
});
it('rises from silence to its volume over its fade in, from its first note on', async () => {
const { host, playbacks, loaded } = fakeHost();
playbacks.start(5, 'song:a', song([], { volume: 0.5, fadeIn: 3 }), '');
host.time = 1;
await loaded();
expect(host.levels).toEqual([{ id: 5, gain: 0.5, at: 1 + LEAD, seconds: 3 }]);
});
it('starts its fade in with its first note heard, later by as much as its buses are late (#346)', async () => {
const levels: { at: number }[] = [];
let release = () => {};
const playbacks = new Playbacks({
now: () => 1,
latency: () => 0,
ahead: (id) => (id === 5 ? 0.125 : 0),
playing: () => false,
stop: () => {},
wake: () => () => {},
prepare: () => new Promise<void>((resolve) => (release = resolve)),
level: (_id, _gain, at) => void levels.push({ at }),
after: () => {},
});
const struck: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(5, 'song:a', song(struck, { volume: 0.5, fadeIn: 3 }), '');
release();
await Promise.resolve();
expect(levels).toEqual([{ at: 1 + LEAD + 0.125 }]);
expect(struck[0]).toMatchObject({ at: 1 + LEAD + 0.125 });
});
it('fades out over its fade, playing on meanwhile, then is gone; a hard stop then ends it at once', async () => {
const { host, playbacks, run, loaded } = fakeHost();
const struck: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(5, 'song:a', song(struck), '');
playbacks.start(6, 'song:a', song([]), '');
await loaded();
run(1);
playbacks.stopNamed('song:a', false, 4);
expect(
host.levels.slice(-2).map(({ id, gain, at, seconds }) => [id, gain, +at.toFixed(9), seconds]),
).toEqual([
[5, 0, 1, 4],
[6, 0, 1, 4],
]);
run(2);
expect([playbacks.has(5), playbacks.current()[0]?.fading]).toEqual([true, expect.closeTo(5, 9)]);
expect(struck.length).toBeGreaterThan(1);
playbacks.stopId(6);
expect(playbacks.current().map((p) => p.id)).toEqual([5]);
run(2.1);
expect([playbacks.has(5), host.pumps.size, host.closed]).toEqual([false, 0, [6, 5]]);
});
it('never starts when a fade out began while its files were loading', async () => {
const { host, playbacks, run, loaded } = fakeHost();
const struck: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(5, 'song:a', song(struck), '');
playbacks.stopId(5, false, 1);
await loaded();
run(2);
expect([struck.length, host.pumps.size, playbacks.has(5)]).toEqual([0, 0, false]);
});
it('goes by its own name, by its kind and by none – a kind never reaches the other', async () => {
expect(['song:a', 'song:', '', 'pattern:', 'song:b'].map((name) => goesBy('song:a', name))).toEqual([
true,
true,
true,
false,
false,
]);
const { playbacks, loaded } = fakeHost();
playbacks.start(1, 'song:a', song([]), '');
playbacks.start(2, 'pattern:p', song([]), '');
await loaded();
playbacks.stopNamed('song:');
expect(playbacks.current().map((p) => p.id)).toEqual([2]);
});
});
/**
* A scene whose behavior plays a song on start: on its first track a two-note pattern at bar 1, on
* its second a sound at bar 2, the second track at -6 dB and panned left, the third soloed.
*/
function playingSong(params: Record<string, string | number | boolean> = {}) {
const f = makeSpriteScene('Box');
const sound = new CreateSoundCommand('Blip', FILE);
f.stack.execute(sound);
const ins = new CreateAssetCommand('instrument', 'Lead');
f.stack.execute(ins);
const lead = ins.asset as InstrumentAsset;
lead.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params.sound = sound.asset.id;
const pattern = new CreateAssetCommand('pattern', 'Riff');
f.stack.execute(pattern);
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', lead.id));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
f.stack.execute(
new SetPatternNotesCommand(pattern.asset.id, [
{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 },
{ id: 'n2', tick: Q, length: Q, note: 64, velocity: 1 },
]),
);
const create = new CreateAssetCommand('song', 'Theme');
f.stack.execute(create);
const id = create.asset.id;
const asset = () => f.project.assets.find((a) => a.id === id) as SongAsset;
f.stack.execute(new SetAssetPropertyCommand(id, 'tempo', 90));
// A new song has one track: two more to lay things out on.
const [one, two, three] = [...asset().tracks, newTrack(2), newTrack(3)];
f.stack.execute(
new SetSongCommand(id, {
tracks: [one, { ...two, volume: -6, pan: -1 }, three!],
items: [
newItem({ track: one.id, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }),
newItem({ track: two.id, kind: 'sound', asset: sound.asset.id, tick: BAR, length: BAR }),
],
}),
);
f.stack.execute(new SetSongLoopCommand(id, { start: BAR, end: 2 * BAR }));
const behavior = makeBehavior(f, 'Logic');
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, behavior.id, { x: 0, y: 0 }, box);
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const play = edit.addNode('PlaySong', { song: id, volume: 0.8, fadeIn: 2, ...params });
edit.connect(start, 'exec', play, 'exec');
return { ...f, songId: id, tracks: [one.id, two.id, three!.id], lead, edit, play };
}
function recordingAudio() {
const sink = {
...SILENT_AUDIO,
sequences: [] as { name: string; play: SequencePlay; owner: string }[],
notes: [] as { name: string; volume: number; owner: string; bus?: string; at?: number }[],
clips: [] as { file: string; clip: ClipTiming }[],
stopped: [] as string[],
playSequence: (name: string, play: SequencePlay, owner: string) => {
sink.sequences.push({ name, play, owner });
return 1000 + sink.sequences.length;
},
playNote: ((name, _layers, _params, volume, owner, _duration, timing = {}) => {
sink.notes.push({ name, volume, owner, ...timing });
return 1;
}) satisfies AudioSink['playNote'],
playClip: (file: string, clip: ClipTiming) => (sink.clips.push({ file, clip }), 2),
stopSequence: (name: string, _letRing = false, fade = 0) => sink.stopped.push(`${name} (fade ${fade})`),
stopPlayback: (id: number, fade: number) => sink.stopped.push(`playback ${id} (fade ${fade})`),
};
return sink;
}
describe('Play song', () => {
it('hands the song to the player: its tempo, intro then loop, its tracks and the level of the whole', () => {
const f = playingSong();
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
const logic = world.entities.find((e) => e.name === 'Logic')!;
const [asked] = audio.sequences;
expect([asked?.name, asked?.owner]).toEqual([`song:${f.songId}`, logic.id]);
expect(asked?.play).toMatchObject({
tempo: 90,
length: 2 * BAR,
loop: { start: BAR, end: 2 * BAR },
volume: 0.8,
fadeIn: 2,
});
expect(asked?.play.sequence.notes.map((n) => [n.tick, n.note])).toEqual([
[0, 60],
[Q, 64],
]);
expect(asked?.play.needs.files).toEqual([FILE]);
const tracks = asked?.play.buses?.filter((b) => b.sends);
expect(tracks?.map((b) => [b.name, +b.gain.toFixed(3), b.pan])).toEqual([
[trackBus(f.songId, f.tracks[0]!), 1, 0],
[trackBus(f.songId, f.tracks[1]!), 0.501, -1],
[trackBus(f.songId, f.tracks[2]!), 1, 0],
]);
});
it('strikes notes on their track, owned like the playing, and clips at their volume on theirs', () => {
const f = playingSong({ detached: true, loop: false, volume: 3 });
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
const [asked] = audio.sequences;
expect([asked?.owner, asked?.play.loop, asked?.play.volume]).toEqual(['', null, 1]);
const bus = trackBus(f.songId, f.tracks[1]!);
asked!.play.strike({
at: 3,
duration: 0.25,
note: 60,
velocity: 1,
instrument: f.lead.id,
tempo: 90,
bus,
});
asked!.play.strikeClip!({
at: 4,
duration: 2,
offset: 0.5,
sound: f.project.assets.find((a) => a.type === 'sound')!.id,
fx: '',
volume: 0.5,
bus,
tempo: 90,
});
expect(audio.notes).toEqual([
{ name: `instrument:${f.lead.id}`, volume: 1, owner: '', at: 3, tempo: 90, bus },
]);
expect(audio.clips).toEqual([
{ file: FILE, clip: { at: 4, offset: 0.5, duration: 2, volume: 0.5, bus } },
]);
});
});
describe('Stop song', () => {
it('stops one song over its fade, every song for none, and one playing by its number', () => {
const f = playingSong();
const one = f.edit.addNode('StopSong', { song: f.songId, fadeOut: 1.5 });
const all = f.edit.addNode('StopSong', { song: '' });
const byNumber = f.edit.addNode('StopSong', { song: f.songId, fadeOut: 2 });
f.edit.connect(f.play, 'exec', one, 'exec');
f.edit.connect(one, 'exec', all, 'exec');
f.edit.connect(all, 'exec', byNumber, 'exec');
f.edit.connect(f.play, 'playback', byNumber, 'playback');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.stopped).toEqual([
`song:${f.songId} (fade 1.5)`,
'song: (fade 0)',
'playback 1001 (fade 2)',
]);
});
});