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
@@ -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 }),
|
||||
|
||||
+596
-62
File diff suppressed because it is too large.
Load diff
@@ -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)),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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