import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text, } from 'pixi.js'; import { SetSongCommand, SetSongLoopCommand } from '../../core/commands/songCommands'; import { patternLength } from '../../core/music/pattern'; import type { LoopRange } from '../../core/music/sequence'; import { 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 { SongAsset } from '../../core/model/Asset'; import type { Vec2 } from '../../core/model/Properties'; import { findAsset, findAssetOf } from '../../core/model/Project'; import { activeDrag, crispText, drawBand, endDrag, Menu, onDragChange, pressCount, type Size, textStyle, Theme, ui, Widget, zoomFactor, } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { TimeRuler } from '../music/TimeRuler'; import { clampTimePan, draggedLoop, fitTime, LOOP_STRIP, RULER_HEIGHT, rulerPartAt, tickToX, timeLines, xToTick, zoomTime, } from '../music/timeView'; import { snapDown, snapNear } from '../pattern/rollEdit'; import type { EditorState } from '../state'; 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, type HeadPart, headPartAt, heldSongView, indexOf, itemAt, itemBox, type ItemPart, itemsWithin, laneTick, loopSeams, nearestTrack, peakOver, SLIDERS_MIN_HEIGHT, sliderShare, sliderValue, type SongView, TRACK_HEIGHT, trackAtY, 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; /** Height of an item's name strip; what it plays is drawn below. */ const LABEL_STRIP = 15; /** The letter on a lit mute or solo 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 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(); private readonly itemsG = new Graphics(); private readonly overlay = new Graphics(); private readonly itemLabels = new Container(); private readonly itemTexts: Text[] = []; private readonly headsLayer = new Container(); private readonly headsG = new Graphics(); private readonly headLabels = new Container(); private readonly headTexts: Text[] = []; 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 }; 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; /** 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[] = []; /** 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; 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 = () => {}; /** A pattern item was double-clicked. */ onOpenPattern: (patternId: string) => void = () => {}; onDiscardTake: () => void = () => {}; constructor(private readonly state: EditorState) { super(); this.focusable = true; this.lanesLayer.addChild(this.lanesG, 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); 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.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)); 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.needsFit = true; this.redraw(); } saveView(): SongView { return { ...this.view }; } restoreView(view: unknown): void { this.view = heldSongView(view as Partial); this.needsFit = false; this.redraw(); } /** The whole song across, from its first track down. */ fit(): void { const asset = this.asset(); if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) { this.needsFit = true; return; } this.needsFit = false; this.view = { ...fitTime(songLength(asset), this.lanesWidth), panY: 0, trackHeight: this.view.trackHeight, }; 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; 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 })); 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] }, 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.deleteSelection() && this.pickedTrack) removeTrack(this.state, asset.id, this.pickedTrack); 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, track) + this.view.trackHeight / 2, }; } /** 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 && indexOf(asset!.tracks).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 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, 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; } { return { ...this.view, 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 = indexOf(asset.tracks); 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; } 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.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); 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); 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 if (area === 'lanes') this.pressLanes(asset, e, g); this.invalidate(); } 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 track = asset.tracks[row]!; if (e.button === 2) { this.openTrackMenu(asset, row, e.global); return; } if (e.button !== 0) return; const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight); 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') && 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 hit = itemAt(this.view, asset.items, asset.tracks, 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 && anchor.kind === 'pattern') { this.onOpenPattern(anchor.asset); 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 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); 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.view = clampTracks( clampTimePan( { ...this.view, panX: gesture.panX + (local.x - gesture.pointer.x), panY: gesture.panY + (local.y - gesture.pointer.y), }, this.lanesWidth, ), asset.tracks.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 = nearestTrack(this.view, asset.tracks.length, 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; } 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 SetSongLoopCommand(asset.id, gesture.loop)); break; } case 'cursor': this.setStart(gesture.tick); break; case 'empty': { const caught = gesture.active ? itemsWithin(this.view, asset.items, asset.tracks, 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; } } this.invalidate(); } /** Follows an asset dragged in from the panel: a pattern or a sound lands on the lane under the pointer. */ private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void { const dragged = findAsset(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 = trackAtY(this.view, asset.tracks.length, g.y); if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== '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 (dragged.type === 'pattern') length = droppedLength(null, patternLength(dragged), songTempo(asset.props), bar); else { this.ensureLoaded(dragged.file); length = droppedLength(soundLibrary.duration(dragged.file), null, songTempo(asset.props), bar); } this.drop = { kind: dragged.type, asset: dragged.id, 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. */ private followHover(asset: SongAsset, local: Vec2, g: 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'; 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: nearestTrack(this.view, asset.tracks.length, 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: { tracks?: SongTrack[]; items?: SongItem[] }, 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 SetSongLoopCommand(asset.id, { start: first, end: first + bar })), }, { label: 'Loop the whole song', disabled: !asset.loop, onSelect: () => this.state.execute(new SetSongLoopCommand(asset.id, null)), }, ]).open(global.x, global.y); } /** The menu of the item under a right-click, picked first unless it is already. */ private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void { const hit = itemAt(this.view, asset.items, asset.tracks, g); if (!hit) return; if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]); new Menu([{ label: 'Delete', onSelect: () => this.deleteSelection() }]).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) }, { label: 'Delete track', 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); let view: SongView; if (area === 'heads') view = zoomTracks(this.view, factor, g.y); else if (area === 'ruler' || area === 'lanes') view = { ...this.view, ...zoomTime(this.view, factor, g.x) }; else return; this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.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 sound = findAssetOf(this.state.project, item.asset, 'sound'); const seconds = sound?.file ? soundLibrary.duration(sound.file) : 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) : 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, }); 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) : []; } private labelOf(item: SongItem): string { return findAsset(this.state.project, item.asset)?.name ?? '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 sound = findAssetOf(this.state.project, item.asset, 'sound'); return sound?.file && soundLibrary.peaks(sound.file) ? 'wave' : 'none'; } private ensureLoaded(file: string): void { if (!file || this.loading.has(file) || soundLibrary.duration(file) !== null) return; this.loading.add(file); void soundLibrary.load(file).then(() => this.invalidate()); } override layout(): void { if (this.needsFit) this.fit(); } override redraw(): void { if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) { this.fit(); 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.itemsG.clear(); this.overlay.clear(); this.headsG.clear(); if (!asset) { this.syncTexts(this.itemTexts, this.itemLabels, []); this.syncTexts(this.headTexts, this.headLabels, []); 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); this.drawLanes(asset, tracks, lw, lh, length); this.drawItems(asset, lw, lh); this.drawHeads(tracks); 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 = indexOf(asset.tracks).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, tracks.length) + Theme.padding * 2, ); } /** A band per track, the picked one lit, and the bar, beat and grid lines across them. */ private drawLanes( asset: SongAsset, tracks: readonly SongTrack[], lw: number, lh: number, length: number, ): void { const c = Theme.colors; tracks.forEach((track, i) => { const y = trackTop(this.view, i); const th = this.view.trackHeight; if (y > lh || y + th < 0) return; 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 }); 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 }); }); // 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)); 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 }); } private drawItems(asset: SongAsset, lw: number, lh: number): void { const c = Theme.colors; const rows = indexOf(this.shownTracks(asset)); const picked = new Set(this.pickedIds); const labels: Placed[] = []; 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 = !findAsset(this.state.project, item.asset); 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, lw); if (take) labels.push(take); this.syncTexts(this.itemTexts, this.itemLabels, 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) }); 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 sound = findAssetOf(this.state.project, item.asset, 'sound'); if (!sound?.file) return; const peaks = soundLibrary.peaks(sound.file); const seconds = soundLibrary.duration(sound.file); if (!peaks || !seconds) { this.ensureLoaded(sound.file); 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: name, mute and solo, the volume and pan sliders. A track is added from a head's menu. */ private drawHeads(tracks: readonly SongTrack[]): void { const c = Theme.colors; const labels: Placed[] = []; tracks.forEach((track, i) => { const y = trackTop(this.view, i); const lit = track.id === this.pickedTrack; const th = this.view.trackHeight; 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, mute, solo, volume, pan } = HEAD_BOXES; labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w }); 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 }, ); if (th < SLIDERS_MIN_HEIGHT) return; const share = sliderShare(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 }); 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(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 }); this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 }); }); this.syncTexts(this.headTexts, this.headLabels, labels); } 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: Text[], parent: Container, labels: readonly Placed[]): void { labels.forEach((label, i) => { let text = pool[i]; if (!text) { text = crispText({ style: textStyle('small') }); pool.push(text); parent.addChild(text); } text.style = label.dark ? DARK_LABEL : textStyle('small'); if (text.text !== label.text) text.text = label.text; for (let cut = label.text.length; text.width > label.w && cut > 1; cut--) text.text = `${label.text.slice(0, cut - 1)}…`; text.visible = true; 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), ); }); for (let i = labels.length; i < pool.length; i++) pool[i]!.visible = false; } }