Apply directional low-pass muffling for behind-source audio
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import { HEARING_RADIUS, type WorldItem } from '../state/gameState';
|
import { HEARING_RADIUS, type WorldItem } from '../state/gameState';
|
||||||
import { AudioEngine } from './audioEngine';
|
import { AudioEngine } from './audioEngine';
|
||||||
import { resolveSpatialMix } from './spatial';
|
import { resolveDirectionalMuffleRatio, resolveSpatialMix } from './spatial';
|
||||||
|
|
||||||
type EmitOutput = {
|
type EmitOutput = {
|
||||||
soundUrl: string;
|
soundUrl: string;
|
||||||
element: HTMLAudioElement;
|
element: HTMLAudioElement;
|
||||||
source: MediaElementAudioSourceNode;
|
source: MediaElementAudioSourceNode;
|
||||||
|
directionalFilter: BiquadFilterNode;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
panner: StereoPannerNode | null;
|
panner: StereoPannerNode | null;
|
||||||
};
|
};
|
||||||
@@ -34,6 +35,7 @@ export class ItemEmitRuntime {
|
|||||||
output.element.pause();
|
output.element.pause();
|
||||||
output.element.src = '';
|
output.element.src = '';
|
||||||
output.source.disconnect();
|
output.source.disconnect();
|
||||||
|
output.directionalFilter.disconnect();
|
||||||
output.gain.disconnect();
|
output.gain.disconnect();
|
||||||
output.panner?.disconnect();
|
output.panner?.disconnect();
|
||||||
this.outputs.delete(itemId);
|
this.outputs.delete(itemId);
|
||||||
@@ -83,17 +85,20 @@ export class ItemEmitRuntime {
|
|||||||
element.preload = 'none';
|
element.preload = 'none';
|
||||||
element.crossOrigin = 'anonymous';
|
element.crossOrigin = 'anonymous';
|
||||||
const source = audioCtx.createMediaElementSource(element);
|
const source = audioCtx.createMediaElementSource(element);
|
||||||
|
const directionalFilter = audioCtx.createBiquadFilter();
|
||||||
|
directionalFilter.type = 'lowpass';
|
||||||
|
directionalFilter.frequency.value = 12000;
|
||||||
const gain = audioCtx.createGain();
|
const gain = audioCtx.createGain();
|
||||||
gain.gain.value = 0;
|
gain.gain.value = 0;
|
||||||
let panner: StereoPannerNode | null = null;
|
let panner: StereoPannerNode | null = null;
|
||||||
source.connect(gain);
|
source.connect(directionalFilter).connect(gain);
|
||||||
if (this.audio.supportsStereoPanner()) {
|
if (this.audio.supportsStereoPanner()) {
|
||||||
panner = audioCtx.createStereoPanner();
|
panner = audioCtx.createStereoPanner();
|
||||||
gain.connect(panner).connect(audioCtx.destination);
|
gain.connect(panner).connect(audioCtx.destination);
|
||||||
} else {
|
} else {
|
||||||
gain.connect(audioCtx.destination);
|
gain.connect(audioCtx.destination);
|
||||||
}
|
}
|
||||||
this.outputs.set(item.id, { soundUrl, element, source, gain, panner });
|
this.outputs.set(item.id, { soundUrl, element, source, directionalFilter, gain, panner });
|
||||||
void element.play().catch(() => undefined);
|
void element.play().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +138,18 @@ export class ItemEmitRuntime {
|
|||||||
});
|
});
|
||||||
const gainValue = mix?.gain ?? 0;
|
const gainValue = mix?.gain ?? 0;
|
||||||
const panValue = mix?.pan ?? 0;
|
const panValue = mix?.pan ?? 0;
|
||||||
|
const muffleRatio = resolveDirectionalMuffleRatio(
|
||||||
|
item.x - playerPosition.x,
|
||||||
|
item.y - playerPosition.y,
|
||||||
|
{
|
||||||
|
enabled: spatialConfig.directional,
|
||||||
|
facingDeg: spatialConfig.facingDeg,
|
||||||
|
coneDeg: 120,
|
||||||
|
rearGain: 0.35,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cutoffHz = 12000 - (12000 - 2500) * muffleRatio;
|
||||||
|
output.directionalFilter.frequency.linearRampToValueAtTime(cutoffHz, audioCtx.currentTime + 0.1);
|
||||||
output.gain.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + 0.1);
|
output.gain.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + 0.1);
|
||||||
if (output.panner) {
|
if (output.panner) {
|
||||||
const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HEARING_RADIUS, type WorldItem } from '../state/gameState';
|
import { HEARING_RADIUS, type WorldItem } from '../state/gameState';
|
||||||
import { EFFECT_IDS, clampEffectLevel, connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects';
|
import { EFFECT_IDS, clampEffectLevel, connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects';
|
||||||
import { AudioEngine } from './audioEngine';
|
import { AudioEngine } from './audioEngine';
|
||||||
import { resolveSpatialMix } from './spatial';
|
import { resolveDirectionalMuffleRatio, resolveSpatialMix } from './spatial';
|
||||||
|
|
||||||
export const RADIO_CHANNEL_OPTIONS = ['stereo', 'mono', 'left', 'right'] as const;
|
export const RADIO_CHANNEL_OPTIONS = ['stereo', 'mono', 'left', 'right'] as const;
|
||||||
export type RadioChannelMode = (typeof RADIO_CHANNEL_OPTIONS)[number];
|
export type RadioChannelMode = (typeof RADIO_CHANNEL_OPTIONS)[number];
|
||||||
@@ -26,6 +26,7 @@ type ItemRadioOutput = {
|
|||||||
effectRuntime: EffectRuntime | null;
|
effectRuntime: EffectRuntime | null;
|
||||||
effect: EffectId;
|
effect: EffectId;
|
||||||
effectValue: number;
|
effectValue: number;
|
||||||
|
directionalFilter: BiquadFilterNode;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
panner: StereoPannerNode | null;
|
panner: StereoPannerNode | null;
|
||||||
};
|
};
|
||||||
@@ -152,6 +153,7 @@ export class RadioStationRuntime {
|
|||||||
output.sourceInput.disconnect();
|
output.sourceInput.disconnect();
|
||||||
output.effectInput.disconnect();
|
output.effectInput.disconnect();
|
||||||
disconnectEffectRuntime(output.effectRuntime);
|
disconnectEffectRuntime(output.effectRuntime);
|
||||||
|
output.directionalFilter.disconnect();
|
||||||
output.gain.disconnect();
|
output.gain.disconnect();
|
||||||
output.panner?.disconnect();
|
output.panner?.disconnect();
|
||||||
this.itemRadioOutputs.delete(itemId);
|
this.itemRadioOutputs.delete(itemId);
|
||||||
@@ -230,6 +232,18 @@ export class RadioStationRuntime {
|
|||||||
});
|
});
|
||||||
const gainValue = mix?.gain ?? 0;
|
const gainValue = mix?.gain ?? 0;
|
||||||
const panValue = mix?.pan ?? 0;
|
const panValue = mix?.pan ?? 0;
|
||||||
|
const muffleRatio = resolveDirectionalMuffleRatio(
|
||||||
|
item.x - playerPosition.x,
|
||||||
|
item.y - playerPosition.y,
|
||||||
|
{
|
||||||
|
enabled: spatialConfig.directional,
|
||||||
|
facingDeg: spatialConfig.facingDeg,
|
||||||
|
coneDeg: 120,
|
||||||
|
rearGain: 0.35,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cutoffHz = 12000 - (12000 - 2500) * muffleRatio;
|
||||||
|
output.directionalFilter.frequency.linearRampToValueAtTime(cutoffHz, audioCtx.currentTime + 0.1);
|
||||||
output.gain.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + 0.1);
|
output.gain.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + 0.1);
|
||||||
if (output.panner) {
|
if (output.panner) {
|
||||||
const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
||||||
@@ -249,7 +263,7 @@ export class RadioStationRuntime {
|
|||||||
}
|
}
|
||||||
output.effectInput.disconnect();
|
output.effectInput.disconnect();
|
||||||
disconnectEffectRuntime(output.effectRuntime);
|
disconnectEffectRuntime(output.effectRuntime);
|
||||||
output.effectRuntime = connectEffectChain(audioCtx, output.effectInput, output.gain, effect, effectValue);
|
output.effectRuntime = connectEffectChain(audioCtx, output.effectInput, output.directionalFilter, effect, effectValue);
|
||||||
output.effect = effect;
|
output.effect = effect;
|
||||||
output.effectValue = effectValue;
|
output.effectValue = effectValue;
|
||||||
}
|
}
|
||||||
@@ -313,11 +327,15 @@ export class RadioStationRuntime {
|
|||||||
|
|
||||||
const gain = audioCtx.createGain();
|
const gain = audioCtx.createGain();
|
||||||
gain.gain.value = 0;
|
gain.gain.value = 0;
|
||||||
|
const directionalFilter = audioCtx.createBiquadFilter();
|
||||||
|
directionalFilter.type = 'lowpass';
|
||||||
|
directionalFilter.frequency.value = 12000;
|
||||||
const effectInput = audioCtx.createGain();
|
const effectInput = audioCtx.createGain();
|
||||||
const channelSource = connectRadioChannelSource(audioCtx, shared.source, channel, effectInput);
|
const channelSource = connectRadioChannelSource(audioCtx, shared.source, channel, effectInput);
|
||||||
const effect = normalizeRadioEffect(item.params.effect);
|
const effect = normalizeRadioEffect(item.params.effect);
|
||||||
const effectValue = normalizeRadioEffectValue(item.params.effectValue);
|
const effectValue = normalizeRadioEffectValue(item.params.effectValue);
|
||||||
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
|
const effectRuntime = connectEffectChain(audioCtx, effectInput, directionalFilter, effect, effectValue);
|
||||||
|
directionalFilter.connect(gain);
|
||||||
let panner: StereoPannerNode | null = null;
|
let panner: StereoPannerNode | null = null;
|
||||||
if (this.audio.supportsStereoPanner()) {
|
if (this.audio.supportsStereoPanner()) {
|
||||||
panner = audioCtx.createStereoPanner();
|
panner = audioCtx.createStereoPanner();
|
||||||
@@ -338,6 +356,7 @@ export class RadioStationRuntime {
|
|||||||
effectRuntime,
|
effectRuntime,
|
||||||
effect,
|
effect,
|
||||||
effectValue,
|
effectValue,
|
||||||
|
directionalFilter,
|
||||||
gain,
|
gain,
|
||||||
panner,
|
panner,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export type SpatialMixResult = {
|
|||||||
pan: number;
|
pan: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DirectionalProfile = {
|
||||||
|
attenuationFactor: number;
|
||||||
|
offAxisRatio: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult | null {
|
export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult | null {
|
||||||
const {
|
const {
|
||||||
dx,
|
dx,
|
||||||
@@ -37,19 +42,8 @@ export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult
|
|||||||
const distance = Math.hypot(dx, dy);
|
const distance = Math.hypot(dx, dy);
|
||||||
let effectiveRange = range;
|
let effectiveRange = range;
|
||||||
if (options.directional?.enabled) {
|
if (options.directional?.enabled) {
|
||||||
const coneDeg = Math.max(1, Math.min(359, options.directional.coneDeg ?? 120));
|
const directionalProfile = resolveDirectionalProfile(dx, dy, options.directional);
|
||||||
const rearGain = Math.max(0, Math.min(1, options.directional.rearGain ?? 0.5));
|
effectiveRange = Math.max(0.01, range * directionalProfile.attenuationFactor);
|
||||||
const facingDeg = normalizeDegrees(options.directional.facingDeg);
|
|
||||||
// `dx/dy` are listener-relative source coords in current callers, so invert to get source->listener bearing.
|
|
||||||
const bearingDeg = bearingFromSourceToListener(-dx, -dy);
|
|
||||||
const diff = angularDifferenceDeg(facingDeg, bearingDeg);
|
|
||||||
const halfCone = coneDeg / 2;
|
|
||||||
if (diff > halfCone) {
|
|
||||||
const span = Math.max(1, 180 - halfCone);
|
|
||||||
const t = Math.max(0, Math.min(1, (diff - halfCone) / span));
|
|
||||||
const directionalFactor = 1 - t * (1 - rearGain);
|
|
||||||
effectiveRange = Math.max(0.01, range * directionalFactor);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distance > effectiveRange) {
|
if (distance > effectiveRange) {
|
||||||
@@ -71,6 +65,15 @@ export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult
|
|||||||
return { distance, gain, pan };
|
return { distance, gain, pan };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveDirectionalMuffleRatio(
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
directional: SpatialMixOptions['directional'],
|
||||||
|
): number {
|
||||||
|
if (!directional?.enabled) return 0;
|
||||||
|
return resolveDirectionalProfile(dx, dy, directional).offAxisRatio;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeDegrees(value: number): number {
|
export function normalizeDegrees(value: number): number {
|
||||||
if (!Number.isFinite(value)) return 0;
|
if (!Number.isFinite(value)) return 0;
|
||||||
const wrapped = value % 360;
|
const wrapped = value % 360;
|
||||||
@@ -87,3 +90,26 @@ function angularDifferenceDeg(a: number, b: number): number {
|
|||||||
const raw = Math.abs(normalizeDegrees(a) - normalizeDegrees(b));
|
const raw = Math.abs(normalizeDegrees(a) - normalizeDegrees(b));
|
||||||
return raw > 180 ? 360 - raw : raw;
|
return raw > 180 ? 360 - raw : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDirectionalProfile(
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
directional: NonNullable<SpatialMixOptions['directional']>,
|
||||||
|
): DirectionalProfile {
|
||||||
|
const coneDeg = Math.max(1, Math.min(359, directional.coneDeg ?? 120));
|
||||||
|
const rearGain = Math.max(0, Math.min(1, directional.rearGain ?? 0.5));
|
||||||
|
const facingDeg = normalizeDegrees(directional.facingDeg);
|
||||||
|
// `dx/dy` are listener-relative source coords in current callers, so invert to get source->listener bearing.
|
||||||
|
const bearingDeg = bearingFromSourceToListener(-dx, -dy);
|
||||||
|
const diff = angularDifferenceDeg(facingDeg, bearingDeg);
|
||||||
|
const halfCone = coneDeg / 2;
|
||||||
|
if (diff <= halfCone) {
|
||||||
|
return { attenuationFactor: 1, offAxisRatio: 0 };
|
||||||
|
}
|
||||||
|
const span = Math.max(1, 180 - halfCone);
|
||||||
|
const offAxisRatio = Math.max(0, Math.min(1, (diff - halfCone) / span));
|
||||||
|
return {
|
||||||
|
attenuationFactor: 1 - offAxisRatio * (1 - rearGain),
|
||||||
|
offAxisRatio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user