import type { Asset, SoundFxAsset } from '../../core/model/Asset'; import { findSound } from '../../core/model/sound'; import { assetRefLabel, soundChoices } from '../../core/properties/assetRef'; import { playedSoundOf } from '../../core/properties/playedSound'; import { resolveAssetProps } from '../../core/properties/resolve'; import { volumeOf } from '../../core/properties/sounds'; import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile'; import { Button, Dropdown, Menu } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { MASTER_MIX } from '../../core/music/mixer'; import { AssetTabEditor } from '../AssetTabEditor'; import { GraphCanvas } from '../graph/GraphCanvas'; import { GRAPH_SNAPS_AT_FIRST, SnapToggle } from '../graph/SnapToggle'; import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; import type { EditorState } from '../state'; import { PreviewParams } from './PreviewParams'; /** Room kept for "N effects", or for the reason the graph does not compile. */ const INFO_WIDTH = 220; const SOUND_WIDTH = 140; /** Whose voices the tab's Stop silences: its own, never a running game's. */ const PREVIEW_OWNER = 'soundfx-preview'; /** * The nodes of one sound FX asset (#311), and in the header a way to hear them: a sound of the * project played through the graph as it stands. Which sound is the tab's, not the asset's – it is * only there to listen with. */ export class SoundFxEditor extends AssetTabEditor { readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST); /** Public for the check scripts. */ readonly soundPicker: Dropdown; readonly playButton: Button; readonly stopButton: Button; private soundId = ''; /** One name and number per parameter of the Input: what the header's Play hands in. */ private readonly params = new PreviewParams(); /** The asset whose voices the tab may still be playing. */ private playedFor = ''; constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); this.soundPicker = new Dropdown('No sound', { width: SOUND_WIDTH, onClick: () => this.openSoundPicker(), }); this.playButton = new Button('Play', { icon: TOOLBAR_ICON_SVG.play, onClick: () => this.play() }); this.stopButton = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop, onClick: () => this.stop() }); this.mount(new GraphCanvas(state, 'sound FX'), [ ...this.snap.widgets, this.soundPicker, this.playButton, this.stopButton, this.params.bar, ]); state.on('load', () => { this.soundId = ''; this.params.forget(); }); } override selectedId(): string { return this.state.selectedSoundFxId ?? ''; } override isMine(asset: Asset): asset is SoundFxAsset { return asset.type === 'soundfx'; } /** The sound or clip (#357) the header plays: the one picked, else the project's first. */ get previewSoundId(): string { const sounds = soundChoices(this.state.project); return sounds.some((s) => s.id === this.soundId) ? this.soundId : (sounds[0]?.id ?? ''); } pickSound(id: string): void { this.soundId = id; this.refresh(); } /** What the header's parameter fields are set to – the check scripts read this. */ get previewValues(): Record { return this.params.current; } /** Sets one parameter the header plays with, as typing into its field does. */ setPreviewValue(key: string, value: number): void { this.params.set(key, value); } /** Plays the picked sound through the graph; a graph that does not compile is not played at all. */ play(): void { const asset = this.asset(); const sound = findSound(this.state.project, this.previewSoundId); const plan = asset && soundFxBuildFor(asset, this.state.project).compiled; if (!sound?.asset.file || !plan) return; const props = resolveAssetProps(this.state.project, sound.asset); const fx = { plan, params: this.params.current }; // Straight into the master: a preview leaves the routing of the sound out (#368). const file = playedSoundOf(sound); const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, MASTER_MIX); this.params.heard(voice); this.playedFor = asset.id; } stop(): void { soundLibrary.stopOwner(PREVIEW_OWNER); this.params.silenced(); } protected override closed(assetId: string): void { if (assetId === this.playedFor) this.stop(); } protected override infoText(asset: SoundFxAsset): string { const build = soundFxBuildFor(asset, this.state.project); if (!build.compiled) return build.reason; return `${build.effects} ${build.effects === 1 ? 'effect' : 'effects'}`; } protected override syncExtras(asset: SoundFxAsset | null): void { this.snap.setEnabled(Boolean(asset)); const id = this.previewSoundId; const sound = findSound(this.state.project, id); this.soundPicker.setText(sound ? assetRefLabel(this.state.project, id) : 'No sound'); this.soundPicker.setEnabled(Boolean(asset && sound)); this.playButton.setEnabled( Boolean(asset && sound?.asset.file && soundFxBuildFor(asset, this.state.project).compiled), ); this.stopButton.setEnabled(Boolean(asset)); this.params.sync(asset?.id ?? '', asset ? soundFxParams(asset.graph) : []); } private openSoundPicker(): void { const current = this.previewSoundId; const items = soundChoices(this.state.project).map((s) => ({ label: s.label, current: s.id === current, onSelect: () => this.pickSound(s.id), })); if (items.length > 0) new Menu(items, { searchable: true }).openBelow(this.soundPicker); } }