Wired into the time of Set sound FX parameters, a change then spans exactly one run of the clip, so a fade or a filter sweep ends with its last sample. - Play sound has a second output, length, in seconds. It is 0 where nothing was played or the sound has not loaded yet. - The AudioSink gives the length of a file, the SoundLibrary takes it from the decoded buffer. A pitch the voice starts on keeps it (#312). - A pitch changed while the voice plays still changes its length, so a pitch sweep over length does not end with the sound. A pitch that keeps the length in real time is #317. Tests: - soundfx, sounds, keep-on-scene-load, node-catalog, sound-library: 462 tests green. - Counter-check: with length always 0, the catalog case and the new soundfx case fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
565 lines
23 KiB
TypeScript
565 lines
23 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,
|
||
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 with
|
||
* its time on the side. A voice is numbered by how many have been played, so the first is 1. Every
|
||
* file lasts a quarter of a second.
|
||
*/
|
||
function fakeAudio(): AudioSink & {
|
||
played: { file: string; owner: string; fx: FxChain | undefined }[];
|
||
tuned: { voice: number; params: Readonly<Record<string, number>> }[];
|
||
times: (number | undefined)[];
|
||
} {
|
||
const sink = {
|
||
played: [] as { file: string; owner: string; fx: FxChain | undefined }[],
|
||
tuned: [] as { voice: number; params: Readonly<Record<string, number>> }[],
|
||
times: [] as (number | undefined)[],
|
||
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>>, time?: number) => {
|
||
sink.tuned.push({ voice, params });
|
||
sink.times.push(time);
|
||
},
|
||
length: () => 0.25,
|
||
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, over the length of that play where `spanned`.
|
||
*/
|
||
function tuning(sound?: string, spanned = false) {
|
||
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'],
|
||
]);
|
||
if (spanned) edit.wire([[first, 'length', set, 'time']]);
|
||
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([]);
|
||
});
|
||
|
||
it('spans a change over the length its Play sound hands out', () => {
|
||
const audio = fakeAudio();
|
||
const { input, frame } = startWorld(tuning(undefined, true), { behaviors: true, audio });
|
||
input.press('x');
|
||
frame();
|
||
expect(audio.times).toEqual([0.25]);
|
||
});
|
||
});
|