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)
|
||||
|
||||
Reference in new issue
Block a user