Refactoring round 10 covers everything since round 9 (808e1ee): 110 commits with about 47 000 new lines, nearly all of them sound, sound FX, instruments, patterns and songs. Behaviour is unchanged. Duplicate code (jscpd over src, 50 tokens, TypeScript only, measured on808e1eeand here alike: 47 clones / 426 lines before, 33 / 245 after; round 9 itself ended at 22 / 160 by this count): - PreviewParams (new, editor/soundfx) is the header's parameter bar of the Sound FX and the Instrument tab: values, defaults, fields and the voices a moved field reaches. Both tabs had it line for line. - fxWorklet: an Effect base runs the bit crusher, phaser, flanger, limiter, LFO and bus shifter, a Source base the pitch player and the Chip. Start is the one wait for a voice's first sample, which four processors wrote out each. - nodes/shared has checkPort, detachedPort, fxParamsPort and letRingPort. The Play and Stop nodes of sounds, notes, patterns and songs spelled each port out, up to five times. - parameterValues builds both Sound FX parameters and Instrument parameters, which differed in name and text only. - SetLoopCommand sets the ruler loop of a pattern or a song. It replaces SetPatternLoopCommand and SetSongLoopCommand. - addLink in music/routing is the one rule for a link that both the song routing and the Master routing keep. - eqCurves works out each band and their sum for the EQ tab, which did that in its drawing code. It replaces eqCurveDb, which only a test called. - Smaller ones: the eight neighbour directions of a tile cell, and drawFloatingBox for the shadowed box of Popup and FloatWindow. - Left on purpose: Play pattern and Play song stay two parallel node definitions, and the smaller clones under ten lines stay. Architecture: - SoundLibrary.ts goes from 2897 to 2188 lines. What a stage of a sound FX becomes as Web Audio nodes, and how the knobs of a playing voice move, is now viewport/fxNodes.ts. None of it keeps state of the library. Dead code (TS language service, references per export and member): - soundRefIds, LFO_SHAPE_INDEX, TREMOLO_ROWS, VIBRATO_ROWS and REVERB_ROWS had no reader. The four routing node defs are registered without an unused exported name. - soundFileOf and portTypeOf were called by tests only. The tests now ask findSound and portOf, which the editor uses. DRY moves from src into the one test that uses it. - 51 values used only in their own file lose their `export`. Types and the node catalogues stay exported. - Kept on purpose: neighboursOf is the cell by cell reading the indexed neighboursIn is checked against, and PitchShifter.latency is the test's one view of how late the shifter is. Comments and docs: - Seven comments told history ("no longer", "any more", "as before", "of before #354"). Nine comment blocks ran far past the line width. Proof that this changed no code: the bundle was md5 identical, and the touched tests and e2e modules were identical after esbuild. - README: sound FX, instrument, pattern, song and bus in the asset list, the pages Instruments, Patterns and Songs, 1919 checks. - Wiki: see its own commit. Tests: Gate (melektaus-atx) 274 files, 4236 tests, CHECK EXIT 0 e2e 34, 64, 65 ALL PASSED, 180 checks Unit, touched areas 16 files, 804 tests No check was added, the e2e sum stays at 1919. Counter-checks, each put back by copy and compared with cmp: - addLink without the loop rule: 2 tests fail, one per routing. - eqCurves with the sum of the last band only: 1 test fails. - Start in the worklet never waiting: e2e 34 fails in exactly the four checks that hang on a voice's first sample (Flanger, Tremolo, Chip, a pitched note planned ahead). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1176 lines
42 KiB
TypeScript
1176 lines
42 KiB
TypeScript
import {
|
||
Container,
|
||
type FederatedPointerEvent,
|
||
type FederatedWheelEvent,
|
||
Graphics,
|
||
type Text,
|
||
} from 'pixi.js';
|
||
import { SetLoopCommand, SetPatternNotesCommand } from '../../core/commands/patternCommands';
|
||
import type { GhostNote, Ghosts } from '../../core/music/ghosts';
|
||
import { HIGHEST_NOTE, isSharp, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes';
|
||
import {
|
||
DEFAULT_VELOCITY,
|
||
patternGrid,
|
||
patternLength,
|
||
patternLoop,
|
||
patternMeter,
|
||
type PatternNote,
|
||
} from '../../core/music/pattern';
|
||
import type { LoopRange } from '../../core/music/sequence';
|
||
import { ticksPerBar } from '../../core/music/time';
|
||
import type { PatternAsset } from '../../core/model/Asset';
|
||
import type { Vec2 } from '../../core/model/Properties';
|
||
import { findAssetOf } from '../../core/model/Project';
|
||
import {
|
||
crispText,
|
||
drawBand,
|
||
Menu,
|
||
type Size,
|
||
textStyle,
|
||
Theme,
|
||
tooltips,
|
||
ui,
|
||
Widget,
|
||
zoomFactor,
|
||
} from '../../ui';
|
||
import { TimeRuler } from '../music/TimeRuler';
|
||
import {
|
||
clampTimePan,
|
||
draggedLoop,
|
||
fitTime,
|
||
LOOP_STRIP,
|
||
RULER_HEIGHT,
|
||
rulerPartAt,
|
||
tickToX,
|
||
timeLines,
|
||
xToTick,
|
||
zoomTime,
|
||
} from '../music/timeView';
|
||
import type { EditorState } from '../state';
|
||
import {
|
||
clampShift,
|
||
draggedLength,
|
||
draggedStart,
|
||
drawnNote,
|
||
duplicateOffset,
|
||
moveDelta,
|
||
placedCopies,
|
||
resized,
|
||
shifted,
|
||
snapDown,
|
||
snapNear,
|
||
trimmed,
|
||
withVelocity,
|
||
} from './rollEdit';
|
||
import {
|
||
centreRows,
|
||
clampRows,
|
||
DEFAULT_ROW_HEIGHT,
|
||
END_GRIP,
|
||
ghostAt,
|
||
type NoteBox,
|
||
type NoteHit,
|
||
noteAt,
|
||
noteAtY,
|
||
noteBox,
|
||
noteTop,
|
||
notesWithin,
|
||
type RollView,
|
||
velocityAtY,
|
||
velocityBarAt,
|
||
zoomRows,
|
||
} from './rollView';
|
||
|
||
const KEYS_WIDTH = 84;
|
||
const VELOCITY_HEIGHT = 64;
|
||
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 WHITE = 0xd9dbe0;
|
||
const BLACK = 0x22252b;
|
||
const MUTED_WHITE = 0x6b6f78;
|
||
const MUTED_BLACK = 0x34373e;
|
||
/** A note from velocity 0 to 1: from a dim blue to a bright one. */
|
||
const SOFT = 0x2a4a80;
|
||
const HARD = 0x8fb8ff;
|
||
/** Notes behind the end of the pattern are kept, but not played: drawn faint. */
|
||
const BEYOND_ALPHA = 0.35;
|
||
/** What else sounds in the song around the pattern (#339): grey, without an edge, under the notes. */
|
||
const GHOST = 0xa4a9b3;
|
||
const GHOST_ALPHA = 0.4;
|
||
const DARK_LABEL = textStyle('small').clone();
|
||
DARK_LABEL.fill = 0x30343c;
|
||
|
||
type Area = 'corner' | 'ruler' | 'keys' | 'grid' | 'lane' | 'laneLabel';
|
||
|
||
/**
|
||
* What a press with Shift did to the picked notes: `added` the note, 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: 'key'; note: number; voice: number }
|
||
| {
|
||
kind: 'loop';
|
||
from: number;
|
||
pointerX: number;
|
||
active: boolean;
|
||
loop: LoopRange | null;
|
||
/** The end of the loop the press was at: a drag moves it. */
|
||
edge: 'start' | 'end' | null;
|
||
}
|
||
| { kind: 'cursor'; tick: number }
|
||
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean; free: boolean }
|
||
| {
|
||
kind: 'move';
|
||
anchor: PatternNote;
|
||
grab: number;
|
||
ticks: number;
|
||
notes: number;
|
||
moved: boolean;
|
||
pointer: Vec2;
|
||
shift: ShiftPress;
|
||
}
|
||
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean; shift: ShiftPress }
|
||
| { kind: 'trim'; anchor: PatternNote; by: number; shift: ShiftPress }
|
||
| { kind: 'erase'; erased: Set<string> }
|
||
| { kind: 'velocity'; anchor: PatternNote; value: number };
|
||
|
||
function mix(a: number, b: number, t: number): number {
|
||
const channel = (shift: number) => {
|
||
const x = (a >> shift) & 0xff;
|
||
const y = (b >> shift) & 0xff;
|
||
return Math.round(x + (y - x) * t) << shift;
|
||
};
|
||
return channel(16) | channel(8) | channel(0);
|
||
}
|
||
|
||
/**
|
||
* The Pattern tab's canvas (#333): the keys down the left, a ruler along the top, the notes in a grid
|
||
* between them and their velocities in a lane below. A click into the grid draws a note, a drag spans
|
||
* a band that picks notes; a note is dragged by its body, sized by either end, which lights up under
|
||
* the pointer, and goes with a right-click or Delete. A press or a drag on the ruler moves where
|
||
* playing starts, one on the strip along its top sets the loop. The wheel zooms: the time over the
|
||
* grid, the rows over the keys.
|
||
*/
|
||
export class PianoRoll extends Widget {
|
||
private readonly bg = new Graphics();
|
||
private readonly gridLayer = new Container();
|
||
private readonly gridG = new Graphics();
|
||
private readonly gridOverlay = new Graphics();
|
||
private readonly keysLayer = new Container();
|
||
private readonly keysG = new Graphics();
|
||
private readonly keyLabels = new Container();
|
||
private readonly keyTexts: Text[] = [];
|
||
private readonly ruler = new TimeRuler();
|
||
private readonly laneLayer = new Container();
|
||
private readonly laneG = new Graphics();
|
||
private readonly clips = {
|
||
grid: new Graphics(),
|
||
keys: new Graphics(),
|
||
ruler: new Graphics(),
|
||
lane: new Graphics(),
|
||
};
|
||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||
private readonly laneLabel = crispText({ text: 'Velocity', style: textStyle('small') });
|
||
private view: RollView = { panX: 0, pxPerTick: 0.1, panY: 0, rowHeight: DEFAULT_ROW_HEIGHT };
|
||
private needsFit = true;
|
||
private assetId = '';
|
||
private gesture: Gesture | null = null;
|
||
/** The part of a note under the pointer while nothing is held. */
|
||
private hover: NoteHit | null = null;
|
||
/** Where the pointer is over this widget, in its own pixels; null while it is elsewhere. */
|
||
private pointer: Vec2 | null = null;
|
||
/** The notes picked, by id; only this tab knows them. */
|
||
private picked = new Set<string>();
|
||
/** Where playing starts and a paste lands, in ticks. */
|
||
private startTick = 0;
|
||
/** What a new note gets: the length of the last one drawn or dragged, and the last velocity set. */
|
||
private lastLength: number | null = null;
|
||
private lastVelocity = DEFAULT_VELOCITY;
|
||
/** What Ctrl+C took, for any pattern of the project. */
|
||
private clipboard: PatternNote[] = [];
|
||
private sounding: ReadonlySet<number> = new Set();
|
||
private names: ReadonlyMap<number, string> = new Map();
|
||
private ghosts: Ghosts | null = null;
|
||
/** Where the playhead stands while the pattern plays, in ticks; null while it does not. */
|
||
playhead: number | null = null;
|
||
/** Starts a note for the keys or for a note drawn or moved; `held` until `onRelease`. Gives back its voice. */
|
||
onPreview: (note: number, held: boolean) => number = () => 0;
|
||
onRelease: (voice: number) => void = () => {};
|
||
onTogglePlay: () => void = () => {};
|
||
/** Where playing starts was moved: a playing pattern jumps there. */
|
||
onStart: (tick: number) => void = () => {};
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super();
|
||
this.focusable = true;
|
||
this.gridLayer.addChild(this.gridG, this.gridOverlay);
|
||
this.keysLayer.addChild(this.keysG, this.keyLabels);
|
||
this.laneLayer.addChild(this.laneG);
|
||
this.addChild(
|
||
this.bg,
|
||
this.gridLayer,
|
||
this.keysLayer,
|
||
this.ruler,
|
||
this.laneLayer,
|
||
this.hint,
|
||
this.laneLabel,
|
||
);
|
||
for (const clip of Object.values(this.clips)) this.addChild(clip);
|
||
this.gridLayer.mask = this.clips.grid;
|
||
this.keysLayer.mask = this.clips.keys;
|
||
this.ruler.mask = this.clips.ruler;
|
||
this.laneLayer.mask = this.clips.lane;
|
||
this.hint.eventMode = 'none';
|
||
this.laneLabel.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', () => {
|
||
const notes = this.asset()?.notes ?? [];
|
||
const alive = new Set(notes.map((n) => n.id));
|
||
for (const id of this.picked) if (!alive.has(id)) this.picked.delete(id);
|
||
this.invalidate();
|
||
});
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 320), h: Math.max(this.minHeight, 200) };
|
||
}
|
||
|
||
get currentAssetId(): string {
|
||
return this.assetId;
|
||
}
|
||
|
||
/** Another pattern starts with nothing picked, playing from its start, framed. */
|
||
show(assetId: string): void {
|
||
if (this.assetId === assetId) return;
|
||
this.endGesture();
|
||
this.assetId = assetId;
|
||
this.hover = null;
|
||
this.picked.clear();
|
||
this.startTick = 0;
|
||
this.needsFit = true;
|
||
this.redraw();
|
||
}
|
||
|
||
saveView(): RollView {
|
||
return { ...this.view };
|
||
}
|
||
|
||
restoreView(view: unknown): void {
|
||
this.view = { ...(view as RollView) };
|
||
this.needsFit = false;
|
||
this.redraw();
|
||
}
|
||
|
||
/** The whole pattern across, and its notes – or what the instrument plays – down the middle. */
|
||
fit(): void {
|
||
const asset = this.asset();
|
||
const width = this.gridWidth;
|
||
const height = this.gridHeight;
|
||
if (!asset || width <= 0 || height <= 0) {
|
||
this.needsFit = true;
|
||
return;
|
||
}
|
||
this.needsFit = false;
|
||
const notes = asset.notes.length > 0 ? asset.notes.map((n) => n.note) : [...this.sounding];
|
||
const low = notes.length > 0 ? Math.min(...notes) : MIDDLE_C - 12;
|
||
const high = notes.length > 0 ? Math.max(...notes) : MIDDLE_C + 12;
|
||
const time = fitTime(patternLength(asset), width);
|
||
this.view = clampRows({ ...time, ...centreRows(low, high, height) }, height);
|
||
this.redraw();
|
||
}
|
||
|
||
/** The notes the instrument plays, and the names a drum kit gives some of them. */
|
||
setInstrument(sounding: ReadonlySet<number>, names: ReadonlyMap<number, string>): void {
|
||
this.sounding = sounding;
|
||
this.names = names;
|
||
this.invalidate();
|
||
}
|
||
|
||
/** The notes of the song around the pattern (#339); null for none. */
|
||
setGhosts(ghosts: Ghosts | null): void {
|
||
if (JSON.stringify(ghosts) === JSON.stringify(this.ghosts)) return;
|
||
this.ghosts = ghosts;
|
||
this.view = clampTimePan(this.view, this.gridWidth, this.earliest);
|
||
this.invalidate();
|
||
}
|
||
|
||
get ghostsShown(): Ghosts | null {
|
||
return this.ghosts;
|
||
}
|
||
|
||
get start(): number {
|
||
return this.startTick;
|
||
}
|
||
|
||
get pickedIds(): string[] {
|
||
return [...this.picked];
|
||
}
|
||
|
||
pick(ids: readonly string[]): void {
|
||
this.picked = new Set(ids);
|
||
this.invalidate();
|
||
}
|
||
|
||
// --- what the keys and the menus do ----------------------------------------------------------
|
||
|
||
selectAll(): boolean {
|
||
const asset = this.asset();
|
||
if (!asset) return false;
|
||
this.pick(asset.notes.map((n) => n.id));
|
||
return true;
|
||
}
|
||
|
||
deleteSelection(): boolean {
|
||
const asset = this.asset();
|
||
if (!asset || this.picked.size === 0) return false;
|
||
const label = this.picked.size === 1 ? 'Delete note' : 'Delete notes';
|
||
this.commit(
|
||
asset,
|
||
asset.notes.filter((n) => !this.picked.has(n.id)),
|
||
label,
|
||
);
|
||
this.picked.clear();
|
||
return true;
|
||
}
|
||
|
||
copy(): boolean {
|
||
const asset = this.asset();
|
||
if (!asset || this.picked.size === 0) return false;
|
||
this.clipboard = asset.notes.filter((n) => this.picked.has(n.id)).map((n) => ({ ...n }));
|
||
const n = this.clipboard.length;
|
||
this.state.setNotice(`Copied ${n} ${n === 1 ? 'note' : 'notes'}`, 'quiet');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Lands under the pointer – the earliest note on the grid line before it, the highest on its row –
|
||
* or, with the pointer off the grid, at the start of playing. False with nothing copied, so Ctrl+V
|
||
* can fall through.
|
||
*/
|
||
paste(): boolean {
|
||
const asset = this.asset();
|
||
if (!asset || this.clipboard.length === 0) return false;
|
||
const spot = this.pasteSpot(asset);
|
||
const copies = spot
|
||
? placedCopies(this.clipboard, spot.tick, spot.note)
|
||
: placedCopies(this.clipboard, this.startTick);
|
||
this.commit(asset, [...asset.notes, ...copies], copies.length === 1 ? 'Paste note' : 'Paste notes');
|
||
this.pick(copies.map((n) => n.id));
|
||
return true;
|
||
}
|
||
|
||
/** A copy of the picked notes right behind them, picked in their place. */
|
||
duplicate(): boolean {
|
||
const asset = this.asset();
|
||
if (!asset || this.picked.size === 0) return false;
|
||
const by = duplicateOffset(asset.notes, this.picked, patternGrid(asset.props));
|
||
const copies = placedCopies(
|
||
asset.notes.filter((n) => this.picked.has(n.id)),
|
||
Math.min(...asset.notes.filter((n) => this.picked.has(n.id)).map((n) => n.tick)) + by,
|
||
);
|
||
this.commit(asset, [...asset.notes, ...copies], 'Duplicate notes');
|
||
this.pick(copies.map((n) => n.id));
|
||
return true;
|
||
}
|
||
|
||
/** Moves the picked notes by ticks and semitones, as far as they can go. */
|
||
nudge(ticks: number, pitch: number): boolean {
|
||
const asset = this.asset();
|
||
if (!asset || this.picked.size === 0) return false;
|
||
const by = clampShift(asset.notes, this.picked, ticks, pitch);
|
||
if (by.ticks !== 0 || by.notes !== 0)
|
||
this.commit(
|
||
asset,
|
||
shifted(asset.notes, this.picked, by.ticks, by.notes),
|
||
by.notes !== 0 ? 'Transpose notes' : 'Move notes',
|
||
);
|
||
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 (mod && key === 'c') return this.copy();
|
||
if (mod && key === 'v') return this.paste();
|
||
if (mod && key === 'd') return this.duplicate() || true;
|
||
if (mod && key === 'a') return this.selectAll();
|
||
if (mod) return false;
|
||
// Taken even with nothing picked: a second Delete must not go on to ask about the pattern itself.
|
||
if (event.key === 'Delete' || event.key === 'Backspace') return this.deleteSelection() || true;
|
||
if (event.key === 'Escape' && this.picked.size > 0) {
|
||
this.pick([]);
|
||
return true;
|
||
}
|
||
const grid = patternGrid(asset.props);
|
||
const bar = ticksPerBar(patternMeter(asset.props));
|
||
if (event.key === 'ArrowUp') return this.nudge(0, event.shiftKey ? 12 : 1);
|
||
if (event.key === 'ArrowDown') return this.nudge(0, event.shiftKey ? -12 : -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;
|
||
}
|
||
|
||
override onBlur(): void {
|
||
super.onBlur();
|
||
if (this.gesture?.kind === 'key') this.endGesture();
|
||
}
|
||
|
||
// --- for the check scripts -------------------------------------------------------------------
|
||
|
||
/** Where a tick and a note meet on screen: the middle of the note's row. */
|
||
gridPoint(tick: number, note: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
return {
|
||
x: at.x + KEYS_WIDTH + tickToX(this.view, tick),
|
||
y: at.y + RULER_HEIGHT + noteTop(this.view, note) + this.view.rowHeight / 2,
|
||
};
|
||
}
|
||
|
||
rulerPoint(tick: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
return { x: at.x + KEYS_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 + KEYS_WIDTH + tickToX(this.view, tick), y: at.y + LOOP_STRIP / 2 };
|
||
}
|
||
|
||
keyPoint(note: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
return {
|
||
x: at.x + KEYS_WIDTH / 2,
|
||
y: at.y + RULER_HEIGHT + noteTop(this.view, note) + this.view.rowHeight / 2,
|
||
};
|
||
}
|
||
|
||
/** Where the lane has a velocity, at a tick. */
|
||
lanePoint(tick: number, velocity: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
const top = this.h - VELOCITY_HEIGHT;
|
||
return {
|
||
x: at.x + KEYS_WIDTH + tickToX(this.view, tick),
|
||
y: at.y + top + (1 - velocity) * VELOCITY_HEIGHT,
|
||
};
|
||
}
|
||
|
||
viewInfo(): RollView & {
|
||
start: number;
|
||
playhead: number | null;
|
||
loop: LoopRange | null;
|
||
keys: number | null;
|
||
hover: NoteHit | null;
|
||
cursor: string;
|
||
} {
|
||
const g = this.gesture;
|
||
return {
|
||
...this.view,
|
||
start: this.startTick,
|
||
playhead: this.playhead,
|
||
loop: this.shownLoop(),
|
||
keys: g?.kind === 'key' ? g.note : null,
|
||
hover: this.hover,
|
||
cursor: String(this.cursor ?? ''),
|
||
};
|
||
}
|
||
|
||
/** The rows of the keys as drawn, top to bottom: the note, its label and whether it sounds. */
|
||
keysDrawn(): { note: number; label: string; sounds: boolean }[] {
|
||
const out: { note: number; label: string; sounds: boolean }[] = [];
|
||
for (const note of this.visibleNotes())
|
||
out.push({ note, label: this.keyLabel(note), sounds: this.sounding.has(note) });
|
||
return out;
|
||
}
|
||
|
||
// --- pointer ---------------------------------------------------------------------------------
|
||
|
||
private get gridWidth(): number {
|
||
return Math.max(0, this.w - KEYS_WIDTH);
|
||
}
|
||
|
||
/** The first tick there is anything to look at: the start of the song around the pattern, else 0. */
|
||
private get earliest(): number {
|
||
return Math.min(0, -(this.ghosts?.origin ?? 0));
|
||
}
|
||
|
||
private get gridHeight(): number {
|
||
return Math.max(0, this.h - RULER_HEIGHT - VELOCITY_HEIGHT);
|
||
}
|
||
|
||
private asset(): PatternAsset | null {
|
||
return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'pattern') ?? null) : null;
|
||
}
|
||
|
||
private areaAt(p: Vec2): Area {
|
||
const left = p.x < KEYS_WIDTH;
|
||
if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler';
|
||
if (p.y >= this.h - VELOCITY_HEIGHT) return left ? 'laneLabel' : 'lane';
|
||
return left ? 'keys' : 'grid';
|
||
}
|
||
|
||
/** A point of the widget in the grid's own pixels. */
|
||
private inGrid(p: Vec2): Vec2 {
|
||
return { x: p.x - KEYS_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;
|
||
}
|
||
const g = this.inGrid(p);
|
||
const tick = xToTick(this.view, g.x);
|
||
if (e.button === 2) {
|
||
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
||
if (area === 'grid') {
|
||
this.gesture = { kind: 'erase', erased: new Set() };
|
||
this.erase(asset, g);
|
||
}
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
if (area === 'keys') {
|
||
const note = noteAtY(this.view, g.y);
|
||
if (note !== null) this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) };
|
||
} else if (area === 'ruler' && rulerPartAt(p.y) === 'loop') {
|
||
const loop = patternLoop(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, patternGrid(asset.props), e.altKey || e.shiftKey),
|
||
};
|
||
} else if (area === 'grid') {
|
||
this.pressGrid(asset, e, g);
|
||
} else if (area === 'lane') {
|
||
const id = velocityBarAt(this.view, asset.notes, this.picked, g.x);
|
||
const anchor = asset.notes.find((n) => n.id === id);
|
||
if (anchor) this.gesture = { kind: 'velocity', anchor, value: anchor.velocity };
|
||
}
|
||
this.invalidate();
|
||
}
|
||
|
||
private pressGrid(asset: PatternAsset, e: FederatedPointerEvent, g: Vec2): void {
|
||
const hit = noteAt(this.view, asset.notes, g);
|
||
const anchor = hit ? asset.notes.find((n) => n.id === hit.id) : undefined;
|
||
if (!hit || !anchor) {
|
||
this.gesture = {
|
||
kind: 'empty',
|
||
start: g,
|
||
current: g,
|
||
active: false,
|
||
add: e.shiftKey,
|
||
free: e.altKey || e.shiftKey,
|
||
};
|
||
return;
|
||
}
|
||
let shift: ShiftPress = null;
|
||
if (e.shiftKey) {
|
||
shift = this.picked.has(hit.id) ? 'unpick' : 'added';
|
||
this.picked.add(hit.id);
|
||
} else if (!this.picked.has(hit.id)) this.picked = new Set([hit.id]);
|
||
if (hit.part === 'end') {
|
||
this.gesture = { kind: 'resize', anchor, by: 0, moved: false, shift };
|
||
return;
|
||
}
|
||
if (hit.part === 'start') {
|
||
this.gesture = { kind: 'trim', anchor, by: 0, shift };
|
||
return;
|
||
}
|
||
const grab = xToTick(this.view, g.x) - anchor.tick;
|
||
this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g, shift };
|
||
this.onPreview(anchor.note, false);
|
||
}
|
||
|
||
private onPointerMove(e: FederatedPointerEvent): void {
|
||
const gesture = this.gesture;
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const local = this.toLocal(e.global);
|
||
const g = this.inGrid({ x: local.x, y: local.y });
|
||
if (!gesture) {
|
||
this.followHover(asset, local, g);
|
||
this.followGhost(local, g, e.global);
|
||
return;
|
||
}
|
||
const tick = xToTick(this.view, g.x);
|
||
const grid = patternGrid(asset.props);
|
||
const free = e.altKey || e.shiftKey;
|
||
switch (gesture.kind) {
|
||
case 'pan':
|
||
this.view = clampRows(
|
||
clampTimePan(
|
||
{
|
||
...this.view,
|
||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||
},
|
||
this.gridWidth,
|
||
this.earliest,
|
||
),
|
||
this.gridHeight,
|
||
);
|
||
break;
|
||
case 'key': {
|
||
const note = noteAtY(this.view, g.y);
|
||
if (note === null || note === gesture.note) return;
|
||
this.onRelease(gesture.voice);
|
||
this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) };
|
||
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(patternLoop(asset), gesture.edge, snap(gesture.from), snap(tick));
|
||
break;
|
||
}
|
||
case 'cursor':
|
||
gesture.tick = this.cursorTick(tick, grid, free);
|
||
break;
|
||
case 'empty': {
|
||
gesture.current = g;
|
||
gesture.free = free;
|
||
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 note = noteAtY(this.view, g.y) ?? gesture.anchor.note + gesture.notes;
|
||
const by = moveDelta(asset.notes, this.picked, gesture.anchor, tick - gesture.grab, note, grid, free);
|
||
if (by.notes !== gesture.notes) this.onPreview(gesture.anchor.note + by.notes, false);
|
||
gesture.ticks = by.ticks;
|
||
gesture.notes = by.notes;
|
||
break;
|
||
}
|
||
case 'resize':
|
||
gesture.moved = true;
|
||
gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length;
|
||
break;
|
||
case 'trim':
|
||
gesture.by = draggedStart(gesture.anchor, tick, grid, free) - gesture.anchor.tick;
|
||
break;
|
||
case 'erase':
|
||
this.erase(asset, g);
|
||
break;
|
||
case 'velocity':
|
||
gesture.value = velocityAtY(local.y, this.h - VELOCITY_HEIGHT, VELOCITY_HEIGHT);
|
||
break;
|
||
}
|
||
this.invalidate();
|
||
}
|
||
|
||
private onPointerUp(): void {
|
||
const gesture = this.gesture;
|
||
this.gesture = null;
|
||
const asset = this.asset();
|
||
if (!gesture || !asset) {
|
||
if (gesture?.kind === 'key') this.onRelease(gesture.voice);
|
||
return;
|
||
}
|
||
const grid = patternGrid(asset.props);
|
||
switch (gesture.kind) {
|
||
case 'key':
|
||
this.onRelease(gesture.voice);
|
||
break;
|
||
case 'loop': {
|
||
const same = JSON.stringify(gesture.loop) === JSON.stringify(patternLoop(asset));
|
||
if (gesture.active && gesture.loop && !same)
|
||
this.state.execute(new SetLoopCommand('pattern', asset.id, gesture.loop));
|
||
break;
|
||
}
|
||
case 'cursor':
|
||
this.setStart(gesture.tick);
|
||
break;
|
||
case 'empty':
|
||
if (gesture.active) {
|
||
const caught = notesWithin(this.view, asset.notes, gesture.start, gesture.current);
|
||
this.picked = new Set([...(gesture.add ? this.picked : []), ...caught]);
|
||
} else this.draw(asset, gesture.start, grid, gesture.free);
|
||
break;
|
||
case 'move':
|
||
if (gesture.moved && (gesture.ticks !== 0 || gesture.notes !== 0)) {
|
||
const label = this.picked.size === 1 ? 'Move note' : 'Move notes';
|
||
this.commit(asset, shifted(asset.notes, this.picked, gesture.ticks, gesture.notes), label);
|
||
} else if (!gesture.moved) {
|
||
if (gesture.shift === null) this.picked = new Set([gesture.anchor.id]);
|
||
else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
|
||
}
|
||
break;
|
||
case 'resize':
|
||
if (gesture.by !== 0) {
|
||
this.commit(
|
||
asset,
|
||
resized(asset.notes, this.picked, gesture.by),
|
||
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
|
||
);
|
||
this.lastLength = gesture.anchor.length + gesture.by;
|
||
} else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
|
||
break;
|
||
case 'trim':
|
||
if (gesture.by !== 0) {
|
||
this.commit(
|
||
asset,
|
||
trimmed(asset.notes, this.picked, gesture.by),
|
||
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
|
||
);
|
||
this.lastLength = gesture.anchor.length - gesture.by;
|
||
} else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
|
||
break;
|
||
case 'erase':
|
||
if (gesture.erased.size > 0) {
|
||
this.commit(
|
||
asset,
|
||
asset.notes.filter((n) => !gesture.erased.has(n.id)),
|
||
gesture.erased.size === 1 ? 'Delete note' : 'Delete notes',
|
||
);
|
||
for (const id of gesture.erased) this.picked.delete(id);
|
||
}
|
||
break;
|
||
case 'velocity':
|
||
this.commit(
|
||
asset,
|
||
withVelocity(asset.notes, this.picked, gesture.anchor, gesture.value),
|
||
'Set velocity',
|
||
);
|
||
this.lastVelocity = gesture.value;
|
||
break;
|
||
}
|
||
this.invalidate();
|
||
}
|
||
|
||
/** Lets go of whatever the pointer held, without writing anything. */
|
||
private endGesture(): void {
|
||
if (this.gesture?.kind === 'key') this.onRelease(this.gesture.voice);
|
||
this.gesture = null;
|
||
}
|
||
|
||
private draw(asset: PatternAsset, at: Vec2, grid: number, free: boolean): void {
|
||
const note = noteAtY(this.view, at.y);
|
||
// Outside the pattern is there to look at, not to draw on.
|
||
if (note === null || !this.inside(asset, xToTick(this.view, at.x))) return;
|
||
const drawn = drawnNote(
|
||
xToTick(this.view, at.x),
|
||
note,
|
||
grid,
|
||
free,
|
||
this.lastLength ?? grid,
|
||
this.lastVelocity,
|
||
);
|
||
this.commit(asset, [...asset.notes, drawn], 'Draw note');
|
||
this.lastLength = drawn.length;
|
||
this.picked = new Set([drawn.id]);
|
||
this.onPreview(note, false);
|
||
}
|
||
|
||
private erase(asset: PatternAsset, g: Vec2): void {
|
||
if (this.gesture?.kind !== 'erase') return;
|
||
const shown = asset.notes.filter((n) => !(this.gesture as { erased: Set<string> }).erased.has(n.id));
|
||
const hit = noteAt(this.view, shown, g);
|
||
if (hit) this.gesture.erased.add(hit.id);
|
||
}
|
||
|
||
/** The part of a note under the pointer: an end it can be dragged by shows a resize cursor and lights up. */
|
||
private followHover(asset: PatternAsset, local: Vec2, g: Vec2): void {
|
||
const hit = this.areaAt(local) === 'grid' && local.x <= this.w ? noteAt(this.view, asset.notes, 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();
|
||
}
|
||
|
||
private inside(asset: PatternAsset, tick: number): boolean {
|
||
return tick >= 0 && tick < patternLength(asset);
|
||
}
|
||
|
||
/** A ghost note under the pointer, with no note of the pattern over it, tells where it comes from. */
|
||
private followGhost(local: Vec2, g: Vec2, global: Vec2): void {
|
||
const asset = this.asset();
|
||
const over = this.ghosts && asset && this.areaAt(local) === 'grid' && !noteAt(this.view, asset.notes, g);
|
||
const ghost = over ? ghostAt(this.view, this.ghosts!.notes, g) : null;
|
||
if (!ghost) return tooltips.leave(this);
|
||
tooltips.point(this, `${ghost.tick}:${ghost.note}`, () => this.ghostTooltip(ghost), global);
|
||
}
|
||
|
||
private ghostTooltip(ghost: GhostNote) {
|
||
const at = this.getGlobalPosition();
|
||
const box = noteBox(this.view, ghost);
|
||
return {
|
||
text: `${ghost.track} · ${ghost.pattern}`,
|
||
anchor: {
|
||
x: at.x + KEYS_WIDTH + box.x,
|
||
y: at.y + RULER_HEIGHT + box.y,
|
||
w: Math.max(2, box.w),
|
||
h: box.h,
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Where a paste goes with the pointer over the grid: the grid line before it and its row; else null. */
|
||
private pasteSpot(asset: PatternAsset): { tick: number; note: number } | null {
|
||
const p = this.pointer;
|
||
if (!p || p.x > this.w || p.y < 0 || this.areaAt(p) !== 'grid') return null;
|
||
const g = this.inGrid(p);
|
||
const tick = xToTick(this.view, g.x);
|
||
const note = noteAtY(this.view, g.y);
|
||
// Outside the pattern nothing lands, as nothing is drawn there.
|
||
if (note === null || !this.inside(asset, tick)) return null;
|
||
return { tick: snapDown(tick, patternGrid(asset.props)), note };
|
||
}
|
||
|
||
/** 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: PatternAsset, notes: readonly PatternNote[], label: string): void {
|
||
if (SetPatternNotesCommand.changes(asset, notes))
|
||
this.state.execute(new SetPatternNotesCommand(asset.id, notes, label));
|
||
}
|
||
|
||
private openRulerMenu(asset: PatternAsset, tick: number, global: Vec2): void {
|
||
const bar = ticksPerBar(patternMeter(asset.props));
|
||
const first = snapDown(tick, bar);
|
||
new Menu([
|
||
{
|
||
label: `Loop bar ${first / bar + 1}`,
|
||
onSelect: () =>
|
||
this.state.execute(new SetLoopCommand('pattern', asset.id, { start: first, end: first + bar })),
|
||
},
|
||
{
|
||
label: 'Loop the whole pattern',
|
||
disabled: !asset.loop,
|
||
onSelect: () => this.state.execute(new SetLoopCommand('pattern', asset.id, null)),
|
||
},
|
||
]).open(global.x, global.y);
|
||
}
|
||
|
||
private onWheel(e: FederatedWheelEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
const area = this.areaAt({ x: local.x, y: local.y });
|
||
const g = this.inGrid({ x: local.x, y: local.y });
|
||
const factor = zoomFactor(e.deltaY);
|
||
let view: RollView;
|
||
if (area === 'keys') view = zoomRows(this.view, factor, g.y);
|
||
else if (area === 'ruler' || area === 'grid' || area === 'lane')
|
||
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
||
else return;
|
||
this.view = clampRows(clampTimePan(view, this.gridWidth, this.earliest), this.gridHeight);
|
||
this.needsFit = false;
|
||
this.redraw();
|
||
e.preventDefault();
|
||
}
|
||
|
||
// --- drawing ---------------------------------------------------------------------------------
|
||
|
||
/** The notes as the running gesture would leave them. */
|
||
private shownNotes(asset: PatternAsset): readonly PatternNote[] {
|
||
const g = this.gesture;
|
||
if (g?.kind === 'move' && g.moved) return shifted(asset.notes, this.picked, g.ticks, g.notes);
|
||
if (g?.kind === 'resize') return resized(asset.notes, this.picked, g.by);
|
||
if (g?.kind === 'trim') return trimmed(asset.notes, this.picked, g.by);
|
||
if (g?.kind === 'velocity') return withVelocity(asset.notes, this.picked, g.anchor, g.value);
|
||
if (g?.kind === 'erase') return asset.notes.filter((n) => !g.erased.has(n.id));
|
||
return asset.notes;
|
||
}
|
||
|
||
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 patternLoop(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;
|
||
}
|
||
|
||
/** The notes whose rows are on screen, highest first. */
|
||
private visibleNotes(): number[] {
|
||
const top = noteAtY(this.view, 0) ?? HIGHEST_NOTE;
|
||
const bottom = noteAtY(this.view, this.gridHeight - 1) ?? LOWEST_NOTE;
|
||
const out: number[] = [];
|
||
for (let note = top; note >= bottom; note--) out.push(note);
|
||
return out;
|
||
}
|
||
|
||
private keyLabel(note: number): string {
|
||
return this.names.get(note) ?? (note % 12 === 0 ? noteName(note) : '');
|
||
}
|
||
|
||
override layout(): void {
|
||
if (this.needsFit) this.fit();
|
||
}
|
||
|
||
override redraw(): void {
|
||
if (this.needsFit && this.gridWidth > 0 && this.gridHeight > 0 && this.asset()) {
|
||
this.fit();
|
||
return;
|
||
}
|
||
const c = Theme.colors;
|
||
const w = this.w;
|
||
const h = this.h;
|
||
const gw = this.gridWidth;
|
||
const gh = this.gridHeight;
|
||
const laneTop = h - VELOCITY_HEIGHT;
|
||
this.bg.clear().rect(0, 0, w, h).fill(c.viewport);
|
||
this.bg.rect(0, 0, KEYS_WIDTH, h).fill(c.panel);
|
||
this.bg.rect(0, 0, w, RULER_HEIGHT).fill(c.panelHeader);
|
||
this.bg.rect(0, laneTop, w, VELOCITY_HEIGHT).fill(c.panel);
|
||
this.bg.rect(KEYS_WIDTH - 1, 0, 1, h).fill(c.border);
|
||
this.bg.rect(0, laneTop, w, 1).fill(c.border);
|
||
this.clips.grid.clear().rect(KEYS_WIDTH, RULER_HEIGHT, gw, gh).fill(0xffffff);
|
||
this.clips.keys
|
||
.clear()
|
||
.rect(0, RULER_HEIGHT, KEYS_WIDTH - 1, gh)
|
||
.fill(0xffffff);
|
||
this.clips.ruler.clear().rect(KEYS_WIDTH, 0, gw, RULER_HEIGHT).fill(0xffffff);
|
||
this.clips.lane
|
||
.clear()
|
||
.rect(KEYS_WIDTH, laneTop + 1, gw, VELOCITY_HEIGHT - 1)
|
||
.fill(0xffffff);
|
||
this.gridLayer.position.set(KEYS_WIDTH, RULER_HEIGHT);
|
||
this.keysLayer.position.set(0, RULER_HEIGHT);
|
||
this.ruler.position.set(KEYS_WIDTH, 0);
|
||
this.laneLayer.position.set(KEYS_WIDTH, laneTop);
|
||
this.laneLabel.position.set(Theme.padding, laneTop + Theme.padding);
|
||
|
||
const asset = this.asset();
|
||
this.gridG.clear();
|
||
this.gridOverlay.clear();
|
||
this.laneG.clear();
|
||
this.keysG.clear();
|
||
if (!asset) {
|
||
this.syncKeyLabels([]);
|
||
this.ruler.visible = false;
|
||
this.hint.text = 'No pattern selected – pick one in the Assets panel or create one.';
|
||
this.hint.position.set(KEYS_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
|
||
this.hint.visible = true;
|
||
return;
|
||
}
|
||
this.ruler.visible = true;
|
||
const notes = this.shownNotes(asset);
|
||
const length = patternLength(asset);
|
||
const rows = this.visibleNotes();
|
||
this.drawRows(rows, gw);
|
||
this.drawLines(asset, gw, gh, length);
|
||
this.drawGhosts(gw, gh);
|
||
this.drawNotes(notes, length, gw, gh);
|
||
this.drawLane(notes, length, gw);
|
||
this.drawKeys(rows);
|
||
this.ruler.draw(this.view, patternMeter(asset.props), gw, {
|
||
length,
|
||
loop: this.shownLoop(),
|
||
start: this.shownStart(),
|
||
playhead: this.playhead,
|
||
});
|
||
const g = this.gesture;
|
||
if (g?.kind === 'empty' && g.active) drawBand(this.gridOverlay, g.start, g.current);
|
||
const start = tickToX(this.view, this.shownStart());
|
||
if (start >= 0 && start <= gw) this.gridOverlay.rect(start, 0, 1, gh).fill({ color: c.text, alpha: 0.3 });
|
||
if (this.playhead !== null) {
|
||
const x = tickToX(this.view, this.playhead);
|
||
if (x >= 0 && x <= gw) {
|
||
this.gridOverlay.rect(x - 0.5, 0, 1, gh).fill(c.hot);
|
||
this.laneG.rect(x - 0.5, 0, 1, VELOCITY_HEIGHT).fill(c.hot);
|
||
}
|
||
}
|
||
this.hint.text = asset.notes.length === 0 ? 'Click to draw a note, drag to pick notes, Space plays.' : '';
|
||
this.hint.visible = this.hint.text !== '';
|
||
this.hint.position.set(KEYS_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
|
||
}
|
||
|
||
/** A row per note, the black keys' rows darker, a line under every C. */
|
||
private drawRows(rows: readonly number[], gw: number): void {
|
||
const c = Theme.colors;
|
||
const rh = this.view.rowHeight;
|
||
for (const note of rows) {
|
||
const y = noteTop(this.view, note);
|
||
this.gridG
|
||
.rect(0, y, gw, rh)
|
||
.fill(isSharp(note) ? { color: 0x000000, alpha: 0.22 } : { color: 0xffffff, alpha: 0.025 });
|
||
if (note % 12 === 0) this.gridG.rect(0, y + rh - 1, gw, 1).fill({ color: c.border, alpha: 0.9 });
|
||
}
|
||
}
|
||
|
||
private drawLines(asset: PatternAsset, gw: number, gh: number, length: number): void {
|
||
const c = Theme.colors;
|
||
const alpha = [1, 0.55, 0.25];
|
||
const grid = patternGrid(asset.props);
|
||
for (const line of timeLines(this.view, patternMeter(asset.props), grid, gw, this.earliest)) {
|
||
this.gridG.rect(line.x, 0, 1, gh).fill({ color: c.border, alpha: alpha[line.level] });
|
||
if (line.level === 0)
|
||
this.laneG.rect(line.x, 0, 1, VELOCITY_HEIGHT).fill({ color: c.border, alpha: 0.6 });
|
||
}
|
||
const end = tickToX(this.view, length);
|
||
if (end < gw) {
|
||
this.gridG.rect(Math.max(0, end), 0, gw - Math.max(0, end), gh).fill({ color: 0x000000, alpha: 0.35 });
|
||
this.laneG
|
||
.rect(Math.max(0, end), 0, gw - Math.max(0, end), VELOCITY_HEIGHT)
|
||
.fill({ color: 0x000000, alpha: 0.25 });
|
||
}
|
||
const zero = Math.min(gw, tickToX(this.view, 0));
|
||
if (zero > 0) {
|
||
this.gridG.rect(0, 0, zero, gh).fill({ color: 0x000000, alpha: 0.5 });
|
||
this.laneG.rect(0, 0, zero, VELOCITY_HEIGHT).fill({ color: 0x000000, alpha: 0.35 });
|
||
}
|
||
// Above the highest note and below the lowest is there to look at too.
|
||
const top = Math.min(gh, noteTop(this.view, HIGHEST_NOTE));
|
||
const bottom = Math.max(0, noteTop(this.view, LOWEST_NOTE) + this.view.rowHeight);
|
||
if (top > 0) this.gridG.rect(0, 0, gw, top).fill({ color: 0x000000, alpha: 0.5 });
|
||
if (bottom < gh) this.gridG.rect(0, bottom, gw, gh - bottom).fill({ color: 0x000000, alpha: 0.5 });
|
||
}
|
||
|
||
private drawGhosts(gw: number, gh: number): void {
|
||
for (const ghost of this.ghosts?.notes ?? []) {
|
||
const box = noteBox(this.view, ghost);
|
||
if (box.x > gw || box.x + box.w < 0 || box.y > gh || box.y + box.h < 0) continue;
|
||
this.gridG
|
||
.rect(box.x, box.y + 1, Math.max(2, box.w), Math.max(1, box.h - 1))
|
||
.fill({ color: GHOST, alpha: GHOST_ALPHA });
|
||
}
|
||
}
|
||
|
||
private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void {
|
||
const c = Theme.colors;
|
||
for (const note of notes) {
|
||
const box = noteBox(this.view, note);
|
||
if (box.x > gw || box.x + box.w < 0 || box.y > gh || box.y + box.h < 0) continue;
|
||
const picked = this.picked.has(note.id);
|
||
const alpha = note.tick >= length ? BEYOND_ALPHA : 1;
|
||
const w = Math.max(2, box.w);
|
||
this.gridG
|
||
.rect(box.x, box.y + 1, w, Math.max(1, box.h - 1))
|
||
.fill({ color: picked ? mix(HARD, 0xffffff, 0.35) : mix(SOFT, HARD, note.velocity), alpha })
|
||
.stroke({ width: 1, color: picked ? c.accentText : c.viewport, alpha, alignment: 1 });
|
||
if (this.hover?.id === note.id && this.hover.part !== 'body')
|
||
this.drawGrip({ ...box, w: Math.max(4, box.w) }, this.hover.part);
|
||
}
|
||
}
|
||
|
||
/** The end of a note the pointer would drag, lit, as on an item of the Song tab. */
|
||
private drawGrip(box: NoteBox, part: 'start' | 'end'): void {
|
||
const reach = Math.min(END_GRIP, box.w / 3);
|
||
const y = box.y + 1;
|
||
const h = Math.max(1, box.h - 1);
|
||
this.gridG
|
||
.rect(part === 'end' ? box.x + box.w - reach : box.x, y, reach, h)
|
||
.fill({ color: 0xffffff, alpha: 0.35 });
|
||
this.gridG.rect(part === 'end' ? box.x + box.w - 2 : box.x, y, 2, h).fill(Theme.colors.accentText);
|
||
}
|
||
|
||
private drawLane(notes: readonly PatternNote[], length: number, gw: number): void {
|
||
const c = Theme.colors;
|
||
this.laneG.rect(0, VELOCITY_HEIGHT / 2, gw, 1).fill({ color: c.border, alpha: 0.5 });
|
||
for (const note of notes) {
|
||
const x = tickToX(this.view, note.tick);
|
||
if (x < -8 || x > gw) continue;
|
||
const top = (1 - note.velocity) * (VELOCITY_HEIGHT - 2) + 2;
|
||
const color = this.picked.has(note.id) ? c.accentText : mix(SOFT, HARD, note.velocity);
|
||
const alpha = note.tick >= length ? BEYOND_ALPHA : 1;
|
||
this.laneG.rect(x, top, 2, VELOCITY_HEIGHT - top).fill({ color, alpha });
|
||
this.laneG
|
||
.rect(x, top, Math.max(2, Math.min(8, note.length * this.view.pxPerTick)), 2)
|
||
.fill({ color, alpha });
|
||
}
|
||
}
|
||
|
||
/** The keys, the ones the instrument plays nothing for greyed out, the held one lit. */
|
||
private drawKeys(rows: readonly number[]): void {
|
||
const c = Theme.colors;
|
||
const rh = this.view.rowHeight;
|
||
const held = this.gesture?.kind === 'key' ? this.gesture.note : null;
|
||
const labelled: { note: number; y: number }[] = [];
|
||
for (const note of rows) {
|
||
const y = noteTop(this.view, note);
|
||
const sounds = this.sounding.has(note);
|
||
const fill =
|
||
note === held
|
||
? c.accent
|
||
: isSharp(note)
|
||
? sounds
|
||
? BLACK
|
||
: MUTED_BLACK
|
||
: sounds
|
||
? WHITE
|
||
: MUTED_WHITE;
|
||
this.keysG
|
||
.rect(0, y, KEYS_WIDTH - 1, rh)
|
||
.fill(fill)
|
||
.stroke({ width: 1, color: c.border, alignment: 1 });
|
||
if (rh >= 9 && this.keyLabel(note)) labelled.push({ note, y });
|
||
}
|
||
this.syncKeyLabels(labelled);
|
||
}
|
||
|
||
private syncKeyLabels(rows: readonly { note: number; y: number }[]): void {
|
||
const rh = this.view.rowHeight;
|
||
rows.forEach(({ note, y }, i) => {
|
||
let text = this.keyTexts[i];
|
||
if (!text) {
|
||
text = crispText({ style: DARK_LABEL });
|
||
this.keyTexts.push(text);
|
||
this.keyLabels.addChild(text);
|
||
}
|
||
const label = this.keyLabel(note);
|
||
if (text.text !== label) text.text = label;
|
||
text.style = isSharp(note) ? textStyle('small') : DARK_LABEL;
|
||
text.visible = true;
|
||
text.position.set(4, y + (rh - text.height) / 2);
|
||
});
|
||
for (let i = rows.length; i < this.keyTexts.length; i++) this.keyTexts[i]!.visible = false;
|
||
}
|
||
}
|