Let a game hand values to the parameters of a sound FX #311
A sound FX played the same every time: its numbers were settings of the nodes. Now the Input declares parameters with a default, every number of Gain, Pitch and EQ is an input they can feed, and a Play sound hands in values for them. - Input declares number parameters in the inspector, each with a Default (DeclaredPort.default, SetPortDefaultCommand). They are outputs of the Input. Input and Output count as terminals now, one of each per graph. - Gain, Pitch and EQ take their numbers at inputs under the keys the settings had, so saved graphs sound as before without a migration. Each value is held to its range however it arrives. - The compiler gives an FxPlan with the parameters still open. fxChainOf fills them in when a voice starts. The player still only sees an FxChain. - New behavior node Sound FX parameters: names an FX and picks its parameters like a Variables node picks variables. Each picked one is a number input that starts at the FX's default. Its output has the new port type fxparams, which only fits Play sound's fx params. - Play sound warns when the parameters name another FX than the one it plays. Names the FX does not know are left out. - Renaming a parameter at the Input renames it in every Sound FX parameters node naming that FX, edges and values included. - The Sound FX tab has a number field per parameter in its header. Play uses those values, they are never written into the asset. - A value counts from the start of a voice. Changing a playing voice is #315, a Mix node is #316. Tests: - soundfx.test.ts 22 (+11): parameters in the plan, held values, a 0-by-default stage, EQ band, save with defaults, wrong port types, default command with undo, Sound FX parameters into Play sound, defaults of picked ones, node problems, mismatch, rename with undo - node-catalog 368 (+3): Play sound fx params, Sound FX parameters - e2e 34: ALL PASSED - 28 checks (+2: header field plays depth 5, a Sound FX parameters node gives the game's voice depth 7) - Gate on melektaus-atx: typecheck, lint, prettier clean, 240 test files, 3636 tests green - Counter-check: with fxChainOf ignoring the values and the rename not followed, 5 of the new tests fail. - Browser probe: header fields, inspector Default and the picked parameter looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
de14f0f9e2
commit
183f356c99
25 files changed
+785
-99
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BEHAVIOR_PARAM,
|
||||
declaresPropertyPorts,
|
||||
declaresSoundFxPorts,
|
||||
declaresVariablePorts,
|
||||
type DeclaredPort,
|
||||
findNode,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
propertyTargetOf,
|
||||
SHADER_CALL,
|
||||
SOUND_FX_INPUT,
|
||||
soundFxSourceOf,
|
||||
variablesSourceOf,
|
||||
} from '../graph';
|
||||
import { isGraphAsset } from '../model/Asset';
|
||||
@@ -342,6 +345,54 @@ export class RenamePortCommand extends RenameCommand {
|
||||
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
|
||||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||||
for (const site of sites) carryPort(site, from, to);
|
||||
if (node.type === SOUND_FX_INPUT) renameSoundFxParamPorts(project, this.assetId, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/** The default of a sound FX parameter (#311): what it is wherever no `Play sound` hands a value in. */
|
||||
export class SetPortDefaultCommand implements Command {
|
||||
readonly label: string;
|
||||
private was: number | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly nodeId: string,
|
||||
private readonly key: string,
|
||||
private readonly value: number,
|
||||
) {
|
||||
this.label = `Set default of ${key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
if (node.type !== SOUND_FX_INPUT) throw new CommandError('Only a sound FX Input has defaults');
|
||||
const port = (node.ports ?? []).find((p) => p.key === this.key);
|
||||
if (!port) throw new CommandError(`Port "${this.key}" not found`);
|
||||
if (!Number.isFinite(this.value)) throw new CommandError('A default is a number');
|
||||
this.was = port.default;
|
||||
this.mark(node, this.value);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.mark(requireNode(project, this.assetId, this.nodeId), this.was);
|
||||
}
|
||||
|
||||
private mark(node: GraphNode, value: number | undefined): void {
|
||||
node.ports = (node.ports ?? []).map((port) => {
|
||||
if (port.key !== this.key) return port;
|
||||
const { default: _, ...plain } = port;
|
||||
return value === undefined ? plain : { ...plain, default: value };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A renamed parameter of a sound FX, followed into every node that sets it. */
|
||||
function renameSoundFxParamPorts(project: Project, fxId: string, from: string, to: string): void {
|
||||
for (const asset of project.assets) {
|
||||
if (!isGraphAsset(asset)) continue;
|
||||
for (const node of asset.graph.nodes)
|
||||
if (declaresSoundFxPorts(node.type) && soundFxSourceOf(node.params) === fxId)
|
||||
renamePortOn(asset.graph, node, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ export type { DeclaredPortKind } from '../properties/kinds';
|
||||
/**
|
||||
* `exec` carries control flow, the others values; `any` accepts every value type. `vec3` is a
|
||||
* colour inside a shader graph, where the values are GLSL and no behavior node ever sees one;
|
||||
* `picture` is what a shader's Pass draws (#301), for the nodes that read a picture.
|
||||
* `picture` is what a shader's Pass draws (#301), for the nodes that read a picture. `fxparams` is
|
||||
* the values a `Play sound` hands to the parameters of its sound FX (#311).
|
||||
*/
|
||||
export type PortType =
|
||||
| 'exec'
|
||||
@@ -19,6 +20,7 @@ export type PortType =
|
||||
| 'vec3'
|
||||
| 'picture'
|
||||
| 'audio'
|
||||
| 'fxparams'
|
||||
| 'any';
|
||||
|
||||
export interface PortRef {
|
||||
@@ -70,6 +72,8 @@ export interface DeclaredPort {
|
||||
* from it. On a start node, a node running the behavior has it picked in advance as well.
|
||||
*/
|
||||
machine?: string;
|
||||
/** On a parameter of a sound FX Input: its value wherever nobody hands one in (#311). */
|
||||
default?: number;
|
||||
}
|
||||
|
||||
export function declaredPortType(kind: DeclaredPortKind): PortType {
|
||||
@@ -183,12 +187,14 @@ 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 – and a
|
||||
* sound on its way through effects (#311) only where a sound goes in.
|
||||
* sound on its way through effects (#311) only where a sound goes in, and its parameters only to a
|
||||
* `Play sound`.
|
||||
*/
|
||||
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 === 'fxparams' || to === 'fxparams') return from === to;
|
||||
if (from === 'number' && to === 'vec3') return true;
|
||||
return from === to || from === 'any' || to === 'any';
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AnimationPlayer } from '../model/animation';
|
||||
import type { Mat } from '../model/matrix';
|
||||
import type { Position, PropertyBag } from '../model/Properties';
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
import { type ChainTransform, composeAlongChain } from '../model/walk';
|
||||
import { enabledAlongChain } from '../properties/enabled';
|
||||
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
|
||||
@@ -238,8 +239,16 @@ export interface NodeContext {
|
||||
* 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.
|
||||
* `fxParams` are values for its parameters; one not given is the default the FX declares.
|
||||
*/
|
||||
playSound(assetId: string, volume: number, loop: boolean | undefined, detached: boolean, fx: string): void;
|
||||
playSound(
|
||||
assetId: string,
|
||||
volume: number,
|
||||
loop: boolean | undefined,
|
||||
detached: boolean,
|
||||
fx: string,
|
||||
fxParams: FxParams,
|
||||
): void;
|
||||
/** Silences every voice of that sound; `''` silences everything this run started. */
|
||||
stopSound(assetId: string): void;
|
||||
/**
|
||||
|
||||
@@ -213,7 +213,8 @@ function isInlinePort(port: PortDef): boolean {
|
||||
port.type !== 'object' &&
|
||||
port.type !== 'struct' &&
|
||||
port.type !== 'picture' &&
|
||||
port.type !== 'audio'
|
||||
port.type !== 'audio' &&
|
||||
port.type !== 'fxparams'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AnimationClip } from '../model/animation';
|
||||
import type { VectorPoint } from '../model/Asset';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import type { Graph } from './Graph';
|
||||
import type { DeclaredPort, Graph } from './Graph';
|
||||
|
||||
/** What running another behavior's graph in place needs: its nodes, its variable defaults, its name. */
|
||||
export interface BehaviorProgram {
|
||||
@@ -17,6 +17,12 @@ export interface ShaderFunction {
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/** A sound FX as a node naming it sees it: the parameters its Input declares (#311). */
|
||||
export interface SoundFxShape {
|
||||
name: string;
|
||||
params: readonly DeclaredPort[];
|
||||
}
|
||||
|
||||
/** One state of a machine: the node id is what a port and the runtime hold, the name is the label. */
|
||||
export interface MachineState {
|
||||
id: string;
|
||||
@@ -52,6 +58,7 @@ export interface GraphLookup {
|
||||
behavior(assetId: string): BehaviorProgram | null;
|
||||
/** A shader function's graph, which a call node takes its ports from and the compiler sets in (#303). */
|
||||
shaderFunction(assetId: string): ShaderFunction | null;
|
||||
soundFx(assetId: string): SoundFxShape | null;
|
||||
machine(assetId: string): StateMachine | null;
|
||||
curve(assetId: string): readonly VectorPoint[] | null;
|
||||
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */
|
||||
@@ -90,6 +97,7 @@ export interface ValueTyping {
|
||||
export const NO_LOOKUP: GraphLookup = {
|
||||
behavior: () => null,
|
||||
shaderFunction: () => null,
|
||||
soundFx: () => null,
|
||||
machine: () => null,
|
||||
curve: () => null,
|
||||
structType: () => null,
|
||||
|
||||
@@ -28,10 +28,29 @@ const ON_START = 'OnStart';
|
||||
export const SHADER_FUNCTION_INPUT = 'ShaderFunctionInput';
|
||||
export const SHADER_FUNCTION_OUTPUT = 'ShaderFunctionOutput';
|
||||
export const SHADER_CALL = 'ShaderCall';
|
||||
/** The two ends of a sound FX graph and the behavior node naming its parameters (#311). */
|
||||
export const SOUND_FX_INPUT = 'SoundFxInput';
|
||||
export const SOUND_FX_OUTPUT = 'SoundFxOutput';
|
||||
export const SOUND_FX_PARAMETERS = 'SoundFxParameters';
|
||||
const PLAY_SOUND = 'PlaySound';
|
||||
|
||||
export const BEHAVIOR_PARAM = 'behavior';
|
||||
export const FUNCTION_PARAM = 'function';
|
||||
export const VARIABLES_PARAM = 'variablesAsset';
|
||||
export const SOUND_FX_PARAM = 'soundFxAsset';
|
||||
|
||||
/** Whose parameters a `Sound FX parameters` node sets. */
|
||||
export const SOUND_FX_SETTING: PropertyDef = {
|
||||
key: SOUND_FX_PARAM,
|
||||
label: 'Sound FX',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['soundfx'],
|
||||
};
|
||||
|
||||
export function soundFxSourceOf(params: PropertyBag): string {
|
||||
return textOf(params[SOUND_FX_PARAM] ?? '');
|
||||
}
|
||||
|
||||
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */
|
||||
export const VARIABLES_SETTING: PropertyDef = {
|
||||
@@ -71,6 +90,11 @@ export function declaresPropertyPorts(type: string): boolean {
|
||||
return type === PROPERTIES;
|
||||
}
|
||||
|
||||
/** Nodes whose declared ports are parameters of the sound FX they name. */
|
||||
export function declaresSoundFxPorts(type: string): boolean {
|
||||
return type === SOUND_FX_PARAMETERS;
|
||||
}
|
||||
|
||||
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
|
||||
export function isValidPortName(name: string): boolean {
|
||||
return isValidFieldName(name) && name !== 'exec';
|
||||
@@ -127,13 +151,15 @@ export function isTerminalType(type: string): boolean {
|
||||
type === BEHAVIOR_START ||
|
||||
type === BEHAVIOR_END ||
|
||||
type === SHADER_FUNCTION_INPUT ||
|
||||
type === SHADER_FUNCTION_OUTPUT
|
||||
type === SHADER_FUNCTION_OUTPUT ||
|
||||
type === SOUND_FX_INPUT ||
|
||||
type === SOUND_FX_OUTPUT
|
||||
);
|
||||
}
|
||||
|
||||
/** The terminal whose ports a caller passes in, rather than takes out. */
|
||||
export function isEntryTerminal(type: string): boolean {
|
||||
return type === BEHAVIOR_START || type === SHADER_FUNCTION_INPUT;
|
||||
return type === BEHAVIOR_START || type === SHADER_FUNCTION_INPUT || type === SOUND_FX_INPUT;
|
||||
}
|
||||
|
||||
export function isTerminalNode(node: GraphNode): boolean {
|
||||
@@ -354,6 +380,7 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin
|
||||
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
|
||||
if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId);
|
||||
if (node.type === SHADER_CALL) return functionCallProblem(node, lookup, ownerId);
|
||||
if (node.type === SOUND_FX_PARAMETERS) return soundFxParametersProblem(node, lookup);
|
||||
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
|
||||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||
if (!id) return 'No behavior picked';
|
||||
@@ -398,6 +425,27 @@ function functionCallProblem(node: GraphNode, lookup: GraphLookup, ownerId: stri
|
||||
return null;
|
||||
}
|
||||
|
||||
function soundFxParametersProblem(node: GraphNode, lookup: GraphLookup): string | null {
|
||||
const id = soundFxSourceOf(node.params);
|
||||
if (!id) return 'No sound FX picked';
|
||||
return lookup.soundFx(id) ? null : 'That sound FX is gone';
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Play sound` whose parameters come from a node naming another sound FX than the one it plays:
|
||||
* the names that FX does not know are left out, so the values would go nowhere. Only said where both
|
||||
* are picked in place – one coming over an edge may be either.
|
||||
*/
|
||||
export function soundFxParamsMismatch(node: GraphNode, graph: Graph): string | null {
|
||||
if (node.type !== PLAY_SOUND || edgesInto(graph, node.id, 'fx').length > 0) return null;
|
||||
const played = textOf(node.params.fx ?? '');
|
||||
const edge = edgesInto(graph, node.id, 'fxParams')[0];
|
||||
const from = edge && findNode(graph, edge.from.node);
|
||||
if (!played || from?.type !== SOUND_FX_PARAMETERS) return null;
|
||||
const named = soundFxSourceOf(from.params);
|
||||
return named && named !== played ? 'The parameters are for another sound FX' : null;
|
||||
}
|
||||
|
||||
/** The ids the nodes of one type in that graph name in `param`, picked or not found alike. */
|
||||
function namedBy(graph: Graph, type: string, param: string): string[] {
|
||||
return graph.nodes
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isStruct } from '../../../model/Properties';
|
||||
import type { FxParams } from '../../../soundfx/nodes';
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber, asString, type Value } from '../../values';
|
||||
import { exec, num, soundFxPort, soundPort, withDefault } from '../shared';
|
||||
@@ -56,8 +58,17 @@ 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. "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()],
|
||||
'unless "detached" is on. "fx" sends it through a sound FX asset; left empty, it plays as it is. ' +
|
||||
'"fx params" takes values for its parameters from a Sound FX parameters node.',
|
||||
inputs: [
|
||||
exec(),
|
||||
soundPort(),
|
||||
withDefault(num('volume'), 1),
|
||||
loopPort(),
|
||||
detachedPort(),
|
||||
soundFxPort(),
|
||||
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
|
||||
],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
@@ -69,7 +80,16 @@ export const PlaySound: NodeDef = {
|
||||
loopChoice(inputs.loop),
|
||||
asBoolean(inputs.detached),
|
||||
asString(inputs.fx),
|
||||
fxParamsOf(inputs.fxParams),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
/** The numbers of a parameter bundle; anything else hands in none. */
|
||||
function fxParamsOf(value: Value | undefined): FxParams {
|
||||
if (!isStruct(value)) return {};
|
||||
const params: Record<string, number> = {};
|
||||
for (const [key, item] of Object.entries(value)) if (typeof item === 'number') params[key] = item;
|
||||
return params;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { StructValue } from '../../../model/Properties';
|
||||
import { SOUND_FX_PARAMETERS, SOUND_FX_SETTING, soundFxSourceOf } from '../../nested';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
|
||||
/**
|
||||
* Values for the parameters of one sound FX (#311), picked from those its Input declares the way a
|
||||
* Variables node picks variables. Each picked one is a number input starting at the FX's default;
|
||||
* the bundle goes into the `fx params` input of a `Play sound`.
|
||||
*/
|
||||
export const SoundFxParameters: NodeDef = {
|
||||
type: SOUND_FX_PARAMETERS,
|
||||
label: 'Sound FX parameters',
|
||||
category: 'data',
|
||||
description:
|
||||
'Values for the parameters of a sound FX, for the "fx params" input of a Play sound. Pick the parameters to set; one not picked keeps the default its Input gives it.',
|
||||
inputs: [],
|
||||
outputs: [{ key: 'params', label: 'params', type: 'fxparams' }],
|
||||
params: [SOUND_FX_SETTING],
|
||||
declaresPorts: 'inputs',
|
||||
portKinds: ['number'],
|
||||
declaredPort: (port, node, _graph, lookup) => {
|
||||
const declared = lookup.soundFx(soundFxSourceOf(node.params))?.params.find((p) => p.key === port.key);
|
||||
const value = declared?.default ?? 0;
|
||||
return {
|
||||
key: port.key,
|
||||
label: port.key,
|
||||
type: 'number',
|
||||
default: value,
|
||||
field: { key: port.key, label: port.key, kind: 'number', default: value },
|
||||
};
|
||||
},
|
||||
run: (_ctx, inputs, _params, def) => {
|
||||
const values: StructValue = Object.fromEntries(def.inputs.map((p) => [p.key, asNumber(inputs[p.key])]));
|
||||
return { outputs: { params: values } };
|
||||
},
|
||||
};
|
||||
@@ -89,6 +89,7 @@ import { Or } from './data/Or';
|
||||
import { Owner } from './data/Owner';
|
||||
import { PackStruct } from './data/PackStruct';
|
||||
import { Properties } from './data/Properties';
|
||||
import { SoundFxParameters } from './data/SoundFxParameters';
|
||||
import { Parent } from './data/Parent';
|
||||
import { Random } from './data/Random';
|
||||
import { RenderLayerOf } from './data/RenderLayerOf';
|
||||
@@ -297,6 +298,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
StopAnimation,
|
||||
PlaySound,
|
||||
StopSound,
|
||||
SoundFxParameters,
|
||||
LoadScene,
|
||||
Log,
|
||||
StopGame,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GraphLookup } from '../graph/lookup';
|
||||
import { SOUND_FX_INPUT, terminalNode } from '../graph/nested';
|
||||
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
|
||||
import { pictureSize } from '../properties/aspect';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
@@ -34,6 +35,12 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
};
|
||||
},
|
||||
shaderFunction: shaderFunctionsOf(project),
|
||||
soundFx: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'soundfx');
|
||||
return asset
|
||||
? { name: asset.name, params: terminalNode(asset.graph, SOUND_FX_INPUT)?.ports ?? [] }
|
||||
: null;
|
||||
},
|
||||
machine: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
if (!asset) return null;
|
||||
|
||||
+47
-13
@@ -1,10 +1,13 @@
|
||||
import { graphEmitter } from '../graph/emitGraph';
|
||||
import { type Graph, graphFingerprint } from '../graph/Graph';
|
||||
import { type DeclaredPort, type Graph, graphFingerprint, type GraphNode } from '../graph/Graph';
|
||||
import { terminalNode } from '../graph/nested';
|
||||
import { inlineValue, nodeDef } from '../graph/NodeRegistry';
|
||||
import { asNumber } from '../graph/values';
|
||||
import type { SoundFxAsset } from '../model/Asset';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import {
|
||||
DRY,
|
||||
type FxChain,
|
||||
EMPTY_PLAN,
|
||||
type FxAmount,
|
||||
type FxPlan,
|
||||
isSoundFxNodeDef,
|
||||
SOUND_FX_INPUT,
|
||||
SOUND_FX_OUTPUT,
|
||||
@@ -13,7 +16,7 @@ import {
|
||||
|
||||
export interface SoundFxBuild {
|
||||
/** Null while the graph has a problem – a sound played through it is then played dry. */
|
||||
compiled: FxChain | null;
|
||||
compiled: FxPlan | null;
|
||||
/** How many nodes between Input and Output the sound passes. */
|
||||
effects: number;
|
||||
/** Per node id, what keeps the graph from compiling. */
|
||||
@@ -22,33 +25,64 @@ export interface SoundFxBuild {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** What travels along an edge of a sound FX graph: the sound so far, or a number. */
|
||||
type FxValue = { plan: FxPlan } | { amount: FxAmount };
|
||||
|
||||
/** The parameters the Input declares, in their order, each with its default. */
|
||||
export function soundFxParams(graph: Graph): readonly DeclaredPort[] {
|
||||
return terminalNode(graph, SOUND_FX_INPUT)?.ports ?? [];
|
||||
}
|
||||
|
||||
function inputOutputs(node: GraphNode): Record<string, FxValue> {
|
||||
const outputs: Record<string, FxValue> = { out: { plan: EMPTY_PLAN } };
|
||||
for (const port of node.ports ?? [])
|
||||
outputs[port.key] = { amount: { value: port.default ?? 0, param: port.key } };
|
||||
return outputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* to the Output; a node fed from nowhere is a problem rather than silence. A number fed from a
|
||||
* parameter stays open in the plan until a voice is started (#311).
|
||||
*/
|
||||
export function compileSoundFx(graph: Graph): SoundFxBuild {
|
||||
const walk = graphEmitter<SoundFxNodeDef, FxChain>(graph, {
|
||||
const walk = graphEmitter<SoundFxNodeDef, FxValue>(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) => {
|
||||
emit: (def, inputs, node) => {
|
||||
if (node.type === SOUND_FX_INPUT) return inputOutputs(node);
|
||||
const amounts: Record<string, FxAmount> = {};
|
||||
for (const [key, value] of Object.entries(inputs)) if ('amount' in value) amounts[key] = value.amount;
|
||||
const arriving = inputs.in;
|
||||
const plan = arriving && 'plan' in arriving ? arriving.plan : EMPTY_PLAN;
|
||||
return { out: { plan: def.emit(plan, amounts) } };
|
||||
},
|
||||
unfed: (node, port) => {
|
||||
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
|
||||
walk.problem(node, 'Nothing comes in: connect it to the Input');
|
||||
return null;
|
||||
},
|
||||
conform: (node, port, value) => {
|
||||
if ((port.type === 'audio') === 'plan' in value) return value;
|
||||
walk.problem(
|
||||
node,
|
||||
port.type === 'audio' ? `"${port.label}" takes a sound` : `"${port.label}" takes a number`,
|
||||
);
|
||||
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 arrived = walk.input(output, nodeDef(SOUND_FX_OUTPUT)!.inputs[0]!);
|
||||
const failed = walk.firstProblem();
|
||||
if (failed !== null || !chain)
|
||||
if (failed !== null || !arrived || !('plan' in arrived))
|
||||
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: '' };
|
||||
return { compiled: arrived.plan, effects, problems: walk.problems, reason: '' };
|
||||
}
|
||||
|
||||
const builds = new Map<string, { fingerprint: string; build: SoundFxBuild }>();
|
||||
|
||||
+109
-34
@@ -1,7 +1,10 @@
|
||||
import type { DeclaredPort } from '../graph/Graph';
|
||||
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
|
||||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import type { FlatDef } from '../properties/PropertySchema';
|
||||
|
||||
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
||||
|
||||
/** 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';
|
||||
|
||||
@@ -21,45 +24,92 @@ export interface FxChain {
|
||||
|
||||
export const DRY: FxChain = { pitch: 0, stages: [] };
|
||||
|
||||
/**
|
||||
* A number of the graph as the compiler knows it: typed in, or a parameter of the Input that a
|
||||
* `Play sound` may hand a value for (#311). `value` is what it is otherwise, and a parameter is held
|
||||
* to the row it ends in only once its value is known.
|
||||
*/
|
||||
export interface FxAmount {
|
||||
value: number;
|
||||
param?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export type FxStagePlan =
|
||||
| { kind: 'gain'; db: FxAmount }
|
||||
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number };
|
||||
|
||||
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
|
||||
export interface FxPlan {
|
||||
pitch: readonly FxAmount[];
|
||||
stages: readonly FxStagePlan[];
|
||||
}
|
||||
|
||||
export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
|
||||
|
||||
/** Values for the parameters, by name; a name the plan does not have is left out. */
|
||||
export type FxParams = Readonly<Record<string, number>>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* at its one audio input, given the numbers at its other inputs. Input and Output have no effect.
|
||||
*/
|
||||
export interface SoundFxNodeDef extends NodeDef {
|
||||
category: 'sound';
|
||||
emit(chain: FxChain, params: PropertyBag): FxChain;
|
||||
emit(plan: FxPlan, amounts: Readonly<Record<string, FxAmount>>): FxPlan;
|
||||
}
|
||||
|
||||
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'> {
|
||||
function numberRow(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));
|
||||
/** An input taking a number: typed in under the key the setting had, or fed from a parameter. */
|
||||
function numberIn(row: FlatDef<'number'>): PortDef {
|
||||
return { key: row.key, label: row.label, type: 'number', field: row };
|
||||
}
|
||||
|
||||
function holdTo(value: number, row: FlatDef<'number'>): number {
|
||||
if (!Number.isFinite(value)) return row.default;
|
||||
return Math.max(row.min ?? -Infinity, Math.min(row.max ?? Infinity, value));
|
||||
}
|
||||
|
||||
/** The amount at an input, held to its row, so a hand-edited project cannot blow a speaker. */
|
||||
function held(amount: FxAmount | undefined, row: FlatDef<'number'>): FxAmount {
|
||||
if (!amount) return { value: row.default };
|
||||
const value = holdTo(amount.value, row);
|
||||
if (amount.param === undefined) return { value };
|
||||
return { value, param: amount.param, min: row.min, max: row.max };
|
||||
}
|
||||
|
||||
/** Whether an amount does nothing however the sound is played: a typed-in 0. */
|
||||
const isNone = (amount: FxAmount) => amount.param === undefined && amount.value === 0;
|
||||
|
||||
/** A parameter of the Input as the nodes it feeds see it. */
|
||||
export function soundFxParamPort(port: DeclaredPort): PortDef {
|
||||
return { key: port.key, label: port.key, type: 'number' };
|
||||
}
|
||||
|
||||
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.',
|
||||
description:
|
||||
'The sound as the Play sound node starts it, before any effect. Every parameter declared here is a number output with a default; a Play sound can hand in other values through a Sound FX parameters node.',
|
||||
inputs: [],
|
||||
outputs: [audioOut],
|
||||
params: [],
|
||||
emit: (chain) => chain,
|
||||
declaresPorts: 'outputs',
|
||||
portKinds: ['number'],
|
||||
declaredPort: (port) => soundFxParamPort(port),
|
||||
emit: (plan) => plan,
|
||||
};
|
||||
|
||||
export const SoundFxOutput: SoundFxNodeDef = {
|
||||
@@ -70,10 +120,10 @@ export const SoundFxOutput: SoundFxNodeDef = {
|
||||
inputs: [audioIn],
|
||||
outputs: [],
|
||||
params: [],
|
||||
emit: (chain) => chain,
|
||||
emit: (plan) => plan,
|
||||
};
|
||||
|
||||
const GAIN = numberParam('gain', 'Gain (dB)', -60, 24, 0.5);
|
||||
const GAIN = numberRow('gain', 'Gain (dB)', -60, 24, 0.5);
|
||||
|
||||
export const SoundFxGain: SoundFxNodeDef = {
|
||||
type: 'SoundFxGain',
|
||||
@@ -81,17 +131,16 @@ export const SoundFxGain: SoundFxNodeDef = {
|
||||
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],
|
||||
inputs: [audioIn, numberIn(GAIN)],
|
||||
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) }] };
|
||||
params: [],
|
||||
emit: (plan, amounts) => {
|
||||
const db = held(amounts.gain, GAIN);
|
||||
return isNone(db) ? plan : { ...plan, stages: [...plan.stages, { kind: 'gain', db }] };
|
||||
},
|
||||
};
|
||||
|
||||
const SEMITONES = numberParam('semitones', 'Semitones', -24, 24, 1);
|
||||
const SEMITONES = numberRow('semitones', 'Semitones', -24, 24, 1);
|
||||
|
||||
export const SoundFxPitch: SoundFxNodeDef = {
|
||||
type: 'SoundFxPitch',
|
||||
@@ -99,10 +148,13 @@ export const SoundFxPitch: SoundFxNodeDef = {
|
||||
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],
|
||||
inputs: [audioIn, numberIn(SEMITONES)],
|
||||
outputs: [audioOut],
|
||||
params: [SEMITONES],
|
||||
emit: (chain, params) => ({ ...chain, pitch: chain.pitch + read(params, SEMITONES) }),
|
||||
params: [],
|
||||
emit: (plan, amounts) => {
|
||||
const semitones = held(amounts.semitones, SEMITONES);
|
||||
return isNone(semitones) ? plan : { ...plan, pitch: [...plan.pitch, semitones] };
|
||||
},
|
||||
};
|
||||
|
||||
/** The five bands of the EQ: a shelf at either end and three peaks between them. */
|
||||
@@ -116,7 +168,7 @@ export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter;
|
||||
|
||||
/** 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));
|
||||
const EQ_ROWS = EQ_BANDS.map((band) => numberRow(band.key, band.label, -24, 24, 0.5));
|
||||
|
||||
export const SoundFxEq: SoundFxNodeDef = {
|
||||
type: 'SoundFxEq',
|
||||
@@ -124,17 +176,17 @@ export const SoundFxEq: SoundFxNodeDef = {
|
||||
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],
|
||||
inputs: [audioIn, ...EQ_ROWS.map(numberIn)],
|
||||
outputs: [audioOut],
|
||||
params: EQ_PARAMS,
|
||||
emit: (chain, params) => {
|
||||
const stages: FxStage[] = [];
|
||||
params: [],
|
||||
emit: (plan, amounts) => {
|
||||
const stages: FxStagePlan[] = [];
|
||||
EQ_BANDS.forEach((band, i) => {
|
||||
const gain = read(params, EQ_PARAMS[i]!);
|
||||
if (gain !== 0)
|
||||
const gain = held(amounts[band.key], EQ_ROWS[i]!);
|
||||
if (!isNone(gain))
|
||||
stages.push({ kind: 'filter', filter: band.filter, frequency: band.frequency, gain, q: PEAK_Q });
|
||||
});
|
||||
return stages.length === 0 ? chain : { ...chain, stages: [...chain.stages, ...stages] };
|
||||
return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -145,3 +197,26 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
|
||||
SoundFxPitch,
|
||||
SoundFxEq,
|
||||
];
|
||||
|
||||
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
|
||||
function amountOf(amount: FxAmount, params: FxParams): number {
|
||||
const given = amount.param !== undefined ? params[amount.param] : undefined;
|
||||
if (given === undefined || !Number.isFinite(given)) return amount.value;
|
||||
return Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, given));
|
||||
}
|
||||
|
||||
/** The chain one voice is sent along: the plan with every parameter filled in. */
|
||||
export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
|
||||
const stages: FxStage[] = [];
|
||||
for (const stage of plan.stages) {
|
||||
if (stage.kind === 'gain') {
|
||||
const db = amountOf(stage.db, params);
|
||||
if (db !== 0) stages.push({ kind: 'gain', gain: 10 ** (db / 20) });
|
||||
continue;
|
||||
}
|
||||
const gain = amountOf(stage.gain, params);
|
||||
if (gain !== 0) stages.push({ ...stage, gain });
|
||||
}
|
||||
const pitch = plan.pitch.reduce((sum, amount) => sum + amountOf(amount, params), 0);
|
||||
return { pitch, stages };
|
||||
}
|
||||
@@ -487,6 +487,8 @@ function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolea
|
||||
// A machine that is gone is kept, as a struct type is: the port then offers no states.
|
||||
if (kind === 'statemachine' && typeof p.machine === 'string' && p.machine !== '')
|
||||
port.machine = p.machine;
|
||||
if (kind === 'number' && typeof p.default === 'number' && Number.isFinite(p.default))
|
||||
port.default = p.default;
|
||||
ports.push(port);
|
||||
}
|
||||
return ports;
|
||||
|
||||
Reference in new issue
Block a user