/** * 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 { PropertyBag } from '../../src/core/model/Properties'; 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 { dampingCutoff, EMPTY_PLAN, type FxChain, fxChainOf, type FxDelay, fxLatencyOf, type FxPlan, fxRings, type FxStage, fxTailOf, type FxVoice, delayShares, impulsesOf, SOUND_FX_NODES, SoundFxReverb, stageLatency, } from '../../src/core/soundfx/nodes'; import { saturationAutoGain } from '../../src/core/soundfx/saturation'; 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`; const OTHER_FILE = `${'b2'.repeat(32)}.wav`; /** What a sound with no FX goes through. */ const DRY: FxChain = { pitch: 0, shifted: false, stages: [] }; /** The level of a gain or filter stage. */ const gainOf = (stage: FxStage | undefined) => (stage && 'gain' in stage ? stage.gain : undefined); /** The pitch of a sound that is not mixed. */ const pitchOf = (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?: PropertyBag }[]) { 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 }; } /** A shelf of a Parametric EQ as its node keeps it. */ const shelf = (type: 'lowshelf' | 'highshelf', frequency: number, gain: number) => ({ type, frequency, gain, q: 1, slope: 12, }); 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', 'SoundFxParametricEq', 'SoundFxFilter', 'SoundFxMix', 'SoundFxDelay', 'SoundFxBitcrusher', 'SoundFxPhaser', 'SoundFxFlanger', 'SoundFxPan', 'SoundFxCompressor', 'SoundFxLimiter', 'SoundFxSaturation', 'SoundFxTremolo', 'SoundFxVibrato', 'SoundFxReverb', ]); f.edit.addNode('SoundFxInput'); f.edit.addNode('SoundFxOutput'); expect(ids()).toEqual([ 'SoundFxGain', 'SoundFxPitch', 'SoundFxParametricEq', 'SoundFxFilter', 'SoundFxMix', 'SoundFxDelay', 'SoundFxBitcrusher', 'SoundFxPhaser', 'SoundFxFlanger', 'SoundFxPan', 'SoundFxCompressor', 'SoundFxLimiter', 'SoundFxSaturation', 'SoundFxTremolo', 'SoundFxVibrato', 'SoundFxReverb', ]); 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: 'SoundFxParametricEq', params: { bands: [shelf('lowshelf', 80, 6), shelf('highshelf', 12000, -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); const stages = chain.stages.filter((s) => s.kind !== 'tap'); expect(stages).toHaveLength(3); expect(stages[0]!.kind).toBe('gain'); expect(gainOf(stages[0])).toBeCloseTo(0.501, 3); expect(stages.slice(1)).toEqual([ { kind: 'pass', filter: 'lowshelf', stages: 1, cutoff: 80, q: 1, gain: 6 }, { kind: 'pass', filter: 'highshelf', stages: 1, cutoff: 12000, q: 1, gain: -3 }, ]); }); it('leaves out what does nothing and holds a value to its row', () => { const quiet = compileSoundFx(line([{ type: 'SoundFxGain' }, { type: 'SoundFxPan' }]).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(gainOf(loud.stages[0])).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 → Pan → Output. */ function forked(mix: Record = {}) { 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 pan = f.edit.addNode('SoundFxPan', { pan: 0.5 }); 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', pan, 'in'); f.edit.connect(pan, '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: 'pan', pan: 0.5 }], }); }); 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: 'pan' }], }); const b = fxChainOf(compileSoundFx(forked({ mix: 5 }).fx.graph).compiled!); expect(b).toMatchObject({ pitch: 0, stages: [{ kind: 'gain' }, { kind: 'pan' }] }); }); it('says its curve only where it is "Both at center", and is still one sound at 0 or 1', () => { expect(fxChainOf(compileSoundFx(forked().fx.graph).compiled!)).not.toHaveProperty('curve'); const f = forked(); f.mix.params.curve = 'both'; expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({ mix: 0.5, curve: 'both' }); f.mix.params.mix = 0; expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({ pitch: 12 }); }); 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 pan = f.fx.graph.nodes.find((n) => n.type === 'SoundFxPan')!; 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(pan, '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: 'pan' }], }); }); 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'); }); }); describe('a Delay in a sound FX graph (#327)', () => { it('echoes at its defaults, the damping a cutoff evenly in octaves between 20 kHz and 500 Hz', () => { const chain = fxChainOf(compileSoundFx(line([{ type: 'SoundFxDelay' }]).fx.graph).compiled!); expect(chain.stages).toEqual([ { kind: 'delay', time: 0.25, feedback: 0.4, damping: dampingCutoff(0.3), mix: 0.5, pingPong: false }, ]); expect([dampingCutoff(0), dampingCutoff(0.5), dampingCutoff(1)]).toEqual([ 20000, expect.closeTo(Math.sqrt(20000 * 500)), expect.closeTo(500), ]); }); it('holds its numbers to their rows, never a feedback of 1, and carries ping-pong', () => { const chain = fxChainOf( compileSoundFx( line([ { type: 'SoundFxDelay', params: { time: 9, feedback: 2, damping: -1, mix: 3, pingPong: true } }, ]).fx.graph, ).compiled!, ); expect(chain.stages).toEqual([ { kind: 'delay', time: 2, feedback: 0.95, damping: 20000, mix: 1, pingPong: true }, ]); }); it('is left out at a typed-in mix of 0, and kept for a mix from a parameter', () => { const off = compileSoundFx(line([{ type: 'SoundFxDelay', params: { mix: 0 } }]).fx.graph); expect(off.compiled).toEqual(EMPTY_PLAN); const fed = withParam('SoundFxDelay', 'mix', [{ key: 'wet', kind: 'number' }]); const plan = soundFxBuildFor(fed.fx).compiled!; expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'delay', mix: 0 }]); expect(fxChainOf(plan, { wet: 0.7 }).stages).toMatchObject([{ kind: 'delay', mix: 0.7 }]); }); it('takes its time from a parameter, held to two seconds', () => { const f = withParam('SoundFxDelay', 'time', [{ key: 'echo', kind: 'number', default: 0.5 }]); const plan = soundFxBuildFor(f.fx).compiled!; expect(fxChainOf(plan).stages).toMatchObject([{ time: 0.5 }]); expect(fxChainOf(plan, { echo: 1 }).stages).toMatchObject([{ time: 1 }]); expect(fxChainOf(plan, { echo: 5 }).stages).toMatchObject([{ time: 2 }]); }); it('synced to beats counts its time in quarter notes at the tempo of the voice (#332)', () => { const synced = (time: number) => compileSoundFx(line([{ type: 'SoundFxDelay', params: { sync: 'beats', time } }]).fx.graph).compiled!; expect(synced(0.75)).toMatchObject({ stages: [{ beats: true, time: { value: 0.75 } }] }); // A dotted eighth at 120 BPM, an eighth triplet at 100, a quarter where no tempo is said. expect(fxChainOf(synced(0.75), {}, 120).stages).toMatchObject([{ time: 0.375 }]); expect((fxChainOf(synced(1 / 3), {}, 100).stages[0] as FxDelay).time).toBeCloseTo(0.2); expect(fxChainOf(synced(1)).stages).toMatchObject([{ time: 0.5 }]); // A whole note at 60 BPM is 4 s: held to the 2 s of the line, as is a tempo out of its range. expect(fxChainOf(synced(4), {}, 60).stages).toMatchObject([{ time: 2 }]); expect(fxChainOf(synced(1), {}, 1000).stages).toMatchObject([{ time: 0.15 }]); // In seconds the tempo changes nothing. const seconds = compileSoundFx(line([{ type: 'SoundFxDelay', params: { time: 0.75 } }]).fx.graph); expect(fxChainOf(seconds.compiled!, {}, 60).stages).toMatchObject([{ time: 0.75 }]); }); }); describe('a Bitcrusher in a sound FX graph (#322)', () => { it('crushes at its defaults and holds its numbers to their rows', () => { const at = (params?: Record) => fxChainOf(compileSoundFx(line([{ type: 'SoundFxBitcrusher', params }]).fx.graph).compiled!).stages; expect(at()).toEqual([{ kind: 'crush', bits: 8, rate: 8000, mix: 1 }]); expect(at({ bits: 0, rate: 1e6, mix: 2 })).toEqual([{ kind: 'crush', bits: 1, rate: 48000, mix: 1 }]); expect(at({ bits: 3.5, rate: 50 })).toEqual([{ kind: 'crush', bits: 3.5, rate: 100, mix: 1 }]); }); it('is left out at a typed-in mix of 0, and kept for bits from a parameter', () => { expect( compileSoundFx(line([{ type: 'SoundFxBitcrusher', params: { mix: 0 } }]).fx.graph).compiled, ).toEqual(EMPTY_PLAN); const f = withParam('SoundFxBitcrusher', 'bits', [{ key: 'crunch', kind: 'number', default: 12 }]); const plan = soundFxBuildFor(f.fx).compiled!; expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'crush', bits: 12 }]); expect(fxChainOf(plan, { crunch: 2.5 }).stages).toMatchObject([{ kind: 'crush', bits: 2.5 }]); expect(fxChainOf(plan, { crunch: 40 }).stages).toMatchObject([{ kind: 'crush', bits: 16 }]); }); }); describe('a Phaser and a Flanger in a sound FX graph (#341)', () => { const at = (type: string, params?: Record) => fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages; it('play at their defaults and hold their numbers to their rows', () => { expect(at('SoundFxPhaser')).toEqual([ { kind: 'phaser', stages: 4, frequency: 800, rate: 0.5, depth: 2, feedback: 0.4, mix: 1 }, ]); expect(at('SoundFxPhaser', { stages: '12', frequency: 5, feedback: 2, depth: -1 })).toMatchObject([ { stages: 12, frequency: 20, feedback: 0.95, depth: 0 }, ]); expect(at('SoundFxPhaser', { stages: '5' })).toMatchObject([{ stages: 4 }]); expect(at('SoundFxFlanger')).toEqual([ { kind: 'flanger', offset: 1, sweep: 0, rate: 0.25, depth: 2, feedback: 0.5, mix: 1 }, ]); expect(at('SoundFxFlanger', { offset: 50, sweep: -500, depth: 99 })).toMatchObject([ { offset: 10, sweep: -125, depth: 10 }, ]); }); it('are left out at a typed-in mix of 0, and kept for a mix from a parameter', () => { expect(at('SoundFxPhaser', { mix: 0 })).toEqual([]); expect(at('SoundFxFlanger', { mix: 0 })).toEqual([]); const f = withParam('SoundFxFlanger', 'mix', [{ key: 'wet', kind: 'number', default: 0 }]); const plan = soundFxBuildFor(f.fx).compiled!; expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'flanger', mix: 0 }]); expect(fxChainOf(plan, { wet: 0.3 }).stages).toMatchObject([{ kind: 'flanger', mix: 0.3 }]); }); }); describe('a Pan in a sound FX graph (#319)', () => { const at = (params?: Record) => fxChainOf(compileSoundFx(line([{ type: 'SoundFxPan', params }]).fx.graph).compiled!).stages; it('moves the sound by its pan held to -1 to 1, and is left out at a typed-in 0', () => { expect(at({ pan: -0.5 })).toEqual([{ kind: 'pan', pan: -0.5 }]); expect(at({ pan: 3 })).toEqual([{ kind: 'pan', pan: 1 }]); expect(at()).toEqual([]); const f = withParam('SoundFxPan', 'pan', [{ key: 'side', kind: 'number', default: 0 }]); const plan = soundFxBuildFor(f.fx).compiled!; expect(fxChainOf(plan).stages).toEqual([{ kind: 'pan', pan: 0 }]); expect(fxChainOf(plan, { side: -2 }).stages).toEqual([{ kind: 'pan', pan: -1 }]); }); }); describe('a Compressor and a Limiter in a sound FX graph (#326)', () => { // The taps a Compressor keeps for its tab do nothing to the sound (#378). const at = (type: string, params?: Record) => fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages.filter( (s) => s.kind !== 'tap', ); it('tap a Compressor on both sides for its tab (#378), a Limiter for its screen (#397)', () => { const kinds = (type: string) => fxChainOf(compileSoundFx(line([{ type }]).fx.graph).compiled!).stages.map((s) => s.kind === 'tap' ? `tap ${s.at}` : s.kind, ); expect(kinds('SoundFxCompressor')).toEqual(['tap in', 'compress', 'tap out']); expect(kinds('SoundFxLimiter')).toEqual(['tap in', 'limit', 'tap out']); }); it('compress at their defaults, the gain after it worked out from makeup and auto-gain', () => { const [stage] = at('SoundFxCompressor', { threshold: -24, knee: 0, ratio: 4, makeup: 2, autoGain: true }); expect(stage).toMatchObject({ kind: 'compress', threshold: -24, knee: 0, ratio: 4, attack: 0.003, release: 0.25, }); const db = (s: FxStage | undefined) => 20 * Math.log10((s as { gain: number }).gain); // 2 dB makeup and 4.5 dB of auto-gain on top, less the 10.8 dB the node adds itself. expect(db(stage)).toBeCloseTo(2 + 4.5 - 10.8, 3); expect(db(at('SoundFxCompressor', { threshold: -24, knee: 0, ratio: 4 })[0])).toBeCloseTo(-10.8, 3); expect(at('SoundFxCompressor', { ratio: 50, threshold: 10 })).toMatchObject([ { ratio: 20, threshold: 0 }, ]); }); it('limit at their defaults, held to their rows, late by their look-ahead', () => { expect(at('SoundFxLimiter')).toEqual([ { kind: 'limit', threshold: -6, ceiling: -1, release: 0.1, autoGain: 1 }, ]); expect(at('SoundFxLimiter', { ceiling: 3, release: 0 })).toMatchObject([{ ceiling: 0, release: 0.001 }]); expect(at('SoundFxLimiter', { autoGain: false })).toMatchObject([{ autoGain: 0 }]); const [limit] = at('SoundFxLimiter'); const [compress] = at('SoundFxCompressor'); expect(stageLatency(limit!, 48000)).toBe(0.005); expect(stageLatency(limit!, 44100)).toBe(221 / 44100); expect(stageLatency(compress!, 44100)).toBe(0.006); expect(fxRings(compileSoundFx(line([{ type: 'SoundFxLimiter' }]).fx.graph).compiled!)).toBe(true); }); it('make a chain as late as its latest way and the stages after it', () => { const limit = at('SoundFxLimiter')[0]!; const compress = at('SoundFxCompressor')[0]!; const way = (stages: FxStage[]) => ({ pitch: 0, shifted: false, stages }); expect(fxLatencyOf({ mix: 0.5, a: way([limit]), b: way([]), stages: [compress] }, 48000)).toBeCloseTo( 0.011, 9, ); expect(fxLatencyOf(way([]), 48000)).toBe(0); }); it('read the auto-gain of a project from before it was a switch: on above 0 (#378)', () => { const gain = (autoGain?: number) => ( at('SoundFxCompressor', { threshold: -24, knee: 0, ratio: 4, ...(autoGain !== undefined && { autoGain }), })[0] as { gain: number; } ).gain; expect(gain(1)).toBeCloseTo(gain(0.5), 9); expect(gain(1)).toBeGreaterThan(gain(0)); expect(gain(0)).toBeCloseTo(gain(), 9); expect(at('SoundFxLimiter', { autoGain: 0 })).toMatchObject([{ autoGain: 0 }]); }); }); describe('a Saturation in a sound FX graph (#323)', () => { const at = (params?: Record) => fxChainOf(compileSoundFx(line([{ type: 'SoundFxSaturation', params }]).fx.graph).compiled!).stages; it('bends at its defaults, its level from auto-gain, and is left out at a typed-in mix of 0', () => { const [stage] = at(); expect(stage).toMatchObject({ kind: 'saturate', curve: 'soft', drive: 12, tone: 20000, mix: 1 }); expect(20 * Math.log10((stage as { level: number }).level)).toBeCloseTo( saturationAutoGain('soft', 12), 9, ); expect(at({ mix: 0 })).toEqual([]); expect(at({ curve: 'nope' })).toMatchObject([{ curve: 'soft' }]); }); it('adds its output on top of its auto-gain, and is late by the oversampling', () => { const levelDb = (s: FxStage | undefined) => 20 * Math.log10((s as { level: number }).level); const [stage] = at({ curve: 'hard', drive: 24, output: 6 }); expect(levelDb(stage)).toBeCloseTo(6 + saturationAutoGain('hard', 24), 9); expect(levelDb(at({ curve: 'hard', drive: 24, output: 6, autoGain: false })[0])).toBeCloseTo(6, 9); expect(stageLatency(stage!, 48000)).toBe(0.004); }); }); describe('a Tremolo and a Vibrato in a sound FX graph (#320)', () => { const at = (type: string, params?: Record) => fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages; it('swing at their defaults, the depth of a Tremolo from percent, and are left out at a typed-in depth of 0', () => { expect(at('SoundFxTremolo')).toEqual([{ kind: 'tremolo', shape: 'sine', rate: 5, depth: 0.5 }]); expect(at('SoundFxTremolo', { shape: 'square', depth: 150 })).toEqual([ { kind: 'tremolo', shape: 'square', rate: 5, depth: 1 }, ]); expect(at('SoundFxVibrato', { rate: 0, depth: 9 })).toEqual([ { kind: 'vibrato', shape: 'sine', rate: 0.5, depth: 4 }, ]); expect(at('SoundFxVibrato', { depth: 0 })).toEqual([]); expect(at('SoundFxTremolo', { depth: 0 })).toEqual([]); }); it('a Vibrato is late by its delay and rings that long, a Tremolo is neither', () => { const [vibrato] = at('SoundFxVibrato', { shape: 'square', rate: 0.5, depth: 4 }); const [tremolo] = at('SoundFxTremolo'); expect(stageLatency(vibrato!, 48000)).toBeCloseTo(0.13, 2); expect(stageLatency(tremolo!, 48000)).toBe(0); expect(fxRings(compileSoundFx(line([{ type: 'SoundFxVibrato' }]).fx.graph).compiled!)).toBe(true); expect(fxRings(compileSoundFx(line([{ type: 'SoundFxTremolo' }]).fx.graph).compiled!)).toBe(false); }); }); describe('a Reverb in a sound FX graph (#328)', () => { const at = (params?: Record) => fxChainOf(compileSoundFx(line([{ type: 'SoundFxReverb', params }]).fx.graph).compiled!).stages; it('takes the numbers of its preset, or the ones typed in for Custom, and is left out at a mix of 0', () => { expect(at()).toEqual([{ kind: 'reverb', decay: 0.6, damping: 0.5, predelay: 0.005, mix: 0.3 }]); expect(at({ room: 'cave', decay: 9 })).toEqual([ { kind: 'reverb', decay: 5, damping: 0.15, predelay: 0.04, mix: 0.3 }, ]); expect(at({ room: 'custom', decay: 40, damping: 0.2, predelay: 0.1, mix: 1 })).toEqual([ { kind: 'reverb', decay: 10, damping: 0.2, predelay: 0.1, mix: 1 }, ]); expect(at({ mix: 0 })).toEqual([]); }); it('shows the rows of Custom and of Sound only for them', () => { const f = line([{ type: 'SoundFxReverb' }]); const reverb = f.fx.graph.nodes.find((n) => n.type === 'SoundFxReverb')!; const inputs = (room: string) => { reverb.params.room = room; return resolveDef(reverb, f.fx.graph, graphLookup(f.project))!.inputs.map((p) => p.key); }; expect(inputs('hall')).toEqual(['in', 'mix']); expect(inputs('custom')).toEqual(['in', 'mix', 'decay', 'damping', 'predelay']); expect(inputs('sound')).toEqual(['in', 'mix', 'predelay']); expect(SoundFxReverb.idleParams!({ room: 'sound' })).toEqual([]); expect(SoundFxReverb.idleParams!({ room: 'hall' })).toEqual(['impulse']); }); it('takes the file of a sound as its impulse response, built again when that sound changes', () => { const f = line([{ type: 'SoundFxReverb' }]); const made = new CreateSoundCommand('Church', FILE); f.stack.execute(made); const reverb = f.fx.graph.nodes.find((n) => n.type === 'SoundFxReverb')!; reverb.params = { room: 'sound', impulse: made.asset.id, predelay: 0.02 }; const plan = soundFxBuildFor(f.fx, f.project).compiled!; expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'reverb', impulse: FILE, predelay: 0.02 }]); expect(impulsesOf(plan)).toEqual([FILE]); // Without the project there is no sound to find. expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!).stages[0]).not.toHaveProperty('impulse'); made.asset.file = OTHER_FILE; expect(impulsesOf(soundFxBuildFor(f.fx, f.project).compiled!)).toEqual([OTHER_FILE]); }); it('rings its pre-delay and its decay, or as long as its sound', () => { const [made] = at({ room: 'hall' }); expect(fxTailOf({ pitch: 0, shifted: false, stages: [made!] })).toBeCloseTo(2.52, 9); const sound: FxStage = { kind: 'reverb', decay: 1, damping: 0, predelay: 0.1, mix: 1, impulse: FILE }; expect(fxTailOf({ pitch: 0, shifted: false, stages: [sound] }, () => 3)).toBeCloseTo(3.1, 9); expect(fxTailOf({ pitch: 0, shifted: false, stages: [sound] })).toBeCloseTo(10.1, 9); }); }); describe('Bypass and Wet (#354)', () => { /** Every effect, and how it says its wet: a row of its own, or the "mix" it had. */ const EFFECTS: Record = { SoundFxBitcrusher: 'mix', SoundFxCompressor: 'wet', SoundFxDelay: 'mix', SoundFxFilter: 'wet', SoundFxFlanger: 'mix', SoundFxGain: 'wet', SoundFxLimiter: 'wet', SoundFxPan: 'wet', SoundFxParametricEq: 'wet', SoundFxPhaser: 'mix', SoundFxPitch: 'wet', SoundFxReverb: 'mix', SoundFxSaturation: 'mix', SoundFxTremolo: 'wet', SoundFxVibrato: 'wet', }; const resolvedInputs = (type: string) => { const f = line([{ type }]); const node = f.fx.graph.nodes.find((n) => n.type === type)!; return resolveDef(node, f.fx.graph, graphLookup(f.project))!.inputs; }; const stagesOf = (type: string, params: PropertyBag = {}) => compileSoundFx(line([{ type, params }]).fx.graph).compiled!.stages; it('are on every effect, the wet of six of them the "mix" they had, and on nothing else', () => { for (const def of SOUND_FX_NODES) { const wet = EFFECTS[def.type]; const inputs = wet ? resolvedInputs(def.type) : def.inputs; expect([def.type, def.params.some((p) => p.key === 'bypass')]).toEqual([def.type, wet !== undefined]); expect([def.type, inputs.filter((p) => p.label === 'Wet').map((p) => p.key)]).toEqual([ def.type, wet ? [wet] : [], ]); expect([def.type, inputs.some((p) => p.label === 'Mix')]).toEqual([ def.type, def.type === 'SoundFxMix', ]); } expect(Object.keys(EFFECTS)).toHaveLength(15); }); it('bypassed, an effect is left out as if it were not there, and its node is drawn faded', () => { for (const type of Object.keys(EFFECTS)) { const on = { gain: -6, semitones: 5, pan: 1, depth: 50, bands: [{ gain: 6 }] }; expect([type, stagesOf(type, on).length > 0 || type === 'SoundFxPitch']).toEqual([type, true]); // The taps a Parametric EQ keeps do nothing to the sound (#374). const bypassed = compileSoundFx(line([{ type, params: { ...on, bypass: true } }]).fx.graph).compiled!; expect([type, { ...bypassed, stages: bypassed.stages.filter((s) => s.kind !== 'tap') }]).toEqual([ type, EMPTY_PLAN, ]); const def = SOUND_FX_NODES.find((d) => d.type === type)!; expect([def.dimmed?.({ bypass: true }), def.dimmed?.({})]).toEqual([true, false]); expect([def.hiddenParams?.({ bypass: true }), def.hiddenParams?.({})]).toEqual([[], ['bypass']]); } }); it('blends what a node adds with what arrives, all of it at 1 and none at a typed-in 0', () => { expect(stagesOf('SoundFxGain', { gain: -6 })).toEqual([{ kind: 'gain', db: { value: -6 } }]); expect(stagesOf('SoundFxGain', { gain: -6, wet: 0 })).toEqual([]); expect(stagesOf('SoundFxGain', { gain: -6, wet: 0.25 })).toEqual([ { kind: 'wet', wet: { value: 0.25 }, stages: [{ kind: 'gain', db: { value: -6 } }] }, ]); // Every band of an EQ in one blend, not each band on its own. const bands = [shelf('lowshelf', 80, 6), shelf('highshelf', 12000, -3)]; const eq = stagesOf('SoundFxParametricEq', { bands, wet: 0.5 }).filter((s) => s.kind !== 'tap'); expect(eq).toMatchObject([{ kind: 'wet', stages: [{ filter: 'lowshelf' }, { filter: 'highshelf' }] }]); // What came before the node stays outside the blend. const f = line([ { type: 'SoundFxGain', params: { gain: 3 } }, { type: 'SoundFxPan', params: { pan: 1, wet: 0.5 } }, ]); expect(compileSoundFx(f.fx.graph).compiled!.stages).toMatchObject([ { kind: 'gain' }, { kind: 'wet', stages: [{ kind: 'pan' }] }, ]); }); it('takes its wet from a parameter and keeps the blend for it, also at 0 or 1 (#315)', () => { const f = line([{ type: 'SoundFxCompressor' }]); const compressor = f.fx.graph.nodes.find((n) => n.type === 'SoundFxCompressor')!; f.input.ports = [{ key: 'blend', kind: 'number', default: 0.5 }]; f.edit.connect(f.input, 'blend', compressor, 'wet'); const plan = compileSoundFx(f.fx.graph).compiled!; const stages = (params?: Record) => fxChainOf(plan, params).stages.filter((s) => s.kind !== 'tap'); expect(stages()).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]); expect(stages({ blend: 0 })).toMatchObject([{ kind: 'wet', wet: 0 }]); expect(stages({ blend: 7 })).toMatchObject([{ kind: 'wet', wet: 1 }]); }); it('a blend is as late as what it blends and rings as long', () => { const [limit] = fxChainOf({ pitch: [], stages: stagesOf('SoundFxLimiter', { wet: 0.5 }) }).stages.filter( (s) => s.kind !== 'tap', ); expect(limit).toMatchObject({ kind: 'wet' }); expect(stageLatency(limit!, 48000)).toBe(0.005); expect(fxRings({ pitch: [], stages: stagesOf('SoundFxLimiter', { wet: 0.5 }) })).toBe(true); expect(fxRings({ pitch: [], stages: stagesOf('SoundFxTremolo', { wet: 0.5 }) })).toBe(false); }); it('the wet of a Filter comes after the gain a shelf adds to it', () => { const f = line([{ type: 'SoundFxFilter', params: { filter: 'lowshelf' } }]); const filter = f.fx.graph.nodes.find((n) => n.type === 'SoundFxFilter')!; const keys = () => resolveDef(filter, f.fx.graph, graphLookup(f.project))!.inputs.map((p) => p.key); expect(keys()).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']); filter.params.filter = 'lowpass'; expect(keys()).toEqual(['in', 'cutoff', 'q', 'wet']); }); it('the wet of a Pitch takes its semitones from 0 up to all of them', () => { expect( pitchOf( fxChainOf( compileSoundFx(line([{ type: 'SoundFxPitch', params: { semitones: 12, wet: 0.5 } }]).fx.graph) .compiled!, ), ), ).toBe(6); expect( compileSoundFx(line([{ type: 'SoundFxPitch', params: { semitones: 12, wet: 0 } }]).fx.graph).compiled, ).toEqual(EMPTY_PLAN); const f = line([{ type: 'SoundFxPitch', params: { semitones: 12 } }]); const pitch = f.fx.graph.nodes.find((n) => n.type === 'SoundFxPitch')!; f.input.ports = [ { key: 'up', kind: 'number', default: 7 }, { key: 'blend', kind: 'number', default: 1 }, ]; f.edit.connect(f.input, 'blend', pitch, 'wet'); const plan = compileSoundFx(f.fx.graph).compiled!; expect(pitchOf(fxChainOf(plan))).toBe(12); expect(pitchOf(fxChainOf(plan, { blend: 0.25 }))).toBe(3); expect(fxChainOf(plan, { blend: 0 })).toMatchObject({ pitch: 0, shifted: true }); f.edit.connect(f.input, 'up', pitch, 'semitones'); const both = compileSoundFx(f.fx.graph).compiled!; expect(pitchOf(fxChainOf(both, { up: -10, blend: 0.5 }))).toBe(-5); }); it('a Delay crossfades: the sound alone at 0, both in full at 0.5, the echoes alone at 1', () => { expect([0, 0.25, 0.5, 0.75, 1].map(delayShares)).toEqual([ { dry: 1, echo: 0 }, { dry: 1, echo: 0.5 }, { dry: 1, echo: 1 }, { dry: 0.5, echo: 1 }, { dry: 0, echo: 1 }, ]); // An echo at wet 0.25 is half as loud as at 0.5, so one fewer of them is still heard. const tail = (mix: number) => fxTailOf({ pitch: 0, shifted: false, stages: [{ kind: 'delay', time: 1, feedback: 0.5, damping: 20000, mix, pingPong: false }], }); expect(tail(0.5)).toBe(10); expect(tail(0.25)).toBe(9); expect(tail(1)).toBe(10); }); }); describe('the tail of a voice (#324)', () => { const echo = (time: number, feedback: number, mix: number): FxStage => ({ kind: 'delay', time, feedback, damping: 20000, mix, pingPong: false, }); const chain = (...stages: FxStage[]): FxChain => ({ pitch: 0, shifted: false, stages }); it('is nothing without an echo', () => { expect(fxTailOf(DRY)).toBe(0); expect(fxTailOf(chain({ kind: 'gain', gain: 2 }))).toBe(0); expect(fxRings(EMPTY_PLAN)).toBe(false); }); it('lasts until the last echo is 60 dB below the sound', () => { expect(fxTailOf(chain(echo(0.25, 0, 0.5)))).toBe(0.25); // 0.5^9 is 2 in 1000, 0.5^10 one in 1000 and a bit less: ten echoes are heard. expect(fxTailOf(chain(echo(0.25, 0.5, 1)))).toBe(2.5); expect(fxTailOf(chain(echo(0.1, 0.95, 1)))).toBeCloseTo(13.5); expect(fxTailOf(chain(echo(0.25, 0.9, 0.0001)))).toBe(0); }); it('adds up echoes behind each other and takes the longer way of a Mix', () => { expect(fxTailOf(chain(echo(0.25, 0, 1), echo(0.5, 0, 1)))).toBe(0.75); const mixed: FxChain = { mix: 0.5, a: chain(echo(1, 0, 1)), b: chain(echo(0.25, 0, 1)), stages: [echo(0.5, 0, 1)], }; expect(fxTailOf(mixed)).toBe(1.5); }); it('is known from the plan before any value is: an echo rings, wherever it is', () => { const f = withParam('SoundFxDelay', 'mix', [{ key: 'wet', kind: 'number' }]); expect(fxRings(soundFxBuildFor(f.fx).compiled!)).toBe(true); const forked = { mix: { value: 0.5 }, a: EMPTY_PLAN, b: soundFxBuildFor(f.fx).compiled!, stages: [] }; expect(fxRings(forked)).toBe(true); }); }); /** * 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> }[]; times: (number | undefined)[]; } { const sink = { played: [] as { file: string; owner: string; fx: FxChain | undefined }[], tuned: [] as { voice: number; params: Readonly> }[], 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>, time?: number) => { sink.tuned.push({ voice, params }); sink.times.push(time); }, playNote: () => 0, playSequence: () => 0, stopSequence: () => {}, playClip: () => 0, stopPlayback: () => {}, release: () => {}, length: () => 0.25, stop: () => {}, tail: () => 0, stopVoice: () => {}, stopOwner: () => {}, setMix: () => {}, setMixVolume: () => {}, mixVolume: () => 1, resetMix: () => {}, tuneMix: () => {}, }; 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).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 the cutoff of a Filter as well', () => { const f = withParam('SoundFxFilter', 'cutoff', [{ key: 'bright', kind: 'number', default: 500 }]); const chain = fxChainOf(soundFxBuildFor(f.fx).compiled!, { bright: 2000 }); expect(chain.stages.filter((s) => s.kind !== 'tap')).toMatchObject([{ kind: 'pass', cutoff: 2000 }]); }); 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; 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); // Also by the scope a note's own sound FX goes by (#346), which a sound does not have. expect(audio.tuned).toEqual([{ voice: 1, params: { depth: 3, 'fx: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]); }); });