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
View File
@@ -1,3 +1,4 @@
import type { AutomationTarget } from '../../core/music/automation';
import { findAssetOf } from '../../core/model/Project';
import type { HeadPart } from '../song/songView';
import { soundLibrary } from '../../viewport/SoundLibrary';
@@ -29,6 +30,12 @@ export function songHooks({ state, editor }: HookContext) {
headAt: (track: number, part: HeadPart, share?: number) => timeline.headPoint(track, part, share),
rulerAt: (tick: number) => timeline.rulerPoint(tick),
loopAt: (tick: number) => timeline.loopPoint(tick),
/** The automation lanes (#337): where a value of one is drawn, its head, and the rows as shown. */
automationAt: (track: number, target: AutomationTarget, tick: number, value: number) =>
timeline.automationPoint(track, target, tick, value),
automationHeadAt: (track: number, target: AutomationTarget) =>
timeline.automationHeadPoint(track, target),
automation: () => timeline.automationShown(),
view: () => timeline.viewInfo(),
items: () => timeline.itemsDrawn(),
playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }),
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
import { listedAssets } from '../../core/music/embedded';
import type { KeyInterpolation } from '../../core/model/Asset';
import { EASING_DIRECTIONS, EASING_SHAPES, easingOf, easingValue } from '../../core/model/animation';
import type { Project } from '../../core/model/Project';
import type { MenuEntry } from '../../ui';
/**
* The easings as entries of a menu (#337), the same choice the inspector gives an animation key: Linear
* and Step straight away, every curve with its directions in a submenu, and the Curve assets of the
* project. The one in use is marked.
*/
export function easingEntries(
project: Project,
current: KeyInterpolation,
set: (ease: KeyInterpolation) => void,
): MenuEntry[] {
const now = easingOf(current);
const curves = listedAssets(project)
.filter((a) => a.type === 'curve')
.sort((a, b) => a.name.localeCompare(b.name));
return EASING_SHAPES.map(({ value, label }): MenuEntry => {
if (value === 'linear' || value === 'step')
return { label, current: now.shape === value, onSelect: () => set(value) };
if (value === 'curve')
return {
label,
disabled: curves.length === 0,
submenu: curves.map((curve) => ({
label: curve.name,
current: now.shape === 'curve' && now.curve === curve.id,
onSelect: () => set(easingValue('curve', undefined, curve.id)),
})),
};
return {
label,
submenu: EASING_DIRECTIONS.map((direction) => ({
label: direction.label,
current: now.shape === value && now.direction === direction.value,
onSelect: () => set(easingValue(value, direction.value)),
})),
};
});
}
+94
View File
@@ -0,0 +1,94 @@
import {
AUTOMATION_TARGETS,
type AutomationPoint,
type AutomationTarget,
targetRange,
} from '../../core/music/automation';
import type { SongTrack } from '../../core/music/song';
import type { KeyInterpolation } from '../../core/model/Asset';
/**
* Editing the points of a lane in the Song tab (#337), apart from Pixi so a test can drive it. Points
* stay in the order of their ticks: one that is dragged stops at its neighbours, so two on one tick –
* a jump – keep which comes first.
*/
/** The points of a track's lane for that target; none while it has no lane. */
export function lanePoints(track: SongTrack, target: AutomationTarget): AutomationPoint[] {
return track.automation?.find((lane) => lane.target === target)?.points ?? [];
}
/** The track with these points on that lane; with none the lane goes, and with the last one `automation`. */
export function withLanePoints(
track: SongTrack,
target: AutomationTarget,
points: readonly AutomationPoint[],
): SongTrack {
const kept = (track.automation ?? []).filter((lane) => lane.target !== target);
const lanes =
points.length > 0
? AUTOMATION_TARGETS.flatMap((t) =>
t === target ? [{ target, points: [...points] }] : kept.filter((lane) => lane.target === t),
)
: kept;
const { automation: _, ...rest } = track;
return lanes.length > 0 ? { ...rest, automation: lanes } : rest;
}
function held(target: AutomationTarget, value: number): number {
const { min, max } = targetRange(target);
return Math.max(min, Math.min(max, value));
}
/** A new point, after those already on its tick, and where it went. */
export function addedPoint(
points: readonly AutomationPoint[],
target: AutomationTarget,
tick: number,
value: number,
): { points: AutomationPoint[]; index: number } {
const at = Math.max(0, Math.round(tick));
let index = 0;
while (index < points.length && points[index]!.tick <= at) index++;
const out = [...points];
out.splice(index, 0, { tick: at, value: held(target, value) });
return { points: out, index };
}
/** The point at `index` dragged to a tick and a value, the tick held between its neighbours'. */
export function draggedPoint(
points: readonly AutomationPoint[],
target: AutomationTarget,
index: number,
tick: number,
value: number,
): AutomationPoint[] {
const point = points[index];
if (!point) return [...points];
const low = points[index - 1]?.tick ?? 0;
const high = points[index + 1]?.tick ?? Infinity;
const out = [...points];
out[index] = {
...point,
tick: Math.min(high, Math.max(low, Math.round(tick))),
value: held(target, value),
};
return out;
}
export function withoutPoint(points: readonly AutomationPoint[], index: number): AutomationPoint[] {
return points.filter((_, i) => i !== index);
}
/** The point at `index` easing to the next one along `ease`; linear is stored as no easing. */
export function easedPoint(
points: readonly AutomationPoint[],
index: number,
ease: KeyInterpolation,
): AutomationPoint[] {
return points.map((point, i) => {
if (i !== index) return point;
const { ease: _, ...rest } = point;
return ease === 'linear' ? rest : { ...rest, ease };
});
}
+138
View File
@@ -0,0 +1,138 @@
import {
AUTOMATION_TARGETS,
type AutomationPoint,
type AutomationTarget,
laneOf,
laneValueAt,
laneValueBefore,
type SequenceLanePoint,
targetRange,
} from '../../core/music/automation';
import type { SongTrack } from '../../core/music/song';
import { clamp } from '../../ui';
import { tickToX, xToTick } from '../music/timeView';
import type { SongView } from './songView';
/**
* The automation lanes of the Song tab (#337), apart from Pixi so a test can drive them: each lane a
* row of its own right under its track, as high as a track, so the rows stay evenly spaced and the
* geometry of `songView` holds for them as for the tracks.
*/
/** A row of the tracks: a track itself, or one of its lanes. `track` is the track's index. */
export interface SongRow {
track: number;
lane: AutomationTarget | null;
}
/** The lanes a track was opened to or closed on by hand, by its id. */
export type OpenLanes = ReadonlyMap<string, readonly AutomationTarget[]>;
export const LANE_LABELS: Readonly<Record<AutomationTarget, string>> = { volume: 'Volume', pan: 'Pan' };
/** The lanes a track shows, in a fixed order: as opened by hand, else the ones with a point. */
export function shownLanes(track: SongTrack, open: OpenLanes): AutomationTarget[] {
const named = open.get(track.id);
return AUTOMATION_TARGETS.filter((target) => (named ? named.includes(target) : !!laneOf(track, target)));
}
export function songRows(tracks: readonly SongTrack[], open: OpenLanes): SongRow[] {
return tracks.flatMap((track, i) => [
{ track: i, lane: null },
...shownLanes(track, open).map((lane) => ({ track: i, lane })),
]);
}
/** The row of each track, by its id. */
export function trackRows(tracks: readonly SongTrack[], rows: readonly SongRow[]): Map<string, number> {
const out = new Map<string, number>();
rows.forEach((row, i) => {
if (row.lane === null) out.set(tracks[row.track]!.id, i);
});
return out;
}
/** What is left free above and below the way of a lane, so a point at either end can still be taken. */
export const LANE_INSET = 6;
/** How near a point a press takes it. */
export const POINT_REACH = 6;
/** Where a value is drawn in a lane `h` high, from its top; past the range it is held to the edge. */
export function laneY(target: AutomationTarget, value: number, h: number): number {
const { min, max } = targetRange(target);
const share = clamp((value - min) / (max - min), 0, 1);
return LANE_INSET + (1 - share) * Math.max(1, h - 2 * LANE_INSET);
}
/** The value a height of a lane stands for: to a tenth of a dB, or a hundredth of the pan. */
export function laneValue(target: AutomationTarget, y: number, h: number): number {
const { min, max } = targetRange(target);
const share = clamp(1 - (y - LANE_INSET) / Math.max(1, h - 2 * LANE_INSET), 0, 1);
const step = target === 'volume' ? 0.1 : 0.01;
return clamp(Math.round((min + share * (max - min)) / step) * step, min, max);
}
/** The point of a lane under a point of its row, the last drawn first; null for none. */
export function pointAt(
view: SongView,
target: AutomationTarget,
points: readonly AutomationPoint[],
x: number,
y: number,
): number | null {
for (let i = points.length - 1; i >= 0; i--) {
const point = points[i]!;
const dx = tickToX(view, point.tick) - x;
const dy = laneY(target, point.value, view.trackHeight) - y;
if (Math.abs(dx) <= POINT_REACH && Math.abs(dy) <= POINT_REACH) return i;
}
return null;
}
/**
* The way of a lane across `width` pixels, a corner every `every` pixels and at each point – a jump
* shows as an upright –, in the row's own pixels.
*/
export function laneWay(
view: SongView,
target: AutomationTarget,
points: readonly SequenceLanePoint[],
width: number,
every = 2,
): { x: number; y: number }[] {
if (points.length === 0) return [];
const h = view.trackHeight;
const way: { x: number; y: number }[] = [];
const corners = new Set<number>();
for (let x = 0; x <= width; x += every) corners.add(x);
for (const point of points) {
const x = tickToX(view, point.tick);
if (x > 0 && x < width) corners.add(x);
}
for (const x of [...corners].sort((a, b) => a - b)) {
const at = points.findIndex((p) => tickToX(view, p.tick) === x);
if (at < 0) {
way.push({ x, y: laneY(target, laneValueAt(points, xToTick(view, x)), h) });
continue;
}
// On a point the way comes in with the value before it and leaves from the last point on its tick.
const tick = points[at]!.tick;
const before = laneValueBefore(points, tick);
if (before !== points[at]!.value) way.push({ x, y: laneY(target, before, h) });
for (let i = at; i < points.length && points[i]!.tick === tick; i++)
way.push({ x, y: laneY(target, points[i]!.value, h) });
}
return way;
}
/** A value as the head of a lane and the sign over a point say it. */
export function laneValueText(target: AutomationTarget, value: number): string {
if (target === 'volume') {
const { min } = targetRange('volume');
if (value <= min) return '-inf dB';
const shown = Math.round(value * 10) / 10;
return `${shown > 0 ? '+' : ''}${shown.toFixed(1)} dB`;
}
const share = Math.round(Math.abs(value) * 100);
return share === 0 ? 'Centre' : `${share}% ${value < 0 ? 'left' : 'right'}`;
}
+16 -11
View File
@@ -99,17 +99,19 @@ export function itemBox(view: SongView, index: number, item: Pick<SongItem, 'tic
export type ItemPart = 'start' | 'body' | 'end';
/** The item under a point, the last drawn first, and which part of it: an end within reach, else the body. */
/**
* The item under a point, the last drawn first, and which part of it: an end within reach, else the
* body. `rows` is the row of each track by its id (`indexOf` while no lane is open, #337).
*/
export function itemAt(
view: SongView,
items: readonly SongItem[],
tracks: readonly SongTrack[],
rows: ReadonlyMap<string, number>,
p: Vec2,
): { id: string; part: ItemPart } | null {
const index = indexOf(tracks);
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]!;
const row = index.get(item.track);
const row = rows.get(item.track);
if (row === undefined) continue;
const box = itemBox(view, row, item);
if (p.y < box.y || p.y > box.y + box.h || p.x < box.x || p.x > box.x + box.w) continue;
@@ -125,18 +127,17 @@ export function itemAt(
export function itemsWithin(
view: SongView,
items: readonly SongItem[],
tracks: readonly SongTrack[],
rows: ReadonlyMap<string, number>,
a: Vec2,
b: Vec2,
): string[] {
const index = indexOf(tracks);
const x0 = Math.min(a.x, b.x);
const x1 = Math.max(a.x, b.x);
const y0 = Math.min(a.y, b.y);
const y1 = Math.max(a.y, b.y);
return items
.filter((item) => {
const row = index.get(item.track);
const row = rows.get(item.track);
if (row === undefined) return false;
const box = itemBox(view, row, item);
return box.x <= x1 && box.x + box.w >= x0 && box.y <= y1 && box.y + box.h >= y0;
@@ -180,11 +181,15 @@ export function indexOf(tracks: readonly SongTrack[]): Map<string, number> {
return new Map(tracks.map((t, i) => [t.id, i]));
}
/** The parts of a track's head, in its own pixels: name, sound FX (#346), mute, solo, volume and pan. */
export type HeadPart = 'name' | 'fx' | 'mute' | 'solo' | 'volume' | 'pan';
/**
* The parts of a track's head, in its own pixels: the switch that opens its lanes (#337), name, sound
* FX (#346), mute, solo, volume and pan.
*/
export type HeadPart = 'lanes' | 'name' | 'fx' | 'mute' | 'solo' | 'volume' | 'pan';
export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
name: { x: 8, y: 4, w: 88, h: 18 },
lanes: { x: 4, y: 5, w: 14, h: 16 },
name: { x: 22, y: 4, w: 76, h: 18 },
fx: { x: 102, y: 5, w: 22, h: 16 },
mute: { x: 128, y: 5, w: 18, h: 16 },
solo: { x: 150, y: 5, w: 18, h: 16 },
@@ -202,7 +207,7 @@ export function trackMeterBox(trackHeight: number): Box {
export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart {
const sliders = trackHeight >= SLIDERS_MIN_HEIGHT;
for (const part of ['fx', 'mute', 'solo', 'volume', 'pan'] as const) {
for (const part of ['lanes', 'fx', 'mute', 'solo', 'volume', 'pan'] as const) {
if (!sliders && (part === 'volume' || part === 'pan')) continue;
const box = HEAD_BOXES[part];
if (x >= box.x - 2 && x <= box.x + box.w + 2 && yInTrack >= box.y - 3 && yInTrack <= box.y + box.h + 3)
+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);
});
});
+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)');