/** * A pattern or song baked (#340): where the parts of its file lie, how it is put together from the * playing looped and the one played once, raised to full scale, how it is played back – and what it * is made from, so a change to anything it sounds by outdates it, and an undo brings it back. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateSoundCommand, RenameAssetCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { AddClipCommand, SetClipPropsCommand } from '../../src/core/commands/clipCommands'; import { SetNodeParamCommand } from '../../src/core/commands/graphCommands'; import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { InstrumentAsset, PatternAsset, SongAsset } from '../../src/core/model/Asset'; import type { PropertyBag } from '../../src/core/model/Properties'; import { projectFiles } from '../../src/core/model/projectFiles'; import { assembleBake, type Bake, bakePlayback, bakePrint, bakeRuns, bakeSpans, bakeUploadName, heldBake, isBakeUpload, normalizeBake, validBake, } from '../../src/core/music/bake'; import { type BakePlay, Playbacks, type SequencePlay } from '../../src/core/music/playbacks'; import { LEAD } from '../../src/core/music/Scheduler'; import { newItem } from '../../src/core/music/song'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { SILENT_AUDIO } from '../../src/runtime/systems/Audio'; import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; const Q = 480; const FILE = `${'a1'.repeat(32)}.wav`; const OTHER = `${'b2'.repeat(32)}.wav`; const BAKED = `${'c3'.repeat(6)}/${'d4'.repeat(32)}.ogg`; /** A pattern of two notes over two bars at 90 BPM, through an instrument of one sample. */ function baked() { const f = makeSpriteScene('Box'); const sound = new CreateSoundCommand('Blip', FILE); f.stack.execute(sound); const other = new CreateSoundCommand('Bell', OTHER); f.stack.execute(other); const ins = new CreateAssetCommand('instrument', 'Lead'); f.stack.execute(ins); const lead = ins.asset as InstrumentAsset; const sample = lead.graph.nodes.find((n) => n.type === 'InstrumentSample')!; sample.params.sound = sound.asset.id; const made = new CreateAssetCommand('pattern', 'Tune'); f.stack.execute(made); const id = made.asset.id; f.stack.execute(new SetAssetPropertyCommand(id, 'instrument', lead.id)); f.stack.execute(new SetAssetPropertyCommand(id, 'tempo', 90)); f.stack.execute(new SetAssetPropertyCommand(id, 'bars', 2)); f.stack.execute( new SetPatternNotesCommand(id, [ { id: 'n1', tick: 0, length: Q, note: 60, velocity: 0.5 }, { id: 'n2', tick: Q, length: Q, note: 64, velocity: 1 }, ]), ); const pattern = () => f.project.assets.find((a) => a.id === id) as PatternAsset; return { ...f, id, lead, sample, sound: sound.asset, other: other.asset, pattern }; } function bakeOf(print: string, fields: Partial = {}): Bake { return { file: BAKED, print, peak: 0.5, length: 100, loopEnd: 100, head: 100, round: 100, bytes: 1234, ...fields, }; } describe('the parts of a bake', () => { it('lies in frames at 48 kHz: the straight run as far as the end or the loop end, then one round', () => { // Two bars of 4/4 at 120 BPM are 4 s; a loop of the second bar. const source = { tempo: 120, length: 8 * Q, loop: { start: 4 * Q, end: 8 * Q } }; expect(bakeSpans(source)).toEqual({ length: 192000, loopEnd: 192000, head: 192000, round: 96000 }); const short = { ...source, loop: { start: 0, end: 4 * Q } }; expect(bakeSpans(short)).toEqual({ length: 192000, loopEnd: 96000, head: 192000, round: 96000 }); }); it('is played looped once past the loop end; once only where that goes further', () => { expect(bakeRuns({ length: 100, loopEnd: 100, round: 100 })).toEqual({ looped: 200, once: null }); expect(bakeRuns({ length: 300, loopEnd: 100, round: 50 })).toEqual({ looped: 150, once: 300 }); }); it('takes the head from the playing once where there is one, the round always from the looped one', () => { const ramp = (length: number, from: number) => Float32Array.from({ length }, (_, i) => from + i); const looped = [ramp(150, 0), ramp(150, 1000)]; const out = assembleBake({ loopEnd: 100, head: 100, round: 50 }, looped, null); expect([out[0]!.length, out[0]![99], out[0]![100], out[1]![149]]).toEqual([150, 99, 100, 1149]); const once = [ramp(300, 5000), ramp(300, 6000)]; const both = assembleBake({ loopEnd: 100, head: 300, round: 50 }, looped, once); // The round follows the head and starts where the looped playing passed its loop end. expect([both[0]!.length, both[0]![299], both[0]![300], both[0]![349]]).toEqual([350, 5299, 100, 149]); }); it('is raised to full scale, and played back at the peak it had', () => { const sides = [Float32Array.of(0.25, -0.5), Float32Array.of(0.125, 0)]; expect(normalizeBake(sides)).toBe(0.5); expect([...sides[0]!, ...sides[1]!]).toEqual([0.5, -1, 0.25, 0]); expect(normalizeBake([new Float32Array(3)])).toBe(0); }); it('plays once to its end, or to the loop end and then round its round', () => { const spans = { length: 96000, loopEnd: 96000, head: 96000, round: 48000 }; expect(bakePlayback(spans, false)).toEqual({ end: 2, until: 2, round: null }); expect(bakePlayback(spans, true)).toEqual({ end: null, until: 2, round: { start: 2, end: 3 } }); expect(bakePlayback({ ...spans, length: 144000, head: 144000 }, true)).toEqual({ end: null, until: 2, round: { start: 3, end: 4 }, }); }); }); describe('what a bake is made from', () => { it('stays the same through a rename, changes with a note and comes back with the undo', () => { const f = baked(); const print = bakePrint(f.project, f.pattern()); expect(print).not.toBe(''); f.stack.execute(new RenameAssetCommand(f.id, 'Other tune')); expect(bakePrint(f.project, f.pattern())).toBe(print); f.stack.execute( new SetPatternNotesCommand(f.id, [{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 0.75 }]), ); expect(bakePrint(f.project, f.pattern())).not.toBe(print); f.stack.undo(); expect(bakePrint(f.project, f.pattern())).toBe(print); }); it('changes with what the pattern only names: the instrument and the level of its sound', () => { const f = baked(); const print = bakePrint(f.project, f.pattern()); f.stack.execute(new SetNodeParamCommand(f.lead.id, f.sample.id, 'sound', f.other.id)); expect(bakePrint(f.project, f.pattern())).not.toBe(print); f.stack.undo(); expect(bakePrint(f.project, f.pattern())).toBe(print); f.stack.execute(new SetAssetPropertyCommand(f.sound.id, 'volume', 0.5)); expect(bakePrint(f.project, f.pattern())).not.toBe(print); }); it('changes with the cut of a clip on a track of a song, and comes back with the undo', () => { const f = baked(); const clip = new AddClipCommand(f.sound.id, { start: 0.25, end: 1 }); f.stack.execute(clip); const made = new CreateAssetCommand('song', 'Theme'); f.stack.execute(made); const song = () => f.project.assets.find((a) => a.id === made.asset.id) as SongAsset; const track = song().tracks[0]!.id; const item = newItem({ track, kind: 'sound', asset: clip.clipId, tick: 0, length: 4 * Q }); f.stack.execute(new SetSongCommand(song().id, { items: [item] })); const print = bakePrint(f.project, song()); expect(print).not.toBe(''); const changes: PropertyBag[] = [ { end: 0.75 }, { reverse: true }, { fadeOut: 50 }, { fadeInCurve: 'sCurve' }, ]; for (const change of changes) { f.stack.execute(new SetClipPropsCommand(f.sound.id, clip.clipId, change)); expect(bakePrint(f.project, song()), JSON.stringify(change)).not.toBe(print); f.stack.undo(); expect(bakePrint(f.project, song())).toBe(print); } }); it('is nothing for a pattern that plays nothing', () => { const f = baked(); f.stack.execute(new SetAssetPropertyCommand(f.id, 'instrument', '')); expect(bakePrint(f.project, f.pattern())).toBe(''); }); it('holds while the print is the same, and only then is its file loaded with the project', () => { const f = baked(); f.pattern().bake = bakeOf(bakePrint(f.project, f.pattern())); expect(validBake(f.project, f.pattern())?.file).toBe(BAKED); expect(projectFiles(f.project).sounds).toContain(BAKED); f.stack.execute(new SetAssetPropertyCommand(f.id, 'tempo', 100)); expect(validBake(f.project, f.pattern())).toBeNull(); expect(projectFiles(f.project).sounds).not.toContain(BAKED); }); }); describe('a bake kept with the project', () => { it('is saved and loaded with its pattern; one that does not fit together is dropped', () => { const f = baked(); f.pattern().bake = bakeOf('x'); const back = deserializeProject(serializeProject(f.project)); expect((back.assets.find((a) => a.id === f.id) as PatternAsset).bake).toEqual(bakeOf('x')); expect(heldBake(bakeOf('x', { head: 50 }))).toBeNull(); expect(heldBake(bakeOf('x', { round: 101 }))).toBeNull(); expect(heldBake(bakeOf('x', { file: 'x.ogg' }))).toBeNull(); expect(heldBake(bakeOf(''))).toBeNull(); }); it('goes up under its name behind a mark the sound picker leaves out by, a slash no folder', () => { expect(bakeUploadName('Boss / theme')).toBe('[bake] Boss - theme.ogg'); expect([isBakeUpload('[bake] Boss - theme.ogg'), isBakeUpload('Boss.ogg')]).toEqual([true, false]); }); }); describe('Play pattern with a bake', () => { function played(loop: boolean) { const f = baked(); 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); edit.connect( edit.addNode('OnStart'), 'exec', edit.addNode('PlayPattern', { pattern: f.id, volume: 0.5, loop }), 'exec', ); const asked: SequencePlay[] = []; const audio = { ...SILENT_AUDIO, playSequence: (_name: string, play: SequencePlay) => (asked.push(play), 1), }; return { ...f, run: () => startWorld(f, { behaviors: true, audio }).frame(), asked }; } it('hands the bake over while it holds, at its peak times the volume of the node', () => { const f = played(true); const length = 2 * 4 * Q; const spans = bakeSpans({ tempo: 90, length, loop: { start: 0, end: length } }); f.pattern().bake = bakeOf(bakePrint(f.project, f.pattern()), spans); f.run(); expect(f.asked[0]?.bake).toEqual({ file: BAKED, gain: 0.25, playback: bakePlayback(spans, true) }); expect(f.asked[0]?.sequence.notes).toHaveLength(2); }); it('hands over none once it is outdated, and the notes are played', () => { const f = played(false); f.pattern().bake = bakeOf('outdated'); f.run(); expect(f.asked[0]?.bake).toBeUndefined(); expect(f.asked[0]?.loop).toBeNull(); }); }); describe('a playing that plays its bake', () => { function host() { const made = { time: 10, sounding: new Set(), baked: [] as { id: number; bake: BakePlay; at: number }[], stopped: [] as { voices: number[]; letRing: boolean }[], pumps: 0, loading: [] as (() => void)[], }; const playbacks = new Playbacks({ now: () => made.time, latency: () => 0, playing: (voice) => made.sounding.has(voice), stop: (voices, letRing = false) => { made.stopped.push({ voices: [...voices], letRing }); for (const voice of voices) made.sounding.delete(voice); }, wake: () => { made.pumps += 1; return () => {}; }, prepare: () => new Promise((resolve) => made.loading.push(resolve)), ahead: () => 0.25, level: () => {}, after: () => {}, playBaked: (id, bake, at) => { made.baked.push({ id, bake, at }); made.sounding.add(500); return 500; }, }); const loaded = async () => { for (const resolve of made.loading.splice(0)) resolve(); await Promise.resolve(); }; return { made, playbacks, loaded }; } const play = (end: number | null): SequencePlay => ({ sequence: { notes: [{ tick: 0, length: Q, note: 60, velocity: 1, instrument: 'lead' }] }, tempo: 120, length: 4 * Q, loop: end === null ? { start: 0, end: 4 * Q } : null, needs: { layers: [], files: [BAKED] }, strike: () => { throw new Error('a baked playing strikes no note'); }, bake: { file: BAKED, gain: 1, playback: { end, until: 2, round: end === null ? { start: 2, end: 4 } : null }, }, }); it('starts one voice of it once its file is there, as late as its first note would be, waking nothing', async () => { const { made, playbacks, loaded } = host(); playbacks.start(7, 'pattern:a', play(null), ''); expect(made.baked).toEqual([]); await loaded(); expect(made.baked.map(({ id, at }) => ({ id, at }))).toEqual([{ id: 7, at: 10 + LEAD + 0.25 }]); expect(made.pumps).toBe(0); expect(playbacks.current().map((p) => [p.id, p.baked, p.runner.startedAt])).toEqual([ [7, true, 10 + LEAD + 0.25], ]); playbacks.stopId(7, true); expect(made.stopped).toEqual([{ voices: [500], letRing: true }]); }); it('played once, it is gone with its voice; looped it stays', async () => { const { made, playbacks, loaded } = host(); playbacks.start(7, 'pattern:a', play(2), ''); playbacks.start(8, 'pattern:b', play(null), ''); await loaded(); expect(playbacks.has(7)).toBe(true); made.sounding.clear(); expect(playbacks.current().map((p) => p.id)).toEqual([8]); }); });