Draw notes for an instrument in a piano roll and loop them #333
game-maker / gate (push) Successful in 3m22s
game-maker / suite (push) Failing after 13m18s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 15:06:51 +02:00
1 parent 9c71eb1a0d
commit 4ea752feef
29 files changed
+2744 -10

No files matched your search

+17 -2
View File
@@ -74,6 +74,7 @@ import { ParticlesEditor } from './particles/ParticlesEditor';
import { ShaderEditor } from './shader/ShaderEditor';
import { SoundFxEditor } from './soundfx/SoundFxEditor';
import { InstrumentEditor } from './instrument/InstrumentEditor';
import { PatternEditor } from './pattern/PatternEditor';
import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts';
import type { PlayFrom } from '../core/model/playSettings';
@@ -97,6 +98,7 @@ export type CentreTab =
| 'particles'
| 'soundfx'
| 'instrument'
| 'pattern'
| 'game'
| 'texture'
| 'vectorart'
@@ -148,6 +150,7 @@ const PANE_ORDER = [
'particles',
'soundfx',
'instrument',
'pattern',
'texture',
'vectorart',
'pixelart',
@@ -169,6 +172,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
particles: 'Particles',
soundfx: 'Sound FX',
instrument: 'Instrument',
pattern: 'Pattern',
texture: 'Texture',
vectorart: 'Vector art',
pixelart: 'Pixel art',
@@ -219,6 +223,7 @@ export class Editor implements PlayHost {
readonly particlesEditor: ParticlesEditor;
readonly soundFxEditor: SoundFxEditor;
readonly instrumentEditor: InstrumentEditor;
readonly patternEditor: PatternEditor;
readonly textureEditor: TextureEditor;
readonly vectorArtEditor: VectorArtEditor;
readonly pixelArtEditor: PixelArtEditor;
@@ -306,6 +311,7 @@ export class Editor implements PlayHost {
this.particlesEditor = new ParticlesEditor(state);
this.soundFxEditor = new SoundFxEditor(state);
this.instrumentEditor = new InstrumentEditor(state);
this.patternEditor = new PatternEditor(state);
this.textureEditor = new TextureEditor(state);
this.vectorArtEditor = new VectorArtEditor(state);
this.pixelArtEditor = new PixelArtEditor(state);
@@ -416,6 +422,7 @@ export class Editor implements PlayHost {
particles: this.particlesEditor,
soundfx: this.soundFxEditor,
instrument: this.instrumentEditor,
pattern: this.patternEditor,
texture: this.textureEditor,
vectorart: this.vectorArtEditor,
pixelart: this.pixelArtEditor,
@@ -486,7 +493,9 @@ export class Editor implements PlayHost {
stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(),
copy: () =>
(this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || copySelection(state),
(this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) ||
(this.tab === 'pattern' && this.patternEditor.canvas.copy()) ||
copySelection(state),
paste: () => this.paste(),
removePoint: () => this.removePoint(),
}),
@@ -583,6 +592,8 @@ export class Editor implements PlayHost {
return this.vectorArtEditor.canvas.removePickedPoint();
case 'curve':
return this.curveEditor.canvas.removePickedPoint();
case 'pattern':
return this.patternEditor.canvas.deleteSelection();
default:
return false;
}
@@ -609,6 +620,8 @@ export class Editor implements PlayHost {
return this.soundFxEditor.canvas.paste();
case 'instrument':
return this.instrumentEditor.canvas.paste();
case 'pattern':
return this.patternEditor.canvas.paste();
default:
return false;
}
@@ -995,6 +1008,7 @@ export class Editor implements PlayHost {
if (this.tab === 'tile') this.tileEditor.tick(step);
// The preview is the editor's own, so it runs on while the game is paused.
if (this.tab === 'curve') this.curveEditor.tick(dt);
if (this.tab === 'pattern') this.patternEditor.tick();
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
// to do it, and its states move just the same.
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
@@ -1027,7 +1041,8 @@ export class Editor implements PlayHost {
(this.tab === 'shader' && this.shaderEditor.animates) ||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
(this.tab === 'tile' && this.tileEditor.isPlaying) ||
(this.tab === 'curve' && this.curveEditor.isPlaying)
(this.tab === 'curve' && this.curveEditor.isPlaying) ||
(this.tab === 'pattern' && this.patternEditor.isPlaying)
);
}
+1
View File
@@ -11,6 +11,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
particles: 'particles',
soundfx: 'soundfx',
instrument: 'instrument',
pattern: 'pattern',
texture: 'texture',
vectorart: 'vectorart',
pixelart: 'pixelart',
+38
View File
@@ -0,0 +1,38 @@
import { findAssetOf } from '../../core/model/Project';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { centreOf, type HookContext } from './hookHelpers';
/** The Pattern tab and its piano roll (#333). */
export function patternHooks({ state, editor }: HookContext) {
const { patternEditor } = editor;
const roll = patternEditor.canvas;
return {
pattern: {
current: () => patternEditor.currentAssetId,
info: () => patternEditor.shownInfo,
rect: () => patternEditor.canvasRect(),
/** The notes as the model holds them, and the loop if one is set. */
notes: (assetId: string) => {
const asset = findAssetOf(state.project, assetId, 'pattern');
return asset ? (JSON.parse(JSON.stringify(asset.notes)) as unknown) : null;
},
loop: (assetId: string) => findAssetOf(state.project, assetId, 'pattern')?.loop ?? null,
picked: () => roll.pickedIds,
/** Where a tick of a note's row, a tick of the ruler, a key, a velocity of the lane are on screen. */
gridAt: (tick: number, note: number) => roll.gridPoint(tick, note),
rulerAt: (tick: number) => roll.rulerPoint(tick),
keyAt: (note: number) => roll.keyPoint(note),
laneAt: (tick: number, velocity: number) => roll.lanePoint(tick, velocity),
view: () => roll.viewInfo(),
keys: () => roll.keysDrawn(),
playAt: () => ({ ...centreOf(patternEditor.playButton), text: patternEditor.playButton.text }),
gridButton: () => ({ ...centreOf(patternEditor.gridButton), text: patternEditor.gridButton.text }),
fitAt: () => centreOf(patternEditor.fitButton),
playing: () => patternEditor.isPlaying,
/** The notes the scheduler handed to the player since Play, and where it stands. */
planned: () => [...patternEditor.music.planned],
position: () => patternEditor.music.current?.position() ?? null,
clock: () => soundLibrary.clock(),
},
};
}
+2
View File
@@ -13,6 +13,7 @@ import {
stageNodes,
} from '../viewport/SoundLibrary';
import { curveHooks } from './debug/curveHooks';
import { patternHooks } from './debug/patternHooks';
import { documentHooks } from './debug/documentHooks';
import { graphHooks } from './debug/graphHooks';
import { centreOf, type HookContext } from './debug/hookHelpers';
@@ -44,6 +45,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
...pixelArtHooks(context),
...vectorArtHooks(context),
...curveHooks(context),
...patternHooks(context),
...renderHooks(context),
/**
* Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing
+94
View File
@@ -0,0 +1,94 @@
import { Container, Graphics, type Text } from 'pixi.js';
import type { LoopRange } from '../../core/music/sequence';
import { type Meter, ticksPerBar } from '../../core/music/time';
import { crispText, textStyle, Theme } from '../../ui';
import { barLabelStep, RULER_HEIGHT, type TimeView, tickToX, timeLines } from './timeView';
/** The strip at the top of the ruler that shows the loop. */
export const LOOP_STRIP = 6;
export interface RulerMarks {
/** Where the piece ends, in ticks: the time before it reads lighter. */
length: number;
loop: LoopRange | null;
/** Where playing starts. */
start: number;
playhead: number | null;
}
/**
* The ruler above the piano roll (#333), meant for the song tab too (#334): bars numbered from 1,
* the beats as short ticks, the loop as a band along its top, where playing starts as a small
* triangle at its foot and the playhead as one at its head. Draws `width` pixels from its own x 0,
* with the view measured from there.
*/
export class TimeRuler extends Container {
private readonly g = new Graphics();
private readonly labels = new Container();
private readonly texts: Text[] = [];
constructor() {
super();
this.eventMode = 'none';
this.addChild(this.g, this.labels);
}
draw(view: TimeView, meter: Meter, width: number, marks: RulerMarks): void {
const c = Theme.colors;
const g = this.g.clear();
g.rect(0, 0, width, RULER_HEIGHT).fill(c.panelHeader);
const end = Math.min(width, tickToX(view, marks.length));
const begin = Math.max(0, tickToX(view, 0));
if (end > begin)
g.rect(begin, LOOP_STRIP, end - begin, RULER_HEIGHT - LOOP_STRIP).fill({ color: c.accent, alpha: 0.1 });
if (marks.loop) {
const x0 = Math.max(0, tickToX(view, marks.loop.start));
const x1 = Math.min(width, tickToX(view, marks.loop.end));
if (x1 > x0) g.rect(x0, 0, x1 - x0, LOOP_STRIP).fill({ color: c.accent, alpha: 0.75 });
}
g.rect(0, RULER_HEIGHT - 1, width, 1).fill(c.border);
const bar = ticksPerBar(meter);
const every = barLabelStep(view, meter);
let used = 0;
// The grid is no business of the ruler: bars and beats only.
for (const line of timeLines(view, meter, Number.MAX_SAFE_INTEGER, width)) {
if (line.x < 0) continue;
const tall = line.level === 0;
g.rect(line.x, tall ? LOOP_STRIP : RULER_HEIGHT - 5, 1, tall ? RULER_HEIGHT - LOOP_STRIP : 5).fill({
color: c.textMuted,
alpha: tall ? 0.8 : 0.6,
});
const number = line.tick / bar;
if (!tall || number % every !== 0) continue;
let text = this.texts[used];
if (!text) {
text = crispText({ style: textStyle('small') });
this.texts.push(text);
this.labels.addChild(text);
}
text.visible = true;
if (text.text !== String(number + 1)) text.text = String(number + 1);
text.position.set(line.x + 3, LOOP_STRIP);
used += 1;
}
for (let i = used; i < this.texts.length; i++) this.texts[i]!.visible = false;
const start = tickToX(view, marks.start);
if (start >= -5 && start <= width + 5)
g.moveTo(start - 5, RULER_HEIGHT - 1)
.lineTo(start + 5, RULER_HEIGHT - 1)
.lineTo(start, RULER_HEIGHT - 7)
.closePath()
.fill(c.text);
if (marks.playhead !== null) {
const x = tickToX(view, marks.playhead);
if (x >= -5 && x <= width + 5)
g.moveTo(x - 5, 0)
.lineTo(x + 5, 0)
.lineTo(x, 7)
.closePath()
.fill(c.hot);
}
}
}
+84
View File
@@ -0,0 +1,84 @@
import { type Meter, ticksPerBar, ticksPerBeat } from '../../core/music/time';
import { clamp } from '../../ui';
/**
* The time axis of the piano roll (#333), and of the song tab after it (#334), apart from Pixi so a
* test can drive it: ticks run left to right, `x` is measured from the left edge of the area that
* shows them. Only across; what is stacked above each other is up to the tab.
*/
export interface TimeView {
/** Pixels from the area's left edge to tick 0. */
panX: number;
pxPerTick: number;
}
export const RULER_HEIGHT = 22;
/** A bar of 4/4 from 8 pixels to a 1/32 of 60. */
export const MIN_PX_PER_TICK = 0.004;
export const MAX_PX_PER_TICK = 1;
const BAR_LABEL_MIN_PX = 36;
const BEAT_MIN_PX = 6;
const GRID_MIN_PX = 5;
export function tickToX(view: TimeView, tick: number): number {
return view.panX + tick * view.pxPerTick;
}
export function xToTick(view: TimeView, x: number): number {
return (x - view.panX) / view.pxPerTick;
}
/** Zooms about `x`, so the tick under the pointer stays under it. */
export function zoomTime(view: TimeView, factor: number, x: number): TimeView {
const pxPerTick = clamp(view.pxPerTick * factor, MIN_PX_PER_TICK, MAX_PX_PER_TICK);
const tick = xToTick(view, x);
return { panX: x - tick * pxPerTick, pxPerTick };
}
/** Time starts at 0: nothing to look at left of it. */
export function clampTimePan<V extends TimeView>(view: V): V {
return view.panX > 0 ? { ...view, panX: 0 } : view;
}
/** `length` ticks across `width` pixels, from tick 0. */
export function fitTime(length: number, width: number): TimeView {
return { panX: 0, pxPerTick: clamp(width / Math.max(1, length), MIN_PX_PER_TICK, MAX_PX_PER_TICK) };
}
/** A line across the time: 0 a bar, 1 a beat, 2 a step of the grid. */
export interface TimeLine {
tick: number;
x: number;
level: 0 | 1 | 2;
}
/**
* The lines from `0` to `width` pixels, each tick only at its strongest level. Beats and the grid
* are left out where they would stand closer than a few pixels.
*/
export function timeLines(view: TimeView, meter: Meter, grid: number, width: number): TimeLine[] {
const levels = new Map<number, 0 | 1 | 2>();
const from = Math.max(0, xToTick(view, 0));
const to = xToTick(view, width);
const spans: [number, 0 | 1 | 2, number][] = [
[ticksPerBar(meter), 0, 0],
[ticksPerBeat(meter), 1, BEAT_MIN_PX],
[grid, 2, GRID_MIN_PX],
];
for (const [step, level, minPx] of spans) {
if (step * view.pxPerTick < minPx) continue;
for (let tick = Math.ceil(from / step) * step; tick <= to; tick += step)
if (!levels.has(tick)) levels.set(tick, level);
}
return [...levels]
.map(([tick, level]) => ({ tick, x: tickToX(view, tick), level }))
.sort((a, b) => a.tick - b.tick);
}
/** Every `n`-th bar gets its number on the ruler, `n` a power of two so the numbers never overlap. */
export function barLabelStep(view: TimeView, meter: Meter): number {
const barPx = ticksPerBar(meter) * view.pxPerTick;
let n = 1;
while (n * barPx < BAR_LABEL_MIN_PX && n < 1024) n *= 2;
return n;
}
+2 -2
View File
@@ -36,8 +36,8 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
sound: 'Sounds',
// Not "Sound FX": that is the `+` menu entry (see Vector art).
soundfx: 'Sound effects',
// Where patterns and songs join it (#330).
instrument: 'Music',
instrument: 'Instruments',
pattern: 'Patterns',
particles: 'Particles',
};
+2
View File
@@ -17,6 +17,7 @@ import list from './icons/list.svg?raw';
import instrument from './icons/instrument.svg?raw';
import joint from './icons/joint.svg?raw';
import palette from './icons/palette.svg?raw';
import pattern from './icons/pattern.svg?raw';
import particles from './icons/particles.svg?raw';
import pixelart from './icons/pixelart.svg?raw';
import plugins from './icons/plugins.svg?raw';
@@ -50,6 +51,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
joint,
palette,
particles,
pattern,
pixelart,
rigidbody,
scene,
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- Notes of a piano roll: bars at their pitch and time. -->
<rect x="-6.5" y="-5.5" width="13" height="11" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.25" />
<rect x="-4.5" y="-3.5" width="4" height="2" fill="currentColor" />
<rect x="-1" y="-0.5" width="5.5" height="2" fill="currentColor" />
<rect x="-3" y="2.2" width="3" height="1.6" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 481 B

+202
View File
@@ -0,0 +1,202 @@
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import {
instrumentBuildFor,
instrumentSourcesOf,
namedNotes,
playedNotes,
} from '../../core/instrument/compile';
import {
GRID_OPTIONS,
PATTERN_GRID_KEY,
patternInstrument,
patternLoop,
patternMeter,
patternSequence,
patternTempo,
} from '../../core/music/pattern';
import type { Asset, PatternAsset } from '../../core/model/Asset';
import { textOf } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { playNote } from '../../runtime/systems/behaviorContext';
import { Button, Dropdown, Label, Menu } from '../../ui';
import { Music } from '../../viewport/music';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor';
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
import type { EditorState } from '../state';
import { PianoRoll } from './PianoRoll';
const INFO_WIDTH = 200;
const GRID_WIDTH = 64;
/** How long a note drawn or moved sounds. */
const PREVIEW_SECONDS = 0.3;
/** Whose voices the tab starts for a key or a drawn note, apart from those the scheduler plays. */
const PREVIEW_OWNER = 'pattern-preview';
/**
* The Pattern tab (#333): the piano roll, and in the header Play – the Space bar does the same –
* and the grid notes are drawn on. The pattern plays round its loop through the scheduler (#332);
* an edit is heard from its next window on, without starting over.
*/
export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
/** Public for the check scripts. */
readonly playButton: Button;
readonly gridButton = new Dropdown('1/16', { width: GRID_WIDTH });
readonly music = new Music(() => this.state.project);
/** The pattern playing, `''` while none is. */
private playingId = '';
/** What the scheduler was last told, so only a change is passed on. */
private told = '';
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
this.playButton = new Button('Play', {
icon: TOOLBAR_ICON_SVG.play,
minWidth: 72,
tooltip: 'Play the pattern round its loop (Space)',
onClick: () => this.togglePlay(),
});
this.gridButton.onClick = () => this.openGridMenu();
const roll = new PianoRoll(state);
roll.onPreview = (note, held) => this.preview(note, held);
roll.onRelease = (voice) => soundLibrary.release(voice);
roll.onTogglePlay = () => this.togglePlay();
roll.onStart = (tick) => {
if (this.isPlaying) this.music.current?.seek(tick);
};
this.mount(roll, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]);
state.on('load', () => this.stop());
}
override selectedId(): string {
return this.state.selectedPatternId ?? '';
}
override isMine(asset: Asset): asset is PatternAsset {
return asset.type === 'pattern';
}
get isPlaying(): boolean {
return this.playingId !== '';
}
togglePlay(): void {
if (this.isPlaying) this.stop();
else this.play();
}
/** Plays the pattern shown from where the ruler says, round its loop. */
play(): void {
const asset = this.asset();
if (!asset) return;
this.playingId = asset.id;
this.told = this.settingsOf(asset);
void this.music.play(patternSequence(asset), {
tempo: patternTempo(asset.props),
meter: patternMeter(asset.props),
loop: patternLoop(asset),
from: this.canvas.start,
});
this.syncPlayButton();
}
stop(): void {
this.music.stop();
this.playingId = '';
this.canvas.playhead = null;
this.canvas.invalidate();
this.syncPlayButton();
}
/** Only called while the tab is visible: the playhead follows what is heard. */
tick(): void {
if (!this.isPlaying) return;
this.canvas.playhead = this.music.current?.position().ticks ?? null;
this.canvas.invalidate();
}
override destroy(options?: Parameters<AssetTabEditor<PatternAsset, PianoRoll>['destroy']>[0]): void {
this.music.stop();
super.destroy(options);
}
/** A key of the roll is held until it is let go; a note drawn or moved sounds for a moment. */
private preview(note: number, held: boolean): number {
const asset = this.asset();
const instrument = asset ? patternInstrument(asset.props) : '';
if (!instrument) return 0;
const struck = { note, velocity: 1, volume: 1, duration: held ? 0 : PREVIEW_SECONDS };
return playNote(
{ project: this.state.project, audio: soundLibrary },
instrument,
struck,
PREVIEW_OWNER,
{},
);
}
private openGridMenu(): void {
const asset = this.asset();
if (!asset) return;
const current = textOf(asset.props[PATTERN_GRID_KEY] ?? '');
new Menu(
GRID_OPTIONS.map((option) => ({
label: option.label,
current: option.value === current,
onSelect: () =>
this.state.execute(new SetAssetPropertyCommand(asset.id, PATTERN_GRID_KEY, option.value)),
})),
).openBelow(this.gridButton);
}
private settingsOf(asset: PatternAsset): string {
return JSON.stringify([patternTempo(asset.props), patternMeter(asset.props), patternLoop(asset)]);
}
/** What changed while playing goes to the scheduler; another pattern shown stops it. */
private follow(asset: PatternAsset | null): void {
if (!this.isPlaying) return;
if (!asset || asset.id !== this.playingId) {
this.stop();
return;
}
this.music.edit(patternSequence(asset));
const settings = this.settingsOf(asset);
if (settings === this.told) return;
this.told = settings;
const scheduler = this.music.current;
scheduler?.setTempo(patternTempo(asset.props));
scheduler?.setMeter(patternMeter(asset.props));
scheduler?.setLoop(patternLoop(asset));
}
private syncPlayButton(): void {
this.playButton.setIcon(this.isPlaying ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play);
this.playButton.setText(this.isPlaying ? 'Stop' : 'Play');
}
protected override infoText(asset: PatternAsset): string {
const count = asset.notes.length;
const notes = `${count} ${count === 1 ? 'note' : 'notes'}`;
const instrument = findAssetOf(this.state.project, patternInstrument(asset.props), 'instrument');
return instrument ? `${notes} · ${instrument.name}` : `${notes} · no instrument`;
}
protected override syncExtras(asset: PatternAsset | null): void {
this.fitButton.setEnabled(asset !== null);
this.playButton.setEnabled(asset !== null);
this.gridButton.setEnabled(asset !== null);
this.gridButton.setText(asset ? textOf(asset.props[PATTERN_GRID_KEY] ?? '1/16') : '1/16');
const instrument = asset
? findAssetOf(this.state.project, patternInstrument(asset.props), 'instrument')
: undefined;
if (instrument) {
const sources = instrumentSourcesOf(this.state.project);
const sounding = [...playedNotes(instrument.graph)].filter(
(note) => instrumentBuildFor(instrument, note, sources).layers.length > 0,
);
this.canvas.setInstrument(new Set(sounding), namedNotes(instrument.graph));
} else this.canvas.setInstrument(new Set(), new Map());
this.follow(asset);
}
}
+960
View File
@@ -0,0 +1,960 @@
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;
}
}
+136
View File
@@ -0,0 +1,136 @@
import { newId } from '../../core/ids';
import { HIGHEST_NOTE, LOWEST_NOTE } from '../../core/instrument/notes';
import type { PatternNote } from '../../core/music/pattern';
import { clamp } from '../../ui';
/**
* What the piano roll does to notes (#333), as plain functions on lists so a test can check them.
* Everything snaps to the grid unless `free` – Alt held –, which puts it on the tick.
*/
/** The grid line at or before a tick. */
export function snapDown(tick: number, grid: number): number {
return Math.floor(tick / grid) * grid;
}
/** The grid line nearest to a tick. */
export function snapNear(tick: number, grid: number): number {
return Math.round(tick / grid) * grid;
}
/** A note drawn at a point: starting at the grid line before it, as long as the last one drawn. */
export function drawnNote(
pointerTick: number,
note: number,
grid: number,
free: boolean,
length: number,
velocity: number,
): PatternNote {
const tick = Math.max(0, free ? Math.round(pointerTick) : snapDown(pointerTick, grid));
return { id: newId(), tick, length: Math.max(1, Math.round(length)), note, velocity };
}
/**
* How far a drag moves the picked notes: the grabbed one's start lands on the grid (or the tick),
* and no note goes before tick 0 or off the keyboard.
*/
export function moveDelta(
notes: readonly PatternNote[],
picked: ReadonlySet<string>,
anchor: PatternNote,
wantedStart: number,
wantedNote: number,
grid: number,
free: boolean,
): { ticks: number; notes: number } {
const start = free ? Math.round(wantedStart) : snapNear(wantedStart, grid);
return clampShift(notes, picked, start - anchor.tick, wantedNote - anchor.note);
}
/** A shift of the picked notes cut short where it would take one before tick 0 or off the keyboard. */
export function clampShift(
notes: readonly PatternNote[],
picked: ReadonlySet<string>,
ticks: number,
pitch: number,
): { ticks: number; notes: number } {
const chosen = notes.filter((n) => picked.has(n.id));
if (chosen.length === 0) return { ticks: 0, notes: 0 };
const first = Math.min(...chosen.map((n) => n.tick));
const low = Math.min(...chosen.map((n) => n.note));
const high = Math.max(...chosen.map((n) => n.note));
return {
// `|| 0`: no -0 for a note at tick 0.
ticks: Math.max(ticks, -first) || 0,
notes: clamp(pitch, LOWEST_NOTE - low, HIGHEST_NOTE - high),
};
}
export function shifted(
notes: readonly PatternNote[],
picked: ReadonlySet<string>,
ticks: number,
pitch: number,
): PatternNote[] {
return notes.map((n) => (picked.has(n.id) ? { ...n, tick: n.tick + ticks, note: n.note + pitch } : n));
}
/**
* The length the grabbed note takes when its end is dragged to a tick: to the grid line nearest the
* pointer, but never back to its own start – then to the first grid line after it.
*/
export function draggedLength(anchor: PatternNote, pointerTick: number, grid: number, free: boolean): number {
if (free) return Math.max(1, Math.round(pointerTick) - anchor.tick);
const end = snapNear(pointerTick, grid);
return end > anchor.tick ? end - anchor.tick : snapDown(anchor.tick, grid) + grid - anchor.tick;
}
/** Every picked note lengthened by what the grabbed one was, none shorter than a tick. */
export function resized(
notes: readonly PatternNote[],
picked: ReadonlySet<string>,
by: number,
): PatternNote[] {
return notes.map((n) => (picked.has(n.id) ? { ...n, length: Math.max(1, n.length + by) } : n));
}
/** How far right a copy of the picked notes goes so it stands right behind them: whole grid steps. */
export function duplicateOffset(
notes: readonly PatternNote[],
picked: ReadonlySet<string>,
grid: number,
): number {
const chosen = notes.filter((n) => picked.has(n.id));
if (chosen.length === 0) return 0;
const first = Math.min(...chosen.map((n) => n.tick));
const end = Math.max(...chosen.map((n) => n.tick + n.length));
return Math.max(grid, Math.ceil((end - first) / grid) * grid);
}
/** Copies of notes with ids of their own, moved so the earliest starts at `at`. */
export function placedCopies(notes: readonly PatternNote[], at: number): PatternNote[] {
if (notes.length === 0) return [];
const first = Math.min(...notes.map((n) => n.tick));
return notes.map((n) => ({ ...n, id: newId(), tick: Math.max(0, at) + (n.tick - first) }));
}
/**
* The grabbed note's velocity set to `value`, the other picked notes moved by as much, each held
* to 0 to 1.
*/
export function withVelocity(
notes: readonly PatternNote[],
picked: ReadonlySet<string>,
anchor: PatternNote,
value: number,
): PatternNote[] {
const by = clamp(value, 0, 1) - anchor.velocity;
return notes.map((n) =>
n.id === anchor.id
? { ...n, velocity: clamp(value, 0, 1) }
: picked.has(n.id) && picked.has(anchor.id)
? { ...n, velocity: clamp(n.velocity + by, 0, 1) }
: n,
);
}
+133
View File
@@ -0,0 +1,133 @@
import { HIGHEST_NOTE, LOWEST_NOTE } from '../../core/instrument/notes';
import type { PatternNote } from '../../core/music/pattern';
import type { Vec2 } from '../../core/model/Properties';
import { clamp } from '../../ui';
import { type TimeView, tickToX } from '../music/timeView';
/**
* The piano roll's geometry (#333), apart from Pixi so a test can drive it. Across it is a time
* view; down it are the 128 notes, the highest on top, each a row. `y` is measured from the top of
* the note area, `x` from its left edge, as in the time view.
*/
export interface RollView extends TimeView {
/** Pixels from the area's top edge to the top of the highest note's row. */
panY: number;
rowHeight: number;
}
export const ROWS = HIGHEST_NOTE - LOWEST_NOTE + 1;
export const MIN_ROW_HEIGHT = 6;
export const MAX_ROW_HEIGHT = 28;
export const DEFAULT_ROW_HEIGHT = 14;
/** How far in from a note's right end a press takes its length rather than the note. */
const END_GRIP = 6;
/** A note narrower than this is still this wide to the pointer. */
const MIN_HIT_WIDTH = 4;
/** How close to a bar of the velocity lane a press must be to take it. */
const VELOCITY_REACH = 4;
export function noteTop(view: RollView, note: number): number {
return view.panY + (HIGHEST_NOTE - note) * view.rowHeight;
}
/** The note whose row holds `y`; null above the highest and below the lowest. */
export function noteAtY(view: RollView, y: number): number | null {
const note = HIGHEST_NOTE - Math.floor((y - view.panY) / view.rowHeight);
return note >= LOWEST_NOTE && note <= HIGHEST_NOTE ? note : null;
}
/** Keeps the rows on screen: no gap above the highest note or below the lowest. */
export function clampRows(view: RollView, height: number): RollView {
const min = Math.min(0, height - ROWS * view.rowHeight);
return { ...view, panY: clamp(view.panY, min, 0) };
}
/** Zooms the rows about `y`, so the note under the pointer stays under it. */
export function zoomRows(view: RollView, factor: number, y: number): RollView {
const rowHeight = clamp(view.rowHeight * factor, MIN_ROW_HEIGHT, MAX_ROW_HEIGHT);
const at = (y - view.panY) / view.rowHeight;
return { ...view, rowHeight, panY: y - at * rowHeight };
}
/**
* The rows from `low` to `high` in the middle of `height` pixels, a row of room above and below:
* at the default height where they fit, else lower.
*/
export function centreRows(low: number, high: number, height: number): Pick<RollView, 'panY' | 'rowHeight'> {
const rowHeight = clamp(height / (high - low + 3), MIN_ROW_HEIGHT, DEFAULT_ROW_HEIGHT);
const middle = (HIGHEST_NOTE - (low + high) / 2 + 0.5) * rowHeight;
return { rowHeight, panY: height / 2 - middle };
}
export interface NoteBox {
x: number;
y: number;
w: number;
h: number;
}
export function noteBox(view: RollView, note: Pick<PatternNote, 'tick' | 'length' | 'note'>): NoteBox {
const x = tickToX(view, note.tick);
return { x, y: noteTop(view, note.note), w: note.length * view.pxPerTick, h: view.rowHeight };
}
export interface NoteHit {
id: string;
/** The body moves the note, the end changes its length. */
part: 'body' | 'end';
}
/** The note under a point; where notes lie over each other, the one drawn last. */
export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec2): NoteHit | null {
for (let i = notes.length - 1; i >= 0; i--) {
const note = notes[i]!;
const box = noteBox(view, note);
const w = Math.max(box.w, MIN_HIT_WIDTH);
if (point.x < box.x || point.x >= box.x + w || point.y < box.y || point.y >= box.y + box.h) continue;
const grip = Math.min(END_GRIP, w / 3);
return { id: note.id, part: point.x >= box.x + w - grip ? 'end' : 'body' };
}
return null;
}
/** The notes a band between two points touches. */
export function notesWithin(view: RollView, notes: readonly PatternNote[], a: Vec2, b: Vec2): string[] {
const left = Math.min(a.x, b.x);
const right = Math.max(a.x, b.x);
const top = Math.min(a.y, b.y);
const bottom = Math.max(a.y, b.y);
return notes
.filter((note) => {
const box = noteBox(view, note);
return box.x <= right && box.x + Math.max(box.w, 1) >= left && box.y <= bottom && box.y + box.h >= top;
})
.map((note) => note.id);
}
/**
* The note whose bar in the velocity lane is under `x`: the bar stands at the note's start. Of
* several there, a picked one comes first, else the one drawn last.
*/
export function velocityBarAt(
view: TimeView,
notes: readonly PatternNote[],
picked: ReadonlySet<string>,
x: number,
): string | null {
let found: string | null = null;
let foundPicked = false;
for (const note of notes) {
if (Math.abs(tickToX(view, note.tick) - x) > VELOCITY_REACH) continue;
const isPicked = picked.has(note.id);
if (found === null || isPicked || !foundPicked) {
found = note.id;
foundPicked = isPicked;
}
}
return found;
}
/** The velocity a point in the lane stands for: 1 at its top, 0 at its foot. */
export function velocityAtY(y: number, top: number, height: number): number {
return clamp(1 - (y - top) / height, 0, 1);
}
+7
View File
@@ -780,6 +780,13 @@ export class EditorState {
return findAssetOf(this.project, s.assetId, 'curve')?.id ?? null;
}
/** The pattern the selection refers to (#333); only the asset itself, its notes are picked in the tab. */
get selectedPatternId(): string | null {
const s = this.selection;
if (s?.kind !== 'asset') return null;
return findAssetOf(this.project, s.assetId, 'pattern')?.id ?? null;
}
/** The timeline row the selection is about; undefined when it is about something else. */
get selectedTrackId(): string | undefined {
return this.selection?.kind === 'track' ? this.selection.trackId : undefined;