A Mix crossfaded: at 0.5 each sound was at half. Layering two sounds in an instrument now goes through a Mix (076c564), so the user asked for a second curve where each side stays full up to the middle and only fades on the other half. At 0.25 a is full and b half, at 0.5 both are full, at 0.75 a is half and b full. - The Mix has a setting Curve with Crossfade (default) and Both at center. A plan and a chain only say `curve` where it is `both`, so old projects and their compiled plans stay as they were. - A share is not a straight line of `mix` with that curve. Two ramps of their own would dip to half each when `mix` glides across the middle. So a ConstantSourceNode carries `mix` on the audio thread, and two WaveShaperNodes make the shares from it. The knob of such a Mix is that one source. - The source is started with the voice and stopped when it is cut or ends by itself, so it does not run on after the voice. - A sound FX node in an instrument keeps the curve of a Mix inside it. - At 0.5 both sounds are added in full, up to +6 dB where both are loud. That is what several edges into the Output did before. The wiki says so. Found on the way: the suite of23bdc3f(task 529) failed the Step check in e2e 34. It timed the jump from the start of the voice, but a timed change runs from when it arrives. In CI that was 80 ms later, and the pitch rightly held to 2.08 s. - stats() says `tunedAt`, the second of the audio clock the last change moved a voice. A change that moves nothing, as On update sends every frame, leaves it. - The Step check times its readings from there. Tests: - sound-library.test.ts: both shares shaped from one source, a glide moves only the source, the source stops with a cut and with an end by itself (50 tests, 2 new) - soundfx.test.ts: curve only where set, one sound at 0 (41 tests) - instrument.test.ts: the curve through a sound FX node (34 tests) - Counter-checks: dropping the curve in throughFx and not stopping the source in done() each turn their new test red - e2e 34 on melektaus-atx: ALL PASSED, 48 checks (+1). Rendered offline in Chrome, a and b come to 1/0, 1/0.5, 1/1, 0.5/1 and 0/1 at mix 0 to 1, and a glide from 0 to 1 reads the same at 0.25, 0.5 and 0.75 s. Step jumped 2.04 s after the change. - Gate on melektaus-atx: CHECK EXIT 0, 244 test files, 3793 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
698 lines
30 KiB
TypeScript
698 lines
30 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 {
|
||
dampingCutoff,
|
||
DRY,
|
||
EMPTY_PLAN,
|
||
type FxChain,
|
||
fxChainOf,
|
||
type FxDelay,
|
||
type FxPlan,
|
||
fxRings,
|
||
type FxStage,
|
||
fxTailOf,
|
||
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 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 = <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 | boolean | string> }[]) {
|
||
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',
|
||
'SoundFxDelay',
|
||
]);
|
||
f.edit.addNode('SoundFxInput');
|
||
f.edit.addNode('SoundFxOutput');
|
||
expect(ids()).toEqual(['SoundFxGain', 'SoundFxPitch', 'SoundFxEq', 'SoundFxMix', 'SoundFxDelay']);
|
||
|
||
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(gainOf(chain.stages[0])).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(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 → 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('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 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');
|
||
});
|
||
});
|
||
|
||
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('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<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);
|
||
},
|
||
playNote: () => 0,
|
||
release: () => {},
|
||
length: () => 0.25,
|
||
stop: () => {},
|
||
tail: () => 0,
|
||
stopVoice: () => {},
|
||
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]);
|
||
});
|
||
});
|