Files
feuerwerk/tests/core/soundfx.test.ts
T
adminandClaude Opus 5.5 c7e9769f90
game-maker / gate (push) Successful in 3m14s
game-maker / suite (push) Successful in 15m39s
Change the sound FX parameters of a sound while it plays #315
Until now a value handed to a sound FX counted from the start of the
voice only. An engine whose pitch follows the speed, or a loop that
gets quieter, could not be built. Every play of Play sound now gives
out the number of its voice, and a new node lets the knobs of that one
voice glide to new values while it plays.

- Play sound has a new output voice: the number of this one play, or 0
  where nothing was played. Kept in a Number variable it reaches the
  voice from another event. Two plays of one sound are two voices.
- New action node Set sound FX parameters (exec, voice, fx params).
  It reaches exactly the voice it is given, a detached one too. A
  voice that has ended, or voice 0, is not reached.
- AudioSink.play returns the voice number and takes the plan and the
  values (FxVoice) instead of a finished chain. AudioSink.tune takes a
  voice number and new values. A name the voice's FX does not have is
  left out, one not given keeps its value.
- SoundLibrary hands out the number at once, before the bytes are
  there, and never twice. A voice still waiting for its bytes starts
  with the values handed in meanwhile.
- Every knob of a voice moves with setTargetAtTime (30 ms time
  constant). A knob whose value did not change is not sent again, so a
  tune every frame costs next to nothing.
- A pitch on a playing voice glides by playbackRate. A new WSOLA copy
  in the middle of a sound is not worked out, so the voice gets shorter
  or longer while it is moved. On a loop that is not heard.
- fxChainOf now gives a chain whose shape depends on the plan alone.
  A stage fed by a parameter stays at 0 dB, and a Mix fed by a
  parameter keeps both ways. A typed-in 0 or Mix at 0/1 is still left
  out by the compiler.
- Every way of a voice has a source of its own, also at the same
  pitch, so each can glide on its own. The pitched copy is shared.
- The tab keeps the numbers of what it plays, so moving a parameter
  field is heard at once.
- Test hook __gm.sound.levels(file) reads the knobs of every voice of
  a file, oldest first, and stats() counts the voices tuned.

Tests:
- soundfx.test.ts: 29 tests (+2 for the node in a running world: the
  voice of the first of two plays of one sound, and a play that played
  nothing)
- sound-library.test.ts: 15 tests (+3: glide, reach by number,
  pending voice)
- node-catalog.test.ts: 370 tests (+2, Play sound checks its voice)
- sounds + keep-on-scene-load: 31 tests, unchanged
- Counter-test: a tune that reaches every voice of the same file makes
  the reach test fail.
- e2e 34 (melektaus-atx): ALL PASSED - 30 checks (+1). In Chrome two
  looping voices of one sound start at depth 7. The one whose number
  sits in a variable glides to 0.7938 (2^(-4/12) = 0.7937), the other
  stays at 1. Both gains stay at 0.5012.
- The gate ran before voices got a number (CHECK EXIT 0, 241 files,
  3658 tests), not after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:53:26 +02:00

549 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
type FxVoice,
} 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, and keeps both ways for it (#315)', () => {
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({ mix: 0, a: { pitch: 12 }, b: { pitch: 0 } });
expect(fxChainOf(plan, { blend: 3 })).toMatchObject({ mix: 1, a: { pitch: 12 }, b: { 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 its values make, and every tune. A
* voice is numbered by how many have been played, so the first is 1.
*/
function fakeAudio(): AudioSink & {
played: { file: string; owner: string; fx: FxChain | undefined }[];
tuned: { voice: number; params: Readonly<Record<string, number>> }[];
} {
const sink = {
played: [] as { file: string; owner: string; fx: FxChain | undefined }[],
tuned: [] as { voice: number; params: Readonly<Record<string, number>> }[],
play: (file: string, _volume: number, _loop: boolean, owner: string, fx?: FxVoice) =>
sink.played.push({ file, owner, fx: fx && fxChainOf(fx.plan, fx.params) }),
tune: (voice: number, params: Readonly<Record<string, number>>) => sink.tuned.push({ voice, params }),
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).toMatchObject([{ 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).toMatchObject([{ 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, even on a playing voice', () => {
const f = withParam('SoundFxGain', 'gain', [{ key: 'loud', kind: 'number' }]);
const plan = soundFxBuildFor(f.fx).compiled!;
expect(plan.stages).toHaveLength(1);
expect(fxChainOf(plan)).toEqual({ pitch: 0, stages: [{ kind: 'gain', gain: 1 }] });
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).toMatchObject([
{ file: FILE, fx: { pitch: 7, stages: [{ kind: 'gain', gain: 1 }] } },
]);
});
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: [{ kind: 'gain', gain: 1 }] });
});
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: [{ kind: 'gain', gain: 1 }] });
f.stack.undo();
expect(f.params.ports).toEqual([{ key: 'depth', kind: 'number' }]);
expect(f.params.params.depth).toBe(7);
});
});
describe('Set sound FX parameters (#315)', () => {
/**
* Pressing x plays Jump through an FX twice, the first `sound` – then hands `depth` to the voice
* the first play gave out.
*/
function tuning(sound?: string) {
const f = makeSpriteScene('Box');
const { fx } = makeFx(f);
const created = new CreateSoundCommand('Jump', FILE);
f.stack.execute(created);
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 first = edit.addNode('PlaySound', { sound: sound ?? created.asset.id, fx: fx.id });
const second = edit.addNode('PlaySound', { sound: created.asset.id, fx: fx.id });
const set = edit.addNode('SetSoundFxParameters');
const params = edit.addNode('SoundFxParameters', { soundFxAsset: fx.id, depth: 3 });
params.ports = [{ key: 'depth', kind: 'number' }];
edit.wire([
[edit.addNode('OnKeyDown', { key: 'x' }), 'exec', first, 'exec'],
[first, 'exec', second, 'exec'],
[second, 'exec', set, 'exec'],
[first, 'voice', set, 'voice'],
[params, 'params', set, 'fxParams'],
]);
return f;
}
it('hands the values to the one voice its Play sound gave out, not to another of the same sound', () => {
const audio = fakeAudio();
const { input, frame } = startWorld(tuning(), { behaviors: true, audio });
input.press('x');
frame();
expect(audio.played).toHaveLength(2);
expect(audio.tuned).toEqual([{ voice: 1, params: { depth: 3 } }]);
});
it('reaches nothing where its Play sound played nothing', () => {
const audio = fakeAudio();
const { input, frame } = startWorld(tuning('no-such-sound'), { behaviors: true, audio });
input.press('x');
frame();
expect(audio.played).toHaveLength(1);
expect(audio.tuned).toEqual([]);
});
});