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

Notes could only be played one at a time, by a key or a behavior. A
pattern is a stretch of notes for one instrument, drawn in a piano
roll and played in time through the scheduler of #332. Songs (#334)
will lay patterns out on tracks.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 15:06:51 +02:00
1 parent 9c71eb1a0d
commit 4ea752feef
29 files changed
+2744 -10

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import {
instrumentBuildFor,
instrumentSourcesOf,
namedNotes,
playedNotes,
} from '../../core/instrument/compile';
import {
GRID_OPTIONS,
PATTERN_GRID_KEY,
patternInstrument,
patternLoop,
patternMeter,
patternSequence,
patternTempo,
} from '../../core/music/pattern';
import type { Asset, PatternAsset } from '../../core/model/Asset';
import { textOf } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { playNote } from '../../runtime/systems/behaviorContext';
import { Button, Dropdown, Label, Menu } from '../../ui';
import { Music } from '../../viewport/music';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor';
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
import type { EditorState } from '../state';
import { PianoRoll } from './PianoRoll';
const INFO_WIDTH = 200;
const GRID_WIDTH = 64;
/** How long a note drawn or moved sounds. */
const PREVIEW_SECONDS = 0.3;
/** Whose voices the tab starts for a key or a drawn note, apart from those the scheduler plays. */
const PREVIEW_OWNER = 'pattern-preview';
/**
* The Pattern tab (#333): the piano roll, and in the header Play – the Space bar does the same –
* and the grid notes are drawn on. The pattern plays round its loop through the scheduler (#332);
* an edit is heard from its next window on, without starting over.
*/
export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
/** Public for the check scripts. */
readonly playButton: Button;
readonly gridButton = new Dropdown('1/16', { width: GRID_WIDTH });
readonly music = new Music(() => this.state.project);
/** The pattern playing, `''` while none is. */
private playingId = '';
/** What the scheduler was last told, so only a change is passed on. */
private told = '';
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
this.playButton = new Button('Play', {
icon: TOOLBAR_ICON_SVG.play,
minWidth: 72,
tooltip: 'Play the pattern round its loop (Space)',
onClick: () => this.togglePlay(),
});
this.gridButton.onClick = () => this.openGridMenu();
const roll = new PianoRoll(state);
roll.onPreview = (note, held) => this.preview(note, held);
roll.onRelease = (voice) => soundLibrary.release(voice);
roll.onTogglePlay = () => this.togglePlay();
roll.onStart = (tick) => {
if (this.isPlaying) this.music.current?.seek(tick);
};
this.mount(roll, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]);
state.on('load', () => this.stop());
}
override selectedId(): string {
return this.state.selectedPatternId ?? '';
}
override isMine(asset: Asset): asset is PatternAsset {
return asset.type === 'pattern';
}
get isPlaying(): boolean {
return this.playingId !== '';
}
togglePlay(): void {
if (this.isPlaying) this.stop();
else this.play();
}
/** Plays the pattern shown from where the ruler says, round its loop. */
play(): void {
const asset = this.asset();
if (!asset) return;
this.playingId = asset.id;
this.told = this.settingsOf(asset);
void this.music.play(patternSequence(asset), {
tempo: patternTempo(asset.props),
meter: patternMeter(asset.props),
loop: patternLoop(asset),
from: this.canvas.start,
});
this.syncPlayButton();
}
stop(): void {
this.music.stop();
this.playingId = '';
this.canvas.playhead = null;
this.canvas.invalidate();
this.syncPlayButton();
}
/** Only called while the tab is visible: the playhead follows what is heard. */
tick(): void {
if (!this.isPlaying) return;
this.canvas.playhead = this.music.current?.position().ticks ?? null;
this.canvas.invalidate();
}
override destroy(options?: Parameters<AssetTabEditor<PatternAsset, PianoRoll>['destroy']>[0]): void {
this.music.stop();
super.destroy(options);
}
/** A key of the roll is held until it is let go; a note drawn or moved sounds for a moment. */
private preview(note: number, held: boolean): number {
const asset = this.asset();
const instrument = asset ? patternInstrument(asset.props) : '';
if (!instrument) return 0;
const struck = { note, velocity: 1, volume: 1, duration: held ? 0 : PREVIEW_SECONDS };
return playNote(
{ project: this.state.project, audio: soundLibrary },
instrument,
struck,
PREVIEW_OWNER,
{},
);
}
private openGridMenu(): void {
const asset = this.asset();
if (!asset) return;
const current = textOf(asset.props[PATTERN_GRID_KEY] ?? '');
new Menu(
GRID_OPTIONS.map((option) => ({
label: option.label,
current: option.value === current,
onSelect: () =>
this.state.execute(new SetAssetPropertyCommand(asset.id, PATTERN_GRID_KEY, option.value)),
})),
).openBelow(this.gridButton);
}
private settingsOf(asset: PatternAsset): string {
return JSON.stringify([patternTempo(asset.props), patternMeter(asset.props), patternLoop(asset)]);
}
/** What changed while playing goes to the scheduler; another pattern shown stops it. */
private follow(asset: PatternAsset | null): void {
if (!this.isPlaying) return;
if (!asset || asset.id !== this.playingId) {
this.stop();
return;
}
this.music.edit(patternSequence(asset));
const settings = this.settingsOf(asset);
if (settings === this.told) return;
this.told = settings;
const scheduler = this.music.current;
scheduler?.setTempo(patternTempo(asset.props));
scheduler?.setMeter(patternMeter(asset.props));
scheduler?.setLoop(patternLoop(asset));
}
private syncPlayButton(): void {
this.playButton.setIcon(this.isPlaying ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play);
this.playButton.setText(this.isPlaying ? 'Stop' : 'Play');
}
protected override infoText(asset: PatternAsset): string {
const count = asset.notes.length;
const notes = `${count} ${count === 1 ? 'note' : 'notes'}`;
const instrument = findAssetOf(this.state.project, patternInstrument(asset.props), 'instrument');
return instrument ? `${notes} · ${instrument.name}` : `${notes} · no instrument`;
}
protected override syncExtras(asset: PatternAsset | null): void {
this.fitButton.setEnabled(asset !== null);
this.playButton.setEnabled(asset !== null);
this.gridButton.setEnabled(asset !== null);
this.gridButton.setText(asset ? textOf(asset.props[PATTERN_GRID_KEY] ?? '1/16') : '1/16');
const instrument = asset
? findAssetOf(this.state.project, patternInstrument(asset.props), 'instrument')
: undefined;
if (instrument) {
const sources = instrumentSourcesOf(this.state.project);
const sounding = [...playedNotes(instrument.graph)].filter(
(note) => instrumentBuildFor(instrument, note, sources).layers.length > 0,
);
this.canvas.setInstrument(new Set(sounding), namedNotes(instrument.graph));
} else this.canvas.setInstrument(new Set(), new Map());
this.follow(asset);
}
}