/** * 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 { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset'; import { bakePlayback, bakePrint, bakeSpans } from '../../src/core/music/bake'; 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(), 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((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 { 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((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 = {}) { 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 SetLoopCommand('song', 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 over its bake while it holds, at the peak it had, its volume left live (#340)', () => { const f = playingSong(); const song = f.project.assets.find((a) => a.id === f.songId) as SongAsset; const spans = bakeSpans({ tempo: 90, length: 2 * BAR, loop: { start: BAR, end: 2 * BAR } }); const file = `${'c3'.repeat(6)}/${'d4'.repeat(32)}.ogg`; song.bake = { file, print: bakePrint(f.project, song), peak: 0.5, bytes: 1, ...spans }; const audio = recordingAudio(); startWorld(f, { behaviors: true, audio }).frame(); expect(audio.sequences[0]?.play).toMatchObject({ bake: { file, gain: 0.5, playback: bakePlayback(spans, true) }, volume: 0.8, fadeIn: 2, }); // The second track panned the other way: the song sounds otherwise, its notes are played. const tracks = song.tracks.map((track, i) => (i === 1 ? { ...track, pan: 1 } : track)); f.stack.execute(new SetSongCommand(f.songId, { tracks })); const changed = recordingAudio(); startWorld(f, { behaviors: true, audio: changed }).frame(); expect(changed.sequences[0]?.play.bake).toBeUndefined(); }); 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)', ]); }); });