/** * Automation of a song (#337): lanes of a track's volume and pan, their value along the song, and * how the scheduler takes a bus along them on the audio clock – in the Song tab, in a game, in a * composition and in a bake alike. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { SetCompositionCommand } from '../../src/core/commands/compositionCommands'; import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { CompositionAsset, SongAsset } from '../../src/core/model/Asset'; import { curveLookup } from '../../src/core/model/curve'; import { findAssetOf } from '../../src/core/model/Project'; import { type AutomationLane, heldLanes, laneMarks, laneValueAt, laneValueBefore, type SequenceLane, type SequenceLanePoint, sequenceLaneOf, unrolledLane, } from '../../src/core/music/automation'; import { bakePrint } from '../../src/core/music/bake'; import { audioItemBus, compositionPlayings } from '../../src/core/music/compositionAudio'; import { dbToGain } from '../../src/core/music/level'; import { LEAD, type ScheduledAutomation, Scheduler, type SchedulerHost, } from '../../src/core/music/Scheduler'; import { heldSongTracks, newItem, songSequence, trackBus } from '../../src/core/music/song'; import { songBuses } from '../../src/core/music/songBuses'; import { makeProject, roundTrip } from '../helpers'; const Q = 480; const BAR = 4 * Q; const pan = (points: SequenceLanePoint[]): SequenceLane => ({ bus: 'b', target: 'pan', points }); /** The marks with their ticks and values to a millionth, so a sum of floats reads as what it is. */ const rounded = (marks: { at: number; value: number; jump: boolean }[]) => marks.map(({ at, value, jump }) => [Math.round(at * 1e6) / 1e6, Math.round(value * 1e6) / 1e6, jump]); describe('the lanes of a track (#337)', () => { it('are held to a target each, points in whole ticks from 0 with values in range, in tick order', () => { expect( heldLanes([ { target: 'pan', points: [ { tick: 960.4, value: 3 }, { tick: 0, value: -0.5, ease: 'sine-in' }, { tick: -1, value: 0 }, { tick: 10, value: Number.NaN }, ], }, { target: 'pan', points: [{ tick: 0, value: 1 }] }, { target: 'volume', points: [] }, { target: 'speed', points: [{ tick: 0, value: 1 }] }, 'volume', { target: 'volume', points: [ { tick: 5, value: 0, ease: 'wobbly' }, { tick: 0, value: -100, ease: 'linear' }, ], }, ]), ).toEqual([ { target: 'pan', points: [ { tick: 0, value: -0.5, ease: 'sine-in' }, { tick: 960, value: 1 }, ], }, { target: 'volume', points: [ { tick: 0, value: -60 }, { tick: 5, value: 0 }, ], }, ]); expect(heldLanes('x')).toEqual([]); }); it('are kept with their track when the project is saved, and a track without one has none', () => { const f = makeProject(); const create = new CreateAssetCommand('song', 'Tune'); f.stack.execute(create); const song = () => findAssetOf(f.project, create.asset.id, 'song') as SongAsset; const lane: AutomationLane = { target: 'volume', points: [{ tick: 0, value: -6, ease: 'expo-out' }] }; const [first] = song().tracks; f.stack.execute( new SetSongCommand(song().id, { tracks: [ { ...first!, automation: [lane] }, { ...first!, id: 'plain', automation: [] }, ], }), ); const saved = roundTrip(f.project).assets.find((a) => a.id === song().id) as SongAsset; expect(saved.tracks.map((t) => t.automation)).toEqual([[lane], undefined]); expect(Object.keys(heldSongTracks([{ id: 'x' }])[0]!)).not.toContain('automation'); }); it('runs from each point to the next along its easing, holds outside them, and jumps where two meet', () => { const points: SequenceLanePoint[] = [ { tick: 0, value: 0 }, { tick: 100, value: 1, ease: 'step' }, { tick: 200, value: 0, ease: 'sine-in' }, { tick: 300, value: 1 }, { tick: 300, value: -1 }, ]; const at = (tick: number) => laneValueAt(points, tick); const before = (tick: number) => laneValueBefore(points, tick); expect([at(-5), at(50), at(100), before(100)]).toEqual([0, 0.5, 1, 1]); // A step holds up to the next point, which it jumps to. expect([at(150), before(200), at(200)]).toEqual([1, 1, 0]); expect(at(250)).toBeCloseTo(1 - Math.cos(Math.PI / 4), 12); expect(before(300)).toBeCloseTo(1, 12); expect([at(300), at(400)]).toEqual([-1, -1]); }); it('eases along a Curve asset it names, and linear once that is gone', () => { const f = makeProject(); f.project.assets.push({ id: 'cv', type: 'curve', name: 'Knee', props: {}, points: [ { x: 0, y: 0 }, { x: 0.5, y: 0.9 }, { x: 1, y: 1 }, ], }); const lane: AutomationLane = { target: 'pan', points: [ { tick: 0, value: 0, ease: 'curve:cv' }, { tick: 100, value: 1 }, ], }; const curves = curveLookup(f.project); expect(laneValueAt(sequenceLaneOf(lane, 'b', curves).points, 25)).toBeCloseTo(0.45, 9); f.project.assets.pop(); expect(laneValueAt(sequenceLaneOf(lane, 'b', curveLookup(f.project)).points, 25)).toBe(0.25); }); }); describe('a lane over a window of the scheduler (#337)', () => { const sweep = pan([ { tick: 0, value: -1 }, { tick: 960, value: 1 }, ]); it('is one ramp where the bus can ramp along it: a straight pan', () => { expect(rounded(laneMarks(sweep, 0, null, 0, 480, true, 9.6))).toEqual([ [0, -1, true], [480, 0, false], ]); // Going on, it starts where the last window ended, as a ramp. expect(rounded(laneMarks(sweep, 0, null, 480, 600, false, 9.6))).toEqual([ [480, 0, false], [600, 0.25, false], ]); }); it('is read every step where the bus cannot: decibels in a gain, or an easing', () => { const fade: SequenceLane = { bus: 'b', target: 'volume', points: [ { tick: 0, value: -60 }, { tick: 960, value: 0 }, ], }; const marks = laneMarks(fade, 0, null, 0, 480, true, 9.6); // 0 and 480, and the 49 readings between. expect(marks).toHaveLength(51); expect(marks[0]).toEqual({ at: 0, value: 0, jump: true }); for (const mark of marks) expect(mark.value).toBeCloseTo(dbToGain(-60 + (60 * mark.at) / 960), 12); expect(marks.slice(1).every((mark, i) => !mark.jump && mark.at > marks[i]!.at)).toBe(true); const eased = pan([ { tick: 0, value: 0, ease: 'sine-in' }, { tick: 100, value: 1 }, ]); expect(laneMarks(eased, 0, null, 0, 100, true, 25).map((mark) => mark.at)).toEqual([0, 25, 50, 75, 100]); }); it('jumps back with the playhead at the end of a loop, and where a step lands', () => { expect(rounded(laneMarks(sweep, 0, { start: 0, end: 960 }, 480, 1440, false, 9.6))).toEqual([ [480, 0, false], [960, 1, false], [960, -1, true], [1440, 0, false], ]); const step = pan([ { tick: 0, value: -1, ease: 'step' }, { tick: 100, value: 1 }, ]); expect(rounded(laneMarks(step, 0, null, 0, 200, true, 9.6))).toEqual([ [0, -1, true], [100, 1, true], [200, 1, false], ]); }); }); describe('a lane round the loop of a song inside a composition (#337)', () => { it('is cut at the loop end and starts again there, each round on from where it was', () => { const points: SequenceLanePoint[] = [ { tick: 0, value: -1 }, { tick: 960, value: 1 }, ]; const unrolled = unrolledLane(points, { start: 0, end: 960 }, 480, 1440, 9.6); expect(unrolled).toEqual([ { tick: 480, value: 0 }, { tick: 960, value: 1 }, { tick: 960, value: -1 }, { tick: 1440, value: 0 }, ]); expect(laneValueAt(unrolled, 700)).toBeCloseTo(laneValueAt(points, 700), 12); expect(laneValueAt(unrolled, 1200)).toBeCloseTo(laneValueAt(points, 240), 12); }); it('keeps a whole easing as it is, and reads one cut in the middle every step', () => { const eased: SequenceLanePoint[] = [ { tick: 0, value: 0, ease: 'sine-in' }, { tick: 960, value: 1 }, ]; expect(unrolledLane(eased, null, 0, 1920, 96)).toEqual([ { tick: 0, value: 0, ease: 'sine-in' }, { tick: 960, value: 1 }, { tick: 1920, value: 1 }, ]); const cut = unrolledLane(eased, null, 480, 960, 96); expect(cut.map((p) => p.tick)).toEqual([480, 576, 672, 768, 864, 960]); for (const point of cut) { expect(point.ease).toBeUndefined(); expect(point.value).toBeCloseTo(laneValueAt(eased, point.tick), 12); } }); }); /** A song "Tune" with a one-bar pattern of one note on its track, and a composition "Cut" at 10 fps. */ function makeTune() { const f = makeProject(); const song = new CreateAssetCommand('song', 'Tune'); const pattern = new CreateAssetCommand('pattern', 'Riff'); const piano = new CreateAssetCommand('instrument', 'Piano'); const comp = new CreateAssetCommand('composition', 'Cut', { fps: 10 }); for (const command of [song, pattern, piano, comp]) f.stack.execute(command); 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: Q, note: 60, velocity: 1 }]), ); const tune = () => findAssetOf(f.project, song.asset.id, 'song') as SongAsset; const trackId = tune().tracks[0]!.id; f.stack.execute( new SetSongCommand(song.asset.id, { items: [newItem({ track: trackId, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR })], }), ); const automate = (...automation: AutomationLane[]) => f.stack.execute(new SetSongCommand(song.asset.id, { tracks: [{ ...tune().tracks[0]!, automation }] })); const cut = () => findAssetOf(f.project, comp.asset.id, 'composition') as CompositionAsset; return { ...f, tune, trackId, automate, cut }; } describe('a song with automation (#337)', () => { it('plays its lanes on the buses of its tracks, which start where the lanes do', () => { const f = makeTune(); expect(songSequence(f.project, f.tune()).lanes).toBeUndefined(); expect(songBuses(f.project, f.tune())[0]).not.toHaveProperty('automated'); f.automate({ target: 'volume', points: [ { tick: 0, value: -12 }, { tick: BAR, value: 0 }, ], }); expect(songSequence(f.project, f.tune()).lanes).toEqual([ { bus: trackBus(f.tune().id, f.trackId), target: 'volume', points: [ { tick: 0, value: -12 }, { tick: BAR, value: 0 }, ], }, ]); // The lane rules the volume, the fader the pan. expect(songBuses(f.project, f.tune())[0]).toMatchObject({ gain: dbToGain(-12), pan: 0, automated: ['volume'], }); }); it('is a bake of its own, and one without automation keeps the print it had', () => { const f = makeTune(); const plain = bakePrint(f.project, f.tune()); const sweep = (to: number): AutomationLane => ({ target: 'pan', points: [ { tick: 0, value: 0.5 }, { tick: BAR / 2, value: to }, ], }); f.automate(sweep(1)); const panned = bakePrint(f.project, f.tune()); expect(panned).not.toBe(plain); // Only later in the song: where its tracks start is the same. f.automate(sweep(-1)); expect(bakePrint(f.project, f.tune())).not.toBe(panned); f.automate(); expect(bakePrint(f.project, f.tune())).toBe(plain); }); it('plays its lanes in a composition on the buses of its item, round its loop, in milliseconds', () => { const f = makeTune(); f.automate({ target: 'pan', points: [ { tick: 0, value: -1 }, { tick: BAR, value: 1 }, ], }); // From 1 s for 3 s, half a second into the song: it goes round its two-second bar once. const item = { id: 'it', track: f.cut().tracks[0]!.id, start: 10, length: 30, offset: 0.5, audio: { kind: 'song' as const, asset: f.tune().id, volume: 0, bus: '' }, }; f.stack.execute(new SetCompositionCommand(f.cut().id, { items: [item] })); const [playing] = compositionPlayings(f.project, f.cut()); expect(playing!.sequence.lanes).toEqual([ { bus: `${audioItemBus(f.cut().id, 'it')}:${f.trackId}`, target: 'pan', points: [ { tick: 1000, value: -0.5 }, { tick: 2500, value: 1 }, { tick: 2500, value: -1 }, { tick: 4000, value: 0.5 }, ], }, ]); expect(playing!.buses.map((bus) => bus.name)).toContain(`${audioItemBus(f.cut().id, 'it')}:${f.trackId}`); }); }); describe('the scheduler and a lane (#337)', () => { it('hands each window to the host on the audio clock, the first fresh, each going on from the last', () => { const automated: ScheduledAutomation[] = []; let time = 10; let pump: (() => void) | null = null; const host: SchedulerHost = { now: () => time, latency: () => 0, play: () => 0, automate: (automation) => void automated.push(automation), playing: () => false, stop: () => {}, wake: (wake) => ((pump = wake), () => (pump = null)), }; const lane = { ...pan([ { tick: 0, value: -1 }, { tick: BAR, value: 1 }, ]), bus: 'song:s:t', }; const scheduler = new Scheduler(host, () => ({ notes: [], lanes: [lane] }), { tempo: 120 }); scheduler.start(); for (let i = 0; i < 8; i++) { time += 0.025; pump!(); } expect(automated.map((a) => [a.bus, a.target, a.fresh])).toEqual([ ['song:s:t', 'pan', true], ...Array.from({ length: automated.length - 1 }, () => ['song:s:t', 'pan', false]), ]); expect(automated[0]!.marks[0]).toEqual({ at: 10 + LEAD, value: -1, jump: true }); automated.slice(1).forEach((window, i) => { expect(window.marks[0]!.at).toBeCloseTo(automated[i]!.marks.at(-1)!.at, 12); expect(window.marks[0]!.jump).toBe(false); }); // Two seconds a bar: from -1 by 1 a second. for (const { marks } of automated) for (const mark of marks) expect(mark.value).toBeCloseTo(-1 + (mark.at - 10 - LEAD), 9); }); });