import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands'; import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { type AutomationPoint, type AutomationTarget, isAutomationTarget, laneOf, laneParams, laneRange, laneValueAt, paramOf, paramTarget, type SequenceLanePoint, sequenceLaneOf, type TrackTarget, } from '../../core/music/automation'; import { trackParams } from '../../core/music/trackParams'; import { SetLoopCommand } from '../../core/commands/patternCommands'; import { SetSongCommand, type SongParts } from '../../core/commands/songCommands'; import { embeddedCopy, embeddedName, embeddedUsedBy, instrumentNear, isEmbedded, listedAssets, songClockProps, } from '../../core/music/embedded'; import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern'; import { songRoutes, trackGates } from '../../core/music/routing'; import type { LoopRange } from '../../core/music/sequence'; import { itemAsset, itemEnd, newItem, newTrack, type SongItem, type SongItemKind, songGrid, songLength, songLoop, songMeter, songTempo, type SongTrack, TRACK_MAX_DB, TRACK_MIN_DB, } from '../../core/music/song'; import { ticksOfSeconds, ticksPerBar } from '../../core/music/time'; 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'; import { activeDrag, crispText, drawBand, endDrag, Menu, onDragChange, pressCount, type Size, TextPool, textStyle, Theme, tooltips, type TooltipTarget, truncate, ui, Widget, zoomFactor, } from '../../ui'; import { assetRefLabel } from '../../core/properties/assetRef'; import { playedSoundById, playedSoundOf } from '../../core/properties/playedSound'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { TimeRuler } from '../music/TimeRuler'; import { clampTimePan, draggedLoop, LOOP_STRIP, RULER_HEIGHT, rulerPartAt, tickToX, timeLines, xToTick, zoomTime, } from '../music/timeView'; 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, withoutLane, withoutPoint, withParamLane, } from './laneEdit'; import { laneKindOf, laneLabel, laneValue, laneValueText, laneWay, laneY, pointAt, shownLanes, type SongRow, songRows, trackLanes, trackRows, } from './laneView'; import { openParamLaneDialog, ParamLaneDialog } from './ParamLaneDialog'; import { drawMeter } from './meterDraw'; import { gainToDb, hushMeter, meterClipped, peakText, QUIET_METER, type StereoMeter, stepMeter, } from './meterView'; import { ownFxEntries } from './ownFx'; import { removeTrack } from './songActions'; import { clampMove, draggedLength, droppedLength, duplicateOffset, moveDelta, moved, placedCopies, resized, roundsOf, split, trimDelta, trimmed, } from './songEdit'; import { type Box, clampTracks, HEAD_BOXES, HEAD_WIDTH, EDGE_REACH, fitSong, type HeadPart, headPartAt, heldSongView, indexOf, itemAt, itemBox, type ItemPart, itemsWithin, laneTick, loopSeams, nearestTrack, OUT_METER_BOX, peakOver, SLIDERS_MIN_HEIGHT, sliderShare, sliderValue, SONG_MAX_PX_PER_TICK, type SongView, TRACK_HEIGHT, trackAtY, trackMeterBox, trackTop, zoomTracks, } from './songView'; const DRAG_THRESHOLD = 3; /** How close to an end of the loop a press on the ruler takes that end. */ const LOOP_EDGE_REACH = 4; const PATTERN_COLOR = 0x35598f; const SOUND_COLOR = 0x2f6f52; const MISSING_COLOR = 0x6b3a3a; const MUTE_COLOR = 0xd08a2c; const TAKE_COLOR = 0xb33a3a; /** The id the track of a take being recorded is drawn under, until it is made. */ 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. */ const DARK_LABEL = textStyle('small').clone(); DARK_LABEL.fill = 0x30343c; interface Placed { text: string; x: number; y: number; w: number; /** Given, the text sits in the middle of the box `w` by `h`. */ h?: number; dark?: boolean; } type Area = 'corner' | 'ruler' | 'heads' | 'lanes'; /** * What a press with Shift did to the picked items: `added` the item, or found it picked – then a * click that changes nothing `unpick`s it. A drag with Shift moves or sizes off the grid instead. */ type ShiftPress = 'added' | 'unpick' | null; type Gesture = | { kind: 'pan'; pointer: Vec2; panX: number; panY: number } | { kind: 'loop'; from: number; pointerX: number; active: boolean; loop: LoopRange | null; edge: 'start' | 'end' | null; } | { kind: 'cursor'; tick: number } | { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean } | { kind: 'move'; anchor: SongItem; grab: number; ticks: number; tracks: number; moved: boolean; pointer: Vec2; shift: ShiftPress; } | { 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 } | { /** 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 * its waveform – a peak per `ticksPerPeak` ticks. */ export interface TakeView { index: number; tick: number; length: number; peaks: readonly number[]; ticksPerPeak: number; uploading: boolean; } /** An asset dragged in from the panel, where it would land. */ interface Drop { kind: SongItemKind; asset: string; track: string; tick: number; length: number; } /** * The Song tab's canvas (#334): the heads of the tracks down the left, a ruler along the top and the * items on the lanes between. An asset dragged in from the panel lands as an item; an item is moved * by its body and sized by either end – pulled out past what it plays, it repeats that, and an end * snaps to where a round starts as well as to the grid –, and goes with Delete or its menu. A head * sets its track's mute, solo, volume and pan. What is picked lives in the editor's selection, so * the inspector shows it; a click on nothing picks the song. The wheel zooms: the time over the * lanes, the tracks over their heads. */ 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(); private readonly itemTexts = new TextPool(this.itemLabels); private readonly headsLayer = new Container(); private readonly headsG = new Graphics(); private readonly headLabels = new Container(); private readonly headTexts = new TextPool(this.headLabels); private readonly ruler = new TimeRuler(); private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() }; private readonly hint = crispText({ text: '', style: textStyle('muted') }); private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT }; /** A zoom or a Fit shown as a transition (#377), the time and the tracks each on their own. */ private readonly timeTween = axisTween( () => ({ scale: this.view.pxPerTick, offset: this.view.panX }), ({ scale, offset }) => (this.view = { ...this.view, pxPerTick: scale, panX: offset }), () => this.redraw(), ); private readonly trackTween = axisTween( () => ({ scale: this.view.trackHeight, offset: this.view.panY }), ({ scale, offset }) => (this.view = { ...this.view, trackHeight: scale, panY: offset }), () => this.redraw(), ); private needsFit = true; private assetId = ''; private gesture: Gesture | null = null; 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. */ private startTick = 0; /** What Ctrl+C took, for any song of the project. */ private clipboard: SongItem[] = []; /** What the song kept to itself of the copied items (#355), as it was then: a paste still has it after a delete. */ private clipboardAssets: Asset[] = []; /** Sounds asked for, so a waveform is drawn once its bytes are there. */ private readonly loading = new Set(); /** Where the playhead stands while the song plays, in ticks; null while it does not. */ playhead: number | null = null; /** While set, the lanes take no edits: only a pan, Space to stop and Esc to throw it away. */ take: TakeView | null = null; /** The level meter of each track (#359), by its id. */ private readonly meters = new Map(); /** What the song gives after its sound FX, and the loudest peak of it since Play, in dB. */ private out: StereoMeter = QUIET_METER; private loudest = -Infinity; private readonly outLabel = crispText({ text: 'Out', style: textStyle('small') }); private readonly outPeak = crispText({ text: '', style: textStyle('small') }); onTogglePlay: () => void = () => {}; /** Where playing starts was moved: a playing song jumps there. */ onStart: (tick: number) => void = () => {}; /** The tracks as a slider in a head would leave them: heard at once, written when it lets go. */ onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {}; /** An item was double-clicked, at a tick of the song. */ onOpenItem: (item: SongItem, tick: number) => void = () => {}; onDiscardTake: () => void = () => {}; constructor(private readonly state: EditorState) { super(); this.focusable = true; this.lanesLayer.addChild(this.lanesG, this.automationG, this.itemsG, this.itemLabels, this.overlay); this.headsLayer.addChild(this.headsG, this.headLabels); this.addChild( this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint, this.outLabel, this.outPeak, ); for (const clip of Object.values(this.clips)) this.addChild(clip); this.lanesLayer.mask = this.clips.lanes; this.headsLayer.mask = this.clips.heads; this.ruler.mask = this.clips.ruler; this.hint.eventMode = 'none'; this.outLabel.eventMode = 'none'; this.outPeak.eventMode = 'none'; this.itemLabels.eventMode = 'none'; this.headLabels.eventMode = 'none'; this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); this.on('pointermove', (e: FederatedPointerEvent) => { const p = this.toLocal(e.global); this.pointer = { x: p.x, y: p.y }; }); 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()); state.on('selection', () => this.invalidate()); // A drag can end anywhere, and this view never hears about it: the ghost would stay without this. onDragChange(() => { if (!activeDrag() && this.drop) { this.drop = null; this.invalidate(); } }); } override measure(): Size { return { w: Math.max(this.minWidth, 360), h: Math.max(this.minHeight, 200) }; } get currentAssetId(): string { return this.assetId; } /** Another song starts playing from its start, framed. */ show(assetId: string): void { if (this.assetId === assetId) return; this.gesture = null; this.drop = null; this.assetId = assetId; this.startTick = 0; this.open = new Map(); this.pickedPoint = null; this.needsFit = true; this.redraw(); } saveView(): SongView & { lanes: Record } { this.timeTween.land(); this.trackTween.land(); 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, Array.isArray(targets) ? (targets as unknown[]).filter(isAutomationTarget) : [], ]), ); this.needsFit = false; this.redraw(); } /** Fit by hand, shown as a transition. */ fit(): void { this.timeTween.change(() => this.trackTween.change(() => this.frame())); } /** The whole song across and all its tracks down, with some room around. */ private frame(): void { const asset = this.asset(); if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) { this.needsFit = true; return; } this.needsFit = false; const count = this.rows(asset).length; this.view = fitSong(songLength(asset), count, this.lanesWidth, this.lanesHeight); this.redraw(); } get start(): number { return this.startTick; } /** The items picked, by id, when the selection is about this song. */ get pickedIds(): string[] { const s = this.state.selection; return s?.kind === 'song' && s.assetId === this.assetId ? s.itemIds : []; } /** The track the selection is on, `''` when it is about something else. */ get pickedTrack(): string { const s = this.state.selection; return s?.kind === 'song' && s.assetId === this.assetId ? s.trackId : ''; } 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] }); } // --- what the keys and the menus do ---------------------------------------------------------- selectAll(): boolean { const asset = this.asset(); if (!asset) return false; this.pick(asset.items.map((i) => i.id)); return true; } deleteSelection(): boolean { const asset = this.asset(); const picked = new Set(this.pickedIds); if (!asset || picked.size === 0) return false; this.commit( asset, { items: asset.items.filter((i) => !picked.has(i.id)) }, picked.size === 1 ? 'Delete item' : 'Delete items', ); this.pick([]); return true; } copy(): boolean { const asset = this.asset(); const picked = new Set(this.pickedIds); if (!asset || picked.size === 0) return false; this.clipboard = asset.items.filter((i) => picked.has(i.id)).map((i) => ({ ...i })); this.clipboardAssets = structuredClone(embeddedUsedBy(this.state.project, this.clipboard)); const n = this.clipboard.length; this.state.setNotice(`Copied ${n} ${n === 1 ? 'item' : 'items'}`, 'quiet'); return true; } /** * Lands under the pointer – the earliest item on the grid line before it, the topmost on its track – * or, with the pointer off the lanes, at the start of playing on the tracks it came from. False with * nothing copied. */ paste(): boolean { const asset = this.asset(); if (!asset || this.clipboard.length === 0) return false; const spot = this.pasteSpot(asset); const copies = placedCopies( this.clipboard, spot?.tick ?? this.startTick, asset.tracks.map((t) => t.id), spot?.row, ); this.commit( asset, { items: [...asset.items, ...copies], spare: this.clipboardAssets }, copies.length === 1 ? 'Paste item' : 'Paste items', ); this.pick(copies.map((i) => i.id)); return true; } /** A copy of the picked items right behind them, picked in their place. */ duplicate(): boolean { const asset = this.asset(); const picked = new Set(this.pickedIds); if (!asset || picked.size === 0) return false; const chosen = asset.items.filter((i) => picked.has(i.id)); const by = duplicateOffset(asset.items, picked, songGrid(asset.props)); const copies = placedCopies( chosen, Math.min(...chosen.map((i) => i.tick)) + by, asset.tracks.map((t) => t.id), ); this.commit(asset, { items: [...asset.items, ...copies] }, 'Duplicate items'); this.pick(copies.map((i) => i.id)); return true; } /** Cuts the picked items where playing starts; the right-hand parts are picked afterwards. */ splitAtStart(): boolean { const asset = this.asset(); const picked = new Set(this.pickedIds); if (!asset || picked.size === 0) return false; const cut = split(asset.items, picked, this.startTick, songTempo(asset.props)); if (cut.right.length === 0) { this.state.setNotice('Where playing starts is not inside a picked item'); return true; } this.commit(asset, { items: cut.items }, cut.right.length === 1 ? 'Split item' : 'Split items'); this.pick(cut.right); return true; } /** Moves the picked items by ticks and tracks, as far as they can go. */ nudge(ticks: number, tracks: number): boolean { const asset = this.asset(); const picked = new Set(this.pickedIds); if (!asset || picked.size === 0) return false; const by = clampMove(asset.items, picked, indexOf(asset.tracks), asset.tracks.length, ticks, tracks); if (by.ticks !== 0 || by.tracks !== 0) this.commit( asset, { items: moved( asset.items, picked, asset.tracks.map((t) => t.id), by.ticks, by.tracks, ), }, picked.size === 1 ? 'Move item' : 'Move items', ); return true; } /** A new track under the others, or at `index`. */ addTrack(index?: number): boolean { const asset = this.asset(); if (!asset) return false; const track = newTrack(asset.tracks.length + 1); const tracks = [...asset.tracks]; tracks.splice(index ?? tracks.length, 0, track); this.commit(asset, { tracks }, 'Add track'); this.pick([], track.id); return true; } override onKeyDown(event: KeyboardEvent): boolean { const asset = this.asset(); if (!asset) return false; const mod = event.ctrlKey || event.metaKey; const key = event.key.toLowerCase(); if (event.code === 'Space' && !mod) { this.onTogglePlay(); return true; } if (this.take) { if (event.key !== 'Escape') return false; this.onDiscardTake(); return true; } if (mod && key === 'c') return this.copy(); if (mod && key === 'v') return this.paste(); if (mod && key === 'd') return this.duplicate() || true; if (mod && key === 'e') return this.splitAtStart() || true; if (mod && key === 'a') return this.selectAll(); 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; } const grid = songGrid(asset.props); const bar = ticksPerBar(songMeter(asset.props)); if (event.key === 'ArrowUp') return this.nudge(0, -1); if (event.key === 'ArrowDown') return this.nudge(0, 1); if (event.key === 'ArrowRight') return this.nudge(event.shiftKey ? bar : grid, 0); if (event.key === 'ArrowLeft') return this.nudge(event.shiftKey ? -bar : -grid, 0); return false; } // --- for the check scripts ------------------------------------------------------------------- /** The middle of a track's lane at a tick, on screen. */ lanePoint(tick: number, track: number): Vec2 { const at = this.getGlobalPosition(); return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), 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 kind = laneKindOf(this.shownTracks(asset!)[track]!, target); const at = this.getGlobalPosition(); return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT + trackTop(this.view, row) + laneY(kind, 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), }; } /** A point of an item: the middle of its body or of one of its ends. */ 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 && this.rowsOfTracks(asset!).get(item.track); if (!item || row === undefined) return null; const box = itemBox(this.view, row, item); const at = this.getGlobalPosition(); const x = part === 'start' ? box.x + 2 : part === 'end' ? box.x + box.w - 2 : box.x + box.w / 2; return { x: at.x + HEAD_WIDTH + x, y: at.y + RULER_HEIGHT + box.y + box.h / 2 }; } /** The meters of these tracks and Out a frame on (#359), with the loudest sample on each side since. */ stepMeters( tracks: ReadonlyMap, out: readonly [number, number], now: number, dt: number, ): void { for (const [id, both] of tracks) this.meters.set(id, stepMeter(this.meters.get(id) ?? QUIET_METER, both, now, dt)); this.out = stepMeter(this.out, out, now, dt); this.loudest = Math.max(this.loudest, gainToDb(Math.max(out[0], out[1]))); this.invalidate(); } /** Nothing plays: the meters go dark, their clip lamps stay lit and so does the loudest peak. */ hushMeters(): void { for (const [id, meter] of this.meters) this.meters.set(id, hushMeter(meter)); this.out = hushMeter(this.out); this.invalidate(); } clearMeters(): void { this.meters.clear(); this.clearOut(); } private clearOut(): void { this.out = QUIET_METER; this.loudest = -Infinity; this.invalidate(); } /** What Out shows (test hook). */ outShown(): { meter: StereoMeter; loudest: number; clipped: boolean } { return { meter: this.out, loudest: this.loudest, clipped: meterClipped(this.out) }; } /** The middle of the Out meter (test hook). */ outPoint(): Vec2 { const at = this.getGlobalPosition(); const box = OUT_METER_BOX; return { x: at.x + box.x + box.w / 2, y: at.y + box.y + box.h / 2 }; } /** What the meter of a track shows (test hook). */ meterOf(trackId: string): StereoMeter { return this.meters.get(trackId) ?? QUIET_METER; } /** The middle of a track's meter (test hook). */ meterPoint(track: number): Vec2 { const at = this.getGlobalPosition(); const box = trackMeterBox(this.view.trackHeight); return { x: at.x + box.x + box.w / 2, y: at.y + RULER_HEIGHT + trackTop(this.view, this.rowOfIndex(track)) + box.y + box.h / 2, }; } /** The middle of a part of a track's head, or a share across its slider. */ headPoint(track: number, part: HeadPart, share = 0.5): Vec2 { const at = this.getGlobalPosition(); const box = HEAD_BOXES[part]; 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, this.rowOfIndex(track)) + box.y + box.h / 2, }; } rulerPoint(tick: number): Vec2 { const at = this.getGlobalPosition(); return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 }; } /** A tick on the strip of the ruler that holds the loop. */ loopPoint(tick: number): Vec2 { const at = this.getGlobalPosition(); return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + LOOP_STRIP / 2 }; } viewInfo(): SongView & { start: number; playhead: number | null; loop: LoopRange | null; drop: Drop | null; hover: { id: string; part: ItemPart } | null; cursor: string; take: (Omit & { peaks: number }) | null; moving: boolean; } { return { ...this.view, moving: this.timeTween.moving || this.trackTween.moving, start: this.startTick, playhead: this.playhead, loop: this.shownLoop(), drop: this.drop, hover: this.hover, cursor: String(this.cursor ?? ''), take: this.take ? { ...this.take, peaks: this.take.peaks.length } : null, }; } /** The items as drawn: their name, what they show, their box on screen and where they start over. */ itemsDrawn(): { id: string; label: string; content: 'notes' | 'wave' | 'none'; box: Box; seams: number[]; }[] { const asset = this.asset(); if (!asset) return []; const rows = this.rowsOfTracks(asset); const at = this.getGlobalPosition(); return this.shownItems(asset).flatMap((item) => { const row = rows.get(item.track); if (row === undefined) return []; const box = itemBox(this.view, row, item); return [ { id: item.id, label: this.labelOf(item), content: this.contentOf(item), box: { x: at.x + HEAD_WIDTH + box.x, y: at.y + RULER_HEIGHT + box.y, w: box.w, h: box.h }, seams: this.seamsOf(item), }, ]; }); } // --- pointer --------------------------------------------------------------------------------- private get lanesWidth(): number { return Math.max(0, this.w - HEAD_WIDTH); } private get lanesHeight(): number { return Math.max(0, this.h - RULER_HEIGHT); } private asset(): SongAsset | null { 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 ? [] : trackLanes(track)); } /** 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'; return left ? 'heads' : 'lanes'; } /** A point of the widget in the lanes' own pixels. */ private inLanes(p: Vec2): Vec2 { return { x: p.x - HEAD_WIDTH, y: p.y - RULER_HEIGHT }; } private onPointerDown(e: FederatedPointerEvent): void { const asset = this.asset(); if (!asset) return; const local = this.toLocal(e.global); const p = { x: local.x, y: local.y }; const area = this.areaAt(p); e.stopPropagation(); ui().focus.focus(this); if (e.button === 1) { this.timeTween.target(() => this.trackTween.target(() => { this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; }), ); return; } if (this.take) return; const g = this.inLanes(p); const tick = xToTick(this.view, g.x); if (area === 'heads') return this.pressHead(asset, e, g.y, p.x); // A click on the corner puts out the clip lamp of Out and its loudest peak. if (area === 'corner') { if (e.button === 0) this.clearOut(); return; } if (e.button === 2) { if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), 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; } if (e.button !== 0) return; if (area === 'ruler' && rulerPartAt(p.y) === 'loop') { const loop = songLoop(asset); const near = (t: number) => Math.abs(tickToX(this.view, t) - g.x) <= LOOP_EDGE_REACH; const edge = near(loop.end) ? 'end' : near(loop.start) ? 'start' : null; this.gesture = { kind: 'loop', from: Math.max(0, tick), pointerX: g.x, active: false, loop: null, edge, }; } else if (area === 'ruler') { this.gesture = { kind: 'cursor', tick: this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey), }; } else this.pressLanes(asset, e, g); this.invalidate(); } private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void { 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; } if (e.button !== 0) return; // A click on the meter puts out its clip lamp. if (x >= trackMeterBox(this.view.trackHeight).x - 2) { const meter = this.meters.get(track.id); if (meter) this.meters.set(track.id, [ { ...meter[0], clipped: false }, { ...meter[1], clipped: false }, ]); this.invalidate(); return; } 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' ? track.mute ? 'Unmute track' : 'Mute track' : track.solo ? 'Unsolo track' : 'Solo track'; this.commit( asset, { 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(`${laneLabel(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 ) { // A double-click sets it back to the middle: 0 dB, or straight ahead. this.commit( asset, { tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: 0 } : t)) }, part === 'volume' ? 'Reset track volume' : 'Reset track pan', ); this.onTracksPreview(this.asset()?.tracks ?? asset.tracks); } else if (part === 'volume' || part === 'pan') { const value = this.sliderAt(part, x); this.gesture = { kind: 'slider', track: track.id, part, value }; this.onTracksPreview(this.shownTracks(asset)); } if (this.pickedTrack !== track.id || this.pickedIds.length > 0) this.pick([], track.id); this.invalidate(); } private sliderAt(part: 'volume' | 'pan', x: number): number { return part === 'volume' ? sliderValue(x, HEAD_BOXES.volume, TRACK_MIN_DB, TRACK_MAX_DB, 0.5) : sliderValue(x, HEAD_BOXES.pan, -1, 1, 0.05); } private pressLanes(asset: SongAsset, e: FederatedPointerEvent, g: Vec2): void { 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 }; return; } if (pressCount(e, `song-item:${anchor.id}`) === 2) { this.onOpenItem(anchor, xToTick(this.view, g.x)); return; } const picked = this.pickedIds; let shift: ShiftPress = null; if (e.shiftKey) { shift = picked.includes(anchor.id) ? 'unpick' : 'added'; if (shift === 'added') this.pick([...picked, anchor.id]); } else if (!picked.includes(anchor.id)) this.pick([anchor.id]); if (hit.part === 'end') this.gesture = { kind: 'resize', anchor, by: 0, shift }; else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor, by: 0, shift }; else { const grab = xToTick(this.view, g.x) - anchor.tick; this.gesture = { kind: 'move', anchor, grab, ticks: 0, tracks: 0, moved: false, pointer: g, shift }; } } /** * 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 kind = laneKindOf(track, target); const y = g.y - top; const hit = pointAt(this.view, kind, 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, kind, tick, laneValue(kind, 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(kind, 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)); } private onPointerMove(e: FederatedPointerEvent): void { const local = this.toLocal(e.global); const g = this.inLanes({ x: local.x, y: local.y }); const asset = this.asset(); const dragged = activeDrag(); if (dragged?.kind === 'asset' && asset) { this.trackDrop(asset, dragged.id, local, g); return; } const gesture = this.gesture; if (!asset) return; if (!gesture) { if (!this.take) this.followHover(asset, local, g, e.global); return; } const tick = xToTick(this.view, g.x); const grid = songGrid(asset.props); const free = e.altKey || e.shiftKey; const picked = new Set(this.pickedIds); switch (gesture.kind) { case 'pan': this.timeTween.carry(() => this.trackTween.carry(() => { this.view = clampTracks( clampTimePan( { ...this.view, panX: gesture.panX + (local.x - gesture.pointer.x), panY: gesture.panY + (local.y - gesture.pointer.y), }, this.lanesWidth, ), this.rows(asset).length, this.lanesHeight, ); }), ); break; case 'loop': { if (!gesture.active && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return; gesture.active = true; const snap = (t: number) => Math.max(0, free ? Math.round(t) : snapNear(t, grid)); gesture.loop = draggedLoop(songLoop(asset), gesture.edge, snap(gesture.from), snap(tick)); break; } case 'cursor': gesture.tick = this.cursorTick(tick, grid, free); break; case 'empty': { gesture.current = g; const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y)); if (!gesture.active && span < DRAG_THRESHOLD) return; gesture.active = true; break; } case 'move': { if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD) return; gesture.moved = true; const row = this.trackNear(asset, g.y); const by = moveDelta( asset.items, picked, indexOf(asset.tracks), asset.tracks.length, gesture.anchor, tick - gesture.grab, row, grid, free, ); gesture.ticks = by.ticks; gesture.tracks = by.tracks; break; } case 'resize': { const tempo = songTempo(asset.props); const rounds = roundsOf(gesture.anchor, this.periodOf(gesture.anchor, tempo), tempo); gesture.by = draggedLength(gesture.anchor, tick, grid, free, rounds) - gesture.anchor.length; break; } case 'trim': { const tempo = songTempo(asset.props); const period = (item: SongItem) => this.periodOf(item, tempo); gesture.by = trimDelta(asset.items, picked, gesture.anchor, tick, grid, free, tempo, period); break; } case 'slider': 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); const owner = asset.tracks.find((t) => t.id === gesture.track); const kind = owner ? laneKindOf(owner, gesture.target) : { target: gesture.target }; gesture.points = draggedPoint( gesture.base, kind, gesture.index, this.cursorTick(xToTick(this.view, g.x + gesture.offset.x), grid, free), laneValue(kind, y, this.view.trackHeight), ); break; } } this.invalidate(); } private onPointerUp(): void { const drop = this.drop; const asset = this.asset(); if (drop && asset) { this.drop = null; endDrag(); const item = newItem(drop); this.commit(asset, { items: [...asset.items, item] }, 'Add item'); this.pick([item.id]); return; } const gesture = this.gesture; this.gesture = null; if (!gesture || !asset) return; const picked = new Set(this.pickedIds); switch (gesture.kind) { case 'loop': { const same = JSON.stringify(gesture.loop) === JSON.stringify(songLoop(asset)); if (gesture.active && gesture.loop && !same) this.state.execute(new SetLoopCommand('song', asset.id, gesture.loop)); break; } case 'cursor': this.setStart(gesture.tick); break; case 'empty': { const caught = gesture.active ? 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])]); else this.state.select({ kind: 'asset', assetId: asset.id }); break; } case 'move': if (gesture.moved && (gesture.ticks !== 0 || gesture.tracks !== 0)) this.commit( asset, { items: moved( asset.items, picked, asset.tracks.map((t) => t.id), gesture.ticks, gesture.tracks, ), }, picked.size === 1 ? 'Move item' : 'Move items', ); else if (!gesture.moved) { if (gesture.shift === null) this.pick([gesture.anchor.id]); else this.unpick(gesture.anchor, gesture.shift); } break; case 'resize': if (gesture.by !== 0) this.commit( asset, { items: resized(asset.items, picked, gesture.by) }, picked.size === 1 ? 'Resize item' : 'Resize items', ); else this.unpick(gesture.anchor, gesture.shift); break; case 'trim': if (gesture.by !== 0) this.commit( asset, { items: this.trimmedItems(asset, picked, gesture.by) }, picked.size === 1 ? 'Trim item' : 'Trim items', ); else this.unpick(gesture.anchor, gesture.shift); break; case 'slider': { const tracks = this.shownTracksWith(asset, gesture); this.commit(asset, { tracks }, gesture.part === 'volume' ? 'Set track volume' : 'Set track pan'); 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(); } /** * Follows an asset dragged in from the panel: a pattern, a sound or a clip of one (#425) lands on the * lane under the pointer. */ private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void { const pattern = findAssetOf(this.state.project, assetId, 'pattern'); const sound = pattern ? null : findSound(this.state.project, assetId); // Left of tick 0 is there to look at: nothing lands there. const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h && xToTick(this.view, g.x) >= 0; const row = this.trackAt(asset, g.y); if (!inside || row === null || !(pattern || sound)) { if (this.drop) { this.drop = null; this.invalidate(); } return; } const grid = songGrid(asset.props); const tick = Math.max(0, snapDown(laneTick(this.view, g.x), grid)); const bar = ticksPerBar(songMeter(asset.props)); let length: number; if (pattern) length = droppedLength(null, patternLength(pattern), songTempo(asset.props), bar); else { const played = playedSoundOf(sound!); this.ensureLoaded(played); length = droppedLength(soundLibrary.duration(played), null, songTempo(asset.props), bar); } const kind = pattern ? 'pattern' : 'sound'; this.drop = { kind, asset: assetId, track: asset.tracks[row]!.id, tick, length }; this.invalidate(); } /** * 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 lane = inside ? this.laneAt(asset, g.y) : null; const point = lane && pointAt( this.view, laneKindOf(lane.track, 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; this.invalidate(); } /** Where a paste goes with the pointer over the lanes: the grid line before it and its track; else null. */ private pasteSpot(asset: SongAsset): { tick: number; row: number } | null { const p = this.pointer; if (!p || p.x > this.w || p.y > this.h || this.areaAt(p) !== 'lanes') return null; const g = this.inLanes(p); 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: 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. */ private cursorTick(tick: number, grid: number, free: boolean): number { return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid)); } private setStart(tick: number): void { this.startTick = Math.max(0, tick); this.onStart(this.startTick); this.invalidate(); } private commit(asset: SongAsset, parts: SongParts, label: string): void { if (SetSongCommand.changes(asset, parts)) this.state.execute(new SetSongCommand(asset.id, parts, label)); } private openRulerMenu(asset: SongAsset, tick: number, global: Vec2): void { const bar = ticksPerBar(songMeter(asset.props)); const first = snapDown(tick, bar); new Menu([ { label: `Loop bar ${first / bar + 1}`, onSelect: () => this.state.execute(new SetLoopCommand('song', asset.id, { start: first, end: first + bar })), }, { label: 'Loop the whole song', disabled: !asset.loop, onSelect: () => this.state.execute(new SetLoopCommand('song', asset.id, null)), }, ]).open(global.x, global.y); } /** 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, 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]); const played = itemAsset(this.state.project, hit); const embedded = played !== undefined && isEmbedded(played); new Menu([ { label: 'Open', disabled: !played, onSelect: () => this.onOpenItem(hit, hit.tick) }, played && embedded ? { label: 'Make project asset', onSelect: () => this.state.execute(new MakeProjectAssetCommand(played.id)), } : { label: 'Embed a copy', disabled: !played, onSelect: () => this.embedCopy(asset, hit) }, { separator: true }, { label: 'Delete', danger: true, onSelect: () => this.deleteSelection() }, ]).open(global.x, global.y); } /** The menu of an empty spot on a track. */ private openSpotMenu(asset: SongAsset, g: Vec2, global: Vec2): void { 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( global.x, global.y, ); } /** * 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 param = paramOf(target); const points = lanePoints(track, target); const index = g ? pointAt(this.view, laneKindOf(track, 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), ), }, // A parameter's lane (#337) has a range to set, and is there until it is taken away. ...(param === null ? [] : [ { separator: true as const }, { label: 'Range…', onSelect: () => this.openParamLaneDialog(track, param) }, { label: 'Remove lane', danger: true, onSelect: () => this.editTrack(track.id, (t) => withoutLane(t, target), 'Remove automation lane'), }, ]), ]).open(global.x, global.y); } /** * Adds a lane for a parameter of a track (#337), or sets the range of one: `name` given, only that * one. A new lane opens under its track. */ openParamLaneDialog(track: SongTrack, name: string | null = null): ParamLaneDialog | null { const asset = this.asset(); if (!asset) return null; const taken = new Set(laneParams(track)); const offered = trackParams(this.state.project, asset, track).filter( (param) => param.name === name || (name === null && !taken.has(param.name)), ); const lane = name === null ? undefined : track.automation?.find((l) => l.target === paramTarget(name)); return openParamLaneDialog( new ParamLaneDialog( offered, name === null ? null : { name, ...laneRange(lane ?? { target: paramTarget(name) }) }, (picked, range) => { if (name === null) this.setOpen(track.id, [...shownLanes(track, this.open), paramTarget(picked)]); this.editTrack( track.id, (t) => withParamLane(t, picked, range), name === null ? 'Add automation lane' : 'Set automation range', ); }, ), ); } /** Changes one track as the song holds it now, as one step to undo. */ private editTrack(trackId: string, change: (track: SongTrack) => SongTrack, label: string): void { const asset = this.asset(); if (!asset) return; this.commit(asset, { tracks: asset.tracks.map((t) => (t.id === trackId ? change(t) : t)) }, label); } /** 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 kind = laneKindOf(track, target); 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(kind, point.value, this.view.trackHeight); return { text: laneValueText(kind, 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 * nearest before it. */ createPattern(asset: SongAsset, row: number, tick: number): boolean { const track = asset.tracks[row]; if (!track || tick < 0) return false; const project = this.state.project; const start = snapDown(tick, songGrid(asset.props)); const create = new CreateAssetCommand( 'pattern', embeddedName(project, asset.id, 'Pattern'), { ...songClockProps(asset.props), [PATTERN_BARS_KEY]: 1, [PATTERN_INSTRUMENT_KEY]: instrumentNear(project, asset.items, track.id, start), }, asset.id, ); const length = ticksPerBar(songMeter(asset.props)); const item = newItem({ track: track.id, kind: 'pattern', asset: create.asset.id, tick: start, length }); const placed = new SetSongCommand(asset.id, { items: [...asset.items, item] }, 'Create pattern'); if (!this.state.execute(new CompoundCommand('Create pattern', [create, placed]))) return false; this.pick([item.id]); this.onOpenItem(item, item.tick); return true; } /** Points the item at a copy of its asset that only the song has (#355), to bend without touching the rest. */ embedCopy(asset: SongAsset, item: SongItem): boolean { const played = itemAsset(this.state.project, item); if (!played || isEmbedded(played)) return false; // An item playing a clip (#357) plays the same clip of the copy. const { copy, ids } = embeddedCopy(played, asset); const named = ids.get(item.asset) ?? copy.id; this.commit( asset, { items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: named } : i)), spare: [copy] }, 'Embed a copy', ); return true; } /** The sound FX a track goes through (#346): none, one of the project's by name, or its own (#355). */ private openTrackFxMenu(asset: SongAsset, track: SongTrack, global: Vec2): void { const parts = (fx: string) => ({ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)), }); const place = (fx: string) => new SetSongCommand(asset.id, parts(fx), 'Set track sound FX'); const set = (fx: string) => this.commit(asset, parts(fx), 'Set track sound FX'); const effects = listedAssets(this.state.project) .filter((a) => a.type === 'soundfx') .sort((a, b) => a.name.localeCompare(b.name)); new Menu([ { label: 'No sound FX', current: track.fx === '', onSelect: () => set('') }, ...(effects.length > 0 ? [{ separator: true as const }] : []), ...effects.map((fx) => ({ label: fx.name, current: fx.id === track.fx, onSelect: () => set(fx.id) })), ...ownFxEntries(this.state, asset.id, track.fx, place), ]).open(global.x, global.y); } private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void { const track = asset.tracks[row]!; const swap = (other: number) => { const tracks = [...asset.tracks]; [tracks[row], tracks[other]] = [tracks[other]!, tracks[row]!]; this.commit(asset, { tracks }, 'Move track'); }; new Menu([ { label: 'Add track above', onSelect: () => this.addTrack(row) }, { label: 'Add track below', onSelect: () => this.addTrack(row + 1) }, { 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, and one for a parameter to add. ...trackLanes(track).map((target) => ({ label: `${laneLabel(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], ); }, })), { label: 'Add parameter lane…', disabled: trackParams(this.state.project, asset, track).every((p) => laneParams(track).includes(p.name), ), onSelect: () => this.openParamLaneDialog(track), }, { separator: true }, { label: 'Delete track', danger: true, disabled: asset.tracks.length <= 1, onSelect: () => { this.pick([], track.id); this.state.execute( new SetSongCommand( asset.id, { tracks: asset.tracks.filter((t) => t.id !== track.id), items: asset.items.filter((i) => i.track !== track.id), }, 'Delete track', ), ); }, }, ]).open(global.x, global.y); } private onWheel(e: FederatedWheelEvent): void { const asset = this.asset(); if (!asset) return; const local = this.toLocal(e.global); const area = this.areaAt({ x: local.x, y: local.y }); const g = this.inLanes({ x: local.x, y: local.y }); const factor = zoomFactor(e.deltaY); const tracks = area === 'heads'; if (!tracks && area !== 'ruler' && area !== 'lanes') return; (tracks ? this.trackTween : this.timeTween).change(() => { 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), this.rows(asset).length, this.lanesHeight); this.needsFit = false; this.redraw(); }); e.preventDefault(); } // --- drawing --------------------------------------------------------------------------------- /** The items as the running gesture would leave them. */ private shownItems(asset: SongAsset): readonly SongItem[] { const g = this.gesture; const picked = new Set(this.pickedIds); if (g?.kind === 'move' && g.moved) return moved( asset.items, picked, asset.tracks.map((t) => t.id), g.ticks, g.tracks, ); if (g?.kind === 'resize') return resized(asset.items, picked, g.by); if (g?.kind === 'trim') return this.trimmedItems(asset, picked, g.by); return asset.items; } private trimmedItems(asset: SongAsset, picked: ReadonlySet, by: number): SongItem[] { const tempo = songTempo(asset.props); return trimmed(asset.items, picked, by, tempo, (item) => this.periodOf(item, tempo)); } /** How long one round of what an item plays is, in ticks: its pattern, or its sound once loaded. */ private periodOf(item: SongItem, tempo: number): number | null { if (item.kind === 'pattern') { const pattern = findAssetOf(this.state.project, item.asset, 'pattern'); return pattern ? patternLength(pattern) : null; } const played = playedSoundById(this.state.project, item.asset); const seconds = played ? soundLibrary.duration(played) : null; return seconds ? ticksOfSeconds(seconds, tempo) : null; } private shownTracks(asset: SongAsset): SongTrack[] { const g = this.gesture; 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, { id: TAKE_TRACK, name: 'Recording', volume: 0, pan: 0, mute: false, solo: false, fx: '', }); return shown; } private shownTracksWith(asset: SongAsset, g: Extract): SongTrack[] { return asset.tracks.map((t) => (t.id === g.track ? { ...t, [g.part]: g.value } : t)); } private shownLoop(): LoopRange | null { const asset = this.asset(); if (!asset) return null; const g = this.gesture; if (g?.kind === 'loop' && g.active) return g.loop; return songLoop(asset); } /** Where playing starts, as a drag on the ruler would leave it. */ private shownStart(): number { return this.gesture?.kind === 'cursor' ? this.gesture.tick : this.startTick; } /** Where what an item plays starts over inside it: each round of its pattern, or of its sound once loaded. */ private seamsOf(item: SongItem): number[] { const asset = this.asset(); if (!asset) return []; const tempo = songTempo(asset.props); const rounds = roundsOf(item, this.periodOf(item, tempo), tempo); return rounds ? loopSeams(item, rounds.period, item.tick - rounds.base) : []; } /** Its asset's name, with a mark in front for one the song keeps to itself (#355); "Sound / Clip" for a clip (#357). */ private labelOf(item: SongItem): string { const project = this.state.project; return itemAsset(project, item) ? assetRefLabel(project, item.asset) : 'missing'; } private contentOf(item: SongItem): 'notes' | 'wave' | 'none' { if (item.kind === 'pattern') { const pattern = findAssetOf(this.state.project, item.asset, 'pattern'); return pattern && pattern.notes.length > 0 ? 'notes' : 'none'; } const played = playedSoundById(this.state.project, item.asset); return played && soundLibrary.peaks(played) ? 'wave' : 'none'; } private ensureLoaded(file: string): void { if (!file || this.loading.has(file) || soundLibrary.peaks(file) !== null) return; this.loading.add(file); void soundLibrary.loadPicture(file).then(() => this.invalidate()); } override layout(): void { if (this.needsFit) this.frame(); } override redraw(): void { if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) { this.frame(); return; } const c = Theme.colors; const w = this.w; const h = this.h; const lw = this.lanesWidth; const lh = this.lanesHeight; this.bg.clear().rect(0, 0, w, h).fill(c.viewport); this.bg.rect(0, 0, HEAD_WIDTH, h).fill(c.panel); this.bg.rect(0, 0, w, RULER_HEIGHT).fill(c.panelHeader); this.bg.rect(HEAD_WIDTH - 1, 0, 1, h).fill(c.border); this.clips.lanes.clear().rect(HEAD_WIDTH, RULER_HEIGHT, lw, lh).fill(0xffffff); this.clips.heads .clear() .rect(0, RULER_HEIGHT, HEAD_WIDTH - 1, lh) .fill(0xffffff); this.clips.ruler.clear().rect(HEAD_WIDTH, 0, lw, RULER_HEIGHT).fill(0xffffff); this.lanesLayer.position.set(HEAD_WIDTH, RULER_HEIGHT); this.headsLayer.position.set(0, RULER_HEIGHT); this.ruler.position.set(HEAD_WIDTH, 0); const asset = this.asset(); this.lanesG.clear(); this.automationG.clear(); this.itemsG.clear(); this.overlay.clear(); this.headsG.clear(); this.outLabel.visible = this.outPeak.visible = asset !== null; if (!asset) { this.itemTexts.show([]); this.headTexts.show([]); this.ruler.visible = false; this.hint.text = 'No song selected – pick one in the Assets panel or create one.'; this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2); this.hint.visible = true; return; } this.ruler.visible = true; const length = songLength(asset); const tracks = this.shownTracks(asset); 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, loop: this.shownLoop(), start: this.shownStart(), playhead: this.playhead, }); const g = this.gesture; if (g?.kind === 'empty' && g.active) drawBand(this.overlay, g.start, g.current); if (this.drop) { const row = trackRows(tracks, rows).get(this.drop.track); if (row !== undefined) { const box = itemBox(this.view, row, this.drop); this.overlay .rect(box.x, box.y, box.w, box.h) .fill({ color: c.accent, alpha: 0.25 }) .stroke({ width: 1, color: c.accent }); } } const start = tickToX(this.view, this.shownStart()); if (start >= 0 && start <= lw) this.overlay.rect(start, 0, 1, lh).fill({ color: c.text, alpha: 0.3 }); if (this.playhead !== null) { const x = tickToX(this.view, this.playhead); if (x >= 0 && x <= lw) this.overlay.rect(x - 0.5, 0, 1, lh).fill(c.hot); } this.hint.text = asset.items.length === 0 ? 'Drag a pattern or a sound from the Assets panel onto a track. Space plays.' : ''; this.hint.visible = this.hint.text !== ''; this.hint.position.set( HEAD_WIDTH + Theme.padding * 2, RULER_HEIGHT + trackTop(this.view, rows.length) + Theme.padding * 2, ); } /** * 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; 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; 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: 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, 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)) this.lanesG.rect(line.x, top, 1, bottom - top).fill({ color: c.border, alpha: alpha[line.level] }); const end = tickToX(this.view, length); if (end < lw) this.lanesG .rect(Math.max(0, end), top, lw - Math.max(0, end), bottom - top) .fill({ color: 0x000000, alpha: 0.2 }); const zero = Math.min(lw, tickToX(this.view, 0)); if (zero > 0) this.lanesG.rect(0, top, zero, bottom - top).fill({ color: 0x000000, alpha: 0.4 }); } /** * 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 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]!; const kind = laneKindOf(track, target); const param = paramOf(target); // 0 dB, or straight ahead; a parameter has no middle of its own. if (param === null) { const middle = top + laneY(kind, 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 value = this.unruledValue(asset, track, target); if (value === null) return; const y = Math.round(top + laneY(kind, value, 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, kind, 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(kind, 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(kind, 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; } /** * What a value is while no point of its lane rules it: the fader's, or a parameter's default where * the track has it (#337); null for a parameter it does not have. */ private unruledValue(asset: SongAsset, track: SongTrack, target: AutomationTarget): number | null { if (target === 'volume' || target === 'pan') return track[target]; const name = paramOf(target); return trackParams(this.state.project, asset, track).find((p) => p.name === name)?.default ?? null; } 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; const box = itemBox(this.view, row, item); if (box.x > lw || box.x + box.w < 0 || box.y > lh || box.y + box.h < 0) continue; const missing = !itemAsset(this.state.project, item); const color = missing ? MISSING_COLOR : item.kind === 'pattern' ? PATTERN_COLOR : SOUND_COLOR; const lit = picked.has(item.id); this.itemsG .roundRect(box.x, box.y, box.w, box.h, 3) .fill({ color, alpha: lit ? 1 : 0.85 }) .stroke({ width: lit ? 2 : 1, color: lit ? c.accentText : 0x000000, alpha: lit ? 1 : 0.45, alignment: 1, }); if (item.kind === 'pattern') this.drawNotes(item, box, lw); else this.drawWave(item, box, lw); this.drawSeams(item, box, lw); 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, 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, 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) .fill({ color: TAKE_COLOR, alpha: take.uploading ? 0.55 : 0.9 }) .stroke({ width: 1, color: 0x000000, alpha: 0.45, alignment: 1 }); const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2; const half = (box.h - LABEL_STRIP - 4) / 2; const perPx = 1 / this.view.pxPerTick / take.ticksPerPeak; for (let x = Math.max(0, Math.ceil(box.x)); x < Math.min(lw, box.x + box.w); x += 2) { const from = Math.floor((x - box.x) * perPx); const to = Math.max(from + 1, Math.floor((x + 2 - box.x) * perPx)); let peak = 0; for (let i = from; i < to && i < take.peaks.length; i++) peak = Math.max(peak, take.peaks[i]!); if (from >= take.peaks.length) break; const reach = Math.max(0.5, peak * half); this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 }); } if (box.w <= 18) return null; const text = take.uploading ? 'Uploading…' : 'Recording…'; return { text, x: box.x + 4, y: box.y + 1, w: box.w - 8 }; } /** A line where what the item plays starts over, the label strip left clear. */ private drawSeams(item: SongItem, box: Box, lw: number): void { for (const tick of this.seamsOf(item)) { const x = Math.round(tickToX(this.view, tick)); if (x <= box.x + 1 || x >= box.x + box.w - 1 || x < 0 || x > lw) continue; this.itemsG .rect(x - 1, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP) .fill({ color: 0x000000, alpha: 0.5 }); this.itemsG.rect(x, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP).fill({ color: 0xffffff, alpha: 0.55 }); this.itemsG .moveTo(x - 3, box.y + LABEL_STRIP) .lineTo(x + 4, box.y + LABEL_STRIP) .lineTo(x + 0.5, box.y + LABEL_STRIP + 4) .closePath() .fill({ color: 0xffffff, alpha: 0.8 }); } } /** The end of an item the pointer would drag, lit. */ private drawGrip(box: Box, part: 'start' | 'end'): void { const reach = Math.min(EDGE_REACH, box.w / 3); const x = part === 'end' ? box.x + box.w - reach : box.x; this.itemsG.rect(x, box.y, reach, box.h).fill({ color: 0xffffff, alpha: 0.28 }); this.itemsG .rect(part === 'end' ? box.x + box.w - 2 : box.x, box.y, 2, box.h) .fill(Theme.colors.accentText); } /** The notes of a pattern item, small, across every round it plays. */ private drawNotes(item: SongItem, box: Box, lw: number): void { const pattern = findAssetOf(this.state.project, item.asset, 'pattern'); if (!pattern || pattern.notes.length === 0) return; const span = patternLength(pattern); const notes = pattern.notes.filter((n) => n.tick < span); if (notes.length === 0) return; const low = Math.min(...notes.map((n) => n.note)); const high = Math.max(...notes.map((n) => n.note)); const top = box.y + LABEL_STRIP; const room = Math.max(4, box.h - LABEL_STRIP - 3); const step = room / Math.max(1, high - low + 1); const into = ((item.offset % span) + span) % span; const end = itemEnd(item); for (let round = item.tick - into; round < end; round += span) for (const n of notes) { const from = Math.max(item.tick, round + n.tick); const to = Math.min(end, round + Math.min(n.tick + n.length, span)); if (to <= from) continue; const x0 = tickToX(this.view, from); const x1 = tickToX(this.view, to); if (x1 < 0 || x0 > lw) continue; const y = top + (high - n.note) * step; this.itemsG .rect(x0, y, Math.max(1, x1 - x0 - 0.5), Math.max(1, Math.min(3, step - 0.5))) .fill({ color: 0xffffff, alpha: 0.8 }); } } /** * The waveform of a sound item, round and round as it plays, from where it starts in it. Each * round is drawn from its own start in the same columns, so every repeat looks alike. */ private drawWave(item: SongItem, box: Box, lw: number): void { const played = playedSoundById(this.state.project, item.asset); if (!played) return; const peaks = soundLibrary.peaks(played); const seconds = soundLibrary.duration(played); if (!peaks || !seconds) { this.ensureLoaded(played); return; } const asset = this.asset()!; const tempo = songTempo(asset.props); const period = ticksOfSeconds(seconds, tempo); const width = tickToX(this.view, period) - tickToX(this.view, 0); if (!(width > 0)) return; const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2; const half = (box.h - LABEL_STRIP - 4) / 2; const from = Math.max(box.x, 0); const to = Math.min(box.x + box.w, lw); const into = ticksOfSeconds(item.offset % seconds, tempo); const firstRound = Math.floor((xToTick(this.view, from) - item.tick + into) / period); for (let round = firstRound; ; round++) { const start = Math.round(tickToX(this.view, item.tick - into + round * period)); if (start >= to) break; for (let dx = Math.max(0, Math.ceil((from - start) / 2) * 2); dx < width; dx += 2) { const x = start + dx; if (x >= to) break; const reach = Math.max(0.5, peakOver(peaks, dx / width, (dx + 2) / width) * half); this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 }); } } } /** * 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[], 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)); const now = this.playhead ?? this.shownStart(); const valueOf = (track: SongTrack, target: TrackTarget) => { const points = this.wayOf(track, target); return points.length > 0 ? laneValueAt(points, now) : track[target]; }; const laneText = (track: SongTrack, target: AutomationTarget) => { const points = this.wayOf(track, target); const value = points.length > 0 ? laneValueAt(points, now) : this.unruledValue(asset, track, target); return value === null ? '—' : laneValueText(laneKindOf(track, target), value); }; 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: laneLabel(lane), x: HEAD_BOXES.name.x, y: top, w: 60 }, { text: laneText(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 { 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); labels.push({ text: 'FX', x: fx.x, y: y + fx.y, w: fx.w, h: fx.h, dark: hasFx }); this.toggle(mute, y, track.mute, MUTE_COLOR); this.toggle(solo, y, track.solo, SOLO_COLOR); labels.push( { text: 'M', x: mute.x, y: y + mute.y, w: mute.w, h: mute.h, dark: track.mute }, { text: 'S', x: solo.x, y: y + solo.y, w: solo.w, h: solo.h, dark: track.solo }, ); 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 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: 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 .rect(pan.x, y + pan.y, pan.w, pan.h) .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(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: 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); } /** Out in the corner (#359): its meter and the loudest peak since Play, red past 0 dB. */ private drawOut(): void { const box = OUT_METER_BOX; drawMeter(this.bg, box, this.out, false); const middle = box.y + box.h / 2; this.outLabel.position.set(8, Math.round(middle - this.outLabel.height / 2)); const text = `${peakText(this.loudest)} dB`; if (this.outPeak.text !== text) this.outPeak.text = text; this.outPeak.style = this.loudest > 0 ? textStyle('warning') : textStyle('small'); 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 .roundRect(box.x, y + box.y, box.w, box.h, 3) .fill(on ? color : c.viewport) .stroke({ width: 1, color: c.border, alignment: 1 }); } /** Puts texts at their places, cut short with "…" where they would run past their width. */ private syncTexts(pool: TextPool, labels: readonly Placed[]): void { const styles = labels.map((label) => (label.dark ? DARK_LABEL : textStyle('small'))); pool.show(labels.map((label, i) => truncate(label.text, styles[i]!, label.w))).forEach((text, i) => { const label = labels[i]!; if (text.style !== styles[i]) text.style = styles[i]!; if (label.h === undefined) text.position.set(label.x, label.y); else text.position.set( Math.round(label.x + (label.w - text.width) / 2), Math.round(label.y + (label.h - text.height) / 2), ); }); } }