A round of fixes to the Pattern and Song tabs: where a paste lands, what Shift does on a drag, how far the rows zoom, and how an item of a song is sized at its ends. - Shift does what Alt does on a drag: off the grid, on the tick. A Shift click on a note or an item still adds or unpicks it. A Shift press only decides on letting go: no drag toggles, a drag moves. - Ctrl+V lands under the pointer. The earliest copy goes on the grid line before it, the highest note on its row or the topmost item on its track, held to the keyboard or the tracks there are. Off the grid or left of bar 1 it lands at the start of playing as before. - The rows of the piano roll zoom to 64 px instead of 28, the tracks of a song to 320 px instead of 160. - Fit in the Pattern tab zooms the rows as well, so the notes fill the height, no longer held to the default of 14 px. - An item of a song can be pulled out by its left end past the start of what it plays, which then repeats. The offset goes round to the end of a round. A sound not loaded yet still stops at its start. - Both ends of an item snap to the start of a round of what it plays as well as to the grid, whichever is nearer. The end of a round between two ticks is cut to the tick before. A start put within half a tick of a round goes right on it, so it plays from the very beginning. - An offset past a whole round is not folded back to 0, so a pattern that grows later still plays from where the item said. - A middle-drag pans past the first and the last row, as it pans left of bar 1: the highest note or the first track up to half the height down, the other end up to half the height up. The room past them is dark in the piano roll and empty in the song. Tests: - Unit: song-edit 14, piano-roll 19, song 14 tests. New cases for the paste spot, pulling out left, the round snap and Fit. - e2e 64+65 on melektaus-atx: ALL PASSED - 54 checks (+4). New: Shift drag and Shift click, Ctrl+V under the pointer in both tabs, Fit zooming the rows, pulling a pattern out left and snapping a sound to the end of its second round. e2e 64 zooms the rows out again after Fit, so the keys it plays later are on screen. - Gate on melektaus-atx: 251 files, 3912 tests, CHECK EXIT 0. - Then the pan down the rows: e2e 64 on melektaus-atx ALL PASSED - 30 checks. Its pan check now also pans the top row down, half the grid's height, and a click above it draws nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
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('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,
|
|
]);
|
|
});
|
|
});
|