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
+337 -71

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)) } };
+73 -23
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 });
}
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 };
}
+66 -22
View File
@@ -6,15 +6,29 @@ const DEFAULT_BASE = '/game-maker/data/sounds';
/** How many pitched copies are kept at once. */
const SHIFTED_KEPT = 32;
/** One sound in flight: its source, its own gain, the file it came from and who is making it. */
/**
* One sound in flight: its sources, its own gain, the file it came from and who is making it. A
* voice has a source per pitch its sound FX plays the file at (#316), started together and as long
* as each other.
*/
interface Voice {
file: string;
/** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */
owner: string;
source: AudioBufferSourceNode;
sources: AudioBufferSourceNode[];
gain: GainNode;
}
/** What one voice is wired from: the file, and its sources by pitch so far. */
interface Wiring {
context: AudioContext;
file: string;
buffer: AudioBuffer;
loop: boolean;
sources: Map<number, AudioBufferSourceNode>;
stages: number;
}
/**
* Plays uploaded sounds, keyed by file name – the texture library's twin on the audio side. Files
* are content addressed, so a decoded buffer can never go stale and nothing is evicted.
@@ -157,7 +171,7 @@ export class SoundLibrary implements AudioSink {
/** Ends these voices now: `onended` only fires next frame and the counts have to be right here. */
private silence(going: Voice[]): void {
for (const voice of going) voice.source.stop();
for (const voice of going) for (const source of voice.sources) source.stop();
this.voices = this.voices.filter((voice) => !going.includes(voice));
this.stopped += going.length;
}
@@ -174,30 +188,58 @@ export class SoundLibrary implements AudioSink {
// A context starts suspended where the page has not been interacted with; without this the
// voice would run silently and end as if it had played.
if (context.state === 'suspended') void context.resume();
const source = context.createBufferSource();
const played = fx.pitch === 0 ? buffer : this.pitched(context, file, buffer, fx.pitch, loop);
source.buffer = played;
source.loop = loop;
const gain = context.createGain();
gain.gain.value = Math.max(0, Math.min(1, volume));
const wiring: Wiring = { context, file, buffer, loop, sources: new Map(), stages: 0 };
this.wire(wiring, fx).connect(gain).connect(context.destination);
const sources = [...wiring.sources.values()];
this.last = {
file,
volume: gain.gain.value,
loop,
pitch: fx.pitch,
stages: fx.stages.length,
duration: played.duration,
pitches: [...wiring.sources.keys()],
stages: wiring.stages,
duration: sources[0]!.buffer!.duration,
};
let tail: AudioNode = source;
for (const stage of fx.stages) tail = tail.connect(fxNode(context, stage));
tail.connect(gain).connect(context.destination);
const voice: Voice = { file, owner, source, gain };
const voice: Voice = { file, owner, sources, gain };
let playing = sources.length;
for (const source of sources)
source.onended = () => {
this.voices = this.voices.filter((other) => other !== voice);
if (--playing === 0) this.voices = this.voices.filter((other) => other !== voice);
};
this.voices.push(voice);
this.started += 1;
source.start();
for (const source of sources) source.start();
}
/**
* The nodes a chain becomes, up to the one that carries its sound on. Two ways at the same pitch
* share a source; a Mix weighs each of its sounds by its share before they are summed.
*/
private wire(wiring: Wiring, chain: FxChain): AudioNode {
const { context } = wiring;
let tail: AudioNode;
if ('mix' in chain) {
tail = context.createGain();
this.wire(wiring, chain.a)
.connect(gainNode(context, 1 - chain.mix))
.connect(tail);
this.wire(wiring, chain.b).connect(gainNode(context, chain.mix)).connect(tail);
} else tail = this.source(wiring, chain.pitch);
for (const stage of chain.stages) tail = tail.connect(fxNode(context, stage));
wiring.stages += chain.stages.length;
return tail;
}
private source(wiring: Wiring, pitch: number): AudioBufferSourceNode {
const held = wiring.sources.get(pitch);
if (held) return held;
const { context, file, buffer, loop } = wiring;
const source = context.createBufferSource();
source.buffer = pitch === 0 ? buffer : this.pitched(context, file, buffer, pitch, loop);
source.loop = loop;
wiring.sources.set(pitch, source);
return source;
}
/**
@@ -244,19 +286,21 @@ interface LastVoice {
file: string;
volume: number;
loop: boolean;
/** Semitones, and how many gain and filter nodes it passed. */
pitch: number;
/** Semitones of each source, one per pitch (#316), and how many gain and filter nodes it passed. */
pitches: number[];
stages: number;
/** Seconds of what was played: a pitched voice is as long as the sound itself (#312). */
duration: number;
}
function fxNode(context: AudioContext, stage: FxStage): AudioNode {
if (stage.kind === 'gain') {
function gainNode(context: AudioContext, level: number): GainNode {
const gain = context.createGain();
gain.gain.value = stage.gain;
gain.gain.value = level;
return gain;
}
}
function fxNode(context: AudioContext, stage: FxStage): AudioNode {
if (stage.kind === 'gain') return gainNode(context, stage.gain);
const filter = context.createBiquadFilter();
filter.type = stage.filter;
filter.frequency.value = stage.frequency;
+95 -10
View File
@@ -12,7 +12,7 @@ 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 { DRY, EMPTY_PLAN, type FxChain, fxChainOf } from '../../src/core/soundfx/nodes';
import { DRY, EMPTY_PLAN, type FxChain, fxChainOf, type FxPlan } from '../../src/core/soundfx/nodes';
import { deserializeProject } from '../../src/core/store/serialize';
import type { AudioSink } from '../../src/runtime/systems/Audio';
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
@@ -29,6 +29,9 @@ import {
const FILE = `${'a1'.repeat(32)}.wav`;
/** 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);
@@ -94,10 +97,17 @@ describe('the sound FX asset', () => {
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']);
expect(ids()).toEqual([
'SoundFxInput',
'SoundFxOutput',
'SoundFxGain',
'SoundFxPitch',
'SoundFxEq',
'SoundFxMix',
]);
f.edit.addNode('SoundFxInput');
f.edit.addNode('SoundFxOutput');
expect(ids()).toEqual(['SoundFxGain', 'SoundFxPitch', 'SoundFxEq']);
expect(ids()).toEqual(['SoundFxGain', 'SoundFxPitch', 'SoundFxEq', 'SoundFxMix']);
const behavior = makeBehavior(f, 'Logic');
expect(paletteEntries(f.project, behavior).some((e) => e.def.category === 'sound')).toBe(false);
@@ -133,7 +143,7 @@ describe('compiling a sound FX graph', () => {
const build = compileSoundFx(f.fx.graph);
expect(build.effects).toBe(4);
const chain = fxChainOf(build.compiled!);
expect(chain.pitch).toBe(7);
expect(pitchOf(chain)).toBe(7);
expect(chain.stages).toHaveLength(3);
expect(chain.stages[0]!.kind).toBe('gain');
expect(chain.stages[0]!.gain).toBeCloseTo(0.501, 3);
@@ -164,6 +174,81 @@ describe('compiling a sound FX graph', () => {
});
});
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('takes its share from a parameter, held between 0 and 1', () => {
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({ pitch: 12 });
expect(fxChainOf(plan, { blend: 3 })).toMatchObject({ 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');
});
});
/** Records what it was asked to play, with the line of effects. */
function fakeAudio(): AudioSink & { played: { file: string; fx: FxChain | undefined }[] } {
const sink = {
@@ -237,11 +322,11 @@ 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(plan.pitch).toEqual([{ value: -12, param: 'depth', min: -64, max: 64 }]);
expect(fxChainOf(plan).pitch).toBe(-12);
expect(fxChainOf(plan, { depth: 5 }).pitch).toBe(5);
expect(fxChainOf(plan, { depth: 100 }).pitch).toBe(64);
expect(fxChainOf(plan, { other: 3 }).pitch).toBe(-12);
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', () => {
@@ -309,7 +394,7 @@ describe('the parameters of a sound FX', () => {
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(fxChainOf(soundFxBuildFor(f.fx).compiled!).pitch).toBe(4);
expect(pitchOf(fxChainOf(soundFxBuildFor(f.fx).compiled!))).toBe(4);
f.stack.undo();
expect(f.input.ports).toEqual([{ key: 'depth', kind: 'number' }]);
});
+61 -3
View File
@@ -27,6 +27,8 @@ interface FakeSource {
const sources: FakeSource[] = [];
/** Every `connect` made, as "from > to" by the kind of node – the order a voice is wired in. */
const wires: string[] = [];
/** The level of every gain node made, in the order they were made. */
const gains: { value: number }[] = [];
const filters: {
type: string;
frequency: { value: number };
@@ -90,7 +92,11 @@ function fakeContext() {
sources.push(source);
return source;
},
createGain: () => wired('gain', { gain: { value: 1 } }),
createGain: () => {
const gain = wired('gain', { gain: { value: 1 } });
gains.push(gain.gain);
return gain;
},
createBiquadFilter: () => {
const filter = wired('filter', {
type: '',
@@ -111,6 +117,7 @@ beforeEach(() => {
sources.length = 0;
wires.length = 0;
filters.length = 0;
gains.length = 0;
decodes = [];
vi.stubGlobal(
'AudioContext',
@@ -158,7 +165,7 @@ describe('the sound library', () => {
file: FILE,
volume: 0.25,
loop: true,
pitch: 0,
pitches: [0],
stages: 0,
duration: 1.5,
});
@@ -184,7 +191,7 @@ describe('the sound library', () => {
expect(filters).toMatchObject([
{ type: 'lowshelf', frequency: { value: 80 }, gain: { value: 6 }, Q: { value: 1 } },
]);
expect(library.stats().last).toMatchObject({ pitch: 12, stages: 2 });
expect(library.stats().last).toMatchObject({ pitches: [12], stages: 2 });
});
it('pitches a voice on a copy of its length rather than by speed, and keeps the copy (#312)', async () => {
@@ -201,6 +208,57 @@ describe('the sound library', () => {
expect([up!.duration, down!.duration]).toEqual([dry!.duration, dry!.duration]);
});
it('mixes two sounds by their shares, a source per pitch, started and stopped together (#316)', async () => {
const library = new SoundLibrary();
await play(
library,
FILE,
{},
{
mix: 0.25,
a: { pitch: 12, stages: [] },
b: { pitch: 0, stages: [{ kind: 'gain', gain: 0.5 }] },
stages: [{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 }],
},
);
expect(wires).toEqual([
'source > gain',
'gain > gain',
'source > gain',
'gain > gain',
'gain > gain',
'gain > filter',
'filter > gain',
'gain > destination',
]);
expect(gains.map((g) => g.value)).toEqual([1, 1, 0.75, 0.5, 0.25]);
expect(sources.map((s) => s.started)).toEqual([true, true]);
expect(library.stats()).toMatchObject({ playing: 1, started: 1, last: { pitches: [12, 0], stages: 2 } });
sources[0]!.onended!();
expect(library.stats().playing).toBe(1);
library.stop(FILE);
expect(sources.map((s) => s.stopped)).toEqual([true, true]);
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
});
it('starts one source for two ways at the same pitch', async () => {
const library = new SoundLibrary();
await play(
library,
FILE,
{},
{
mix: 0.5,
a: { pitch: 0, stages: [] },
b: { pitch: 0, stages: [{ kind: 'gain', gain: 0.5 }] },
stages: [],
},
);
expect(sources).toHaveLength(1);
expect(wires.filter((w) => w.startsWith('source'))).toEqual(['source > gain', 'source > gain']);
});
it('a stop while the bytes are in flight keeps the sound from starting afterwards', async () => {
const library = new SoundLibrary();
await play(library, FILE, { hold: true });
+33 -5
View File
@@ -301,7 +301,7 @@ export async function sound(t) {
const fxPalette = await json('__gm.overlayTexts()');
check(
'its palette offers the sound effects and no behavior node',
['Input', 'Output', 'Gain', 'Pitch', '5-Band-EQ'].every((label) => fxPalette.includes(label)) &&
['Input', 'Output', 'Gain', 'Pitch', '5-Band-EQ', 'Mix'].every((label) => fxPalette.includes(label)) &&
!fxPalette.includes('Branch'),
JSON.stringify(fxPalette.slice(0, 12)),
);
@@ -340,7 +340,7 @@ export async function sound(t) {
check(
'the tab’s Play sends the sound through the graph: an octave down, as long as before, one gain stage',
heardFx.last?.file === blip.file &&
heardFx.last.pitch === -12 &&
heardFx.last.pitches.join() === '-12' &&
heardFx.last.duration === blipSeconds &&
heardFx.last.stages === 1,
JSON.stringify({ ...heardFx, blipSeconds }),
@@ -352,10 +352,38 @@ export async function sound(t) {
const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started);
check(
'the header plays with the value its depth field is set to',
heardParam.last?.pitch === 5 && heardParam.last.stages === 1,
heardParam.last?.pitches.join() === '5' && heardParam.last.stages === 1,
JSON.stringify(heardParam),
);
await evaluate('__gm.sound.stopAll()');
// #316: the Pitch → Gain way and the dry Input meet in a Mix – two sources, one per pitch.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-mix', 'SoundFxMix', 550, 0, { mix: 0.5 }))});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e5', 'fx-gain', 'out', 'fx-mix', 'a'))},
${JSON.stringify(edge('fx-e6', 'fx-in', 'out', 'fx-mix', 'b'))},
${JSON.stringify(edge('fx-e7', 'fx-mix', 'out', 'fx-out', 'in'))},
);`);
const mixInfo = await json('__gm.soundfx.info()');
const beforeMix = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const heardMix = await waitFor(stats, (s) => s.started > beforeMix.started);
check(
'a Mix of the pitched way and the dry Input plays one voice from two sources of the same length',
mixInfo === '3 effects' &&
heardMix.last?.pitches.join() === '5,0' &&
heardMix.last.stages === 1 &&
heardMix.last.duration === blipSeconds,
JSON.stringify({ mixInfo, ...heardMix }),
);
await evaluate('__gm.sound.stopAll()');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-mix');
graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
@@ -365,7 +393,7 @@ export async function sound(t) {
const gameFx = await waitFor(stats, (s) => s.started > beforeGameFx.started);
check(
'a Play sound naming the asset plays the game’s voice through it',
gameFx.last?.pitch === -12 && gameFx.last.stages === 1,
gameFx.last?.pitches.join() === '-12' && gameFx.last.stages === 1,
JSON.stringify(gameFx),
);
await stop();
@@ -382,7 +410,7 @@ export async function sound(t) {
const gameParam = await waitFor(stats, (s) => s.started > beforeGameParam.started);
check(
'a Sound FX parameters node hands depth 7 to the game’s voice',
gameParam.last?.pitch === 7 && gameParam.last.stages === 1,
gameParam.last?.pitches.join() === '7' && gameParam.last.stages === 1,
JSON.stringify(gameParam),
);
await stop();