Both were asked for in #367 "for later". The node of a Parametric EQ now shows its curve in small, and its tab shows what passes the node while it is seen. #375, the curve in the node: - NodeDef has an optional `picture`: a height, and shapes made from the settings as plain point lists (guide, area, line). It is general, the Parametric EQ is its first user. - NodeView draws it in a box below the rows, again only when the box or the settings change. A drag in the EQ tab changes them, so the node follows it live. - Without a band the box stays, flat on its 0 dB line, so the node does not grow at the first band. - The axes of the curve, `eqCurvePoints` and `fillShapes` move from editor/soundfx/eqView to core/soundfx/parametricEq. Tab and node draw the sum from the same function. #374, the spectrum: - A node marked `tapped` gets a `tap` stage in front of its own stages and one behind them when compiled, in a sound FX and in an instrument alike. Its id is asset and node, since a copied asset keeps its node ids. - A tap is a gain of 1. SpectrumTaps (new, viewport/spectrum.ts) holds the tap of every voice and bus while it plays. - While the EQ tab is seen, in the tab row or in a window, the taps of that node feed one analyser per side (8192 points). Nothing is made before a tap of the node plays, so no AudioContext is opened early. - The tab draws both behind the curve, on a scale of 0 to -96 dBFS: the sound coming in faint and grey, the sound going on in the colour of a sound port. The analyser smooths it, and once the sound has gone the sum still hands it silence, so it falls away. - Once the tab is not seen, Editor.tick lets the analysers go, like the meters of the Song tab. - A tap does nothing to the sound, so `stats().last.stages` leaves it out, and a bypassed EQ keeps its taps. Decided on my own: - Every sound passing the node counts: Play of the sound FX tab, an instrument's keys, a song, a run. The issue proposed that. - The spectrum has no labels of its own. Its scale is in the wiki. Tests: Gate (melektaus-atx) 275 files, 4247 tests (11 new), EXIT 0 e2e 34 (melektaus-atx) ALL PASSED, 98 checks (2 new), twice Counter-checks, each put back by copy and compared with cmp: - The node's curve drawn without its bands: 2 unit tests fail. - The spectrum without the loudest bin between two points: 1 fails. - SpectrumTaps.stop leaving the taps linked: 1 fails. - Taps that never feed the analyser: e2e 34 fails in exactly the spectrum check (frames 0). Run in the container only. The screenshots of the second run were looked at: the blip as one peak behind the curve, and the dip at 4 kHz in the node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1122 lines
49 KiB
TypeScript
1122 lines
49 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 { 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 = <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?: 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 };
|
||
}
|
||
|
||
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',
|
||
'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',
|
||
'SoundFxEq',
|
||
'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: '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('a Bitcrusher in a sound FX graph (#322)', () => {
|
||
it('crushes at its defaults and holds its numbers to their rows', () => {
|
||
const at = (params?: Record<string, number>) =>
|
||
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<string, string | number>) =>
|
||
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<string, number>) =>
|
||
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)', () => {
|
||
const at = (type: string, params?: Record<string, number>) =>
|
||
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages;
|
||
|
||
it('compress at their defaults, the gain after it worked out from makeup and auto-gain', () => {
|
||
const [stage] = at('SoundFxCompressor', { threshold: -12, knee: 0, ratio: 4, autoGain: 1 });
|
||
expect(stage).toMatchObject({
|
||
kind: 'compress',
|
||
threshold: -12,
|
||
knee: 0,
|
||
ratio: 4,
|
||
attack: 0.003,
|
||
release: 0.25,
|
||
});
|
||
// 9 dB of full auto-gain, less the 5.4 dB the node adds itself.
|
||
expect(20 * Math.log10((stage as { gain: number }).gain)).toBeCloseTo(3.6, 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 }]);
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe('a Saturation in a sound FX graph (#323)', () => {
|
||
const at = (params?: Record<string, string | number>) =>
|
||
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('blends its output and its auto-gain, and is late by the oversampling', () => {
|
||
const [stage] = at({ curve: 'hard', drive: 24, output: 6, autoGain: 0.5 });
|
||
const levelDb = 20 * Math.log10((stage as { level: number }).level);
|
||
expect(levelDb).toBeCloseTo(0.5 * 6 + 0.5 * saturationAutoGain('hard', 24), 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<string, string | number>) =>
|
||
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<string, string | number>) =>
|
||
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<string, 'wet' | 'mix'> = {
|
||
SoundFxEq: 'wet',
|
||
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(16);
|
||
});
|
||
|
||
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, low: 3, 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 eq = stagesOf('SoundFxEq', { low: 6, high: -3, wet: 0.5 });
|
||
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!;
|
||
expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]);
|
||
expect(fxChainOf(plan, { blend: 0 }).stages).toMatchObject([{ kind: 'wet', wet: 0 }]);
|
||
expect(fxChainOf(plan, { blend: 7 }).stages).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;
|
||
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<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,
|
||
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<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);
|
||
// 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]);
|
||
});
|
||
});
|