Files
adminandClaude Opus 5.5 bd99f9e79d 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>
2026-10-19 09:31:48 +02:00

263 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { newItem, type SongItem } from '../../src/core/music/song';
import { secondsOfTicks } from '../../src/core/music/time';
import {
clampMove,
draggedLength,
droppedLength,
duplicateOffset,
moveDelta,
moved,
placedCopies,
roundsOf,
snapEdge,
split,
trimDelta,
trimmed,
} from '../../src/editor/song/songEdit';
import {
clampTracks,
FIT_MARGIN,
fitSong,
HEAD_BOXES,
headPartAt,
itemAt,
heldSongView,
itemsWithin,
loopSeams,
MAX_TRACK_HEIGHT,
MIN_TRACK_HEIGHT,
peakOver,
SLIDERS_MIN_HEIGHT,
sliderValue,
SONG_MAX_PX_PER_TICK,
type SongView,
TRACK_HEIGHT,
trackAtY,
zoomTracks,
} from '../../src/editor/song/songView';
import { MAX_PX_PER_TICK, tickToX, zoomTime } from '../../src/editor/music/timeView';
const BAR = 1920;
const TRACKS = ['t1', 't2', 't3'];
const rows = new Map(TRACKS.map((id, i) => [id, i]));
function item(
id: string,
track: string,
tick: number,
length: number,
extra: Partial<SongItem> = {},
): SongItem {
return { ...newItem({ track, kind: 'pattern', asset: 'p', tick, length }), id, ...extra };
}
describe('moving and sizing items (#334)', () => {
const items = [item('a', 't1', BAR, BAR), item('b', 't2', 3 * BAR, BAR)];
const both = new Set(['a', 'b']);
it('moves the picked items on the grid, across tracks, never before 0 or off the tracks', () => {
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 700, 1, BAR, false)).toEqual({
ticks: 0,
tracks: 1,
});
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 1000, 2, BAR, false)).toEqual({
ticks: BAR,
tracks: 1,
});
expect(moveDelta(items, both, rows, 3, items[0]!, -5000, 0, BAR, false)).toEqual({
ticks: -BAR,
tracks: 0,
});
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 7, 0, BAR, true)).toEqual({
ticks: 7,
tracks: 0,
});
expect(clampMove(items, both, rows, 3, 0, -1)).toEqual({ ticks: 0, tracks: 0 });
expect(moved(items, both, TRACKS, 480, 1).map((i) => [i.tick, i.track])).toEqual([
[BAR + 480, 't2'],
[3 * BAR + 480, 't3'],
]);
});
it('lengthens by the right end to the grid, and never back past its own start', () => {
expect(draggedLength(items[0]!, 3 * BAR + 100, BAR, false)).toBe(2 * BAR);
expect(draggedLength(items[0]!, 0, BAR, false)).toBe(BAR);
expect(draggedLength(items[0]!, BAR + 5, BAR, true)).toBe(5);
});
it('cuts back by the left end what it plays with it, and pulls it out no further than its start', () => {
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0.25 });
const picked = new Set(['s']);
// 0.25 s at 120 BPM is 240 ticks: that is as far back as the left end goes.
expect(trimDelta([sound], picked, sound, 0, 120, false, 120)).toBe(-240);
// Past its own end the left end stops on the last grid line before it.
expect(trimDelta([sound], picked, sound, 5 * BAR, 120, false, 120)).toBe(BAR - 120);
expect(trimDelta([sound], picked, sound, 5 * BAR, 120, true, 120)).toBe(BAR - 1);
const [cut] = trimmed([sound], picked, 480, 120);
expect([cut!.tick, cut!.length, cut!.offset]).toEqual([BAR + 480, BAR - 480, 0.75]);
const pattern = item('p', 't1', BAR, BAR, { offset: 120 });
expect(trimDelta([pattern], new Set(['p']), pattern, 0, 60, false, 120)).toBe(-120);
expect(trimmed([pattern], new Set(['p']), -120, 120)[0]).toMatchObject({ tick: BAR - 120, offset: 0 });
});
it('pulls an item out left past what it plays, which then repeats from its end', () => {
// 0.5 s at 120 BPM is 480 ticks; a pattern a bar long.
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0 });
const pattern = item('p', 't1', BAR, BAR);
const period = (i: SongItem) => (i.kind === 'sound' ? 480 : BAR);
expect(trimDelta([sound], new Set(['s']), sound, 0, 120, false, 120, period)).toBe(-BAR);
expect(trimDelta([sound], new Set(['s']), sound, 0, 120, false, 120)).toBe(0);
const [s] = trimmed([sound], new Set(['s']), -240, 120, period);
expect([s!.tick, s!.length, s!.offset]).toEqual([BAR - 240, BAR + 240, 0.25]);
const [p] = trimmed([pattern], new Set(['p']), -480, 120, period);
expect([p!.tick, p!.length, p!.offset]).toEqual([BAR - 480, BAR + 480, BAR - 480]);
});
it('snaps an end to where what the item plays starts over, as well as to the grid', () => {
// A sound 700.4 ticks long: its rounds end between two ticks.
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0 });
const rounds = roundsOf(sound, 700.4, 120)!;
expect(rounds).toEqual({ base: BAR, period: 700.4 });
expect(snapEdge(BAR + 1390, BAR, rounds)).toBeCloseTo(BAR + 1400.8);
expect(snapEdge(BAR + 1900, BAR, rounds)).toBe(2 * BAR);
expect(snapEdge(BAR + 1390, BAR, null)).toBe(2 * BAR);
// The end is cut to the tick before, so nothing of the next round sounds.
expect(draggedLength(sound, BAR + 1390, BAR, false, rounds)).toBe(1400);
// The start put back on a round plays from the very beginning, not a hair into it.
const cut = item('c', 't1', BAR + 100, 2000, { kind: 'sound', offset: 100 / 960 });
const picked = new Set(['c']);
const by = trimDelta([cut], picked, cut, BAR + 705, 480, false, 120, () => 700.4);
expect(by).toBe(600);
expect(trimmed([cut], picked, by, 120, () => 700.4)[0]!.offset).toBe(secondsOfTicks(700.4, 120));
// A start dragged right up to a round that is its own end does not snap there, it would leave a
// tick: it takes the grid line before.
const two = item('t', 't1', BAR, 1400, { kind: 'sound', offset: 0 });
expect(trimDelta([two], new Set(['t']), two, BAR + 1390, 480, false, 120, () => 700)).toBe(960);
// An offset a whole pattern in stays so: the pattern may grow later.
const far = item('f', 't1', BAR, 2 * BAR);
expect(trimmed([far], new Set(['f']), BAR, 120, () => BAR)[0]!.offset).toBe(BAR);
});
it('splits the picked items the point falls inside, and gives the right-hand parts', () => {
const cut = split(items, new Set(['a', 'b']), BAR + 480, 120);
expect(cut.items.map((i) => [i.track, i.tick, i.length, i.offset])).toEqual([
['t1', BAR, 480, 0],
['t1', BAR + 480, BAR - 480, 480],
['t2', 3 * BAR, BAR, 0],
]);
expect(cut.right).toEqual([cut.items[1]!.id]);
});
it('puts a duplicate right behind the picked items, and a paste on their tracks', () => {
expect(duplicateOffset(items, both, BAR)).toBe(3 * BAR);
const copies = placedCopies(items, 480, ['t2', 't3']);
expect(copies.map((i) => [i.tick, i.track])).toEqual([
[480, 't2'],
[480 + 2 * BAR, 't2'],
]);
expect(copies.every((c) => c.id !== 'a' && c.id !== 'b')).toBe(true);
// Under the pointer: the topmost on its track, as far down as there are tracks.
expect(placedCopies(items, 0, TRACKS, 2).map((i) => i.track)).toEqual(['t2', 't3']);
expect(placedCopies(items, 0, TRACKS, 0).map((i) => i.track)).toEqual(['t1', 't2']);
});
it('makes a dropped pattern as long as it is, a sound as long as it plays at the tempo, else a bar', () => {
expect(droppedLength(null, 3 * BAR, 120, BAR)).toBe(3 * BAR);
expect(droppedLength(1.25, null, 120, BAR)).toBe(1200);
expect(droppedLength(null, null, 120, BAR)).toBe(BAR);
});
});
describe('where things are in the Song tab (#334)', () => {
const view: SongView = { panX: 0, pxPerTick: 0.1, panY: 0, trackHeight: TRACK_HEIGHT };
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, 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, 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',
]);
});
it('scrolls half the height past the first or the last track at most', () => {
expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(40);
expect(clampTracks({ ...view, panY: 900 }, 3, 400).panY).toBe(200);
// Tracks that fit with room to spare are not pushed up off the top.
expect(clampTracks({ ...view, panY: -40 }, 1, 400).panY).toBe(0);
expect(clampTracks({ ...view, panY: -5000 }, 10, 400).panY).toBe(400 - 200 - 10 * TRACK_HEIGHT);
});
it('tells the parts of a head apart and reads a slider from left to right', () => {
const { mute, solo, volume, pan } = HEAD_BOXES;
expect(headPartAt(mute.x + 4, mute.y + 4)).toBe('mute');
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(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);
// A head too low for its sliders has none to hit.
expect(headPartAt(volume.x + 10, volume.y + 4, SLIDERS_MIN_HEIGHT - 1)).toBe('name');
expect(headPartAt(mute.x + 4, mute.y + 4, MIN_TRACK_HEIGHT)).toBe('mute');
});
it('zooms the tracks about the pointer, within their bounds, and fills in an old saved view', () => {
const zoomed = zoomTracks({ ...view, panY: -20 }, 2, 150);
expect(zoomed.trackHeight).toBe(2 * TRACK_HEIGHT);
expect(trackAtY(zoomed, 10, 150)).toBe(trackAtY({ ...view, panY: -20 }, 10, 150));
expect(zoomTracks(view, 100, 0).trackHeight).toBe(MAX_TRACK_HEIGHT);
expect(zoomTracks(view, 0.01, 0).trackHeight).toBe(MIN_TRACK_HEIGHT);
expect(clampTracks({ ...zoomed, panY: -9999 }, 10, 400).panY).toBe(400 - 200 - 10 * 2 * TRACK_HEIGHT);
expect(heldSongView({ panX: -5, pxPerTick: 0.3, panY: -10 })).toEqual({
panX: -5,
pxPerTick: 0.3,
panY: -10,
trackHeight: TRACK_HEIGHT,
});
});
it('fits the song across and its tracks down with room around, and zooms in further than a pattern', () => {
// 4 bars and 4 tracks in 1000 x 500: 90 pixels free left and right, 45 above and below.
const fitted = fitSong(4 * BAR, 4, 1000, 500);
expect(fitted.panX).toBeCloseTo(1000 * FIT_MARGIN);
expect(fitted.panY).toBeCloseTo(500 * FIT_MARGIN);
expect(tickToX(fitted, 4 * BAR)).toBeCloseTo(1000 * (1 - FIT_MARGIN));
expect(fitted.panY + 4 * fitted.trackHeight).toBeCloseTo(500 * (1 - FIT_MARGIN));
// A track keeps the heights it may have, however many or few there are.
expect(fitSong(BAR, 1, 1000, 2000).trackHeight).toBe(MAX_TRACK_HEIGHT);
expect(fitSong(BAR, 60, 1000, 500).trackHeight).toBe(MIN_TRACK_HEIGHT);
// The wheel goes past where a pattern stops, up to the song's own bound.
const deep = zoomTime({ panX: 0, pxPerTick: 1 }, 100, 0, SONG_MAX_PX_PER_TICK);
expect(SONG_MAX_PX_PER_TICK).toBeGreaterThan(MAX_PX_PER_TICK);
expect(deep.pxPerTick).toBe(SONG_MAX_PX_PER_TICK);
expect(fitSong(1, 1, 1000, 500).pxPerTick).toBe(SONG_MAX_PX_PER_TICK);
});
it('marks where an item starts over inside it, from as far in as it starts', () => {
expect(loopSeams({ tick: BAR, length: 3 * BAR }, BAR, 0)).toEqual([2 * BAR, 3 * BAR]);
expect(loopSeams({ tick: BAR, length: 2 * BAR }, BAR, 480)).toEqual([2 * BAR - 480, 3 * BAR - 480]);
expect(loopSeams({ tick: 0, length: BAR }, BAR, 0)).toEqual([]);
expect(loopSeams({ tick: 0, length: 1000 }, 400.5, 0)).toEqual([400.5, 801]);
expect(loopSeams({ tick: 0, length: BAR }, 0, 0)).toEqual([]);
});
it('draws a waveform column as the loudest of every bucket it covers', () => {
const peaks = [0.1, 0.9, 0.2, 0.3];
expect(peakOver(peaks, 0, 0.25)).toBe(0.1);
expect(peakOver(peaks, 0.2, 0.3)).toBe(0.9);
expect(peakOver(peaks, 0.5, 1)).toBe(0.3);
expect(peakOver(peaks, 0.6, 0.61)).toBe(0.2);
expect(peakOver(peaks, 0.99, 1.2)).toBe(0.3);
});
});