import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; import { SetSongCommand } from '../../src/core/commands/songCommands'; import { ghostNotes, placeOf, roundAt, roundsOf } from '../../src/core/music/ghosts'; import { newItem, newTrack, type SongItem } from '../../src/core/music/song'; import type { SongAsset } from '../../src/core/model/Asset'; import { makeProject } from '../helpers'; const BAR = 1920; /** A song with two tracks; "Riff" plays C and E in a bar, "Bass" a whole bar of C2. */ function makeSong() { const f = makeProject(); const create = new CreateAssetCommand('song', 'Tune'); const riff = new CreateAssetCommand('pattern', 'Riff'); const bass = new CreateAssetCommand('pattern', 'Bass'); for (const command of [create, riff, bass]) f.stack.execute(command); const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset; song().tracks.push(newTrack(2)); for (const id of [riff.asset.id, bass.asset.id]) f.stack.execute(new SetAssetPropertyCommand(id, 'bars', 1)); f.stack.execute( new SetPatternNotesCommand(riff.asset.id, [ { id: 'n1', tick: 0, length: 480, note: 60, velocity: 1 }, { id: 'n2', tick: 960, length: 1200, note: 64, velocity: 0.5 }, ]), ); f.stack.execute( new SetPatternNotesCommand(bass.asset.id, [{ id: 'b1', tick: 0, length: BAR, note: 36, velocity: 1 }]), ); f.project.assets.push({ id: 'snd', type: 'sound', name: 'Drum loop', props: {}, file: 'loop.wav' }); const track = (i: number) => song().tracks[i]!.id; const item = (fields: Partial & Pick): SongItem => ({ ...newItem({ track: track(0), kind: 'pattern', asset: riff.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, riff: riff.asset.id, bass: bass.asset.id }; } /** Riff twice from bar 2 and once more in bar 5 on track 1, a sound in bar 1; Bass for six bars on track 2. */ function laidOut( f: ReturnType, riffFields: Partial = {}, laterFields: Partial = {}, ) { const a = f.item({ id: 'a', tick: BAR, length: 2 * BAR, ...riffFields }); const later = f.item({ id: 'c', tick: 4 * BAR, length: BAR, ...laterFields }); const sound = f.item({ id: 's', kind: 'sound', asset: 'snd', tick: 0, length: BAR }); const bass = f.item({ id: 'b', track: f.track(1), asset: f.bass, tick: 0, length: 6 * BAR }); f.lay(a, later, sound, bass); return { a, later, bass }; } const brief = (notes: { tick: number; note: number; length: number; track: string; pattern: string }[]) => notes.map((n) => `${n.tick}:${n.note}:${n.length}:${n.track}:${n.pattern}`); describe('ghost notes of a pattern in a song (#339)', () => { it('shows the other tracks all along the song and the own track around the round, counted from the round', () => { const f = makeSong(); laidOut(f); const ghosts = ghostNotes(f.project, f.riff, { songId: f.song().id, itemId: 'a', round: 1 })!; expect(ghosts.origin).toBe(2 * BAR); expect(ghosts.song).toBe('Tune'); expect(ghosts.track).toBe('Track 1'); expect(ghosts.bar).toBe(3); // The round edited (bar 3) is not there; its first round (bar 2) and the item in bar 5 are. expect(brief(ghosts.notes)).toEqual([ `${-2 * BAR}:36:${BAR}:Track 2:Bass`, `${-BAR}:36:${BAR}:Track 2:Bass`, `${-BAR}:60:480:Track 1:Riff`, `${-BAR + 960}:64:960:Track 1:Riff`, `0:36:${BAR}:Track 2:Bass`, `${BAR}:36:${BAR}:Track 2:Bass`, `${2 * BAR}:36:${BAR}:Track 2:Bass`, `${2 * BAR}:60:480:Track 1:Riff`, `${2 * BAR + 960}:64:960:Track 1:Riff`, `${3 * BAR}:36:${BAR}:Track 2:Bass`, ]); }); it('puts a note on the row it sounds with: its own transpose, less that of the item the pattern is seen in', () => { const f = makeSong(); laidOut(f, { transpose: 5 }, { transpose: 2 }); const ghosts = ghostNotes(f.project, f.riff, { songId: f.song().id, itemId: 'a', round: 0 })!; expect(ghosts.notes.filter((n) => n.pattern === 'Bass').map((n) => n.note)).toEqual(Array(6).fill(31)); // The item's own second round is moved as far as the round edited: on the pattern's own rows. expect(ghosts.notes.filter((n) => n.pattern === 'Riff').map((n) => n.note)).toEqual([60, 64, 57, 61]); }); it('counts the rounds of an item from before its start, by its offset', () => { const item = { ...newItem({ track: 't', kind: 'pattern', asset: 'p', tick: BAR, length: 2 * BAR }), offset: 480, }; // Rounds start at bar 2 less 480, then a bar on: three of them touch the item. expect(roundsOf(item, BAR)).toBe(3); expect(roundAt(item, BAR, BAR + 100)).toBe(0); expect(roundAt(item, BAR, 2 * BAR - 481)).toBe(0); expect(roundAt(item, BAR, 2 * BAR - 480)).toBe(1); expect(roundAt(item, BAR, 9 * BAR)).toBe(2); expect(roundAt(item, BAR, 0)).toBe(0); }); it('keeps the place remembered while its item plays the pattern, at most at its last round', () => { const f = makeSong(); laidOut(f); const songId = f.song().id; expect(placeOf(f.project, f.riff, { songId, itemId: 'a', round: 9 })).toEqual({ songId, itemId: 'a', round: 1, }); // Without a place remembered: Riff lies twice in the song, so nowhere; Bass once, so there. expect(placeOf(f.project, f.riff)).toBeNull(); expect(placeOf(f.project, f.bass)).toEqual({ songId, itemId: 'b', round: 0 }); }); it('falls back to the only item of the pattern once the remembered one is gone or plays another', () => { const f = makeSong(); const { later, bass } = laidOut(f); const songId = f.song().id; const remembered = { songId, itemId: 'a', round: 1 }; f.lay(f.item({ id: 'a', tick: BAR, length: 2 * BAR, asset: f.bass }), later, { ...bass, tick: 8 * BAR }); expect(placeOf(f.project, f.riff, remembered)).toEqual({ songId, itemId: 'c', round: 0 }); f.lay(later, f.item({ id: 'x', tick: 6 * BAR, length: BAR })); expect(placeOf(f.project, f.riff, remembered)).toBeNull(); expect(ghostNotes(f.project, f.riff, remembered)).toBeNull(); }); });