Files
feuerwerk/src/editor/pattern/PatternEditor.ts
T
adminandClaude Opus 5.5 19902a9f02
feuerwerk / gate (push) Successful in 4m47s
feuerwerk / suite (push) Successful in 19m5s
Keep groups, comments and silent music in view for the agent (#429)
A live test of the music areas built a pattern, a song and a
composition that stayed silent: no instrument at first, and later
notes outside the C4 to B4 range of a new Notes node. The agent got
no hint of either. The same test showed that new nodes left groups
behind, that a comment could end up under other nodes, and that an
end node like Output stayed left of the devices put in front of it.

- get_asset lists under silent what of a pattern, song or
  composition is not heard. That is a missing instrument, an
  instrument that sounds no note, or notes outside what its Notes
  nodes take in, in a song after the transpose.
- soundingNotes moves from the pattern tab to
  src/core/instrument/compile.ts. The tab and the agent use the
  same rule.
- When new nodes go between two that have no room, everything from
  the fed node on moves right together. This holds for Arrange with
  a selection too, so no link runs backwards.
- A new node linked to a node in a group joins that group, and the
  group grows in the same undo step. A group that would take in a
  foreign node stays as it is.
- Arrange takes groups and comments along: a group whose nodes all
  moved the same keeps its size, otherwise it is fitted anew. The
  rules live in the new src/editor/graph/annotationFollow.ts.
- add_comment and fit_group put a comment below its nodes when a
  node or a group title is in the way above. lint_graph counts
  covered comments as coveredNotes.
- add_notes, get_asset, lint_graph and the relay instructions name
  the default range and how to mend a covered comment.

Tests:
- graph-follow (new, 12), agent-edit, agent-music (11) and the
  other agent, graph, pattern and relay tests green
- 10 deliberate breakages, each one red in its own test
- e2e 71: 25/25
- Gate: CHECK EXIT 0, 324 files, 5002 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:27:57 +02:00

282 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { instrumentSourcesOf, namedNotes, soundingNotes } from '../../core/instrument/compile';
import { ghostNotes, placeOf } from '../../core/music/ghosts';
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 { fxVoiceOf } from '../../core/music/needs';
import { assetFxOf } from '../../core/properties/assetFx';
import { DEFAULT_TEMPO } from '../../core/music/time';
import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
import { Button, Dropdown, Label, Menu, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { MASTER_MIX } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { SoundStore } from '../../core/store/SoundStore';
import { AssetTabEditor } from '../AssetTabEditor';
import { BakeButton } from '../music/BakeButton';
import { claimTransport, releaseTransport } from '../music/transport';
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. Bake (#340) is for a game: the
* tab always plays the notes.
*/
export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
/** Public for the check scripts. */
readonly playButton: Button;
readonly gridButton = new Dropdown('1/16', { width: GRID_WIDTH });
/** Whether a note drawn, grabbed or moved sounds; off by default. A key down the left always does. */
readonly auditionButton: Button;
/** Whether the notes the song plays around the pattern are shown (#339); on by default. */
readonly ghostsButton: Button;
/** Where the ghost notes come from: song, track and bar. */
readonly ghostSource = new Label('', { style: 'muted' });
readonly bakeButton: BakeButton;
/** Heard through the sound FX (#352), then straight into the master: a preview leaves the routing out (#368). */
readonly music = new Music(
() => this.state.project,
() => MASTER_MIX,
() => {
const asset = this.asset();
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), patternTempo(asset.props));
},
);
/** The pattern playing, `''` while none is. */
private playingId = '';
/** What the scheduler was last told, so only a change is passed on. */
private told = '';
private readonly halt = () => this.stop();
constructor(state: EditorState, sounds: SoundStore) {
super(state, { infoWidth: INFO_WIDTH });
this.bakeButton = new BakeButton(
state,
sounds,
() => this.asset(),
() => this.refresh(),
);
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();
this.auditionButton = new Button('Audition', {
toggle: true,
tooltip: 'Let a note sound when it is drawn, grabbed or moved',
onClick: () => this.auditionButton.setToggled(!this.auditionButton.toggled),
});
this.ghostsButton = new Button('Ghosts', {
toggle: true,
tooltip: () =>
this.ghostsButton.enabled
? 'Show what the song plays around this pattern, faint'
: 'Double-click an item of this pattern in a song to see what plays around it',
onClick: () => {
this.ghostsButton.setToggled(!this.ghostsButton.toggled);
this.refresh();
},
});
this.ghostsButton.setToggled(true);
const roll = new PianoRoll(state);
roll.onPreview = (note, held) => this.preview(note, held);
roll.onRelease = (voice) => {
soundLibrary.release(voice);
this.music.idle();
};
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,
this.auditionButton,
this.ghostsButton,
this.bakeButton,
this.ghostSource,
]);
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;
claimTransport(this.halt);
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 {
releaseTransport(this.halt);
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();
}
protected override closed(assetId: string): void {
if (assetId === this.playingId) this.stop();
if (assetId === this.currentAssetId) soundLibrary.stopOwner(PREVIEW_OWNER);
}
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 with Audition on. */
private preview(note: number, held: boolean): number {
if (!held && !this.auditionButton.toggled) return 0;
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 };
if (!held) setTimeout(() => this.music.idle(), PREVIEW_SECONDS * 1000);
return playNote(
{ project: this.state.project, audio: soundLibrary },
instrument,
struck,
PREVIEW_OWNER,
{},
{ bus: this.music.heardOn() },
fxVoiceOf(this.state.project, instrumentFx(this.state.project, instrument, ''), DEFAULT_TEMPO),
);
}
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');
const text = instrument ? `${notes} · ${instrument.name}` : `${notes} · no instrument`;
const bake = this.bakeButton.statusText(asset);
return bake ? `${text} · ${bake}` : text;
}
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 sounding = soundingNotes(instrument, instrumentSourcesOf(this.state.project));
this.canvas.setInstrument(sounding, namedNotes(instrument.graph));
} else this.canvas.setInstrument(new Set(), new Map());
this.syncGhosts(asset);
this.bakeButton.sync();
this.follow(asset);
}
/** The song item the pattern was opened from – or the only one it lies in – and what plays around it. */
private syncGhosts(asset: PatternAsset | null): void {
const project = this.state.project;
const place = asset && placeOf(project, asset.id, this.state.patternPlaces.get(asset.id));
const ghosts = asset && place ? ghostNotes(project, asset.id, place) : null;
this.ghostsButton.setEnabled(ghosts !== null);
this.canvas.setGhosts(this.ghostsButton.toggled ? ghosts : null);
const source = ghosts ? `${ghosts.song} · ${ghosts.track} · bar ${ghosts.bar}` : '';
if (source === this.ghostSource.text) return;
this.ghostSource.setText(source);
(this.ghostSource.parent as Widget | null)?.layout();
}
}