Stage 3 of #337. Lanes moved volume and pan only. Now a lane can steer a parameter of anything on its track, and a voice already planned or still waiting for its file follows it too. - A lane may aim at param:<name>. A parameter has no range of its own, so the lane keeps min and max. A parameter lane stays without points, so its range is not lost. Remove lane takes it away. - It reaches the track FX, the FX of an item, the instrument of a note with its own FX, and the FX of a clip. When several have the name, all of them change, as with Set sound FX parameters. - The scheduler hands the lane value to a note or clip at its start. Values a voice only reads at its start act from the next note. - While a voice sounds, the knobs of buses and voices of the track follow the lane on the audio clock, read every 10 ms, because a knob is not always the parameter itself (trackParams.ts). - A voice waiting for its file takes the marks when it starts. - When a lane is removed, the parameter goes back to its default. An edit while playing does not reset a parameter a lane rules. - This holds in the song tab, for Play song, in compositions and in the bake. - Song tab: Add parameter lane in the track head menu opens a dialog for parameter and range (ParamLaneDialog.ts). The lane head menu has Range and Remove lane. - Inspector: while a lane rules a value, a hint at the track names the lanes and says the fields act once the lane is gone. Known limit: a knob that depends on two automated parameters reckons each lane with the last value of the other, so it may lag one window. Tests: - automation-params.test.ts (9, new), 3 new in sound-library and 3 in song-lanes. Affected files 407/407. - 17 counter checks, all compile, all red. - e2e 65 on melektaus-atx: ALL PASSED, 67 checks. 65h3 is new: a clip with FX parameter cut starts at the lane value and its knob stays within 0.04 dB of the lane. It goes red when automateBus does not pass parameters on. - Gate on melektaus-atx: CHECK EXIT 0, 330 files, 5080 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
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,
|
|
withoutLane,
|
|
withoutPoint,
|
|
withParamLane,
|
|
} from '../../src/editor/song/laneEdit';
|
|
import {
|
|
LANE_INSET,
|
|
laneKindOf,
|
|
laneLabel,
|
|
laneStep,
|
|
laneValue,
|
|
laneValueText,
|
|
laneWay,
|
|
laneY,
|
|
pointAt,
|
|
shownLanes,
|
|
songRows,
|
|
trackLanes,
|
|
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 lane of a parameter (#337)', () => {
|
|
const cut = { target: 'param:cut' as const, min: 0, max: 2000 };
|
|
const withCut = track('a', [
|
|
{ target: 'volume', points: [{ tick: 0, value: -6 }] },
|
|
{ ...cut, points: [] },
|
|
]);
|
|
|
|
it('is shown by its name after the volume and the pan, with no point too: it was added', () => {
|
|
expect(laneLabel('param:cut')).toBe('cut');
|
|
expect(trackLanes(withCut)).toEqual(['volume', 'pan', 'param:cut']);
|
|
expect(shownLanes(withCut, new Map())).toEqual(['volume', 'param:cut']);
|
|
expect(laneKindOf(withCut, 'param:cut')).toEqual(cut);
|
|
expect(laneKindOf(withCut, 'pan')).toEqual({ target: 'pan' });
|
|
});
|
|
|
|
it('draws and reads its values in its own range, to a step that cuts it into a hundred or more', () => {
|
|
expect(laneY(cut, 0, 52)).toBe(52 - LANE_INSET);
|
|
expect(laneY(cut, 2000, 52)).toBe(LANE_INSET);
|
|
expect(laneStep(cut)).toBe(10);
|
|
expect(laneStep({ target: 'param:mix' })).toBe(0.01);
|
|
expect(laneValue(cut, laneY(cut, 1234, 52), 52)).toBe(1230);
|
|
expect(laneValue({ target: 'param:mix' }, 26, 52)).toBe(0.5);
|
|
expect(laneValueText(cut, 1234)).toBe('1230');
|
|
expect(laneValueText({ target: 'param:mix' }, 0.123)).toBe('0.12');
|
|
expect(addedPoint([], cut, 0, 5000).points).toEqual([{ tick: 0, value: 2000 }]);
|
|
});
|
|
|
|
it('keeps its lane and range without a point, takes its points into a new range, and goes only when removed', () => {
|
|
const a = withLanePoints(withCut, 'param:cut', [{ tick: 0, value: 1500 }]);
|
|
expect(a.automation?.map((l) => l.target)).toEqual(['volume', 'param:cut']);
|
|
const emptied = withLanePoints(a, 'param:cut', []);
|
|
expect(emptied.automation?.[1]).toEqual({ ...cut, points: [] });
|
|
const narrow = withParamLane(a, 'cut', { min: 0, max: 1000 });
|
|
expect(narrow.automation?.[1]).toEqual({ ...cut, max: 1000, points: [{ tick: 0, value: 1000 }] });
|
|
// A new one comes last; a later volume lane still goes first.
|
|
const more = withLanePoints(withParamLane(narrow, 'tone', { min: 0, max: 1 }), 'pan', [
|
|
{ tick: 0, value: 0 },
|
|
]);
|
|
expect(more.automation?.map((l) => l.target)).toEqual(['volume', 'pan', 'param:cut', 'param:tone']);
|
|
expect(withoutLane(more, 'param:cut').automation?.map((l) => l.target)).toEqual([
|
|
'volume',
|
|
'pan',
|
|
'param:tone',
|
|
]);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|