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>
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
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,
|
||
];
|