Files
feuerwerk/src/editor/pattern/PatternEditor.ts
T
adminandClaude Opus 5.5 a295d233ed Give sounds, instruments, patterns and songs a sound FX (#352)
A sound FX could only be picked where something was played: on a Play
node, a track or an item of a song. A sound that should always sound
through the same effect needed that pick everywhere it was used. Now
the asset says it once, and every place that plays it follows.

- New row Sound FX on a sound, an instrument, a pattern and a song,
  right before Bus. A sound goes through it on every voice, an
  instrument on every note after its graph, a pattern on all its
  notes together and a song on its output.
- The fx input of Play sound, Play note, Play pattern and Play song
  and the Sound FX of a song item now read "From the asset" while
  empty. None is a pick of its own, stored as `#none` like `#master`.
  A pick replaces the FX of the asset, it does not come on top.
- A pattern item's Instrument reads "From the asset" instead of None.
  The meaning is unchanged.
- A double-click on a sound item opens the sound, as one on a pattern
  item opens the pattern.
- The tabs play through the FX of their asset. The Pattern and Song
  tab put it on a bus of their own between the output and the bus of
  the asset. The keys of the Pattern tab play there too.
- Notes of a pattern or song go through the FX of their instrument.
  Its chain is loaded with the other needs of the sequence.

Decided without asking:
- Old items and Play nodes with an empty fx now mean "From the
  asset". No asset has an FX yet, so nothing sounds different.
- The label is "From the asset" as on Loop and Bus, not "From asset".
- A sound used as a Sample, as the impulse of a Reverb or as the test
  sound of the Sound FX tab plays without its own FX.

Findings:
- In e2e, `__gm.inspectorField('Sound FX')` and `('Instrument')` find
  the group of the Assets panel first. The check asks for the last
  match.

Tests:
- Unit asset-fx (new): 8 green. Six counter-checks each turned red.
- Field lists of sound, pattern and song: Sound FX added.
- e2e 65 (melektaus-atx): ALL PASSED, 46 checks (3 new). Counter-check
  without the FX bus of the tab: that check red.
- Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4142 tests.
- Not checked: how it sounds, the Sound, Instrument and Pattern tab
  in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:02:26 +02:00

271 lines
10 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 {
instrumentBuildFor,
instrumentSourcesOf,
namedNotes,
playedNotes,
} 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 { playedBus } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor';
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.
*/
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' });
/** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */
readonly music = new Music(
() => this.state.project,
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
() => {
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) {
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();
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.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();
}
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');
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.syncGhosts(asset);
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();
}
}