Files
feuerwerk/src/core/soundfx/nodes.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

148 lines
5.3 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 { NodeDef, PortDef } from '../graph/NodeRegistry';
import type { PropertyBag } from '../model/Properties';
import type { FlatDef } from '../properties/PropertySchema';
/** The filter shapes the EQ uses – Web Audio's names, so the player hands them on as they are. */
export type FxFilter = 'lowshelf' | 'peaking' | 'highshelf';
/** One step a voice passes on its way out: a volume, or one band of an equalizer. */
export type FxStage =
| { kind: 'gain'; gain: number }
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: number; q: number };
/**
* A sound on its way through the graph: how many semitones it is played up or down, and what it
* passes, in order. `gain` is a factor, a filter's `gain` is in dB as Web Audio takes it.
*/
export interface FxChain {
pitch: number;
stages: readonly FxStage[];
}
export const DRY: FxChain = { pitch: 0, stages: [] };
/**
* A node of a sound FX graph (#311). It never runs: `emit` says what it does to the sound arriving
* at its one input. Input and Output have a side less and no effect of their own.
*/
export interface SoundFxNodeDef extends NodeDef {
category: 'sound';
emit(chain: FxChain, params: PropertyBag): FxChain;
}
export function isSoundFxNodeDef(def: NodeDef): def is SoundFxNodeDef {
return def.category === 'sound' && 'emit' in def;
}
export const SOUND_FX_INPUT = 'SoundFxInput';
export const SOUND_FX_OUTPUT = 'SoundFxOutput';
const audioIn: PortDef = { key: 'in', label: 'in', type: 'audio' };
const audioOut: PortDef = { key: 'out', label: 'out', type: 'audio' };
function numberParam(key: string, label: string, min: number, max: number, step: number): FlatDef<'number'> {
return { key, label, kind: 'number', default: 0, min, max, step };
}
/** A param's value held to its row, so a hand-edited project cannot blow a speaker. */
function read(params: PropertyBag, def: FlatDef<'number'>): number {
const value = Number(params[def.key] ?? def.default);
if (!Number.isFinite(value)) return def.default;
return Math.max(def.min ?? -Infinity, Math.min(def.max ?? Infinity, value));
}
export const SoundFxInput: SoundFxNodeDef = {
type: SOUND_FX_INPUT,
label: 'Input',
category: 'sound',
description: 'The sound as the Play sound node starts it, before any effect.',
inputs: [],
outputs: [audioOut],
params: [],
emit: (chain) => chain,
};
export const SoundFxOutput: SoundFxNodeDef = {
type: SOUND_FX_OUTPUT,
label: 'Output',
category: 'sound',
description: 'What is heard: the sound as it arrives here, with every effect on the way.',
inputs: [audioIn],
outputs: [],
params: [],
emit: (chain) => chain,
};
const GAIN = numberParam('gain', 'Gain (dB)', -60, 24, 0.5);
export const SoundFxGain: SoundFxNodeDef = {
type: 'SoundFxGain',
label: 'Gain',
category: 'sound',
description:
'Makes the sound louder or quieter, in decibels: 0 leaves it as it is, -6 is about half as loud, +6 twice. Above 0 it can clip.',
inputs: [audioIn],
outputs: [audioOut],
params: [GAIN],
emit: (chain, params) => {
const db = read(params, GAIN);
if (db === 0) return chain;
return { ...chain, stages: [...chain.stages, { kind: 'gain', gain: 10 ** (db / 20) }] };
},
};
const SEMITONES = numberParam('semitones', 'Semitones', -24, 24, 1);
export const SoundFxPitch: SoundFxNodeDef = {
type: 'SoundFxPitch',
label: 'Pitch',
category: 'sound',
description:
'Plays the sound higher or lower, in semitones: 12 is an octave up. It is the speed the sound is played at, so a higher one is also shorter, and it applies to the whole sound wherever the node sits in the line.',
inputs: [audioIn],
outputs: [audioOut],
params: [SEMITONES],
emit: (chain, params) => ({ ...chain, pitch: chain.pitch + read(params, SEMITONES) }),
};
/** The five bands of the EQ: a shelf at either end and three peaks between them. */
export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter; frequency: number }[] = [
{ key: 'low', label: '80 Hz', filter: 'lowshelf', frequency: 80 },
{ key: 'lowMid', label: '250 Hz', filter: 'peaking', frequency: 250 },
{ key: 'mid', label: '1 kHz', filter: 'peaking', frequency: 1000 },
{ key: 'highMid', label: '4 kHz', filter: 'peaking', frequency: 4000 },
{ key: 'high', label: '12 kHz', filter: 'highshelf', frequency: 12000 },
];
/** About an octave and a half wide: neighbouring peaks overlap, so no frequency falls between them. */
const PEAK_Q = 1;
const EQ_PARAMS = EQ_BANDS.map((band) => numberParam(band.key, band.label, -24, 24, 0.5));
export const SoundFxEq: SoundFxNodeDef = {
type: 'SoundFxEq',
label: 'EQ',
category: 'sound',
description:
'A five-band equalizer: raises or lowers the lows (80 Hz), low mids (250 Hz), mids (1 kHz), high mids (4 kHz) and highs (12 kHz), each in decibels. A band at 0 does nothing.',
inputs: [audioIn],
outputs: [audioOut],
params: EQ_PARAMS,
emit: (chain, params) => {
const stages: FxStage[] = [];
EQ_BANDS.forEach((band, i) => {
const gain = read(params, EQ_PARAMS[i]!);
if (gain !== 0)
stages.push({ kind: 'filter', filter: band.filter, frequency: band.frequency, gain, q: PEAK_Q });
});
return stages.length === 0 ? chain : { ...chain, stages: [...chain.stages, ...stages] };
},
};
export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxInput,
SoundFxOutput,
SoundFxGain,
SoundFxPitch,
SoundFxEq,
];