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
@@ -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)');
|
||||
|
||||
Reference in new issue
Block a user