Two things came up on the new tab. Floated and sized down, it spilled its knobs out of its window, and sized up it left the window empty. Auto-gain lifted far too much: it brought 0 dB back to 0 dB, which at -40 dB, 4:1 and knee 6 is +28.1 dB, so a sound at -24 dB came out at -6 dB. And as a knob from 0 to 1 it blended between makeup and auto-gain, so at 1 the makeup knob did nothing. Layout: - The knobs stay on the bottom line, centred, with the help line above them. - The levels fill the height left above. The curve is a square at their right, centred up and down. It grows with the tab from 180 to 360 px and takes no more than 40 % of its width (picturesLayout). - A floated window cannot be sized below what its pane needs. A pane says so in its minWidth and minHeight, and the Compressor's is every knob and the smallest pictures. Other panes keep 320 x 200. - A pane put into an open window that is too small grows the window. Auto-gain, as decided in #378: - A Compressor's auto-gain gives back what it takes from a sound at -18 dB, so that one comes out as loud as it went in. At the numbers above that is +14.6 dB. - Auto-gain is a switch on the Compressor, the Limiter and the Saturation alike, a setting and no longer an input. The makeup or output is added on top of it instead of being blended with it. - A project from before reads a number as on above 0. Nodes never set keep their defaults: off on the Compressor, on on the other two. - The tab shows the switch at the right of the knobs, with the dB it gives below it. With it on, the curve has a line at -18 dB from the curve up to the diagonal. - Every level reading keeps the gain of its time. Before, the whole picture was read by the current makeup, so after a change the tinting of the last seconds was wrong or missing. Findings: - My first e2e run of the layout clicked Dock where it was before the window was sized up, so the window stayed open and the next check went red. The check reads the button again. Tests: - Gate (melektaus-atx): 282 files, 4344 tests (6 new) - e2e 34 (melektaus-atx): ALL PASSED, 118 checks (2 new) - Offline in Chrome: a sine at -18 dB through -24 dB and 4:1 came out at -17.92 dB with auto-gain on and -22.42 dB with it off. - Counter-checks: the curve set at the top instead of the middle, and every reading read at a gain of 0, each fail exactly their test. - Looked at the window in screenshots: at its least every knob and the switch show. At 1280 x 797 the levels fill the height, the curve is centred at the right and the knobs are at the bottom. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1158 lines
50 KiB
TypeScript
1158 lines
50 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)', () => {
|
||
// The taps a Compressor keeps for its tab do nothing to the sound (#378).
|
||
const at = (type: string, params?: Record<string, number | boolean>) =>
|
||
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages.filter(
|
||
(s) => s.kind !== 'tap',
|
||
);
|
||
|
||
it('tap a Compressor on both sides for its tab, a Limiter not (#378)', () => {
|
||
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(['limit']);
|
||
});
|
||
|
||
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<string, string | number | boolean>) =>
|
||
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<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!;
|
||
const stages = (params?: Record<string, number>) =>
|
||
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;
|
||
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]);
|
||
});
|
||
});
|