A sound FX could only be put on a single sound. Now a track, a pattern item, a Play pattern and a Play song send everything they play through one chain of it, as an insert in a DAW. A Play note gets one of its own. - Track: an FX box in the head next to M and S opens a menu of the project's sound FX, and the track's inspector has the row too. Order: items, track FX, volume, pan. - Pattern item: Sound FX in its inspector. Its notes play on a bus of their own into the track's bus. - Play pattern and Play song take fx and fx params. The chain sits on the master of the playing, after the tracks and before the level and the fades. Set sound FX parameters reaches it by the playback. - Play note takes fx and fx params next to the instrument's params. The FX parameters go by "fx:" so the two sets stay apart. Set sound FX parameters on a note changes a name both have in both. - A Pitch on a bus is a new PitchShifter: the phase vocoder of the PitchPlayer, fed while it plays. It cannot look ahead and is exactly one frame late at every pitch, 2048 samples. - That latency is made up. The other way of a Mix is held back by a delay of the same length. Every note and clip starts as much earlier as the buses it passes are late. The scheduler plans that much further ahead, and the first note waits that much after Play. - The Song tab waits for the chains before it plays and lets its buses go a moment after Stop. A playing in a game lets them go after its echoes and what the shifters still hold. - songBuses moved to its own file. Kept in song.ts it pulled the FX compiler into the node registry through an import cycle. Decisions of the user: one chain for the whole (1a), FX everywhere asked for (2a), Set sound FX parameters on playbacks (3a), and a pitch shifter of its own with exact latency compensation for a Pitch on a bus. Tests: - Unit pitch-shifter (new): 11 green. At 0 semitones the output is the input 2048 samples later, off by at most 1.2e-7. - Unit sound-library: 84 green, 8 new. Counter-checks (no early start, no Mix delay, chain made every time, tune misses the master) each turned their test red. - Unit bus-fx (new): 6 green, 4 counter-checks red. Scheduler ahead: 1 new, its counter-check red. - e2e 65 (melektaus-atx): ALL PASSED, 35 checks, 4 new, first run. Chrome at 44.1 kHz: the bus was 0.046440 s late, the note started exactly that much early. Offline the shifter moved 1 kHz to 2000 Hz and passed a click 2048 samples later. - Gate (melektaus-atx): CHECK EXIT 0, 259 files, 4021 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1533 lines
56 KiB
TypeScript
1533 lines
56 KiB
TypeScript
import {
|
||
Container,
|
||
type FederatedPointerEvent,
|
||
type FederatedWheelEvent,
|
||
Graphics,
|
||
type Text,
|
||
} from 'pixi.js';
|
||
import { SetSongCommand, SetSongLoopCommand } from '../../core/commands/songCommands';
|
||
import { patternLength } from '../../core/music/pattern';
|
||
import type { LoopRange } from '../../core/music/sequence';
|
||
import {
|
||
itemEnd,
|
||
newItem,
|
||
newTrack,
|
||
type SongItem,
|
||
type SongItemKind,
|
||
songGrid,
|
||
songLength,
|
||
songLoop,
|
||
songMeter,
|
||
songTempo,
|
||
type SongTrack,
|
||
TRACK_MAX_DB,
|
||
TRACK_MIN_DB,
|
||
} from '../../core/music/song';
|
||
import { ticksOfSeconds, ticksPerBar } from '../../core/music/time';
|
||
import type { SongAsset } from '../../core/model/Asset';
|
||
import type { Vec2 } from '../../core/model/Properties';
|
||
import { findAsset, findAssetOf } from '../../core/model/Project';
|
||
import {
|
||
activeDrag,
|
||
crispText,
|
||
drawBand,
|
||
endDrag,
|
||
Menu,
|
||
onDragChange,
|
||
pressCount,
|
||
type Size,
|
||
textStyle,
|
||
Theme,
|
||
ui,
|
||
Widget,
|
||
zoomFactor,
|
||
} from '../../ui';
|
||
import { playedSoundById, playedSoundOf } from '../../core/properties/playedSound';
|
||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||
import { TimeRuler } from '../music/TimeRuler';
|
||
import {
|
||
clampTimePan,
|
||
draggedLoop,
|
||
fitTime,
|
||
LOOP_STRIP,
|
||
RULER_HEIGHT,
|
||
rulerPartAt,
|
||
tickToX,
|
||
timeLines,
|
||
xToTick,
|
||
zoomTime,
|
||
} from '../music/timeView';
|
||
import { snapDown, snapNear } from '../pattern/rollEdit';
|
||
import type { EditorState } from '../state';
|
||
import { removeTrack } from './songActions';
|
||
import {
|
||
clampMove,
|
||
draggedLength,
|
||
droppedLength,
|
||
duplicateOffset,
|
||
moveDelta,
|
||
moved,
|
||
placedCopies,
|
||
resized,
|
||
roundsOf,
|
||
split,
|
||
trimDelta,
|
||
trimmed,
|
||
} from './songEdit';
|
||
import {
|
||
type Box,
|
||
clampTracks,
|
||
HEAD_BOXES,
|
||
HEAD_WIDTH,
|
||
EDGE_REACH,
|
||
type HeadPart,
|
||
headPartAt,
|
||
heldSongView,
|
||
indexOf,
|
||
itemAt,
|
||
itemBox,
|
||
type ItemPart,
|
||
itemsWithin,
|
||
laneTick,
|
||
loopSeams,
|
||
nearestTrack,
|
||
peakOver,
|
||
SLIDERS_MIN_HEIGHT,
|
||
sliderShare,
|
||
sliderValue,
|
||
type SongView,
|
||
TRACK_HEIGHT,
|
||
trackAtY,
|
||
trackTop,
|
||
zoomTracks,
|
||
} from './songView';
|
||
|
||
const DRAG_THRESHOLD = 3;
|
||
/** How close to an end of the loop a press on the ruler takes that end. */
|
||
const LOOP_EDGE_REACH = 4;
|
||
const PATTERN_COLOR = 0x35598f;
|
||
const SOUND_COLOR = 0x2f6f52;
|
||
const MISSING_COLOR = 0x6b3a3a;
|
||
const MUTE_COLOR = 0xd08a2c;
|
||
const TAKE_COLOR = 0xb33a3a;
|
||
/** The id the track of a take being recorded is drawn under, until it is made. */
|
||
const TAKE_TRACK = '__take';
|
||
const SOLO_COLOR = 0xc9c23a;
|
||
const FX_COLOR = 0x8fb8de;
|
||
/** 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 take being recorded (#342): the track it gets at `index`, where it starts, how far it got, and
|
||
* its waveform – a peak per `ticksPerPeak` ticks.
|
||
*/
|
||
export interface TakeView {
|
||
index: number;
|
||
tick: number;
|
||
length: number;
|
||
peaks: readonly number[];
|
||
ticksPerPeak: number;
|
||
uploading: boolean;
|
||
}
|
||
|
||
/** An asset dragged in from the panel, where it would land. */
|
||
interface Drop {
|
||
kind: SongItemKind;
|
||
asset: string;
|
||
track: string;
|
||
tick: number;
|
||
length: number;
|
||
}
|
||
|
||
/**
|
||
* The Song tab's canvas (#334): the heads of the tracks down the left, a ruler along the top and the
|
||
* items on the lanes between. An asset dragged in from the panel lands as an item; an item is moved
|
||
* by its body and sized by either end – pulled out past what it plays, it repeats that, and an end
|
||
* snaps to where a round starts as well as to the grid –, and goes with Delete or its menu. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's
|
||
* selection, so the inspector shows it; a click on nothing picks the song. The wheel zooms: the time
|
||
* over the lanes, the tracks over their heads.
|
||
*/
|
||
export class SongTimeline extends Widget {
|
||
private readonly bg = new Graphics();
|
||
private readonly lanesLayer = new Container();
|
||
private readonly lanesG = new Graphics();
|
||
private readonly itemsG = new Graphics();
|
||
private readonly overlay = new Graphics();
|
||
private readonly itemLabels = new Container();
|
||
private readonly itemTexts: Text[] = [];
|
||
private readonly headsLayer = new Container();
|
||
private readonly headsG = new Graphics();
|
||
private readonly headLabels = new Container();
|
||
private readonly headTexts: Text[] = [];
|
||
private readonly ruler = new TimeRuler();
|
||
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
|
||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
|
||
private needsFit = true;
|
||
private assetId = '';
|
||
private gesture: Gesture | null = null;
|
||
private drop: Drop | null = null;
|
||
/** The part of an item under the pointer while nothing is held. */
|
||
private hover: { id: string; part: ItemPart } | null = null;
|
||
/** Where the pointer is over this widget, in its own pixels; null while it is elsewhere. */
|
||
private pointer: Vec2 | null = null;
|
||
/** Where playing starts and a paste lands, in ticks. */
|
||
private startTick = 0;
|
||
/** What Ctrl+C took, for any song of the project. */
|
||
private clipboard: SongItem[] = [];
|
||
/** Sounds asked for, so a waveform is drawn once its bytes are there. */
|
||
private readonly loading = new Set<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;
|
||
onTogglePlay: () => void = () => {};
|
||
/** Where playing starts was moved: a playing song jumps there. */
|
||
onStart: (tick: number) => void = () => {};
|
||
/** The tracks as a slider in a head would leave them: heard at once, written when it lets go. */
|
||
onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {};
|
||
/** A pattern item was double-clicked. */
|
||
onOpenPattern: (patternId: string) => void = () => {};
|
||
onDiscardTake: () => void = () => {};
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super();
|
||
this.focusable = true;
|
||
this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay);
|
||
this.headsLayer.addChild(this.headsG, this.headLabels);
|
||
this.addChild(this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint);
|
||
for (const clip of Object.values(this.clips)) this.addChild(clip);
|
||
this.lanesLayer.mask = this.clips.lanes;
|
||
this.headsLayer.mask = this.clips.heads;
|
||
this.ruler.mask = this.clips.ruler;
|
||
this.hint.eventMode = 'none';
|
||
this.itemLabels.eventMode = 'none';
|
||
this.headLabels.eventMode = 'none';
|
||
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||
this.on('pointermove', (e: FederatedPointerEvent) => {
|
||
const p = this.toLocal(e.global);
|
||
this.pointer = { x: p.x, y: p.y };
|
||
});
|
||
this.on('pointerleave', () => (this.pointer = null));
|
||
this.onPointerRelease(() => this.onPointerUp());
|
||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||
state.on('project', () => this.invalidate());
|
||
state.on('selection', () => this.invalidate());
|
||
// A drag can end anywhere, and this view never hears about it: the ghost would stay without this.
|
||
onDragChange(() => {
|
||
if (!activeDrag() && this.drop) {
|
||
this.drop = null;
|
||
this.invalidate();
|
||
}
|
||
});
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 360), h: Math.max(this.minHeight, 200) };
|
||
}
|
||
|
||
get currentAssetId(): string {
|
||
return this.assetId;
|
||
}
|
||
|
||
/** Another song starts playing from its start, framed. */
|
||
show(assetId: string): void {
|
||
if (this.assetId === assetId) return;
|
||
this.gesture = null;
|
||
this.drop = null;
|
||
this.assetId = assetId;
|
||
this.startTick = 0;
|
||
this.needsFit = true;
|
||
this.redraw();
|
||
}
|
||
|
||
saveView(): SongView {
|
||
return { ...this.view };
|
||
}
|
||
|
||
restoreView(view: unknown): void {
|
||
this.view = heldSongView(view as Partial<SongView>);
|
||
this.needsFit = false;
|
||
this.redraw();
|
||
}
|
||
|
||
/** The whole song across, from its first track down. */
|
||
fit(): void {
|
||
const asset = this.asset();
|
||
if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) {
|
||
this.needsFit = true;
|
||
return;
|
||
}
|
||
this.needsFit = false;
|
||
this.view = {
|
||
...fitTime(songLength(asset), this.lanesWidth),
|
||
panY: 0,
|
||
trackHeight: this.view.trackHeight,
|
||
};
|
||
this.redraw();
|
||
}
|
||
|
||
get start(): number {
|
||
return this.startTick;
|
||
}
|
||
|
||
/** The items picked, by id, when the selection is about this song. */
|
||
get pickedIds(): string[] {
|
||
const s = this.state.selection;
|
||
return s?.kind === 'song' && s.assetId === this.assetId ? s.itemIds : [];
|
||
}
|
||
|
||
/** The track the selection is on, `''` when it is about something else. */
|
||
get pickedTrack(): string {
|
||
const s = this.state.selection;
|
||
return s?.kind === 'song' && s.assetId === this.assetId ? s.trackId : '';
|
||
}
|
||
|
||
pick(itemIds: readonly string[], trackId?: string): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const last = asset.items.find((i) => i.id === itemIds.at(-1));
|
||
const track = trackId ?? last?.track ?? (this.pickedTrack || asset.tracks[0]?.id || '');
|
||
this.state.select({ kind: 'song', assetId: asset.id, trackId: track, itemIds: [...itemIds] });
|
||
}
|
||
|
||
// --- what the keys and the menus do ----------------------------------------------------------
|
||
|
||
selectAll(): boolean {
|
||
const asset = this.asset();
|
||
if (!asset) return false;
|
||
this.pick(asset.items.map((i) => i.id));
|
||
return true;
|
||
}
|
||
|
||
deleteSelection(): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
this.commit(
|
||
asset,
|
||
{ items: asset.items.filter((i) => !picked.has(i.id)) },
|
||
picked.size === 1 ? 'Delete item' : 'Delete items',
|
||
);
|
||
this.pick([]);
|
||
return true;
|
||
}
|
||
|
||
copy(): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
this.clipboard = asset.items.filter((i) => picked.has(i.id)).map((i) => ({ ...i }));
|
||
const n = this.clipboard.length;
|
||
this.state.setNotice(`Copied ${n} ${n === 1 ? 'item' : 'items'}`, 'quiet');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Lands under the pointer – the earliest item on the grid line before it, the topmost on its track –
|
||
* or, with the pointer off the lanes, at the start of playing on the tracks it came from. False with
|
||
* nothing copied.
|
||
*/
|
||
paste(): boolean {
|
||
const asset = this.asset();
|
||
if (!asset || this.clipboard.length === 0) return false;
|
||
const spot = this.pasteSpot(asset);
|
||
const copies = placedCopies(
|
||
this.clipboard,
|
||
spot?.tick ?? this.startTick,
|
||
asset.tracks.map((t) => t.id),
|
||
spot?.row,
|
||
);
|
||
this.commit(
|
||
asset,
|
||
{ items: [...asset.items, ...copies] },
|
||
copies.length === 1 ? 'Paste item' : 'Paste items',
|
||
);
|
||
this.pick(copies.map((i) => i.id));
|
||
return true;
|
||
}
|
||
|
||
/** A copy of the picked items right behind them, picked in their place. */
|
||
duplicate(): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
const chosen = asset.items.filter((i) => picked.has(i.id));
|
||
const by = duplicateOffset(asset.items, picked, songGrid(asset.props));
|
||
const copies = placedCopies(
|
||
chosen,
|
||
Math.min(...chosen.map((i) => i.tick)) + by,
|
||
asset.tracks.map((t) => t.id),
|
||
);
|
||
this.commit(asset, { items: [...asset.items, ...copies] }, 'Duplicate items');
|
||
this.pick(copies.map((i) => i.id));
|
||
return true;
|
||
}
|
||
|
||
/** Cuts the picked items where playing starts; the right-hand parts are picked afterwards. */
|
||
splitAtStart(): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
const cut = split(asset.items, picked, this.startTick, songTempo(asset.props));
|
||
if (cut.right.length === 0) {
|
||
this.state.setNotice('Where playing starts is not inside a picked item');
|
||
return true;
|
||
}
|
||
this.commit(asset, { items: cut.items }, cut.right.length === 1 ? 'Split item' : 'Split items');
|
||
this.pick(cut.right);
|
||
return true;
|
||
}
|
||
|
||
/** Moves the picked items by ticks and tracks, as far as they can go. */
|
||
nudge(ticks: number, tracks: number): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
const by = clampMove(asset.items, picked, indexOf(asset.tracks), asset.tracks.length, ticks, tracks);
|
||
if (by.ticks !== 0 || by.tracks !== 0)
|
||
this.commit(
|
||
asset,
|
||
{
|
||
items: moved(
|
||
asset.items,
|
||
picked,
|
||
asset.tracks.map((t) => t.id),
|
||
by.ticks,
|
||
by.tracks,
|
||
),
|
||
},
|
||
picked.size === 1 ? 'Move item' : 'Move items',
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/** A new track under the others, or at `index`. */
|
||
addTrack(index?: number): boolean {
|
||
const asset = this.asset();
|
||
if (!asset) return false;
|
||
const track = newTrack(asset.tracks.length + 1);
|
||
const tracks = [...asset.tracks];
|
||
tracks.splice(index ?? tracks.length, 0, track);
|
||
this.commit(asset, { tracks }, 'Add track');
|
||
this.pick([], track.id);
|
||
return true;
|
||
}
|
||
|
||
override onKeyDown(event: KeyboardEvent): boolean {
|
||
const asset = this.asset();
|
||
if (!asset) return false;
|
||
const mod = event.ctrlKey || event.metaKey;
|
||
const key = event.key.toLowerCase();
|
||
if (event.code === 'Space' && !mod) {
|
||
this.onTogglePlay();
|
||
return true;
|
||
}
|
||
if (this.take) {
|
||
if (event.key !== 'Escape') return false;
|
||
this.onDiscardTake();
|
||
return true;
|
||
}
|
||
if (mod && key === 'c') return this.copy();
|
||
if (mod && key === 'v') return this.paste();
|
||
if (mod && key === 'd') return this.duplicate() || true;
|
||
if (mod && key === 'e') return this.splitAtStart() || true;
|
||
if (mod && key === 'a') return this.selectAll();
|
||
if (mod) return false;
|
||
// Taken even with nothing picked: a Delete must not go on to ask about the song itself.
|
||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||
if (!this.deleteSelection() && this.pickedTrack) removeTrack(this.state, asset.id, this.pickedTrack);
|
||
return true;
|
||
}
|
||
if (event.key === 'Escape' && this.pickedIds.length > 0) {
|
||
this.pick([]);
|
||
return true;
|
||
}
|
||
const grid = songGrid(asset.props);
|
||
const bar = ticksPerBar(songMeter(asset.props));
|
||
if (event.key === 'ArrowUp') return this.nudge(0, -1);
|
||
if (event.key === 'ArrowDown') return this.nudge(0, 1);
|
||
if (event.key === 'ArrowRight') return this.nudge(event.shiftKey ? bar : grid, 0);
|
||
if (event.key === 'ArrowLeft') return this.nudge(event.shiftKey ? -bar : -grid, 0);
|
||
return false;
|
||
}
|
||
|
||
// --- for the check scripts -------------------------------------------------------------------
|
||
|
||
/** The middle of a track's lane at a tick, on screen. */
|
||
lanePoint(tick: number, track: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
return {
|
||
x: at.x + HEAD_WIDTH + tickToX(this.view, tick),
|
||
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + this.view.trackHeight / 2,
|
||
};
|
||
}
|
||
|
||
/** A point of an item: the middle of its body or of one of its ends. */
|
||
itemPoint(itemId: string, part: 'start' | 'body' | 'end' = 'body'): Vec2 | null {
|
||
const asset = this.asset();
|
||
const item = asset?.items.find((i) => i.id === itemId);
|
||
const row = item && indexOf(asset!.tracks).get(item.track);
|
||
if (!item || row === undefined) return null;
|
||
const box = itemBox(this.view, row, item);
|
||
const at = this.getGlobalPosition();
|
||
const x = part === 'start' ? box.x + 2 : part === 'end' ? box.x + box.w - 2 : box.x + box.w / 2;
|
||
return { x: at.x + HEAD_WIDTH + x, y: at.y + RULER_HEIGHT + box.y + box.h / 2 };
|
||
}
|
||
|
||
/** The middle of a part of a track's head, or a share across its slider. */
|
||
headPoint(track: number, part: HeadPart, share = 0.5): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
const box = HEAD_BOXES[part];
|
||
const slider = part === 'volume' || part === 'pan';
|
||
return {
|
||
x: at.x + box.x + (slider ? box.w * share : box.w / 2),
|
||
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + box.y + box.h / 2,
|
||
};
|
||
}
|
||
|
||
rulerPoint(tick: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
|
||
}
|
||
|
||
/** A tick on the strip of the ruler that holds the loop. */
|
||
loopPoint(tick: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + LOOP_STRIP / 2 };
|
||
}
|
||
|
||
viewInfo(): SongView & {
|
||
start: number;
|
||
playhead: number | null;
|
||
loop: LoopRange | null;
|
||
drop: Drop | null;
|
||
hover: { id: string; part: ItemPart } | null;
|
||
cursor: string;
|
||
take: (Omit<TakeView, 'peaks'> & { peaks: number }) | null;
|
||
} {
|
||
return {
|
||
...this.view,
|
||
start: this.startTick,
|
||
playhead: this.playhead,
|
||
loop: this.shownLoop(),
|
||
drop: this.drop,
|
||
hover: this.hover,
|
||
cursor: String(this.cursor ?? ''),
|
||
take: this.take ? { ...this.take, peaks: this.take.peaks.length } : null,
|
||
};
|
||
}
|
||
|
||
/** The items as drawn: their name, what they show, their box on screen and where they start over. */
|
||
itemsDrawn(): {
|
||
id: string;
|
||
label: string;
|
||
content: 'notes' | 'wave' | 'none';
|
||
box: Box;
|
||
seams: number[];
|
||
}[] {
|
||
const asset = this.asset();
|
||
if (!asset) return [];
|
||
const rows = indexOf(asset.tracks);
|
||
const at = this.getGlobalPosition();
|
||
return this.shownItems(asset).flatMap((item) => {
|
||
const row = rows.get(item.track);
|
||
if (row === undefined) return [];
|
||
const box = itemBox(this.view, row, item);
|
||
return [
|
||
{
|
||
id: item.id,
|
||
label: this.labelOf(item),
|
||
content: this.contentOf(item),
|
||
box: { x: at.x + HEAD_WIDTH + box.x, y: at.y + RULER_HEIGHT + box.y, w: box.w, h: box.h },
|
||
seams: this.seamsOf(item),
|
||
},
|
||
];
|
||
});
|
||
}
|
||
|
||
// --- pointer ---------------------------------------------------------------------------------
|
||
|
||
private get lanesWidth(): number {
|
||
return Math.max(0, this.w - HEAD_WIDTH);
|
||
}
|
||
|
||
private get lanesHeight(): number {
|
||
return Math.max(0, this.h - RULER_HEIGHT);
|
||
}
|
||
|
||
private asset(): SongAsset | null {
|
||
return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'song') ?? null) : null;
|
||
}
|
||
|
||
private areaAt(p: Vec2): Area {
|
||
const left = p.x < HEAD_WIDTH;
|
||
if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler';
|
||
return left ? 'heads' : 'lanes';
|
||
}
|
||
|
||
/** A point of the widget in the lanes' own pixels. */
|
||
private inLanes(p: Vec2): Vec2 {
|
||
return { x: p.x - HEAD_WIDTH, y: p.y - RULER_HEIGHT };
|
||
}
|
||
|
||
private onPointerDown(e: FederatedPointerEvent): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const local = this.toLocal(e.global);
|
||
const p = { x: local.x, y: local.y };
|
||
const area = this.areaAt(p);
|
||
e.stopPropagation();
|
||
ui().focus.focus(this);
|
||
if (e.button === 1) {
|
||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||
return;
|
||
}
|
||
if (this.take) return;
|
||
const g = this.inLanes(p);
|
||
const tick = xToTick(this.view, g.x);
|
||
if (area === 'heads') return this.pressHead(asset, e, g.y, p.x);
|
||
if (e.button === 2) {
|
||
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
||
if (area === 'lanes') this.openItemMenu(asset, g, e.global);
|
||
this.invalidate();
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
if (area === 'ruler' && rulerPartAt(p.y) === 'loop') {
|
||
const loop = songLoop(asset);
|
||
const near = (t: number) => Math.abs(tickToX(this.view, t) - g.x) <= LOOP_EDGE_REACH;
|
||
const edge = near(loop.end) ? 'end' : near(loop.start) ? 'start' : null;
|
||
this.gesture = {
|
||
kind: 'loop',
|
||
from: Math.max(0, tick),
|
||
pointerX: g.x,
|
||
active: false,
|
||
loop: null,
|
||
edge,
|
||
};
|
||
} else if (area === 'ruler') {
|
||
this.gesture = {
|
||
kind: 'cursor',
|
||
tick: this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey),
|
||
};
|
||
} else if (area === 'lanes') this.pressLanes(asset, e, g);
|
||
this.invalidate();
|
||
}
|
||
|
||
private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void {
|
||
const row = trackAtY(this.view, asset.tracks.length, y);
|
||
if (row === null) return;
|
||
const track = asset.tracks[row]!;
|
||
if (e.button === 2) {
|
||
this.openTrackMenu(asset, row, e.global);
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
|
||
if (part === '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') &&
|
||
pressCount(e, `song-slider:${track.id}:${part}`) === 2
|
||
) {
|
||
// A double-click sets it back to the middle: 0 dB, or straight ahead.
|
||
this.commit(
|
||
asset,
|
||
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: 0 } : t)) },
|
||
part === 'volume' ? 'Reset track volume' : 'Reset track pan',
|
||
);
|
||
this.onTracksPreview(this.asset()?.tracks ?? asset.tracks);
|
||
} else if (part === 'volume' || part === 'pan') {
|
||
const value = this.sliderAt(part, x);
|
||
this.gesture = { kind: 'slider', track: track.id, part, value };
|
||
this.onTracksPreview(this.shownTracks(asset));
|
||
}
|
||
if (this.pickedTrack !== track.id || this.pickedIds.length > 0) this.pick([], track.id);
|
||
this.invalidate();
|
||
}
|
||
|
||
private sliderAt(part: 'volume' | 'pan', x: number): number {
|
||
return part === 'volume'
|
||
? sliderValue(x, HEAD_BOXES.volume, TRACK_MIN_DB, TRACK_MAX_DB, 0.5)
|
||
: sliderValue(x, HEAD_BOXES.pan, -1, 1, 0.05);
|
||
}
|
||
|
||
private pressLanes(asset: SongAsset, e: FederatedPointerEvent, g: Vec2): void {
|
||
const hit = itemAt(this.view, asset.items, asset.tracks, g);
|
||
const anchor = hit ? asset.items.find((i) => i.id === hit.id) : undefined;
|
||
if (!hit || !anchor) {
|
||
this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey };
|
||
return;
|
||
}
|
||
if (pressCount(e, `song-item:${anchor.id}`) === 2 && anchor.kind === 'pattern') {
|
||
this.onOpenPattern(anchor.asset);
|
||
return;
|
||
}
|
||
const picked = this.pickedIds;
|
||
let shift: ShiftPress = null;
|
||
if (e.shiftKey) {
|
||
shift = picked.includes(anchor.id) ? 'unpick' : 'added';
|
||
if (shift === 'added') this.pick([...picked, anchor.id]);
|
||
} else if (!picked.includes(anchor.id)) this.pick([anchor.id]);
|
||
if (hit.part === 'end') this.gesture = { kind: 'resize', anchor, by: 0, shift };
|
||
else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor, by: 0, shift };
|
||
else {
|
||
const grab = xToTick(this.view, g.x) - anchor.tick;
|
||
this.gesture = { kind: 'move', anchor, grab, ticks: 0, tracks: 0, moved: false, pointer: g, shift };
|
||
}
|
||
}
|
||
|
||
/** A click with Shift that changed nothing takes a picked item out of the picked ones. */
|
||
private unpick(anchor: SongItem, shift: ShiftPress): void {
|
||
if (shift === 'unpick') this.pick(this.pickedIds.filter((id) => id !== anchor.id));
|
||
}
|
||
|
||
private onPointerMove(e: FederatedPointerEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
const g = this.inLanes({ x: local.x, y: local.y });
|
||
const asset = this.asset();
|
||
const dragged = activeDrag();
|
||
if (dragged?.kind === 'asset' && asset) {
|
||
this.trackDrop(asset, dragged.id, local, g);
|
||
return;
|
||
}
|
||
const gesture = this.gesture;
|
||
if (!asset) return;
|
||
if (!gesture) {
|
||
if (!this.take) this.followHover(asset, local, g);
|
||
return;
|
||
}
|
||
const tick = xToTick(this.view, g.x);
|
||
const grid = songGrid(asset.props);
|
||
const free = e.altKey || e.shiftKey;
|
||
const picked = new Set(this.pickedIds);
|
||
switch (gesture.kind) {
|
||
case 'pan':
|
||
this.view = clampTracks(
|
||
clampTimePan(
|
||
{
|
||
...this.view,
|
||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||
},
|
||
this.lanesWidth,
|
||
),
|
||
asset.tracks.length,
|
||
this.lanesHeight,
|
||
);
|
||
break;
|
||
case 'loop': {
|
||
if (!gesture.active && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return;
|
||
gesture.active = true;
|
||
const snap = (t: number) => Math.max(0, free ? Math.round(t) : snapNear(t, grid));
|
||
gesture.loop = draggedLoop(songLoop(asset), gesture.edge, snap(gesture.from), snap(tick));
|
||
break;
|
||
}
|
||
case 'cursor':
|
||
gesture.tick = this.cursorTick(tick, grid, free);
|
||
break;
|
||
case 'empty': {
|
||
gesture.current = g;
|
||
const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y));
|
||
if (!gesture.active && span < DRAG_THRESHOLD) return;
|
||
gesture.active = true;
|
||
break;
|
||
}
|
||
case 'move': {
|
||
if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD)
|
||
return;
|
||
gesture.moved = true;
|
||
const row = nearestTrack(this.view, asset.tracks.length, g.y);
|
||
const by = moveDelta(
|
||
asset.items,
|
||
picked,
|
||
indexOf(asset.tracks),
|
||
asset.tracks.length,
|
||
gesture.anchor,
|
||
tick - gesture.grab,
|
||
row,
|
||
grid,
|
||
free,
|
||
);
|
||
gesture.ticks = by.ticks;
|
||
gesture.tracks = by.tracks;
|
||
break;
|
||
}
|
||
case 'resize': {
|
||
const tempo = songTempo(asset.props);
|
||
const rounds = roundsOf(gesture.anchor, this.periodOf(gesture.anchor, tempo), tempo);
|
||
gesture.by = draggedLength(gesture.anchor, tick, grid, free, rounds) - gesture.anchor.length;
|
||
break;
|
||
}
|
||
case 'trim': {
|
||
const tempo = songTempo(asset.props);
|
||
const period = (item: SongItem) => this.periodOf(item, tempo);
|
||
gesture.by = trimDelta(asset.items, picked, gesture.anchor, tick, grid, free, tempo, period);
|
||
break;
|
||
}
|
||
case 'slider':
|
||
gesture.value = this.sliderAt(gesture.part, local.x);
|
||
this.onTracksPreview(this.shownTracks(asset));
|
||
break;
|
||
}
|
||
this.invalidate();
|
||
}
|
||
|
||
private onPointerUp(): void {
|
||
const drop = this.drop;
|
||
const asset = this.asset();
|
||
if (drop && asset) {
|
||
this.drop = null;
|
||
endDrag();
|
||
const item = newItem(drop);
|
||
this.commit(asset, { items: [...asset.items, item] }, 'Add item');
|
||
this.pick([item.id]);
|
||
return;
|
||
}
|
||
const gesture = this.gesture;
|
||
this.gesture = null;
|
||
if (!gesture || !asset) return;
|
||
const picked = new Set(this.pickedIds);
|
||
switch (gesture.kind) {
|
||
case 'loop': {
|
||
const same = JSON.stringify(gesture.loop) === JSON.stringify(songLoop(asset));
|
||
if (gesture.active && gesture.loop && !same)
|
||
this.state.execute(new SetSongLoopCommand(asset.id, gesture.loop));
|
||
break;
|
||
}
|
||
case 'cursor':
|
||
this.setStart(gesture.tick);
|
||
break;
|
||
case 'empty': {
|
||
const caught = gesture.active
|
||
? itemsWithin(this.view, asset.items, asset.tracks, gesture.start, gesture.current)
|
||
: [];
|
||
const kept = gesture.add ? this.pickedIds : [];
|
||
if (caught.length > 0 || kept.length > 0) this.pick([...new Set([...kept, ...caught])]);
|
||
else this.state.select({ kind: 'asset', assetId: asset.id });
|
||
break;
|
||
}
|
||
case 'move':
|
||
if (gesture.moved && (gesture.ticks !== 0 || gesture.tracks !== 0))
|
||
this.commit(
|
||
asset,
|
||
{
|
||
items: moved(
|
||
asset.items,
|
||
picked,
|
||
asset.tracks.map((t) => t.id),
|
||
gesture.ticks,
|
||
gesture.tracks,
|
||
),
|
||
},
|
||
picked.size === 1 ? 'Move item' : 'Move items',
|
||
);
|
||
else if (!gesture.moved) {
|
||
if (gesture.shift === null) this.pick([gesture.anchor.id]);
|
||
else this.unpick(gesture.anchor, gesture.shift);
|
||
}
|
||
break;
|
||
case 'resize':
|
||
if (gesture.by !== 0)
|
||
this.commit(
|
||
asset,
|
||
{ items: resized(asset.items, picked, gesture.by) },
|
||
picked.size === 1 ? 'Resize item' : 'Resize items',
|
||
);
|
||
else this.unpick(gesture.anchor, gesture.shift);
|
||
break;
|
||
case 'trim':
|
||
if (gesture.by !== 0)
|
||
this.commit(
|
||
asset,
|
||
{ items: this.trimmedItems(asset, picked, gesture.by) },
|
||
picked.size === 1 ? 'Trim item' : 'Trim items',
|
||
);
|
||
else this.unpick(gesture.anchor, gesture.shift);
|
||
break;
|
||
case 'slider': {
|
||
const tracks = this.shownTracksWith(asset, gesture);
|
||
this.commit(asset, { tracks }, gesture.part === 'volume' ? 'Set track volume' : 'Set track pan');
|
||
this.onTracksPreview(this.asset()?.tracks ?? tracks);
|
||
break;
|
||
}
|
||
}
|
||
this.invalidate();
|
||
}
|
||
|
||
/** Follows an asset dragged in from the panel: a pattern or a sound lands on the lane under the pointer. */
|
||
private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void {
|
||
const dragged = findAsset(this.state.project, assetId);
|
||
// Left of tick 0 is there to look at: nothing lands there.
|
||
const inside =
|
||
local.x >= HEAD_WIDTH &&
|
||
local.x <= this.w &&
|
||
local.y >= RULER_HEIGHT &&
|
||
local.y <= this.h &&
|
||
xToTick(this.view, g.x) >= 0;
|
||
const row = trackAtY(this.view, asset.tracks.length, g.y);
|
||
if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== 'sound')) {
|
||
if (this.drop) {
|
||
this.drop = null;
|
||
this.invalidate();
|
||
}
|
||
return;
|
||
}
|
||
const grid = songGrid(asset.props);
|
||
const tick = Math.max(0, snapDown(laneTick(this.view, g.x), grid));
|
||
const bar = ticksPerBar(songMeter(asset.props));
|
||
let length: number;
|
||
if (dragged.type === 'pattern')
|
||
length = droppedLength(null, patternLength(dragged), songTempo(asset.props), bar);
|
||
else {
|
||
const played = playedSoundOf(this.state.project, dragged);
|
||
this.ensureLoaded(played);
|
||
length = droppedLength(soundLibrary.duration(played), null, songTempo(asset.props), bar);
|
||
}
|
||
this.drop = { kind: dragged.type, asset: dragged.id, track: asset.tracks[row]!.id, tick, length };
|
||
this.invalidate();
|
||
}
|
||
|
||
/** The part of an item under the pointer: an end it can be dragged by shows a resize cursor and lights up. */
|
||
private followHover(asset: SongAsset, local: Vec2, g: Vec2): void {
|
||
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
|
||
const hit = inside ? itemAt(this.view, asset.items, asset.tracks, g) : null;
|
||
const cursor = hit && hit.part !== 'body' ? 'ew-resize' : 'default';
|
||
if (this.cursor !== cursor) this.cursor = cursor;
|
||
if (JSON.stringify(hit) === JSON.stringify(this.hover)) return;
|
||
this.hover = hit;
|
||
this.invalidate();
|
||
}
|
||
|
||
/** Where a paste goes with the pointer over the lanes: the grid line before it and its track; else null. */
|
||
private pasteSpot(asset: SongAsset): { tick: number; row: number } | null {
|
||
const p = this.pointer;
|
||
if (!p || p.x > this.w || p.y > this.h || this.areaAt(p) !== 'lanes') return null;
|
||
const g = this.inLanes(p);
|
||
const tick = xToTick(this.view, g.x);
|
||
// Left of tick 0 nothing lands, as nothing is dropped there.
|
||
if (tick < 0) return null;
|
||
return {
|
||
tick: snapDown(tick, songGrid(asset.props)),
|
||
row: nearestTrack(this.view, asset.tracks.length, g.y),
|
||
};
|
||
}
|
||
|
||
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt or Shift on the tick. */
|
||
private cursorTick(tick: number, grid: number, free: boolean): number {
|
||
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
|
||
}
|
||
|
||
private setStart(tick: number): void {
|
||
this.startTick = Math.max(0, tick);
|
||
this.onStart(this.startTick);
|
||
this.invalidate();
|
||
}
|
||
|
||
private commit(asset: SongAsset, parts: { tracks?: SongTrack[]; items?: SongItem[] }, label: string): void {
|
||
if (SetSongCommand.changes(asset, parts)) this.state.execute(new SetSongCommand(asset.id, parts, label));
|
||
}
|
||
|
||
private openRulerMenu(asset: SongAsset, tick: number, global: Vec2): void {
|
||
const bar = ticksPerBar(songMeter(asset.props));
|
||
const first = snapDown(tick, bar);
|
||
new Menu([
|
||
{
|
||
label: `Loop bar ${first / bar + 1}`,
|
||
onSelect: () =>
|
||
this.state.execute(new SetSongLoopCommand(asset.id, { start: first, end: first + bar })),
|
||
},
|
||
{
|
||
label: 'Loop the whole song',
|
||
disabled: !asset.loop,
|
||
onSelect: () => this.state.execute(new SetSongLoopCommand(asset.id, null)),
|
||
},
|
||
]).open(global.x, global.y);
|
||
}
|
||
|
||
/** The menu of the item under a right-click, picked first unless it is already. */
|
||
private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void {
|
||
const hit = itemAt(this.view, asset.items, asset.tracks, g);
|
||
if (!hit) return;
|
||
if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]);
|
||
new Menu([{ label: 'Delete', onSelect: () => this.deleteSelection() }]).open(global.x, global.y);
|
||
}
|
||
|
||
/** The sound FX a track goes through (#346): none, or one of the project's, by name. */
|
||
private openTrackFxMenu(asset: SongAsset, track: SongTrack, global: Vec2): void {
|
||
const set = (fx: string) =>
|
||
this.commit(
|
||
asset,
|
||
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)) },
|
||
'Set track sound FX',
|
||
);
|
||
const effects = this.state.project.assets
|
||
.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) })),
|
||
]).open(global.x, global.y);
|
||
}
|
||
|
||
private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void {
|
||
const track = asset.tracks[row]!;
|
||
const swap = (other: number) => {
|
||
const tracks = [...asset.tracks];
|
||
[tracks[row], tracks[other]] = [tracks[other]!, tracks[row]!];
|
||
this.commit(asset, { tracks }, 'Move track');
|
||
};
|
||
new Menu([
|
||
{ label: 'Add track above', onSelect: () => this.addTrack(row) },
|
||
{ label: 'Add track below', onSelect: () => this.addTrack(row + 1) },
|
||
{ label: 'Move up', disabled: row === 0, onSelect: () => swap(row - 1) },
|
||
{ label: 'Move down', disabled: row === asset.tracks.length - 1, onSelect: () => swap(row + 1) },
|
||
{
|
||
label: 'Delete track',
|
||
disabled: asset.tracks.length <= 1,
|
||
onSelect: () => {
|
||
this.pick([], track.id);
|
||
this.state.execute(
|
||
new SetSongCommand(
|
||
asset.id,
|
||
{
|
||
tracks: asset.tracks.filter((t) => t.id !== track.id),
|
||
items: asset.items.filter((i) => i.track !== track.id),
|
||
},
|
||
'Delete track',
|
||
),
|
||
);
|
||
},
|
||
},
|
||
]).open(global.x, global.y);
|
||
}
|
||
|
||
private onWheel(e: FederatedWheelEvent): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const local = this.toLocal(e.global);
|
||
const area = this.areaAt({ x: local.x, y: local.y });
|
||
const g = this.inLanes({ x: local.x, y: local.y });
|
||
const factor = zoomFactor(e.deltaY);
|
||
let view: SongView;
|
||
if (area === 'heads') view = zoomTracks(this.view, factor, g.y);
|
||
else if (area === 'ruler' || area === 'lanes')
|
||
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
||
else return;
|
||
this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight);
|
||
this.needsFit = false;
|
||
this.redraw();
|
||
e.preventDefault();
|
||
}
|
||
|
||
// --- drawing ---------------------------------------------------------------------------------
|
||
|
||
/** The items as the running gesture would leave them. */
|
||
private shownItems(asset: SongAsset): readonly SongItem[] {
|
||
const g = this.gesture;
|
||
const picked = new Set(this.pickedIds);
|
||
if (g?.kind === 'move' && g.moved)
|
||
return moved(
|
||
asset.items,
|
||
picked,
|
||
asset.tracks.map((t) => t.id),
|
||
g.ticks,
|
||
g.tracks,
|
||
);
|
||
if (g?.kind === 'resize') return resized(asset.items, picked, g.by);
|
||
if (g?.kind === 'trim') return this.trimmedItems(asset, picked, g.by);
|
||
return asset.items;
|
||
}
|
||
|
||
private trimmedItems(asset: SongAsset, picked: ReadonlySet<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) : 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) : [];
|
||
}
|
||
|
||
private labelOf(item: SongItem): string {
|
||
return findAsset(this.state.project, item.asset)?.name ?? 'missing';
|
||
}
|
||
|
||
private contentOf(item: SongItem): 'notes' | 'wave' | 'none' {
|
||
if (item.kind === 'pattern') {
|
||
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
|
||
return pattern && pattern.notes.length > 0 ? 'notes' : 'none';
|
||
}
|
||
const 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.duration(file) !== null) return;
|
||
this.loading.add(file);
|
||
void soundLibrary.load(file).then(() => this.invalidate());
|
||
}
|
||
|
||
override layout(): void {
|
||
if (this.needsFit) this.fit();
|
||
}
|
||
|
||
override redraw(): void {
|
||
if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) {
|
||
this.fit();
|
||
return;
|
||
}
|
||
const c = Theme.colors;
|
||
const w = this.w;
|
||
const h = this.h;
|
||
const lw = this.lanesWidth;
|
||
const lh = this.lanesHeight;
|
||
this.bg.clear().rect(0, 0, w, h).fill(c.viewport);
|
||
this.bg.rect(0, 0, HEAD_WIDTH, h).fill(c.panel);
|
||
this.bg.rect(0, 0, w, RULER_HEIGHT).fill(c.panelHeader);
|
||
this.bg.rect(HEAD_WIDTH - 1, 0, 1, h).fill(c.border);
|
||
this.clips.lanes.clear().rect(HEAD_WIDTH, RULER_HEIGHT, lw, lh).fill(0xffffff);
|
||
this.clips.heads
|
||
.clear()
|
||
.rect(0, RULER_HEIGHT, HEAD_WIDTH - 1, lh)
|
||
.fill(0xffffff);
|
||
this.clips.ruler.clear().rect(HEAD_WIDTH, 0, lw, RULER_HEIGHT).fill(0xffffff);
|
||
this.lanesLayer.position.set(HEAD_WIDTH, RULER_HEIGHT);
|
||
this.headsLayer.position.set(0, RULER_HEIGHT);
|
||
this.ruler.position.set(HEAD_WIDTH, 0);
|
||
|
||
const asset = this.asset();
|
||
this.lanesG.clear();
|
||
this.itemsG.clear();
|
||
this.overlay.clear();
|
||
this.headsG.clear();
|
||
if (!asset) {
|
||
this.syncTexts(this.itemTexts, this.itemLabels, []);
|
||
this.syncTexts(this.headTexts, this.headLabels, []);
|
||
this.ruler.visible = false;
|
||
this.hint.text = 'No song selected – pick one in the Assets panel or create one.';
|
||
this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
|
||
this.hint.visible = true;
|
||
return;
|
||
}
|
||
this.ruler.visible = true;
|
||
const length = songLength(asset);
|
||
const tracks = this.shownTracks(asset);
|
||
this.drawLanes(asset, tracks, lw, lh, length);
|
||
this.drawItems(asset, lw, lh);
|
||
this.drawHeads(tracks);
|
||
this.ruler.draw(this.view, songMeter(asset.props), lw, {
|
||
length,
|
||
loop: this.shownLoop(),
|
||
start: this.shownStart(),
|
||
playhead: this.playhead,
|
||
});
|
||
const g = this.gesture;
|
||
if (g?.kind === 'empty' && g.active) drawBand(this.overlay, g.start, g.current);
|
||
if (this.drop) {
|
||
const row = indexOf(asset.tracks).get(this.drop.track);
|
||
if (row !== undefined) {
|
||
const box = itemBox(this.view, row, this.drop);
|
||
this.overlay
|
||
.rect(box.x, box.y, box.w, box.h)
|
||
.fill({ color: c.accent, alpha: 0.25 })
|
||
.stroke({ width: 1, color: c.accent });
|
||
}
|
||
}
|
||
const start = tickToX(this.view, this.shownStart());
|
||
if (start >= 0 && start <= lw) this.overlay.rect(start, 0, 1, lh).fill({ color: c.text, alpha: 0.3 });
|
||
if (this.playhead !== null) {
|
||
const x = tickToX(this.view, this.playhead);
|
||
if (x >= 0 && x <= lw) this.overlay.rect(x - 0.5, 0, 1, lh).fill(c.hot);
|
||
}
|
||
this.hint.text =
|
||
asset.items.length === 0
|
||
? 'Drag a pattern or a sound from the Assets panel onto a track. Space plays.'
|
||
: '';
|
||
this.hint.visible = this.hint.text !== '';
|
||
this.hint.position.set(
|
||
HEAD_WIDTH + Theme.padding * 2,
|
||
RULER_HEIGHT + trackTop(this.view, tracks.length) + Theme.padding * 2,
|
||
);
|
||
}
|
||
|
||
/** A band per track, the picked one lit, and the bar, beat and grid lines across them. */
|
||
private drawLanes(
|
||
asset: SongAsset,
|
||
tracks: readonly SongTrack[],
|
||
lw: number,
|
||
lh: number,
|
||
length: number,
|
||
): void {
|
||
const c = Theme.colors;
|
||
tracks.forEach((track, i) => {
|
||
const y = trackTop(this.view, i);
|
||
const th = this.view.trackHeight;
|
||
if (y > lh || y + th < 0) return;
|
||
const lit = track.id === this.pickedTrack && this.pickedIds.length === 0;
|
||
this.lanesG
|
||
.rect(0, y, lw, th)
|
||
.fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 });
|
||
if (i === 0) this.lanesG.rect(0, y, lw, 1).fill({ color: c.border, alpha: 0.8 });
|
||
this.lanesG.rect(0, y + th - 1, lw, 1).fill({ color: c.border, alpha: 0.8 });
|
||
});
|
||
// Above the first track and below the last, panned to, is empty.
|
||
const top = Math.max(0, trackTop(this.view, 0));
|
||
const bottom = Math.min(lh, trackTop(this.view, tracks.length));
|
||
if (bottom <= top) return;
|
||
const alpha = [0.9, 0.45, 0.2];
|
||
for (const line of timeLines(this.view, songMeter(asset.props), songGrid(asset.props), lw))
|
||
this.lanesG.rect(line.x, top, 1, bottom - top).fill({ color: c.border, alpha: alpha[line.level] });
|
||
const end = tickToX(this.view, length);
|
||
if (end < lw)
|
||
this.lanesG
|
||
.rect(Math.max(0, end), top, lw - Math.max(0, end), bottom - top)
|
||
.fill({ color: 0x000000, alpha: 0.2 });
|
||
const zero = Math.min(lw, tickToX(this.view, 0));
|
||
if (zero > 0) this.lanesG.rect(0, top, zero, bottom - top).fill({ color: 0x000000, alpha: 0.4 });
|
||
}
|
||
|
||
private drawItems(asset: SongAsset, lw: number, lh: number): void {
|
||
const c = Theme.colors;
|
||
const rows = indexOf(this.shownTracks(asset));
|
||
const picked = new Set(this.pickedIds);
|
||
const labels: Placed[] = [];
|
||
for (const item of this.shownItems(asset)) {
|
||
const row = rows.get(item.track);
|
||
if (row === undefined) continue;
|
||
const box = itemBox(this.view, row, item);
|
||
if (box.x > lw || box.x + box.w < 0 || box.y > lh || box.y + box.h < 0) continue;
|
||
const missing = !findAsset(this.state.project, item.asset);
|
||
const color = missing ? MISSING_COLOR : item.kind === 'pattern' ? PATTERN_COLOR : SOUND_COLOR;
|
||
const lit = picked.has(item.id);
|
||
this.itemsG
|
||
.roundRect(box.x, box.y, box.w, box.h, 3)
|
||
.fill({ color, alpha: lit ? 1 : 0.85 })
|
||
.stroke({
|
||
width: lit ? 2 : 1,
|
||
color: lit ? c.accentText : 0x000000,
|
||
alpha: lit ? 1 : 0.45,
|
||
alignment: 1,
|
||
});
|
||
if (item.kind === 'pattern') this.drawNotes(item, box, lw);
|
||
else this.drawWave(item, box, lw);
|
||
this.drawSeams(item, box, lw);
|
||
if (this.hover?.id === item.id && this.hover.part !== 'body') this.drawGrip(box, this.hover.part);
|
||
if (box.w > 18) labels.push({ text: this.labelOf(item), x: box.x + 4, y: box.y + 1, w: box.w - 8 });
|
||
}
|
||
const take = this.take && this.drawTake(this.take, lw);
|
||
if (take) labels.push(take);
|
||
this.syncTexts(this.itemTexts, this.itemLabels, labels);
|
||
}
|
||
|
||
/** The take growing on its track as it is recorded, red, with what came in so far. */
|
||
private drawTake(take: TakeView, lw: number): Placed | null {
|
||
const box = itemBox(this.view, take.index, { tick: take.tick, length: Math.max(1, take.length) });
|
||
if (box.x > lw || box.x + box.w < 0) return null;
|
||
this.itemsG
|
||
.roundRect(box.x, box.y, box.w, box.h, 3)
|
||
.fill({ color: TAKE_COLOR, alpha: take.uploading ? 0.55 : 0.9 })
|
||
.stroke({ width: 1, color: 0x000000, alpha: 0.45, alignment: 1 });
|
||
const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2;
|
||
const half = (box.h - LABEL_STRIP - 4) / 2;
|
||
const perPx = 1 / this.view.pxPerTick / take.ticksPerPeak;
|
||
for (let x = Math.max(0, Math.ceil(box.x)); x < Math.min(lw, box.x + box.w); x += 2) {
|
||
const from = Math.floor((x - box.x) * perPx);
|
||
const to = Math.max(from + 1, Math.floor((x + 2 - box.x) * perPx));
|
||
let peak = 0;
|
||
for (let i = from; i < to && i < take.peaks.length; i++) peak = Math.max(peak, take.peaks[i]!);
|
||
if (from >= take.peaks.length) break;
|
||
const reach = Math.max(0.5, peak * half);
|
||
this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 });
|
||
}
|
||
if (box.w <= 18) return null;
|
||
const text = take.uploading ? 'Uploading…' : 'Recording…';
|
||
return { text, x: box.x + 4, y: box.y + 1, w: box.w - 8 };
|
||
}
|
||
|
||
/** A line where what the item plays starts over, the label strip left clear. */
|
||
private drawSeams(item: SongItem, box: Box, lw: number): void {
|
||
for (const tick of this.seamsOf(item)) {
|
||
const x = Math.round(tickToX(this.view, tick));
|
||
if (x <= box.x + 1 || x >= box.x + box.w - 1 || x < 0 || x > lw) continue;
|
||
this.itemsG
|
||
.rect(x - 1, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP)
|
||
.fill({ color: 0x000000, alpha: 0.5 });
|
||
this.itemsG.rect(x, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP).fill({ color: 0xffffff, alpha: 0.55 });
|
||
this.itemsG
|
||
.moveTo(x - 3, box.y + LABEL_STRIP)
|
||
.lineTo(x + 4, box.y + LABEL_STRIP)
|
||
.lineTo(x + 0.5, box.y + LABEL_STRIP + 4)
|
||
.closePath()
|
||
.fill({ color: 0xffffff, alpha: 0.8 });
|
||
}
|
||
}
|
||
|
||
/** The end of an item the pointer would drag, lit. */
|
||
private drawGrip(box: Box, part: 'start' | 'end'): void {
|
||
const reach = Math.min(EDGE_REACH, box.w / 3);
|
||
const x = part === 'end' ? box.x + box.w - reach : box.x;
|
||
this.itemsG.rect(x, box.y, reach, box.h).fill({ color: 0xffffff, alpha: 0.28 });
|
||
this.itemsG
|
||
.rect(part === 'end' ? box.x + box.w - 2 : box.x, box.y, 2, box.h)
|
||
.fill(Theme.colors.accentText);
|
||
}
|
||
|
||
/** The notes of a pattern item, small, across every round it plays. */
|
||
private drawNotes(item: SongItem, box: Box, lw: number): void {
|
||
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
|
||
if (!pattern || pattern.notes.length === 0) return;
|
||
const span = patternLength(pattern);
|
||
const notes = pattern.notes.filter((n) => n.tick < span);
|
||
if (notes.length === 0) return;
|
||
const low = Math.min(...notes.map((n) => n.note));
|
||
const high = Math.max(...notes.map((n) => n.note));
|
||
const top = box.y + LABEL_STRIP;
|
||
const room = Math.max(4, box.h - LABEL_STRIP - 3);
|
||
const step = room / Math.max(1, high - low + 1);
|
||
const into = ((item.offset % span) + span) % span;
|
||
const end = itemEnd(item);
|
||
for (let round = item.tick - into; round < end; round += span)
|
||
for (const n of notes) {
|
||
const from = Math.max(item.tick, round + n.tick);
|
||
const to = Math.min(end, round + Math.min(n.tick + n.length, span));
|
||
if (to <= from) continue;
|
||
const x0 = tickToX(this.view, from);
|
||
const x1 = tickToX(this.view, to);
|
||
if (x1 < 0 || x0 > lw) continue;
|
||
const y = top + (high - n.note) * step;
|
||
this.itemsG
|
||
.rect(x0, y, Math.max(1, x1 - x0 - 0.5), Math.max(1, Math.min(3, step - 0.5)))
|
||
.fill({ color: 0xffffff, alpha: 0.8 });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The waveform of a sound item, round and round as it plays, from where it starts in it. Each
|
||
* round is drawn from its own start in the same columns, so every repeat looks alike.
|
||
*/
|
||
private drawWave(item: SongItem, box: Box, lw: number): void {
|
||
const 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: name, mute and solo, the volume and pan sliders. A track is added from a head's menu. */
|
||
private drawHeads(tracks: readonly SongTrack[]): void {
|
||
const c = Theme.colors;
|
||
const labels: Placed[] = [];
|
||
tracks.forEach((track, i) => {
|
||
const y = trackTop(this.view, i);
|
||
const lit = track.id === this.pickedTrack;
|
||
const th = this.view.trackHeight;
|
||
this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
|
||
if (i === 0) this.headsG.rect(0, y, HEAD_WIDTH - 1, 1).fill(c.border);
|
||
this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill(c.border);
|
||
const { name, fx, mute, solo, volume, pan } = HEAD_BOXES;
|
||
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 },
|
||
);
|
||
if (th < SLIDERS_MIN_HEIGHT) return;
|
||
const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB);
|
||
this.headsG
|
||
.rect(volume.x, y + volume.y, volume.w, volume.h)
|
||
.fill(c.viewport)
|
||
.stroke({ width: 1, color: c.border, alignment: 1 });
|
||
this.headsG
|
||
.rect(volume.x + 1, y + volume.y + 1, Math.max(0, (volume.w - 2) * share), volume.h - 2)
|
||
.fill({ color: c.accent, alpha: 0.8 });
|
||
const unity = volume.x + volume.w * sliderShare(0, TRACK_MIN_DB, TRACK_MAX_DB);
|
||
this.headsG.rect(unity, y + volume.y - 2, 1, volume.h + 4).fill({ color: c.text, alpha: 0.5 });
|
||
this.headsG
|
||
.rect(pan.x, y + pan.y, pan.w, pan.h)
|
||
.fill(c.viewport)
|
||
.stroke({ width: 1, color: c.border, alignment: 1 });
|
||
const centre = pan.x + pan.w / 2;
|
||
const knob = pan.x + pan.w * sliderShare(track.pan, -1, 1);
|
||
this.headsG
|
||
.rect(Math.min(centre, knob), y + pan.y + 2, Math.max(1, Math.abs(knob - centre)), pan.h - 4)
|
||
.fill({ color: c.accent, alpha: 0.8 });
|
||
this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 });
|
||
});
|
||
this.syncTexts(this.headTexts, this.headLabels, labels);
|
||
}
|
||
|
||
private toggle(box: Box, y: number, on: boolean, color: number): void {
|
||
const c = Theme.colors;
|
||
this.headsG
|
||
.roundRect(box.x, y + box.y, box.w, box.h, 3)
|
||
.fill(on ? color : c.viewport)
|
||
.stroke({ width: 1, color: c.border, alignment: 1 });
|
||
}
|
||
|
||
/** Puts texts at their places, cut short with "…" where they would run past their width. */
|
||
private syncTexts(pool: Text[], parent: Container, labels: readonly Placed[]): void {
|
||
labels.forEach((label, i) => {
|
||
let text = pool[i];
|
||
if (!text) {
|
||
text = crispText({ style: textStyle('small') });
|
||
pool.push(text);
|
||
parent.addChild(text);
|
||
}
|
||
text.style = label.dark ? DARK_LABEL : textStyle('small');
|
||
if (text.text !== label.text) text.text = label.text;
|
||
for (let cut = label.text.length; text.width > label.w && cut > 1; cut--)
|
||
text.text = `${label.text.slice(0, cut - 1)}…`;
|
||
text.visible = true;
|
||
if (label.h === undefined) text.position.set(label.x, label.y);
|
||
else
|
||
text.position.set(
|
||
Math.round(label.x + (label.w - text.width) / 2),
|
||
Math.round(label.y + (label.h - text.height) / 2),
|
||
);
|
||
});
|
||
for (let i = labels.length; i < pool.length; i++) pool[i]!.visible = false;
|
||
}
|
||
}
|