A round of small fixes to the Pattern and Song tabs: what the ruler and a pan do in both, and a few things the tabs carried that were in the way. - A middle-drag can take bar 1 up to half the grid or lanes in. The time before it is drawn dark on the ruler, the grid and the lanes. Nothing lands there: a click draws no note, a drop shows no frame. - A press or drag on the ruler puts the start on the nearest grid line, no longer on the one before the pointer. - A new song has one track instead of four. The "+ Track" row under the heads is gone, a head's menu adds tracks above or below. A song still keeps at least one, so that menu is always there. - A note can be sized by its left end too: the start goes to the nearest grid line and the end stays put, never before tick 0. - An end of a note under the pointer lights up and shows a resize cursor, as on an item of the Song tab. Tests: - Unit: piano-roll, song-edit, song, song-play 52 tests. Two new cases for the left end and the pan, the song fixtures add their tracks themselves now. - e2e 64+65 on melektaus-atx: ALL PASSED - 50 checks (+2). e2e 65 adds its tracks through the head's menu and clicks the ruler three quarters into a bar, which only the nearest line passes. - Gate on melektaus-atx: 251 files, 3909 tests, CHECK EXIT 0. - A probe shot of the piano roll panned in: the time before bar 1 dark, the left end of a note lit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
/**
|
||
* 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('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]);
|
||
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],
|
||
]);
|
||
});
|
||
|
||
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)',
|
||
]);
|
||
});
|
||
});
|