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

+138
View File
@@ -490,6 +490,144 @@ export async function song(t) {
song.tracks[2].solo = false;
song.tracks[0].mute = false;`);
// 65g2. Automation lanes (#337). The triangle in a head opens a lane for the volume and one for the
// pan right under its track, and what lies below moves down. A click on a lane sets a point on
// the grid, a press dragged on sets one and takes it along, as one step. A dragged point stops
// at its neighbour. A point's menu sets how it eases to the next, it says its value when the
// pointer rests on it, and a double-click takes it away. The fader a lane rules does not move.
// Clear lane leaves the lane open and empty, the triangle closes it.
const lanesShown = () => json('__gm.song.automation()');
const volumeAt = (tick, value) => json(`__gm.song.automationAt(1, 'volume', ${tick}, ${value})`);
const volumePoints = async () =>
(await model()).tracks[1].automation?.find((lane) => lane.target === 'volume')?.points ?? [];
const lowItem = (await model()).items.find((item) => item.track !== tracks[0] && item.track !== tracks[1]);
const lowY = async () => (await json('__gm.song.items()')).find((item) => item.id === lowItem?.id)?.box.y;
const lowBefore = await lowY();
const rowHeight = (await json('__gm.song.view()')).trackHeight;
p = await json("__gm.song.headAt(1, 'lanes')");
await click(p.x, p.y);
await sleep(100);
const lanesOpened = await lanesShown();
const lowAfter = await lowY();
check(
'the triangle of a head opens a volume and a pan lane under its track, and what lies below moves down',
JSON.stringify(lanesOpened.rows.map((row) => row.lane)) ===
JSON.stringify([null, null, 'volume', 'pan', null, null, null]) &&
lanesOpened.rows[2].track === tracks[1] &&
lanesOpened.rows[3].track === tracks[1] &&
lowItem !== undefined &&
Math.abs(lowAfter - lowBefore - 2 * rowHeight) < 0.5,
JSON.stringify({ lanesOpened, lowBefore, lowAfter, rowHeight }),
);
p = await volumeAt(BAR + 100, -12);
await click(p.x, p.y);
await sleep(100);
const onePoint = await volumePoints();
p = await volumeAt(3 * BAR, -30);
to = await volumeAt(4 * BAR + 50, 0);
await drag(p.x, p.y, to.x, to.y);
await sleep(100);
const twoPoints = await volumePoints();
const addedLabel = await json('__gm.state.commands.undoLabel');
await press('z', { ctrl: true });
await sleep(100);
const laneUndone = await volumePoints();
await clickToolbar('redo');
await sleep(100);
const laneRedone = await volumePoints();
check(
'a click on a lane sets a point on the bar, a drag sets one and takes it along as one step',
onePoint.length === 1 &&
onePoint[0].tick === BAR &&
Math.abs(onePoint[0].value + 12) < 1.5 &&
twoPoints.length === 2 &&
twoPoints[1].tick === 4 * BAR &&
Math.abs(twoPoints[1].value) < 1.5 &&
addedLabel === 'Add automation point' &&
laneUndone.length === 1 &&
JSON.stringify(laneRedone) === JSON.stringify(twoPoints),
JSON.stringify({ onePoint, twoPoints, addedLabel, laneUndone, laneRedone }),
);
p = await volumeAt(twoPoints[0].tick, twoPoints[0].value);
to = await volumeAt(6 * BAR, twoPoints[0].value);
await drag(p.x, p.y, to.x, to.y);
await sleep(100);
const heldPoints = await volumePoints();
await press('z', { ctrl: true });
await sleep(100);
check(
'a point dragged past its neighbour stops on its tick: two points there, a jump',
heldPoints.length === 2 &&
heldPoints[0].tick === 4 * BAR &&
heldPoints[1].tick === 4 * BAR &&
Math.abs(heldPoints[0].value - twoPoints[0].value) < 0.2 &&
JSON.stringify(await volumePoints()) === JSON.stringify(twoPoints),
JSON.stringify(heldPoints),
);
p = await volumeAt(twoPoints[0].tick, twoPoints[0].value);
await click(p.x, p.y, 'right');
await sleep(150);
const pointRows = await json('__gm.menuRows()');
await clickText('Ease to next point');
await sleep(150);
await clickText('Sine');
await sleep(150);
await clickText('In');
await sleep(150);
const eased = await volumePoints();
const shownValue = (v) =>
`${Math.round(v * 10) / 10 > 0 ? '+' : ''}${(Math.round(v * 10) / 10).toFixed(1)} dB`;
p = await volumeAt(twoPoints[1].tick, twoPoints[1].value);
await mouse('mouseMoved', p.x - 1, p.y);
await mouse('mouseMoved', p.x, p.y);
await t.waitUntil('__gm.tooltip() !== null', { label: 'the sign over a point of a lane' });
const pointSign = await json('__gm.tooltip()');
check(
"a point's menu sets how it eases to the next, and resting on a point says its value",
pointRows?.includes('Ease to next point') &&
pointRows.includes('Delete point') &&
eased[0].ease === 'sine-in' &&
eased[1].ease === undefined &&
pointSign?.text === shownValue(twoPoints[1].value),
JSON.stringify({ pointRows, eased, pointSign }),
);
p = await json("__gm.song.headAt(1, 'volume', 0.5)");
to = await json("__gm.song.headAt(1, 'volume', 0)");
await drag(p.x, p.y, to.x + 1, to.y);
await sleep(100);
const ruledFader = { volume: (await model()).tracks[1].volume, notice: await json('__gm.state.notice') };
p = await volumeAt(twoPoints[1].tick, twoPoints[1].value);
await doubleClick(p.x, p.y);
await sleep(100);
const afterDouble = await volumePoints();
check(
'the fader a lane rules does not move, and a double-click takes a point away',
ruledFader.volume === 0 &&
ruledFader.notice.startsWith('Volume follows its automation lane') &&
afterDouble.length === 1 &&
afterDouble[0].tick === BAR,
JSON.stringify({ ruledFader, afterDouble }),
);
p = await json("__gm.song.automationHeadAt(1, 'volume')");
await click(p.x, p.y, 'right');
await sleep(150);
await clickText('Clear lane');
await sleep(100);
const laneCleared = { track: (await model()).tracks[1], rows: (await lanesShown()).rows.length };
p = await json("__gm.song.headAt(1, 'lanes')");
await click(p.x, p.y);
await sleep(100);
const closedRows = (await lanesShown()).rows.length;
check(
'Clear lane takes the lane out of the song and leaves it open, the triangle closes the lanes',
!('automation' in laneCleared.track) &&
laneCleared.rows === 7 &&
closedRows === 5 &&
(await lowY()) === lowBefore,
JSON.stringify({ laneCleared, closedRows }),
);
await evaluate("__gm.state.notice = ''");
// 65h. Space plays from the start: the pattern a bar into itself at 2 s a bar, the sound as a
// clip round and round, each on the bus of its track.
p = await json('__gm.song.rulerAt(0)');