Files
feuerwerk/tests/core/song-play.test.ts
T
adminandClaude Opus 5.5 aa097bd076 Zoom with the wheel and set the loop on its own strip in music tabs
The ruler of the Pattern and Song tabs did two things with one drag,
and the wheel did three things with Alt and Shift. Both now do one
thing each, and the Song tab gets a few edits it was missing.

- The ruler has a loop strip along its top. A drag there sets the
  loop, a press or drag below it moves only where playing starts.
- The wheel only zooms: the time over the grid, lanes and ruler, the
  rows over the keys, the tracks over their heads. Alt and Shift no
  longer change it. A middle-drag still pans.
- Tracks can be zoomed from 28 to 160 px. Below 46 px a head shows
  only its name, M and S. An old saved view gets the old height.
- A click on nothing in the lanes picks the song itself.
- A right-click on an item opens a menu with Delete instead of
  deleting it. The inspector has no delete buttons any more.
- A sound item goes up to +12 dB, a track up to +12 dB. A pattern
  item stays at 0 dB, since a note is held to unity.
- An end of an item under the pointer lights up and shows a resize
  cursor. A long item marks every point where it starts over.
- The Pattern tab has an Audition switch in its header, off at first.
  Only with it on does a note sound when drawn, grabbed or moved.
  The keys down the left sound either way.
- A looped sound item draws every round alike. Each round is drawn
  from its own start, and each column shows the loudest part of the
  sound it covers instead of one sampled bucket.
- A song is at least one bar long instead of four. An empty song
  fits that bar across, and Play song ends after the last item.
- The ghost notes held back in #334 got their own issue, #339.

Finding: suite 542 for f97961c failed 5 game checks in e2e 64 and 65
while my runs on the same Windows machine overlapped it. Modules
60-65 alone without load passed, so this push's suite has to show it.

Tests:
- Unit: song-edit 11, piano-roll 17, song 14, sound-library 67.
  Four of them are new, one in song changed.
- e2e 64+65 on melektaus-atx: ALL PASSED - 46 checks (+3).
- e2e 60-65 on melektaus-atx: ALL PASSED - 86 checks.
- Gate on melektaus-atx: 251 files, 3907 tests, CHECK EXIT 0.
- Audition, after the gate: e2e 64 alone ALL PASSED - 25 checks
  (+1), transitions and piano-roll 30 tests, typecheck and ESLint
  clean.
- Waveform and one bar, after the gate: song, song-edit, song-play
  and the node catalog 438 tests. e2e 65 alone ALL PASSED - 25
  checks (+1). A probe shot of a looped pop sound: all six rounds
  pixel for pixel alike.

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

326 lines
13 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, 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('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));
const [one, two, three] = asset().tracks;
f.stack.execute(
new SetSongCommand(id, {
tracks: asset().tracks.map((t, i) => (i === 1 ? { ...t, volume: -6, pan: -1 } : t)),
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]);
expect(asked?.play.buses?.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],
[expect.any(String), 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)',
]);
});
});