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
@@ -1950,7 +1950,9 @@ const CASES: Record<string, Case[]> = {
|
||||
// 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<string, Case[]> = {
|
||||
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: [
|
||||
{
|
||||
|
||||
+186
-7
@@ -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<string, number>; 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);
|
||||
});
|
||||
});
|
||||
+10
-3
@@ -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<Record<string, number>>;
|
||||
}[];
|
||||
silenced: string[];
|
||||
/** The scenes the context was asked to load, in order. */
|
||||
loaded: string[];
|
||||
@@ -258,8 +265,8 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): 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,
|
||||
|
||||
Reference in new issue
Block a user