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:
adminandClaude Opus 5.5 committed 2026-10-19 09:31:48 +02:00
1 parent 389a7c0fd1
commit bd99f9e79d
9 files changed
+1262 -80

No files matched your search

+7 -7
View File
@@ -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);
+223
View File
@@ -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);
});
});