From 14a382ab40a14052a3a0d2a3c45a055a5785c2a0 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 21 Feb 2026 19:25:26 -0500 Subject: [PATCH] Centralize spatial audio math across runtimes --- client/src/audio/audioEngine.ts | 54 ++++++++++++------------- client/src/audio/itemEmitRuntime.ts | 25 ++++++------ client/src/audio/radioStationRuntime.ts | 25 ++++++------ client/src/audio/spatial.ts | 49 ++++++++++++++++++++++ 4 files changed, 100 insertions(+), 53 deletions(-) create mode 100644 client/src/audio/spatial.ts diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index 7c615ba..ba445cb 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -7,6 +7,7 @@ import { type EffectId, type EffectRuntime, } from './effects'; +import { resolveSpatialMix } from './spatial'; export type SpatialPeerRuntime = { nickname: string; @@ -235,14 +236,15 @@ export class AudioEngine { for (const peer of peers) { if (!peer.gain) continue; - const dist = Math.hypot(peer.x - playerPosition.x, peer.y - playerPosition.y); - let gainValue = 0; - let panValue = 0; - if (dist < HEARING_RADIUS) { - gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); - panValue = Math.sin(((peer.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2)); - } - if (dist < 1.5) gainValue = 1; + const mix = resolveSpatialMix({ + dx: peer.x - playerPosition.x, + dy: peer.y - playerPosition.y, + range: HEARING_RADIUS, + nearFieldDistance: 1.5, + nearFieldGain: 1, + }); + const gainValue = mix?.gain ?? 0; + const panValue = mix?.pan ?? 0; peer.gain.gain.linearRampToValueAtTime(gainValue, this.audioCtx.currentTime + 0.1); if (peer.panner) { const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); @@ -284,7 +286,12 @@ export class AudioEngine { const { audioCtx, sfxGainNode } = this; if (!audioCtx || !sfxGainNode) return; - const resolved = this.resolveSpatialMix(sourcePosition, gain); + const resolved = resolveSpatialMix({ + dx: sourcePosition.x, + dy: sourcePosition.y, + range: HEARING_RADIUS, + baseGain: gain, + }); if (!resolved) return; try { @@ -380,10 +387,17 @@ export class AudioEngine { if (!audioCtx || !sfxGainNode) return; const baseGain = spec.gain ?? 1; - const resolved = this.resolveSpatialMix(spec.sourcePosition, baseGain); + const resolved = spec.sourcePosition + ? resolveSpatialMix({ + dx: spec.sourcePosition.x, + dy: spec.sourcePosition.y, + range: HEARING_RADIUS, + baseGain, + }) + : { gain: baseGain, pan: 0 }; if (!resolved) return; const finalGain = resolved.gain; - const panValue = resolved.pan; + const panValue = spec.sourcePosition ? resolved.pan : undefined; if (finalGain <= 0) return; @@ -409,24 +423,6 @@ export class AudioEngine { oscillator.stop(startTime + spec.duration); } - private resolveSpatialMix( - sourcePosition: { x: number; y: number } | undefined, - baseGain: number, - ): { gain: number; pan?: number } | null { - if (!sourcePosition) { - return { gain: baseGain }; - } - const distance = Math.hypot(sourcePosition.x, sourcePosition.y); - if (distance > HEARING_RADIUS) { - return null; - } - const volumeRatio = Math.max(0, 1 - distance / HEARING_RADIUS); - const finalGain = baseGain * Math.pow(volumeRatio, 2); - const clampedX = Math.max(-HEARING_RADIUS, Math.min(HEARING_RADIUS, sourcePosition.x)); - const pan = Math.sin((clampedX / HEARING_RADIUS) * (Math.PI / 2)); - return { gain: finalGain, pan }; - } - private async getSampleBuffer(url: string): Promise { if (!this.audioCtx) { throw new Error('Audio context not initialized'); diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index 3b4878d..8eb0857 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -1,5 +1,6 @@ import { HEARING_RADIUS, type WorldItem } from '../state/gameState'; import { AudioEngine } from './audioEngine'; +import { resolveSpatialMix } from './spatial'; type EmitOutput = { soundUrl: string; @@ -107,18 +108,18 @@ export class ItemEmitRuntime { output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); continue; } - const dist = Math.hypot(item.x - playerPosition.x, item.y - playerPosition.y); - let gainValue = 0; - let panValue = 0; - if (dist < HEARING_RADIUS) { - gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); - panValue = Math.sin(((item.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2)); - } - if (dist <= 1) { - gainValue = 1; - panValue = 0; - } - output.gain.gain.linearRampToValueAtTime(gainValue * ITEM_EMIT_BASE_GAIN, audioCtx.currentTime + 0.1); + const mix = resolveSpatialMix({ + dx: item.x - playerPosition.x, + dy: item.y - playerPosition.y, + range: HEARING_RADIUS, + baseGain: ITEM_EMIT_BASE_GAIN, + nearFieldDistance: 1, + nearFieldGain: 1, + nearFieldCenterPan: true, + }); + const gainValue = mix?.gain ?? 0; + const panValue = mix?.pan ?? 0; + 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)); output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1); diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts index f44c9ac..e50c066 100644 --- a/client/src/audio/radioStationRuntime.ts +++ b/client/src/audio/radioStationRuntime.ts @@ -1,6 +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'; export const RADIO_CHANNEL_OPTIONS = ['stereo', 'mono', 'left', 'right'] as const; export type RadioChannelMode = (typeof RADIO_CHANNEL_OPTIONS)[number]; @@ -202,18 +203,18 @@ export class RadioStationRuntime { output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); continue; } - const dist = Math.hypot(item.x - playerPosition.x, item.y - playerPosition.y); - let gainValue = 0; - let panValue = 0; - if (dist < HEARING_RADIUS) { - gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); - panValue = Math.sin(((item.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2)); - } - if (dist <= 1) { - gainValue = 1; - panValue = 0; - } - output.gain.gain.linearRampToValueAtTime(gainValue * normalizedVolume, audioCtx.currentTime + 0.1); + const mix = resolveSpatialMix({ + dx: item.x - playerPosition.x, + dy: item.y - playerPosition.y, + range: HEARING_RADIUS, + baseGain: normalizedVolume, + nearFieldDistance: 1, + nearFieldGain: 1, + nearFieldCenterPan: true, + }); + const gainValue = mix?.gain ?? 0; + const panValue = mix?.pan ?? 0; + 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)); output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1); diff --git a/client/src/audio/spatial.ts b/client/src/audio/spatial.ts new file mode 100644 index 0000000..dbbe5ab --- /dev/null +++ b/client/src/audio/spatial.ts @@ -0,0 +1,49 @@ +export type SpatialMixOptions = { + dx: number; + dy: number; + range: number; + baseGain?: number; + nearFieldDistance?: number; + nearFieldGain?: number; + nearFieldCenterPan?: boolean; +}; + +export type SpatialMixResult = { + distance: number; + gain: number; + pan: number; +}; + +export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult | null { + const { + dx, + dy, + range, + baseGain = 1, + nearFieldDistance, + nearFieldGain = 1, + nearFieldCenterPan = false, + } = options; + if (!(range > 0)) { + return null; + } + + const distance = Math.hypot(dx, dy); + if (distance > range) { + return null; + } + + const volumeRatio = Math.max(0, 1 - distance / range); + let gain = baseGain * Math.pow(volumeRatio, 2); + const clampedX = Math.max(-range, Math.min(range, dx)); + let pan = Math.sin((clampedX / range) * (Math.PI / 2)); + + if (nearFieldDistance !== undefined && distance < nearFieldDistance) { + gain = baseGain * nearFieldGain; + if (nearFieldCenterPan) { + pan = 0; + } + } + + return { distance, gain, pan }; +}