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:
adminandClaude Opus 5.5 committed 2026-10-05 16:45:42 +02:00
1 parent 93c0a80b7d
commit de14f0f9e2
34 files changed
+899 -43

No files matched your search

+9
View File
@@ -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;
}
+1
View File
@@ -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',
+14
View File
@@ -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,
+4 -1
View File
@@ -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;
+4
View File
@@ -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;
}
+10 -2
View File
@@ -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));
+2
View File
@@ -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',
};
+2
View File
@@ -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,
+7
View File
@@ -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

+107
View File
@@ -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);
}
}
+5
View File
@@ -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;