A bass line is written against the chords it goes with. Until now the piano roll showed only the pattern itself, so writing a part to fit the rest of a song meant switching between the Pattern and the Song tab. - A double-click on a pattern item remembers song, item and the round clicked into for that pattern. The place lives in the session, not in the project, and goes by the item's id, so moving the item moves the ghost notes along. - Opened from the Assets panel, the pattern shows the item of the last double-click. Without one, a pattern that lies in exactly one item of all the songs shows that item's first round. - Ghost notes are the pattern items of the other tracks all along the song, and on the own track the items before and after and the other rounds of the item. The round edited itself does not count. Sound items have no notes. - A ghost note sits on the row it sounds with: its own transpose, less the transpose of the item the pattern was opened from. - The roll draws them grey, without an edge, under the notes. Resting on one names its track and its pattern. - The header has a Ghosts switch, on by default, and names where they come from, e.g. "Tune · Bass · bar 5". - The time axis pans back to the start of the song, with bar and grid lines before the pattern. Fit still frames only the pattern. - A click or a paste outside the pattern no longer lands. Before, only the time left of bar 1 was closed, notes could still be drawn past the end, where they are kept but never played. - The rounds of a pattern item are now worked out in one place, itemNotes() in song.ts, which songSequence() and the ghost notes share. Tests: - Unit ghosts (new): 5 green. Counter-check with the round edited not left out and the own transpose not taken off: 2 red. - Unit piano-roll: 1 new (pan back to the song, lines before bar 1). Counter-check without the song's start: red. - e2e 65 (melektaus-atx): ALL PASSED - 38 checks (3 new). The first run was red on the tooltip check: the ghost note it rested on lay a fifth up, outside the rows Fit frames. The test now uses a tone. - e2e 64 (melektaus-atx): ALL PASSED - 30 checks. - Gate (melektaus-atx): CHECK EXIT 0, 260 files, 4027 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
11 KiB
TypeScript
263 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('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,
|
|
]);
|
|
});
|
|
});
|