Centralize spatial audio math across runtimes
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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