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>
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
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();
|
||
}
|
||
}
|