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>
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
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();
|
||
}
|
||
}
|