Stage 2 of #337. The lanes of stage 1 played, but nobody could make one. Now a track folds out a lane for volume and one for pan, and points are set, dragged and eased right on the timeline. - A lane is a row of its own, as high as a track, below its track. The view functions stay row based, so zoom, fit, item drags and the composition (which shares them) are unchanged. - itemAt and itemsWithin take a map of track id to row. - A triangle in the track head folds the lanes out. The head menu shows or hides each lane. A lane with points is open by itself. - Which lanes are open belongs to the view of the tab and is saved with it. It is not part of the song and makes no undo step. - A click sets a point on the grid, Alt or Shift on the exact tick. Press and drag sets one and drags it in a single undo step. - A dragged point stops at its neighbour. Two points on one tick make a jump. Double click or Delete removes a point. - The value shows next to a dragged point and as a tooltip. - Right click on a point opens "Ease to next point" with the same choice as animation keys, curve assets included. - The lane head shows the value at the playhead. Its menu clears or hides the lane. - While a lane rules a value, the fader shows it in the lane colour and cannot be dragged (decision 4). A hint says why. - Geometry in laneView.ts, edit rules in laneEdit.ts, the easing menu in easingMenu.ts. New test hooks automationAt, automationHeadAt and automation under __gm.song. Open: the Volume (dB) field of a track in the inspector can still be changed while a lane rules it. It has no effect while playing. Tests: - song-lanes.test.ts (12, new) and song-edit adapted: 27/27. Editor tests of song and composition 62/62. - 9 counter checks, all compile, all red. - e2e 65 on melektaus-atx: ALL PASSED, 66 checks. 65g2 is new with 6 checks. It goes red when a set point is not taken on release. - Gate on melektaus-atx: CHECK EXIT 0, 329 files, 5065 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
8.6 KiB
TypeScript
224 lines
8.6 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type { AutomationPoint } from '../../src/core/music/automation';
|
|
import type { SongTrack } from '../../src/core/music/song';
|
|
import type { Project } from '../../src/core/model/Project';
|
|
import type { MenuEntry, MenuItem, MenuSubmenu } from '../../src/ui';
|
|
import { easingEntries } from '../../src/editor/song/easingMenu';
|
|
import {
|
|
addedPoint,
|
|
draggedPoint,
|
|
easedPoint,
|
|
lanePoints,
|
|
withLanePoints,
|
|
withoutPoint,
|
|
} from '../../src/editor/song/laneEdit';
|
|
import {
|
|
LANE_INSET,
|
|
laneValue,
|
|
laneValueText,
|
|
laneWay,
|
|
laneY,
|
|
pointAt,
|
|
shownLanes,
|
|
songRows,
|
|
trackRows,
|
|
} from '../../src/editor/song/laneView';
|
|
import { trackAtY, type SongView } from '../../src/editor/song/songView';
|
|
|
|
function track(id: string, automation?: SongTrack['automation']): SongTrack {
|
|
return {
|
|
id,
|
|
name: id,
|
|
volume: -6,
|
|
pan: 0,
|
|
mute: false,
|
|
solo: false,
|
|
fx: '',
|
|
...(automation && { automation }),
|
|
};
|
|
}
|
|
|
|
const VOLUME: AutomationPoint[] = [
|
|
{ tick: 0, value: -12 },
|
|
{ tick: 960, value: 0, ease: 'step' },
|
|
{ tick: 1920, value: -24 },
|
|
];
|
|
|
|
describe('the rows of the Song tab with automation lanes (#337)', () => {
|
|
const tracks = [track('a'), track('b', [{ target: 'pan', points: [{ tick: 0, value: 0.5 }] }]), track('c')];
|
|
|
|
it('shows the lanes with points until a track is opened or closed by hand', () => {
|
|
expect(shownLanes(tracks[1]!, new Map())).toEqual(['pan']);
|
|
expect(shownLanes(tracks[0]!, new Map())).toEqual([]);
|
|
// Opened by hand the lanes come in their own order, closed by hand even one with points goes.
|
|
expect(shownLanes(tracks[0]!, new Map([['a', ['pan', 'volume'] as const]]))).toEqual(['volume', 'pan']);
|
|
expect(shownLanes(tracks[1]!, new Map([['b', []]]))).toEqual([]);
|
|
});
|
|
|
|
it('puts each lane right under its track, and finds a track by the row of its own', () => {
|
|
const open = new Map([['a', ['volume', 'pan'] as const]]);
|
|
const rows = songRows(tracks, open);
|
|
expect(rows).toEqual([
|
|
{ track: 0, lane: null },
|
|
{ track: 0, lane: 'volume' },
|
|
{ track: 0, lane: 'pan' },
|
|
{ track: 1, lane: null },
|
|
{ track: 1, lane: 'pan' },
|
|
{ track: 2, lane: null },
|
|
]);
|
|
expect([...trackRows(tracks, rows)]).toEqual([
|
|
['a', 0],
|
|
['b', 3],
|
|
['c', 5],
|
|
]);
|
|
// A lane is as high as a track, so the rows stay evenly spaced.
|
|
const view: SongView = { panX: 0, pxPerTick: 0.1, panY: 0, trackHeight: 40 };
|
|
expect(trackAtY(view, rows.length, 3 * 40 + 1)).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('the way of a lane (#337)', () => {
|
|
const view: SongView = { panX: 10, pxPerTick: 0.1, panY: 0, trackHeight: 52 };
|
|
|
|
it('draws a value from the bottom of its range to the top, and reads it back to a step', () => {
|
|
expect(laneY('volume', -60, 52)).toBe(52 - LANE_INSET);
|
|
expect(laneY('volume', 12, 52)).toBe(LANE_INSET);
|
|
expect(laneY('pan', 0, 52)).toBe(26);
|
|
// Past the range a value is drawn at the edge.
|
|
expect(laneY('pan', 3, 52)).toBe(LANE_INSET);
|
|
expect(laneValue('pan', 26, 52)).toBe(0);
|
|
expect(laneValue('volume', laneY('volume', -6.3, 52), 52)).toBeCloseTo(-6.3, 6);
|
|
expect(laneValue('volume', -100, 52)).toBe(12);
|
|
expect(laneValue('pan', laneY('pan', 0.333, 52), 52)).toBeCloseTo(0.33, 6);
|
|
});
|
|
|
|
it('finds the point under the pointer, the last drawn first', () => {
|
|
const points = [
|
|
{ tick: 0, value: 0 },
|
|
{ tick: 0, value: 0 },
|
|
{ tick: 960, value: 6 },
|
|
];
|
|
const y = laneY('volume', 0, 52);
|
|
expect(pointAt(view, 'volume', points, 10, y)).toBe(1);
|
|
expect(pointAt(view, 'volume', points, 10 + 96 + 5, laneY('volume', 6, 52) - 5)).toBe(2);
|
|
expect(pointAt(view, 'volume', points, 60, y)).toBeNull();
|
|
});
|
|
|
|
it('holds before the first point and after the last, and jumps upright on a step', () => {
|
|
const way = laneWay(view, 'volume', VOLUME, 300, 2);
|
|
const at = (x: number) => way.filter((p) => p.x === x).map((p) => p.y);
|
|
// Before the first point (x 10) the lane holds its value.
|
|
expect(at(0)).toEqual([laneY('volume', -12, 52)]);
|
|
// Halfway to the point at tick 960 (x 106) the linear way is at -6 dB, and reaches 0 dB there.
|
|
expect(at(58)).toEqual([laneY('volume', -6, 52)]);
|
|
expect(at(106)).toEqual([laneY('volume', 0, 52)]);
|
|
// The step holds until tick 1920 (x 202), where it jumps to the last point.
|
|
expect(at(200)).toEqual([laneY('volume', 0, 52)]);
|
|
expect(at(202)).toEqual([laneY('volume', 0, 52), laneY('volume', -24, 52)]);
|
|
expect(at(300)).toEqual([laneY('volume', -24, 52)]);
|
|
expect(way.map((p) => p.x)).toEqual([...way.map((p) => p.x)].sort((a, b) => a - b));
|
|
});
|
|
|
|
it('says a value as a head and a sign do', () => {
|
|
expect(laneValueText('volume', -6)).toBe('-6.0 dB');
|
|
expect(laneValueText('volume', 3.25)).toBe('+3.3 dB');
|
|
expect(laneValueText('volume', -60)).toBe('-inf dB');
|
|
expect(laneValueText('pan', 0)).toBe('Centre');
|
|
expect(laneValueText('pan', -0.25)).toBe('25% left');
|
|
expect(laneValueText('pan', 1)).toBe('100% right');
|
|
});
|
|
});
|
|
|
|
describe('editing the points of a lane (#337)', () => {
|
|
it('puts the points on their lane, the lanes in their order, and drops a lane without points', () => {
|
|
const a = withLanePoints(
|
|
track('a', [{ target: 'pan', points: [{ tick: 0, value: 1 }] }]),
|
|
'volume',
|
|
VOLUME,
|
|
);
|
|
expect(a.automation?.map((l) => l.target)).toEqual(['volume', 'pan']);
|
|
expect(lanePoints(a, 'volume')).toEqual(VOLUME);
|
|
const b = withLanePoints(a, 'pan', []);
|
|
expect(b.automation?.map((l) => l.target)).toEqual(['volume']);
|
|
expect('automation' in withLanePoints(b, 'volume', [])).toBe(false);
|
|
});
|
|
|
|
it('adds a point after the ones already on its tick, held to whole ticks and the range', () => {
|
|
expect(addedPoint(VOLUME, 'volume', 960.4, 3)).toEqual({
|
|
points: [VOLUME[0], VOLUME[1], { tick: 960, value: 3 }, VOLUME[2]],
|
|
index: 2,
|
|
});
|
|
expect(addedPoint([], 'pan', -5, 4)).toEqual({ points: [{ tick: 0, value: 1 }], index: 0 });
|
|
});
|
|
|
|
it('drags a point no further than its neighbours, keeping how it eases', () => {
|
|
expect(draggedPoint(VOLUME, 'volume', 1, 5000, -3)[1]).toEqual({ tick: 1920, value: -3, ease: 'step' });
|
|
expect(draggedPoint(VOLUME, 'volume', 1, -10, 99)[1]).toEqual({ tick: 0, value: 12, ease: 'step' });
|
|
expect(draggedPoint(VOLUME, 'volume', 2, 9999.6, -60)[2]).toEqual({ tick: 10000, value: -60 });
|
|
expect(draggedPoint(VOLUME, 'volume', 7, 0, 0)).toEqual(VOLUME);
|
|
});
|
|
|
|
it('takes a point away and sets how one eases, linear stored as none', () => {
|
|
expect(withoutPoint(VOLUME, 1)).toEqual([VOLUME[0], VOLUME[2]]);
|
|
expect(easedPoint(VOLUME, 0, 'sine-in')[0]).toEqual({ tick: 0, value: -12, ease: 'sine-in' });
|
|
expect(easedPoint(VOLUME, 1, 'linear')[1]).toEqual({ tick: 960, value: 0 });
|
|
});
|
|
});
|
|
|
|
describe('the easings as a menu (#337)', () => {
|
|
const project = {
|
|
assets: [
|
|
{ id: 'c2', type: 'curve', name: 'Swell' },
|
|
{ id: 'c1', type: 'curve', name: 'Dip' },
|
|
{ id: 'c3', type: 'curve', name: 'Inside', owner: 'song' },
|
|
],
|
|
} as unknown as Project;
|
|
const byLabel = (entries: MenuEntry[], label: string) =>
|
|
entries.find((e) => 'label' in e && e.label === label) as MenuItem & MenuSubmenu;
|
|
|
|
it('offers every easing, marks the one in use and hands back what is picked', () => {
|
|
const set = vi.fn();
|
|
const entries = easingEntries(project, 'cubic-out', set);
|
|
expect(entries.map((e) => ('label' in e ? e.label : '-'))).toEqual([
|
|
'Linear',
|
|
'Step',
|
|
'Sine',
|
|
'Quad',
|
|
'Cubic',
|
|
'Quart',
|
|
'Quint',
|
|
'Expo',
|
|
'Circ',
|
|
'Back',
|
|
'Elastic',
|
|
'Bounce',
|
|
'Curve',
|
|
]);
|
|
expect(byLabel(entries, 'Linear').current).toBe(false);
|
|
const cubic = byLabel(entries, 'Cubic').submenu as MenuItem[];
|
|
expect(cubic.map((e) => [e.label, e.current])).toEqual([
|
|
['In', false],
|
|
['Out', true],
|
|
['In-out', false],
|
|
]);
|
|
(byLabel(entries, 'Quad').submenu as MenuItem[])[0]!.onSelect();
|
|
byLabel(entries, 'Step').onSelect();
|
|
expect(set.mock.calls).toEqual([['ease-in'], ['step']]);
|
|
});
|
|
|
|
it('lists the Curve assets of the project by name, not those a song keeps to itself', () => {
|
|
const set = vi.fn();
|
|
const curve = byLabel(easingEntries(project, 'curve:c2', set), 'Curve');
|
|
const items = curve.submenu as MenuItem[];
|
|
expect(items.map((e) => [e.label, e.current])).toEqual([
|
|
['Dip', false],
|
|
['Swell', true],
|
|
]);
|
|
items[0]!.onSelect();
|
|
expect(set).toHaveBeenCalledWith('curve:c1');
|
|
expect(
|
|
byLabel(easingEntries({ assets: [] } as unknown as Project, 'linear', set), 'Curve').disabled,
|
|
).toBe(true);
|
|
});
|
|
});
|