Blend two ways of a sound FX with a Mix node #316
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Successful in 15m30s

A sound FX was a single line from Input to Output, so two versions
of one sound could not be heard together. The new Mix node joins two
ways again, and each way can carry its own effects, its own Pitch
included.

- Mix has the audio inputs a and b and a number input mix (0 to 1,
  default 0.5). At 0 only a is heard, at 1 only b. Like every number
  it can come from a parameter of the Input and is held to its range.
- The compiled plan and the chain a voice gets are now a tree. A
  source carries a pitch and stages, a mix carries a, b and the
  stages after it. A Mix that ends at exactly 0 or 1 collapses to the
  one way heard, so the silent way is never worked out.
- A Pitch is still worked out on the sound as it starts (#312). Behind
  a Mix it goes to both of its sounds. An effect before a Pitch
  therefore acts as if it came after it, the node now says so.
- The player starts one source per pitch, all at once and all of the
  same length, and two ways at the same pitch share one. A mix becomes
  two gain nodes with 1 - mix and mix into a sum. A voice leaves the
  count when all of its sources have ended.
- stats().last reports pitches (one per source) instead of pitch.

Tests:
- soundfx.test.ts: 27 tests (+5), sound-library.test.ts: 12 (+2),
  node-catalog.test.ts: 368, all green. Typecheck, ESLint, Prettier
  clean.
- Counter-check: a Pitch behind the Mix given to a only, and b mixed
  at 1 - mix, each fails its new test.
- e2e 34 on melektaus-atx: ALL PASSED, 29 checks (+1). The new check
  plays the pitched way mixed with the dry Input in the browser: two
  sources at 5 and 0 semitones, as long as the sound itself.
- The full gate did not run before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 20:09:30 +02:00
1 parent 790e483198
commit 072537b2e8
6 files changed
+342 -76

No files matched your search

+9 -8
View File
@@ -41,10 +41,10 @@ function inputOutputs(node: GraphNode): Record<string, FxValue> {
}
/**
* A sound FX graph to the line a voice is sent along: walked back from the Output to the Input, every
* node on the way adding what it does. An input takes one edge, so the line never forks on its way
* to the Output; a node fed from nowhere is a problem rather than silence. A number fed from a
* parameter stays open in the plan until a voice is started (#311).
* A sound FX graph to the way a voice is sent: walked back from the Output to the Input, every node
* on the way adding what it does. An input takes one edge, so only a Mix joins two ways into one
* (#316); a node fed from nowhere is a problem rather than silence. A number fed from a parameter
* stays open in the plan until a voice is started (#311).
*/
export function compileSoundFx(graph: Graph): SoundFxBuild {
const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, {
@@ -53,10 +53,11 @@ export function compileSoundFx(graph: Graph): SoundFxBuild {
emit: (def, inputs, node) => {
if (node.type === SOUND_FX_INPUT) return inputOutputs(node);
const amounts: Record<string, FxAmount> = {};
for (const [key, value] of Object.entries(inputs)) if ('amount' in value) amounts[key] = value.amount;
const arriving = inputs.in;
const plan = arriving && 'plan' in arriving ? arriving.plan : EMPTY_PLAN;
return { out: { plan: def.emit(plan, amounts) } };
const sounds: Record<string, FxPlan> = {};
for (const [key, value] of Object.entries(inputs))
if ('amount' in value) amounts[key] = value.amount;
else sounds[key] = value.plan;
return { out: { plan: def.emit(sounds, amounts) } };
},
unfed: (node, port) => {
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
+75 -25
View File
@@ -15,12 +15,12 @@ export type FxStage =
/**
* A sound on its way through the graph: how many semitones it is played up or down, and what it
* passes, in order. `gain` is a factor, a filter's `gain` is in dB as Web Audio takes it.
* passes, in order. `gain` is a factor, a filter's `gain` is in dB as Web Audio takes it. Behind a
* Mix (#316) it is two sounds, `b` making up the share `mix` of what goes on through `stages`.
*/
export interface FxChain {
pitch: number;
stages: readonly FxStage[];
}
export type FxChain =
| { pitch: number; stages: readonly FxStage[] }
| { mix: number; a: FxChain; b: FxChain; stages: readonly FxStage[] };
export const DRY: FxChain = { pitch: 0, stages: [] };
@@ -41,10 +41,9 @@ export type FxStagePlan =
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number };
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export interface FxPlan {
pitch: readonly FxAmount[];
stages: readonly FxStagePlan[];
}
export type FxPlan =
| { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[] }
| { mix: FxAmount; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] };
export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
@@ -52,12 +51,13 @@ export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
export type FxParams = Readonly<Record<string, number>>;
/**
* A node of a sound FX graph (#311). It never runs: `emit` says what it does to the sound arriving
* at its one audio input, given the numbers at its other inputs. Input and Output have no effect.
* A node of a sound FX graph (#311). It never runs: `emit` says what it does to the sounds arriving
* at its audio inputs, by input key, given the numbers at its other inputs. Input and Output have no
* effect.
*/
export interface SoundFxNodeDef extends NodeDef {
category: 'sound';
emit(plan: FxPlan, amounts: Readonly<Record<string, FxAmount>>): FxPlan;
emit(sounds: Readonly<Record<string, FxPlan>>, amounts: Readonly<Record<string, FxAmount>>): FxPlan;
}
export function isSoundFxNodeDef(def: NodeDef): def is SoundFxNodeDef {
@@ -92,6 +92,18 @@ function held(amount: FxAmount | undefined, row: FlatDef<'number'>): FxAmount {
/** Whether an amount does nothing however the sound is played: a typed-in 0. */
const isNone = (amount: FxAmount) => amount.param === undefined && amount.value === 0;
const arriving = (sounds: Readonly<Record<string, FxPlan>>) => sounds.in ?? EMPTY_PLAN;
function withStages(plan: FxPlan, stages: readonly FxStagePlan[]): FxPlan {
return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] };
}
/** A pitch is worked out on the sound as it starts, so behind a Mix it goes to both sounds. */
function withPitch(plan: FxPlan, semitones: FxAmount): FxPlan {
if ('mix' in plan) return { ...plan, a: withPitch(plan.a, semitones), b: withPitch(plan.b, semitones) };
return { ...plan, pitch: [...plan.pitch, semitones] };
}
/** A parameter of the Input as the nodes it feeds see it. */
export function soundFxParamPort(port: DeclaredPort): PortDef {
return { key: port.key, label: port.key, type: 'number' };
@@ -109,7 +121,7 @@ export const SoundFxInput: SoundFxNodeDef = {
declaresPorts: 'outputs',
portKinds: ['number'],
declaredPort: (port) => soundFxParamPort(port),
emit: (plan) => plan,
emit: () => EMPTY_PLAN,
};
export const SoundFxOutput: SoundFxNodeDef = {
@@ -120,7 +132,7 @@ export const SoundFxOutput: SoundFxNodeDef = {
inputs: [audioIn],
outputs: [],
params: [],
emit: (plan) => plan,
emit: arriving,
};
const GAIN = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
@@ -134,9 +146,9 @@ export const SoundFxGain: SoundFxNodeDef = {
inputs: [audioIn, numberIn(GAIN)],
outputs: [audioOut],
params: [],
emit: (plan, amounts) => {
emit: (sounds, amounts) => {
const db = held(amounts.gain, GAIN);
return isNone(db) ? plan : { ...plan, stages: [...plan.stages, { kind: 'gain', db }] };
return withStages(arriving(sounds), isNone(db) ? [] : [{ kind: 'gain', db }]);
},
};
@@ -147,13 +159,13 @@ export const SoundFxPitch: SoundFxNodeDef = {
label: 'Pitch',
category: 'sound',
description:
'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length, and the pitch applies to the whole sound wherever the node sits in the line.',
'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length. The pitch is worked out on the sound as it starts, so an effect before the Pitch acts as if it came after it.',
inputs: [audioIn, numberIn(SEMITONES)],
outputs: [audioOut],
params: [],
emit: (plan, amounts) => {
emit: (sounds, amounts) => {
const semitones = held(amounts.semitones, SEMITONES);
return isNone(semitones) ? plan : { ...plan, pitch: [...plan.pitch, semitones] };
return isNone(semitones) ? arriving(sounds) : withPitch(arriving(sounds), semitones);
},
};
@@ -179,14 +191,35 @@ export const SoundFxEq: SoundFxNodeDef = {
inputs: [audioIn, ...EQ_ROWS.map(numberIn)],
outputs: [audioOut],
params: [],
emit: (plan, amounts) => {
emit: (sounds, amounts) => {
const stages: FxStagePlan[] = [];
EQ_BANDS.forEach((band, i) => {
const gain = held(amounts[band.key], EQ_ROWS[i]!);
if (!isNone(gain))
stages.push({ kind: 'filter', filter: band.filter, frequency: band.frequency, gain, q: PEAK_Q });
});
return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] };
return withStages(arriving(sounds), stages);
},
};
const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
export const SoundFxMix: SoundFxNodeDef = {
type: 'SoundFxMix',
label: 'Mix',
category: 'sound',
description:
'Blends two sounds: at 0 only a is heard, at 1 only b, at 0.5 half of each. Both ways can start at the same Input, each with its own effects, its own Pitch included.',
inputs: [{ key: 'a', label: 'a', type: 'audio' }, { key: 'b', label: 'b', type: 'audio' }, numberIn(MIX)],
outputs: [audioOut],
params: [],
emit: (sounds, amounts) => {
const mix = held(amounts.mix, MIX);
const a = sounds.a ?? EMPTY_PLAN;
const b = sounds.b ?? EMPTY_PLAN;
if (mix.param === undefined && mix.value <= 0) return a;
if (mix.param === undefined && mix.value >= 1) return b;
return { mix, a, b, stages: [] };
},
};
@@ -196,6 +229,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxGain,
SoundFxPitch,
SoundFxEq,
SoundFxMix,
];
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
@@ -205,8 +239,7 @@ function amountOf(amount: FxAmount, params: FxParams): number {
return Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, given));
}
/** The chain one voice is sent along: the plan with every parameter filled in. */
export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
function stagesOf(plan: FxPlan, params: FxParams): FxStage[] {
const stages: FxStage[] = [];
for (const stage of plan.stages) {
if (stage.kind === 'gain') {
@@ -217,6 +250,23 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
const gain = amountOf(stage.gain, params);
if (gain !== 0) stages.push({ ...stage, gain });
}
const pitch = plan.pitch.reduce((sum, amount) => sum + amountOf(amount, params), 0);
return { pitch, stages };
return stages;
}
/**
* The chain one voice is sent along: the plan with every parameter filled in. A Mix that ends at 0
* or 1 is left out with the sound it silences, so that one is not even started.
*/
export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
const stages = stagesOf(plan, params);
if (!('mix' in plan)) {
const pitch = plan.pitch.reduce((sum, amount) => sum + amountOf(amount, params), 0);
return { pitch, stages };
}
const mix = amountOf(plan.mix, params);
if (mix <= 0 || mix >= 1) {
const heard = fxChainOf(mix <= 0 ? plan.a : plan.b, params);
return { ...heard, stages: [...heard.stages, ...stages] };
}
return { mix, a: fxChainOf(plan.a, params), b: fxChainOf(plan.b, params), stages };
}