diff --git a/src/editor/debug/songHooks.ts b/src/editor/debug/songHooks.ts index 5d08bdf5..ebc5d471 100644 --- a/src/editor/debug/songHooks.ts +++ b/src/editor/debug/songHooks.ts @@ -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 }), diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 6bbd8c8c..5c4a498c 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -1,6 +1,15 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands'; import { CompoundCommand } from '../../core/commands/CompoundCommand'; +import { + AUTOMATION_TARGETS, + type AutomationPoint, + type AutomationTarget, + laneOf, + laneValueAt, + type SequenceLanePoint, + sequenceLaneOf, +} from '../../core/music/automation'; import { SetLoopCommand } from '../../core/commands/patternCommands'; import { SetSongCommand, type SongParts } from '../../core/commands/songCommands'; import { @@ -32,7 +41,8 @@ import { TRACK_MIN_DB, } from '../../core/music/song'; import { ticksOfSeconds, ticksPerBar } from '../../core/music/time'; -import type { Asset, SongAsset } from '../../core/model/Asset'; +import type { Asset, KeyInterpolation, SongAsset } from '../../core/model/Asset'; +import { curveLookup } from '../../core/model/curve'; import type { Vec2 } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; import { findSound } from '../../core/model/sound'; @@ -48,6 +58,8 @@ import { TextPool, textStyle, Theme, + tooltips, + type TooltipTarget, truncate, ui, Widget, @@ -71,6 +83,20 @@ import { import { snapDown, snapNear } from '../pattern/rollEdit'; import type { EditorState } from '../state'; import { axisTween } from '../viewTween'; +import { easingEntries } from './easingMenu'; +import { addedPoint, draggedPoint, easedPoint, lanePoints, withLanePoints, withoutPoint } from './laneEdit'; +import { + LANE_LABELS, + laneValue, + laneValueText, + laneWay, + laneY, + pointAt, + shownLanes, + type SongRow, + songRows, + trackRows, +} from './laneView'; import { drawMeter } from './meterDraw'; import { gainToDb, @@ -141,6 +167,8 @@ const TAKE_COLOR = 0xb33a3a; const TAKE_TRACK = '__take'; const SOLO_COLOR = 0xc9c23a; const FX_COLOR = 0x8fb8de; +/** The way and the points of an automation lane (#337), and a fader its lane rules. */ +const AUTOMATION_COLOR = 0xe06c9f; /** Height of an item's name strip; what it plays is drawn below. */ const LABEL_STRIP = 15; /** The letter on a lit mute, solo or FX button. */ @@ -189,7 +217,28 @@ type Gesture = } | { kind: 'resize'; anchor: SongItem; by: number; shift: ShiftPress } | { kind: 'trim'; anchor: SongItem; by: number; shift: ShiftPress } - | { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number }; + | { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number } + | { + /** A point of a lane (#337) dragged, or added by the press and dragged on; `points` as it would leave them. */ + kind: 'point'; + track: string; + target: AutomationTarget; + index: number; + base: AutomationPoint[]; + points: AutomationPoint[]; + /** From the pointer to the point, so the point does not jump to where it was taken. */ + offset: Vec2; + pointer: Vec2; + moved: boolean; + added: boolean; + }; + +/** A point of a lane, by its track, its lane and its place among the lane's points. */ +interface PointRef { + track: string; + target: AutomationTarget; + index: number; +} /** * A take being recorded (#342): the track it gets at `index`, where it starts, how far it got, and @@ -226,6 +275,8 @@ export class SongTimeline extends Widget { private readonly bg = new Graphics(); private readonly lanesLayer = new Container(); private readonly lanesG = new Graphics(); + /** The way and the points of each open automation lane (#337). */ + private readonly automationG = new Graphics(); private readonly itemsG = new Graphics(); private readonly overlay = new Graphics(); private readonly itemLabels = new Container(); @@ -255,6 +306,10 @@ export class SongTimeline extends Widget { private drop: Drop | null = null; /** The part of an item under the pointer while nothing is held. */ private hover: { id: string; part: ItemPart } | null = null; + /** The lanes each track was opened to or closed on by hand (#337); kept with the view, not in the song. */ + private open = new Map(); + /** The point of a lane picked last: Delete takes it away. */ + private pickedPoint: PointRef | null = null; /** Where the pointer is over this widget, in its own pixels; null while it is elsewhere. */ private pointer: Vec2 | null = null; /** Where playing starts and a paste lands, in ticks. */ @@ -288,7 +343,7 @@ export class SongTimeline extends Widget { constructor(private readonly state: EditorState) { super(); this.focusable = true; - this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay); + this.lanesLayer.addChild(this.lanesG, this.automationG, this.itemsG, this.itemLabels, this.overlay); this.headsLayer.addChild(this.headsG, this.headLabels); this.addChild( this.bg, @@ -315,7 +370,10 @@ export class SongTimeline extends Widget { const p = this.toLocal(e.global); this.pointer = { x: p.x, y: p.y }; }); - this.on('pointerleave', () => (this.pointer = null)); + this.on('pointerleave', () => { + this.pointer = null; + tooltips.leave(this); + }); this.onPointerRelease(() => this.onPointerUp()); this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); state.on('project', () => this.invalidate()); @@ -344,18 +402,27 @@ export class SongTimeline extends Widget { this.drop = null; this.assetId = assetId; this.startTick = 0; + this.open = new Map(); + this.pickedPoint = null; this.needsFit = true; this.redraw(); } - saveView(): SongView { + saveView(): SongView & { lanes: Record } { this.timeTween.land(); this.trackTween.land(); - return { ...this.view }; + return { ...this.view, lanes: Object.fromEntries(this.open) }; } restoreView(view: unknown): void { this.view = heldSongView(view as Partial); + const lanes = (view as { lanes?: unknown } | null)?.lanes; + this.open = new Map( + Object.entries(typeof lanes === 'object' && lanes !== null ? lanes : {}).map(([id, targets]) => [ + id, + AUTOMATION_TARGETS.filter((t) => Array.isArray(targets) && targets.includes(t)), + ]), + ); this.needsFit = false; this.redraw(); } @@ -373,7 +440,7 @@ export class SongTimeline extends Widget { return; } this.needsFit = false; - const count = this.shownTracks(asset).length; + const count = this.rows(asset).length; this.view = fitSong(songLength(asset), count, this.lanesWidth, this.lanesHeight); this.redraw(); } @@ -397,6 +464,7 @@ export class SongTimeline extends Widget { pick(itemIds: readonly string[], trackId?: string): void { const asset = this.asset(); if (!asset) return; + this.pickedPoint = null; const last = asset.items.find((i) => i.id === itemIds.at(-1)); const track = trackId ?? last?.track ?? (this.pickedTrack || asset.tracks[0]?.id || ''); this.state.select({ kind: 'song', assetId: asset.id, trackId: track, itemIds: [...itemIds] }); @@ -548,9 +616,15 @@ export class SongTimeline extends Widget { if (mod) return false; // Taken even with nothing picked: a Delete must not go on to ask about the song itself. if (event.key === 'Delete' || event.key === 'Backspace') { + if (this.deletePoint()) return true; if (!this.deleteSelection() && this.pickedTrack) removeTrack(this.state, asset.id, this.pickedTrack); return true; } + if (event.key === 'Escape' && this.pickedPoint) { + this.pickedPoint = null; + this.invalidate(); + return true; + } if (event.key === 'Escape' && this.pickedIds.length > 0) { this.pick([]); return true; @@ -571,7 +645,42 @@ export class SongTimeline extends Widget { const at = this.getGlobalPosition(); return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), - y: at.y + RULER_HEIGHT + trackTop(this.view, track) + this.view.trackHeight / 2, + y: at.y + RULER_HEIGHT + trackTop(this.view, this.rowOfIndex(track)) + this.view.trackHeight / 2, + }; + } + + /** Where a value at a tick is drawn on a track's automation lane (#337), on screen; null while it is closed. */ + automationPoint(track: number, target: AutomationTarget, tick: number, value: number): Vec2 | null { + const asset = this.asset(); + const row = asset ? this.rows(asset).findIndex((r) => r.track === track && r.lane === target) : -1; + if (row < 0) return null; + const at = this.getGlobalPosition(); + return { + x: at.x + HEAD_WIDTH + tickToX(this.view, tick), + y: at.y + RULER_HEIGHT + trackTop(this.view, row) + laneY(target, value, this.view.trackHeight), + }; + } + + /** The middle of the head of a track's automation lane, on screen; null while it is closed. */ + automationHeadPoint(track: number, target: AutomationTarget): Vec2 | null { + const asset = this.asset(); + const row = asset ? this.rows(asset).findIndex((r) => r.track === track && r.lane === target) : -1; + if (row < 0) return null; + const at = this.getGlobalPosition(); + return { + x: at.x + HEAD_WIDTH / 2, + y: at.y + RULER_HEIGHT + trackTop(this.view, row) + this.view.trackHeight / 2, + }; + } + + /** The rows as shown, top down: each track and the lanes open under it, and the lane point picked. */ + automationShown(): { rows: { track: string; lane: AutomationTarget | null }[]; picked: PointRef | null } { + const asset = this.asset(); + if (!asset) return { rows: [], picked: null }; + const tracks = this.shownTracks(asset); + return { + rows: this.rows(asset).map((r) => ({ track: tracks[r.track]!.id, lane: r.lane })), + picked: this.livePoint(asset), }; } @@ -579,7 +688,7 @@ export class SongTimeline extends Widget { itemPoint(itemId: string, part: 'start' | 'body' | 'end' = 'body'): Vec2 | null { const asset = this.asset(); const item = asset?.items.find((i) => i.id === itemId); - const row = item && indexOf(asset!.tracks).get(item.track); + const row = item && this.rowsOfTracks(asset!).get(item.track); if (!item || row === undefined) return null; const box = itemBox(this.view, row, item); const at = this.getGlobalPosition(); @@ -642,7 +751,7 @@ export class SongTimeline extends Widget { const box = trackMeterBox(this.view.trackHeight); return { x: at.x + box.x + box.w / 2, - y: at.y + RULER_HEIGHT + trackTop(this.view, track) + box.y + box.h / 2, + y: at.y + RULER_HEIGHT + trackTop(this.view, this.rowOfIndex(track)) + box.y + box.h / 2, }; } @@ -653,7 +762,7 @@ export class SongTimeline extends Widget { const slider = part === 'volume' || part === 'pan'; return { x: at.x + box.x + (slider ? box.w * share : box.w / 2), - y: at.y + RULER_HEIGHT + trackTop(this.view, track) + box.y + box.h / 2, + y: at.y + RULER_HEIGHT + trackTop(this.view, this.rowOfIndex(track)) + box.y + box.h / 2, }; } @@ -701,7 +810,7 @@ export class SongTimeline extends Widget { }[] { const asset = this.asset(); if (!asset) return []; - const rows = indexOf(asset.tracks); + const rows = this.rowsOfTracks(asset); const at = this.getGlobalPosition(); return this.shownItems(asset).flatMap((item) => { const row = rows.get(item.track); @@ -733,6 +842,108 @@ export class SongTimeline extends Widget { return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'song') ?? null) : null; } + /** The rows down the lanes: each shown track and its open automation lanes (#337). */ + private rows(asset: SongAsset): SongRow[] { + return songRows(this.shownTracks(asset), this.open); + } + + /** The row of each shown track, by its id. */ + private rowsOfTracks(asset: SongAsset): Map { + const tracks = this.shownTracks(asset); + return trackRows(tracks, songRows(tracks, this.open)); + } + + /** The row of the shown track at `index`. */ + private rowOfIndex(index: number): number { + const asset = this.asset(); + const row = asset ? this.rows(asset).findIndex((r) => r.track === index && r.lane === null) : -1; + return row < 0 ? index : row; + } + + /** The index of the track whose own row a height falls on; null on a lane or past the last. */ + private trackAt(asset: SongAsset, y: number): number | null { + const rows = this.rows(asset); + const at = trackAtY(this.view, rows.length, y); + const row = at === null ? undefined : rows[at]; + return row && row.lane === null ? row.track : null; + } + + /** The index of the track a height is nearest to, a lane counting as its track: where a dragged item goes. */ + private trackNear(asset: SongAsset, y: number): number { + const rows = this.rows(asset); + return rows[nearestTrack(this.view, rows.length, y)]?.track ?? 0; + } + + /** The automation lane a height falls on, with its track and the top of its row. */ + private laneAt( + asset: SongAsset, + y: number, + ): { track: SongTrack; target: AutomationTarget; top: number } | null { + const rows = this.rows(asset); + const at = trackAtY(this.view, rows.length, y); + const row = at === null ? undefined : rows[at]; + const track = row?.lane ? this.shownTracks(asset)[row.track] : undefined; + return track && row?.lane ? { track, target: row.lane, top: trackTop(this.view, at!) } : null; + } + + /** The point picked, while it is still there and its track is what is picked. */ + private livePoint(asset: SongAsset): PointRef | null { + const ref = this.pickedPoint; + if (!ref || this.pickedTrack !== ref.track || this.pickedIds.length > 0) return null; + const track = asset.tracks.find((t) => t.id === ref.track); + return track && ref.index < lanePoints(track, ref.target).length ? ref : null; + } + + /** A track's lanes as they are shown now, held so: a lane edited by hand stays open when it loses its points. */ + private keepOpen(track: SongTrack): void { + if (!this.open.has(track.id)) this.open.set(track.id, shownLanes(track, this.open)); + } + + private setOpen(trackId: string, targets: readonly AutomationTarget[]): void { + this.open.set(trackId, [...targets]); + const asset = this.asset(); + if (asset) this.view = clampTracks(this.view, this.rows(asset).length, this.lanesHeight); + this.redraw(); + } + + /** Opens all the lanes of a track, or closes them while any is open. */ + toggleLanes(trackId: string): void { + const track = this.asset()?.tracks.find((t) => t.id === trackId); + if (track) this.setOpen(trackId, shownLanes(track, this.open).length > 0 ? [] : AUTOMATION_TARGETS); + } + + /** Changes the points of a lane of a track as the song holds them now, as one step to undo. */ + private editLane( + trackId: string, + target: AutomationTarget, + change: (points: AutomationPoint[]) => AutomationPoint[], + label: string, + ): void { + const asset = this.asset(); + if (!asset) return; + const tracks = asset.tracks.map((t) => + t.id === trackId ? withLanePoints(t, target, change(lanePoints(t, target))) : t, + ); + this.commit(asset, { tracks }, label); + } + + /** Takes the picked point of a lane away; false with none picked. */ + deletePoint(): boolean { + const asset = this.asset(); + const ref = asset ? this.livePoint(asset) : null; + const track = ref ? asset?.tracks.find((t) => t.id === ref.track) : undefined; + if (!ref || !track) return false; + this.keepOpen(track); + this.pickedPoint = null; + this.editLane( + ref.track, + ref.target, + (points) => withoutPoint(points, ref.index), + 'Delete automation point', + ); + return true; + } + private areaAt(p: Vec2): Area { const left = p.x < HEAD_WIDTH; if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler'; @@ -771,7 +982,9 @@ export class SongTimeline extends Widget { } if (e.button === 2) { if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global); - if (area === 'lanes') this.openItemMenu(asset, g, e.global); + const lane = area === 'lanes' ? this.laneAt(asset, g.y) : null; + if (lane) this.openLaneMenu(lane, g, e.global); + else if (area === 'lanes') this.openItemMenu(asset, g, e.global); this.invalidate(); return; } @@ -798,9 +1011,19 @@ export class SongTimeline extends Widget { } private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void { - const row = trackAtY(this.view, asset.tracks.length, y); - if (row === null) return; + const rows = this.rows(asset); + const at = trackAtY(this.view, rows.length, y); + if (at === null) return; + const { track: row, lane } = rows[at]!; const track = asset.tracks[row]!; + if (lane) { + // The head of a lane: its menu, or a click that picks its track. + if (e.button === 2) + this.openLaneMenu({ track, target: lane, top: trackTop(this.view, at) }, null, e.global); + else if (e.button === 0) this.pick([], track.id); + this.invalidate(); + return; + } if (e.button === 2) { this.openTrackMenu(asset, row, e.global); return; @@ -817,8 +1040,9 @@ export class SongTimeline extends Widget { this.invalidate(); return; } - const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight); - if (part === 'fx') this.openTrackFxMenu(asset, track, e.global); + const part = headPartAt(x, y - trackTop(this.view, at), this.view.trackHeight); + if (part === 'lanes') this.toggleLanes(track.id); + else if (part === 'fx') this.openTrackFxMenu(asset, track, e.global); else if (part === 'mute' || part === 'solo') { const label = part === 'mute' @@ -833,6 +1057,11 @@ export class SongTimeline extends Widget { { tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: !t[part] } : t)) }, label, ); + } else if ((part === 'volume' || part === 'pan') && laneOf(track, part)) { + // Decision 4 of #337: a lane with points rules the value, the fader only shows it. + this.state.setNotice( + `${LANE_LABELS[part]} follows its automation lane – clear the lane to set it here`, + ); } else if ( (part === 'volume' || part === 'pan') && pressCount(e, `song-slider:${track.id}:${part}`) === 2 @@ -860,7 +1089,10 @@ export class SongTimeline extends Widget { } private pressLanes(asset: SongAsset, e: FederatedPointerEvent, g: Vec2): void { - const hit = itemAt(this.view, asset.items, asset.tracks, g); + const lane = this.laneAt(asset, g.y); + if (lane) return this.pressLane(asset, e, lane, g); + this.pickedPoint = null; + const hit = itemAt(this.view, asset.items, this.rowsOfTracks(asset), g); const anchor = hit ? asset.items.find((i) => i.id === hit.id) : undefined; if (!hit || !anchor) { this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey }; @@ -884,6 +1116,57 @@ export class SongTimeline extends Widget { } } + /** + * A press on an automation lane (#337): on a point it picks it to drag, a second one takes it away; + * anywhere else it adds a point there – on the grid, with Alt or Shift on the tick – to drag on. + */ + private pressLane( + asset: SongAsset, + e: FederatedPointerEvent, + lane: { track: SongTrack; target: AutomationTarget; top: number }, + g: Vec2, + ): void { + const { track, target, top } = lane; + this.keepOpen(track); + const points = lanePoints(track, target); + const y = g.y - top; + const hit = pointAt(this.view, target, points, g.x, y); + if (hit !== null && pressCount(e, `song-point:${track.id}:${target}:${hit}`) === 2) { + this.pickedPoint = null; + this.editLane(track.id, target, (now) => withoutPoint(now, hit), 'Delete automation point'); + return; + } + if (this.pickedTrack !== track.id || this.pickedIds.length > 0) this.pick([], track.id); + let base = points; + let index = hit; + if (index === null) { + const tick = this.cursorTick(xToTick(this.view, g.x), songGrid(asset.props), e.altKey || e.shiftKey); + ({ points: base, index } = addedPoint( + points, + target, + tick, + laneValue(target, y, this.view.trackHeight), + )); + } + const point = base[index]!; + this.pickedPoint = { track: track.id, target, index }; + this.gesture = { + kind: 'point', + track: track.id, + target, + index, + base, + points: base, + offset: { + x: tickToX(this.view, point.tick) - g.x, + y: laneY(target, point.value, this.view.trackHeight) - y, + }, + pointer: g, + moved: false, + added: hit === null, + }; + } + /** A click with Shift that changed nothing takes a picked item out of the picked ones. */ private unpick(anchor: SongItem, shift: ShiftPress): void { if (shift === 'unpick') this.pick(this.pickedIds.filter((id) => id !== anchor.id)); @@ -901,7 +1184,7 @@ export class SongTimeline extends Widget { const gesture = this.gesture; if (!asset) return; if (!gesture) { - if (!this.take) this.followHover(asset, local, g); + if (!this.take) this.followHover(asset, local, g, e.global); return; } const tick = xToTick(this.view, g.x); @@ -921,7 +1204,7 @@ export class SongTimeline extends Widget { }, this.lanesWidth, ), - asset.tracks.length, + this.rows(asset).length, this.lanesHeight, ); }), @@ -948,7 +1231,7 @@ export class SongTimeline extends Widget { if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD) return; gesture.moved = true; - const row = nearestTrack(this.view, asset.tracks.length, g.y); + const row = this.trackNear(asset, g.y); const by = moveDelta( asset.items, picked, @@ -980,6 +1263,24 @@ export class SongTimeline extends Widget { gesture.value = this.sliderAt(gesture.part, local.x); this.onTracksPreview(this.shownTracks(asset)); break; + case 'point': { + if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD) + return; + gesture.moved = true; + const row = this.rows(asset).findIndex( + (r) => r.lane === gesture.target && asset.tracks[r.track]?.id === gesture.track, + ); + if (row < 0) break; + const y = g.y + gesture.offset.y - trackTop(this.view, row); + gesture.points = draggedPoint( + gesture.base, + gesture.target, + gesture.index, + this.cursorTick(xToTick(this.view, g.x + gesture.offset.x), grid, free), + laneValue(gesture.target, y, this.view.trackHeight), + ); + break; + } } this.invalidate(); } @@ -1011,7 +1312,7 @@ export class SongTimeline extends Widget { break; case 'empty': { const caught = gesture.active - ? itemsWithin(this.view, asset.items, asset.tracks, gesture.start, gesture.current) + ? itemsWithin(this.view, asset.items, this.rowsOfTracks(asset), gesture.start, gesture.current) : []; const kept = gesture.add ? this.pickedIds : []; if (caught.length > 0 || kept.length > 0) this.pick([...new Set([...kept, ...caught])]); @@ -1062,6 +1363,17 @@ export class SongTimeline extends Widget { this.onTracksPreview(this.asset()?.tracks ?? tracks); break; } + case 'point': { + const changed = JSON.stringify(gesture.points) !== JSON.stringify(gesture.base); + if (gesture.added || (gesture.moved && changed)) + this.editLane( + gesture.track, + gesture.target, + () => gesture.points, + gesture.added ? 'Add automation point' : 'Move automation point', + ); + break; + } } this.invalidate(); } @@ -1080,7 +1392,7 @@ export class SongTimeline extends Widget { local.y >= RULER_HEIGHT && local.y <= this.h && xToTick(this.view, g.x) >= 0; - const row = trackAtY(this.view, asset.tracks.length, g.y); + const row = this.trackAt(asset, g.y); if (!inside || row === null || !(pattern || sound)) { if (this.drop) { this.drop = null; @@ -1103,11 +1415,35 @@ export class SongTimeline extends Widget { this.invalidate(); } - /** The part of an item under the pointer: an end it can be dragged by shows a resize cursor and lights up. */ - private followHover(asset: SongAsset, local: Vec2, g: Vec2): void { + /** + * The part of an item under the pointer: an end it can be dragged by shows a resize cursor and lights + * up. A point of a lane (#337) says its value. + */ + private followHover(asset: SongAsset, local: Vec2, g: Vec2, global: Vec2): void { const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h; - const hit = inside ? itemAt(this.view, asset.items, asset.tracks, g) : null; - const cursor = hit && hit.part !== 'body' ? 'ew-resize' : 'default'; + const lane = inside ? this.laneAt(asset, g.y) : null; + const point = + lane && pointAt(this.view, lane.target, lanePoints(lane.track, lane.target), g.x, g.y - lane.top); + if (lane && point !== null) { + const { track, target } = lane; + tooltips.point( + this, + `point:${track.id}:${target}:${point}`, + () => this.pointTooltip(track.id, target, point), + { + x: global.x, + y: global.y, + }, + ); + } else tooltips.leave(this); + const hit = inside && !lane ? itemAt(this.view, asset.items, this.rowsOfTracks(asset), g) : null; + const cursor = lane + ? point !== null + ? 'move' + : 'crosshair' + : hit && hit.part !== 'body' + ? 'ew-resize' + : 'default'; if (this.cursor !== cursor) this.cursor = cursor; if (JSON.stringify(hit) === JSON.stringify(this.hover)) return; this.hover = hit; @@ -1122,10 +1458,7 @@ export class SongTimeline extends Widget { const tick = xToTick(this.view, g.x); // Left of tick 0 nothing lands, as nothing is dropped there. if (tick < 0) return null; - return { - tick: snapDown(tick, songGrid(asset.props)), - row: nearestTrack(this.view, asset.tracks.length, g.y), - }; + return { tick: snapDown(tick, songGrid(asset.props)), row: this.trackNear(asset, g.y) }; } /** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt or Shift on the tick. */ @@ -1162,7 +1495,7 @@ export class SongTimeline extends Widget { /** The menu of the item under a right-click, picked first unless it is already; off one, of the spot. */ private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void { - const at = itemAt(this.view, asset.items, asset.tracks, g); + const at = itemAt(this.view, asset.items, this.rowsOfTracks(asset), g); const hit = at && asset.items.find((i) => i.id === at.id); if (!hit) return this.openSpotMenu(asset, g, global); if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]); @@ -1183,7 +1516,7 @@ export class SongTimeline extends Widget { /** The menu of an empty spot on a track. */ private openSpotMenu(asset: SongAsset, g: Vec2, global: Vec2): void { - const row = trackAtY(this.view, asset.tracks.length, g.y); + const row = this.trackAt(asset, g.y); const tick = xToTick(this.view, g.x); if (row === null || tick < 0) return; new Menu([{ label: 'Create pattern', onSelect: () => this.createPattern(asset, row, tick) }]).open( @@ -1192,6 +1525,73 @@ export class SongTimeline extends Widget { ); } + /** + * The menu of an automation lane (#337), from its head or its way (`g` set): on a point, how it eases + * to the next and taking it away; else clearing the lane, so the fader rules again, and hiding it. + */ + private openLaneMenu( + lane: { track: SongTrack; target: AutomationTarget; top: number }, + g: Vec2 | null, + global: Vec2, + ): void { + const { track, target } = lane; + const points = lanePoints(track, target); + const index = g ? pointAt(this.view, target, points, g.x, g.y - lane.top) : null; + if (this.pickedTrack !== track.id || this.pickedIds.length > 0) this.pick([], track.id); + if (index !== null) { + this.pickedPoint = { track: track.id, target, index }; + const ease = (value: KeyInterpolation) => + this.editLane(track.id, target, (now) => easedPoint(now, index, value), 'Set automation easing'); + new Menu([ + { + label: 'Ease to next point', + disabled: index === points.length - 1, + submenu: easingEntries(this.state.project, points[index]!.ease ?? 'linear', ease), + }, + { separator: true }, + { label: 'Delete point', danger: true, onSelect: () => this.deletePoint() }, + ]).open(global.x, global.y); + this.invalidate(); + return; + } + new Menu([ + { + label: 'Clear lane', + danger: true, + disabled: points.length === 0, + onSelect: () => { + this.keepOpen(track); + this.editLane(track.id, target, () => [], 'Clear automation lane'); + }, + }, + { + label: 'Hide lane', + onSelect: () => + this.setOpen( + track.id, + shownLanes(track, this.open).filter((t) => t !== target), + ), + }, + ]).open(global.x, global.y); + } + + /** What the sign over a point of a lane says: its value, as the song holds it at the time. */ + private pointTooltip(trackId: string, target: AutomationTarget, index: number): TooltipTarget | null { + const asset = this.asset(); + const tracks = asset ? this.shownTracks(asset) : []; + const track = tracks.find((t) => t.id === trackId); + const point = track && lanePoints(track, target)[index]; + const row = asset + ? this.rows(asset).findIndex((r) => r.lane === target && tracks[r.track]?.id === trackId) + : -1; + if (!point || row < 0) return null; + const at = this.getGlobalPosition(); + const x = at.x + HEAD_WIDTH + tickToX(this.view, point.tick); + const y = + at.y + RULER_HEIGHT + trackTop(this.view, row) + laneY(target, point.value, this.view.trackHeight); + return { text: laneValueText(target, point.value), anchor: { x: x - 4, y: y - 4, w: 8, h: 8 } }; + } + /** * A pattern the song keeps to itself (#355), a bar long on the song's clock, on an item from the * grid line before `tick` on; opened right away. It plays the instrument of the track's pattern @@ -1267,6 +1667,24 @@ export class SongTimeline extends Widget { { label: 'Move up', disabled: row === 0, onSelect: () => swap(row - 1) }, { label: 'Move down', disabled: row === asset.tracks.length - 1, onSelect: () => swap(row + 1) }, { separator: true }, + // Which automation lanes (#337) show under the track. + ...AUTOMATION_TARGETS.map((target) => ({ + label: `${LANE_LABELS[target]} lane`, + checked: () => + shownLanes(this.asset()?.tracks.find((t) => t.id === track.id) ?? track, this.open).includes( + target, + ), + keepOpen: true as const, + onSelect: () => { + const now = this.asset()?.tracks.find((t) => t.id === track.id) ?? track; + const shown = shownLanes(now, this.open); + this.setOpen( + track.id, + shown.includes(target) ? shown.filter((t) => t !== target) : [...shown, target], + ); + }, + })), + { separator: true }, { label: 'Delete track', danger: true, @@ -1301,7 +1719,7 @@ export class SongTimeline extends Widget { const view = tracks ? zoomTracks(this.view, factor, g.y) : { ...this.view, ...zoomTime(this.view, factor, g.x, SONG_MAX_PX_PER_TICK) }; - this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight); + this.view = clampTracks(clampTimePan(view, this.lanesWidth), this.rows(asset).length, this.lanesHeight); this.needsFit = false; this.redraw(); }); @@ -1345,7 +1763,12 @@ export class SongTimeline extends Widget { private shownTracks(asset: SongAsset): SongTrack[] { const g = this.gesture; - const tracks = g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks; + const tracks = + g?.kind === 'slider' + ? this.shownTracksWith(asset, g) + : g?.kind === 'point' + ? asset.tracks.map((t) => (t.id === g.track ? withLanePoints(t, g.target, g.points) : t)) + : asset.tracks; if (!this.take) return tracks; const shown = [...tracks]; shown.splice(Math.min(this.take.index, shown.length), 0, { @@ -1437,6 +1860,7 @@ export class SongTimeline extends Widget { const asset = this.asset(); this.lanesG.clear(); + this.automationG.clear(); this.itemsG.clear(); this.overlay.clear(); this.headsG.clear(); @@ -1453,9 +1877,11 @@ export class SongTimeline extends Widget { this.ruler.visible = true; const length = songLength(asset); const tracks = this.shownTracks(asset); - this.drawLanes(asset, tracks, lw, lh, length); - this.drawItems(asset, lw, lh); - this.drawHeads(asset, tracks); + const rows = songRows(tracks, this.open); + this.drawLanes(asset, tracks, rows, lw, lh, length); + const values = this.drawAutomation(asset, tracks, rows, lw, lh); + this.drawItems(asset, lw, lh, values); + this.drawHeads(asset, tracks, rows); this.drawOut(); this.ruler.draw(this.view, songMeter(asset.props), lw, { length, @@ -1466,7 +1892,7 @@ export class SongTimeline extends Widget { const g = this.gesture; if (g?.kind === 'empty' && g.active) drawBand(this.overlay, g.start, g.current); if (this.drop) { - const row = indexOf(asset.tracks).get(this.drop.track); + const row = trackRows(tracks, rows).get(this.drop.track); if (row !== undefined) { const box = itemBox(this.view, row, this.drop); this.overlay @@ -1488,33 +1914,39 @@ export class SongTimeline extends Widget { this.hint.visible = this.hint.text !== ''; this.hint.position.set( HEAD_WIDTH + Theme.padding * 2, - RULER_HEIGHT + trackTop(this.view, tracks.length) + Theme.padding * 2, + RULER_HEIGHT + trackTop(this.view, rows.length) + Theme.padding * 2, ); } - /** A band per track, the picked one lit, and the bar, beat and grid lines across them. */ + /** + * A band per track, the picked one lit, a darker one for each of its open automation lanes (#337), + * and the bar, beat and grid lines across them. + */ private drawLanes( asset: SongAsset, tracks: readonly SongTrack[], + rows: readonly SongRow[], lw: number, lh: number, length: number, ): void { const c = Theme.colors; - tracks.forEach((track, i) => { + rows.forEach((row, i) => { const y = trackTop(this.view, i); const th = this.view.trackHeight; if (y > lh || y + th < 0) return; + const track = tracks[row.track]!; const lit = track.id === this.pickedTrack && this.pickedIds.length === 0; - this.lanesG - .rect(0, y, lw, th) - .fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 }); + const band = row.lane + ? { color: 0x000000, alpha: 0.14 } + : { color: 0xffffff, alpha: row.track % 2 ? 0.015 : 0.035 }; + this.lanesG.rect(0, y, lw, th).fill(lit && !row.lane ? { color: c.accent, alpha: 0.08 } : band); if (i === 0) this.lanesG.rect(0, y, lw, 1).fill({ color: c.border, alpha: 0.8 }); - this.lanesG.rect(0, y + th - 1, lw, 1).fill({ color: c.border, alpha: 0.8 }); + this.lanesG.rect(0, y + th - 1, lw, 1).fill({ color: c.border, alpha: row.lane ? 0.4 : 0.8 }); }); // Above the first track and below the last, panned to, is empty. const top = Math.max(0, trackTop(this.view, 0)); - const bottom = Math.min(lh, trackTop(this.view, tracks.length)); + const bottom = Math.min(lh, trackTop(this.view, rows.length)); if (bottom <= top) return; const alpha = [0.9, 0.45, 0.2]; for (const line of timeLines(this.view, songMeter(asset.props), songGrid(asset.props), lw)) @@ -1528,11 +1960,73 @@ export class SongTimeline extends Widget { if (zero > 0) this.lanesG.rect(0, top, zero, bottom - top).fill({ color: 0x000000, alpha: 0.4 }); } - private drawItems(asset: SongAsset, lw: number, lh: number): void { + /** + * The open automation lanes (#337): a line where the middle is, the way the value takes from point to + * point and the points, the picked one lit; a lane without points shows the fader's value, dashed. + * The value of a point being dragged is written next to it. + */ + private drawAutomation( + asset: SongAsset, + tracks: readonly SongTrack[], + rows: readonly SongRow[], + lw: number, + lh: number, + ): Placed[] { const c = Theme.colors; - const rows = indexOf(this.shownTracks(asset)); - const picked = new Set(this.pickedIds); + const g = this.automationG; + const th = this.view.trackHeight; + const picked = this.livePoint(asset); + const dragged = this.gesture?.kind === 'point' && this.gesture.moved ? this.gesture : null; const labels: Placed[] = []; + rows.forEach((row, i) => { + const target = row.lane; + const top = trackTop(this.view, i); + if (!target || top > lh || top + th < 0) return; + const track = tracks[row.track]!; + // 0 dB, or straight ahead. + const middle = top + laneY(target, 0, th); + g.rect(0, Math.round(middle), lw, 1).fill({ color: c.text, alpha: 0.12 }); + const points = this.wayOf(track, target); + if (points.length === 0) { + const y = Math.round(top + laneY(target, track[target], th)); + for (let x = 0; x < lw; x += 8) g.rect(x, y, 4, 1).fill({ color: AUTOMATION_COLOR, alpha: 0.45 }); + return; + } + const way = laneWay(this.view, target, points, lw); + way.forEach((p, k) => (k === 0 ? g.moveTo(p.x, top + p.y) : g.lineTo(p.x, top + p.y))); + g.stroke({ width: 1.5, color: AUTOMATION_COLOR, alpha: 0.95 }); + points.forEach((point, k) => { + const x = tickToX(this.view, point.tick); + if (x < -6 || x > lw + 6) return; + const y = top + laneY(target, point.value, th); + const lit = picked?.track === track.id && picked.target === target && picked.index === k; + g.rect(x - 3, y - 3, 6, 6) + .fill(lit ? c.accentText : AUTOMATION_COLOR) + .stroke({ width: 1, color: 0x000000, alpha: 0.6, alignment: 1 }); + if (dragged?.track === track.id && dragged.target === target && dragged.index === k) + labels.push({ + text: laneValueText(target, point.value), + x: x + 7, + y: Math.max(top, y - 16), + w: 90, + }); + }); + }); + return labels; + } + + /** The points of a track's lane as they are played: Curve assets looked up. */ + private wayOf(track: SongTrack, target: AutomationTarget): readonly SequenceLanePoint[] { + const points = lanePoints(track, target); + if (points.length === 0) return []; + return sequenceLaneOf({ target, points }, '', curveLookup(this.state.project)).points; + } + + private drawItems(asset: SongAsset, lw: number, lh: number, values: readonly Placed[]): void { + const c = Theme.colors; + const rows = this.rowsOfTracks(asset); + const picked = new Set(this.pickedIds); + const labels: Placed[] = [...values]; for (const item of this.shownItems(asset)) { const row = rows.get(item.track); if (row === undefined) continue; @@ -1556,14 +2050,14 @@ export class SongTimeline extends Widget { if (this.hover?.id === item.id && this.hover.part !== 'body') this.drawGrip(box, this.hover.part); if (box.w > 18) labels.push({ text: this.labelOf(item), x: box.x + 4, y: box.y + 1, w: box.w - 8 }); } - const take = this.take && this.drawTake(this.take, lw); + const take = this.take && this.drawTake(this.take, rows.get(TAKE_TRACK) ?? this.take.index, lw); if (take) labels.push(take); this.syncTexts(this.itemTexts, labels); } /** The take growing on its track as it is recorded, red, with what came in so far. */ - private drawTake(take: TakeView, lw: number): Placed | null { - const box = itemBox(this.view, take.index, { tick: take.tick, length: Math.max(1, take.length) }); + private drawTake(take: TakeView, row: number, lw: number): Placed | null { + const box = itemBox(this.view, row, { tick: take.tick, length: Math.max(1, take.length) }); if (box.x > lw || box.x + box.w < 0) return null; this.itemsG .roundRect(box.x, box.y, box.w, box.h, 3) @@ -1680,24 +2174,45 @@ export class SongTimeline extends Widget { } /** - * The heads: name, mute and solo, the volume and pan sliders, and the meter (#359), grey on a track - * mute or solo silences. A track is added from a head's menu. + * The heads: the switch of its lanes (#337), name, mute and solo, the volume and pan sliders, and the + * meter (#359), grey on a track mute or solo silences. A slider a lane rules shows the lane's value + * where playing is, in the lane's colour. Under a track the head of each open lane: its name and + * that value. A track is added from a head's menu. */ - private drawHeads(asset: SongAsset, tracks: readonly SongTrack[]): void { + private drawHeads(asset: SongAsset, tracks: readonly SongTrack[], rows: readonly SongRow[]): void { const c = Theme.colors; const labels: Placed[] = []; const routes = songRoutes({ tracks, graph: asset.graph }); const gates = trackGates(tracks, routes); const heard = (id: string) => routes.some((route) => route.from === id && gates.get(id)?.passes(route.to)); - tracks.forEach((track, i) => { + const now = this.playhead ?? this.shownStart(); + const valueOf = (track: SongTrack, target: AutomationTarget) => { + const points = this.wayOf(track, target); + return points.length > 0 ? laneValueAt(points, now) : track[target]; + }; + rows.forEach((row, i) => { const y = trackTop(this.view, i); + const track = tracks[row.track]!; const lit = track.id === this.pickedTrack; const th = this.view.trackHeight; + const lane = row.lane; + if (lane) { + this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.1 } : c.viewport); + this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill({ color: c.border, alpha: 0.6 }); + this.headsG.rect(HEAD_BOXES.name.x - 8, y, 2, th).fill({ color: AUTOMATION_COLOR, alpha: 0.7 }); + const top = y + Math.max(2, Math.min(HEAD_BOXES.name.y, (th - 18) / 2)); + labels.push( + { text: LANE_LABELS[lane], x: HEAD_BOXES.name.x, y: top, w: 60 }, + { text: laneValueText(lane, valueOf(track, lane)), x: HEAD_BOXES.name.x + 62, y: top, w: 90 }, + ); + return; + } this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel); if (i === 0) this.headsG.rect(0, y, HEAD_WIDTH - 1, 1).fill(c.border); this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill(c.border); - const { name, fx, mute, solo, volume, pan } = HEAD_BOXES; + const { lanes, name, fx, mute, solo, volume, pan } = HEAD_BOXES; + this.drawLaneSwitch(lanes, y, shownLanes(track, this.open).length > 0, !!track.automation?.length); labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w }); const hasFx = track.fx !== ''; this.toggle(fx, y, hasFx, FX_COLOR); @@ -1711,14 +2226,15 @@ export class SongTimeline extends Widget { const meter = trackMeterBox(th); drawMeter(this.headsG, { ...meter, y: y + meter.y }, this.meterOf(track.id), true, !heard(track.id)); if (th < SLIDERS_MIN_HEIGHT) return; - const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB); + const ruled = { volume: !!laneOf(track, 'volume'), pan: !!laneOf(track, 'pan') }; + const share = sliderShare(valueOf(track, 'volume'), TRACK_MIN_DB, TRACK_MAX_DB); this.headsG .rect(volume.x, y + volume.y, volume.w, volume.h) .fill(c.viewport) .stroke({ width: 1, color: c.border, alignment: 1 }); this.headsG .rect(volume.x + 1, y + volume.y + 1, Math.max(0, (volume.w - 2) * share), volume.h - 2) - .fill({ color: c.accent, alpha: 0.8 }); + .fill({ color: ruled.volume ? AUTOMATION_COLOR : c.accent, alpha: 0.8 }); const unity = volume.x + volume.w * sliderShare(0, TRACK_MIN_DB, TRACK_MAX_DB); this.headsG.rect(unity, y + volume.y - 2, 1, volume.h + 4).fill({ color: c.text, alpha: 0.5 }); this.headsG @@ -1726,10 +2242,10 @@ export class SongTimeline extends Widget { .fill(c.viewport) .stroke({ width: 1, color: c.border, alignment: 1 }); const centre = pan.x + pan.w / 2; - const knob = pan.x + pan.w * sliderShare(track.pan, -1, 1); + const knob = pan.x + pan.w * sliderShare(valueOf(track, 'pan'), -1, 1); this.headsG .rect(Math.min(centre, knob), y + pan.y + 2, Math.max(1, Math.abs(knob - centre)), pan.h - 4) - .fill({ color: c.accent, alpha: 0.8 }); + .fill({ color: ruled.pan ? AUTOMATION_COLOR : c.accent, alpha: 0.8 }); this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 }); }); this.syncTexts(this.headTexts, labels); @@ -1747,6 +2263,24 @@ export class SongTimeline extends Widget { this.outPeak.position.set(box.x + box.w + 4, Math.round(middle - this.outPeak.height / 2)); } + /** The switch of a track's lanes: a triangle, pointing down while they are open, in their colour while it has some. */ + private drawLaneSwitch(box: Box, y: number, open: boolean, automated: boolean): void { + const cx = box.x + box.w / 2; + const cy = y + box.y + box.h / 2; + const color = automated ? AUTOMATION_COLOR : Theme.colors.textMuted; + if (open) + this.headsG + .moveTo(cx - 4, cy - 2) + .lineTo(cx + 4, cy - 2) + .lineTo(cx, cy + 3); + else + this.headsG + .moveTo(cx - 2, cy - 4) + .lineTo(cx + 3, cy) + .lineTo(cx - 2, cy + 4); + this.headsG.closePath().fill(color); + } + private toggle(box: Box, y: number, on: boolean, color: number): void { const c = Theme.colors; this.headsG diff --git a/src/editor/song/easingMenu.ts b/src/editor/song/easingMenu.ts new file mode 100644 index 00000000..3066d47e --- /dev/null +++ b/src/editor/song/easingMenu.ts @@ -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)), + })), + }; + }); +} diff --git a/src/editor/song/laneEdit.ts b/src/editor/song/laneEdit.ts new file mode 100644 index 00000000..2802b568 --- /dev/null +++ b/src/editor/song/laneEdit.ts @@ -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 }; + }); +} diff --git a/src/editor/song/laneView.ts b/src/editor/song/laneView.ts new file mode 100644 index 00000000..c17e73c3 --- /dev/null +++ b/src/editor/song/laneView.ts @@ -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; + +export const LANE_LABELS: Readonly> = { 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 { + const out = new Map(); + 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(); + 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'}`; +} diff --git a/src/editor/song/songView.ts b/src/editor/song/songView.ts index a30d7fae..2175d090 100644 --- a/src/editor/song/songView.ts +++ b/src/editor/song/songView.ts @@ -99,17 +99,19 @@ export function itemBox(view: SongView, index: number, item: Pick, 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, 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 { 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> = { - 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) diff --git a/tests/editor/song-edit.test.ts b/tests/editor/song-edit.test.ts index 3ab535ec..d99a0ae2 100644 --- a/tests/editor/song-edit.test.ts +++ b/tests/editor/song-edit.test.ts @@ -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); diff --git a/tests/editor/song-lanes.test.ts b/tests/editor/song-lanes.test.ts new file mode 100644 index 00000000..7f94c4fa --- /dev/null +++ b/tests/editor/song-lanes.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 9d1dea48..a9003a0d 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -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)');