/** * 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 } 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`; 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 }[]) { 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']); f.edit.addNode('SoundFxInput'); f.edit.addNode('SoundFxOutput'); expect(ids()).toEqual(['SoundFxGain', 'SoundFxPitch', 'SoundFxEq']); 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(chain.pitch).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); }); }); /** 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) => 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(plan.pitch).toEqual([{ value: -12, param: 'depth', min: -24, max: 24 }]); expect(fxChainOf(plan).pitch).toBe(-12); expect(fxChainOf(plan, { depth: 5 }).pitch).toBe(5); expect(fxChainOf(plan, { depth: 100 }).pitch).toBe(24); expect(fxChainOf(plan, { other: 3 }).pitch).toBe(-12); }); it('keep a stage that is 0 by default, so a value handed in can open it', () => { const f = withParam('SoundFxGain', 'gain', [{ key: 'loud', kind: 'number' }]); const plan = soundFxBuildFor(f.fx).compiled!; expect(plan.stages).toHaveLength(1); expect(fxChainOf(plan)).toEqual(DRY); expect(fxChainOf(plan, { loud: 6 }).stages[0]).toMatchObject({ kind: 'gain' }); expect((fxChainOf(plan, { loud: 6 }).stages[0] as { gain: number }).gain).toBeCloseTo(10 ** (6 / 20)); }); it('feed an EQ band as well', () => { const f = withParam('SoundFxEq', 'mid', [{ key: 'body', kind: 'number', default: 3 }]); const chain = fxChainOf(soundFxBuildFor(f.fx).compiled!, { body: -4 }); expect(chain.stages).toEqual([{ kind: 'filter', filter: 'peaking', frequency: 1000, gain: -4, q: 1 }]); }); it('are kept with their defaults through a save, a default only on a number', () => { const f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number', default: -7 }]); expect(roundTrip(f.project)).toEqual(f.project); const loaded = deserializeProject({ name: 'P', assets: [ { id: 'fx', type: 'soundfx', name: 'FX', graph: { nodes: [ { id: 'a', type: 'SoundFxInput', x: 0, y: 0, params: {}, ports: [ { key: 'p', kind: 'number', default: 'loud' }, { key: 'q', kind: 'number', default: 2 }, ], }, ], edges: [], }, }, ], }); expect((loaded.assets[0] as SoundFxAsset).graph.nodes[0]!.ports).toEqual([ { key: 'p', kind: 'number' }, { key: 'q', kind: 'number', default: 2 }, ]); }); it('do not go where a sound goes, and a sound does not go where a number does', () => { const f = line([{ type: 'SoundFxGain' }]); f.input.ports = [{ key: 'level', kind: 'number' }]; const gain = f.fx.graph.nodes.find((n) => n.type === 'SoundFxGain')!; f.fx.graph.edges.find((e) => e.to.node === gain.id)!.from.port = 'level'; expect(compileSoundFx(f.fx.graph).problems[gain.id]).toBe('"in" takes a sound'); expect(portsCompatible('fxparams', 'fxparams')).toBe(true); expect(portsCompatible('fxparams', 'any')).toBe(false); expect(portsCompatible('struct', 'fxparams')).toBe(false); }); it('have a default set by a command that undoes', () => { const f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number' }]); f.stack.execute(new SetPortDefaultCommand(f.fx.id, f.input.id, 'depth', 4)); expect(f.input.ports).toEqual([{ key: 'depth', kind: 'number', default: 4 }]); expect(fxChainOf(soundFxBuildFor(f.fx).compiled!).pitch).toBe(4); f.stack.undo(); expect(f.input.ports).toEqual([{ key: 'depth', kind: 'number' }]); }); }); describe('Sound FX parameters and Play sound', () => { function scene(options: { pick?: string[]; values?: Record; 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); }); });