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 } | { 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(); /** 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 = new Set(); private names: ReadonlyMap = 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, names: ReadonlyMap): 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 }).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; } }