Apply directional low-pass muffling for behind-source audio

This commit is contained in:
Jage9
2026-02-21 19:55:21 -05:00
parent d42206bafb
commit f96bc9116a
3 changed files with 81 additions and 19 deletions

View File

@@ -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));

View File

@@ -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,
}); });

View File

@@ -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,
};
}