Let automation lanes of a song track be drawn in the song tab (#337)
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>
This commit is contained in:
1 parent
389a7c0fd1
commit
bd99f9e79d
9 files changed
+1262
-80
No files matched your search
@@ -41,7 +41,6 @@ import { MAX_PX_PER_TICK, tickToX, zoomTime } from '../../src/editor/music/timeV
|
||||
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, fx: '' }));
|
||||
|
||||
function item(
|
||||
id: string,
|
||||
@@ -175,14 +174,14 @@ describe('where things are in the Song tab (#334)', () => {
|
||||
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({
|
||||
expect(itemAt(view, items, rows, { x: 96, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'body' });
|
||||
expect(itemAt(view, items, rows, { x: 190, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'end' });
|
||||
expect(itemAt(view, items, rows, { 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([
|
||||
expect(itemAt(view, items, rows, { x: 96, y: TRACK_HEIGHT * 1.5 })).toBeNull();
|
||||
expect(itemsWithin(view, items, rows, { x: 150, y: 10 }, { x: 250, y: TRACK_HEIGHT + 10 })).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
@@ -202,7 +201,8 @@ describe('where things are in the Song tab (#334)', () => {
|
||||
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(headPartAt(30, 8)).toBe('name');
|
||||
expect(headPartAt(10, 12)).toBe('lanes');
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user