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)