Files
feuerwerk/src/editor/pattern/PianoRoll.ts
T
adminandClaude Opus 5.5 23922ee4a2
game-maker / gate (push) Successful in 3m58s
game-maker / suite (push) Failing after 14m51s
Show the zoom of the music tabs and the EQ as a transition (#377)
The zoom transition of #309 left out the Pattern, Song and Sound tabs
and the Parametric EQ's curve. There a wheel step or Fit still jumped,
whatever "Zoom (ms)" in the general settings said.

- axisTween wraps ViewTween for a single axis. The music tabs zoom
  their time across and their rows, tracks or height down apart, so
  each keeps one tween per axis and either goes its own way.
- Fit by its button is shown as a transition too, as in the other
  tabs. The fit on opening a document still jumps.
- A middle-button pan first lands a transition under way, and saving
  a document's view lands it on the step it was heading for.
- The EQ's range is saved on the node at once. While the zoom is on
  its way the curve draws a range of its own and writes nothing.
  Its dB lines are those of the new range, sliding to their place,
  so no line shows a crooked number.

Found on the way, not part of #377:
- CI task 611 for 82e96e6 failed the stream check of e2e 34. The
  renderer had grown by 60.1 MB with nothing decoded. The check
  summed the RSS of every Chrome renderer, and the test Chrome runs
  four of them. It now measures the largest one, the tab's own, and
  writes all of them into the check's detail.

Tests:
- view-tween.test.ts: 18 tests, 2 new. They fail when axisTween
  writes the wrong offset.
- e2e 34, 64, 65 on melektaus-atx: ALL PASSED, 202 checks, 4 new.
  At 1000 ms each zoom is between its two steps a frame after the
  wheel and lands on the step a jump reaches.
- Stream check: the tab's renderer grew by 8.6 MB streamed and by
  66.9 MB decoded.
- Gate on melektaus-atx: CHECK EXIT 0, 280 files, 4321 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:28:50 +02:00

1201 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Container,
type FederatedPointerEvent,
type FederatedWheelEvent,
Graphics,
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 { axisTween } from '../viewTween';
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 };
/** A zoom or a Fit shown as a transition (#377), the time and the rows each on their own. */
private readonly timeTween = axisTween(
() => ({ scale: this.view.pxPerTick, offset: this.view.panX }),
({ scale, offset }) => (this.view = { ...this.view, pxPerTick: scale, panX: offset }),
() => this.redraw(),
);
private readonly rowTween = axisTween(
() => ({ scale: this.view.rowHeight, offset: this.view.panY }),
({ scale, offset }) => (this.view = { ...this.view, rowHeight: scale, panY: offset }),
() => this.redraw(),
);
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 {
this.timeTween.land();
this.rowTween.land();
return { ...this.view };
}
restoreView(view: unknown): void {
this.view = { ...(view as RollView) };
this.needsFit = false;
this.redraw();
}
/** Fit by hand, shown as a transition. */
fit(): void {
this.timeTween.change(() => this.rowTween.change(() => this.frame()));
}
/** The whole pattern across, and its notes – or what the instrument plays – down the middle. */
private frame(): 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;
moving: boolean;
} {
const g = this.gesture;
return {
...this.view,
moving: this.timeTween.moving || this.rowTween.moving,
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.timeTween.finish();
this.rowTween.finish();
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);
const rows = area === 'keys';
if (!rows && area !== 'ruler' && area !== 'grid' && area !== 'lane') return;
(rows ? this.rowTween : this.timeTween).change(() => {
const view = rows
? zoomRows(this.view, factor, g.y)
: { ...this.view, ...zoomTime(this.view, factor, g.x) };
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.frame();
}
override redraw(): void {
if (this.needsFit && this.gridWidth > 0 && this.gridHeight > 0 && this.asset()) {
this.frame();
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;
}
}