Patterns could only be heard one at a time, in their own tab or from a behavior. A song lays patterns and sounds out on tracks along one time axis and plays them through the scheduler of #332, the third piece of the music tools of #330. - New asset type song under the heading Songs, with four empty tracks, 120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows in the inspector. Tracks, items and the loop are the asset's data, held to their rules on load and in every command. - A track has a name, volume, pan, mute and solo. Each is heard on a bus of its own, so a change reaches what is sounding at once. - A pattern item repeats its pattern for as long as it is, with a transpose and another instrument if wanted. A sound item plays its sound as a clip at its own speed, round and round, through a sound FX if wanted. Both have a volume on top of their track's. - Cut back at the left end or split, an item starts as far into what it plays as the cut is: ticks into a pattern, seconds into a sound. - The tab shows the heads with M, S, volume and pan, the ruler of the Pattern tab and the items, a pattern with its notes, a sound with its waveform, a missing asset red. Patterns and sounds are dragged in from the Assets panel. Body, right and left end move, lengthen and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits where playing starts, Delete, arrows. A double-click opens a pattern item in its tab. - What is picked is a new selection of its own, so the inspector shows the item or the track, with a button to delete it. - The scheduler plans clips beside notes. Where the playhead starts or jumps back inside a clip, it is taken up that far into its sound. A clip fades out at the end of its item or of the loop. - The editor plays one piece at a time: a pattern started stops the song and the other way round. - The rules are pure functions in core/music/song, editor/song/ songView and songEdit. Findings: - The suites of4ea752fand2a3b90bwere red at e2e 54: the Pattern pushed Struct below the fold of the + menu. The module now scrolls to it, as e2e 47 does for Vector art. It also left the menu open, which made e2e 55 fail behind it. - The Assets panel only dragged assets a scene can place. Patterns and sounds are dragged now as well, and a scene says why it does not take one instead of calling every asset a scene. Tests: - song.test.ts (new, 14) and song-edit.test.ts (new, 9): model, round trip, sequence of patterns and clips, mute and solo, cuts, clips on the scheduler, moving, sizing, trimming, splitting, duplicating, hits, head parts. sound-library.test.ts +5: clip offset, end fade, pitched clip, track bus, peaks. - e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and 55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset types: ALL PASSED, 62 checks. - Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests. - Counter-checks: no taking up of a clip, no pattern offset, no clip end each fail 3, 1 and 1 tests. - Probe on stein2: the tab with four tracks, before and while playing, looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
5.8 KiB
TypeScript
150 lines
5.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { newItem, type SongItem } from '../../src/core/music/song';
|
|
import {
|
|
clampMove,
|
|
draggedLength,
|
|
droppedLength,
|
|
duplicateOffset,
|
|
moveDelta,
|
|
moved,
|
|
placedCopies,
|
|
split,
|
|
trimDelta,
|
|
trimmed,
|
|
} from '../../src/editor/song/songEdit';
|
|
import {
|
|
clampTracks,
|
|
HEAD_BOXES,
|
|
headPartAt,
|
|
itemAt,
|
|
itemsWithin,
|
|
sliderValue,
|
|
type SongView,
|
|
TRACK_HEIGHT,
|
|
} from '../../src/editor/song/songView';
|
|
|
|
const BAR = 1920;
|
|
const TRACKS = ['t1', 't2', 't3'];
|
|
const rows = new Map(TRACKS.map((id, i) => [id, i]));
|
|
const tracks = TRACKS.map((id) => ({ id, name: id, volume: 0, pan: 0, mute: false, solo: false }));
|
|
|
|
function item(
|
|
id: string,
|
|
track: string,
|
|
tick: number,
|
|
length: number,
|
|
extra: Partial<SongItem> = {},
|
|
): SongItem {
|
|
return { ...newItem({ track, kind: 'pattern', asset: 'p', tick, length }), id, ...extra };
|
|
}
|
|
|
|
describe('moving and sizing items (#334)', () => {
|
|
const items = [item('a', 't1', BAR, BAR), item('b', 't2', 3 * BAR, BAR)];
|
|
const both = new Set(['a', 'b']);
|
|
|
|
it('moves the picked items on the grid, across tracks, never before 0 or off the tracks', () => {
|
|
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 700, 1, BAR, false)).toEqual({
|
|
ticks: 0,
|
|
tracks: 1,
|
|
});
|
|
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 1000, 2, BAR, false)).toEqual({
|
|
ticks: BAR,
|
|
tracks: 1,
|
|
});
|
|
expect(moveDelta(items, both, rows, 3, items[0]!, -5000, 0, BAR, false)).toEqual({
|
|
ticks: -BAR,
|
|
tracks: 0,
|
|
});
|
|
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 7, 0, BAR, true)).toEqual({
|
|
ticks: 7,
|
|
tracks: 0,
|
|
});
|
|
expect(clampMove(items, both, rows, 3, 0, -1)).toEqual({ ticks: 0, tracks: 0 });
|
|
expect(moved(items, both, TRACKS, 480, 1).map((i) => [i.tick, i.track])).toEqual([
|
|
[BAR + 480, 't2'],
|
|
[3 * BAR + 480, 't3'],
|
|
]);
|
|
});
|
|
|
|
it('lengthens by the right end to the grid, and never back past its own start', () => {
|
|
expect(draggedLength(items[0]!, 3 * BAR + 100, BAR, false)).toBe(2 * BAR);
|
|
expect(draggedLength(items[0]!, 0, BAR, false)).toBe(BAR);
|
|
expect(draggedLength(items[0]!, BAR + 5, BAR, true)).toBe(5);
|
|
});
|
|
|
|
it('cuts back by the left end what it plays with it, and pulls it out no further than its start', () => {
|
|
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0.25 });
|
|
const picked = new Set(['s']);
|
|
// 0.25 s at 120 BPM is 240 ticks: that is as far back as the left end goes.
|
|
expect(trimDelta([sound], picked, sound, 0, 120, false, 120)).toBe(-240);
|
|
expect(trimDelta([sound], picked, sound, 5 * BAR, 120, false, 120)).toBe(BAR - 1);
|
|
const [cut] = trimmed([sound], picked, 480, 120);
|
|
expect([cut!.tick, cut!.length, cut!.offset]).toEqual([BAR + 480, BAR - 480, 0.75]);
|
|
const pattern = item('p', 't1', BAR, BAR, { offset: 120 });
|
|
expect(trimDelta([pattern], new Set(['p']), pattern, 0, 60, false, 120)).toBe(-120);
|
|
expect(trimmed([pattern], new Set(['p']), -120, 120)[0]).toMatchObject({ tick: BAR - 120, offset: 0 });
|
|
});
|
|
|
|
it('splits the picked items the point falls inside, and gives the right-hand parts', () => {
|
|
const cut = split(items, new Set(['a', 'b']), BAR + 480, 120);
|
|
expect(cut.items.map((i) => [i.track, i.tick, i.length, i.offset])).toEqual([
|
|
['t1', BAR, 480, 0],
|
|
['t1', BAR + 480, BAR - 480, 480],
|
|
['t2', 3 * BAR, BAR, 0],
|
|
]);
|
|
expect(cut.right).toEqual([cut.items[1]!.id]);
|
|
});
|
|
|
|
it('puts a duplicate right behind the picked items, and a paste on their tracks', () => {
|
|
expect(duplicateOffset(items, both, BAR)).toBe(3 * BAR);
|
|
const copies = placedCopies(items, 480, ['t2', 't3']);
|
|
expect(copies.map((i) => [i.tick, i.track])).toEqual([
|
|
[480, 't2'],
|
|
[480 + 2 * BAR, 't2'],
|
|
]);
|
|
expect(copies.every((c) => c.id !== 'a' && c.id !== 'b')).toBe(true);
|
|
});
|
|
|
|
it('makes a dropped pattern as long as it is, a sound as long as it plays at the tempo, else a bar', () => {
|
|
expect(droppedLength(null, 3 * BAR, 120, BAR)).toBe(3 * BAR);
|
|
expect(droppedLength(1.25, null, 120, BAR)).toBe(1200);
|
|
expect(droppedLength(null, null, 120, BAR)).toBe(BAR);
|
|
});
|
|
});
|
|
|
|
describe('where things are in the Song tab (#334)', () => {
|
|
const view: SongView = { panX: 0, pxPerTick: 0.1, panY: 0 };
|
|
const items = [item('a', 't1', 0, BAR), item('b', 't2', BAR, BAR)];
|
|
|
|
it('finds an item and which end of it is under the pointer', () => {
|
|
expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'body' });
|
|
expect(itemAt(view, items, tracks, { x: 190, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'end' });
|
|
expect(itemAt(view, items, tracks, { x: 194, y: TRACK_HEIGHT * 1.5 })).toEqual({
|
|
id: 'b',
|
|
part: 'start',
|
|
});
|
|
expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT * 1.5 })).toBeNull();
|
|
expect(itemsWithin(view, items, tracks, { x: 150, y: 10 }, { x: 250, y: TRACK_HEIGHT + 10 })).toEqual([
|
|
'a',
|
|
'b',
|
|
]);
|
|
});
|
|
|
|
it('scrolls no further than the tracks and the row that adds one', () => {
|
|
expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(0);
|
|
expect(clampTracks({ ...view, panY: -500 }, 10, 400).panY).toBe(400 - 10 * TRACK_HEIGHT - 28);
|
|
});
|
|
|
|
it('tells the parts of a head apart and reads a slider from left to right', () => {
|
|
const { mute, solo, volume, pan } = HEAD_BOXES;
|
|
expect(headPartAt(mute.x + 4, mute.y + 4)).toBe('mute');
|
|
expect(headPartAt(solo.x + 4, solo.y + 4)).toBe('solo');
|
|
expect(headPartAt(volume.x + 10, volume.y + 4)).toBe('volume');
|
|
expect(headPartAt(pan.x + 10, pan.y + 4)).toBe('pan');
|
|
expect(headPartAt(20, 8)).toBe('name');
|
|
expect(sliderValue(volume.x, volume, -60, 6, 0.5)).toBe(-60);
|
|
expect(sliderValue(volume.x + volume.w, volume, -60, 6, 0.5)).toBe(6);
|
|
expect(sliderValue(pan.x + pan.w / 2, pan, -1, 1, 0.05)).toBe(0);
|
|
});
|
|
});
|