A sound FX was a single line from Input to Output, so two versions of one sound could not be heard together. The new Mix node joins two ways again, and each way can carry its own effects, its own Pitch included. - Mix has the audio inputs a and b and a number input mix (0 to 1, default 0.5). At 0 only a is heard, at 1 only b. Like every number it can come from a parameter of the Input and is held to its range. - The compiled plan and the chain a voice gets are now a tree. A source carries a pitch and stages, a mix carries a, b and the stages after it. A Mix that ends at exactly 0 or 1 collapses to the one way heard, so the silent way is never worked out. - A Pitch is still worked out on the sound as it starts (#312). Behind a Mix it goes to both of its sounds. An effect before a Pitch therefore acts as if it came after it, the node now says so. - The player starts one source per pitch, all at once and all of the same length, and two ways at the same pitch share one. A mix becomes two gain nodes with 1 - mix and mix into a sum. A voice leaves the count when all of its sources have ended. - stats().last reports pitches (one per source) instead of pitch. Tests: - soundfx.test.ts: 27 tests (+5), sound-library.test.ts: 12 (+2), node-catalog.test.ts: 368, all green. Typecheck, ESLint, Prettier clean. - Counter-check: a Pitch behind the Mix given to a only, and b mixed at 1 - mix, each fails its new test. - e2e 34 on melektaus-atx: ALL PASSED, 29 checks (+1). The new check plays the pitched way mixed with the dry Input in the browser: two sources at 5 and 0 semitones, as long as the sound itself. - The full gate did not run before this commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
484 lines
20 KiB
TypeScript
484 lines
20 KiB
TypeScript
/**
|
||
* A sound FX asset (#311) is a graph a sound is played through: Input, effects, Output. It never
|
||
* runs as a behavior does – the compiler turns it into the line of effects a voice passes.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { buildAsset, CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands';
|
||
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, EMPTY_PLAN, type FxChain, fxChainOf, type FxPlan } 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';
|
||
import {
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeProject,
|
||
makeSpriteScene,
|
||
place,
|
||
type ProjectFixture,
|
||
roundTrip,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
const FILE = `${'a1'.repeat(32)}.wav`;
|
||
|
||
/** The pitch of a sound that is not mixed. */
|
||
const pitchOf = <T extends FxChain | FxPlan>(sound: T) => ('pitch' in sound ? sound.pitch : undefined);
|
||
|
||
function makeFx(f: ProjectFixture = makeProject()) {
|
||
const command = new CreateAssetCommand('soundfx', 'Cave');
|
||
f.stack.execute(command);
|
||
const fx = command.asset as SoundFxAsset;
|
||
return { ...f, fx, edit: graphEditor(f, fx as unknown as BehaviorAsset) };
|
||
}
|
||
|
||
/** Input, the given effects in a row, Output. */
|
||
function line(effects: { type: string; params?: Record<string, number> }[]) {
|
||
const f = makeFx();
|
||
const input = f.edit.addNode('SoundFxInput');
|
||
let tail = input;
|
||
for (const effect of effects) {
|
||
const node = f.edit.addNode(effect.type, effect.params ?? {});
|
||
f.edit.connect(tail, 'out', node, 'in');
|
||
tail = node;
|
||
}
|
||
const output = f.edit.addNode('SoundFxOutput');
|
||
f.edit.connect(tail, 'out', output, 'in');
|
||
return { ...f, input, output };
|
||
}
|
||
|
||
describe('the sound FX asset', () => {
|
||
it('is not placeable, starts with an empty graph and is what the fx picker offers', () => {
|
||
expect(isPlaceableType('soundfx')).toBe(false);
|
||
expect(buildAsset('soundfx', 'FX')).toMatchObject({ type: 'soundfx', graph: { nodes: [], edges: [] } });
|
||
const f = makeFx();
|
||
f.stack.execute(new CreateSoundCommand('Jump', FILE));
|
||
expect(assetRefChoices(f.project, ['soundfx']).map((a) => a.id)).toEqual([f.fx.id]);
|
||
});
|
||
|
||
it('survives a round trip and keeps only sound FX nodes', () => {
|
||
const f = line([{ type: 'SoundFxGain', params: { gain: -6 } }]);
|
||
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: {} },
|
||
{ id: 'b', type: 'OnStart', x: 0, y: 0, params: {} },
|
||
],
|
||
edges: [],
|
||
},
|
||
},
|
||
],
|
||
});
|
||
expect((loaded.assets[0] as SoundFxAsset).graph.nodes.map((n) => n.id)).toEqual(['a']);
|
||
});
|
||
|
||
it('carries sound only from a sound port into a sound port', () => {
|
||
expect(portsCompatible('audio', 'audio')).toBe(true);
|
||
expect(portsCompatible('audio', 'any')).toBe(false);
|
||
expect(portsCompatible('number', 'audio')).toBe(false);
|
||
expect(portsCompatible('any', 'audio')).toBe(false);
|
||
});
|
||
|
||
it('offers its effects, Input and Output once each, and none of them in a behavior', () => {
|
||
const f = makeFx();
|
||
const ids = () => paletteEntries(f.project, f.fx).map((e) => e.id);
|
||
expect(ids()).toEqual([
|
||
'SoundFxInput',
|
||
'SoundFxOutput',
|
||
'SoundFxGain',
|
||
'SoundFxPitch',
|
||
'SoundFxEq',
|
||
'SoundFxMix',
|
||
]);
|
||
f.edit.addNode('SoundFxInput');
|
||
f.edit.addNode('SoundFxOutput');
|
||
expect(ids()).toEqual(['SoundFxGain', 'SoundFxPitch', 'SoundFxEq', 'SoundFxMix']);
|
||
|
||
const behavior = makeBehavior(f, 'Logic');
|
||
expect(paletteEntries(f.project, behavior).some((e) => e.def.category === 'sound')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('compiling a sound FX graph', () => {
|
||
it('needs an Output, and an Output fed from the Input', () => {
|
||
const f = makeFx();
|
||
expect(compileSoundFx(f.fx.graph)).toMatchObject({ compiled: null, reason: 'No Output node' });
|
||
|
||
const output = f.edit.addNode('SoundFxOutput');
|
||
const gain = f.edit.addNode('SoundFxGain');
|
||
f.edit.connect(gain, 'out', output, 'in');
|
||
const build = compileSoundFx(f.fx.graph);
|
||
expect(build.compiled).toBeNull();
|
||
expect(build.problems[gain.id]).toBe('Nothing comes in: connect it to the Input');
|
||
});
|
||
|
||
it('passes the sound on untouched through Input and Output alone', () => {
|
||
const build = compileSoundFx(line([]).fx.graph);
|
||
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', () => {
|
||
const f = line([
|
||
{ type: 'SoundFxGain', params: { gain: -6 } },
|
||
{ type: 'SoundFxPitch', params: { semitones: 12 } },
|
||
{ type: 'SoundFxEq', params: { low: 6, high: -3 } },
|
||
{ type: 'SoundFxPitch', params: { semitones: -5 } },
|
||
]);
|
||
const build = compileSoundFx(f.fx.graph);
|
||
expect(build.effects).toBe(4);
|
||
const chain = fxChainOf(build.compiled!);
|
||
expect(pitchOf(chain)).toBe(7);
|
||
expect(chain.stages).toHaveLength(3);
|
||
expect(chain.stages[0]!.kind).toBe('gain');
|
||
expect(chain.stages[0]!.gain).toBeCloseTo(0.501, 3);
|
||
expect(chain.stages.slice(1)).toEqual([
|
||
{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 },
|
||
{ kind: 'filter', filter: 'highshelf', frequency: 12000, gain: -3, q: 1 },
|
||
]);
|
||
});
|
||
|
||
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(EMPTY_PLAN);
|
||
expect(quiet.effects).toBe(2);
|
||
|
||
const loud = fxChainOf(
|
||
compileSoundFx(line([{ type: 'SoundFxGain', params: { gain: 200 } }]).fx.graph).compiled!,
|
||
);
|
||
expect(loud.stages[0]!.gain).toBeCloseTo(10 ** (24 / 20));
|
||
});
|
||
|
||
it('ignores a node hanging off the line', () => {
|
||
const f = line([{ type: 'SoundFxPitch', params: { semitones: 3 } }]);
|
||
const aside = f.edit.addNode('SoundFxGain', { gain: -20 });
|
||
f.edit.connect(f.input, 'out', aside, 'in');
|
||
const build = compileSoundFx(f.fx.graph);
|
||
expect(fxChainOf(build.compiled!)).toEqual({ pitch: 3, stages: [] });
|
||
expect(build.effects).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('a Mix in a sound FX graph (#316)', () => {
|
||
/** Input → Pitch +12 → Mix a, Input → Gain -6 → Mix b, Mix → EQ → Output. */
|
||
function forked(mix: Record<string, number> = {}) {
|
||
const f = makeFx();
|
||
const input = f.edit.addNode('SoundFxInput');
|
||
const pitch = f.edit.addNode('SoundFxPitch', { semitones: 12 });
|
||
const gain = f.edit.addNode('SoundFxGain', { gain: -6 });
|
||
const node = f.edit.addNode('SoundFxMix', mix);
|
||
const eq = f.edit.addNode('SoundFxEq', { low: 6 });
|
||
const output = f.edit.addNode('SoundFxOutput');
|
||
f.edit.connect(input, 'out', pitch, 'in');
|
||
f.edit.connect(input, 'out', gain, 'in');
|
||
f.edit.connect(pitch, 'out', node, 'a');
|
||
f.edit.connect(gain, 'out', node, 'b');
|
||
f.edit.connect(node, 'out', eq, 'in');
|
||
f.edit.connect(eq, 'out', output, 'in');
|
||
return { ...f, input, mix: node };
|
||
}
|
||
|
||
it('joins two ways from the Input, each with its own pitch, half each by default', () => {
|
||
const build = compileSoundFx(forked().fx.graph);
|
||
expect(build).toMatchObject({ effects: 4, reason: '' });
|
||
const chain = fxChainOf(build.compiled!);
|
||
expect(chain).toMatchObject({
|
||
mix: 0.5,
|
||
a: { pitch: 12, stages: [] },
|
||
b: { pitch: 0, stages: [{ kind: 'gain' }] },
|
||
stages: [{ kind: 'filter', filter: 'lowshelf', gain: 6 }],
|
||
});
|
||
});
|
||
|
||
it('is only the one sound at 0 or 1, with what follows the Mix', () => {
|
||
expect(fxChainOf(compileSoundFx(forked({ mix: 0 }).fx.graph).compiled!)).toMatchObject({
|
||
pitch: 12,
|
||
stages: [{ kind: 'filter' }],
|
||
});
|
||
const b = fxChainOf(compileSoundFx(forked({ mix: 5 }).fx.graph).compiled!);
|
||
expect(b).toMatchObject({ pitch: 0, stages: [{ kind: 'gain' }, { kind: 'filter' }] });
|
||
});
|
||
|
||
it('takes its share from a parameter, held between 0 and 1', () => {
|
||
const f = forked();
|
||
f.input.ports = [{ key: 'blend', kind: 'number', default: 0.25 }];
|
||
f.edit.connect(f.input, 'blend', f.mix, 'mix');
|
||
const plan = compileSoundFx(f.fx.graph).compiled!;
|
||
expect(fxChainOf(plan)).toMatchObject({ mix: 0.25 });
|
||
expect(fxChainOf(plan, { blend: 0.8 })).toMatchObject({ mix: 0.8 });
|
||
expect(fxChainOf(plan, { blend: -3 })).toMatchObject({ pitch: 12 });
|
||
expect(fxChainOf(plan, { blend: 3 })).toMatchObject({ pitch: 0 });
|
||
});
|
||
|
||
it('hands a Pitch behind it to both of its sounds', () => {
|
||
const f = forked();
|
||
const output = f.fx.graph.nodes.find((n) => n.type === 'SoundFxOutput')!;
|
||
const eq = f.fx.graph.nodes.find((n) => n.type === 'SoundFxEq')!;
|
||
f.fx.graph.edges = f.fx.graph.edges.filter((e) => e.to.node !== output.id);
|
||
const after = f.edit.addNode('SoundFxPitch', { semitones: -5 });
|
||
f.edit.connect(eq, 'out', after, 'in');
|
||
f.edit.connect(after, 'out', output, 'in');
|
||
expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({
|
||
a: { pitch: 7 },
|
||
b: { pitch: -5 },
|
||
stages: [{ kind: 'filter' }],
|
||
});
|
||
});
|
||
|
||
it('needs both of its sounds', () => {
|
||
const f = forked();
|
||
f.fx.graph.edges = f.fx.graph.edges.filter((e) => !(e.to.node === f.mix.id && e.to.port === 'b'));
|
||
const build = compileSoundFx(f.fx.graph);
|
||
expect(build.compiled).toBeNull();
|
||
expect(build.problems[f.mix.id]).toBe('Nothing comes in: connect it to the Input');
|
||
});
|
||
});
|
||
|
||
/** Records what it was asked to play, with the line of effects. */
|
||
function fakeAudio(): AudioSink & { played: { file: string; fx: FxChain | undefined }[] } {
|
||
const sink = {
|
||
played: [] as { file: string; fx: FxChain | undefined }[],
|
||
play: (file: string, _volume: number, _loop: boolean, _owner: string, fx?: FxChain) =>
|
||
sink.played.push({ file, fx }),
|
||
stop: () => {},
|
||
stopOwner: () => {},
|
||
};
|
||
return sink;
|
||
}
|
||
|
||
describe('Play sound with fx', () => {
|
||
function playingScene(fxId: (f: ReturnType<typeof makeFx>) => string) {
|
||
const scene = makeSpriteScene('Box');
|
||
const withFx = makeFx(scene);
|
||
const fx = withFx.fx;
|
||
const input = withFx.edit.addNode('SoundFxInput');
|
||
const pitch = withFx.edit.addNode('SoundFxPitch', { semitones: -12 });
|
||
const output = withFx.edit.addNode('SoundFxOutput');
|
||
withFx.edit.connect(input, 'out', pitch, 'in');
|
||
withFx.edit.connect(pitch, 'out', output, 'in');
|
||
const sound = new CreateSoundCommand('Jump', FILE);
|
||
scene.stack.execute(sound);
|
||
const behavior = makeBehavior(scene, 'Logic');
|
||
place(scene, behavior.id, { x: 0, y: 0 }, place(scene, scene.spriteId, { x: 0, y: 0 }));
|
||
const edit = graphEditor(scene, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const play = edit.addNode('PlaySound', { sound: sound.asset.id, fx: fxId(withFx) });
|
||
edit.connect(start, 'exec', play, 'exec');
|
||
return { ...scene, fx, output, pitch };
|
||
}
|
||
|
||
it('sends the voice through the asset the node names', () => {
|
||
const f = playingScene((s) => s.fx.id);
|
||
const audio = fakeAudio();
|
||
startWorld(f, { behaviors: true, audio }).frame();
|
||
expect(audio.played).toEqual([{ file: FILE, fx: { pitch: -12, stages: [] } }]);
|
||
});
|
||
|
||
it('plays it dry without an fx, with one that is gone, and with one that does not compile', () => {
|
||
const none = fakeAudio();
|
||
startWorld(
|
||
playingScene(() => ''),
|
||
{ behaviors: true, audio: none },
|
||
).frame();
|
||
const gone = fakeAudio();
|
||
startWorld(
|
||
playingScene(() => 'no-such-fx'),
|
||
{ behaviors: true, audio: gone },
|
||
).frame();
|
||
const broken = playingScene((s) => s.fx.id);
|
||
broken.fx.graph.edges = [];
|
||
expect(soundFxBuildFor(broken.fx).compiled).toBeNull();
|
||
const brokenAudio = fakeAudio();
|
||
startWorld(broken, { behaviors: true, audio: brokenAudio }).frame();
|
||
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(pitchOf(plan)).toEqual([{ value: -12, param: 'depth', min: -64, max: 64 }]);
|
||
expect(pitchOf(fxChainOf(plan))).toBe(-12);
|
||
expect(pitchOf(fxChainOf(plan, { depth: 5 }))).toBe(5);
|
||
expect(pitchOf(fxChainOf(plan, { depth: 100 }))).toBe(64);
|
||
expect(pitchOf(fxChainOf(plan, { other: 3 }))).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(pitchOf(fxChainOf(soundFxBuildFor(f.fx).compiled!))).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);
|
||
});
|
||
});
|