Files
feuerwerk/tests/core/soundfx.test.ts
T
adminandClaude Opus 5.5 6e70421b92
game-maker / gate (push) Successful in 3m13s
game-maker / suite (push) Successful in 15m36s
Keep the length of a sound whatever its pitch does #317
A Pitch used to keep the length only for the pitch a voice started at.
Set sound FX parameters changed it by speed, so a sweep made the sound
longer or shorter, and length into time no longer spanned the sound.
Now a way with a Pitch is played by an AudioWorklet, a phase vocoder
whose pitch is an AudioParam in semitones. The length never changes,
and a pitch glides, ramps and eases like a Gain.

- PitchPlayer (core/soundfx) is the vocoder as a plain class, so it
  runs in a unit test. Frames of about 43 ms, four overlapping, a real
  FFT done as a complex one of half the size.
- Peaks are moved with the bins around them (Laroche and Dolson's
  phase locking), and frames are turned by half their size so a phase
  is the one at the middle. Without both, a tone beat against itself
  and dipped to 0.54 of its loudness while the pitch moved.
- It reads the sound itself rather than being fed it. It looks ahead
  as far as a frame reaches, so a voice starts with its first sample
  and every way of a Mix stays in step. A change of pitch is heard
  about half a frame (20 ms) late.
- At exactly 0 a frame is the sound itself and costs next to nothing.
  Coming back to 0, the phases are pulled round to the sound's for
  about half a second before it switches, so the switch does not dip.
- pitchWorklet (viewport) is the processor, bundled by Vite through
  ?worker&url. It is loaded with the first play that has a Pitch, which
  waits for it like for its bytes. A file's samples are sent with its
  first voice only and kept in the worklet's scope.
- A way has the worklet when its plan has a Pitch, a typed-in 0
  excepted (FxChain.shifted). A way without one stays a plain buffer
  source. Without AudioWorklet a Pitch is not heard, and the console
  says so.
- The pitched copies (WSOLA, pitchShift.ts) and their cache are gone.
- Decisions of the user: the player at the source rather than an
  effect in the chain, a phase vocoder rather than WSOLA, no limit on
  voices, no range tighter than the node's -64 to +64.
- stats() says whether the worklet is there, levels() gives a pitch in
  semitones, and __gm.sound.pitchWorkletUrl() lets a check run it.

Tests:
- pitch-player.test.ts (new, replaces pitch-shift.test.ts): 11 tests.
  Pitches from -30.5 to +12 within 2 %, the length to the sample, 0
  exact to 1e-5, a sweep half a frame behind, 8 kHz sounds, loops,
  ramps there and back without a dip.
- sound-library.test.ts: 23 tests (+4), soundfx.test.ts: 31 (+1).
- Counter-checks: no worklet in the player, shifted taken from the
  value, no pull back to 0. 15 tests fail, files restored by cmp.
- e2e 34 on melektaus-atx: ALL PASSED, 34 checks (+1). In real Chrome
  the worklet is there and the pitch param glides to 3.0007.
  Offline: 880.0 Hz for 440 an octave up, 48000 of 48000 samples, a
  sweep 0 to -12 at 392 then 248 Hz, still 48000 samples.
- Gate on melektaus-atx: 242 test files, 3690 tests, CHECK EXIT 0.
- Cost: 53 ms per second of a stereo voice at +5 in Node on stein2,
  5 ms at 0. In headless Chrome 16 voices for 2 s took 1.3 s, about 24
  fill the audio thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:00:13 +02:00

572 lines
24 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, shifted: true, 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, shifted: false, 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('keep a pitch that is 0 by default on the worklet, and only a typed-in 0 off it (#317)', () => {
const fed = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number' }]);
expect(fxChainOf(soundFxBuildFor(fed.fx).compiled!)).toMatchObject({ pitch: 0, shifted: true });
const typed = line([{ type: 'SoundFxPitch', params: { semitones: 0 } }]);
expect(fxChainOf(soundFxBuildFor(typed.fx).compiled!)).toMatchObject({ pitch: 0, shifted: false });
});
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, shifted: true, 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, shifted: true, 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]);
});
});