Give every sound FX effect a Bypass and a Wet (#354)

A node could only be taken out of a sound FX by unplugging it, and
only six of them could be blended with the sound they get. Every
effect now has both, in the sound FX graph and in the instrument
graph alike. The Mix, the Input and the Output have neither.

- Bypass is a checkbox setting. A bypassed node is left out when
  the graph is compiled, so it costs nothing and holds nothing back.
  The graph draws it faded, and its body says "Bypass: true".
- Wet is a number input like the others. It can be a parameter, be
  moved by Set sound FX parameters and, where the node allows it for
  its other numbers, follow an Envelope.
- A blend is a stage of its own (`wet`) around what the node adds.
  The sound as it came in is held back as far as the node is late,
  so a Compressor, a Limiter or a Vibrato do not comb it out.
- The six nodes that had a Mix keep their key `mix` and only call
  it Wet. Saved values, parameters and edges stay as they were.
- The Delay crossfades now: the sound alone at 0, the sound and its
  echoes in full at 0.5, the echoes alone at 1. Before, the sound
  was always in full and `mix` was the echoes.
- The Pitch takes its semitones by Wet instead of blending, through
  a share `times` on the amount that an instrument binds as well.

Findings:

- A Delay saved before this sounds different. It sounds the same at
  half its old value. The two chip presets with a Delay (Echo Lead,
  Bell) are halved to keep their sound.
- Project cbbf160bb7e8 has two Delays (mix 1 and 0.5). They are
  left as they are.

Tests:

- Unit: soundfx 65 (8 new), sound-library 107 (3 new), instrument
  35 (1 new), envelope 13. Two delay tests and the filter ports test
  follow the new numbers.
- Counter-checks: 8, each turns its test red.
- e2e 34 (melektaus-atx): ALL PASSED, 83 checks (2 new). The
  ping-pong check at wet 0.25 measures what it did at mix 0.5.
- Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4154 tests.
- After the gate the derived ports of a blend are asked for once
  instead of twice: typecheck, lint and the three unit files green
  again (113 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 08:20:52 +02:00
1 parent c95fbc167e
commit 7a17030472
13 files changed
+978 -385

No files matched your search

+2 -2
View File
@@ -116,7 +116,7 @@ describe('a Filter', () => {
it('has a gain only as a shelf or a peak, and a slope only where it gets steeper (#318)', () => {
const peak = filterIn({ filter: 'peaking', gain: 6, slope: '48' });
const def = (node: GraphNode, fx: SoundFxAsset) => resolveDef(node, fx.graph, NO_LOOKUP)!;
expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain']);
expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']);
expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']);
expect(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[0]).toMatchObject({
stages: 1,
@@ -124,7 +124,7 @@ describe('a Filter', () => {
});
const notch = filterIn({ filter: 'notch', gain: 6, slope: '48' });
expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q']);
expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'wet']);
expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]);
// A gain typed in before the type changed does nothing.
expect(fxChainOf(compileSoundFx(notch.fx.graph).compiled!).stages[0]).toMatchObject({
+40
View File
@@ -403,6 +403,46 @@ describe('compiling a note', () => {
expect(callProblem(through, lookup, f.instrument.id)).toBe('No sound FX picked');
});
it('binds the wet of a sound FX it sends a sound through, of a blend and of a Pitch (#354)', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
// The FX: Input, a Gain and a Pitch, both at the wet of its parameter "blend", Output.
const made = new CreateAssetCommand('soundfx', 'Half');
f.stack.execute(made);
const fx = made.asset as SoundFxAsset;
const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset);
const input = fxEdit.addNode('SoundFxInput');
f.stack.execute(new AddPortCommand(fx.id, input.id, { key: 'blend', kind: 'number' }));
f.stack.execute(new SetPortDefaultCommand(fx.id, input.id, 'blend', 0.5));
const gain = fxEdit.addNode('SoundFxGain', { gain: -6 });
const pitch = fxEdit.addNode('SoundFxPitch', { semitones: 12 });
const fxOut = fxEdit.addNode('SoundFxOutput');
fxEdit.wire([
[input, 'out', gain, 'in'],
[input, 'blend', gain, 'wet'],
[gain, 'out', pitch, 'in'],
[input, 'blend', pitch, 'wet'],
[pitch, 'out', fxOut, 'in'],
]);
const sample = nodeOf(f, 'InstrumentSample');
const output = nodeOf(f, 'InstrumentOutput');
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
const through = f.edit.addNode('InstrumentSoundFx', { fx: fx.id });
f.edit.wire([
[sample, 'out', through, 'in'],
[through, 'out', output, 'in'],
]);
const params = f.edit.addNode('InstrumentParameters');
f.stack.execute(new AddPortCommand(f.instrument.id, params.id, { key: 'dry', kind: 'number' }));
f.edit.connect(params, 'dry', through, 'blend');
const bound = { value: 0, param: 'dry', min: 0, max: 1 };
const played = way(compile(f, 60).layers[0])!;
expect(played.stages).toEqual([
{ kind: 'wet', wet: bound, stages: [{ kind: 'gain', db: { value: -6 } }] },
]);
expect(played.pitch).toEqual([{ value: 12, times: bound }]);
});
it('keeps the curve of a Mix inside a sound FX it sends a sound through', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
+158
View File
@@ -25,7 +25,9 @@ import {
type FxStage,
fxTailOf,
type FxVoice,
delayShares,
impulsesOf,
SOUND_FX_NODES,
SoundFxReverb,
stageLatency,
} from '../../src/core/soundfx/nodes';
@@ -582,6 +584,162 @@ describe('a Reverb in a sound FX graph (#328)', () => {
});
});
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',
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: Record<string, number | boolean | string> = {}) =>
compileSoundFx(line([{ type, params }]).fx.graph).compiled!.stages;
it('are on every effect, the wet of six of them the "mix" they had, and on nothing else', () => {
for (const def of SOUND_FX_NODES) {
const wet = EFFECTS[def.type];
const inputs = wet ? resolvedInputs(def.type) : def.inputs;
expect([def.type, def.params.some((p) => p.key === 'bypass')]).toEqual([def.type, wet !== undefined]);
expect([def.type, inputs.filter((p) => p.label === 'Wet').map((p) => p.key)]).toEqual([
def.type,
wet ? [wet] : [],
]);
expect([def.type, inputs.some((p) => p.label === 'Mix')]).toEqual([
def.type,
def.type === 'SoundFxMix',
]);
}
expect(Object.keys(EFFECTS)).toHaveLength(15);
});
it('bypassed, an effect is left out as if it were not there, and its node is drawn faded', () => {
for (const type of Object.keys(EFFECTS)) {
const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50 };
expect([type, stagesOf(type, on).length > 0 || type === 'SoundFxPitch']).toEqual([type, true]);
expect([
type,
compileSoundFx(line([{ type, params: { ...on, bypass: true } }]).fx.graph).compiled,
]).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',