Play a sound through a graph of gain, pitch and EQ effects #311
A sound could only be played as it was uploaded. A Sound FX asset is
a node graph like a shader: Input, effects, Output. Play sound has a
new input fx that sends the voice through it.
- New asset type soundfx ("Sound FX"), not placeable, with a graph
and its own tab. The heading in the asset panel is "Sound effects",
so it does not take the click meant for the + menu entry.
- Nodes in core/soundfx/nodes.ts, category sound, port type audio
that only fits audio: Input, Output, Gain (dB), Pitch (semitones),
EQ (80 Hz and 12 kHz shelves, peaks at 250 Hz, 1 kHz and 4 kHz).
- compileSoundFx walks back from the Output with the shared graph
emitter. An input takes one edge, so the result is one line: a
pitch and a list of gain and filter stages. A node without input
or a graph without Output reports a problem on the node.
- Pitch is the playback rate of the source. It applies to the whole
sound and also changes its length, the node description says so.
- The BehaviorSystem resolves fx and hands the line to the AudioSink.
An fx that is missing or does not compile plays the sound dry.
- SoundLibrary wires source, stages, volume, destination in that
order. stats().last now also names pitch and stage count.
- The tab header picks one of the project's sounds and plays it
through the graph as it stands. Stop there ends only its own voice.
Tests:
- soundfx.test.ts (new, 11), sound-library (9, +2 for the wiring),
node-catalog (+1 for fx), graph and asset-groups adjusted
- e2e 34: ALL PASSED - 26 checks (+5: + menu opens the tab, palette,
2 effects, tab Play and game Play through the FX at -12 st)
- Gate on melektaus-atx: typecheck, lint, prettier clean, 239 of 240
test files green. The red one was sound-library (its fake
AudioContext had no playbackRate), fixed afterwards, gate not
re-run in full.
- Counter-check: without the audio rule in portsCompatible the port
test fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
93c0a80b7d
commit
de14f0f9e2
34 files changed
+899
-43
No files matched your search
@@ -72,6 +72,7 @@ import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
|
||||
import { ProjectSession } from './ProjectSession';
|
||||
import { ParticlesEditor } from './particles/ParticlesEditor';
|
||||
import { ShaderEditor } from './shader/ShaderEditor';
|
||||
import { SoundFxEditor } from './soundfx/SoundFxEditor';
|
||||
import { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||||
import { installShortcuts } from './shortcuts';
|
||||
import type { PlayFrom } from '../core/model/playSettings';
|
||||
@@ -93,6 +94,7 @@ export type CentreTab =
|
||||
| 'statemachine'
|
||||
| 'shader'
|
||||
| 'particles'
|
||||
| 'soundfx'
|
||||
| 'game'
|
||||
| 'texture'
|
||||
| 'vectorart'
|
||||
@@ -142,6 +144,7 @@ const PANE_ORDER = [
|
||||
'statemachine',
|
||||
'shader',
|
||||
'particles',
|
||||
'soundfx',
|
||||
'texture',
|
||||
'vectorart',
|
||||
'pixelart',
|
||||
@@ -161,6 +164,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
statemachine: 'State machine',
|
||||
shader: 'Shader',
|
||||
particles: 'Particles',
|
||||
soundfx: 'Sound FX',
|
||||
texture: 'Texture',
|
||||
vectorart: 'Vector art',
|
||||
pixelart: 'Pixel art',
|
||||
@@ -209,6 +213,7 @@ export class Editor implements PlayHost {
|
||||
readonly stateMachineEditor: StateMachineEditor;
|
||||
readonly shaderEditor: ShaderEditor;
|
||||
readonly particlesEditor: ParticlesEditor;
|
||||
readonly soundFxEditor: SoundFxEditor;
|
||||
readonly textureEditor: TextureEditor;
|
||||
readonly vectorArtEditor: VectorArtEditor;
|
||||
readonly pixelArtEditor: PixelArtEditor;
|
||||
@@ -294,6 +299,7 @@ export class Editor implements PlayHost {
|
||||
this.stateMachineEditor = new StateMachineEditor(state);
|
||||
this.shaderEditor = new ShaderEditor(state);
|
||||
this.particlesEditor = new ParticlesEditor(state);
|
||||
this.soundFxEditor = new SoundFxEditor(state);
|
||||
this.textureEditor = new TextureEditor(state);
|
||||
this.vectorArtEditor = new VectorArtEditor(state);
|
||||
this.pixelArtEditor = new PixelArtEditor(state);
|
||||
@@ -402,6 +408,7 @@ export class Editor implements PlayHost {
|
||||
statemachine: this.stateMachineEditor,
|
||||
shader: this.shaderEditor,
|
||||
particles: this.particlesEditor,
|
||||
soundfx: this.soundFxEditor,
|
||||
texture: this.textureEditor,
|
||||
vectorart: this.vectorArtEditor,
|
||||
pixelart: this.pixelArtEditor,
|
||||
@@ -591,6 +598,8 @@ export class Editor implements PlayHost {
|
||||
return this.shaderEditor.canvas.paste();
|
||||
case 'particles':
|
||||
return this.particlesEditor.canvas.paste();
|
||||
case 'soundfx':
|
||||
return this.soundFxEditor.canvas.paste();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
shader: 'shader',
|
||||
shaderfunction: 'shader',
|
||||
particles: 'particles',
|
||||
soundfx: 'soundfx',
|
||||
texture: 'texture',
|
||||
vectorart: 'vectorart',
|
||||
pixelart: 'pixelart',
|
||||
|
||||
@@ -96,6 +96,20 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
startRing: (nodeId: string) => editor.stateMachineEditor.canvas.startRingDrawn(nodeId),
|
||||
stateMarks: () => editor.stateMachineEditor.canvas.stateMarksDrawn(),
|
||||
},
|
||||
soundfx: {
|
||||
current: () => editor.soundFxEditor.currentAssetId,
|
||||
info: () => editor.soundFxEditor.shownInfo,
|
||||
canvasRect: () => editor.soundFxEditor.canvas.canvasRect(),
|
||||
nodeProblem: (nodeId: string) => editor.soundFxEditor.canvas.nodeProblem(nodeId),
|
||||
/** The header's listening controls: which sound, and whether Play is offered. */
|
||||
preview: () => ({
|
||||
sound: editor.soundFxEditor.previewSoundId,
|
||||
picker: editor.soundFxEditor.soundPicker.text,
|
||||
play: editor.soundFxEditor.playButton.enabled,
|
||||
}),
|
||||
playAt: () => centreOf(editor.soundFxEditor.playButton),
|
||||
stopAt: () => centreOf(editor.soundFxEditor.stopButton),
|
||||
},
|
||||
particles: {
|
||||
current: () => editor.particlesEditor.currentAssetId,
|
||||
info: () => editor.particlesEditor.shownInfo,
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||
import { findAsset, findGraphAsset } from '../../core/model/Project';
|
||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
import { ViewTween } from '../viewTween';
|
||||
@@ -305,7 +306,9 @@ export class GraphCanvas extends Widget {
|
||||
? shaderLibrary.checkFor(asset, this.state.project).problems
|
||||
: asset.type === 'particles'
|
||||
? particleLibrary.buildFor(asset).problems
|
||||
: null;
|
||||
: asset.type === 'soundfx'
|
||||
? soundFxBuildFor(asset).problems
|
||||
: null;
|
||||
for (const node of asset.graph.nodes) {
|
||||
const resolved = resolveDef(node, asset.graph, lookup);
|
||||
if (!resolved) continue;
|
||||
|
||||
@@ -67,6 +67,8 @@ function categoryColor(category: NodeCategory): number {
|
||||
return c.categoryShader;
|
||||
case 'particles':
|
||||
return c.categoryParticles;
|
||||
case 'sound':
|
||||
return c.categorySound;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +93,8 @@ export function portColor(type: PortType): number {
|
||||
return c.portVec3;
|
||||
case 'picture':
|
||||
return c.portPicture;
|
||||
case 'audio':
|
||||
return c.portAudio;
|
||||
case 'any':
|
||||
return c.portAny;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes';
|
||||
import { offeredIn, ShaderFunctionInput, ShaderFunctionOutput } from '../../core/shader/functions';
|
||||
import { SHADER_NODES, SHADER_OUTPUT, SHADER_VERTEX_OUTPUT } from '../../core/shader/nodes';
|
||||
import { SOUND_FX_INPUT, SOUND_FX_NODES, SOUND_FX_OUTPUT } from '../../core/soundfx/nodes';
|
||||
|
||||
/**
|
||||
* One row of the palette. `def` is the definition the node would actually have once placed, so the
|
||||
@@ -125,7 +126,8 @@ function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsse
|
||||
|
||||
/**
|
||||
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
|
||||
* new one named by its number; a shader holds shader nodes (`shaderEntries`). In a behavior, every other behavior appears under its own name instead of behind a
|
||||
* new one named by its number; a shader holds shader nodes (`shaderEntries`), a sound FX its effects
|
||||
* with one Input and one Output. In a behavior, every other behavior appears under its own name instead of behind a
|
||||
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
|
||||
* through (#294) –, and the two terminals drop out once the graph has them.
|
||||
*/
|
||||
@@ -135,6 +137,12 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
|
||||
return [{ id: State.type, hint: State.category, def: State, params }];
|
||||
}
|
||||
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
|
||||
if (asset.type === 'soundfx') {
|
||||
const placed = new Set(asset.graph.nodes.map((n) => n.type));
|
||||
return SOUND_FX_NODES.filter(
|
||||
(def) => !(def.type === SOUND_FX_INPUT || def.type === SOUND_FX_OUTPUT) || !placed.has(def.type),
|
||||
).map((def) => ({ id: def.type, hint: def.category, def, params: {} }));
|
||||
}
|
||||
if (asset.type === 'particles') {
|
||||
const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT);
|
||||
const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf));
|
||||
@@ -147,7 +155,7 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
|
||||
const entries: PaletteEntry[] = [];
|
||||
for (const def of allNodeDefs()) {
|
||||
if (namesBehavior(def.type) || def.category === 'state') continue;
|
||||
if (def.category === 'shader' || def.category === 'particles') continue;
|
||||
if (def.category === 'shader' || def.category === 'particles' || def.category === 'sound') continue;
|
||||
if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue;
|
||||
entries.push({ id: def.type, hint: def.category, def, params: {} });
|
||||
if (def.type === FIND) entries.push(filterEntry(def));
|
||||
|
||||
@@ -34,6 +34,8 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
|
||||
shaderfunction: 'Shader functions',
|
||||
font: 'Fonts',
|
||||
sound: 'Sounds',
|
||||
// Not "Sound FX": that is the `+` menu entry (see Vector art).
|
||||
soundfx: 'Sound effects',
|
||||
particles: 'Particles',
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import scene from './icons/scene.svg?raw';
|
||||
import shader from './icons/shader.svg?raw';
|
||||
import shaderfunction from './icons/shaderfunction.svg?raw';
|
||||
import sound from './icons/sound.svg?raw';
|
||||
import soundfx from './icons/soundfx.svg?raw';
|
||||
import sprite from './icons/sprite.svg?raw';
|
||||
import statemachine from './icons/statemachine.svg?raw';
|
||||
import struct from './icons/struct.svg?raw';
|
||||
@@ -53,6 +54,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
|
||||
shader,
|
||||
shaderfunction,
|
||||
sound,
|
||||
soundfx,
|
||||
sprite,
|
||||
statemachine,
|
||||
struct,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- Three faders of an equalizer: what a sound is played through. -->
|
||||
<path d="M-4.5 -6.5 V6.5 M0 -6.5 V6.5 M4.5 -6.5 V6.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" />
|
||||
<rect x="-6.5" y="0.5" width="4" height="2.5" rx="0.8" fill="currentColor" />
|
||||
<rect x="-2" y="-4" width="4" height="2.5" rx="0.8" fill="currentColor" />
|
||||
<rect x="2.5" y="-1" width="4" height="2.5" rx="0.8" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 537 B |
@@ -0,0 +1,107 @@
|
||||
import type { Asset, SoundFxAsset } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { volumeOf } from '../../core/properties/sounds';
|
||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||
import { Button, Dropdown, Menu } from '../../ui';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
/** 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<SoundFxAsset, GraphCanvas> {
|
||||
readonly snap = new SnapToggle(() => this.canvas);
|
||||
/** Public for the check scripts. */
|
||||
readonly soundPicker: Dropdown;
|
||||
readonly playButton: Button;
|
||||
readonly stopButton: Button;
|
||||
private soundId = '';
|
||||
|
||||
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,
|
||||
]);
|
||||
state.on('load', () => {
|
||||
this.soundId = '';
|
||||
});
|
||||
}
|
||||
|
||||
override selectedId(): string {
|
||||
return this.state.selectedSoundFxId ?? '';
|
||||
}
|
||||
|
||||
override isMine(asset: Asset): asset is SoundFxAsset {
|
||||
return asset.type === 'soundfx';
|
||||
}
|
||||
|
||||
/** The sound the header plays: the one picked, else the project's first. */
|
||||
get previewSoundId(): string {
|
||||
const sounds = this.state.project.assets.filter((a) => a.type === 'sound');
|
||||
return sounds.some((a) => a.id === this.soundId) ? this.soundId : (sounds[0]?.id ?? '');
|
||||
}
|
||||
|
||||
pickSound(id: string): void {
|
||||
this.soundId = id;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** 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 = findAssetOf(this.state.project, this.previewSoundId, 'sound');
|
||||
const chain = asset && soundFxBuildFor(asset).compiled;
|
||||
if (!sound?.file || !chain) return;
|
||||
const volume = volumeOf(resolveAssetProps(this.state.project, sound));
|
||||
soundLibrary.play(sound.file, volume, false, PREVIEW_OWNER, chain);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
soundLibrary.stopOwner(PREVIEW_OWNER);
|
||||
}
|
||||
|
||||
protected override infoText(asset: SoundFxAsset): string {
|
||||
const build = soundFxBuildFor(asset);
|
||||
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 sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
|
||||
this.soundPicker.setText(sound?.name ?? 'No sound');
|
||||
this.soundPicker.setEnabled(Boolean(asset && sound));
|
||||
this.playButton.setEnabled(Boolean(asset && sound?.file && soundFxBuildFor(asset).compiled));
|
||||
this.stopButton.setEnabled(Boolean(asset));
|
||||
}
|
||||
|
||||
private openSoundPicker(): void {
|
||||
const current = this.previewSoundId;
|
||||
const items = this.state.project.assets
|
||||
.filter((a) => a.type === 'sound')
|
||||
.map((a) => ({ label: a.name, current: a.id === current, onSelect: () => this.pickSound(a.id) }));
|
||||
if (items.length > 0) new Menu(items).openBelow(this.soundPicker);
|
||||
}
|
||||
}
|
||||
@@ -810,6 +810,11 @@ export class EditorState {
|
||||
return this.selectedGraphId('particles');
|
||||
}
|
||||
|
||||
/** The sound FX asset the selection refers to: the asset, or one of its nodes. */
|
||||
get selectedSoundFxId(): string | null {
|
||||
return this.selectedGraphId('soundfx');
|
||||
}
|
||||
|
||||
private selectedGraphId(type: GraphAsset['type']): string | null {
|
||||
const s = this.selection;
|
||||
if (!s) return null;
|
||||
|
||||
Reference in new issue
Block a user