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 & Pick): 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', 'bus', ]); 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); }); });