Centralize spatial audio math across runtimes
This commit is contained in:
@@ -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<AudioBuffer> {
|
||||
if (!this.audioCtx) {
|
||||
throw new Error('Audio context not initialized');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
49
client/src/audio/spatial.ts
Normal file
49
client/src/audio/spatial.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user