Centralize spatial audio math across runtimes

This commit is contained in:
Jage9
2026-02-21 19:25:26 -05:00
parent 008de60727
commit 14a382ab40
4 changed files with 100 additions and 53 deletions

View File

@@ -7,6 +7,7 @@ import {
type EffectId, type EffectId,
type EffectRuntime, type EffectRuntime,
} from './effects'; } from './effects';
import { resolveSpatialMix } from './spatial';
export type SpatialPeerRuntime = { export type SpatialPeerRuntime = {
nickname: string; nickname: string;
@@ -235,14 +236,15 @@ export class AudioEngine {
for (const peer of peers) { for (const peer of peers) {
if (!peer.gain) continue; if (!peer.gain) continue;
const dist = Math.hypot(peer.x - playerPosition.x, peer.y - playerPosition.y); const mix = resolveSpatialMix({
let gainValue = 0; dx: peer.x - playerPosition.x,
let panValue = 0; dy: peer.y - playerPosition.y,
if (dist < HEARING_RADIUS) { range: HEARING_RADIUS,
gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); nearFieldDistance: 1.5,
panValue = Math.sin(((peer.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2)); nearFieldGain: 1,
} });
if (dist < 1.5) gainValue = 1; const gainValue = mix?.gain ?? 0;
const panValue = mix?.pan ?? 0;
peer.gain.gain.linearRampToValueAtTime(gainValue, this.audioCtx.currentTime + 0.1); peer.gain.gain.linearRampToValueAtTime(gainValue, this.audioCtx.currentTime + 0.1);
if (peer.panner) { if (peer.panner) {
const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
@@ -284,7 +286,12 @@ export class AudioEngine {
const { audioCtx, sfxGainNode } = this; const { audioCtx, sfxGainNode } = this;
if (!audioCtx || !sfxGainNode) return; 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; if (!resolved) return;
try { try {
@@ -380,10 +387,17 @@ export class AudioEngine {
if (!audioCtx || !sfxGainNode) return; if (!audioCtx || !sfxGainNode) return;
const baseGain = spec.gain ?? 1; 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; if (!resolved) return;
const finalGain = resolved.gain; const finalGain = resolved.gain;
const panValue = resolved.pan; const panValue = spec.sourcePosition ? resolved.pan : undefined;
if (finalGain <= 0) return; if (finalGain <= 0) return;
@@ -409,24 +423,6 @@ export class AudioEngine {
oscillator.stop(startTime + spec.duration); 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<AudioBuffer> { private async getSampleBuffer(url: string): Promise<AudioBuffer> {
if (!this.audioCtx) { if (!this.audioCtx) {
throw new Error('Audio context not initialized'); throw new Error('Audio context not initialized');

View File

@@ -1,5 +1,6 @@
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';
type EmitOutput = { type EmitOutput = {
soundUrl: string; soundUrl: string;
@@ -107,18 +108,18 @@ export class ItemEmitRuntime {
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
continue; continue;
} }
const dist = Math.hypot(item.x - playerPosition.x, item.y - playerPosition.y); const mix = resolveSpatialMix({
let gainValue = 0; dx: item.x - playerPosition.x,
let panValue = 0; dy: item.y - playerPosition.y,
if (dist < HEARING_RADIUS) { range: HEARING_RADIUS,
gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); baseGain: ITEM_EMIT_BASE_GAIN,
panValue = Math.sin(((item.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2)); nearFieldDistance: 1,
} nearFieldGain: 1,
if (dist <= 1) { nearFieldCenterPan: true,
gainValue = 1; });
panValue = 0; const gainValue = mix?.gain ?? 0;
} const panValue = mix?.pan ?? 0;
output.gain.gain.linearRampToValueAtTime(gainValue * ITEM_EMIT_BASE_GAIN, 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));
output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1); output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1);

View File

@@ -1,6 +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';
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];
@@ -202,18 +203,18 @@ export class RadioStationRuntime {
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
continue; continue;
} }
const dist = Math.hypot(item.x - playerPosition.x, item.y - playerPosition.y); const mix = resolveSpatialMix({
let gainValue = 0; dx: item.x - playerPosition.x,
let panValue = 0; dy: item.y - playerPosition.y,
if (dist < HEARING_RADIUS) { range: HEARING_RADIUS,
gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); baseGain: normalizedVolume,
panValue = Math.sin(((item.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2)); nearFieldDistance: 1,
} nearFieldGain: 1,
if (dist <= 1) { nearFieldCenterPan: true,
gainValue = 1; });
panValue = 0; const gainValue = mix?.gain ?? 0;
} const panValue = mix?.pan ?? 0;
output.gain.gain.linearRampToValueAtTime(gainValue * normalizedVolume, 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));
output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1); output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1);

View File

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