Notes could only be played one at a time, by a key or a behavior. A pattern is a stretch of notes for one instrument, drawn in a piano roll and played in time through the scheduler of #332. Songs (#334) will lay patterns out on tracks. - New asset type pattern under the heading Patterns. Instruments are listed under Instruments now, since a heading is one per type. - Notes are a start and a length in ticks, a note number and a velocity, kept to their rules on load and in every command. The instrument, the length in bars, the grid, the meter and the tempo are rows in the inspector. The loop is the asset's own data, the whole pattern while none is set. - Grids from 1/1 to 1/32 and the triplets 1/4T to 1/32T, all whole ticks. The meter only moves the bar lines, the notes stay put. - The tab shows the keys down the left, a ruler with bars, beats, loop and start along the top, the notes between them and a velocity lane below. Keys the instrument plays nothing for are grey, the names of a drum kit stand on its keys. - A click draws a note on the grid line before the pointer, as long as the last one drawn or lengthened and as hard as the last velocity set. The body moves a note, the right end lengthens it, Alt leaves the grid. A drag on empty grid picks with a band, Shift adds, a right-click deletes. - Keys: Space plays, Delete, Ctrl+C/V/D/A, the arrows by a semitone or a grid step and with Shift by an octave or a bar. A paste lands where playing starts. Every gesture is one undo step. - A click on the ruler sets where playing starts, a drag the loop or one of its ends, its menu loops a bar or the whole pattern. - The wheel scrolls, Shift across, on the ruler or with Alt it zooms. Ctrl+wheel stays with the browser, Pixi listens passively. - Play runs the loop through the scheduler. An edit goes to it on the next window without a restart, a new loop or tempo counts from what is planned. Another pattern shown or a project opened stops it. - The time axis and the ruler are their own pieces in editor/music for the song tab. The rules of the roll are pure functions in rollView and rollEdit. Findings: - Music.play could start after a stop that came while it was still loading its files. A run counter makes the late start give up. Music.edit now loads what a new note needs as well. - A click on an end of the loop would have set the loop again. A press there only moves the end once it is dragged. Tests: - pattern.test.ts (new, 9) and piano-roll.test.ts (new, 16): model, grids, meter, held notes, sequence, commands, loop, round trip, drum names, time lines, rows, hits, bands, lane, edit rules. - e2e 64 (new) on melektaus-atx: ALL PASSED, 20 checks. e2e 4 with 29 asset types: ALL PASSED, 62 checks. - Gate on melektaus-atx: CHECK EXIT 0, 247 test files, 3839 tests. - Counter-checks: the move without its clamp, the sequence without the end of the pattern, the duplicate offset without whole grid steps each fail 1 test. Without passing edits to the scheduler the e2e check for a note drawn while playing fails, the rest passes. - Probe on stein2: the tab with a drum kit, before and while playing, looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
8.1 KiB
TypeScript
200 lines
8.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { PatternNote } from '../../src/core/music/pattern';
|
|
import {
|
|
barLabelStep,
|
|
clampTimePan,
|
|
fitTime,
|
|
timeLines,
|
|
xToTick,
|
|
zoomTime,
|
|
} from '../../src/editor/music/timeView';
|
|
import {
|
|
clampShift,
|
|
draggedLength,
|
|
drawnNote,
|
|
duplicateOffset,
|
|
moveDelta,
|
|
placedCopies,
|
|
resized,
|
|
shifted,
|
|
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 never shows time before 0', () => {
|
|
const zoomed = zoomTime({ panX: -100, pxPerTick: 0.1 }, 2, 300);
|
|
expect(xToTick(zoomed, 300)).toBeCloseTo(4000);
|
|
expect(clampTimePan({ panX: 40, pxPerTick: 0.1 }).panX).toBe(0);
|
|
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);
|
|
});
|
|
});
|
|
|
|
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('keeps the rows on screen and zooms them about the pointer', () => {
|
|
expect(clampRows({ ...VIEW, panY: 50 }, 300).panY).toBe(0);
|
|
expect(clampRows({ ...VIEW, panY: -5000 }, 300).panY).toBe(300 - 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(28);
|
|
});
|
|
|
|
it('centres the notes it is fitted to, lower rows where they do not fit', () => {
|
|
const fitted = centreRows(60, 64, 200);
|
|
expect(fitted.rowHeight).toBe(14);
|
|
const view = { ...VIEW, ...fitted };
|
|
expect(noteAtY(view, 100)).toBe(62);
|
|
expect(centreRows(24, 48, 200).rowHeight).toBeCloseTo(200 / 27);
|
|
expect(centreRows(0, 127, 200).rowHeight).toBe(6);
|
|
});
|
|
|
|
it('tells a note body from its right end, 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: 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('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('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,
|
|
]);
|
|
});
|
|
});
|