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>
2407 lines
91 KiB
TypeScript
2407 lines
91 KiB
TypeScript
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),
|
||
);
|
||
});
|
||
}
|
||
}
|