Files
feuerwerk/src/editor/pattern/PianoRoll.ts
T
adminandClaude Opus 5.5 4ea752feef
game-maker / gate (push) Successful in 3m22s
game-maker / suite (push) Failing after 13m18s
Draw notes for an instrument in a piano roll and loop them #333
Notes could only be played one at a time, by a key or a behavior. A
pattern is a stretch of notes for one instrument, drawn in a piano
roll and played in time through the scheduler of #332. Songs (#334)
will lay patterns out on tracks.

- New asset type pattern under the heading Patterns. Instruments are
  listed under Instruments now, since a heading is one per type.
- Notes are a start and a length in ticks, a note number and a
  velocity, kept to their rules on load and in every command. The
  instrument, the length in bars, the grid, the meter and the tempo
  are rows in the inspector. The loop is the asset's own data, the
  whole pattern while none is set.
- Grids from 1/1 to 1/32 and the triplets 1/4T to 1/32T, all whole
  ticks. The meter only moves the bar lines, the notes stay put.
- The tab shows the keys down the left, a ruler with bars, beats,
  loop and start along the top, the notes between them and a
  velocity lane below. Keys the instrument plays nothing for are
  grey, the names of a drum kit stand on its keys.
- A click draws a note on the grid line before the pointer, as long
  as the last one drawn or lengthened and as hard as the last
  velocity set. The body moves a note, the right end lengthens it,
  Alt leaves the grid. A drag on empty grid picks with a band, Shift
  adds, a right-click deletes.
- Keys: Space plays, Delete, Ctrl+C/V/D/A, the arrows by a semitone
  or a grid step and with Shift by an octave or a bar. A paste lands
  where playing starts. Every gesture is one undo step.
- A click on the ruler sets where playing starts, a drag the loop or
  one of its ends, its menu loops a bar or the whole pattern.
- The wheel scrolls, Shift across, on the ruler or with Alt it zooms.
  Ctrl+wheel stays with the browser, Pixi listens passively.
- Play runs the loop through the scheduler. An edit goes to it on
  the next window without a restart, a new loop or tempo counts from
  what is planned. Another pattern shown or a project opened stops it.
- The time axis and the ruler are their own pieces in editor/music
  for the song tab. The rules of the roll are pure functions in
  rollView and rollEdit.

Findings:
- Music.play could start after a stop that came while it was still
  loading its files. A run counter makes the late start give up.
  Music.edit now loads what a new note needs as well.
- A click on an end of the loop would have set the loop again. A
  press there only moves the end once it is dragged.

Tests:
- pattern.test.ts (new, 9) and piano-roll.test.ts (new, 16): model,
  grids, meter, held notes, sequence, commands, loop, round trip,
  drum names, time lines, rows, hits, bands, lane, edit rules.
- e2e 64 (new) on melektaus-atx: ALL PASSED, 20 checks. e2e 4 with
  29 asset types: ALL PASSED, 62 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 247 test files, 3839 tests.
- Counter-checks: the move without its clamp, the sequence without
  the end of the pattern, the duplicate offset without whole grid
  steps each fail 1 test. Without passing edits to the scheduler the
  e2e check for a note drawn while playing fails, the rest passes.
- Probe on stein2: the tab with a drum kit, before and while playing,
  looked at in two screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:06:51 +02:00

961 lines
34 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 { SetPatternLoopCommand, SetPatternNotesCommand } from '../../core/commands/patternCommands';
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, ui, Widget, zoomFactor } from '../../ui';
import { TimeRuler } from '../music/TimeRuler';
import {
clampTimePan,
fitTime,
RULER_HEIGHT,
tickToX,
timeLines,
xToTick,
zoomTime,
} from '../music/timeView';
import type { EditorState } from '../state';
import {
clampShift,
draggedLength,
drawnNote,
duplicateOffset,
moveDelta,
placedCopies,
resized,
shifted,
snapDown,
snapNear,
withVelocity,
} from './rollEdit';
import {
centreRows,
clampRows,
DEFAULT_ROW_HEIGHT,
noteAt,
noteAtY,
noteBox,
noteTop,
notesWithin,
type RollView,
velocityAtY,
velocityBarAt,
zoomRows,
} from './rollView';
export const KEYS_WIDTH = 84;
export 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;
const DARK_LABEL = textStyle('small').clone();
DARK_LABEL.fill = 0x30343c;
type Area = 'corner' | 'ruler' | 'keys' | 'grid' | 'lane' | 'laneLabel';
type Gesture =
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
| { kind: 'key'; note: number; voice: number }
| {
kind: 'ruler';
from: number;
pointerX: number;
active: boolean;
loop: LoopRange | null;
free: boolean;
/** The end of the loop the press was at: a drag moves it, a click is a click all the same. */
edge: 'start' | 'end' | null;
}
| { 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;
}
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean }
| { 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, lengthened by its right end, and goes with a
* right-click or Delete. The ruler sets where playing starts, and a drag on it the loop.
*/
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 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();
/** 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.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.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();
}
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 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 copies = 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 };
}
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;
} {
const g = this.gesture;
return {
...this.view,
start: this.startTick,
playhead: this.playhead,
loop: this.shownLoop(),
keys: g?.kind === 'key' ? g.note : null,
};
}
/** 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);
}
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') {
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: 'ruler',
from: Math.max(0, tick),
pointerX: g.x,
active: false,
loop: null,
free: e.altKey,
edge,
};
} 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 };
return;
}
if (e.shiftKey) {
if (this.picked.has(hit.id)) this.picked.delete(hit.id);
else this.picked.add(hit.id);
return;
}
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 };
return;
}
const grab = xToTick(this.view, g.x) - anchor.tick;
this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g };
this.onPreview(anchor.note, false);
}
private onPointerMove(e: FederatedPointerEvent): void {
const gesture = this.gesture;
const asset = this.asset();
if (!gesture || !asset) return;
const local = this.toLocal(e.global);
const g = this.inGrid({ x: local.x, y: local.y });
const tick = xToTick(this.view, g.x);
const grid = patternGrid(asset.props);
const free = e.altKey;
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.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 'ruler': {
gesture.free = free;
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));
const at = snap(tick);
const { start, end } = patternLoop(asset);
if (gesture.edge === 'start') gesture.loop = { start: Math.min(at, end - 1), end };
else if (gesture.edge === 'end') gesture.loop = { start, end: Math.max(at, start + 1) };
else {
const from = snap(gesture.from);
gesture.loop = at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) };
}
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 '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 'ruler':
if (gesture.active) {
const same = JSON.stringify(gesture.loop) === JSON.stringify(patternLoop(asset));
if (gesture.loop && !same) this.state.execute(new SetPatternLoopCommand(asset.id, gesture.loop));
} else this.setStart(gesture.free ? Math.round(gesture.from) : snapDown(gesture.from, grid));
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) this.picked = new Set([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;
}
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);
if (note === null) 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);
}
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 SetPatternLoopCommand(asset.id, { start: first, end: first + bar })),
},
{
label: 'Loop the whole pattern',
disabled: !asset.loop,
onSelect: () => this.state.execute(new SetPatternLoopCommand(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 });
let view: RollView = this.view;
if (area === 'ruler' || (e.altKey && area !== 'keys'))
view = { ...view, ...zoomTime(view, zoomFactor(e.deltaY), g.x) };
else if (e.altKey) view = zoomRows(view, zoomFactor(e.deltaY), g.y);
else if (e.shiftKey) view = { ...view, panX: view.panX - e.deltaY };
else view = { ...view, panX: view.panX - e.deltaX, panY: view.panY - e.deltaY };
this.view = clampRows(clampTimePan(view), 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 === '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 === 'ruler' && g.active) return g.loop;
return patternLoop(asset);
}
/** 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.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.startTick,
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.startTick);
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];
for (const line of timeLines(this.view, patternMeter(asset.props), patternGrid(asset.props), gw)) {
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 });
}
}
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 });
}
}
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;
}
}