Files
feuerwerk/src/core/graph/nodes/shared.ts
T
adminandClaude Opus 5.5 de14f0f9e2 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>
2026-10-05 16:45:42 +02:00

153 lines
5.6 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 type { PropertyValue } from '../../model/Properties';
import type { PropertyDef } from '../../properties/PropertySchema';
import type { NodeDef, PortDef } from '../NodeRegistry';
import { asBoolean, asNumber } from '../values';
export const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
export const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
export const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
export const str = (key: string, label = key): PortDef => ({ key, label, type: 'string' });
export const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
export const list = (key: string, label = key): PortDef => ({ key, label, type: 'list' });
export const struct = (key: string, label = key): PortDef => ({ key, label, type: 'struct' });
export const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
/** A whole-number input (an index, a count): its field shows no decimals, the node floors it anyway. */
export const int = (key: string, value = 0, label = key): PortDef => ({
key,
label,
type: 'number',
default: value,
field: { key, label, kind: 'number', default: value, step: 1, decimals: 0 },
});
/** Outputs of the three collision events: the other body, its collider and the own collider. */
export const collisionOutputs = (): PortDef[] => [
exec(),
obj('other'),
obj('otherCollider', 'other collider'),
obj('collider'),
];
/**
* How a contact touches: which way the other side pushes (a unit vector pointing from it towards the
* own collider), how hard, and the middle of the contact points in the world. The physics engine
* works them out (#146, #149).
*/
export const contactOutputs = (): PortDef[] => [
num('normalX', 'normal x'),
num('normalY', 'normal y'),
num('impulse'),
num('pointX', 'point x'),
num('pointY', 'point y'),
];
/** A tile asset; unconnected as an input, it shows the tile picker. */
export const tilePort = (key = 'tile', label = key): PortDef => ({
key,
label,
type: 'string',
field: { key, label: 'Tile', kind: 'asset', default: '', assetTypes: ['tile'] },
});
export const PER_CELL_INPUT = 'perCell';
/**
* Which pairs a collision event hears of a map's trigger tiles (#160): off, one per area of a tile –
* a pond is entered once –, on, one per cell.
*/
export const perCellPort = (): PortDef => ({
key: PER_CELL_INPUT,
label: 'per cell',
type: 'boolean',
default: false,
field: { key: PER_CELL_INPUT, label: 'Per cell (trigger tiles)', kind: 'boolean', default: false },
});
/**
* The trigger tile a collider lies on (#160) and its cell in the map, which is then `other`. No
* tile and cell -1, -1 for anything else: another collider, or a map's solid cells.
*/
export const tileOutputs = (): PortDef[] => [tilePort(), num('cellX', 'cell x'), num('cellY', 'cell y')];
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
/** No `assetTypes`: any placeable asset is a valid answer here. */
const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
/**
* The `asset` input of `Spawn` and `Is asset`; unconnected, it shows the asset picker. The key is the
* one the setting had before it became a port, so a saved pick is the inline value now.
*/
export const assetPort = (): PortDef => ({ key: 'asset', label: 'asset', type: 'string', field: assetParam });
/**
* The `sound` input of the two sound nodes; unconnected, it shows the sound picker – the way
* `machinePort` shows the machine picker. A `Sound` literal is what feeds it through an edge.
*/
export const soundPort = (): PortDef => ({
key: 'sound',
label: 'sound',
type: 'string',
field: { key: 'sound', label: 'Sound', kind: 'sound', default: '' },
});
/** The `fx` input of `Play sound` (#311); unconnected, it shows the picker of sound FX assets. */
export const soundFxPort = (): PortDef => ({
key: 'fx',
label: 'fx',
type: 'string',
field: { key: 'fx', label: 'FX', kind: 'asset', default: '', assetTypes: ['soundfx'] },
});
export function binaryMath(type: string, description: string, fn: (a: number, b: number) => number): NodeDef {
return {
type,
label: type,
category: 'data',
description,
inputs: [num('a'), num('b')],
outputs: [num('result')],
params: [],
run: (_ctx, inputs) => ({ outputs: { result: fn(asNumber(inputs.a), asNumber(inputs.b)) } }),
};
}
export function unaryMath(type: string, description: string, fn: (value: number) => number): NodeDef {
return {
type,
label: type,
category: 'data',
description,
inputs: [num('value')],
outputs: [num('result')],
params: [],
run: (_ctx, inputs) => ({ outputs: { result: fn(asNumber(inputs.value)) } }),
};
}
export function binaryLogic(
type: string,
description: string,
fn: (a: boolean, b: boolean) => boolean,
): NodeDef {
return {
type,
label: type,
category: 'data',
description,
inputs: [bool('a'), bool('b')],
outputs: [bool('result')],
params: [],
run: (_ctx, inputs) => ({ outputs: { result: fn(asBoolean(inputs.a), asBoolean(inputs.b)) } }),
};
}
/** Scales (x, y) to length 1; the zero vector stays zero. */
export function normalize(x: number, y: number): { x: number; y: number; length: number } {
const length = Math.hypot(x, y);
return length === 0 ? { x: 0, y: 0, length: 0 } : { x: x / length, y: y / length, length };
}