Add emit proxy fix and HRTF audio mode

This commit is contained in:
Jage9
2026-03-09 04:06:46 -04:00
parent ef656b2b39
commit a34a9f7f42
13 changed files with 663 additions and 123 deletions

View File

@@ -7,7 +7,14 @@ import {
type EffectId,
type EffectRuntime,
} from './effects';
import { applySpatialMixToNodes, resolveSpatialMix, SPATIAL_RAMP_SECONDS, SPATIAL_TIME_CONSTANT_SECONDS } from './spatial';
import { resolveSpatialMix } from './spatial';
import {
applySpatialOutput,
createSpatialOutputRuntime,
disconnectSpatialOutputRuntime,
type SpatialOutputRuntime,
type SpatialRenderMode,
} from './spatialOutput';
export type SpatialPeerRuntime = {
nickname: string;
@@ -15,7 +22,7 @@ export type SpatialPeerRuntime = {
y: number;
listenGain?: number;
gain?: GainNode;
panner?: StereoPannerNode;
spatialOutput?: SpatialOutputRuntime;
audioElement?: HTMLAudioElement;
};
@@ -36,7 +43,7 @@ type ActiveSpatialSampleRuntime = {
range: number;
baseGain: number;
gainNode: GainNode;
pannerNode: StereoPannerNode | null;
spatialOutput: SpatialOutputRuntime;
sourceNode: AudioBufferSourceNode;
};
@@ -56,6 +63,7 @@ export class AudioEngine {
private loopbackEnabled = false;
private loopbackRuntime: EffectRuntime | null = null;
private outputMode: OutputMode = 'stereo';
private spatialMode: SpatialRenderMode = 'classic';
private masterVolume = 50;
private voiceLayerEnabled = true;
private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off');
@@ -189,6 +197,19 @@ export class AudioEngine {
this.outputMode = mode;
}
setSpatialMode(mode: SpatialRenderMode): void {
this.spatialMode = mode;
}
getSpatialMode(): SpatialRenderMode {
return this.spatialMode;
}
toggleSpatialMode(): SpatialRenderMode {
this.spatialMode = this.spatialMode === 'classic' ? 'hrtf' : 'classic';
return this.spatialMode;
}
setMasterVolume(value: number): number {
const next = Math.max(0, Math.min(100, Number.isFinite(value) ? Math.round(value) : 50));
this.masterVolume = next;
@@ -279,21 +300,20 @@ export class AudioEngine {
const gainNode = this.audioCtx.createGain();
sourceNode.connect(gainNode);
let pannerNode: StereoPannerNode | undefined;
if (this.supportsStereoPanner()) {
pannerNode = this.audioCtx.createStereoPanner();
if (this.voiceLayerEnabled) {
gainNode.connect(pannerNode).connect(this.masterGainNode ?? this.audioCtx.destination);
}
} else {
if (this.voiceLayerEnabled) {
gainNode.connect(this.masterGainNode ?? this.audioCtx.destination);
}
let spatialOutput: SpatialOutputRuntime = { kind: 'none' };
if (this.voiceLayerEnabled) {
spatialOutput = createSpatialOutputRuntime({
audioCtx: this.audioCtx,
inputNode: gainNode,
destination: this.masterGainNode ?? this.audioCtx.destination,
outputMode: this.outputMode,
spatialMode: this.spatialMode,
});
}
peer.audioElement = audioElement;
peer.gain = gainNode;
peer.panner = pannerNode;
peer.spatialOutput = spatialOutput;
}
updateSpatialAudio(peers: Iterable<SpatialPeerRuntime>, playerPosition: { x: number; y: number }): void {
@@ -310,13 +330,15 @@ export class AudioEngine {
});
const listenGain = Number.isFinite(peer.listenGain) ? Math.max(0, peer.listenGain as number) : 1;
const scaledMix = mix ? { ...mix, gain: mix.gain * listenGain } : null;
applySpatialMixToNodes({
applySpatialOutput({
audioCtx: this.audioCtx,
runtime: peer.spatialOutput ?? { kind: 'none' },
gainNode: peer.gain,
pannerNode: peer.panner ?? null,
mix: scaledMix,
outputMode: this.outputMode,
transition: 'target',
dx: peer.x - playerPosition.x,
dy: peer.y - playerPosition.y,
});
}
}
@@ -375,20 +397,20 @@ export class AudioEngine {
const gainNode = audioCtx.createGain();
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
source.connect(gainNode);
let pannerNode: StereoPannerNode | null = null;
if (this.supportsStereoPanner() && this.outputMode === 'stereo') {
pannerNode = audioCtx.createStereoPanner();
gainNode.connect(pannerNode).connect(sfxGainNode);
} else {
gainNode.connect(sfxGainNode);
}
const spatialOutput = createSpatialOutputRuntime({
audioCtx,
inputNode: gainNode,
destination: sfxGainNode,
outputMode: this.outputMode,
spatialMode: this.spatialMode,
});
const runtime: ActiveSpatialSampleRuntime = {
sourceX: sourcePosition.x,
sourceY: sourcePosition.y,
range: Math.max(1, range),
baseGain: gain,
gainNode,
pannerNode,
spatialOutput,
sourceNode: source,
};
this.activeSpatialSamples.add(runtime);
@@ -401,7 +423,7 @@ export class AudioEngine {
// Ignore stale graph disconnects.
}
gainNode.disconnect();
pannerNode?.disconnect();
disconnectSpatialOutputRuntime(spatialOutput);
};
source.start();
} catch {
@@ -428,20 +450,20 @@ export class AudioEngine {
const gainNode = audioCtx.createGain();
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
source.connect(gainNode);
let pannerNode: StereoPannerNode | null = null;
if (this.supportsStereoPanner() && this.outputMode === 'stereo') {
pannerNode = audioCtx.createStereoPanner();
gainNode.connect(pannerNode).connect(sfxGainNode);
} else {
gainNode.connect(sfxGainNode);
}
const spatialOutput = createSpatialOutputRuntime({
audioCtx,
inputNode: gainNode,
destination: sfxGainNode,
outputMode: this.outputMode,
spatialMode: this.spatialMode,
});
const runtime: ActiveSpatialSampleRuntime = {
sourceX: sourcePosition.x,
sourceY: sourcePosition.y,
range: Math.max(1, range),
baseGain: gain,
gainNode,
pannerNode,
spatialOutput,
sourceNode: source,
};
this.activeSpatialSamples.add(runtime);
@@ -455,7 +477,7 @@ export class AudioEngine {
// Ignore stale graph disconnects.
}
gainNode.disconnect();
pannerNode?.disconnect();
disconnectSpatialOutputRuntime(spatialOutput);
resolve();
};
source.start();
@@ -526,10 +548,12 @@ export class AudioEngine {
peer.audioElement.remove();
}
peer.gain?.disconnect();
peer.panner?.disconnect();
if (peer.spatialOutput) {
disconnectSpatialOutputRuntime(peer.spatialOutput);
}
peer.audioElement = undefined;
peer.gain = undefined;
peer.panner = undefined;
peer.spatialOutput = undefined;
}
private rebuildOutboundEffectGraph(): void {
@@ -589,8 +613,6 @@ export class AudioEngine {
: { gain: baseGain, pan: 0 };
if (!resolved) return;
const finalGain = resolved.gain;
const panValue = spec.sourcePosition ? resolved.pan : undefined;
if (finalGain <= 0) return;
const startTime = audioCtx.currentTime + (spec.delay ?? 0);
@@ -603,16 +625,40 @@ export class AudioEngine {
gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + spec.duration);
oscillator.connect(gainNode);
if (panValue !== undefined && this.supportsStereoPanner() && this.outputMode === 'stereo') {
const panner = audioCtx.createStereoPanner();
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, panValue)), startTime);
gainNode.connect(panner).connect(sfxGainNode);
let spatialOutput: SpatialOutputRuntime | null = null;
if (spec.sourcePosition && this.outputMode === 'stereo') {
if (this.spatialMode === 'hrtf' && typeof audioCtx.createPanner === 'function') {
const panner = audioCtx.createPanner();
panner.panningModel = 'HRTF';
panner.distanceModel = 'inverse';
panner.refDistance = 1;
panner.maxDistance = 10000;
panner.rolloffFactor = 0;
panner.positionX.setValueAtTime(spec.sourcePosition.x, startTime);
panner.positionY.setValueAtTime(0, startTime);
panner.positionZ.setValueAtTime(-spec.sourcePosition.y, startTime);
gainNode.connect(panner).connect(sfxGainNode);
spatialOutput = { kind: 'hrtf', node: panner };
} else if (this.supportsStereoPanner()) {
const panner = audioCtx.createStereoPanner();
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, resolved.pan)), startTime);
gainNode.connect(panner).connect(sfxGainNode);
spatialOutput = { kind: 'classic', node: panner };
} else {
gainNode.connect(sfxGainNode);
}
} else {
gainNode.connect(sfxGainNode);
}
oscillator.start(startTime);
oscillator.stop(startTime + spec.duration);
oscillator.onended = () => {
if (spatialOutput) {
disconnectSpatialOutputRuntime(spatialOutput);
}
gainNode.disconnect();
};
}
private applySpatialSampleRuntime(
@@ -628,22 +674,27 @@ export class AudioEngine {
baseGain: sample.baseGain,
});
if (initial) {
const gainValue = mix?.gain ?? 0;
sample.gainNode.gain.setTargetAtTime(gainValue, this.audioCtx.currentTime, ONE_SHOT_ATTACK_SECONDS);
if (sample.pannerNode) {
const panValue = mix?.pan ?? 0;
const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
sample.pannerNode.pan.setValueAtTime(resolvedPan, this.audioCtx.currentTime);
}
applySpatialOutput({
audioCtx: this.audioCtx,
runtime: sample.spatialOutput,
gainNode: sample.gainNode,
mix,
outputMode: this.outputMode,
transition: 'linear',
dx: sample.sourceX - playerPosition.x,
dy: sample.sourceY - playerPosition.y,
});
return;
}
applySpatialMixToNodes({
applySpatialOutput({
audioCtx: this.audioCtx,
runtime: sample.spatialOutput,
gainNode: sample.gainNode,
pannerNode: sample.pannerNode,
mix,
outputMode: this.outputMode,
transition: 'target',
dx: sample.sourceX - playerPosition.x,
dy: sample.sourceY - playerPosition.y,
});
}