diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 3925559e..acd753af 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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() }; } diff --git a/src/core/commands/patternCommands.ts b/src/core/commands/patternCommands.ts new file mode 100644 index 00000000..38545a1a --- /dev/null +++ b/src/core/commands/patternCommands.ts @@ -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; + } +} diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 0036c405..6ff83636 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -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`); diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index 0f9195cd..373ffb9b 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -44,6 +44,7 @@ import { InstrumentOutput, isInstrumentNodeDef, loops, + noteNameKey, noteOutputKey, noteParam, notesRange, @@ -370,3 +371,17 @@ export function playedNotes(graph: Graph): Set { } 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 { + const names = new Map(); + 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; +} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 2d4216e5..76997905 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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`. */ diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 243f21b0..9cc43475 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { sound: 'Sound', soundfx: 'Sound FX', instrument: 'Instrument', + pattern: 'Pattern', particles: 'Particles', }; @@ -71,8 +73,8 @@ export const ASSET_TYPE_LABELS: Record = { * 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 = new Set([ 'sound', 'soundfx', 'instrument', + 'pattern', 'variables', 'struct', ]); diff --git a/src/core/music/pattern.ts b/src/core/music/pattern.ts new file mode 100644 index 00000000..fb1aa8be --- /dev/null +++ b/src/core/music/pattern.ts @@ -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(); + const notes: PatternNote[] = []; + for (const item of raw as unknown[]) { + if (typeof item !== 'object' || item === null) continue; + const n = item as Record; + 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); +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 0227bb74..4ce50393 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { 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: [ diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index a60d4e40..de026a21 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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[] = []; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 6f48ffaf..ac57aa7f 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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 = { 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) ); } diff --git a/src/editor/assetTabs.ts b/src/editor/assetTabs.ts index 8ea68197..cbde84f3 100644 --- a/src/editor/assetTabs.ts +++ b/src/editor/assetTabs.ts @@ -11,6 +11,7 @@ export const TAB_OF_ASSET: Partial> = { particles: 'particles', soundfx: 'soundfx', instrument: 'instrument', + pattern: 'pattern', texture: 'texture', vectorart: 'vectorart', pixelart: 'pixelart', diff --git a/src/editor/debug/patternHooks.ts b/src/editor/debug/patternHooks.ts new file mode 100644 index 00000000..2e717501 --- /dev/null +++ b/src/editor/debug/patternHooks.ts @@ -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(), + }, + }; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 9320c709..c931ba3d 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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 diff --git a/src/editor/music/TimeRuler.ts b/src/editor/music/TimeRuler.ts new file mode 100644 index 00000000..dceb4f37 --- /dev/null +++ b/src/editor/music/TimeRuler.ts @@ -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); + } + } +} diff --git a/src/editor/music/timeView.ts b/src/editor/music/timeView.ts new file mode 100644 index 00000000..35a2cb02 --- /dev/null +++ b/src/editor/music/timeView.ts @@ -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(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(); + 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; +} diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index 64a331e0..75757bca 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -36,8 +36,8 @@ export const ASSET_GROUP_LABELS: Record = { 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', }; diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 308c80d8..8ae9541a 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -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 = { joint, palette, particles, + pattern, pixelart, rigidbody, scene, diff --git a/src/editor/panels/icons/pattern.svg b/src/editor/panels/icons/pattern.svg new file mode 100644 index 00000000..ac4e6298 --- /dev/null +++ b/src/editor/panels/icons/pattern.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/editor/pattern/PatternEditor.ts b/src/editor/pattern/PatternEditor.ts new file mode 100644 index 00000000..c9b9c53a --- /dev/null +++ b/src/editor/pattern/PatternEditor.ts @@ -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 { + /** 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['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); + } +} diff --git a/src/editor/pattern/PianoRoll.ts b/src/editor/pattern/PianoRoll.ts new file mode 100644 index 00000000..d7f29f99 --- /dev/null +++ b/src/editor/pattern/PianoRoll.ts @@ -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 } + | { kind: 'velocity'; anchor: PatternNote; value: number }; + +function mix(a: number, b: number, t: number): number { + const channel = (shift: number) => { + const x = (a >> shift) & 0xff; + const y = (b >> shift) & 0xff; + return Math.round(x + (y - x) * t) << shift; + }; + return channel(16) | channel(8) | channel(0); +} + +/** + * The Pattern tab's canvas (#333): the keys down the left, a ruler along the top, the notes in a grid + * between them and their velocities in a lane below. A click into the grid draws a note, a drag spans + * a band that picks notes; a note is dragged by its body, lengthened by its right end, and goes with a + * right-click or Delete. The ruler sets where playing starts, and a drag on it the loop. + */ +export class PianoRoll extends Widget { + private readonly bg = new Graphics(); + private readonly gridLayer = new Container(); + private readonly gridG = new Graphics(); + private readonly gridOverlay = new Graphics(); + private readonly keysLayer = new Container(); + private readonly keysG = new Graphics(); + private readonly keyLabels = new Container(); + private readonly keyTexts: Text[] = []; + private readonly ruler = new TimeRuler(); + private readonly laneLayer = new Container(); + private readonly laneG = new Graphics(); + private readonly clips = { + grid: new Graphics(), + keys: new Graphics(), + ruler: new Graphics(), + lane: new Graphics(), + }; + private readonly hint = crispText({ text: '', style: textStyle('muted') }); + private readonly laneLabel = crispText({ text: 'Velocity', style: textStyle('small') }); + private view: RollView = { panX: 0, pxPerTick: 0.1, panY: 0, rowHeight: DEFAULT_ROW_HEIGHT }; + private needsFit = true; + private assetId = ''; + private gesture: Gesture | null = null; + /** The notes picked, by id; only this tab knows them. */ + private picked = new Set(); + /** Where playing starts and a paste lands, in ticks. */ + private startTick = 0; + /** What a new note gets: the length of the last one drawn or dragged, and the last velocity set. */ + private lastLength: number | null = null; + private lastVelocity = DEFAULT_VELOCITY; + /** What Ctrl+C took, for any pattern of the project. */ + private clipboard: PatternNote[] = []; + private sounding: ReadonlySet = new Set(); + private names: ReadonlyMap = new Map(); + /** Where the playhead stands while the pattern plays, in ticks; null while it does not. */ + playhead: number | null = null; + /** Starts a note for the keys or for a note drawn or moved; `held` until `onRelease`. Gives back its voice. */ + onPreview: (note: number, held: boolean) => number = () => 0; + onRelease: (voice: number) => void = () => {}; + onTogglePlay: () => void = () => {}; + /** Where playing starts was moved: a playing pattern jumps there. */ + onStart: (tick: number) => void = () => {}; + + constructor(private readonly state: EditorState) { + super(); + this.focusable = true; + this.gridLayer.addChild(this.gridG, this.gridOverlay); + this.keysLayer.addChild(this.keysG, this.keyLabels); + this.laneLayer.addChild(this.laneG); + this.addChild( + this.bg, + this.gridLayer, + this.keysLayer, + this.ruler, + this.laneLayer, + this.hint, + this.laneLabel, + ); + for (const clip of Object.values(this.clips)) this.addChild(clip); + this.gridLayer.mask = this.clips.grid; + this.keysLayer.mask = this.clips.keys; + this.ruler.mask = this.clips.ruler; + this.laneLayer.mask = this.clips.lane; + this.hint.eventMode = 'none'; + this.laneLabel.eventMode = 'none'; + + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.onPointerRelease(() => this.onPointerUp()); + this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + state.on('project', () => { + const notes = this.asset()?.notes ?? []; + const alive = new Set(notes.map((n) => n.id)); + for (const id of this.picked) if (!alive.has(id)) this.picked.delete(id); + this.invalidate(); + }); + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 320), h: Math.max(this.minHeight, 200) }; + } + + get currentAssetId(): string { + return this.assetId; + } + + /** Another pattern starts with nothing picked, playing from its start, framed. */ + show(assetId: string): void { + if (this.assetId === assetId) return; + this.endGesture(); + this.assetId = assetId; + this.picked.clear(); + this.startTick = 0; + this.needsFit = true; + this.redraw(); + } + + saveView(): RollView { + return { ...this.view }; + } + + restoreView(view: unknown): void { + this.view = { ...(view as RollView) }; + this.needsFit = false; + this.redraw(); + } + + /** The whole pattern across, and its notes – or what the instrument plays – down the middle. */ + fit(): void { + const asset = this.asset(); + const width = this.gridWidth; + const height = this.gridHeight; + if (!asset || width <= 0 || height <= 0) { + this.needsFit = true; + return; + } + this.needsFit = false; + const notes = asset.notes.length > 0 ? asset.notes.map((n) => n.note) : [...this.sounding]; + const low = notes.length > 0 ? Math.min(...notes) : MIDDLE_C - 12; + const high = notes.length > 0 ? Math.max(...notes) : MIDDLE_C + 12; + const time = fitTime(patternLength(asset), width); + this.view = clampRows({ ...time, ...centreRows(low, high, height) }, height); + this.redraw(); + } + + /** The notes the instrument plays, and the names a drum kit gives some of them. */ + setInstrument(sounding: ReadonlySet, names: ReadonlyMap): void { + this.sounding = sounding; + this.names = names; + this.invalidate(); + } + + get start(): number { + return this.startTick; + } + + get pickedIds(): string[] { + return [...this.picked]; + } + + pick(ids: readonly string[]): void { + this.picked = new Set(ids); + this.invalidate(); + } + + // --- what the keys and the menus do ---------------------------------------------------------- + + selectAll(): boolean { + const asset = this.asset(); + if (!asset) return false; + this.pick(asset.notes.map((n) => n.id)); + return true; + } + + deleteSelection(): boolean { + const asset = this.asset(); + if (!asset || this.picked.size === 0) return false; + const label = this.picked.size === 1 ? 'Delete note' : 'Delete notes'; + this.commit( + asset, + asset.notes.filter((n) => !this.picked.has(n.id)), + label, + ); + this.picked.clear(); + return true; + } + + copy(): boolean { + const asset = this.asset(); + if (!asset || this.picked.size === 0) return false; + this.clipboard = asset.notes.filter((n) => this.picked.has(n.id)).map((n) => ({ ...n })); + const n = this.clipboard.length; + this.state.setNotice(`Copied ${n} ${n === 1 ? 'note' : 'notes'}`, 'quiet'); + return true; + } + + /** Lands at the start of playing; false with nothing copied, so Ctrl+V can fall through. */ + paste(): boolean { + const asset = this.asset(); + if (!asset || this.clipboard.length === 0) return false; + const copies = placedCopies(this.clipboard, this.startTick); + this.commit(asset, [...asset.notes, ...copies], copies.length === 1 ? 'Paste note' : 'Paste notes'); + this.pick(copies.map((n) => n.id)); + return true; + } + + /** A copy of the picked notes right behind them, picked in their place. */ + duplicate(): boolean { + const asset = this.asset(); + if (!asset || this.picked.size === 0) return false; + const by = duplicateOffset(asset.notes, this.picked, patternGrid(asset.props)); + const copies = placedCopies( + asset.notes.filter((n) => this.picked.has(n.id)), + Math.min(...asset.notes.filter((n) => this.picked.has(n.id)).map((n) => n.tick)) + by, + ); + this.commit(asset, [...asset.notes, ...copies], 'Duplicate notes'); + this.pick(copies.map((n) => n.id)); + return true; + } + + /** Moves the picked notes by ticks and semitones, as far as they can go. */ + nudge(ticks: number, pitch: number): boolean { + const asset = this.asset(); + if (!asset || this.picked.size === 0) return false; + const by = clampShift(asset.notes, this.picked, ticks, pitch); + if (by.ticks !== 0 || by.notes !== 0) + this.commit( + asset, + shifted(asset.notes, this.picked, by.ticks, by.notes), + by.notes !== 0 ? 'Transpose notes' : 'Move notes', + ); + return true; + } + + override onKeyDown(event: KeyboardEvent): boolean { + const asset = this.asset(); + if (!asset) return false; + const mod = event.ctrlKey || event.metaKey; + const key = event.key.toLowerCase(); + if (event.code === 'Space' && !mod) { + this.onTogglePlay(); + return true; + } + if (mod && key === 'c') return this.copy(); + if (mod && key === 'v') return this.paste(); + if (mod && key === 'd') return this.duplicate() || true; + if (mod && key === 'a') return this.selectAll(); + if (mod) return false; + // Taken even with nothing picked: a second Delete must not go on to ask about the pattern itself. + if (event.key === 'Delete' || event.key === 'Backspace') return this.deleteSelection() || true; + if (event.key === 'Escape' && this.picked.size > 0) { + this.pick([]); + return true; + } + const grid = patternGrid(asset.props); + const bar = ticksPerBar(patternMeter(asset.props)); + if (event.key === 'ArrowUp') return this.nudge(0, event.shiftKey ? 12 : 1); + if (event.key === 'ArrowDown') return this.nudge(0, event.shiftKey ? -12 : -1); + if (event.key === 'ArrowRight') return this.nudge(event.shiftKey ? bar : grid, 0); + if (event.key === 'ArrowLeft') return this.nudge(event.shiftKey ? -bar : -grid, 0); + return false; + } + + override onBlur(): void { + super.onBlur(); + if (this.gesture?.kind === 'key') this.endGesture(); + } + + // --- for the check scripts ------------------------------------------------------------------- + + /** Where a tick and a note meet on screen: the middle of the note's row. */ + gridPoint(tick: number, note: number): Vec2 { + const at = this.getGlobalPosition(); + return { + x: at.x + KEYS_WIDTH + tickToX(this.view, tick), + y: at.y + RULER_HEIGHT + noteTop(this.view, note) + this.view.rowHeight / 2, + }; + } + + rulerPoint(tick: number): Vec2 { + const at = this.getGlobalPosition(); + return { x: at.x + KEYS_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 }; + } + + keyPoint(note: number): Vec2 { + const at = this.getGlobalPosition(); + return { + x: at.x + KEYS_WIDTH / 2, + y: at.y + RULER_HEIGHT + noteTop(this.view, note) + this.view.rowHeight / 2, + }; + } + + /** Where the lane has a velocity, at a tick. */ + lanePoint(tick: number, velocity: number): Vec2 { + const at = this.getGlobalPosition(); + const top = this.h - VELOCITY_HEIGHT; + return { + x: at.x + KEYS_WIDTH + tickToX(this.view, tick), + y: at.y + top + (1 - velocity) * VELOCITY_HEIGHT, + }; + } + + viewInfo(): RollView & { + start: number; + playhead: number | null; + loop: LoopRange | null; + keys: number | null; + } { + const g = this.gesture; + return { + ...this.view, + start: this.startTick, + playhead: this.playhead, + loop: this.shownLoop(), + keys: g?.kind === 'key' ? g.note : null, + }; + } + + /** The rows of the keys as drawn, top to bottom: the note, its label and whether it sounds. */ + keysDrawn(): { note: number; label: string; sounds: boolean }[] { + const out: { note: number; label: string; sounds: boolean }[] = []; + for (const note of this.visibleNotes()) + out.push({ note, label: this.keyLabel(note), sounds: this.sounding.has(note) }); + return out; + } + + // --- pointer --------------------------------------------------------------------------------- + + private get gridWidth(): number { + return Math.max(0, this.w - KEYS_WIDTH); + } + + private get gridHeight(): number { + return Math.max(0, this.h - RULER_HEIGHT - VELOCITY_HEIGHT); + } + + private asset(): PatternAsset | null { + return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'pattern') ?? null) : null; + } + + private areaAt(p: Vec2): Area { + const left = p.x < KEYS_WIDTH; + if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler'; + if (p.y >= this.h - VELOCITY_HEIGHT) return left ? 'laneLabel' : 'lane'; + return left ? 'keys' : 'grid'; + } + + /** A point of the widget in the grid's own pixels. */ + private inGrid(p: Vec2): Vec2 { + return { x: p.x - KEYS_WIDTH, y: p.y - RULER_HEIGHT }; + } + + private onPointerDown(e: FederatedPointerEvent): void { + const asset = this.asset(); + if (!asset) return; + const local = this.toLocal(e.global); + const p = { x: local.x, y: local.y }; + const area = this.areaAt(p); + e.stopPropagation(); + ui().focus.focus(this); + if (e.button === 1) { + this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; + return; + } + const g = this.inGrid(p); + const tick = xToTick(this.view, g.x); + if (e.button === 2) { + if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global); + if (area === 'grid') { + this.gesture = { kind: 'erase', erased: new Set() }; + this.erase(asset, g); + } + return; + } + if (e.button !== 0) return; + if (area === 'keys') { + const note = noteAtY(this.view, g.y); + if (note !== null) this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) }; + } else if (area === 'ruler') { + const loop = patternLoop(asset); + const near = (t: number) => Math.abs(tickToX(this.view, t) - g.x) <= LOOP_EDGE_REACH; + const edge = near(loop.end) ? 'end' : near(loop.start) ? 'start' : null; + this.gesture = { + kind: 'ruler', + from: Math.max(0, tick), + pointerX: g.x, + active: false, + loop: null, + free: e.altKey, + edge, + }; + } else if (area === 'grid') { + this.pressGrid(asset, e, g); + } else if (area === 'lane') { + const id = velocityBarAt(this.view, asset.notes, this.picked, g.x); + const anchor = asset.notes.find((n) => n.id === id); + if (anchor) this.gesture = { kind: 'velocity', anchor, value: anchor.velocity }; + } + this.invalidate(); + } + + private pressGrid(asset: PatternAsset, e: FederatedPointerEvent, g: Vec2): void { + const hit = noteAt(this.view, asset.notes, g); + const anchor = hit ? asset.notes.find((n) => n.id === hit.id) : undefined; + if (!hit || !anchor) { + this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey, free: e.altKey }; + return; + } + if (e.shiftKey) { + if (this.picked.has(hit.id)) this.picked.delete(hit.id); + else this.picked.add(hit.id); + return; + } + if (!this.picked.has(hit.id)) this.picked = new Set([hit.id]); + if (hit.part === 'end') { + this.gesture = { kind: 'resize', anchor, by: 0, moved: false }; + return; + } + const grab = xToTick(this.view, g.x) - anchor.tick; + this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g }; + this.onPreview(anchor.note, false); + } + + private onPointerMove(e: FederatedPointerEvent): void { + const gesture = this.gesture; + const asset = this.asset(); + if (!gesture || !asset) return; + const local = this.toLocal(e.global); + const g = this.inGrid({ x: local.x, y: local.y }); + const tick = xToTick(this.view, g.x); + const grid = patternGrid(asset.props); + const free = e.altKey; + switch (gesture.kind) { + case 'pan': + this.view = clampRows( + clampTimePan({ + ...this.view, + panX: gesture.panX + (local.x - gesture.pointer.x), + panY: gesture.panY + (local.y - gesture.pointer.y), + }), + this.gridHeight, + ); + break; + case 'key': { + const note = noteAtY(this.view, g.y); + if (note === null || note === gesture.note) return; + this.onRelease(gesture.voice); + this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) }; + break; + } + case 'ruler': { + gesture.free = free; + if (!gesture.active && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return; + gesture.active = true; + const snap = (t: number) => Math.max(0, free ? Math.round(t) : snapNear(t, grid)); + const at = snap(tick); + const { start, end } = patternLoop(asset); + if (gesture.edge === 'start') gesture.loop = { start: Math.min(at, end - 1), end }; + else if (gesture.edge === 'end') gesture.loop = { start, end: Math.max(at, start + 1) }; + else { + const from = snap(gesture.from); + gesture.loop = at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) }; + } + break; + } + case 'empty': { + gesture.current = g; + gesture.free = free; + const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y)); + if (!gesture.active && span < DRAG_THRESHOLD) return; + gesture.active = true; + break; + } + case 'move': { + if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD) + return; + gesture.moved = true; + const note = noteAtY(this.view, g.y) ?? gesture.anchor.note + gesture.notes; + const by = moveDelta(asset.notes, this.picked, gesture.anchor, tick - gesture.grab, note, grid, free); + if (by.notes !== gesture.notes) this.onPreview(gesture.anchor.note + by.notes, false); + gesture.ticks = by.ticks; + gesture.notes = by.notes; + break; + } + case 'resize': + gesture.moved = true; + gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length; + break; + case 'erase': + this.erase(asset, g); + break; + case 'velocity': + gesture.value = velocityAtY(local.y, this.h - VELOCITY_HEIGHT, VELOCITY_HEIGHT); + break; + } + this.invalidate(); + } + + private onPointerUp(): void { + const gesture = this.gesture; + this.gesture = null; + const asset = this.asset(); + if (!gesture || !asset) { + if (gesture?.kind === 'key') this.onRelease(gesture.voice); + return; + } + const grid = patternGrid(asset.props); + switch (gesture.kind) { + case 'key': + this.onRelease(gesture.voice); + break; + case 'ruler': + if (gesture.active) { + const same = JSON.stringify(gesture.loop) === JSON.stringify(patternLoop(asset)); + if (gesture.loop && !same) this.state.execute(new SetPatternLoopCommand(asset.id, gesture.loop)); + } else this.setStart(gesture.free ? Math.round(gesture.from) : snapDown(gesture.from, grid)); + break; + case 'empty': + if (gesture.active) { + const caught = notesWithin(this.view, asset.notes, gesture.start, gesture.current); + this.picked = new Set([...(gesture.add ? this.picked : []), ...caught]); + } else this.draw(asset, gesture.start, grid, gesture.free); + break; + case 'move': + if (gesture.moved && (gesture.ticks !== 0 || gesture.notes !== 0)) { + const label = this.picked.size === 1 ? 'Move note' : 'Move notes'; + this.commit(asset, shifted(asset.notes, this.picked, gesture.ticks, gesture.notes), label); + } else if (!gesture.moved) this.picked = new Set([gesture.anchor.id]); + break; + case 'resize': + if (gesture.by !== 0) { + this.commit( + asset, + resized(asset.notes, this.picked, gesture.by), + this.picked.size === 1 ? 'Resize note' : 'Resize notes', + ); + this.lastLength = gesture.anchor.length + gesture.by; + } + break; + case 'erase': + if (gesture.erased.size > 0) { + this.commit( + asset, + asset.notes.filter((n) => !gesture.erased.has(n.id)), + gesture.erased.size === 1 ? 'Delete note' : 'Delete notes', + ); + for (const id of gesture.erased) this.picked.delete(id); + } + break; + case 'velocity': + this.commit( + asset, + withVelocity(asset.notes, this.picked, gesture.anchor, gesture.value), + 'Set velocity', + ); + this.lastVelocity = gesture.value; + break; + } + this.invalidate(); + } + + /** Lets go of whatever the pointer held, without writing anything. */ + private endGesture(): void { + if (this.gesture?.kind === 'key') this.onRelease(this.gesture.voice); + this.gesture = null; + } + + private draw(asset: PatternAsset, at: Vec2, grid: number, free: boolean): void { + const note = noteAtY(this.view, at.y); + if (note === null) return; + const drawn = drawnNote( + xToTick(this.view, at.x), + note, + grid, + free, + this.lastLength ?? grid, + this.lastVelocity, + ); + this.commit(asset, [...asset.notes, drawn], 'Draw note'); + this.lastLength = drawn.length; + this.picked = new Set([drawn.id]); + this.onPreview(note, false); + } + + private erase(asset: PatternAsset, g: Vec2): void { + if (this.gesture?.kind !== 'erase') return; + const shown = asset.notes.filter((n) => !(this.gesture as { erased: Set }).erased.has(n.id)); + const hit = noteAt(this.view, shown, g); + if (hit) this.gesture.erased.add(hit.id); + } + + private setStart(tick: number): void { + this.startTick = Math.max(0, tick); + this.onStart(this.startTick); + this.invalidate(); + } + + private commit(asset: PatternAsset, notes: readonly PatternNote[], label: string): void { + if (SetPatternNotesCommand.changes(asset, notes)) + this.state.execute(new SetPatternNotesCommand(asset.id, notes, label)); + } + + private openRulerMenu(asset: PatternAsset, tick: number, global: Vec2): void { + const bar = ticksPerBar(patternMeter(asset.props)); + const first = snapDown(tick, bar); + new Menu([ + { + label: `Loop bar ${first / bar + 1}`, + onSelect: () => + this.state.execute(new SetPatternLoopCommand(asset.id, { start: first, end: first + bar })), + }, + { + label: 'Loop the whole pattern', + disabled: !asset.loop, + onSelect: () => this.state.execute(new SetPatternLoopCommand(asset.id, null)), + }, + ]).open(global.x, global.y); + } + + private onWheel(e: FederatedWheelEvent): void { + const local = this.toLocal(e.global); + const area = this.areaAt({ x: local.x, y: local.y }); + const g = this.inGrid({ x: local.x, y: local.y }); + let view: RollView = this.view; + if (area === 'ruler' || (e.altKey && area !== 'keys')) + view = { ...view, ...zoomTime(view, zoomFactor(e.deltaY), g.x) }; + else if (e.altKey) view = zoomRows(view, zoomFactor(e.deltaY), g.y); + else if (e.shiftKey) view = { ...view, panX: view.panX - e.deltaY }; + else view = { ...view, panX: view.panX - e.deltaX, panY: view.panY - e.deltaY }; + this.view = clampRows(clampTimePan(view), this.gridHeight); + this.needsFit = false; + this.redraw(); + e.preventDefault(); + } + + // --- drawing --------------------------------------------------------------------------------- + + /** The notes as the running gesture would leave them. */ + private shownNotes(asset: PatternAsset): readonly PatternNote[] { + const g = this.gesture; + if (g?.kind === 'move' && g.moved) return shifted(asset.notes, this.picked, g.ticks, g.notes); + if (g?.kind === 'resize') return resized(asset.notes, this.picked, g.by); + if (g?.kind === 'velocity') return withVelocity(asset.notes, this.picked, g.anchor, g.value); + if (g?.kind === 'erase') return asset.notes.filter((n) => !g.erased.has(n.id)); + return asset.notes; + } + + private shownLoop(): LoopRange | null { + const asset = this.asset(); + if (!asset) return null; + const g = this.gesture; + if (g?.kind === 'ruler' && g.active) return g.loop; + return patternLoop(asset); + } + + /** The notes whose rows are on screen, highest first. */ + private visibleNotes(): number[] { + const top = noteAtY(this.view, 0) ?? HIGHEST_NOTE; + const bottom = noteAtY(this.view, this.gridHeight - 1) ?? LOWEST_NOTE; + const out: number[] = []; + for (let note = top; note >= bottom; note--) out.push(note); + return out; + } + + private keyLabel(note: number): string { + return this.names.get(note) ?? (note % 12 === 0 ? noteName(note) : ''); + } + + override layout(): void { + if (this.needsFit) this.fit(); + } + + override redraw(): void { + if (this.needsFit && this.gridWidth > 0 && this.gridHeight > 0 && this.asset()) { + this.fit(); + return; + } + const c = Theme.colors; + const w = this.w; + const h = this.h; + const gw = this.gridWidth; + const gh = this.gridHeight; + const laneTop = h - VELOCITY_HEIGHT; + this.bg.clear().rect(0, 0, w, h).fill(c.viewport); + this.bg.rect(0, 0, KEYS_WIDTH, h).fill(c.panel); + this.bg.rect(0, 0, w, RULER_HEIGHT).fill(c.panelHeader); + this.bg.rect(0, laneTop, w, VELOCITY_HEIGHT).fill(c.panel); + this.bg.rect(KEYS_WIDTH - 1, 0, 1, h).fill(c.border); + this.bg.rect(0, laneTop, w, 1).fill(c.border); + this.clips.grid.clear().rect(KEYS_WIDTH, RULER_HEIGHT, gw, gh).fill(0xffffff); + this.clips.keys + .clear() + .rect(0, RULER_HEIGHT, KEYS_WIDTH - 1, gh) + .fill(0xffffff); + this.clips.ruler.clear().rect(KEYS_WIDTH, 0, gw, RULER_HEIGHT).fill(0xffffff); + this.clips.lane + .clear() + .rect(KEYS_WIDTH, laneTop + 1, gw, VELOCITY_HEIGHT - 1) + .fill(0xffffff); + this.gridLayer.position.set(KEYS_WIDTH, RULER_HEIGHT); + this.keysLayer.position.set(0, RULER_HEIGHT); + this.ruler.position.set(KEYS_WIDTH, 0); + this.laneLayer.position.set(KEYS_WIDTH, laneTop); + this.laneLabel.position.set(Theme.padding, laneTop + Theme.padding); + + const asset = this.asset(); + this.gridG.clear(); + this.gridOverlay.clear(); + this.laneG.clear(); + this.keysG.clear(); + if (!asset) { + this.syncKeyLabels([]); + this.ruler.visible = false; + this.hint.text = 'No pattern selected – pick one in the Assets panel or create one.'; + this.hint.position.set(KEYS_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2); + this.hint.visible = true; + return; + } + this.ruler.visible = true; + const notes = this.shownNotes(asset); + const length = patternLength(asset); + const rows = this.visibleNotes(); + this.drawRows(rows, gw); + this.drawLines(asset, gw, gh, length); + this.drawNotes(notes, length, gw, gh); + this.drawLane(notes, length, gw); + this.drawKeys(rows); + this.ruler.draw(this.view, patternMeter(asset.props), gw, { + length, + loop: this.shownLoop(), + start: this.startTick, + playhead: this.playhead, + }); + const g = this.gesture; + if (g?.kind === 'empty' && g.active) drawBand(this.gridOverlay, g.start, g.current); + const start = tickToX(this.view, this.startTick); + if (start >= 0 && start <= gw) this.gridOverlay.rect(start, 0, 1, gh).fill({ color: c.text, alpha: 0.3 }); + if (this.playhead !== null) { + const x = tickToX(this.view, this.playhead); + if (x >= 0 && x <= gw) { + this.gridOverlay.rect(x - 0.5, 0, 1, gh).fill(c.hot); + this.laneG.rect(x - 0.5, 0, 1, VELOCITY_HEIGHT).fill(c.hot); + } + } + this.hint.text = asset.notes.length === 0 ? 'Click to draw a note, drag to pick notes, Space plays.' : ''; + this.hint.visible = this.hint.text !== ''; + this.hint.position.set(KEYS_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2); + } + + /** A row per note, the black keys' rows darker, a line under every C. */ + private drawRows(rows: readonly number[], gw: number): void { + const c = Theme.colors; + const rh = this.view.rowHeight; + for (const note of rows) { + const y = noteTop(this.view, note); + this.gridG + .rect(0, y, gw, rh) + .fill(isSharp(note) ? { color: 0x000000, alpha: 0.22 } : { color: 0xffffff, alpha: 0.025 }); + if (note % 12 === 0) this.gridG.rect(0, y + rh - 1, gw, 1).fill({ color: c.border, alpha: 0.9 }); + } + } + + private drawLines(asset: PatternAsset, gw: number, gh: number, length: number): void { + const c = Theme.colors; + const alpha = [1, 0.55, 0.25]; + for (const line of timeLines(this.view, patternMeter(asset.props), patternGrid(asset.props), gw)) { + this.gridG.rect(line.x, 0, 1, gh).fill({ color: c.border, alpha: alpha[line.level] }); + if (line.level === 0) + this.laneG.rect(line.x, 0, 1, VELOCITY_HEIGHT).fill({ color: c.border, alpha: 0.6 }); + } + const end = tickToX(this.view, length); + if (end < gw) { + this.gridG.rect(Math.max(0, end), 0, gw - Math.max(0, end), gh).fill({ color: 0x000000, alpha: 0.35 }); + this.laneG + .rect(Math.max(0, end), 0, gw - Math.max(0, end), VELOCITY_HEIGHT) + .fill({ color: 0x000000, alpha: 0.25 }); + } + } + + private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void { + const c = Theme.colors; + for (const note of notes) { + const box = noteBox(this.view, note); + if (box.x > gw || box.x + box.w < 0 || box.y > gh || box.y + box.h < 0) continue; + const picked = this.picked.has(note.id); + const alpha = note.tick >= length ? BEYOND_ALPHA : 1; + const w = Math.max(2, box.w); + this.gridG + .rect(box.x, box.y + 1, w, Math.max(1, box.h - 1)) + .fill({ color: picked ? mix(HARD, 0xffffff, 0.35) : mix(SOFT, HARD, note.velocity), alpha }) + .stroke({ width: 1, color: picked ? c.accentText : c.viewport, alpha, alignment: 1 }); + } + } + + private drawLane(notes: readonly PatternNote[], length: number, gw: number): void { + const c = Theme.colors; + this.laneG.rect(0, VELOCITY_HEIGHT / 2, gw, 1).fill({ color: c.border, alpha: 0.5 }); + for (const note of notes) { + const x = tickToX(this.view, note.tick); + if (x < -8 || x > gw) continue; + const top = (1 - note.velocity) * (VELOCITY_HEIGHT - 2) + 2; + const color = this.picked.has(note.id) ? c.accentText : mix(SOFT, HARD, note.velocity); + const alpha = note.tick >= length ? BEYOND_ALPHA : 1; + this.laneG.rect(x, top, 2, VELOCITY_HEIGHT - top).fill({ color, alpha }); + this.laneG + .rect(x, top, Math.max(2, Math.min(8, note.length * this.view.pxPerTick)), 2) + .fill({ color, alpha }); + } + } + + /** The keys, the ones the instrument plays nothing for greyed out, the held one lit. */ + private drawKeys(rows: readonly number[]): void { + const c = Theme.colors; + const rh = this.view.rowHeight; + const held = this.gesture?.kind === 'key' ? this.gesture.note : null; + const labelled: { note: number; y: number }[] = []; + for (const note of rows) { + const y = noteTop(this.view, note); + const sounds = this.sounding.has(note); + const fill = + note === held + ? c.accent + : isSharp(note) + ? sounds + ? BLACK + : MUTED_BLACK + : sounds + ? WHITE + : MUTED_WHITE; + this.keysG + .rect(0, y, KEYS_WIDTH - 1, rh) + .fill(fill) + .stroke({ width: 1, color: c.border, alignment: 1 }); + if (rh >= 9 && this.keyLabel(note)) labelled.push({ note, y }); + } + this.syncKeyLabels(labelled); + } + + private syncKeyLabels(rows: readonly { note: number; y: number }[]): void { + const rh = this.view.rowHeight; + rows.forEach(({ note, y }, i) => { + let text = this.keyTexts[i]; + if (!text) { + text = crispText({ style: DARK_LABEL }); + this.keyTexts.push(text); + this.keyLabels.addChild(text); + } + const label = this.keyLabel(note); + if (text.text !== label) text.text = label; + text.style = isSharp(note) ? textStyle('small') : DARK_LABEL; + text.visible = true; + text.position.set(4, y + (rh - text.height) / 2); + }); + for (let i = rows.length; i < this.keyTexts.length; i++) this.keyTexts[i]!.visible = false; + } +} diff --git a/src/editor/pattern/rollEdit.ts b/src/editor/pattern/rollEdit.ts new file mode 100644 index 00000000..e2fc4856 --- /dev/null +++ b/src/editor/pattern/rollEdit.ts @@ -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, + 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, + 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, + 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, + 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, + 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, + 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, + ); +} diff --git a/src/editor/pattern/rollView.ts b/src/editor/pattern/rollView.ts new file mode 100644 index 00000000..e20d72e2 --- /dev/null +++ b/src/editor/pattern/rollView.ts @@ -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 { + 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): 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, + 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); +} diff --git a/src/editor/state.ts b/src/editor/state.ts index 18a065d7..c78e3e46 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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; diff --git a/src/viewport/music.ts b/src/viewport/music.ts index b86c1481..abf7264d 100644 --- a/src/viewport/music.ts +++ b/src/viewport/music.ts @@ -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 { 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; } diff --git a/tests/core/pattern.test.ts b/tests/core/pattern.test.ts new file mode 100644 index 00000000..d41f91c8 --- /dev/null +++ b/tests/core/pattern.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { SetPatternLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; +import { namedNotes } from '../../src/core/instrument/compile'; +import { starterInstrumentGraph } from '../../src/core/instrument/nodes'; +import type { PatternAsset } from '../../src/core/model/Asset'; +import { + DEFAULT_GRID, + gridTicks, + heldPatternNotes, + patternGrid, + patternLength, + patternLoop, + patternMeter, + type PatternNote, + patternSequence, + patternTempo, +} from '../../src/core/music/pattern'; +import { schemaForAsset } from '../../src/core/properties/PropertySchema'; +import { makeProject, roundTrip } from '../helpers'; + +function makePattern() { + const f = makeProject(); + const create = new CreateAssetCommand('pattern', 'Beat'); + f.stack.execute(create); + const asset = create.asset; + const pattern = () => f.project.assets.find((a) => a.id === asset.id) as PatternAsset; + const set = (key: string, value: string | number) => + f.stack.execute(new SetAssetPropertyCommand(asset.id, key, value)); + return { ...f, id: asset.id, pattern, set }; +} + +const note = (id: string, tick: number, length: number, n = 60, velocity = 0.8): PatternNote => ({ + id, + tick, + length, + note: n, + velocity, +}); + +describe('pattern asset (#333)', () => { + it('starts empty: four bars of 4/4 at 120 BPM on a 1/16 grid, no instrument', () => { + const f = makePattern(); + const p = f.pattern(); + expect(p.notes).toEqual([]); + expect(p.loop).toBeUndefined(); + expect(patternMeter(p.props)).toEqual({ beats: 4, unit: 4 }); + expect(patternTempo(p.props)).toBe(120); + expect(patternGrid(p.props)).toBe(120); + expect(patternLength(p)).toBe(4 * 1920); + expect(schemaForAsset(f.project, p).map((d) => d.key)).toEqual([ + 'instrument', + 'bars', + 'grid', + 'beats', + 'beat', + 'tempo', + ]); + }); + + it('counts its grids in whole ticks, triplets too', () => { + expect(['1/1', '1/4', '1/16', '1/32', '1/4T', '1/8T', '1/16T', '1/32T'].map(gridTicks)).toEqual([ + 1920, 480, 120, 60, 320, 160, 80, 40, + ]); + expect(gridTicks('nonsense')).toBe(gridTicks(DEFAULT_GRID)); + }); + + it('measures its length in bars of its meter: 3 bars of 6/8 are 4320 ticks', () => { + const f = makePattern(); + f.set('bars', 3); + f.set('beats', 6); + f.set('beat', '8'); + expect(patternLength(f.pattern())).toBe(3 * 6 * 240); + }); + + it('holds notes to whole ticks, a tick at least, the keyboard and 0 to 1, sorted, ids unique', () => { + const held = heldPatternNotes([ + { id: 'b', tick: 480.4, length: 0.2, note: 64, velocity: 2 }, + { id: 'a', tick: 0, length: 240, note: 60, velocity: 0.5 }, + { id: 'a', tick: 0, length: 240, note: 67, velocity: 0.5 }, + { id: 'c', tick: -10, length: 240, note: 60 }, + { id: 'd', tick: 0, length: 240, note: 128 }, + 'junk', + ]); + expect(held.map((n) => [n.tick, n.length, n.note, n.velocity])).toEqual([ + [0, 240, 60, 0.5], + [0, 240, 67, 0.5], + [480, 1, 64, 1], + ]); + expect(new Set(held.map((n) => n.id)).size).toBe(3); + expect(held[0]!.id).toBe('a'); + }); + + it('plays nothing without an instrument; with one, the notes before its end, cut off there', () => { + const f = makePattern(); + f.set('bars', 1); + f.stack.execute( + new SetPatternNotesCommand(f.id, [ + note('a', 0, 480), + note('b', 1800, 480, 62), + note('c', 1920, 240, 64), + ]), + ); + expect(patternSequence(f.pattern()).notes).toEqual([]); + const ins = new CreateAssetCommand('instrument', 'Piano'); + f.stack.execute(ins); + f.set('instrument', ins.asset.id); + expect(patternSequence(f.pattern()).notes).toEqual([ + { tick: 0, length: 480, note: 60, velocity: 0.8, instrument: ins.asset.id }, + { tick: 1800, length: 120, note: 62, velocity: 0.8, instrument: ins.asset.id }, + ]); + }); + + it('writes the notes in one undo step, and none when they read the same', () => { + const f = makePattern(); + const notes = [note('a', 0, 120)]; + f.stack.execute(new SetPatternNotesCommand(f.id, notes, 'Draw note')); + expect(f.pattern().notes).toEqual(notes); + expect(SetPatternNotesCommand.changes(f.pattern(), notes)).toBe(false); + expect(SetPatternNotesCommand.changes(f.pattern(), [note('a', 120, 120)])).toBe(true); + f.stack.undo(); + expect(f.pattern().notes).toEqual([]); + }); + + it('loops the whole pattern until a loop is set, and a loop is cut at its end', () => { + const f = makePattern(); + expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 7680 }); + f.stack.execute(new SetPatternLoopCommand(f.id, { start: 1920, end: 9600 })); + expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 }); + expect(patternLoop(f.pattern())).toEqual({ start: 1920, end: 7680 }); + f.set('bars', 1); + expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 1920 }); + f.stack.execute(new SetPatternLoopCommand(f.id, null)); + expect(f.pattern().loop).toBeUndefined(); + f.stack.undo(); + expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 }); + }); + + it('survives a round trip with its notes and its loop', () => { + const f = makePattern(); + f.stack.execute( + new SetPatternNotesCommand(f.id, [note('a', 0, 120, 36, 1), note('b', 240, 60, 38, 0.25)]), + ); + f.stack.execute(new SetPatternLoopCommand(f.id, { start: 0, end: 960 })); + f.set('grid', '1/8T'); + const back = roundTrip(f.project).assets.find((a) => a.id === f.id) as PatternAsset; + expect(back.notes).toEqual(f.pattern().notes); + expect(back.loop).toEqual({ start: 0, end: 960 }); + expect(patternGrid(back.props)).toBe(160); + }); + + it('reads the names a drum kit gives its notes, for the keys of the roll', () => { + const graph = starterInstrumentGraph(); + const notes = graph.nodes.find((n) => n.type === 'InstrumentNotes')!; + Object.assign(notes.params, { from: 36, to: 38, singleNotes: true, name36: 'Kick', name38: ' Snare ' }); + expect([...namedNotes(graph)]).toEqual([ + [36, 'Kick'], + [38, 'Snare'], + ]); + notes.params.singleNotes = false; + expect(namedNotes(graph).size).toBe(0); + }); +}); diff --git a/tests/editor/piano-roll.test.ts b/tests/editor/piano-roll.test.ts new file mode 100644 index 00000000..9c5401d5 --- /dev/null +++ b/tests/editor/piano-roll.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import type { PatternNote } from '../../src/core/music/pattern'; +import { + barLabelStep, + clampTimePan, + fitTime, + timeLines, + xToTick, + zoomTime, +} from '../../src/editor/music/timeView'; +import { + clampShift, + draggedLength, + drawnNote, + duplicateOffset, + moveDelta, + placedCopies, + resized, + shifted, + withVelocity, +} from '../../src/editor/pattern/rollEdit'; +import { + centreRows, + clampRows, + noteAt, + noteAtY, + noteTop, + notesWithin, + type RollView, + ROWS, + velocityAtY, + velocityBarAt, + zoomRows, +} from '../../src/editor/pattern/rollView'; + +const note = (id: string, tick: number, length: number, n = 60, velocity = 0.8): PatternNote => ({ + id, + tick, + length, + note: n, + velocity, +}); +/** 0.1 px per tick: a 1/16 is 12 px, a bar of 4/4 192. Rows of 10 px, C4 (60) at the top. */ +const VIEW: RollView = { panX: 0, pxPerTick: 0.1, panY: -(127 - 60) * 10, rowHeight: 10 }; +const COMMON = { beats: 4, unit: 4 }; + +describe('time view of the piano roll (#333)', () => { + it('zooms about the pointer and never shows time before 0', () => { + const zoomed = zoomTime({ panX: -100, pxPerTick: 0.1 }, 2, 300); + expect(xToTick(zoomed, 300)).toBeCloseTo(4000); + expect(clampTimePan({ panX: 40, pxPerTick: 0.1 }).panX).toBe(0); + expect(fitTime(7680, 768)).toEqual({ panX: 0, pxPerTick: 0.1 }); + }); + + it('draws bars, beats and the grid, each tick at its strongest, the grid only with room', () => { + const lines = timeLines(VIEW, COMMON, 120, 200); + expect(lines.filter((l) => l.level === 0).map((l) => l.tick)).toEqual([0, 1920]); + expect(lines.filter((l) => l.level === 1).map((l) => l.tick)).toEqual([480, 960, 1440]); + expect(lines.filter((l) => l.level === 2)).toHaveLength(12); + // At a twentieth of that a 1/16 is 0.6 px: bars only. + expect(timeLines({ panX: 0, pxPerTick: 0.005 }, COMMON, 120, 200).every((l) => l.level === 0)).toBe(true); + }); + + it('keeps the beats of 6/8 between the lines of a triplet grid', () => { + const lines = timeLines({ panX: 0, pxPerTick: 0.1 }, { beats: 6, unit: 8 }, 160, 150); + expect(lines.filter((l) => l.level === 1).map((l) => l.tick)).toEqual([240, 480, 720, 960, 1200]); + expect(lines.filter((l) => l.level === 2).map((l) => l.tick)).toEqual([160, 320, 640, 800, 1120, 1280]); + }); + + it('numbers every bar while there is room, else every second, fourth …', () => { + expect(barLabelStep({ panX: 0, pxPerTick: 0.1 }, COMMON)).toBe(1); + expect(barLabelStep({ panX: 0, pxPerTick: 0.01 }, COMMON)).toBe(2); + expect(barLabelStep({ panX: 0, pxPerTick: 0.004 }, COMMON)).toBe(8); + }); +}); + +describe('rows of the piano roll (#333)', () => { + it('puts the highest note on top and finds the note under a point', () => { + expect(noteTop(VIEW, 60)).toBe(0); + expect(noteTop(VIEW, 59)).toBe(10); + expect(noteAtY(VIEW, 5)).toBe(60); + expect(noteAtY(VIEW, 15)).toBe(59); + expect(noteAtY({ ...VIEW, panY: 0 }, -1)).toBeNull(); + expect(noteAtY({ ...VIEW, panY: 0 }, ROWS * 10)).toBeNull(); + }); + + it('keeps the rows on screen and zooms them about the pointer', () => { + expect(clampRows({ ...VIEW, panY: 50 }, 300).panY).toBe(0); + expect(clampRows({ ...VIEW, panY: -5000 }, 300).panY).toBe(300 - ROWS * 10); + const zoomed = zoomRows(VIEW, 2, 25); + expect(zoomed.rowHeight).toBe(20); + expect(noteAtY(zoomed, 25)).toBe(58); + expect(zoomRows(VIEW, 100, 0).rowHeight).toBe(28); + }); + + it('centres the notes it is fitted to, lower rows where they do not fit', () => { + const fitted = centreRows(60, 64, 200); + expect(fitted.rowHeight).toBe(14); + const view = { ...VIEW, ...fitted }; + expect(noteAtY(view, 100)).toBe(62); + expect(centreRows(24, 48, 200).rowHeight).toBeCloseTo(200 / 27); + expect(centreRows(0, 127, 200).rowHeight).toBe(6); + }); + + it('tells a note body from its right end, and takes the one drawn last', () => { + const notes = [note('a', 0, 480), note('b', 240, 480)]; + expect(noteAt(VIEW, notes, { x: 10, y: 5 })).toEqual({ id: 'a', part: 'body' }); + expect(noteAt(VIEW, notes, { x: 30, y: 5 })).toEqual({ id: 'b', part: 'body' }); + expect(noteAt(VIEW, notes, { x: 70, y: 5 })).toEqual({ id: 'b', part: 'end' }); + expect(noteAt(VIEW, notes, { x: 10, y: 15 })).toBeNull(); + // A note of a tick is still 4 px to the pointer. + expect(noteAt(VIEW, [note('c', 1000, 1)], { x: 102, y: 5 })?.id).toBe('c'); + }); + + it('picks the notes a band touches', () => { + const notes = [note('a', 0, 120, 60), note('b', 480, 120, 59), note('c', 960, 120, 55)]; + expect(notesWithin(VIEW, notes, { x: 5, y: 2 }, { x: 50, y: 18 })).toEqual(['a', 'b']); + expect(notesWithin(VIEW, notes, { x: 50, y: 60 }, { x: 0, y: 0 })).toEqual(['a', 'b']); + }); + + it('finds the velocity bar under the pointer, a picked one first', () => { + const notes = [note('a', 0, 120), note('b', 0, 120, 64), note('c', 480, 120)]; + expect(velocityBarAt(VIEW, notes, new Set(), 2)).toBe('b'); + expect(velocityBarAt(VIEW, notes, new Set(['a']), 2)).toBe('a'); + expect(velocityBarAt(VIEW, notes, new Set(), 30)).toBeNull(); + expect(velocityAtY(10, 10, 64)).toBe(1); + expect(velocityAtY(42, 10, 64)).toBe(0.5); + expect(velocityAtY(100, 10, 64)).toBe(0); + }); +}); + +describe('editing in the piano roll (#333)', () => { + it('draws on the grid line before the pointer, or on the tick with Alt', () => { + expect(drawnNote(250, 62, 120, false, 120, 0.8)).toMatchObject({ + tick: 240, + length: 120, + note: 62, + velocity: 0.8, + }); + expect(drawnNote(250.4, 62, 120, true, 960, 0.5)).toMatchObject({ + tick: 250, + length: 960, + velocity: 0.5, + }); + expect(drawnNote(250, 62, 120, false, 120, 1).id).not.toBe(drawnNote(250, 62, 120, false, 120, 1).id); + }); + + it('moves the grabbed note onto the grid, the others along, never before 0 or off the keys', () => { + const notes = [note('a', 240, 120, 60), note('b', 480, 120, 120), note('c', 0, 120, 30)]; + const picked = new Set(['a', 'b']); + expect(moveDelta(notes, picked, notes[0]!, 370, 62, 120, false)).toEqual({ ticks: 120, notes: 2 }); + expect(moveDelta(notes, picked, notes[0]!, 370, 62, 120, true)).toEqual({ ticks: 130, notes: 2 }); + expect(moveDelta(notes, picked, notes[0]!, -500, 80, 120, false)).toEqual({ ticks: -240, notes: 7 }); + expect(clampShift(notes, new Set(['c']), -10, -40)).toEqual({ ticks: 0, notes: -30 }); + expect(shifted(notes, picked, 120, 2).map((n) => [n.tick, n.note])).toEqual([ + [360, 62], + [600, 122], + [0, 30], + ]); + }); + + it('drags a length to the nearest grid line, never back to its own start', () => { + const anchor = note('a', 240, 120); + expect(draggedLength(anchor, 470, 120, false)).toBe(240); + expect(draggedLength(anchor, 250, 120, false)).toBe(120); + expect(draggedLength(note('o', 250, 60), 255, 120, false)).toBe(110); + expect(draggedLength(anchor, 241.2, 120, true)).toBe(1); + expect(resized([anchor, note('b', 0, 60)], new Set(['a', 'b']), -100).map((n) => n.length)).toEqual([ + 20, 1, + ]); + }); + + it('puts a duplicate right behind the picked notes, in whole grid steps', () => { + const notes = [note('a', 0, 120), note('b', 240, 60), note('c', 960, 120)]; + expect(duplicateOffset(notes, new Set(['a', 'b']), 120)).toBe(360); + expect(duplicateOffset(notes, new Set(['b']), 120)).toBe(120); + expect(duplicateOffset(notes, new Set(), 120)).toBe(0); + }); + + it('pastes copies with ids of their own, the earliest at the start', () => { + const copies = placedCopies([note('a', 480, 120, 60), note('b', 720, 60, 64)], 1920); + expect(copies.map((n) => [n.tick, n.note])).toEqual([ + [1920, 60], + [2160, 64], + ]); + expect(copies.map((n) => n.id)).not.toContain('a'); + }); + + it('sets the grabbed velocity and moves the other picked ones by as much', () => { + const notes = [note('a', 0, 120, 60, 0.5), note('b', 0, 120, 62, 0.9), note('c', 0, 120, 64, 0.2)]; + expect(withVelocity(notes, new Set(['a', 'b']), notes[0]!, 0.7).map((n) => n.velocity)).toEqual([ + 0.7, 1, 0.2, + ]); + // Unpicked, the grabbed note goes alone. + expect(withVelocity(notes, new Set(['b']), notes[0]!, 0.1).map((n) => n.velocity)).toEqual([ + 0.1, 0.9, 0.2, + ]); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 888db557..30f13ece 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -2,7 +2,7 @@ import { colourDistance } from '../lib/harness.mjs'; import { inView } from './lib/scaffold.mjs'; /** One per entry of `ASSET_TYPES` – a new asset type counts here too. */ -const ASSET_TYPE_COUNT = 28; +const ASSET_TYPE_COUNT = 29; /** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */ export async function assets(t) { diff --git a/tools/e2e/64-pattern.mjs b/tools/e2e/64-pattern.mjs new file mode 100644 index 00000000..b20c6cbc --- /dev/null +++ b/tools/e2e/64-pattern.mjs @@ -0,0 +1,353 @@ +import { edge, node } from '../lib/builders.mjs'; +import { pickAssetType } from './lib/scaffold.mjs'; + +/** + * Patterns and their piano roll (#333). "+ → Pattern" opens the tab; with an instrument picked its + * keys sound where the instrument plays. A click draws a note on the grid, its right end lengthens + * it, its body moves it – with Alt off the grid –, a band picks notes, Ctrl+D puts a copy behind + * them, the arrows transpose, Delete and a right-click take notes away, Ctrl+C/V copies them to the + * start set on the ruler, the lane sets a velocity, a drag on the ruler sets the loop. Space plays the + * pattern round its loop through the scheduler, and a note drawn meanwhile is planned without a + * restart. Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and + * the sound counters, as in e2e 34. + * + * Everything it adds carries a `pt64` id or is the pattern it made, and goes again at the end. + */ +const BLIP_BYTES = 4044; +const INSTRUMENT = 'pt64-ins'; + +export async function pattern(t) { + const { check, sleep, json, evaluate, mutate, click, clickText, press, drag, wheel, shot, showTab } = t; + const stats = () => json('__gm.sound.stats()'); + const waitFor = async (read, done, tries = 25) => { + let value = await read(); + for (let i = 0; i < tries && !done(value); i++) { + await sleep(150); + value = await read(); + } + return value; + }; + + await press('Escape'); + await evaluate('__gm.state.select(null)'); + await showTab('scene'); + const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`); + const blip = stored.find((item) => item.bytes === BLIP_BYTES); + await mutate(` + __gm.state.project.assets.push( + { id: 'pt64-snd', type: 'sound', name: 'Blip 64', props: {}, file: ${JSON.stringify(blip?.file ?? '')} }, + { + id: ${JSON.stringify(INSTRUMENT)}, + type: 'instrument', + name: 'Lead 64', + props: {}, + graph: { + nodes: ${JSON.stringify([ + node('pt64-notes', 'InstrumentNotes', 0, 0, { from: 60, to: 71 }), + node('pt64-sample', 'InstrumentSample', 200, 0, { sound: 'pt64-snd' }), + node('pt64-out', 'InstrumentOutput', 420, 0), + ])}, + edges: ${JSON.stringify([ + edge('pt64-e1', 'pt64-notes', 'any', 'pt64-sample', 'trigger'), + edge('pt64-e2', 'pt64-sample', 'out', 'pt64-out', 'in'), + ])}, + }, + }, + );`); + await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`); + + // 64a. "+ → Pattern" makes an empty pattern and opens its tab. + await clickText('+'); + await pickAssetType(t, 'Pattern'); + const created = await json(`{ + tab: __gm.tab(), + current: __gm.pattern.current(), + info: __gm.pattern.info(), + patterns: __gm.state.project.assets.filter((a) => a.type === 'pattern').map((a) => ({ id: a.id, notes: a.notes.length })), + }`); + check( + 'the + menu creates an empty pattern and opens its tab, which has no instrument yet', + created.tab === 'pattern' && + created.patterns.length === 1 && + created.current === created.patterns[0].id && + created.patterns[0].notes === 0 && + created.info === '0 notes · no instrument', + JSON.stringify(created), + ); + const id = created.patterns[0]?.id; + const notes = () => json(`__gm.pattern.notes(${JSON.stringify(id)})`); + await mutate( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).props.instrument = ${JSON.stringify(INSTRUMENT)};`, + ); + const keyed = await json('{ info: __gm.pattern.info(), keys: __gm.pattern.keys() }'); + const sounds = (n) => keyed.keys.find((k) => k.note === n)?.sounds; + check( + 'with an instrument its keys sound from C4 to B4 and the others are grey', + keyed.info === '0 notes · Lead 64' && + sounds(60) && + sounds(71) && + sounds(72) === false && + sounds(59) === false, + JSON.stringify(keyed), + ); + + // 64b. Zoomed in on the ruler, a click draws a 1/16 on the grid line before it, and sounds. + const before = await json('__gm.pattern.view()'); + const r0 = await json('__gm.pattern.rulerAt(0)'); + await wheel(r0.x, r0.y, -600); + const zoomed = await json('__gm.pattern.view()'); + check( + 'the wheel on the ruler zooms the time about the pointer', + zoomed.pxPerTick > before.pxPerTick * 2 && Math.abs(zoomed.panX) < 1.5, + JSON.stringify({ before, zoomed }), + ); + const heard = await stats(); + let p = await json('__gm.pattern.gridAt(250, 62)'); + await click(p.x, p.y); + const drawn = await notes(); + const drawnSound = await waitFor(stats, (s) => s.started > heard.started); + check( + 'a click draws a 1/16 on the grid line before the pointer, picks it and lets it sound', + drawn.length === 1 && + drawn[0].tick === 240 && + drawn[0].length === 120 && + drawn[0].note === 62 && + drawn[0].velocity === 0.8 && + (await json('__gm.pattern.picked()')).join() === drawn[0].id && + drawnSound.started > heard.started, + JSON.stringify({ drawn, drawnSound, heard }), + ); + const first = drawn[0].id; + + // 64c. Its right end takes it to the grid line nearest the pointer; the next note drawn is as long. + const end = await json('__gm.pattern.gridAt(360, 62)'); + p = await json('__gm.pattern.gridAt(720, 62)'); + await drag(end.x - 2, end.y, p.x, p.y); + await sleep(500); + p = await json('__gm.pattern.gridAt(1000, 64)'); + await click(p.x, p.y); + const lengths = await notes(); + check( + 'dragging the end lengthens it to 1/4, and the next note drawn takes that length', + lengths.length === 2 && + lengths.find((n) => n.id === first)?.length === 480 && + lengths.find((n) => n.id !== first)?.tick === 960 && + lengths.find((n) => n.id !== first)?.length === 480, + JSON.stringify(lengths), + ); + const second = lengths.find((n) => n.id !== first)?.id; + + // 64d. Its body moves it onto the grid and to another key; with Alt it lands on the tick. + await sleep(500); + p = await json('__gm.pattern.gridAt(300, 62)'); + let to = await json('__gm.pattern.gridAt(780, 60)'); + await drag(p.x, p.y, to.x, to.y); + const moved = (await notes()).find((n) => n.id === first); + check( + 'dragging a note moves it to the grid line and the key under the pointer', + moved?.tick === 720 && moved?.note === 60, + JSON.stringify(moved), + ); + await sleep(500); + p = await json('__gm.pattern.gridAt(800, 60)'); + to = await json('__gm.pattern.gridAt(850, 60)'); + await drag(p.x, p.y, to.x, to.y, { modifiers: { alt: true } }); + const free = (await notes()).find((n) => n.id === first); + await press('z', { ctrl: true }); + const back = (await notes()).find((n) => n.id === first); + check( + 'with Alt held it moves off the grid, to the tick, and Ctrl+Z takes it back', + free?.tick % 120 !== 0 && Math.abs(free.tick - 770) <= 6 && back?.tick === 720, + JSON.stringify({ free, back }), + ); + + // 64e. A band picks both; Ctrl+D puts a copy right behind them, picked; the arrows transpose it. + p = await json('__gm.pattern.gridAt(100, 67)'); + to = await json('__gm.pattern.gridAt(1600, 58)'); + await drag(p.x, p.y, to.x, to.y); + const banded = await json('__gm.pattern.picked()'); + await press('d', { ctrl: true }); + const doubled = await notes(); + const copies = await json('__gm.pattern.picked()'); + const copyOf = (n) => doubled.find((c) => copies.includes(c.id) && c.note === n); + check( + 'a band picks the notes it touches, and Ctrl+D copies them right behind, picking the copies', + banded.length === 2 && + banded.includes(first) && + banded.includes(second) && + doubled.length === 4 && + copies.length === 2 && + !copies.includes(first) && + copyOf(60)?.tick === 1440 && + copyOf(64)?.tick === 1680, + JSON.stringify({ banded, doubled, copies }), + ); + await press('ArrowUp'); + const up = (await notes()).filter((n) => copies.includes(n.id)).map((n) => n.note); + await press('ArrowUp', { shift: true }); + const octave = (await notes()).filter((n) => copies.includes(n.id)).map((n) => n.note); + await press('ArrowDown', { shift: true }); + check( + 'an arrow transposes the picked notes a semitone, with Shift an octave', + up.join() === '61,65' && octave.join() === '73,77', + JSON.stringify({ up, octave }), + ); + + // 64f. Delete takes the picked notes, Ctrl+Z brings them back. + await press('Delete'); + const deleted = await notes(); + await press('z', { ctrl: true }); + const restored = await notes(); + check( + 'Delete takes the picked notes away and Ctrl+Z brings them back', + deleted.length === 2 && restored.length === 4, + JSON.stringify({ deleted: deleted.length, restored: restored.length }), + ); + + // 64g. Ctrl+A, Ctrl+C; a click on the ruler at bar 2 sets the start; Ctrl+V lands there. + await press('a', { ctrl: true }); + await press('c', { ctrl: true }); + const bar2 = await json('__gm.pattern.rulerAt(1920)'); + await click(bar2.x + 3, bar2.y); + const startSet = (await json('__gm.pattern.view()')).start; + await press('v', { ctrl: true }); + const pasted = await notes(); + const pastedIds = await json('__gm.pattern.picked()'); + const pastedTicks = pasted.filter((n) => pastedIds.includes(n.id)).map((n) => n.tick); + check( + 'a click on the ruler sets where playing starts, and Ctrl+V pastes the copied notes there', + startSet === 1920 && pasted.length === 8 && pastedIds.length === 4 && Math.min(...pastedTicks) === 1920, + JSON.stringify({ startSet, count: pasted.length, pastedTicks }), + ); + + // 64h. A right-click takes a note away; the lane sets the velocity of the one it grabs. + p = await json('__gm.pattern.gridAt(1980, 60)'); + await click(p.x, p.y, 'right'); + const erased = await notes(); + const lane = await json('__gm.pattern.laneAt(720, 0.8)'); + const low = await json('__gm.pattern.laneAt(720, 0.25)'); + await drag(lane.x + 1, lane.y + 2, low.x + 1, low.y); + const soft = await notes(); + check( + 'a right-click deletes the note under it, and dragging its bar in the lane sets its velocity alone', + erased.length === 7 && + Math.abs(soft.find((n) => n.id === first).velocity - 0.25) < 0.04 && + soft.filter((n) => n.id !== first).every((n) => n.velocity === 0.8), + JSON.stringify({ count: erased.length, velocities: soft.map((n) => n.velocity) }), + ); + + // 64i. A drag on the ruler sets the loop; its menu takes it back to the whole pattern. + p = await json('__gm.pattern.rulerAt(1920)'); + to = await json('__gm.pattern.rulerAt(2880)'); + await drag(p.x, p.y, to.x, to.y); + const looped = await json(`__gm.pattern.loop(${JSON.stringify(id)})`); + await click(p.x + 20, p.y, 'right'); + await clickText('Loop the whole pattern'); + const whole = await json( + `{ loop: __gm.pattern.loop(${JSON.stringify(id)}), view: __gm.pattern.view().loop }`, + ); + check( + 'a drag on the ruler sets the loop on the grid, and "Loop the whole pattern" takes it away', + looped?.start === 1920 && + looped?.end === 2880 && + whole.loop === null && + whole.view.start === 0 && + whole.view.end === 7680, + JSON.stringify({ looped, whole }), + ); + + // 64j. Space plays bar 1 round its loop from the start, each note at its tick on the audio clock; + // a note drawn while it plays is planned in the next round, without starting over. + p = await json('__gm.pattern.rulerAt(1920)'); + to = await json('__gm.pattern.rulerAt(0)'); + await drag(p.x, p.y, to.x, to.y); + const bar1 = await json(`__gm.pattern.loop(${JSON.stringify(id)})`); + // At the loop's start, but a click: it sets where playing starts and leaves the loop as it is. + await click(to.x + 2, to.y); + await press('Space'); + const planned = await waitFor( + () => json('__gm.pattern.planned()'), + (list) => list.length >= 4, + 30, + ); + const playing = await json('{ playing: __gm.pattern.playing(), button: __gm.pattern.playAt().text }'); + const at = (tick) => planned.find((n) => Math.abs(n.at - planned[0].at - (tick - 720) / 960) < 1e-6); + check( + 'Space plays the loop of bar 1 from its start: every note planned at its tick on the audio clock', + bar1?.start === 0 && + bar1?.end === 1920 && + playing.playing && + playing.button === 'Stop' && + planned[0].note === 60 && + planned[0].instrument === INSTRUMENT && + at(960)?.note === 64 && + at(1440)?.note === 61 && + at(1680)?.note === 65, + JSON.stringify({ + bar1, + playing, + planned: planned.map((n) => [n.note, +(n.at - planned[0].at).toFixed(4)]), + }), + ); + const head = await json('__gm.pattern.position()'); + p = await json('__gm.pattern.gridAt(1800, 69)'); + await click(p.x, p.y); + const live = await waitFor( + () => json('__gm.pattern.planned()'), + (list) => list.some((n) => n.note === 69), + 30, + ); + check( + 'a note drawn while it plays is heard in the next round, the playing goes on', + live.some((n) => n.note === 69) && live.length > planned.length && (await json('__gm.pattern.playing()')), + JSON.stringify({ head, live: live.map((n) => n.note) }), + ); + await shot('64-pattern'); + await press('Space'); + const stopped = await waitFor( + async () => ({ + playing: await json('__gm.pattern.playing()'), + stats: await stats(), + view: await json('__gm.pattern.view()'), + }), + (s) => s.stats.playing === 0, + ); + check( + 'Space again stops it, its voices fade and the playhead goes', + !stopped.playing && + stopped.stats.playing === 0 && + stopped.view.playhead === null && + (await json('__gm.pattern.playAt().text')) === 'Play', + JSON.stringify(stopped), + ); + + // 64k. A key down the left sounds while it is held. + const keyBefore = await stats(); + const key = await json('__gm.pattern.keyAt(67)'); + await t.mouse('mouseMoved', key.x, key.y); + await t.mouse('mousePressed', key.x, key.y); + const held = await waitFor(stats, (s) => s.started > keyBefore.started); + const heldKey = (await json('__gm.pattern.view()')).keys; + await t.mouse('mouseReleased', key.x, key.y); + check( + 'a key of the roll sounds while it is held and is lit', + held.started > keyBefore.started && heldKey === 67 && (await json('__gm.pattern.view()')).keys === null, + JSON.stringify({ held, heldKey }), + ); + + // 64l. The step takes back everything it made. + await evaluate('__gm.sound.stopAll()'); + await mutate(` + const gone = new Set(['pt64-snd', ${JSON.stringify(INSTRUMENT)}, ${JSON.stringify(id)}]); + const project = __gm.state.project; + project.assets = project.assets.filter((a) => !gone.has(a.id)); + __gm.state.select(null);`); + await showTab('scene'); + check( + 'the step leaves nothing of its own behind', + (await json( + `__gm.state.project.assets.filter((a) => a.id.startsWith('pt64') || a.type === 'pattern').length`, + )) === 0, + '', + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 2fe20d4e..6c88f826 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -66,6 +66,7 @@ import { release } from './60-release.mjs'; import { fullscreen } from './61-fullscreen.mjs'; import { devicePixels } from './62-device-pixels.mjs'; import { renderLayers } from './63-render-layers.mjs'; +import { pattern } from './64-pattern.mjs'; export const STEPS = [ hierarchy, @@ -131,4 +132,5 @@ export const STEPS = [ fullscreen, devicePixels, renderLayers, + pattern, ];