diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts index fa564539..88ccd411 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -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); } } diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index b47d5259..569980b6 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -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'; } diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index dd0e60f1..3e436ea1 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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; /** diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 7b96310b..1455e7aa 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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' ); } diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index 65347e16..8a6d1639 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -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, diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 55821f40..77407b7c 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -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 diff --git a/src/core/graph/nodes/action/PlaySound.ts b/src/core/graph/nodes/action/PlaySound.ts index 44e8b1e8..407df093 100644 --- a/src/core/graph/nodes/action/PlaySound.ts +++ b/src/core/graph/nodes/action/PlaySound.ts @@ -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 = {}; + for (const [key, item] of Object.entries(value)) if (typeof item === 'number') params[key] = item; + return params; +} diff --git a/src/core/graph/nodes/data/SoundFxParameters.ts b/src/core/graph/nodes/data/SoundFxParameters.ts new file mode 100644 index 00000000..e7c598d9 --- /dev/null +++ b/src/core/graph/nodes/data/SoundFxParameters.ts @@ -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 } }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 03f5f97f..13758769 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index 0af9ac05..f89949fb 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -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; diff --git a/src/core/soundfx/compile.ts b/src/core/soundfx/compile.ts index 0363d47d..c3f6de91 100644 --- a/src/core/soundfx/compile.ts +++ b/src/core/soundfx/compile.ts @@ -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 { + const outputs: Record = { 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(graph, { + const walk = graphEmitter(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 = {}; + 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(); diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index d12287ec..b4eb0cfc 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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>; + /** * 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>): 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 }; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 3c0d917a..b47e8782 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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; diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index a99b9e31..2eeb026c 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -109,6 +109,9 @@ export function graphHooks({ state, editor }: HookContext) { }), playAt: () => centreOf(editor.soundFxEditor.playButton), stopAt: () => centreOf(editor.soundFxEditor.stopButton), + /** The header's parameter values, and setting one as its field would. */ + params: () => editor.soundFxEditor.previewValues, + setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value), }, particles: { current: () => editor.particlesEditor.currentAssetId, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 960eb8e0..b4458ea5 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -22,6 +22,7 @@ import { RUN_BEHAVIOR, SHADER_CALL, callsFunction, + soundFxParamsMismatch, startStateOf, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; @@ -325,6 +326,7 @@ export class GraphCanvas extends Widget { const problem = compilerProblems?.[node.id] ?? (!compilerProblems || node.type === SHADER_CALL ? callProblem(node, lookup, asset.id) : null) ?? + soundFxParamsMismatch(node, asset.graph) ?? ghostProblem(node, asset.graph, resolved) ?? blockProblem(asset.graph, node); view.sync(node, asset.graph, this.state.project, def, problem); diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 38d6760a..dd066531 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -95,6 +95,8 @@ export function portColor(type: PortType): number { return c.portPicture; case 'audio': return c.portAudio; + case 'fxparams': + return c.portFxParams; case 'any': return c.portAny; } diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 2b89c1df..6e13eb4e 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -1,6 +1,7 @@ import { callProblem, declaresPropertyPorts, + declaresSoundFxPorts, declaresVariablePorts, edgesInto, edgesOfNode, @@ -16,6 +17,8 @@ import { OBJECT_TYPE_PARAM, portSignature, resolveDef, + soundFxParamsMismatch, + soundFxSourceOf, STATE_TRANSITION, stateName, STRUCT_TYPE_PARAM, @@ -42,6 +45,7 @@ import { addNote, addTitle, type InspectorHost } from './host'; import { buildPortSection, buildPropertyPortSection, + buildSoundFxPortSection, buildVariablePortSection, variableChoices, } from './portSection'; @@ -67,7 +71,9 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string) const declared = (node.ports ?? []) .map( (p) => - `${p.key}:${p.kind}${p.write ? ':set' : ''}${p.machine ? `:${p.machine}:${machineOf(p.machine)}` : ''}`, + `${p.key}:${p.kind}${p.write ? ':set' : ''}${p.machine ? `:${p.machine}:${machineOf(p.machine)}` : ''}${ + p.default !== undefined ? `=${p.default}` : '' + }`, ) .join(','); // They decide which rows the inspector builds, and two behaviors may share a name in the title. @@ -82,13 +88,13 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string) // The source names which declarations the port menu offers, so switching it has to rebuild. const typeId = nodeStructType(node) || listNodeItemType(node); const typeName = structAssetOf(project, typeId)?.name ?? ''; - const source = `${variablesSourceOf(node.params)}:${typeId}:${typeName}`; + const source = `${variablesSourceOf(node.params)}:${soundFxSourceOf(node.params)}:${typeId}:${typeName}`; const choices = variableChoices(project, assetId, node) .map((v) => v.key) .join(','); return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join( ',', - )}|${source}:${choices}|${target}|${transitions}`; + )}|${source}:${choices}|${target}|${transitions}|${soundFxParamsMismatch(node, asset.graph) ?? ''}`; } /** The name of the state at the other end of an edge – what a transition row is labelled with. */ @@ -111,7 +117,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s addTitle(host, `${def.category.toUpperCase()} NODE`); host.body.add(new Label(def.label, { style: 'bodyBold' })); addNote(host, def.description); - const problem = callProblem(node, graphLookup(host.state.project), assetId); + const problem = + callProblem(node, graphLookup(host.state.project), assetId) ?? soundFxParamsMismatch(node, asset.graph); if (problem) addNote(host, problem, 'warning'); const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => { const row = createFieldRow(host, param, node.params[param.key] ?? param.default, handlers); @@ -119,7 +126,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s host.body.add(row.row); }; // Both draw their settings inside the port section: a setting decides what the menu offers. - const ownSettings = declaresPropertyPorts(node.type) || declaresVariablePorts(node.type); + const ownSettings = + declaresPropertyPorts(node.type) || declaresVariablePorts(node.type) || declaresSoundFxPorts(node.type); // The type of a Pack/Expand decides its fields (#253), so it stands above them – and picking one // rewrites the ports, which a plain param edit would not. const typed = isTypedNode(node); @@ -134,6 +142,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s const pickItemType = (value: PropertyValue) => host.state.execute(new SetListNodeTypeCommand(assetId, nodeId, textOf(value))); if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting); + else if (declaresSoundFxPorts(node.type)) buildSoundFxPortSection(host, assetId, node, addSetting); else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting); else if (def.declaresPorts) { const typeName = structAssetOf(host.state.project, nodeStructType(node))?.name ?? null; diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 6782f285..922c5c74 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -6,12 +6,14 @@ import { RemovePortCommand, RenamePortCommand, RepointPortCommand, + SetPortDefaultCommand, SetPortMachineCommand, SetPortWriteCommand, } from '../../core/commands/portCommands'; import { type DeclaredPort, type GraphNode, + declaresSoundFxPorts, isEntryTerminal, isPortOf, isTerminalNode, @@ -23,6 +25,10 @@ import { portKindOf, SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT, + SOUND_FX_INPUT, + SOUND_FX_SETTING, + soundFxSourceOf, + terminalNode, VARIABLES_SETTING, variablesSourceOf, propertyTargetOf, @@ -44,6 +50,7 @@ import { Label, Menu, type MenuItem, + NumberField, type SectionHeader, type SortBlock, sortBlocks, @@ -58,6 +65,7 @@ import { kindMenu, markLabel } from './variableRows'; /** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */ export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] { + if (declaresSoundFxPorts(node.type)) return soundFxChoices(project, node); const source = variablesSourceOf(node.params); if (source) return findAssetOf(project, source, 'variables')?.variables ?? []; const asset = findGraphAsset(project, assetId); @@ -90,6 +98,35 @@ export function buildVariablePortSection( }); } +/** The parameters the Input of the sound FX a `Sound FX parameters` node names declares (#311). */ +function soundFxChoices(project: Project, node: GraphNode): PropertyDef[] { + const fx = findAssetOf(project, soundFxSourceOf(node.params), 'soundfx'); + const ports = fx ? (terminalNode(fx.graph, SOUND_FX_INPUT)?.ports ?? []) : []; + return ports.map((p) => ({ key: p.key, label: p.key, kind: 'number', default: p.default ?? 0 })); +} + +/** The ports of a `Sound FX parameters` node: picked from the parameters of the FX it names. */ +export function buildSoundFxPortSection( + host: InspectorHost, + assetId: string, + node: GraphNode, + addSetting: (def: PropertyDef) => void, +): void { + addSetting(SOUND_FX_SETTING); + const id = soundFxSourceOf(node.params); + const fx = id ? findAssetOf(host.state.project, id, 'soundfx') : undefined; + buildPickedPortSection(host, assetId, node, { + noun: 'Parameter', + choices: soundFxChoices(host.state.project, node), + owner: fx ? `"${fx.name}"` : 'a sound FX', + empty: id + ? fx + ? `"${fx.name}" declares no parameters yet. Add them at its Input.` + : 'That sound FX is gone.' + : 'Pick a sound FX first.', + }); +} + /** The type decides what the ports are picked from; Any names them by hand, with a kind picked. */ export function buildPropertyPortSection( host: InspectorHost, @@ -133,7 +170,7 @@ export function buildPropertyPortSection( } interface PickedPorts { - noun: 'Variable' | 'Property'; + noun: 'Variable' | 'Property' | 'Parameter'; choices: readonly PropertyDef[]; /** Whose ports these are, for the warning on one it does not have. */ owner: string; @@ -153,7 +190,7 @@ function buildPickedPortSection( { noun, choices, owner, empty, custom = false, note, hidden }: PickedPorts, ): void { const offered = hidden ? choices.filter((def) => !hidden(def)) : choices; - const plural = noun === 'Property' ? 'properties' : 'variables'; + const plural = noun === 'Property' ? 'properties' : noun === 'Parameter' ? 'parameters' : 'variables'; const ports = node.ports ?? []; const taken = new Set(ports.map((p) => p.key)); const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) }); @@ -191,9 +228,10 @@ function buildPickedPortSection( const pickedRow = (port: DeclaredPort): Grip => { // A machine a variable holds is a running value; which one its states come from is picked here. const carries = noun === 'Variable' && port.kind === 'statemachine'; + // A parameter is only ever handed in, never written back. const toggle = [ ...(carries ? [machinePick(host, assetId, node, port)] : []), - ...writeToggle(host, assetId, node, port), + ...(noun === 'Parameter' ? [] : writeToggle(host, assetId, node, port)), ]; if (custom) return portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle).grip; @@ -238,9 +276,10 @@ export function buildPortSection( typeName: string | null = null, ): void { const onStart = isEntryTerminal(node.type); - const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field'; + const ofSoundFx = node.type === SOUND_FX_INPUT; + const noun = ofSoundFx ? 'Parameter' : isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field'; const ofFunction = node.type === SHADER_FUNCTION_INPUT || node.type === SHADER_FUNCTION_OUTPUT; - const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out'; + const base = ofSoundFx ? 'param' : noun === 'Field' ? 'field' : onStart ? 'in' : 'out'; const header = sectionHeader(host, `${noun.toUpperCase()}S`); // With a Struct asset as its type the fields are that asset's (#253): changed there, not here. @@ -277,11 +316,13 @@ export function buildPortSection( host, noun === 'Field' ? 'No fields yet. Every field added here becomes a port of the struct.' - : ofFunction - ? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.` - : onStart - ? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.' - : 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.', + : ofSoundFx + ? 'No parameters yet. Every parameter added here is a number with a default, and a Sound FX parameters node can set it when the sound is played.' + : ofFunction + ? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.` + : onStart + ? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.' + : 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.', ); const blocks = ports.map((port) => @@ -291,13 +332,18 @@ export function buildPortSection( node, port, portNameField(host, assetId, node, port), - onStart && port.kind === 'statemachine' ? [machinePick(host, assetId, node, port)] : [], + ofSoundFx + ? defaultField(host, assetId, node, port) + : onStart && port.kind === 'statemachine' + ? [machinePick(host, assetId, node, port)] + : [], ), ); sortBlocks(blocks, (from, to) => host.state.execute(new MovePortCommand(assetId, node.id, from, to))); } const plural = (noun: string) => `${noun.toLowerCase()}s`; +const DEFAULT_WIDTH = 56; function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField { return new TextField(port.key, { @@ -332,6 +378,19 @@ function machinePick(host: InspectorHost, assetId: string, node: GraphNode, port return pick; } +/** The value a sound FX parameter has where nobody hands one in. */ +function defaultField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] { + const field = new NumberField(port.default ?? 0, { + width: DEFAULT_WIDTH, + step: 0.1, + onChange: (value) => { + if (value !== (port.default ?? 0)) + host.state.execute(new SetPortDefaultCommand(assetId, node.id, port.key, value)); + }, + }); + return [new Label('Default', { style: 'muted' }), field]; +} + function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] { const box = new Checkbox(port.write === true); box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on)); diff --git a/src/editor/soundfx/SoundFxEditor.ts b/src/editor/soundfx/SoundFxEditor.ts index e1ee326a..20259b93 100644 --- a/src/editor/soundfx/SoundFxEditor.ts +++ b/src/editor/soundfx/SoundFxEditor.ts @@ -2,8 +2,9 @@ import type { Asset, SoundFxAsset } from '../../core/model/Asset'; import { findAssetOf } from '../../core/model/Project'; import { resolveAssetProps } from '../../core/properties/resolve'; import { volumeOf } from '../../core/properties/sounds'; -import { soundFxBuildFor } from '../../core/soundfx/compile'; -import { Button, Dropdown, Menu } from '../../ui'; +import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile'; +import { fxChainOf } from '../../core/soundfx/nodes'; +import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { AssetTabEditor } from '../AssetTabEditor'; import { GraphCanvas } from '../graph/GraphCanvas'; @@ -14,6 +15,7 @@ import type { EditorState } from '../state'; /** Room kept for "N effects", or for the reason the graph does not compile. */ const INFO_WIDTH = 220; const SOUND_WIDTH = 140; +const PARAM_WIDTH = 48; /** Whose voices the tab's Stop silences: its own, never a running game's. */ const PREVIEW_OWNER = 'soundfx-preview'; @@ -29,6 +31,15 @@ export class SoundFxEditor extends AssetTabEditor { readonly playButton: Button; readonly stopButton: Button; private soundId = ''; + /** One name and number per parameter of the Input: what the header's Play hands in. */ + private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' }); + private readonly paramFields = new Map(); + /** The values the tab plays with – its own, never written into the asset. */ + private values: Record = {}; + /** The defaults the values were taken from, so an edited default comes through and a moved value stays. */ + private defaults: Record = {}; + private paramsKey = ''; + private valuesFor = ''; constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); @@ -43,9 +54,11 @@ export class SoundFxEditor extends AssetTabEditor { this.soundPicker, this.playButton, this.stopButton, + this.paramBar, ]); state.on('load', () => { this.soundId = ''; + this.valuesFor = ''; }); } @@ -68,14 +81,26 @@ export class SoundFxEditor extends AssetTabEditor { this.refresh(); } + /** What the header's parameter fields are set to – the check scripts read this. */ + get previewValues(): Record { + return { ...this.values }; + } + + /** Sets one parameter the header plays with, as typing into its field does. */ + setPreviewValue(key: string, value: number): void { + if (!(key in this.values)) return; + this.values[key] = value; + this.paramFields.get(key)?.setNumber(value); + } + /** Plays the picked sound through the graph; a graph that does not compile is not played at all. */ play(): void { const asset = this.asset(); const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound'); - const chain = asset && soundFxBuildFor(asset).compiled; - if (!sound?.file || !chain) return; + const plan = asset && soundFxBuildFor(asset).compiled; + if (!sound?.file || !plan) return; const volume = volumeOf(resolveAssetProps(this.state.project, sound)); - soundLibrary.play(sound.file, volume, false, PREVIEW_OWNER, chain); + soundLibrary.play(sound.file, volume, false, PREVIEW_OWNER, fxChainOf(plan, this.values)); } stop(): void { @@ -95,6 +120,48 @@ export class SoundFxEditor extends AssetTabEditor { this.soundPicker.setEnabled(Boolean(asset && sound)); this.playButton.setEnabled(Boolean(asset && sound?.file && soundFxBuildFor(asset).compiled)); this.stopButton.setEnabled(Boolean(asset)); + this.syncParams(asset); + } + + /** + * Another asset starts from its defaults; an edit of the graph keeps what a field was moved to, + * unless that parameter's default changed with it. + */ + private syncParams(asset: SoundFxAsset | null): void { + const params = asset ? soundFxParams(asset.graph) : []; + const same = (asset?.id ?? '') === this.valuesFor; + this.valuesFor = asset?.id ?? ''; + const values: Record = {}; + const defaults: Record = {}; + for (const port of params) { + const fallback = port.default ?? 0; + const kept = same && this.defaults[port.key] === fallback ? this.values[port.key] : undefined; + values[port.key] = kept ?? fallback; + defaults[port.key] = fallback; + } + this.values = values; + this.defaults = defaults; + + const key = params.map((p) => p.key).join('\u0000'); + if (key !== this.paramsKey) { + this.paramsKey = key; + for (const child of this.paramBar.removeChildren()) child.destroy({ children: true }); + this.paramFields.clear(); + for (const port of params) { + this.paramBar.add(new Label(port.key, { style: 'small' })); + const field = new NumberField(values[port.key], { + width: PARAM_WIDTH, + step: 0.1, + onInput: (value) => (this.values[port.key] = value), + onChange: (value) => (this.values[port.key] = value), + }); + this.paramFields.set(port.key, field); + this.paramBar.add(field); + } + if (this.paramBar.parent instanceof Stack) this.paramBar.parent.relayout(); + this.paramBar.relayout(); + } + for (const [name, field] of this.paramFields) field.setNumber(this.values[name] ?? 0); } private openSoundPicker(): void { diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 0abcb034..0cfa19ab 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -16,7 +16,7 @@ import { hasRenderLayer } from '../../core/properties/PropertySchema'; import { resolveAssetProps } from '../../core/properties/resolve'; import { loopsOf, volumeOf } from '../../core/properties/sounds'; import { soundFxBuildFor } from '../../core/soundfx/compile'; -import { DRY } from '../../core/soundfx/nodes'; +import { DRY, fxChainOf, type FxParams } from '../../core/soundfx/nodes'; import type { Entity } from '../Entity'; import { PhysicsSystem } from '../physics/PhysicsSystem'; import type { World } from '../World'; @@ -164,8 +164,8 @@ export function behaviorContext( // eslint-disable-next-line no-console console.log(`[behavior ${entity.name}]`, message); }, - playSound: (assetId, volume, loop, detached, fx) => - playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx), + playSound: (assetId, volume, loop, detached, fx, fxParams) => + playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams), // An id nothing resolves stops nothing, rather than silencing the whole run by accident; // only the empty id means "everything", which is what the node promises. stopSound: (assetId) => { @@ -217,7 +217,8 @@ export function behaviorContext( * What the graph asked for, as the sink understands it: the asset's own volume scaled by the * node's, and its loop flag unless the node overrules it either way. The asset is what a sound * *is* – a node that says nothing therefore plays it as the project describes it. An FX asset that - * does not compile plays the sound dry: a broken effect should not silence the game. + * does not compile plays the sound dry: a broken effect should not silence the game. Its parameters + * take the values handed in, the others their defaults. */ function playSound( { project, audio }: ContextSources, @@ -226,13 +227,15 @@ function playSound( loop: boolean | undefined, owner: string, fxId: string, + fxParams: FxParams, ): void { const asset = findAssetOf(project, assetId, 'sound'); if (!asset?.file) return; const props = resolveAssetProps(project, asset); const level = Math.max(0, Math.min(1, volumeOf(props) * volume)); const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined; - audio.play(asset.file, level, loop ?? loopsOf(props), owner, (fx && soundFxBuildFor(fx).compiled) || DRY); + const plan = fx && soundFxBuildFor(fx).compiled; + audio.play(asset.file, level, loop ?? loopsOf(props), owner, plan ? fxChainOf(plan, fxParams) : DRY); } /** Where a ray of that length ends; where it has no direction, where it starts. */ diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 9d0a2580..a3d939a9 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -80,6 +80,7 @@ export const Theme = { portVec3: 0xff9ad5, portPicture: 0x9b8cff, portAudio: 0xf0c060, + portFxParams: 0xd0629b, portAny: 0xbdbdbd, /** Ring around the port a running edge drag would connect to. */ portTarget: 0xffffff, diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 845b36a8..9506d378 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1950,7 +1950,9 @@ const CASES: Record = { // flag – is the BehaviorSystem's to resolve, since only it holds the project (see // `sounds.test.ts`). after: (ctx) => - expect(ctx.played).toEqual([{ assetId: 'snd-1', volume: 0.5, loop: true, detached: false, fx: '' }]), + expect(ctx.played).toEqual([ + { assetId: 'snd-1', volume: 0.5, loop: true, detached: false, fx: '', fxParams: {} }, + ]), }, { name: 'detached says the voice is nobody’s, so destroying this object leaves it playing', @@ -1976,6 +1978,24 @@ const CASES: Record = { next: 'exec', after: (ctx) => expect(ctx.played[0]?.fx).toBe('fx-1'), }, + { + name: 'hands on the numbers of its fx params, and nothing for what is no bundle', + inputs: { sound: 'snd-1', volume: 1, loop: '', fx: 'fx-1', fxParams: { depth: -12, name: 'x' } }, + next: 'exec', + after: (ctx) => expect(ctx.played[0]?.fxParams).toEqual({ depth: -12 }), + }, + ], + SoundFxParameters: [ + { + name: 'bundles its picked parameters as numbers', + ports: [ + { key: 'depth', kind: 'number' }, + { key: 'loud', kind: 'number' }, + ], + inputs: { depth: '-12', loud: 3 }, + outputs: { params: { depth: -12, loud: 3 } }, + }, + { name: 'with none picked the bundle is empty', outputs: { params: {} } }, ], LoadScene: [ { diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index f84ac396..3a59d9c1 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -4,12 +4,15 @@ */ import { describe, expect, it } from 'vitest'; import { buildAsset, CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands'; -import { portsCompatible } from '../../src/core/graph/Graph'; +import { RenamePortCommand, SetPortDefaultCommand } from '../../src/core/commands/portCommands'; +import { callProblem, resolveDef, soundFxParamsMismatch } from '../../src/core/graph'; +import { type DeclaredPort, portsCompatible } from '../../src/core/graph/Graph'; +import { graphLookup } from '../../src/core/model/graphLookup'; import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { assetRefChoices } from '../../src/core/properties/assetRef'; import { compileSoundFx, soundFxBuildFor } from '../../src/core/soundfx/compile'; -import { DRY, type FxChain } from '../../src/core/soundfx/nodes'; +import { DRY, EMPTY_PLAN, type FxChain, fxChainOf } from '../../src/core/soundfx/nodes'; import { deserializeProject } from '../../src/core/store/serialize'; import type { AudioSink } from '../../src/runtime/systems/Audio'; import { paletteEntries } from '../../src/editor/graph/paletteEntries'; @@ -116,7 +119,8 @@ describe('compiling a sound FX graph', () => { it('passes the sound on untouched through Input and Output alone', () => { const build = compileSoundFx(line([]).fx.graph); - expect(build).toMatchObject({ compiled: DRY, effects: 0, reason: '' }); + expect(build).toMatchObject({ compiled: EMPTY_PLAN, effects: 0, reason: '' }); + expect(fxChainOf(build.compiled!)).toEqual(DRY); }); it('turns every node on the line into its stages, in order', () => { @@ -128,7 +132,7 @@ describe('compiling a sound FX graph', () => { ]); const build = compileSoundFx(f.fx.graph); expect(build.effects).toBe(4); - const chain = build.compiled!; + const chain = fxChainOf(build.compiled!); expect(chain.pitch).toBe(7); expect(chain.stages).toHaveLength(3); expect(chain.stages[0]!.kind).toBe('gain'); @@ -141,10 +145,12 @@ describe('compiling a sound FX graph', () => { it('leaves out what does nothing and holds a value to its row', () => { const quiet = compileSoundFx(line([{ type: 'SoundFxGain' }, { type: 'SoundFxEq' }]).fx.graph); - expect(quiet.compiled).toEqual(DRY); + expect(quiet.compiled).toEqual(EMPTY_PLAN); expect(quiet.effects).toBe(2); - const loud = compileSoundFx(line([{ type: 'SoundFxGain', params: { gain: 200 } }]).fx.graph).compiled!; + const loud = fxChainOf( + compileSoundFx(line([{ type: 'SoundFxGain', params: { gain: 200 } }]).fx.graph).compiled!, + ); expect(loud.stages[0]!.gain).toBeCloseTo(10 ** (24 / 20)); }); @@ -153,7 +159,7 @@ describe('compiling a sound FX graph', () => { const aside = f.edit.addNode('SoundFxGain', { gain: -20 }); f.edit.connect(f.input, 'out', aside, 'in'); const build = compileSoundFx(f.fx.graph); - expect(build.compiled).toEqual({ pitch: 3, stages: [] }); + expect(fxChainOf(build.compiled!)).toEqual({ pitch: 3, stages: [] }); expect(build.effects).toBe(1); }); }); @@ -217,3 +223,176 @@ describe('Play sound with fx', () => { for (const audio of [none, gone, brokenAudio]) expect(audio.played).toEqual([{ file: FILE, fx: DRY }]); }); }); + +/** Input with the given parameters, its `param` fed into the effect's `into`, then Output. */ +function withParam(type: string, into: string, ports: DeclaredPort[], param = ports[0]!.key) { + const f = line([{ type }]); + f.input.ports = ports; + const effect = f.fx.graph.nodes.find((n) => n.type === type)!; + f.edit.connect(f.input, param, effect, into); + return f; +} + +describe('the parameters of a sound FX', () => { + it('stay open in the plan and take the value handed in, held to the row', () => { + const f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number', default: -12 }]); + const plan = soundFxBuildFor(f.fx).compiled!; + expect(plan.pitch).toEqual([{ value: -12, param: 'depth', min: -24, max: 24 }]); + expect(fxChainOf(plan).pitch).toBe(-12); + expect(fxChainOf(plan, { depth: 5 }).pitch).toBe(5); + expect(fxChainOf(plan, { depth: 100 }).pitch).toBe(24); + expect(fxChainOf(plan, { other: 3 }).pitch).toBe(-12); + }); + + it('keep a stage that is 0 by default, so a value handed in can open it', () => { + const f = withParam('SoundFxGain', 'gain', [{ key: 'loud', kind: 'number' }]); + const plan = soundFxBuildFor(f.fx).compiled!; + expect(plan.stages).toHaveLength(1); + expect(fxChainOf(plan)).toEqual(DRY); + expect(fxChainOf(plan, { loud: 6 }).stages[0]).toMatchObject({ kind: 'gain' }); + expect((fxChainOf(plan, { loud: 6 }).stages[0] as { gain: number }).gain).toBeCloseTo(10 ** (6 / 20)); + }); + + it('feed an EQ band as well', () => { + const f = withParam('SoundFxEq', 'mid', [{ key: 'body', kind: 'number', default: 3 }]); + const chain = fxChainOf(soundFxBuildFor(f.fx).compiled!, { body: -4 }); + expect(chain.stages).toEqual([{ kind: 'filter', filter: 'peaking', frequency: 1000, gain: -4, q: 1 }]); + }); + + it('are kept with their defaults through a save, a default only on a number', () => { + const f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number', default: -7 }]); + expect(roundTrip(f.project)).toEqual(f.project); + const loaded = deserializeProject({ + name: 'P', + assets: [ + { + id: 'fx', + type: 'soundfx', + name: 'FX', + graph: { + nodes: [ + { + id: 'a', + type: 'SoundFxInput', + x: 0, + y: 0, + params: {}, + ports: [ + { key: 'p', kind: 'number', default: 'loud' }, + { key: 'q', kind: 'number', default: 2 }, + ], + }, + ], + edges: [], + }, + }, + ], + }); + expect((loaded.assets[0] as SoundFxAsset).graph.nodes[0]!.ports).toEqual([ + { key: 'p', kind: 'number' }, + { key: 'q', kind: 'number', default: 2 }, + ]); + }); + + it('do not go where a sound goes, and a sound does not go where a number does', () => { + const f = line([{ type: 'SoundFxGain' }]); + f.input.ports = [{ key: 'level', kind: 'number' }]; + const gain = f.fx.graph.nodes.find((n) => n.type === 'SoundFxGain')!; + f.fx.graph.edges.find((e) => e.to.node === gain.id)!.from.port = 'level'; + expect(compileSoundFx(f.fx.graph).problems[gain.id]).toBe('"in" takes a sound'); + expect(portsCompatible('fxparams', 'fxparams')).toBe(true); + expect(portsCompatible('fxparams', 'any')).toBe(false); + expect(portsCompatible('struct', 'fxparams')).toBe(false); + }); + + it('have a default set by a command that undoes', () => { + const f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number' }]); + f.stack.execute(new SetPortDefaultCommand(f.fx.id, f.input.id, 'depth', 4)); + expect(f.input.ports).toEqual([{ key: 'depth', kind: 'number', default: 4 }]); + expect(fxChainOf(soundFxBuildFor(f.fx).compiled!).pitch).toBe(4); + f.stack.undo(); + expect(f.input.ports).toEqual([{ key: 'depth', kind: 'number' }]); + }); +}); + +describe('Sound FX parameters and Play sound', () => { + function scene(options: { pick?: string[]; values?: Record; other?: boolean } = {}) { + const f = makeSpriteScene('Box'); + const withFx = makeFx(f); + const { fx, edit: fxEdit } = withFx; + const input = fxEdit.addNode('SoundFxInput'); + input.ports = [ + { key: 'depth', kind: 'number', default: -12 }, + { key: 'loud', kind: 'number', default: 0 }, + ]; + const pitch = fxEdit.addNode('SoundFxPitch'); + const gain = fxEdit.addNode('SoundFxGain'); + const output = fxEdit.addNode('SoundFxOutput'); + fxEdit.connect(input, 'out', pitch, 'in'); + fxEdit.connect(pitch, 'out', gain, 'in'); + fxEdit.connect(gain, 'out', output, 'in'); + fxEdit.connect(input, 'depth', pitch, 'semitones'); + fxEdit.connect(input, 'loud', gain, 'gain'); + const sound = new CreateSoundCommand('Jump', FILE); + f.stack.execute(sound); + const behavior = makeBehavior(f, 'Logic'); + place(f, behavior.id, { x: 0, y: 0 }, place(f, f.spriteId, { x: 0, y: 0 })); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const play = edit.addNode('PlaySound', { sound: sound.asset.id, fx: fx.id }); + const named = options.other ? 'another-fx' : fx.id; + const params = edit.addNode('SoundFxParameters', { soundFxAsset: named, ...options.values }); + params.ports = (options.pick ?? []).map((key) => ({ key, kind: 'number' as const })); + edit.connect(start, 'exec', play, 'exec'); + edit.connect(params, 'params', play, 'fxParams'); + return { ...f, fx, input, behavior, play, params }; + } + + it('hands the picked values to the voice, the others keep their defaults', () => { + const f = scene({ pick: ['depth'], values: { depth: 7 } }); + const audio = fakeAudio(); + startWorld(f, { behaviors: true, audio }).frame(); + expect(audio.played).toEqual([{ file: FILE, fx: { pitch: 7, stages: [] } }]); + }); + + it('starts a picked parameter at the default of the FX', () => { + const f = scene({ pick: ['depth', 'loud'] }); + const def = resolveDef(f.params, f.behavior.graph, graphLookup(f.project))!; + expect(def.inputs.map((p) => [p.key, p.default])).toEqual([ + ['depth', -12], + ['loud', 0], + ]); + const audio = fakeAudio(); + startWorld(f, { behaviors: true, audio }).frame(); + expect(audio.played[0]!.fx).toEqual({ pitch: -12, stages: [] }); + }); + + it('says so at the node when no FX is picked or it is gone', () => { + const f = scene(); + const lookup = graphLookup(f.project); + expect(callProblem(f.params, lookup)).toBeNull(); + f.params.params.soundFxAsset = ''; + expect(callProblem(f.params, lookup)).toBe('No sound FX picked'); + f.params.params.soundFxAsset = 'gone'; + expect(callProblem(f.params, lookup)).toBe('That sound FX is gone'); + }); + + it('warns at Play sound when the parameters are for another FX', () => { + expect(soundFxParamsMismatch(scene().play, scene().behavior.graph)).toBeNull(); + const f = scene({ other: true }); + expect(soundFxParamsMismatch(f.play, f.behavior.graph)).toBe('The parameters are for another sound FX'); + }); + + it('follow a parameter renamed at the Input, edges and value included, and back on undo', () => { + const f = scene({ pick: ['depth'], values: { depth: 7 } }); + f.stack.execute(new RenamePortCommand(f.fx.id, f.input.id, 'depth', 'drop')); + expect(f.params.ports).toEqual([{ key: 'drop', kind: 'number' }]); + expect(f.params.params.drop).toBe(7); + const audio = fakeAudio(); + startWorld(f, { behaviors: true, audio }).frame(); + expect(audio.played[0]!.fx).toEqual({ pitch: 7, stages: [] }); + f.stack.undo(); + expect(f.params.ports).toEqual([{ key: 'depth', kind: 'number' }]); + expect(f.params.params.depth).toBe(7); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 89450b39..b376e514 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -205,7 +205,14 @@ export type FakeContext = NodeContext & { stopped: boolean; changes: { machineId: string; from: string; to: string }[]; /** What the context was asked to play and to silence, in order – nothing here makes a noise. */ - played: { assetId: string; volume: number; loop: boolean | undefined; detached: boolean; fx: string }[]; + played: { + assetId: string; + volume: number; + loop: boolean | undefined; + detached: boolean; + fx: string; + fxParams: Readonly>; + }[]; silenced: string[]; /** The scenes the context was asked to load, in order. */ loaded: string[]; @@ -258,8 +265,8 @@ export function fakeContext(overrides: Partial = {}): FakeContext { played: [], silenced: [], loaded: [], - playSound: (assetId, volume, loop, detached, fx) => - ctx.played.push({ assetId, volume, loop, detached, fx }), + playSound: (assetId, volume, loop, detached, fx, fxParams) => + ctx.played.push({ assetId, volume, loop, detached, fx, fxParams }), stopSound: (assetId) => ctx.silenced.push(assetId), loadScene: (sceneId) => ctx.loaded.push(sceneId), spawn: () => null, diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 3e226280..03168487 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -272,7 +272,9 @@ export async function sound(t) { // 34g-3. A sound FX asset (#311): "+ → Sound FX" opens its tab, which says what keeps the graph // from compiling; built into Input → Pitch → Gain → Output, the header plays the sound - // through it, and a `Play sound` naming it sends the game's voice the same way. + // through it, and a `Play sound` naming it sends the game's voice the same way. The pitch + // comes from a parameter `depth` of the Input: the header has a field for it, and a + // `Sound FX parameters` node hands the game's value in. await showTab('scene'); await clickText('+'); await pickAssetType(t, 'Sound FX'); @@ -309,8 +311,8 @@ export async function sound(t) { fx.name = 'Low'; fx.graph = ${JSON.stringify({ nodes: [ - node('fx-in', 'SoundFxInput', 0, 0), - node('fx-pitch', 'SoundFxPitch', 220, 0, { semitones: -12 }), + node('fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'depth', kind: 'number', default: -12 }]), + node('fx-pitch', 'SoundFxPitch', 220, 0), node('fx-gain', 'SoundFxGain', 440, 0, { gain: -6 }), node('fx-out', 'SoundFxOutput', 660, 0), ], @@ -318,12 +320,15 @@ export async function sound(t) { edge('fx-e1', 'fx-in', 'out', 'fx-pitch', 'in'), edge('fx-e2', 'fx-pitch', 'out', 'fx-gain', 'in'), edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'), + edge('fx-e4', 'fx-in', 'depth', 'fx-pitch', 'semitones'), ], })};`); - const fxBuilt = await json('{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview() }'); + const fxBuilt = await json( + '{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview(), params: __gm.soundfx.params() }', + ); check( - 'Input → Pitch → Gain → Output compiles to two effects, and Play is offered', - fxBuilt.info === '2 effects' && fxBuilt.preview.play === true, + 'Input → Pitch → Gain → Output compiles to two effects, Play is offered, depth starts at its default', + fxBuilt.info === '2 effects' && fxBuilt.preview.play === true && fxBuilt.params.depth === -12, JSON.stringify(fxBuilt), ); await evaluate('__gm.sound.stopAll()'); @@ -337,6 +342,16 @@ export async function sound(t) { JSON.stringify(heardFx), ); await evaluate('__gm.sound.stopAll()'); + await evaluate('__gm.soundfx.setParam("depth", 5)'); + const beforeParam = await stats(); + await click(fxPlayAt.x, fxPlayAt.y); + const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started); + check( + 'the header plays with the value its depth field is set to', + heardParam.last?.pitch === 5 && heardParam.last.stages === 1, + JSON.stringify(heardParam), + ); + await evaluate('__gm.sound.stopAll()'); await showTab('scene'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; @@ -352,6 +367,25 @@ export async function sound(t) { await stop(); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; + graph.nodes.push(${JSON.stringify( + node('sn-fxp', 'SoundFxParameters', 0, 120, { soundFxAsset: fxId, depth: 7 }, [ + { key: 'depth', kind: 'number' }, + ]), + )}); + graph.edges.push(${JSON.stringify(edge('sn-e-fxp', 'sn-fxp', 'params', 'sn-play', 'fxParams'))});`); + const beforeGameParam = await stats(); + await play(); + const gameParam = await waitFor(stats, (s) => s.started > beforeGameParam.started); + check( + 'a Sound FX parameters node hands depth 7 to the game’s voice', + gameParam.last?.pitch === 7 && gameParam.last.stages === 1, + JSON.stringify(gameParam), + ); + await stop(); + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; + graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-fxp'); + graph.edges = graph.edges.filter((e) => e.id !== 'sn-e-fxp'); Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: '', fx: '' }); const project = __gm.state.project; project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(fxId)});`);