Files
feuerwerk/tests/core/song.test.ts
T
adminandClaude Opus 5.5 0e73fd85b9
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
Route sounds, songs and buses in one Master routing graph (#351)
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>
2026-10-09 09:18:46 +02:00

361 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/songCommands';
import type { PatternAsset, SongAsset } from '../../src/core/model/Asset';
import {
heldSongItems,
heldSongTracks,
itemFrom,
newItem,
newTrack,
type SongItem,
type SongTrack,
songGrid,
songLength,
songLoop,
songMeter,
songSequence,
trackItemsBus,
} from '../../src/core/music/song';
import { songBuses } from '../../src/core/music/songBuses';
import { type ScheduledClip, Scheduler } from '../../src/core/music/Scheduler';
import { clipsIn, type Sequence } from '../../src/core/music/sequence';
import { secondsOfTicks } from '../../src/core/music/time';
import { schemaForAsset } from '../../src/core/properties/PropertySchema';
import { makeProject, roundTrip } from '../helpers';
const BAR = 1920;
/** A song with four tracks, a one-bar pattern of two notes played by "Piano", and a sound. */
function makeSong(tracks = 4) {
const f = makeProject();
const create = new CreateAssetCommand('song', 'Tune');
const pattern = new CreateAssetCommand('pattern', 'Riff');
const piano = new CreateAssetCommand('instrument', 'Piano');
const organ = new CreateAssetCommand('instrument', 'Organ');
for (const command of [create, pattern, piano, organ]) f.stack.execute(command);
// A new song has one track; the tests lay things out on more, without an undo step of their own.
const created = f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
for (let i = created.tracks.length; i < tracks; i++) created.tracks.push(newTrack(i + 1));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', piano.asset.id));
f.stack.execute(
new SetPatternNotesCommand(pattern.asset.id, [
{ id: 'n1', tick: 0, length: 480, note: 60, velocity: 1 },
{ id: 'n2', tick: 960, length: 1200, note: 64, velocity: 0.5 },
]),
);
f.project.assets.push({ id: 'snd', type: 'sound', name: 'Drum loop', props: {}, file: 'loop.wav' });
const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
const track = (i: number) => song().tracks[i]!.id;
const item = (fields: Partial<SongItem> & Pick<SongItem, 'tick' | 'length'>): SongItem => ({
...newItem({ track: track(0), kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }),
...fields,
});
const lay = (...items: SongItem[]) => f.stack.execute(new SetSongCommand(song().id, { items }));
return {
...f,
song,
track,
item,
lay,
patternId: pattern.asset.id,
piano: piano.asset.id,
organ: organ.asset.id,
};
}
describe('the song asset (#334)', () => {
it('starts with one track, nothing on it, at 120 BPM in 4/4 on a grid of a bar', () => {
const f = makeSong(1);
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']);
expect(f.song().items).toEqual([]);
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'sign', 'grid', 'fx']);
expect(songGrid(f.song().props)).toBe(BAR);
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T'));
expect(songGrid(f.song().props)).toBe(160);
});
it('is written in one of the signs from 1/2 to 9/8, else in 4/4 (#363)', () => {
const f = makeSong();
const sign = schemaForAsset(f.project, f.song()).find((d) => d.key === 'sign');
expect([sign?.label, sign?.default]).toEqual(['Sign', '4/4']);
expect(sign?.kind === 'enum' && sign.options.map((o) => o.value)).toEqual([
'1/2',
'2/2',
'3/2',
'1/4',
'2/4',
'3/4',
'4/4',
'5/4',
'6/4',
'5/8',
'6/8',
'7/8',
'8/8',
'9/8',
]);
expect(songMeter(f.song().props)).toEqual({ beats: 4, unit: 4 });
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'sign', '9/8'));
expect([songMeter(f.song().props), songLength(f.song())]).toEqual([{ beats: 9, unit: 8 }, 9 * 240]);
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'sign', '3/2'));
expect(songMeter(f.song().props)).toEqual({ beats: 3, unit: 2 });
expect([songMeter({ sign: '7/4' }), songMeter({ sign: 'x' })]).toEqual([
{ beats: 4, unit: 4 },
{ beats: 4, unit: 4 },
]);
});
it('is at least a bar long, else up to the bar line after its last item, and loops all of it', () => {
const f = makeSong();
expect(songLength(f.song())).toBe(BAR);
f.lay(f.item({ tick: 0, length: 100 }));
expect(songLength(f.song())).toBe(BAR);
f.lay(f.item({ tick: 4 * BAR, length: 100 }));
expect(songLength(f.song())).toBe(5 * BAR);
expect(songLoop(f.song())).toEqual({ start: 0, end: 5 * BAR });
f.stack.execute(new SetSongLoopCommand(f.song().id, { start: BAR, end: 2 * BAR }));
expect(songLoop(f.song())).toEqual({ start: BAR, end: 2 * BAR });
f.stack.undo();
expect(f.song().loop).toBeUndefined();
});
it('keeps tracks and items to their rules on load, and a song survives the round trip', () => {
const tracks = heldSongTracks([{ id: 'a', name: '', volume: 99, pan: -4, mute: 1 }, 'x', { id: 'a' }]);
expect(tracks.map((t) => [t.name, t.volume, t.pan, t.mute])).toEqual([
['Track 1', 12, -1, false],
['Track 2', 0, 0, false],
]);
expect(tracks[1]!.id).not.toBe('a');
expect(heldSongTracks([])).toHaveLength(1);
const items = heldSongItems(
[
{
id: 'i',
track: 'a',
kind: 'pattern',
asset: 'p',
tick: 10.4,
length: 0.2,
offset: 3.7,
volume: 5,
transpose: 99,
},
{ track: 'gone', kind: 'pattern', tick: 0, length: 10 },
{ track: 'a', kind: 'video', tick: 0, length: 10 },
{ track: 'a', kind: 'sound', tick: -1, length: 10 },
{ track: 'a', kind: 'sound', tick: 0, length: 10, offset: 1.25, volume: 30 },
],
tracks,
);
// A sound item goes up to +12 dB, a pattern item's notes no louder than they are.
expect(items.map((i) => [i.tick, i.length, i.offset, i.volume, i.transpose])).toEqual([
[0, 10, 1.25, 12, 0],
[10, 1, 4, 0, 48],
]);
const f = makeSong();
f.lay(f.item({ tick: BAR, length: BAR, transpose: -2, volume: -6 }));
f.stack.execute(new SetSongLoopCommand(f.song().id, { start: 0, end: BAR }));
const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset;
expect([back.tracks, back.items, back.loop]).toEqual([f.song().tracks, f.song().items, f.song().loop]);
});
it('takes a track with its items, and an undo brings both back', () => {
const f = makeSong();
f.lay(f.item({ tick: 0, length: BAR }), f.item({ tick: 0, length: BAR, track: f.track(1) }));
const before = JSON.stringify(f.song());
const tracks = f.song().tracks.filter((t) => t.id !== f.track(0));
f.stack.execute(new SetSongCommand(f.song().id, { tracks }));
expect(f.song().items.map((i) => i.track)).toEqual([tracks[0]!.id]);
f.stack.undo();
expect(JSON.stringify(f.song())).toBe(before);
expect(SetSongCommand.changes(f.song(), { items: f.song().items })).toBe(false);
});
});
describe('what a song plays (#334)', () => {
it('repeats a pattern for as long as its item is, each note held to the item and the pattern', () => {
const f = makeSong();
f.lay(f.item({ tick: BAR, length: 2 * BAR + 480 }));
const { notes } = songSequence(f.project, f.song());
expect(notes.map((n) => [n.tick, n.length, n.note])).toEqual([
[BAR, 480, 60],
[BAR + 960, 960, 64],
[2 * BAR, 480, 60],
[2 * BAR + 960, 960, 64],
[3 * BAR, 480, 60],
]);
expect(
notes.every((n) => n.instrument === f.piano && n.bus === trackItemsBus(f.song().id, f.track(0))),
).toBe(true);
});
it('starts a pattern as far in as its offset says, moved by its transpose, with another instrument', () => {
const f = makeSong();
f.lay(f.item({ tick: 0, length: BAR, offset: 960, transpose: 12, instrument: f.organ, volume: -6 }));
const { notes } = songSequence(f.project, f.song());
expect(notes.map((n) => [n.tick, n.length, n.note, n.instrument])).toEqual([
[0, 960, 76, f.organ],
[960, 480, 72, f.organ],
]);
expect(notes[0]!.volume).toBeCloseTo(0.501, 3);
});
it('plays a sound as a clip with its offset and FX, and leaves out what has nothing to play', () => {
const f = makeSong();
f.lay(
f.item({
kind: 'sound',
asset: 'snd',
tick: 480,
length: BAR,
offset: 0.25,
fx: 'fx1',
track: f.track(2),
}),
f.item({ kind: 'sound', asset: 'gone', tick: 0, length: BAR }),
f.item({ asset: 'gone', tick: 0, length: BAR }),
);
expect(songSequence(f.project, f.song())).toEqual({
notes: [],
clips: [
{
tick: 480,
length: BAR,
sound: 'snd',
fx: 'fx1',
offset: 0.25,
volume: 1,
bus: trackItemsBus(f.song().id, f.track(2)),
},
],
});
});
it('silences a muted track, and while any track is soloed every one that is not', () => {
const f = makeSong(2);
const [a, b] = f.song().tracks;
const sends = (tracks: SongTrack[]) =>
songBuses(f.project, { ...f.song(), tracks }).flatMap((bus) =>
bus.sends ? [bus.sends.map((s) => s.gain)] : [],
);
const level = songBuses(f.project, { ...f.song(), tracks: [{ ...a!, volume: -6, pan: 0.5 }, b!] })[0]!;
expect(level.gain).toBeCloseTo(0.501, 3);
expect(level.pan).toBe(0.5);
expect(sends([a!, b!])).toEqual([[1], [1]]);
expect(sends([{ ...a!, mute: true }, b!])).toEqual([[0], [1]]);
expect(sends([{ ...a!, solo: true }, b!])).toEqual([[1], [0]]);
});
it('cuts an item in two where its rest plays on: ticks into a pattern, seconds into a sound', () => {
const f = makeSong();
const pattern = f.item({ tick: BAR, length: BAR, offset: 120 });
expect(itemFrom(pattern, BAR + 480, 120)).toMatchObject({
tick: BAR + 480,
length: BAR - 480,
offset: 600,
});
const sound = f.item({ kind: 'sound', asset: 'snd', tick: BAR, length: BAR, offset: 0.5 });
expect(itemFrom(sound, BAR + 480, 120)).toMatchObject({ tick: BAR + 480, offset: 1 });
expect(itemFrom(sound, BAR, 120)).toBeNull();
expect(itemFrom(sound, 2 * BAR, 120)).toBeNull();
});
});
describe('clips on the scheduler (#334)', () => {
const clip = (tick: number, length: number) => ({
tick,
length,
sound: 's',
fx: '',
offset: 0,
volume: 1,
bus: '',
});
const sequence: Sequence = { notes: [], clips: [clip(0, 4 * BAR), clip(2 * BAR, BAR)] };
it('takes a clip up mid-way where the playhead starts, and only there', () => {
expect(clipsIn(sequence, BAR, null, 0, 10, true).map((c) => [c.at, c.skip, c.length])).toEqual([
[0, BAR, 3 * BAR],
]);
expect(clipsIn(sequence, BAR, null, 0, 10, false)).toEqual([]);
expect(clipsIn(sequence, BAR, null, BAR, BAR + 10, false).map((c) => [c.at, c.skip])).toEqual([[BAR, 0]]);
});
it('ends a clip at the end of the loop and takes it up again after the jump back', () => {
const loop = { start: BAR, end: 2 * BAR + 960 };
const due = clipsIn(sequence, BAR, loop, 0, 2 * BAR, true);
expect(due.map((c) => [c.at, c.skip, c.length])).toEqual([
[0, BAR, BAR + 960],
[BAR, 0, 960],
[BAR + 960, BAR, BAR + 960],
]);
});
it('hands a clip to the player at its second, as far into the sound as the playhead is', () => {
const played: ScheduledClip[] = [];
let now = 10;
const scheduler = new Scheduler(
{
now: () => now,
latency: () => 0,
play: () => 0,
playClip: (c) => played.push(c),
playing: () => true,
stop: () => {},
wake: () => () => {},
},
() => ({ notes: [], clips: [{ ...clip(0, 4 * BAR), offset: 0.25, volume: 0.5, bus: 'b' }] }),
{ tempo: 120 },
);
scheduler.start(BAR + 480);
expect(played).toHaveLength(1);
expect(played[0]!.at).toBeCloseTo(10.05, 9);
expect(played[0]!.offset).toBeCloseTo(0.25 + secondsOfTicks(BAR + 480, 120), 9);
expect(played[0]!.duration).toBeCloseTo(secondsOfTicks(4 * BAR - BAR - 480, 120), 9);
expect([played[0]!.volume, played[0]!.bus, played[0]!.tempo]).toEqual([0.5, 'b', 120]);
now += 0.5;
scheduler.pump();
expect(played).toHaveLength(1);
});
it('gives the notes of a song their volume and track on the way to the player', () => {
const f = makeSong();
f.lay(f.item({ tick: 0, length: 480, volume: -6 }));
const notes: unknown[] = [];
const scheduler = new Scheduler(
{
now: () => 0,
latency: () => 0,
play: (n) => notes.push(n),
playing: () => true,
stop: () => {},
wake: () => () => {},
},
() => songSequence(f.project, f.song()),
);
scheduler.start();
expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({ note: 60, bus: trackItemsBus(f.song().id, f.track(0)) });
expect((notes[0] as { volume: number }).volume).toBeCloseTo(0.501, 3);
});
});
describe('a pattern laid out in a song', () => {
it('is found by its id: an edit to the pattern is heard everywhere it lies', () => {
const f = makeSong();
f.lay(f.item({ tick: 0, length: BAR }), f.item({ tick: BAR, length: BAR, track: f.track(1) }));
f.stack.execute(
new SetPatternNotesCommand(f.patternId, [{ id: 'n', tick: 240, length: 10, note: 70, velocity: 1 }]),
);
expect(songSequence(f.project, f.song()).notes.map((n) => [n.tick, n.note])).toEqual([
[240, 70],
[BAR + 240, 70],
]);
expect((f.project.assets.find((a) => a.id === f.patternId) as PatternAsset).notes).toHaveLength(1);
});
});