Files
feuerwerk/src/editor/song/SongTimeline.ts
T
adminandClaude Opus 5.5 b50c7dbc35 Let a song track automate parameters of its sounds and FX (#337)
Stage 3 of #337. Lanes moved volume and pan only. Now a lane can
steer a parameter of anything on its track, and a voice already
planned or still waiting for its file follows it too.

- A lane may aim at param:<name>. A parameter has no range of its
  own, so the lane keeps min and max. A parameter lane stays without
  points, so its range is not lost. Remove lane takes it away.
- It reaches the track FX, the FX of an item, the instrument of a
  note with its own FX, and the FX of a clip. When several have the
  name, all of them change, as with Set sound FX parameters.
- The scheduler hands the lane value to a note or clip at its start.
  Values a voice only reads at its start act from the next note.
- While a voice sounds, the knobs of buses and voices of the track
  follow the lane on the audio clock, read every 10 ms, because a
  knob is not always the parameter itself (trackParams.ts).
- A voice waiting for its file takes the marks when it starts.
- When a lane is removed, the parameter goes back to its default.
  An edit while playing does not reset a parameter a lane rules.
- This holds in the song tab, for Play song, in compositions and in
  the bake.
- Song tab: Add parameter lane in the track head menu opens a dialog
  for parameter and range (ParamLaneDialog.ts). The lane head menu
  has Range and Remove lane.
- Inspector: while a lane rules a value, a hint at the track names
  the lanes and says the fields act once the lane is gone.

Known limit: a knob that depends on two automated parameters reckons
each lane with the last value of the other, so it may lag one window.

Tests:
- automation-params.test.ts (9, new), 3 new in sound-library and 3
  in song-lanes. Affected files 407/407.
- 17 counter checks, all compile, all red.
- e2e 65 on melektaus-atx: ALL PASSED, 67 checks. 65h3 is new: a
  clip with FX parameter cut starts at the lane value and its knob
  stays within 0.04 dB of the lane. It goes red when automateBus
  does not pass parameters on.
- Gate on melektaus-atx: CHECK EXIT 0, 330 files, 5080 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:30:42 +02:00

2407 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, AutomationTarget[]>();
/** 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<string>();
/** 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<string, StereoMeter>();
/** 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<string, AutomationTarget[]> } {
this.timeTween.land();
this.trackTween.land();
return { ...this.view, lanes: Object.fromEntries(this.open) };
}
restoreView(view: unknown): void {
this.view = heldSongView(view as Partial<SongView>);
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<string, readonly [number, number]>,
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<TakeView, 'peaks'> & { 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<string, number> {
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<string>, 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<Gesture, { kind: 'slider' }>): 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),
);
});
}
}