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

+2
View File
@@ -106,6 +106,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'soundfx', graph: createEmptyGraph() };
case 'instrument':
return { ...base, type: 'instrument', graph: starterInstrumentGraph() };
case 'pattern':
return { ...base, type: 'pattern', notes: [] };
case 'particles':
return { ...base, type: 'particles', graph: createEmptyGraph() };
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Editing the notes of a pattern (#333). As with a curve, one gesture is one command: the whole list
* is written once the pointer lets go, so an undo takes the drag back. What is written is held to the
* rules of a note first (`heldPatternNotes`).
*/
import type { PatternAsset } from '../model/Asset';
import { heldPatternNotes, type PatternNote } from '../music/pattern';
import { heldLoop, type LoopRange } from '../music/sequence';
import type { Project } from '../model/Project';
import type { Command } from './Command';
import { requirePattern } from './require';
export class SetPatternNotesCommand implements Command {
private previous: PatternNote[] = [];
constructor(
private readonly assetId: string,
private readonly notes: readonly PatternNote[],
readonly label = 'Edit notes',
) {}
/** Nothing to do when the notes already read exactly like this – no empty undo step. */
static changes(asset: PatternAsset, notes: readonly PatternNote[]): boolean {
return JSON.stringify(asset.notes) !== JSON.stringify(heldPatternNotes(notes));
}
execute(project: Project): void {
const asset = requirePattern(project, this.assetId);
this.previous = asset.notes;
asset.notes = heldPatternNotes(this.notes);
}
undo(project: Project): void {
requirePattern(project, this.assetId).notes = this.previous;
}
}
/** Sets the loop of the ruler, or takes it away (`null`): then the whole pattern is the loop. */
export class SetPatternLoopCommand implements Command {
readonly label: string;
private previous: LoopRange | undefined;
constructor(
private readonly assetId: string,
private readonly loop: LoopRange | null,
) {
this.label = loop ? 'Set loop' : 'Loop the whole pattern';
}
execute(project: Project): void {
const asset = requirePattern(project, this.assetId);
this.previous = asset.loop;
const held = heldLoop(this.loop);
if (held) asset.loop = { start: Math.round(held.start), end: Math.round(held.end) };
else delete asset.loop;
}
undo(project: Project): void {
const asset = requirePattern(project, this.assetId);
if (this.previous) asset.loop = this.previous;
else delete asset.loop;
}
}
+7
View File
@@ -4,6 +4,7 @@ import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type {
Asset,
CurveAsset,
PatternAsset,
TileAsset,
BehaviorAsset,
StateMachineAsset,
@@ -141,6 +142,12 @@ export function requireCurve(project: Project, assetId: string): CurveAsset {
return asset;
}
export function requirePattern(project: Project, assetId: string): PatternAsset {
const asset = findAssetOf(project, assetId, 'pattern');
if (!asset) throw new CommandError(`Pattern ${assetId} not found`);
return asset;
}
export function requireVariablesAsset(project: Project, assetId: string): VariablesAsset {
const asset = findAssetOf(project, assetId, 'variables');
if (!asset) throw new CommandError(`Variables ${assetId} not found`);
+15
View File
@@ -44,6 +44,7 @@ import {
InstrumentOutput,
isInstrumentNodeDef,
loops,
noteNameKey,
noteOutputKey,
noteParam,
notesRange,
@@ -370,3 +371,17 @@ export function playedNotes(graph: Graph): Set<number> {
}
return notes;
}
/** The notes a Notes node with single notes has given a name of their own (a drum kit's). */
export function namedNotes(graph: Graph): Map<number, string> {
const names = new Map<number, string>();
for (const node of graph.nodes) {
if (node.type !== INSTRUMENT_NOTES || !singleNotes(node.params)) continue;
const { from, to } = notesRange(node.params);
for (let note = from; note <= to; note++) {
const name = textOf(node.params[noteNameKey(note)] ?? '').trim();
if (name && !names.has(note)) names.set(note, name);
}
}
return names;
}
+14
View File
@@ -1,4 +1,6 @@
import type { Graph } from '../graph/Graph';
import type { PatternNote } from '../music/pattern';
import type { LoopRange } from '../music/sequence';
import type { PropertyDef } from '../properties/PropertySchema';
import type { AssetType } from './AssetTypes';
import type { PropertyBag, PropertyValue, Vec2 } from './Properties';
@@ -328,6 +330,17 @@ export interface InstrumentAsset extends AssetBase {
graph: Graph;
}
/**
* Notes for one instrument (#333), drawn in a piano roll. The instrument, the length in bars, the
* grid, the meter and the tempo are properties; `loop` is the stretch the playhead runs round, the
* whole pattern while there is none.
*/
export interface PatternAsset extends AssetBase {
type: 'pattern';
notes: PatternNote[];
loop?: LoopRange;
}
/**
* A tile a tilemap paints with: `pictures` names one block per animation step (a texture id for a
* sheet that holds nothing else, a frame id for one block cut out of a sheet), and `props` say
@@ -582,6 +595,7 @@ export type Asset =
| SoundAsset
| SoundFxAsset
| InstrumentAsset
| PatternAsset
| ParticlesAsset;
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
+6 -2
View File
@@ -26,6 +26,7 @@ export const ASSET_TYPES = [
'sound',
'soundfx',
'instrument',
'pattern',
'particles',
] as const;
@@ -59,6 +60,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
sound: 'Sound',
soundfx: 'Sound FX',
instrument: 'Instrument',
pattern: 'Pattern',
particles: 'Particles',
};
@@ -71,8 +73,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
* A shader function is part of other shaders (#303), worn by nothing itself. A sound FX is what a
* sound is played through (#311), heard but never seen, and an instrument what a note is played
* with (#331).
* sound is played through (#311), heard but never seen, an instrument what a note is played
* with (#331) and a pattern the notes it plays (#333).
*/
export type PlaceableAssetType = Exclude<
AssetType,
@@ -91,6 +93,7 @@ export type PlaceableAssetType = Exclude<
| 'sound'
| 'soundfx'
| 'instrument'
| 'pattern'
| 'variables'
| 'struct'
>;
@@ -111,6 +114,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'sound',
'soundfx',
'instrument',
'pattern',
'variables',
'struct',
]);
+157
View File
@@ -0,0 +1,157 @@
/**
* A pattern (#333): notes for one instrument, edited in a piano roll and played by the scheduler
* (#332). The notes are counted in ticks; the meter only says where the bars of the ruler are, so a
* pattern changed from 4/4 to 6/8 keeps every note where it was.
*/
import { newId } from '../ids';
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
import type { PatternAsset } from '../model/Asset';
import { type PropertyBag, textOf } from '../model/Properties';
import { heldLoop, type LoopRange, type Sequence } from './sequence';
import { heldMeter, heldTempo, type Meter, ticksPerBar } from './time';
export interface PatternNote {
id: string;
/** Where it starts and how long it is held, in ticks. */
tick: number;
length: number;
/** As MIDI counts it: 60 is C4. */
note: number;
/** 0 to 1. */
velocity: number;
}
export const PATTERN_INSTRUMENT_KEY = 'instrument';
export const PATTERN_BARS_KEY = 'bars';
export const PATTERN_GRID_KEY = 'grid';
export const PATTERN_BEATS_KEY = 'beats';
export const PATTERN_BEAT_KEY = 'beat';
export const PATTERN_TEMPO_KEY = 'tempo';
export const DEFAULT_BARS = 4;
export const MAX_BARS = 256;
export const DEFAULT_GRID = '1/16';
/** What a new note is struck with until another velocity has been set. */
export const DEFAULT_VELOCITY = 0.8;
/** The grids a note is drawn on, in ticks: with 480 to the quarter every triplet is a whole number. */
const GRIDS: readonly (readonly [string, number])[] = [
['1/1', 1920],
['1/2', 960],
['1/4', 480],
['1/8', 240],
['1/16', 120],
['1/32', 60],
['1/4T', 320],
['1/8T', 160],
['1/16T', 80],
['1/32T', 40],
];
export const GRID_OPTIONS = GRIDS.map(([value]) => ({ value, label: value }));
export const BEAT_OPTIONS = [1, 2, 4, 8, 16, 32].map((unit) => ({ value: String(unit), label: `1/${unit}` }));
export function gridTicks(grid: string): number {
return (GRIDS.find(([value]) => value === grid) ?? GRIDS[4]!)[1];
}
function wholeNumber(
value: PropertyBag[string] | undefined,
fallback: number,
min: number,
max: number,
): number {
const n = typeof value === 'number' ? Math.round(value) : NaN;
return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback;
}
export function patternMeter(props: PropertyBag): Meter {
return heldMeter({
beats: wholeNumber(props[PATTERN_BEATS_KEY], 4, 1, 32),
unit: Number(textOf(props[PATTERN_BEAT_KEY] ?? '4')),
});
}
export function patternTempo(props: PropertyBag): number {
const tempo = props[PATTERN_TEMPO_KEY];
return heldTempo(typeof tempo === 'number' ? tempo : NaN);
}
export function patternBars(props: PropertyBag): number {
return wholeNumber(props[PATTERN_BARS_KEY], DEFAULT_BARS, 1, MAX_BARS);
}
export function patternGrid(props: PropertyBag): number {
return gridTicks(textOf(props[PATTERN_GRID_KEY] ?? DEFAULT_GRID));
}
export function patternInstrument(props: PropertyBag): string {
return textOf(props[PATTERN_INSTRUMENT_KEY] ?? '');
}
/** Where the pattern ends, in ticks: a note starting there or later is kept, but not played. */
export function patternLength(asset: PatternAsset): number {
return patternBars(asset.props) * ticksPerBar(patternMeter(asset.props));
}
/** The loop the playhead runs round: as it was set, cut at the end of the pattern; else all of it. */
export function patternLoop(asset: PatternAsset): LoopRange {
const length = patternLength(asset);
const held = heldLoop(asset.loop ?? null);
if (!held || held.start >= length) return { start: 0, end: length };
return { start: held.start, end: Math.min(held.end, length) };
}
/** What the scheduler plays of it: the notes before its end, with its instrument; none without one. */
export function patternSequence(asset: PatternAsset): Sequence {
const instrument = patternInstrument(asset.props);
if (!instrument) return { notes: [] };
const length = patternLength(asset);
return {
notes: asset.notes
.filter((n) => n.tick < length)
.map((n) => ({
tick: n.tick,
length: Math.min(n.length, length - n.tick),
note: n.note,
velocity: n.velocity,
instrument,
})),
};
}
/** Notes in the order they are played, the lower one first where two start together. */
export function byStart(a: PatternNote, b: PatternNote): number {
return a.tick - b.tick || a.note - b.note;
}
/**
* Held to what a note may be: a start in whole ticks from 0, at least a tick long, a note of the
* keyboard, a velocity from 0 to 1, an id no other note has. Anything else in the list is dropped.
*/
export function heldPatternNotes(raw: unknown): PatternNote[] {
if (!Array.isArray(raw)) return [];
const ids = new Set<string>();
const notes: PatternNote[] = [];
for (const item of raw as unknown[]) {
if (typeof item !== 'object' || item === null) continue;
const n = item as Record<string, unknown>;
const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : NaN);
const tick = Math.round(num(n.tick));
const length = num(n.length) > 0 ? Math.max(1, Math.round(num(n.length))) : NaN;
const note = Math.round(num(n.note));
const velocity = num(n.velocity);
if (!(tick >= 0) || !(length >= 1) || !(note >= LOWEST_NOTE && note <= HIGHEST_NOTE)) continue;
let id = typeof n.id === 'string' && n.id !== '' ? n.id : newId();
while (ids.has(id)) id = newId();
ids.add(id);
notes.push({
id,
tick,
length,
note,
velocity: Number.isFinite(velocity) ? Math.max(0, Math.min(1, velocity)) : DEFAULT_VELOCITY,
});
}
return notes.sort(byStart);
}
+56
View File
@@ -104,6 +104,20 @@ import {
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
import {
BEAT_OPTIONS,
DEFAULT_BARS,
DEFAULT_GRID,
GRID_OPTIONS,
MAX_BARS,
PATTERN_BARS_KEY,
PATTERN_BEAT_KEY,
PATTERN_BEATS_KEY,
PATTERN_GRID_KEY,
PATTERN_INSTRUMENT_KEY,
PATTERN_TEMPO_KEY,
} from '../music/pattern';
import { DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO } from '../music/time';
import { Theme } from '../../ui';
/**
@@ -659,6 +673,48 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
soundfx: [],
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
instrument: [],
// The notes and the loop are intrinsic data on the asset (see PatternAsset). The meter and the
// tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334).
pattern: [
{
key: PATTERN_INSTRUMENT_KEY,
label: 'Instrument',
kind: 'asset',
default: '',
assetTypes: ['instrument'],
},
{
key: PATTERN_BARS_KEY,
label: 'Length (bars)',
kind: 'number',
default: DEFAULT_BARS,
min: 1,
max: MAX_BARS,
step: 1,
decimals: 0,
},
{ key: PATTERN_GRID_KEY, label: 'Grid', kind: 'enum', default: DEFAULT_GRID, options: GRID_OPTIONS },
{
key: PATTERN_BEATS_KEY,
label: 'Beats per bar',
kind: 'number',
default: 4,
min: 1,
max: 32,
step: 1,
decimals: 0,
},
{ key: PATTERN_BEAT_KEY, label: 'Beat', kind: 'enum', default: '4', options: BEAT_OPTIONS },
{
key: PATTERN_TEMPO_KEY,
label: 'Tempo (BPM)',
kind: 'number',
default: DEFAULT_TEMPO,
min: MIN_TEMPO,
max: MAX_TEMPO,
step: 1,
},
],
// How many particles and for how long; what one of them does is the asset's graph, whose Property
// nodes add their rows after these – see `schemaForBag`.
particles: [
+13
View File
@@ -21,6 +21,8 @@ import { isParticleNodeDef } from '../particles/nodes';
import { isShaderNodeDef } from '../shader/nodes';
import { isSoundFxNodeDef } from '../soundfx/nodes';
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
import { heldPatternNotes } from '../music/pattern';
import { heldLoop, type LoopRange } from '../music/sequence';
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
import type {
AnimationKey,
@@ -271,6 +273,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
assets.push(drawing);
} else if (type === 'palette') {
assets.push({ id, type, name, props, colors: normalizePalette(a.colors) });
} else if (type === 'pattern') {
const loop = readLoop(a.loop);
assets.push({ id, type, name, props, notes: heldPatternNotes(a.notes), ...(loop && { loop }) });
} else if (type === 'curve') {
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });
} else if (type === 'animation') {
@@ -650,6 +655,14 @@ function normalizePixelArt(
return { width: canvasSide(raw.width), height: canvasSide(raw.height), layers, frames };
}
/** A loop of a pattern (#333): two numbers of ticks with room between them, else none. */
function readLoop(raw: unknown): LoopRange | null {
const r = asRecord(raw);
const start = asFinite(r.start);
const end = asFinite(r.end);
return start === null || end === null ? null : heldLoop({ start: Math.round(start), end: Math.round(end) });
}
/** Colours as 0xRRGGBB, at most `PALETTE_MAX`; anything that is no colour falls away. */
function normalizePalette(raw: unknown): number[] {
const colors: number[] = [];
+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;
+8 -3
View File
@@ -27,12 +27,13 @@ export interface PlannedNote extends ScheduledNote {
/**
* The scheduler on the sound library's clock (#332), its notes played by the instruments of the
* project. Nothing in the editor plays a sequence yet – the piano roll (#333) will; until then the
* check scripts drive it through `__gm.music`.
* project: what the Pattern tab plays (#333), and what the check scripts drive through `__gm.music`.
*/
export class Music {
private scheduler: Scheduler | null = null;
private sequence: Sequence = { notes: [] };
/** Counts starts and stops: a start still loading its files gives up once another came after it. */
private run = 0;
/** The last notes planned, newest last, for a check to hold against the clock. */
readonly planned: PlannedNote[] = [];
@@ -41,9 +42,11 @@ export class Music {
/** Loads what the notes need, then plays from `from` ticks. */
async play(sequence: Sequence, options: SchedulerOptions & { from?: number } = {}): Promise<void> {
this.stop();
const run = this.run;
this.sequence = sequence;
this.planned.length = 0;
await soundLibrary.prepare(this.layersOf(sequence));
if (run !== this.run) return;
this.scheduler = new Scheduler(
{
now: () => soundLibrary.time(),
@@ -61,12 +64,14 @@ export class Music {
this.scheduler.start(options.from ?? 0);
}
/** A change heard from the next window on. */
/** A change heard from the next window on; a note new to it loads what it needs meanwhile. */
edit(sequence: Sequence): void {
this.sequence = sequence;
void soundLibrary.prepare(this.layersOf(sequence));
}
stop(): void {
this.run += 1;
this.scheduler?.stop();
this.scheduler = null;
}