Blend two ways of a sound FX with a Mix node #316
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:
1 parent
790e483198
commit
072537b2e8
6 files changed
+342
-76
No files matched your search
@@ -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
|
* A sound FX graph to the way a voice is sent: walked back from the Output to the Input, every node
|
||||||
* node on the way adding what it does. An input takes one edge, so the line never forks on its way
|
* on the way adding what it does. An input takes one edge, so only a Mix joins two ways into one
|
||||||
* to the Output; a node fed from nowhere is a problem rather than silence. A number fed from a
|
* (#316); a node fed from nowhere is a problem rather than silence. A number fed from a parameter
|
||||||
* parameter stays open in the plan until a voice is started (#311).
|
* stays open in the plan until a voice is started (#311).
|
||||||
*/
|
*/
|
||||||
export function compileSoundFx(graph: Graph): SoundFxBuild {
|
export function compileSoundFx(graph: Graph): SoundFxBuild {
|
||||||
const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, {
|
const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, {
|
||||||
@@ -53,10 +53,11 @@ export function compileSoundFx(graph: Graph): SoundFxBuild {
|
|||||||
emit: (def, inputs, node) => {
|
emit: (def, inputs, node) => {
|
||||||
if (node.type === SOUND_FX_INPUT) return inputOutputs(node);
|
if (node.type === SOUND_FX_INPUT) return inputOutputs(node);
|
||||||
const amounts: Record<string, FxAmount> = {};
|
const amounts: Record<string, FxAmount> = {};
|
||||||
for (const [key, value] of Object.entries(inputs)) if ('amount' in value) amounts[key] = value.amount;
|
const sounds: Record<string, FxPlan> = {};
|
||||||
const arriving = inputs.in;
|
for (const [key, value] of Object.entries(inputs))
|
||||||
const plan = arriving && 'plan' in arriving ? arriving.plan : EMPTY_PLAN;
|
if ('amount' in value) amounts[key] = value.amount;
|
||||||
return { out: { plan: def.emit(plan, amounts) } };
|
else sounds[key] = value.plan;
|
||||||
|
return { out: { plan: def.emit(sounds, amounts) } };
|
||||||
},
|
},
|
||||||
unfed: (node, port) => {
|
unfed: (node, port) => {
|
||||||
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
|
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
|
||||||
|
|||||||
+75
-25
@@ -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
|
* 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 {
|
export type FxChain =
|
||||||
pitch: number;
|
| { pitch: number; stages: readonly FxStage[] }
|
||||||
stages: readonly FxStage[];
|
| { mix: number; a: FxChain; b: FxChain; stages: readonly FxStage[] };
|
||||||
}
|
|
||||||
|
|
||||||
export const DRY: FxChain = { pitch: 0, stages: [] };
|
export const DRY: FxChain = { pitch: 0, stages: [] };
|
||||||
|
|
||||||
@@ -41,10 +41,9 @@ export type FxStagePlan =
|
|||||||
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number };
|
| { 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`. */
|
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
|
||||||
export interface FxPlan {
|
export type FxPlan =
|
||||||
pitch: readonly FxAmount[];
|
| { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[] }
|
||||||
stages: readonly FxStagePlan[];
|
| { mix: FxAmount; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] };
|
||||||
}
|
|
||||||
|
|
||||||
export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
|
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>>;
|
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
|
* A node of a sound FX graph (#311). It never runs: `emit` says what it does to the sounds arriving
|
||||||
* at its one audio input, given the numbers at its other inputs. Input and Output have no effect.
|
* 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 {
|
export interface SoundFxNodeDef extends NodeDef {
|
||||||
category: 'sound';
|
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 {
|
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. */
|
/** 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 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. */
|
/** A parameter of the Input as the nodes it feeds see it. */
|
||||||
export function soundFxParamPort(port: DeclaredPort): PortDef {
|
export function soundFxParamPort(port: DeclaredPort): PortDef {
|
||||||
return { key: port.key, label: port.key, type: 'number' };
|
return { key: port.key, label: port.key, type: 'number' };
|
||||||
@@ -109,7 +121,7 @@ export const SoundFxInput: SoundFxNodeDef = {
|
|||||||
declaresPorts: 'outputs',
|
declaresPorts: 'outputs',
|
||||||
portKinds: ['number'],
|
portKinds: ['number'],
|
||||||
declaredPort: (port) => soundFxParamPort(port),
|
declaredPort: (port) => soundFxParamPort(port),
|
||||||
emit: (plan) => plan,
|
emit: () => EMPTY_PLAN,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SoundFxOutput: SoundFxNodeDef = {
|
export const SoundFxOutput: SoundFxNodeDef = {
|
||||||
@@ -120,7 +132,7 @@ export const SoundFxOutput: SoundFxNodeDef = {
|
|||||||
inputs: [audioIn],
|
inputs: [audioIn],
|
||||||
outputs: [],
|
outputs: [],
|
||||||
params: [],
|
params: [],
|
||||||
emit: (plan) => plan,
|
emit: arriving,
|
||||||
};
|
};
|
||||||
|
|
||||||
const GAIN = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
|
const GAIN = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
|
||||||
@@ -134,9 +146,9 @@ export const SoundFxGain: SoundFxNodeDef = {
|
|||||||
inputs: [audioIn, numberIn(GAIN)],
|
inputs: [audioIn, numberIn(GAIN)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [],
|
params: [],
|
||||||
emit: (plan, amounts) => {
|
emit: (sounds, amounts) => {
|
||||||
const db = held(amounts.gain, GAIN);
|
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',
|
label: 'Pitch',
|
||||||
category: 'sound',
|
category: 'sound',
|
||||||
description:
|
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)],
|
inputs: [audioIn, numberIn(SEMITONES)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [],
|
params: [],
|
||||||
emit: (plan, amounts) => {
|
emit: (sounds, amounts) => {
|
||||||
const semitones = held(amounts.semitones, SEMITONES);
|
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)],
|
inputs: [audioIn, ...EQ_ROWS.map(numberIn)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [],
|
params: [],
|
||||||
emit: (plan, amounts) => {
|
emit: (sounds, amounts) => {
|
||||||
const stages: FxStagePlan[] = [];
|
const stages: FxStagePlan[] = [];
|
||||||
EQ_BANDS.forEach((band, i) => {
|
EQ_BANDS.forEach((band, i) => {
|
||||||
const gain = held(amounts[band.key], EQ_ROWS[i]!);
|
const gain = held(amounts[band.key], EQ_ROWS[i]!);
|
||||||
if (!isNone(gain))
|
if (!isNone(gain))
|
||||||
stages.push({ kind: 'filter', filter: band.filter, frequency: band.frequency, gain, q: PEAK_Q });
|
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,
|
SoundFxGain,
|
||||||
SoundFxPitch,
|
SoundFxPitch,
|
||||||
SoundFxEq,
|
SoundFxEq,
|
||||||
|
SoundFxMix,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
|
/** 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));
|
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. */
|
function stagesOf(plan: FxPlan, params: FxParams): FxStage[] {
|
||||||
export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
|
|
||||||
const stages: FxStage[] = [];
|
const stages: FxStage[] = [];
|
||||||
for (const stage of plan.stages) {
|
for (const stage of plan.stages) {
|
||||||
if (stage.kind === 'gain') {
|
if (stage.kind === 'gain') {
|
||||||
@@ -217,6 +250,23 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
|
|||||||
const gain = amountOf(stage.gain, params);
|
const gain = amountOf(stage.gain, params);
|
||||||
if (gain !== 0) stages.push({ ...stage, gain });
|
if (gain !== 0) stages.push({ ...stage, gain });
|
||||||
}
|
}
|
||||||
const pitch = plan.pitch.reduce((sum, amount) => sum + amountOf(amount, params), 0);
|
return stages;
|
||||||
return { pitch, 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 };
|
||||||
}
|
}
|
||||||
@@ -6,15 +6,29 @@ const DEFAULT_BASE = '/game-maker/data/sounds';
|
|||||||
/** How many pitched copies are kept at once. */
|
/** How many pitched copies are kept at once. */
|
||||||
const SHIFTED_KEPT = 32;
|
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 {
|
interface Voice {
|
||||||
file: string;
|
file: string;
|
||||||
/** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */
|
/** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */
|
||||||
owner: string;
|
owner: string;
|
||||||
source: AudioBufferSourceNode;
|
sources: AudioBufferSourceNode[];
|
||||||
gain: GainNode;
|
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
|
* 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.
|
* 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. */
|
/** Ends these voices now: `onended` only fires next frame and the counts have to be right here. */
|
||||||
private silence(going: Voice[]): void {
|
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.voices = this.voices.filter((voice) => !going.includes(voice));
|
||||||
this.stopped += going.length;
|
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
|
// 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.
|
// voice would run silently and end as if it had played.
|
||||||
if (context.state === 'suspended') void context.resume();
|
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();
|
const gain = context.createGain();
|
||||||
gain.gain.value = Math.max(0, Math.min(1, volume));
|
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 = {
|
this.last = {
|
||||||
file,
|
file,
|
||||||
volume: gain.gain.value,
|
volume: gain.gain.value,
|
||||||
loop,
|
loop,
|
||||||
pitch: fx.pitch,
|
pitches: [...wiring.sources.keys()],
|
||||||
stages: fx.stages.length,
|
stages: wiring.stages,
|
||||||
duration: played.duration,
|
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 };
|
|
||||||
source.onended = () => {
|
|
||||||
this.voices = this.voices.filter((other) => other !== voice);
|
|
||||||
};
|
};
|
||||||
|
const voice: Voice = { file, owner, sources, gain };
|
||||||
|
let playing = sources.length;
|
||||||
|
for (const source of sources)
|
||||||
|
source.onended = () => {
|
||||||
|
if (--playing === 0) this.voices = this.voices.filter((other) => other !== voice);
|
||||||
|
};
|
||||||
this.voices.push(voice);
|
this.voices.push(voice);
|
||||||
this.started += 1;
|
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;
|
file: string;
|
||||||
volume: number;
|
volume: number;
|
||||||
loop: boolean;
|
loop: boolean;
|
||||||
/** Semitones, and how many gain and filter nodes it passed. */
|
/** Semitones of each source, one per pitch (#316), and how many gain and filter nodes it passed. */
|
||||||
pitch: number;
|
pitches: number[];
|
||||||
stages: number;
|
stages: number;
|
||||||
/** Seconds of what was played: a pitched voice is as long as the sound itself (#312). */
|
/** Seconds of what was played: a pitched voice is as long as the sound itself (#312). */
|
||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gainNode(context: AudioContext, level: number): GainNode {
|
||||||
|
const gain = context.createGain();
|
||||||
|
gain.gain.value = level;
|
||||||
|
return gain;
|
||||||
|
}
|
||||||
|
|
||||||
function fxNode(context: AudioContext, stage: FxStage): AudioNode {
|
function fxNode(context: AudioContext, stage: FxStage): AudioNode {
|
||||||
if (stage.kind === 'gain') {
|
if (stage.kind === 'gain') return gainNode(context, stage.gain);
|
||||||
const gain = context.createGain();
|
|
||||||
gain.gain.value = stage.gain;
|
|
||||||
return gain;
|
|
||||||
}
|
|
||||||
const filter = context.createBiquadFilter();
|
const filter = context.createBiquadFilter();
|
||||||
filter.type = stage.filter;
|
filter.type = stage.filter;
|
||||||
filter.frequency.value = stage.frequency;
|
filter.frequency.value = stage.frequency;
|
||||||
|
|||||||
+95
-10
@@ -12,7 +12,7 @@ import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
|||||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||||
import { assetRefChoices } from '../../src/core/properties/assetRef';
|
import { assetRefChoices } from '../../src/core/properties/assetRef';
|
||||||
import { compileSoundFx, soundFxBuildFor } from '../../src/core/soundfx/compile';
|
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 { deserializeProject } from '../../src/core/store/serialize';
|
||||||
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
||||||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||||||
@@ -29,6 +29,9 @@ import {
|
|||||||
|
|
||||||
const FILE = `${'a1'.repeat(32)}.wav`;
|
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()) {
|
function makeFx(f: ProjectFixture = makeProject()) {
|
||||||
const command = new CreateAssetCommand('soundfx', 'Cave');
|
const command = new CreateAssetCommand('soundfx', 'Cave');
|
||||||
f.stack.execute(command);
|
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', () => {
|
it('offers its effects, Input and Output once each, and none of them in a behavior', () => {
|
||||||
const f = makeFx();
|
const f = makeFx();
|
||||||
const ids = () => paletteEntries(f.project, f.fx).map((e) => e.id);
|
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('SoundFxInput');
|
||||||
f.edit.addNode('SoundFxOutput');
|
f.edit.addNode('SoundFxOutput');
|
||||||
expect(ids()).toEqual(['SoundFxGain', 'SoundFxPitch', 'SoundFxEq']);
|
expect(ids()).toEqual(['SoundFxGain', 'SoundFxPitch', 'SoundFxEq', 'SoundFxMix']);
|
||||||
|
|
||||||
const behavior = makeBehavior(f, 'Logic');
|
const behavior = makeBehavior(f, 'Logic');
|
||||||
expect(paletteEntries(f.project, behavior).some((e) => e.def.category === 'sound')).toBe(false);
|
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);
|
const build = compileSoundFx(f.fx.graph);
|
||||||
expect(build.effects).toBe(4);
|
expect(build.effects).toBe(4);
|
||||||
const chain = fxChainOf(build.compiled!);
|
const chain = fxChainOf(build.compiled!);
|
||||||
expect(chain.pitch).toBe(7);
|
expect(pitchOf(chain)).toBe(7);
|
||||||
expect(chain.stages).toHaveLength(3);
|
expect(chain.stages).toHaveLength(3);
|
||||||
expect(chain.stages[0]!.kind).toBe('gain');
|
expect(chain.stages[0]!.kind).toBe('gain');
|
||||||
expect(chain.stages[0]!.gain).toBeCloseTo(0.501, 3);
|
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. */
|
/** Records what it was asked to play, with the line of effects. */
|
||||||
function fakeAudio(): AudioSink & { played: { file: string; fx: FxChain | undefined }[] } {
|
function fakeAudio(): AudioSink & { played: { file: string; fx: FxChain | undefined }[] } {
|
||||||
const sink = {
|
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', () => {
|
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 f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number', default: -12 }]);
|
||||||
const plan = soundFxBuildFor(f.fx).compiled!;
|
const plan = soundFxBuildFor(f.fx).compiled!;
|
||||||
expect(plan.pitch).toEqual([{ value: -12, param: 'depth', min: -64, max: 64 }]);
|
expect(pitchOf(plan)).toEqual([{ value: -12, param: 'depth', min: -64, max: 64 }]);
|
||||||
expect(fxChainOf(plan).pitch).toBe(-12);
|
expect(pitchOf(fxChainOf(plan))).toBe(-12);
|
||||||
expect(fxChainOf(plan, { depth: 5 }).pitch).toBe(5);
|
expect(pitchOf(fxChainOf(plan, { depth: 5 }))).toBe(5);
|
||||||
expect(fxChainOf(plan, { depth: 100 }).pitch).toBe(64);
|
expect(pitchOf(fxChainOf(plan, { depth: 100 }))).toBe(64);
|
||||||
expect(fxChainOf(plan, { other: 3 }).pitch).toBe(-12);
|
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', () => {
|
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' }]);
|
const f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number' }]);
|
||||||
f.stack.execute(new SetPortDefaultCommand(f.fx.id, f.input.id, 'depth', 4));
|
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(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();
|
f.stack.undo();
|
||||||
expect(f.input.ports).toEqual([{ key: 'depth', kind: 'number' }]);
|
expect(f.input.ports).toEqual([{ key: 'depth', kind: 'number' }]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface FakeSource {
|
|||||||
const sources: FakeSource[] = [];
|
const sources: FakeSource[] = [];
|
||||||
/** Every `connect` made, as "from > to" by the kind of node – the order a voice is wired in. */
|
/** Every `connect` made, as "from > to" by the kind of node – the order a voice is wired in. */
|
||||||
const wires: string[] = [];
|
const wires: string[] = [];
|
||||||
|
/** The level of every gain node made, in the order they were made. */
|
||||||
|
const gains: { value: number }[] = [];
|
||||||
const filters: {
|
const filters: {
|
||||||
type: string;
|
type: string;
|
||||||
frequency: { value: number };
|
frequency: { value: number };
|
||||||
@@ -90,7 +92,11 @@ function fakeContext() {
|
|||||||
sources.push(source);
|
sources.push(source);
|
||||||
return source;
|
return source;
|
||||||
},
|
},
|
||||||
createGain: () => wired('gain', { gain: { value: 1 } }),
|
createGain: () => {
|
||||||
|
const gain = wired('gain', { gain: { value: 1 } });
|
||||||
|
gains.push(gain.gain);
|
||||||
|
return gain;
|
||||||
|
},
|
||||||
createBiquadFilter: () => {
|
createBiquadFilter: () => {
|
||||||
const filter = wired('filter', {
|
const filter = wired('filter', {
|
||||||
type: '',
|
type: '',
|
||||||
@@ -111,6 +117,7 @@ beforeEach(() => {
|
|||||||
sources.length = 0;
|
sources.length = 0;
|
||||||
wires.length = 0;
|
wires.length = 0;
|
||||||
filters.length = 0;
|
filters.length = 0;
|
||||||
|
gains.length = 0;
|
||||||
decodes = [];
|
decodes = [];
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'AudioContext',
|
'AudioContext',
|
||||||
@@ -158,7 +165,7 @@ describe('the sound library', () => {
|
|||||||
file: FILE,
|
file: FILE,
|
||||||
volume: 0.25,
|
volume: 0.25,
|
||||||
loop: true,
|
loop: true,
|
||||||
pitch: 0,
|
pitches: [0],
|
||||||
stages: 0,
|
stages: 0,
|
||||||
duration: 1.5,
|
duration: 1.5,
|
||||||
});
|
});
|
||||||
@@ -184,7 +191,7 @@ describe('the sound library', () => {
|
|||||||
expect(filters).toMatchObject([
|
expect(filters).toMatchObject([
|
||||||
{ type: 'lowshelf', frequency: { value: 80 }, gain: { value: 6 }, Q: { value: 1 } },
|
{ 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 () => {
|
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]);
|
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 () => {
|
it('a stop while the bytes are in flight keeps the sound from starting afterwards', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary();
|
||||||
await play(library, FILE, { hold: true });
|
await play(library, FILE, { hold: true });
|
||||||
|
|||||||
+33
-5
@@ -301,7 +301,7 @@ export async function sound(t) {
|
|||||||
const fxPalette = await json('__gm.overlayTexts()');
|
const fxPalette = await json('__gm.overlayTexts()');
|
||||||
check(
|
check(
|
||||||
'its palette offers the sound effects and no behavior node',
|
'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'),
|
!fxPalette.includes('Branch'),
|
||||||
JSON.stringify(fxPalette.slice(0, 12)),
|
JSON.stringify(fxPalette.slice(0, 12)),
|
||||||
);
|
);
|
||||||
@@ -340,7 +340,7 @@ export async function sound(t) {
|
|||||||
check(
|
check(
|
||||||
'the tab’s Play sends the sound through the graph: an octave down, as long as before, one gain stage',
|
'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?.file === blip.file &&
|
||||||
heardFx.last.pitch === -12 &&
|
heardFx.last.pitches.join() === '-12' &&
|
||||||
heardFx.last.duration === blipSeconds &&
|
heardFx.last.duration === blipSeconds &&
|
||||||
heardFx.last.stages === 1,
|
heardFx.last.stages === 1,
|
||||||
JSON.stringify({ ...heardFx, blipSeconds }),
|
JSON.stringify({ ...heardFx, blipSeconds }),
|
||||||
@@ -352,10 +352,38 @@ export async function sound(t) {
|
|||||||
const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started);
|
const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started);
|
||||||
check(
|
check(
|
||||||
'the header plays with the value its depth field is set to',
|
'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),
|
JSON.stringify(heardParam),
|
||||||
);
|
);
|
||||||
await evaluate('__gm.sound.stopAll()');
|
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 showTab('scene');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
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);
|
const gameFx = await waitFor(stats, (s) => s.started > beforeGameFx.started);
|
||||||
check(
|
check(
|
||||||
'a Play sound naming the asset plays the game’s voice through it',
|
'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),
|
JSON.stringify(gameFx),
|
||||||
);
|
);
|
||||||
await stop();
|
await stop();
|
||||||
@@ -382,7 +410,7 @@ export async function sound(t) {
|
|||||||
const gameParam = await waitFor(stats, (s) => s.started > beforeGameParam.started);
|
const gameParam = await waitFor(stats, (s) => s.started > beforeGameParam.started);
|
||||||
check(
|
check(
|
||||||
'a Sound FX parameters node hands depth 7 to the game’s voice',
|
'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),
|
JSON.stringify(gameParam),
|
||||||
);
|
);
|
||||||
await stop();
|
await stop();
|
||||||
|
|||||||
Reference in new issue
Block a user