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
@@ -101,6 +101,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'font', file: '' };
|
||||
case 'sound':
|
||||
return { ...base, type: 'sound', file: '' };
|
||||
case 'soundfx':
|
||||
return { ...base, type: 'soundfx', graph: createEmptyGraph() };
|
||||
case 'particles':
|
||||
return { ...base, type: 'particles', graph: createEmptyGraph() };
|
||||
}
|
||||
|
||||
+14
-2
@@ -9,7 +9,17 @@ export type { DeclaredPortKind } from '../properties/kinds';
|
||||
* `picture` is what a shader's Pass draws (#301), for the nodes that read a picture.
|
||||
*/
|
||||
export type PortType =
|
||||
'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'vec3' | 'picture' | 'any';
|
||||
| 'exec'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'string'
|
||||
| 'object'
|
||||
| 'list'
|
||||
| 'struct'
|
||||
| 'vec3'
|
||||
| 'picture'
|
||||
| 'audio'
|
||||
| 'any';
|
||||
|
||||
export interface PortRef {
|
||||
node: string;
|
||||
@@ -172,11 +182,13 @@ export function graphFingerprint(graph: Graph): string {
|
||||
|
||||
/**
|
||||
* A number may feed a colour: the shader compiler spreads it over the three channels. A picture is
|
||||
* no value a calculation could take, so like `exec` it goes only where a picture is read.
|
||||
* no value a calculation could take, so like `exec` it goes only where a picture is read – and a
|
||||
* sound on its way through effects (#311) only where a sound goes in.
|
||||
*/
|
||||
export function portsCompatible(from: PortType, to: PortType): boolean {
|
||||
if (from === 'exec' || to === 'exec') return from === to;
|
||||
if (from === 'picture' || to === 'picture') return from === to;
|
||||
if (from === 'audio' || to === 'audio') return from === to;
|
||||
if (from === 'number' && to === 'vec3') return true;
|
||||
return from === to || from === 'any' || to === 'any';
|
||||
}
|
||||
@@ -237,8 +237,9 @@ export interface NodeContext {
|
||||
* The voice belongs to the behavior that started it and ends when that behavior is destroyed –
|
||||
* a sound is something a thing in the world is doing. `detached` gives that up on purpose, for
|
||||
* the one case where the rule is in the way: the death sound of an object that destroys itself.
|
||||
* `fx` names a sound FX asset the voice is played through (#311); `''` plays it as it is.
|
||||
*/
|
||||
playSound(assetId: string, volume: number, loop: boolean | undefined, detached: boolean): void;
|
||||
playSound(assetId: string, volume: number, loop: boolean | undefined, detached: boolean, fx: string): void;
|
||||
/** Silences every voice of that sound; `''` silences everything this run started. */
|
||||
stopSound(assetId: string): void;
|
||||
/**
|
||||
|
||||
@@ -30,11 +30,12 @@ export interface PortDef {
|
||||
}
|
||||
|
||||
/**
|
||||
* `state`, `shader` and `particles` are the categories no behavior graph offers: their nodes are the
|
||||
* states of a state machine, the GLSL of a shader asset and the motion of one particle.
|
||||
* `state`, `shader`, `particles` and `sound` are the categories no behavior graph offers: their nodes
|
||||
* are the states of a state machine, the GLSL of a shader asset, the motion of one particle and the
|
||||
* effects of a sound FX asset.
|
||||
*/
|
||||
export type NodeCategory =
|
||||
'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader' | 'particles';
|
||||
'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader' | 'particles' | 'sound';
|
||||
|
||||
/**
|
||||
* How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body,
|
||||
@@ -207,7 +208,13 @@ export function inlineInputs(def: NodeDef): PortDef[] {
|
||||
function isInlinePort(port: PortDef): boolean {
|
||||
if (port.inline === false) return false;
|
||||
if (port.type === 'vec3') return port.field !== undefined;
|
||||
return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct' && port.type !== 'picture';
|
||||
return (
|
||||
port.type !== 'exec' &&
|
||||
port.type !== 'object' &&
|
||||
port.type !== 'struct' &&
|
||||
port.type !== 'picture' &&
|
||||
port.type !== 'audio'
|
||||
);
|
||||
}
|
||||
|
||||
/** Untyped, an input writes the zero of its kind; as its default the node body shows it, not just the name. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber, asString, type Value } from '../../values';
|
||||
import { exec, num, soundPort, withDefault } from '../shared';
|
||||
import { exec, num, soundFxPort, soundPort, withDefault } from '../shared';
|
||||
|
||||
/**
|
||||
* Three answers, not two: a checkbox cannot say "leave it to the asset", and without that a sound
|
||||
@@ -56,14 +56,20 @@ export const PlaySound: NodeDef = {
|
||||
"leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " +
|
||||
'the node only colors it. Each play is a voice of its own: firing this twice in a row is heard ' +
|
||||
'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' +
|
||||
'unless "detached" is on.',
|
||||
inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort()],
|
||||
'unless "detached" is on. "fx" sends it through a sound FX asset; left empty, it plays as it is.',
|
||||
inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort(), soundFxPort()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const sound = asString(inputs.sound);
|
||||
if (sound)
|
||||
ctx.playSound(sound, asNumber(inputs.volume), loopChoice(inputs.loop), asBoolean(inputs.detached));
|
||||
ctx.playSound(
|
||||
sound,
|
||||
asNumber(inputs.volume),
|
||||
loopChoice(inputs.loop),
|
||||
asBoolean(inputs.detached),
|
||||
asString(inputs.fx),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -146,6 +146,7 @@ import { StateTransition } from './flow/StateTransition';
|
||||
import { PARTICLE_NODES } from '../../particles/nodes';
|
||||
import { SHADER_FUNCTION_NODES } from '../../shader/functions';
|
||||
import { SHADER_NODES } from '../../shader/nodes';
|
||||
import { SOUND_FX_NODES } from '../../soundfx/nodes';
|
||||
import { State } from '../stateMachine';
|
||||
import { Variables } from './variable/Variables';
|
||||
|
||||
@@ -314,3 +315,4 @@ registerNode(State);
|
||||
for (const def of SHADER_NODES) registerNode(def);
|
||||
for (const def of SHADER_FUNCTION_NODES) registerNode(def);
|
||||
for (const def of PARTICLE_NODES) registerNode(def);
|
||||
for (const def of SOUND_FX_NODES) registerNode(def);
|
||||
@@ -94,6 +94,14 @@ export const soundPort = (): PortDef => ({
|
||||
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,
|
||||
|
||||
+12
-1
@@ -310,6 +310,15 @@ export interface SoundAsset extends AssetBase {
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a sound is played through (#311): the graph runs from its `Input` over effect nodes to its
|
||||
* `Output`, and a `Play sound` naming it sends the voice along that line (see `core/soundfx`).
|
||||
*/
|
||||
export interface SoundFxAsset extends AssetBase {
|
||||
type: 'soundfx';
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile a tilemap paints with: `pictures` names one block per animation step (a texture id for a
|
||||
* sheet that holds nothing else, a frame id for one block cut out of a sheet), and `props` say
|
||||
@@ -562,6 +571,7 @@ export type Asset =
|
||||
| ShaderFunctionAsset
|
||||
| FontAsset
|
||||
| SoundAsset
|
||||
| SoundFxAsset
|
||||
| ParticlesAsset;
|
||||
|
||||
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
|
||||
@@ -573,7 +583,7 @@ export function isVariableOwner(asset: Asset | undefined): asset is VariableOwne
|
||||
|
||||
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
|
||||
export type GraphAsset =
|
||||
BehaviorAsset | StateMachineAsset | ShaderAsset | ShaderFunctionAsset | ParticlesAsset;
|
||||
BehaviorAsset | StateMachineAsset | ShaderAsset | ShaderFunctionAsset | ParticlesAsset | SoundFxAsset;
|
||||
|
||||
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'behavior',
|
||||
@@ -581,6 +591,7 @@ const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'shader',
|
||||
'shaderfunction',
|
||||
'particles',
|
||||
'soundfx',
|
||||
]);
|
||||
|
||||
export function isGraphAsset(asset: Asset): asset is GraphAsset {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const ASSET_TYPES = [
|
||||
'shaderfunction',
|
||||
'font',
|
||||
'sound',
|
||||
'soundfx',
|
||||
'particles',
|
||||
] as const;
|
||||
|
||||
@@ -55,6 +56,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
shaderfunction: 'Shader function',
|
||||
font: 'Font',
|
||||
sound: 'Sound',
|
||||
soundfx: 'Sound FX',
|
||||
particles: 'Particles',
|
||||
};
|
||||
|
||||
@@ -66,7 +68,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
|
||||
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
|
||||
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
|
||||
* A shader function is part of other shaders (#303), worn by nothing itself.
|
||||
* A shader function is part of other shaders (#303), worn by nothing itself. A sound FX is what a
|
||||
* sound is played through (#311), heard but never seen.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
@@ -83,6 +86,7 @@ export type PlaceableAssetType = Exclude<
|
||||
| 'shaderfunction'
|
||||
| 'font'
|
||||
| 'sound'
|
||||
| 'soundfx'
|
||||
| 'variables'
|
||||
| 'struct'
|
||||
>;
|
||||
@@ -101,6 +105,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'shaderfunction',
|
||||
'font',
|
||||
'sound',
|
||||
'soundfx',
|
||||
'variables',
|
||||
'struct',
|
||||
]);
|
||||
|
||||
@@ -655,6 +655,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
|
||||
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
|
||||
],
|
||||
// The effects are the asset's graph (see SoundFxAsset), not a property.
|
||||
soundfx: [],
|
||||
// How many particles and for how long; what one of them does is the asset's graph, whose Property
|
||||
// nodes add their rows after these – see `schemaForBag`.
|
||||
particles: [
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { graphEmitter } from '../graph/emitGraph';
|
||||
import { type Graph, graphFingerprint } from '../graph/Graph';
|
||||
import type { SoundFxAsset } from '../model/Asset';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import {
|
||||
DRY,
|
||||
type FxChain,
|
||||
isSoundFxNodeDef,
|
||||
SOUND_FX_INPUT,
|
||||
SOUND_FX_OUTPUT,
|
||||
type SoundFxNodeDef,
|
||||
} from './nodes';
|
||||
|
||||
export interface SoundFxBuild {
|
||||
/** Null while the graph has a problem – a sound played through it is then played dry. */
|
||||
compiled: FxChain | null;
|
||||
/** How many nodes between Input and Output the sound passes. */
|
||||
effects: number;
|
||||
/** Per node id, what keeps the graph from compiling. */
|
||||
problems: Record<string, string>;
|
||||
/** One line for the tab: empty once compiled. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sound FX graph to the line a voice is sent along: walked back from the Output to the Input, every
|
||||
* node on the way adding what it does. An input takes one edge, so the line never forks on its way
|
||||
* to the Output; a node fed from nowhere is a problem rather than silence.
|
||||
*/
|
||||
export function compileSoundFx(graph: Graph): SoundFxBuild {
|
||||
const walk = graphEmitter<SoundFxNodeDef, FxChain>(graph, {
|
||||
isDef: isSoundFxNodeDef,
|
||||
noun: 'sound FX',
|
||||
// Only reached with every input fed, so whatever is not the Input has its sound arriving.
|
||||
emit: (def, inputs, node) => ({ out: def.emit(inputs.in ?? DRY, node.params) }),
|
||||
unfed: (node) => {
|
||||
walk.problem(node, 'Nothing comes in: connect it to the Input');
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const output = graph.nodes.find((n) => n.type === SOUND_FX_OUTPUT);
|
||||
if (!output) return { compiled: null, effects: 0, problems: walk.problems, reason: 'No Output node' };
|
||||
const chain = walk.input(output, nodeDef(SOUND_FX_OUTPUT)!.inputs[0]!);
|
||||
const failed = walk.firstProblem();
|
||||
if (failed !== null || !chain)
|
||||
return { compiled: null, effects: 0, problems: walk.problems, reason: failed ?? 'Cannot compile' };
|
||||
const effects = graph.nodes.filter(
|
||||
(n) => walk.reached(n) && n.type !== SOUND_FX_INPUT && n.type !== SOUND_FX_OUTPUT,
|
||||
).length;
|
||||
return { compiled: chain, effects, problems: walk.problems, reason: '' };
|
||||
}
|
||||
|
||||
const builds = new Map<string, { fingerprint: string; build: SoundFxBuild }>();
|
||||
|
||||
/**
|
||||
* The asset's build, compiled again only when what it does has changed – a voice is started far
|
||||
* more often than its graph is edited, and a moved node changes nothing.
|
||||
*/
|
||||
export function soundFxBuildFor(asset: SoundFxAsset): SoundFxBuild {
|
||||
const fingerprint = graphFingerprint(asset.graph);
|
||||
const cached = builds.get(asset.id);
|
||||
if (cached && cached.fingerprint === fingerprint) return cached.build;
|
||||
const build = compileSoundFx(asset.graph);
|
||||
builds.set(asset.id, { fingerprint, build });
|
||||
return build;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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,
|
||||
];
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../graph';
|
||||
import { isParticleNodeDef } from '../particles/nodes';
|
||||
import { isShaderNodeDef } from '../shader/nodes';
|
||||
import { isSoundFxNodeDef } from '../soundfx/nodes';
|
||||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||||
import type {
|
||||
AnimationKey,
|
||||
@@ -234,6 +235,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||||
} else if (type === 'particles') {
|
||||
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
|
||||
} else if (type === 'soundfx') {
|
||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, isSoundFxNodeDef) });
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||||
} else if (type === 'font') {
|
||||
|
||||
Reference in new issue
Block a user