import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; import { namedNotes } from '../../src/core/instrument/compile'; import { starterInstrumentGraph } from '../../src/core/instrument/nodes'; import type { PatternAsset } from '../../src/core/model/Asset'; import { DEFAULT_GRID, gridTicks, heldPatternNotes, patternGrid, patternLength, patternLoop, patternMeter, type PatternNote, patternSequence, patternTempo, } from '../../src/core/music/pattern'; import { SIGN_OPTIONS } from '../../src/core/music/time'; import { schemaForAsset } from '../../src/core/properties/PropertySchema'; import { makeProject, roundTrip } from '../helpers'; function makePattern() { const f = makeProject(); const create = new CreateAssetCommand('pattern', 'Beat'); f.stack.execute(create); const asset = create.asset; const pattern = () => f.project.assets.find((a) => a.id === asset.id) as PatternAsset; const set = (key: string, value: string | number) => f.stack.execute(new SetAssetPropertyCommand(asset.id, key, value)); return { ...f, id: asset.id, pattern, set }; } const note = (id: string, tick: number, length: number, n = 60, velocity = 0.8): PatternNote => ({ id, tick, length, note: n, velocity, }); describe('pattern asset (#333)', () => { it('starts empty: four bars of 4/4 at 120 BPM on a 1/16 grid, no instrument', () => { const f = makePattern(); const p = f.pattern(); expect(p.notes).toEqual([]); expect(p.loop).toBeUndefined(); expect(patternMeter(p.props)).toEqual({ beats: 4, unit: 4 }); expect(patternTempo(p.props)).toBe(120); expect(patternGrid(p.props)).toBe(120); expect(patternLength(p)).toBe(4 * 1920); expect(schemaForAsset(f.project, p).map((d) => d.key)).toEqual([ 'instrument', 'bars', 'grid', 'sign', 'tempo', 'fx', ]); }); it('counts its grids in whole ticks, triplets too', () => { expect(['1/1', '1/4', '1/16', '1/32', '1/4T', '1/8T', '1/16T', '1/32T'].map(gridTicks)).toEqual([ 1920, 480, 120, 60, 320, 160, 80, 40, ]); expect(gridTicks('nonsense')).toBe(gridTicks(DEFAULT_GRID)); }); it('measures its length in bars of its meter: 3 bars of 6/8 are 4320 ticks', () => { const f = makePattern(); f.set('bars', 3); f.set('sign', '6/8'); expect(patternLength(f.pattern())).toBe(3 * 6 * 240); }); it('picks its meter from the signs a song has too, 4/4 for any other (#364)', () => { const f = makePattern(); f.set('sign', '9/8'); expect(patternMeter(f.pattern().props)).toEqual({ beats: 9, unit: 8 }); f.set('sign', '12/8'); expect(patternMeter(f.pattern().props)).toEqual({ beats: 4, unit: 4 }); expect(schemaForAsset(f.project, f.pattern()).find((d) => d.key === 'sign')).toMatchObject({ kind: 'enum', options: SIGN_OPTIONS, }); }); it('holds notes to whole ticks, a tick at least, the keyboard and 0 to 1, sorted, ids unique', () => { const held = heldPatternNotes([ { id: 'b', tick: 480.4, length: 0.2, note: 64, velocity: 2 }, { id: 'a', tick: 0, length: 240, note: 60, velocity: 0.5 }, { id: 'a', tick: 0, length: 240, note: 67, velocity: 0.5 }, { id: 'c', tick: -10, length: 240, note: 60 }, { id: 'd', tick: 0, length: 240, note: 128 }, 'junk', ]); expect(held.map((n) => [n.tick, n.length, n.note, n.velocity])).toEqual([ [0, 240, 60, 0.5], [0, 240, 67, 0.5], [480, 1, 64, 1], ]); expect(new Set(held.map((n) => n.id)).size).toBe(3); expect(held[0]!.id).toBe('a'); }); it('plays nothing without an instrument; with one, the notes before its end, cut off there', () => { const f = makePattern(); f.set('bars', 1); f.stack.execute( new SetPatternNotesCommand(f.id, [ note('a', 0, 480), note('b', 1800, 480, 62), note('c', 1920, 240, 64), ]), ); expect(patternSequence(f.pattern()).notes).toEqual([]); const ins = new CreateAssetCommand('instrument', 'Piano'); f.stack.execute(ins); f.set('instrument', ins.asset.id); expect(patternSequence(f.pattern()).notes).toEqual([ { tick: 0, length: 480, note: 60, velocity: 0.8, instrument: ins.asset.id }, { tick: 1800, length: 120, note: 62, velocity: 0.8, instrument: ins.asset.id }, ]); }); it('writes the notes in one undo step, and none when they read the same', () => { const f = makePattern(); const notes = [note('a', 0, 120)]; f.stack.execute(new SetPatternNotesCommand(f.id, notes, 'Draw note')); expect(f.pattern().notes).toEqual(notes); expect(SetPatternNotesCommand.changes(f.pattern(), notes)).toBe(false); expect(SetPatternNotesCommand.changes(f.pattern(), [note('a', 120, 120)])).toBe(true); f.stack.undo(); expect(f.pattern().notes).toEqual([]); }); it('loops the whole pattern until a loop is set, and a loop is cut at its end', () => { const f = makePattern(); expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 7680 }); f.stack.execute(new SetLoopCommand('pattern', f.id, { start: 1920, end: 9600 })); expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 }); expect(patternLoop(f.pattern())).toEqual({ start: 1920, end: 7680 }); f.set('bars', 1); expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 1920 }); f.stack.execute(new SetLoopCommand('pattern', f.id, null)); expect(f.pattern().loop).toBeUndefined(); f.stack.undo(); expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 }); }); it('survives a round trip with its notes and its loop', () => { const f = makePattern(); f.stack.execute( new SetPatternNotesCommand(f.id, [note('a', 0, 120, 36, 1), note('b', 240, 60, 38, 0.25)]), ); f.stack.execute(new SetLoopCommand('pattern', f.id, { start: 0, end: 960 })); f.set('grid', '1/8T'); const back = roundTrip(f.project).assets.find((a) => a.id === f.id) as PatternAsset; expect(back.notes).toEqual(f.pattern().notes); expect(back.loop).toEqual({ start: 0, end: 960 }); expect(patternGrid(back.props)).toBe(160); }); it('reads the names a drum kit gives its notes, for the keys of the roll', () => { const graph = starterInstrumentGraph(); const notes = graph.nodes.find((n) => n.type === 'InstrumentNotes')!; Object.assign(notes.params, { from: 36, to: 38, singleNotes: true, name36: 'Kick', name38: ' Snare ' }); expect([...namedNotes(graph)]).toEqual([ [36, 'Kick'], [38, 'Snare'], ]); notes.params.singleNotes = false; expect(namedNotes(graph).size).toBe(0); }); });