From f96bc9116afc1e072a9e5efdba19fa12307c10bf Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 21 Feb 2026 19:55:21 -0500 Subject: [PATCH] Apply directional low-pass muffling for behind-source audio --- client/src/audio/itemEmitRuntime.ts | 23 +++++++++-- client/src/audio/radioStationRuntime.ts | 25 ++++++++++-- client/src/audio/spatial.ts | 52 ++++++++++++++++++------- 3 files changed, 81 insertions(+), 19 deletions(-) diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index a396b52..8c439bf 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -1,11 +1,12 @@ import { HEARING_RADIUS, type WorldItem } from '../state/gameState'; import { AudioEngine } from './audioEngine'; -import { resolveSpatialMix } from './spatial'; +import { resolveDirectionalMuffleRatio, resolveSpatialMix } from './spatial'; type EmitOutput = { soundUrl: string; element: HTMLAudioElement; source: MediaElementAudioSourceNode; + directionalFilter: BiquadFilterNode; gain: GainNode; panner: StereoPannerNode | null; }; @@ -34,6 +35,7 @@ export class ItemEmitRuntime { output.element.pause(); output.element.src = ''; output.source.disconnect(); + output.directionalFilter.disconnect(); output.gain.disconnect(); output.panner?.disconnect(); this.outputs.delete(itemId); @@ -83,17 +85,20 @@ export class ItemEmitRuntime { element.preload = 'none'; element.crossOrigin = 'anonymous'; const source = audioCtx.createMediaElementSource(element); + const directionalFilter = audioCtx.createBiquadFilter(); + directionalFilter.type = 'lowpass'; + directionalFilter.frequency.value = 12000; const gain = audioCtx.createGain(); gain.gain.value = 0; let panner: StereoPannerNode | null = null; - source.connect(gain); + source.connect(directionalFilter).connect(gain); if (this.audio.supportsStereoPanner()) { panner = audioCtx.createStereoPanner(); gain.connect(panner).connect(audioCtx.destination); } else { 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); } @@ -133,6 +138,18 @@ export class ItemEmitRuntime { }); const gainValue = mix?.gain ?? 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); if (output.panner) { const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts index 407b324..f654008 100644 --- a/client/src/audio/radioStationRuntime.ts +++ b/client/src/audio/radioStationRuntime.ts @@ -1,7 +1,7 @@ import { HEARING_RADIUS, type WorldItem } from '../state/gameState'; import { EFFECT_IDS, clampEffectLevel, connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects'; 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 type RadioChannelMode = (typeof RADIO_CHANNEL_OPTIONS)[number]; @@ -26,6 +26,7 @@ type ItemRadioOutput = { effectRuntime: EffectRuntime | null; effect: EffectId; effectValue: number; + directionalFilter: BiquadFilterNode; gain: GainNode; panner: StereoPannerNode | null; }; @@ -152,6 +153,7 @@ export class RadioStationRuntime { output.sourceInput.disconnect(); output.effectInput.disconnect(); disconnectEffectRuntime(output.effectRuntime); + output.directionalFilter.disconnect(); output.gain.disconnect(); output.panner?.disconnect(); this.itemRadioOutputs.delete(itemId); @@ -230,6 +232,18 @@ export class RadioStationRuntime { }); const gainValue = mix?.gain ?? 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); if (output.panner) { const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); @@ -249,7 +263,7 @@ export class RadioStationRuntime { } output.effectInput.disconnect(); 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.effectValue = effectValue; } @@ -313,11 +327,15 @@ export class RadioStationRuntime { const gain = audioCtx.createGain(); gain.gain.value = 0; + const directionalFilter = audioCtx.createBiquadFilter(); + directionalFilter.type = 'lowpass'; + directionalFilter.frequency.value = 12000; const effectInput = audioCtx.createGain(); const channelSource = connectRadioChannelSource(audioCtx, shared.source, channel, effectInput); const effect = normalizeRadioEffect(item.params.effect); 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; if (this.audio.supportsStereoPanner()) { panner = audioCtx.createStereoPanner(); @@ -338,6 +356,7 @@ export class RadioStationRuntime { effectRuntime, effect, effectValue, + directionalFilter, gain, panner, }); diff --git a/client/src/audio/spatial.ts b/client/src/audio/spatial.ts index ce60d30..eb77a11 100644 --- a/client/src/audio/spatial.ts +++ b/client/src/audio/spatial.ts @@ -20,6 +20,11 @@ export type SpatialMixResult = { pan: number; }; +type DirectionalProfile = { + attenuationFactor: number; + offAxisRatio: number; +}; + export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult | null { const { dx, @@ -37,19 +42,8 @@ export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult const distance = Math.hypot(dx, dy); let effectiveRange = range; if (options.directional?.enabled) { - const coneDeg = Math.max(1, Math.min(359, options.directional.coneDeg ?? 120)); - const rearGain = Math.max(0, Math.min(1, options.directional.rearGain ?? 0.5)); - 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); - } + const directionalProfile = resolveDirectionalProfile(dx, dy, options.directional); + effectiveRange = Math.max(0.01, range * directionalProfile.attenuationFactor); } if (distance > effectiveRange) { @@ -71,6 +65,15 @@ export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult 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 { if (!Number.isFinite(value)) return 0; const wrapped = value % 360; @@ -87,3 +90,26 @@ function angularDifferenceDeg(a: number, b: number): number { const raw = Math.abs(normalizeDegrees(a) - normalizeDegrees(b)); return raw > 180 ? 360 - raw : raw; } + +function resolveDirectionalProfile( + dx: number, + dy: number, + directional: NonNullable, +): 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, + }; +}