import { describe, expect, it } from 'vitest'; import type { PatternNote } from '../../src/core/music/pattern'; import { barLabelStep, clampTimePan, draggedLoop, fitTime, LOOP_STRIP, rulerPartAt, timeLines, xToTick, zoomTime, } from '../../src/editor/music/timeView'; import { clampShift, draggedLength, draggedStart, drawnNote, duplicateOffset, moveDelta, placedCopies, resized, shifted, trimmed, withVelocity, } from '../../src/editor/pattern/rollEdit'; import { centreRows, clampRows, noteAt, noteAtY, noteTop, notesWithin, type RollView, ROWS, velocityAtY, velocityBarAt, zoomRows, } from '../../src/editor/pattern/rollView'; const note = (id: string, tick: number, length: number, n = 60, velocity = 0.8): PatternNote => ({ id, tick, length, note: n, velocity, }); /** 0.1 px per tick: a 1/16 is 12 px, a bar of 4/4 192. Rows of 10 px, C4 (60) at the top. */ const VIEW: RollView = { panX: 0, pxPerTick: 0.1, panY: -(127 - 60) * 10, rowHeight: 10 }; const COMMON = { beats: 4, unit: 4 }; describe('time view of the piano roll (#333)', () => { it('zooms about the pointer and pans tick 0 at most half the width in', () => { const zoomed = zoomTime({ panX: -100, pxPerTick: 0.1 }, 2, 300); expect(xToTick(zoomed, 300)).toBeCloseTo(4000); expect(clampTimePan({ panX: 40, pxPerTick: 0.1 }, 600).panX).toBe(40); expect(clampTimePan({ panX: 400, pxPerTick: 0.1 }, 600).panX).toBe(300); expect(clampTimePan({ panX: -50, pxPerTick: 0.1 }, 600).panX).toBe(-50); expect(fitTime(7680, 768)).toEqual({ panX: 0, pxPerTick: 0.1 }); }); it('pans as far left as a song before the pattern goes, with its lines (#339)', () => { // The song starts two bars before the pattern: 384 px more than half the width. expect(clampTimePan({ panX: 5000, pxPerTick: 0.1 }, 600, -3840).panX).toBeCloseTo(684); expect(clampTimePan({ panX: 500, pxPerTick: 0.1 }, 600, -3840).panX).toBe(500); expect(clampTimePan({ panX: 5000, pxPerTick: 0.1 }, 600, 960).panX).toBe(300); const before = { panX: 384, pxPerTick: 0.1 }; const bars = (earliest?: number) => timeLines(before, COMMON, 120, 600, earliest) .filter((l) => l.level === 0) .map((l) => l.tick); expect(bars()).toEqual([0, 1920]); expect(bars(-3840)).toEqual([-3840, -1920, 0, 1920]); }); it('draws bars, beats and the grid, each tick at its strongest, the grid only with room', () => { const lines = timeLines(VIEW, COMMON, 120, 200); expect(lines.filter((l) => l.level === 0).map((l) => l.tick)).toEqual([0, 1920]); expect(lines.filter((l) => l.level === 1).map((l) => l.tick)).toEqual([480, 960, 1440]); expect(lines.filter((l) => l.level === 2)).toHaveLength(12); // At a twentieth of that a 1/16 is 0.6 px: bars only. expect(timeLines({ panX: 0, pxPerTick: 0.005 }, COMMON, 120, 200).every((l) => l.level === 0)).toBe(true); }); it('keeps the beats of 6/8 between the lines of a triplet grid', () => { const lines = timeLines({ panX: 0, pxPerTick: 0.1 }, { beats: 6, unit: 8 }, 160, 150); expect(lines.filter((l) => l.level === 1).map((l) => l.tick)).toEqual([240, 480, 720, 960, 1200]); expect(lines.filter((l) => l.level === 2).map((l) => l.tick)).toEqual([160, 320, 640, 800, 1120, 1280]); }); it('numbers every bar while there is room, else every second, fourth …', () => { expect(barLabelStep({ panX: 0, pxPerTick: 0.1 }, COMMON)).toBe(1); expect(barLabelStep({ panX: 0, pxPerTick: 0.01 }, COMMON)).toBe(2); expect(barLabelStep({ panX: 0, pxPerTick: 0.004 }, COMMON)).toBe(8); }); it('sets the loop on the strip along the top of the ruler, and the start below it', () => { expect(rulerPartAt(0)).toBe('loop'); expect(rulerPartAt(LOOP_STRIP - 1)).toBe('loop'); expect(rulerPartAt(LOOP_STRIP)).toBe('time'); const loop = { start: 1920, end: 3840 }; expect(draggedLoop(loop, null, 960, 480)).toEqual({ start: 480, end: 960 }); expect(draggedLoop(loop, null, 960, 960)).toBeNull(); expect(draggedLoop(loop, 'end', 3840, 5760)).toEqual({ start: 1920, end: 5760 }); expect(draggedLoop(loop, 'end', 3840, 0)).toEqual({ start: 1920, end: 1921 }); expect(draggedLoop(loop, 'start', 1920, 9999)).toEqual({ start: 3839, end: 3840 }); }); }); describe('rows of the piano roll (#333)', () => { it('puts the highest note on top and finds the note under a point', () => { expect(noteTop(VIEW, 60)).toBe(0); expect(noteTop(VIEW, 59)).toBe(10); expect(noteAtY(VIEW, 5)).toBe(60); expect(noteAtY(VIEW, 15)).toBe(59); expect(noteAtY({ ...VIEW, panY: 0 }, -1)).toBeNull(); expect(noteAtY({ ...VIEW, panY: 0 }, ROWS * 10)).toBeNull(); }); it('pans the rows half the height past either end at most and zooms them about the pointer', () => { expect(clampRows({ ...VIEW, panY: 50 }, 300).panY).toBe(50); expect(clampRows({ ...VIEW, panY: 500 }, 300).panY).toBe(150); expect(clampRows({ ...VIEW, panY: -5000 }, 300).panY).toBe(300 - 150 - ROWS * 10); const zoomed = zoomRows(VIEW, 2, 25); expect(zoomed.rowHeight).toBe(20); expect(noteAtY(zoomed, 25)).toBe(58); expect(zoomRows(VIEW, 100, 0).rowHeight).toBe(64); }); it('centres the notes it is fitted to, their rows as high as fills the height', () => { const fitted = centreRows(60, 64, 200); // Five notes and a row of room above and below fill 200 px: the rows grow past the default. expect(fitted.rowHeight).toBeCloseTo(200 / 7); const view = { ...VIEW, ...fitted }; expect(noteAtY(view, 100)).toBe(62); expect(centreRows(60, 60, 400).rowHeight).toBe(64); expect(centreRows(24, 48, 200).rowHeight).toBeCloseTo(200 / 27); expect(centreRows(0, 127, 200).rowHeight).toBe(6); }); it('tells a note body from its ends, and takes the one drawn last', () => { const notes = [note('a', 0, 480), note('b', 240, 480)]; expect(noteAt(VIEW, notes, { x: 10, y: 5 })).toEqual({ id: 'a', part: 'body' }); expect(noteAt(VIEW, notes, { x: 30, y: 5 })).toEqual({ id: 'b', part: 'body' }); expect(noteAt(VIEW, notes, { x: 70, y: 5 })).toEqual({ id: 'b', part: 'end' }); expect(noteAt(VIEW, notes, { x: 25, y: 5 })).toEqual({ id: 'b', part: 'start' }); expect(noteAt(VIEW, notes, { x: 10, y: 15 })).toBeNull(); // A note of a tick is still 4 px to the pointer. expect(noteAt(VIEW, [note('c', 1000, 1)], { x: 102, y: 5 })?.id).toBe('c'); }); it('picks the notes a band touches', () => { const notes = [note('a', 0, 120, 60), note('b', 480, 120, 59), note('c', 960, 120, 55)]; expect(notesWithin(VIEW, notes, { x: 5, y: 2 }, { x: 50, y: 18 })).toEqual(['a', 'b']); expect(notesWithin(VIEW, notes, { x: 50, y: 60 }, { x: 0, y: 0 })).toEqual(['a', 'b']); }); it('finds the velocity bar under the pointer, a picked one first', () => { const notes = [note('a', 0, 120), note('b', 0, 120, 64), note('c', 480, 120)]; expect(velocityBarAt(VIEW, notes, new Set(), 2)).toBe('b'); expect(velocityBarAt(VIEW, notes, new Set(['a']), 2)).toBe('a'); expect(velocityBarAt(VIEW, notes, new Set(), 30)).toBeNull(); expect(velocityAtY(10, 10, 64)).toBe(1); expect(velocityAtY(42, 10, 64)).toBe(0.5); expect(velocityAtY(100, 10, 64)).toBe(0); }); }); describe('editing in the piano roll (#333)', () => { it('draws on the grid line before the pointer, or on the tick with Alt', () => { expect(drawnNote(250, 62, 120, false, 120, 0.8)).toMatchObject({ tick: 240, length: 120, note: 62, velocity: 0.8, }); expect(drawnNote(250.4, 62, 120, true, 960, 0.5)).toMatchObject({ tick: 250, length: 960, velocity: 0.5, }); expect(drawnNote(250, 62, 120, false, 120, 1).id).not.toBe(drawnNote(250, 62, 120, false, 120, 1).id); }); it('moves the grabbed note onto the grid, the others along, never before 0 or off the keys', () => { const notes = [note('a', 240, 120, 60), note('b', 480, 120, 120), note('c', 0, 120, 30)]; const picked = new Set(['a', 'b']); expect(moveDelta(notes, picked, notes[0]!, 370, 62, 120, false)).toEqual({ ticks: 120, notes: 2 }); expect(moveDelta(notes, picked, notes[0]!, 370, 62, 120, true)).toEqual({ ticks: 130, notes: 2 }); expect(moveDelta(notes, picked, notes[0]!, -500, 80, 120, false)).toEqual({ ticks: -240, notes: 7 }); expect(clampShift(notes, new Set(['c']), -10, -40)).toEqual({ ticks: 0, notes: -30 }); expect(shifted(notes, picked, 120, 2).map((n) => [n.tick, n.note])).toEqual([ [360, 62], [600, 122], [0, 30], ]); }); it('drags a length to the nearest grid line, never back to its own start', () => { const anchor = note('a', 240, 120); expect(draggedLength(anchor, 470, 120, false)).toBe(240); expect(draggedLength(anchor, 250, 120, false)).toBe(120); expect(draggedLength(note('o', 250, 60), 255, 120, false)).toBe(110); expect(draggedLength(anchor, 241.2, 120, true)).toBe(1); expect(resized([anchor, note('b', 0, 60)], new Set(['a', 'b']), -100).map((n) => n.length)).toEqual([ 20, 1, ]); }); it('drags a start to the nearest grid line, its end kept, never up to the end or before 0', () => { const anchor = note('a', 240, 240); expect(draggedStart(anchor, 130, 120, false)).toBe(120); expect(draggedStart(anchor, 470, 120, false)).toBe(360); expect(draggedStart(anchor, -300, 120, false)).toBe(0); expect(draggedStart(anchor, 479.6, 120, true)).toBe(479); const notes = trimmed([anchor, note('b', 60, 60), note('c', 0, 60)], new Set(['a', 'b']), -120); expect(notes.map((n) => [n.tick, n.length])).toEqual([ [120, 360], [0, 120], [0, 60], ]); expect(trimmed([anchor], new Set(['a']), 999).map((n) => [n.tick, n.length])).toEqual([[479, 1]]); }); it('puts a duplicate right behind the picked notes, in whole grid steps', () => { const notes = [note('a', 0, 120), note('b', 240, 60), note('c', 960, 120)]; expect(duplicateOffset(notes, new Set(['a', 'b']), 120)).toBe(360); expect(duplicateOffset(notes, new Set(['b']), 120)).toBe(120); expect(duplicateOffset(notes, new Set(), 120)).toBe(0); }); it('pastes copies with ids of their own, the earliest at the start', () => { const copies = placedCopies([note('a', 480, 120, 60), note('b', 720, 60, 64)], 1920); expect(copies.map((n) => [n.tick, n.note])).toEqual([ [1920, 60], [2160, 64], ]); expect(copies.map((n) => n.id)).not.toContain('a'); }); it('pastes under the pointer: the highest copied note on its row, all kept on the keyboard', () => { const copied = [note('a', 480, 120, 60), note('b', 720, 60, 64)]; expect(placedCopies(copied, 960, 72).map((n) => [n.tick, n.note])).toEqual([ [960, 68], [1200, 72], ]); // Up at the top the lowest would still fit, but the highest would not: held to 127. expect(placedCopies(copied, 0, 200).map((n) => n.note)).toEqual([123, 127]); expect(placedCopies(copied, 0, 2).map((n) => n.note)).toEqual([0, 4]); }); it('sets the grabbed velocity and moves the other picked ones by as much', () => { const notes = [note('a', 0, 120, 60, 0.5), note('b', 0, 120, 62, 0.9), note('c', 0, 120, 64, 0.2)]; expect(withVelocity(notes, new Set(['a', 'b']), notes[0]!, 0.7).map((n) => n.velocity)).toEqual([ 0.7, 1, 0.2, ]); // Unpicked, the grabbed note goes alone. expect(withVelocity(notes, new Set(['b']), notes[0]!, 0.1).map((n) => n.velocity)).toEqual([ 0.1, 0.9, 0.2, ]); }); });