Remove HRTF mode and keep emit proxy fix
This commit is contained in:
@@ -106,10 +106,6 @@
|
|||||||
"keys": "M",
|
"keys": "M",
|
||||||
"description": "Mute/unmute yourself"
|
"description": "Mute/unmute yourself"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"keys": "H",
|
|
||||||
"description": "Toggle classic/HRTF spatial audio"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"keys": "Shift+M",
|
"keys": "Shift+M",
|
||||||
"description": "Toggle stereo/mono output"
|
"description": "Toggle stereo/mono output"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Maintainer-controlled web client version metadata.
|
// Maintainer-controlled web client version metadata.
|
||||||
window.CHGRID_RELEASE_VERSION = "0.1.1";
|
window.CHGRID_RELEASE_VERSION = "0.1.1";
|
||||||
window.CHGRID_CLIENT_REVISION = "R351";
|
window.CHGRID_CLIENT_REVISION = "R352";
|
||||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -7,14 +7,7 @@ import {
|
|||||||
type EffectId,
|
type EffectId,
|
||||||
type EffectRuntime,
|
type EffectRuntime,
|
||||||
} from './effects';
|
} from './effects';
|
||||||
import { resolveSpatialMix } from './spatial';
|
import { applySpatialMixToNodes, resolveSpatialMix, SPATIAL_RAMP_SECONDS, SPATIAL_TIME_CONSTANT_SECONDS } from './spatial';
|
||||||
import {
|
|
||||||
applySpatialOutput,
|
|
||||||
createSpatialOutputRuntime,
|
|
||||||
disconnectSpatialOutputRuntime,
|
|
||||||
type SpatialOutputRuntime,
|
|
||||||
type SpatialRenderMode,
|
|
||||||
} from './spatialOutput';
|
|
||||||
|
|
||||||
export type SpatialPeerRuntime = {
|
export type SpatialPeerRuntime = {
|
||||||
nickname: string;
|
nickname: string;
|
||||||
@@ -22,7 +15,7 @@ export type SpatialPeerRuntime = {
|
|||||||
y: number;
|
y: number;
|
||||||
listenGain?: number;
|
listenGain?: number;
|
||||||
gain?: GainNode;
|
gain?: GainNode;
|
||||||
spatialOutput?: SpatialOutputRuntime;
|
panner?: StereoPannerNode;
|
||||||
audioElement?: HTMLAudioElement;
|
audioElement?: HTMLAudioElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,7 +36,7 @@ type ActiveSpatialSampleRuntime = {
|
|||||||
range: number;
|
range: number;
|
||||||
baseGain: number;
|
baseGain: number;
|
||||||
gainNode: GainNode;
|
gainNode: GainNode;
|
||||||
spatialOutput: SpatialOutputRuntime;
|
pannerNode: StereoPannerNode | null;
|
||||||
sourceNode: AudioBufferSourceNode;
|
sourceNode: AudioBufferSourceNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +56,6 @@ export class AudioEngine {
|
|||||||
private loopbackEnabled = false;
|
private loopbackEnabled = false;
|
||||||
private loopbackRuntime: EffectRuntime | null = null;
|
private loopbackRuntime: EffectRuntime | null = null;
|
||||||
private outputMode: OutputMode = 'stereo';
|
private outputMode: OutputMode = 'stereo';
|
||||||
private spatialMode: SpatialRenderMode = 'classic';
|
|
||||||
private masterVolume = 50;
|
private masterVolume = 50;
|
||||||
private voiceLayerEnabled = true;
|
private voiceLayerEnabled = true;
|
||||||
private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off');
|
private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off');
|
||||||
@@ -197,19 +189,6 @@ export class AudioEngine {
|
|||||||
this.outputMode = mode;
|
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 {
|
setMasterVolume(value: number): number {
|
||||||
const next = Math.max(0, Math.min(100, Number.isFinite(value) ? Math.round(value) : 50));
|
const next = Math.max(0, Math.min(100, Number.isFinite(value) ? Math.round(value) : 50));
|
||||||
this.masterVolume = next;
|
this.masterVolume = next;
|
||||||
@@ -300,20 +279,21 @@ export class AudioEngine {
|
|||||||
const gainNode = this.audioCtx.createGain();
|
const gainNode = this.audioCtx.createGain();
|
||||||
sourceNode.connect(gainNode);
|
sourceNode.connect(gainNode);
|
||||||
|
|
||||||
let spatialOutput: SpatialOutputRuntime = { kind: 'none' };
|
let pannerNode: StereoPannerNode | undefined;
|
||||||
|
if (this.supportsStereoPanner()) {
|
||||||
|
pannerNode = this.audioCtx.createStereoPanner();
|
||||||
if (this.voiceLayerEnabled) {
|
if (this.voiceLayerEnabled) {
|
||||||
spatialOutput = createSpatialOutputRuntime({
|
gainNode.connect(pannerNode).connect(this.masterGainNode ?? this.audioCtx.destination);
|
||||||
audioCtx: this.audioCtx,
|
}
|
||||||
inputNode: gainNode,
|
} else {
|
||||||
destination: this.masterGainNode ?? this.audioCtx.destination,
|
if (this.voiceLayerEnabled) {
|
||||||
outputMode: this.outputMode,
|
gainNode.connect(this.masterGainNode ?? this.audioCtx.destination);
|
||||||
spatialMode: this.spatialMode,
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
peer.audioElement = audioElement;
|
peer.audioElement = audioElement;
|
||||||
peer.gain = gainNode;
|
peer.gain = gainNode;
|
||||||
peer.spatialOutput = spatialOutput;
|
peer.panner = pannerNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSpatialAudio(peers: Iterable<SpatialPeerRuntime>, playerPosition: { x: number; y: number }): void {
|
updateSpatialAudio(peers: Iterable<SpatialPeerRuntime>, playerPosition: { x: number; y: number }): void {
|
||||||
@@ -330,15 +310,13 @@ export class AudioEngine {
|
|||||||
});
|
});
|
||||||
const listenGain = Number.isFinite(peer.listenGain) ? Math.max(0, peer.listenGain as number) : 1;
|
const listenGain = Number.isFinite(peer.listenGain) ? Math.max(0, peer.listenGain as number) : 1;
|
||||||
const scaledMix = mix ? { ...mix, gain: mix.gain * listenGain } : null;
|
const scaledMix = mix ? { ...mix, gain: mix.gain * listenGain } : null;
|
||||||
applySpatialOutput({
|
applySpatialMixToNodes({
|
||||||
audioCtx: this.audioCtx,
|
audioCtx: this.audioCtx,
|
||||||
runtime: peer.spatialOutput ?? { kind: 'none' },
|
|
||||||
gainNode: peer.gain,
|
gainNode: peer.gain,
|
||||||
|
pannerNode: peer.panner ?? null,
|
||||||
mix: scaledMix,
|
mix: scaledMix,
|
||||||
outputMode: this.outputMode,
|
outputMode: this.outputMode,
|
||||||
transition: 'target',
|
transition: 'target',
|
||||||
dx: peer.x - playerPosition.x,
|
|
||||||
dy: peer.y - playerPosition.y,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,20 +375,20 @@ export class AudioEngine {
|
|||||||
const gainNode = audioCtx.createGain();
|
const gainNode = audioCtx.createGain();
|
||||||
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
|
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
|
||||||
source.connect(gainNode);
|
source.connect(gainNode);
|
||||||
const spatialOutput = createSpatialOutputRuntime({
|
let pannerNode: StereoPannerNode | null = null;
|
||||||
audioCtx,
|
if (this.supportsStereoPanner() && this.outputMode === 'stereo') {
|
||||||
inputNode: gainNode,
|
pannerNode = audioCtx.createStereoPanner();
|
||||||
destination: sfxGainNode,
|
gainNode.connect(pannerNode).connect(sfxGainNode);
|
||||||
outputMode: this.outputMode,
|
} else {
|
||||||
spatialMode: this.spatialMode,
|
gainNode.connect(sfxGainNode);
|
||||||
});
|
}
|
||||||
const runtime: ActiveSpatialSampleRuntime = {
|
const runtime: ActiveSpatialSampleRuntime = {
|
||||||
sourceX: sourcePosition.x,
|
sourceX: sourcePosition.x,
|
||||||
sourceY: sourcePosition.y,
|
sourceY: sourcePosition.y,
|
||||||
range: Math.max(1, range),
|
range: Math.max(1, range),
|
||||||
baseGain: gain,
|
baseGain: gain,
|
||||||
gainNode,
|
gainNode,
|
||||||
spatialOutput,
|
pannerNode,
|
||||||
sourceNode: source,
|
sourceNode: source,
|
||||||
};
|
};
|
||||||
this.activeSpatialSamples.add(runtime);
|
this.activeSpatialSamples.add(runtime);
|
||||||
@@ -423,7 +401,7 @@ export class AudioEngine {
|
|||||||
// Ignore stale graph disconnects.
|
// Ignore stale graph disconnects.
|
||||||
}
|
}
|
||||||
gainNode.disconnect();
|
gainNode.disconnect();
|
||||||
disconnectSpatialOutputRuntime(spatialOutput);
|
pannerNode?.disconnect();
|
||||||
};
|
};
|
||||||
source.start();
|
source.start();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -450,20 +428,20 @@ export class AudioEngine {
|
|||||||
const gainNode = audioCtx.createGain();
|
const gainNode = audioCtx.createGain();
|
||||||
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
|
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
|
||||||
source.connect(gainNode);
|
source.connect(gainNode);
|
||||||
const spatialOutput = createSpatialOutputRuntime({
|
let pannerNode: StereoPannerNode | null = null;
|
||||||
audioCtx,
|
if (this.supportsStereoPanner() && this.outputMode === 'stereo') {
|
||||||
inputNode: gainNode,
|
pannerNode = audioCtx.createStereoPanner();
|
||||||
destination: sfxGainNode,
|
gainNode.connect(pannerNode).connect(sfxGainNode);
|
||||||
outputMode: this.outputMode,
|
} else {
|
||||||
spatialMode: this.spatialMode,
|
gainNode.connect(sfxGainNode);
|
||||||
});
|
}
|
||||||
const runtime: ActiveSpatialSampleRuntime = {
|
const runtime: ActiveSpatialSampleRuntime = {
|
||||||
sourceX: sourcePosition.x,
|
sourceX: sourcePosition.x,
|
||||||
sourceY: sourcePosition.y,
|
sourceY: sourcePosition.y,
|
||||||
range: Math.max(1, range),
|
range: Math.max(1, range),
|
||||||
baseGain: gain,
|
baseGain: gain,
|
||||||
gainNode,
|
gainNode,
|
||||||
spatialOutput,
|
pannerNode,
|
||||||
sourceNode: source,
|
sourceNode: source,
|
||||||
};
|
};
|
||||||
this.activeSpatialSamples.add(runtime);
|
this.activeSpatialSamples.add(runtime);
|
||||||
@@ -477,7 +455,7 @@ export class AudioEngine {
|
|||||||
// Ignore stale graph disconnects.
|
// Ignore stale graph disconnects.
|
||||||
}
|
}
|
||||||
gainNode.disconnect();
|
gainNode.disconnect();
|
||||||
disconnectSpatialOutputRuntime(spatialOutput);
|
pannerNode?.disconnect();
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
source.start();
|
source.start();
|
||||||
@@ -548,12 +526,10 @@ export class AudioEngine {
|
|||||||
peer.audioElement.remove();
|
peer.audioElement.remove();
|
||||||
}
|
}
|
||||||
peer.gain?.disconnect();
|
peer.gain?.disconnect();
|
||||||
if (peer.spatialOutput) {
|
peer.panner?.disconnect();
|
||||||
disconnectSpatialOutputRuntime(peer.spatialOutput);
|
|
||||||
}
|
|
||||||
peer.audioElement = undefined;
|
peer.audioElement = undefined;
|
||||||
peer.gain = undefined;
|
peer.gain = undefined;
|
||||||
peer.spatialOutput = undefined;
|
peer.panner = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private rebuildOutboundEffectGraph(): void {
|
private rebuildOutboundEffectGraph(): void {
|
||||||
@@ -613,6 +589,8 @@ export class AudioEngine {
|
|||||||
: { gain: baseGain, pan: 0 };
|
: { gain: baseGain, pan: 0 };
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const finalGain = resolved.gain;
|
const finalGain = resolved.gain;
|
||||||
|
const panValue = spec.sourcePosition ? resolved.pan : undefined;
|
||||||
|
|
||||||
if (finalGain <= 0) return;
|
if (finalGain <= 0) return;
|
||||||
|
|
||||||
const startTime = audioCtx.currentTime + (spec.delay ?? 0);
|
const startTime = audioCtx.currentTime + (spec.delay ?? 0);
|
||||||
@@ -625,40 +603,16 @@ export class AudioEngine {
|
|||||||
gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + spec.duration);
|
gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + spec.duration);
|
||||||
|
|
||||||
oscillator.connect(gainNode);
|
oscillator.connect(gainNode);
|
||||||
let spatialOutput: SpatialOutputRuntime | null = null;
|
if (panValue !== undefined && this.supportsStereoPanner() && this.outputMode === 'stereo') {
|
||||||
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();
|
const panner = audioCtx.createStereoPanner();
|
||||||
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, resolved.pan)), startTime);
|
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, panValue)), startTime);
|
||||||
gainNode.connect(panner).connect(sfxGainNode);
|
gainNode.connect(panner).connect(sfxGainNode);
|
||||||
spatialOutput = { kind: 'classic', node: panner };
|
|
||||||
} else {
|
|
||||||
gainNode.connect(sfxGainNode);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
gainNode.connect(sfxGainNode);
|
gainNode.connect(sfxGainNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
oscillator.start(startTime);
|
oscillator.start(startTime);
|
||||||
oscillator.stop(startTime + spec.duration);
|
oscillator.stop(startTime + spec.duration);
|
||||||
oscillator.onended = () => {
|
|
||||||
if (spatialOutput) {
|
|
||||||
disconnectSpatialOutputRuntime(spatialOutput);
|
|
||||||
}
|
|
||||||
gainNode.disconnect();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applySpatialSampleRuntime(
|
private applySpatialSampleRuntime(
|
||||||
@@ -674,27 +628,22 @@ export class AudioEngine {
|
|||||||
baseGain: sample.baseGain,
|
baseGain: sample.baseGain,
|
||||||
});
|
});
|
||||||
if (initial) {
|
if (initial) {
|
||||||
applySpatialOutput({
|
const gainValue = mix?.gain ?? 0;
|
||||||
audioCtx: this.audioCtx,
|
sample.gainNode.gain.setTargetAtTime(gainValue, this.audioCtx.currentTime, ONE_SHOT_ATTACK_SECONDS);
|
||||||
runtime: sample.spatialOutput,
|
if (sample.pannerNode) {
|
||||||
gainNode: sample.gainNode,
|
const panValue = mix?.pan ?? 0;
|
||||||
mix,
|
const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
||||||
outputMode: this.outputMode,
|
sample.pannerNode.pan.setValueAtTime(resolvedPan, this.audioCtx.currentTime);
|
||||||
transition: 'linear',
|
}
|
||||||
dx: sample.sourceX - playerPosition.x,
|
|
||||||
dy: sample.sourceY - playerPosition.y,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applySpatialOutput({
|
applySpatialMixToNodes({
|
||||||
audioCtx: this.audioCtx,
|
audioCtx: this.audioCtx,
|
||||||
runtime: sample.spatialOutput,
|
|
||||||
gainNode: sample.gainNode,
|
gainNode: sample.gainNode,
|
||||||
|
pannerNode: sample.pannerNode,
|
||||||
mix,
|
mix,
|
||||||
outputMode: this.outputMode,
|
outputMode: this.outputMode,
|
||||||
transition: 'target',
|
transition: 'target',
|
||||||
dx: sample.sourceX - playerPosition.x,
|
|
||||||
dy: sample.sourceY - playerPosition.y,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
## Goal
|
|
||||||
|
|
||||||
Add an optional HRTF-based spatial audio mode for Chat Grid so positional sounds use browser 3D panning rather than the current shared left/right stereo pan model.
|
|
||||||
|
|
||||||
This must preserve the current source-specific behavior of grid audio. Different sounds already originate from different positions and runtimes, and that should remain true after any HRTF work.
|
|
||||||
|
|
||||||
## Feasibility
|
|
||||||
|
|
||||||
This is feasible in the current client architecture.
|
|
||||||
|
|
||||||
Why:
|
|
||||||
|
|
||||||
- The client already has shared spatial math in [`spatial.ts`](/home/jjm/code/chgrid/client/src/audio/spatial.ts).
|
|
||||||
- Most spatial sources already route through a small set of audio modules.
|
|
||||||
- The browser Web Audio API supports `PannerNode` with `panningModel = "HRTF"`.
|
|
||||||
|
|
||||||
What is not true today:
|
|
||||||
|
|
||||||
- There is not one single central spatial node for all sources.
|
|
||||||
- Most spatial sources still create their own `StereoPannerNode` directly.
|
|
||||||
|
|
||||||
So the right plan is not "flip one switch." The right plan is to introduce a shared spatial output abstraction, then migrate the existing spatial sources to it.
|
|
||||||
|
|
||||||
## Current Spatial Coverage
|
|
||||||
|
|
||||||
The current spatial system already covers most of the sources you care about:
|
|
||||||
|
|
||||||
- peer voice in [`audioEngine.ts`](/home/jjm/code/chgrid/client/src/audio/audioEngine.ts)
|
|
||||||
- remote footsteps / teleports / item-use one-shots in [`audioEngine.ts`](/home/jjm/code/chgrid/client/src/audio/audioEngine.ts) and [`main.ts`](/home/jjm/code/chgrid/client/src/main.ts)
|
|
||||||
- clock announcements in [`clockAnnouncer.ts`](/home/jjm/code/chgrid/client/src/audio/clockAnnouncer.ts)
|
|
||||||
- radios in [`radioStationRuntime.ts`](/home/jjm/code/chgrid/client/src/audio/radioStationRuntime.ts)
|
|
||||||
- item emit sounds in [`itemEmitRuntime.ts`](/home/jjm/code/chgrid/client/src/audio/itemEmitRuntime.ts)
|
|
||||||
- piano notes in [`pianoSynth.ts`](/home/jjm/code/chgrid/client/src/audio/pianoSynth.ts)
|
|
||||||
|
|
||||||
The common part today is the gain/pan math in [`spatial.ts`](/home/jjm/code/chgrid/client/src/audio/spatial.ts), not the actual Web Audio node graph.
|
|
||||||
|
|
||||||
## Main Constraint
|
|
||||||
|
|
||||||
The current spatial model computes:
|
|
||||||
|
|
||||||
- gain
|
|
||||||
- stereo pan
|
|
||||||
|
|
||||||
HRTF needs more than that:
|
|
||||||
|
|
||||||
- source position on X/Y/Z axes
|
|
||||||
- listener position
|
|
||||||
- listener orientation
|
|
||||||
- `PannerNode` distance model and cone settings
|
|
||||||
|
|
||||||
So the plan should keep the existing spatial math for range/directional audibility, but move pan handling into a shared HRTF-aware node builder.
|
|
||||||
|
|
||||||
## Recommended Design
|
|
||||||
|
|
||||||
### 1. Introduce a spatial mode setting
|
|
||||||
|
|
||||||
Add a new audio spatial mode concept, separate from the current output mode:
|
|
||||||
|
|
||||||
- `stereo`
|
|
||||||
- `mono`
|
|
||||||
- `hrtf`
|
|
||||||
|
|
||||||
Do not overload the existing `mono` / `stereo` toggle with HRTF semantics.
|
|
||||||
|
|
||||||
Why:
|
|
||||||
|
|
||||||
- mono/stereo is a speaker/downmix preference
|
|
||||||
- HRTF is a spatial rendering mode
|
|
||||||
|
|
||||||
If you want to keep the current command surface small, the first pass can expose:
|
|
||||||
|
|
||||||
- output mode: `mono` / `stereo`
|
|
||||||
- spatial mode: `classic` / `hrtf`
|
|
||||||
|
|
||||||
Where:
|
|
||||||
|
|
||||||
- `classic` means current gain + `StereoPannerNode` behavior
|
|
||||||
- `hrtf` means current gain plus `PannerNode`
|
|
||||||
|
|
||||||
For now, a simple keyboard toggle is reasonable. `H` makes sense as an initial shortcut as long as it does not conflict with an existing command in normal mode.
|
|
||||||
|
|
||||||
### 2. Add a shared spatial node helper
|
|
||||||
|
|
||||||
Create one shared helper under `client/src/audio/`, for example:
|
|
||||||
|
|
||||||
- `spatialGraph.ts`
|
|
||||||
|
|
||||||
It should own:
|
|
||||||
|
|
||||||
- creation of either `StereoPannerNode` or `PannerNode`
|
|
||||||
- common connect/disconnect behavior
|
|
||||||
- common position/orientation updates
|
|
||||||
- a small runtime type so all spatial sources can be updated uniformly
|
|
||||||
|
|
||||||
What it should not do:
|
|
||||||
|
|
||||||
- erase the fact that different sound sources have different lifecycles
|
|
||||||
- collapse radio, voice, emitters, piano, and one-shots into one generic runtime if that loses behavior
|
|
||||||
|
|
||||||
The centralization goal should be limited to shared node construction and shared spatial updates, not flattening all audio features into one code path.
|
|
||||||
|
|
||||||
This helper should replace direct `createStereoPanner()` calls in:
|
|
||||||
|
|
||||||
- [`audioEngine.ts`](/home/jjm/code/chgrid/client/src/audio/audioEngine.ts)
|
|
||||||
- [`radioStationRuntime.ts`](/home/jjm/code/chgrid/client/src/audio/radioStationRuntime.ts)
|
|
||||||
- [`itemEmitRuntime.ts`](/home/jjm/code/chgrid/client/src/audio/itemEmitRuntime.ts)
|
|
||||||
- [`pianoSynth.ts`](/home/jjm/code/chgrid/client/src/audio/pianoSynth.ts)
|
|
||||||
|
|
||||||
### 3. Keep current gain/distance logic in `spatial.ts`
|
|
||||||
|
|
||||||
The current `resolveSpatialMix()` logic is still useful for:
|
|
||||||
|
|
||||||
- audibility cutoff
|
|
||||||
- gain shaping
|
|
||||||
- directional attenuation
|
|
||||||
|
|
||||||
I would keep that server/game-feel logic and reuse it for HRTF mode as the gain envelope.
|
|
||||||
|
|
||||||
What should change:
|
|
||||||
|
|
||||||
- `pan` should stop being the main output for HRTF mode
|
|
||||||
- HRTF mode should instead map source/listener coordinates into a `PannerNode`
|
|
||||||
|
|
||||||
So the likely split is:
|
|
||||||
|
|
||||||
- `resolveSpatialMix()` continues to return gain and optional directional attenuation
|
|
||||||
- a new helper computes node position/orientation updates for HRTF
|
|
||||||
|
|
||||||
### 4. Add listener orientation support
|
|
||||||
|
|
||||||
HRTF only becomes meaningful if the listener orientation is updated.
|
|
||||||
|
|
||||||
The natural mapping here is:
|
|
||||||
|
|
||||||
- listener position: player `x`, `y`
|
|
||||||
- listener forward direction: player facing / heading
|
|
||||||
|
|
||||||
If the grid does not currently track a stable listening orientation outside movement, define one explicitly and keep it updated in the main loop or audio engine update path.
|
|
||||||
|
|
||||||
Without listener orientation, HRTF will still spatialize left/right, but front/back cues will be much weaker and less intentional.
|
|
||||||
|
|
||||||
### 5. Preserve current item and source features
|
|
||||||
|
|
||||||
This is the main guardrail for the change.
|
|
||||||
|
|
||||||
The HRTF work should preserve existing behavior for:
|
|
||||||
|
|
||||||
- radio channel routing and radio effect chains
|
|
||||||
- item emit timing, looping, delays, and effect chains
|
|
||||||
- piano voice handling and release behavior
|
|
||||||
- peer voice listen gain
|
|
||||||
- directional cones / rear attenuation
|
|
||||||
- distance-gated subscribe / unsubscribe behavior
|
|
||||||
- current per-source positions on the grid
|
|
||||||
|
|
||||||
The correct implementation is:
|
|
||||||
|
|
||||||
- keep source-specific runtimes where they still own real behavior
|
|
||||||
- centralize only the spatial rendering layer they share
|
|
||||||
|
|
||||||
If a piece of code looks similar but still owns different behavior, treat it as separate unless the duplication is clearly only about node construction or coordinate updates.
|
|
||||||
|
|
||||||
### 6. Convert spatial sources incrementally
|
|
||||||
|
|
||||||
Recommended order:
|
|
||||||
|
|
||||||
1. peer voice
|
|
||||||
2. one-shot world sounds in `AudioEngine`
|
|
||||||
3. radios
|
|
||||||
4. item emitters
|
|
||||||
5. piano synth
|
|
||||||
|
|
||||||
That order gives the largest user impact first and keeps the early work in the most centralized code.
|
|
||||||
|
|
||||||
### 7. Preserve current directional muffling/effects behavior
|
|
||||||
|
|
||||||
Directional cones and muffling already exist in the current spatial logic for items/radios.
|
|
||||||
|
|
||||||
Do not move that responsibility into `PannerNode` alone.
|
|
||||||
|
|
||||||
Instead:
|
|
||||||
|
|
||||||
- keep current directional attenuation logic in `spatial.ts`
|
|
||||||
- optionally later map some of it to `coneInnerAngle`, `coneOuterAngle`, and `coneOuterGain`
|
|
||||||
|
|
||||||
For the first pass, software-side directional gain shaping is simpler and more predictable.
|
|
||||||
|
|
||||||
## Important Realities
|
|
||||||
|
|
||||||
### Not every sound should use HRTF
|
|
||||||
|
|
||||||
These should remain non-spatial:
|
|
||||||
|
|
||||||
- UI confirmations/cancels
|
|
||||||
- local footstep/self-confirmation sounds
|
|
||||||
- menu/help feedback
|
|
||||||
|
|
||||||
HRTF should apply only to world-positioned sounds.
|
|
||||||
|
|
||||||
### Radio and emitters are continuous sources
|
|
||||||
|
|
||||||
These are not one-shot sounds. For them, the implementation needs:
|
|
||||||
|
|
||||||
- persistent `PannerNode` lifecycles
|
|
||||||
- regular listener/source position updates
|
|
||||||
- no audible zippering/clicks on movement updates
|
|
||||||
|
|
||||||
That is why shared spatial node handling matters.
|
|
||||||
|
|
||||||
### Voice is the best early target
|
|
||||||
|
|
||||||
Peer voice already has:
|
|
||||||
|
|
||||||
- per-peer runtime state
|
|
||||||
- continuous streaming
|
|
||||||
- position updates every frame
|
|
||||||
|
|
||||||
So it is the strongest real-world test for whether HRTF improves the grid.
|
|
||||||
|
|
||||||
## Suggested First Pass Scope
|
|
||||||
|
|
||||||
First pass should do only this:
|
|
||||||
|
|
||||||
- add `classic` vs `hrtf` spatial mode
|
|
||||||
- add a temporary `H` toggle for that mode
|
|
||||||
- support HRTF for:
|
|
||||||
- peer voice
|
|
||||||
- remote one-shot spatial samples
|
|
||||||
- radios
|
|
||||||
- item emitters
|
|
||||||
- leave piano on the old model until the shared helper is stable if needed
|
|
||||||
|
|
||||||
That gets most of the value without forcing every audio path to change at once.
|
|
||||||
|
|
||||||
## User Settings / Commands
|
|
||||||
|
|
||||||
The current client already stores output mode in [`settingsStore.ts`](/home/jjm/code/chgrid/client/src/settings/settingsStore.ts) and toggles it from [`main.ts`](/home/jjm/code/chgrid/client/src/main.ts).
|
|
||||||
|
|
||||||
I would add:
|
|
||||||
|
|
||||||
- persisted spatial mode setting
|
|
||||||
- one command to cycle:
|
|
||||||
- `classic`
|
|
||||||
- `hrtf`
|
|
||||||
|
|
||||||
For the first pass, mapping that command to `H` is reasonable.
|
|
||||||
|
|
||||||
Keep `mono` / `stereo` separate.
|
|
||||||
|
|
||||||
If needed, HRTF mode can automatically degrade to `classic` when browser support is missing.
|
|
||||||
|
|
||||||
## Testing Plan
|
|
||||||
|
|
||||||
### Functional
|
|
||||||
|
|
||||||
- peer voice moves around listener and remains audible
|
|
||||||
- front/back changes are perceptible with facing changes
|
|
||||||
- radio/item emitters move cleanly with no disconnects
|
|
||||||
- clock announcements and remote footsteps still play
|
|
||||||
- mono output still disables spatial left/right behavior cleanly
|
|
||||||
|
|
||||||
### Regression
|
|
||||||
|
|
||||||
- no breaks in existing media/effect chains
|
|
||||||
- no stuck nodes after item cleanup / peer disconnect
|
|
||||||
- no crashes on browsers without useful `PannerNode` support
|
|
||||||
|
|
||||||
### Listening
|
|
||||||
|
|
||||||
- test with headphones first
|
|
||||||
- verify that HRTF does not make near-field sounds too quiet or too harsh
|
|
||||||
- verify that movement/facing updates do not create pumping artifacts
|
|
||||||
|
|
||||||
## Recommended Implementation Order
|
|
||||||
|
|
||||||
1. Add spatial mode setting and persistence.
|
|
||||||
2. Add shared spatial node runtime/helper.
|
|
||||||
3. Convert peer voice and one-shot spatial samples in `AudioEngine`.
|
|
||||||
4. Convert radios and item emitters.
|
|
||||||
5. Tune listener orientation and gain curves.
|
|
||||||
6. Convert piano if the result still feels worth it after the first pass.
|
|
||||||
|
|
||||||
## Bottom Line
|
|
||||||
|
|
||||||
HRTF is possible here, but the codebase is not yet one-node-centralized enough to make it a trivial switch.
|
|
||||||
|
|
||||||
The good news is that the architecture is already close:
|
|
||||||
|
|
||||||
- common spatial math exists
|
|
||||||
- spatial sources are clearly identified
|
|
||||||
- most of the remaining work is consolidating node creation and adding listener/source position handling for `PannerNode`
|
|
||||||
|
|
||||||
That makes this a realistic next-step audio feature, not a speculative rewrite.
|
|
||||||
|
|
||||||
The key design rule should be:
|
|
||||||
|
|
||||||
- centralize the spatial rendering layer where it is truly shared
|
|
||||||
- preserve all existing per-source and per-item behavior unless it is demonstrably duplicate
|
|
||||||
@@ -3,8 +3,7 @@ import { getItemTypeGlobalProperties } from '../items/itemRegistry';
|
|||||||
import { AudioEngine } from './audioEngine';
|
import { AudioEngine } from './audioEngine';
|
||||||
import { connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects';
|
import { connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects';
|
||||||
import { normalizeRadioEffect, normalizeRadioEffectValue } from './radioStationRuntime';
|
import { normalizeRadioEffect, normalizeRadioEffectValue } from './radioStationRuntime';
|
||||||
import { resolveSpatialMix } from './spatial';
|
import { applySpatialMixToNodes, resolveSpatialMix } from './spatial';
|
||||||
import { applySpatialOutput, createSpatialOutputRuntime, disconnectSpatialOutputRuntime, type SpatialOutputRuntime } from './spatialOutput';
|
|
||||||
import { volumePercentToGain } from './volume';
|
import { volumePercentToGain } from './volume';
|
||||||
|
|
||||||
type EmitOutput = {
|
type EmitOutput = {
|
||||||
@@ -19,7 +18,7 @@ type EmitOutput = {
|
|||||||
initialDelaySeconds: number;
|
initialDelaySeconds: number;
|
||||||
loopDelaySeconds: number;
|
loopDelaySeconds: number;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
spatialOutput: SpatialOutputRuntime;
|
panner: StereoPannerNode | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EmitResumeState = {
|
type EmitResumeState = {
|
||||||
@@ -133,7 +132,7 @@ export class ItemEmitRuntime {
|
|||||||
output.effectInput.disconnect();
|
output.effectInput.disconnect();
|
||||||
disconnectEffectRuntime(output.effectRuntime);
|
disconnectEffectRuntime(output.effectRuntime);
|
||||||
output.gain.disconnect();
|
output.gain.disconnect();
|
||||||
disconnectSpatialOutputRuntime(output.spatialOutput);
|
output.panner?.disconnect();
|
||||||
this.outputs.delete(itemId);
|
this.outputs.delete(itemId);
|
||||||
}
|
}
|
||||||
this.pendingEmitStarts.delete(itemId);
|
this.pendingEmitStarts.delete(itemId);
|
||||||
@@ -218,6 +217,7 @@ export class ItemEmitRuntime {
|
|||||||
const effectInput = audioCtx.createGain();
|
const effectInput = audioCtx.createGain();
|
||||||
const gain = audioCtx.createGain();
|
const gain = audioCtx.createGain();
|
||||||
gain.gain.value = 0;
|
gain.gain.value = 0;
|
||||||
|
let panner: StereoPannerNode | null = null;
|
||||||
source.connect(effectInput);
|
source.connect(effectInput);
|
||||||
const effect = normalizeRadioEffect(item.params.emitEffect);
|
const effect = normalizeRadioEffect(item.params.emitEffect);
|
||||||
const effectValue = normalizeRadioEffectValue(item.params.emitEffectValue);
|
const effectValue = normalizeRadioEffectValue(item.params.emitEffectValue);
|
||||||
@@ -321,13 +321,12 @@ export class ItemEmitRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination;
|
const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination;
|
||||||
const spatialOutput = createSpatialOutputRuntime({
|
if (this.audio.supportsStereoPanner()) {
|
||||||
audioCtx,
|
panner = audioCtx.createStereoPanner();
|
||||||
inputNode: gain,
|
gain.connect(panner).connect(destination);
|
||||||
destination,
|
} else {
|
||||||
outputMode: this.audio.getOutputMode(),
|
gain.connect(destination);
|
||||||
spatialMode: this.audio.getSpatialMode(),
|
}
|
||||||
});
|
|
||||||
this.outputs.set(item.id, {
|
this.outputs.set(item.id, {
|
||||||
soundUrl,
|
soundUrl,
|
||||||
element,
|
element,
|
||||||
@@ -340,7 +339,7 @@ export class ItemEmitRuntime {
|
|||||||
initialDelaySeconds,
|
initialDelaySeconds,
|
||||||
loopDelaySeconds,
|
loopDelaySeconds,
|
||||||
gain,
|
gain,
|
||||||
spatialOutput,
|
panner,
|
||||||
});
|
});
|
||||||
if (!matchingResumeState && !this.nextEmitStartAtMs.has(item.id) && initialDelaySeconds > 0) {
|
if (!matchingResumeState && !this.nextEmitStartAtMs.has(item.id) && initialDelaySeconds > 0) {
|
||||||
this.nextEmitStartAtMs.set(item.id, Date.now() + initialDelaySeconds * 1000);
|
this.nextEmitStartAtMs.set(item.id, Date.now() + initialDelaySeconds * 1000);
|
||||||
@@ -423,15 +422,13 @@ export class ItemEmitRuntime {
|
|||||||
});
|
});
|
||||||
const emitVolume = volumePercentToGain(item.params.emitVolume, 100);
|
const emitVolume = volumePercentToGain(item.params.emitVolume, 100);
|
||||||
const scaledMix = mix ? { ...mix, gain: mix.gain * emitVolume } : null;
|
const scaledMix = mix ? { ...mix, gain: mix.gain * emitVolume } : null;
|
||||||
applySpatialOutput({
|
applySpatialMixToNodes({
|
||||||
audioCtx,
|
audioCtx,
|
||||||
runtime: output.spatialOutput,
|
|
||||||
gainNode: output.gain,
|
gainNode: output.gain,
|
||||||
|
pannerNode: output.panner,
|
||||||
mix: scaledMix,
|
mix: scaledMix,
|
||||||
outputMode: this.audio.getOutputMode(),
|
outputMode: this.audio.getOutputMode(),
|
||||||
transition: 'target',
|
transition: 'target',
|
||||||
dx: item.x - playerPosition.x,
|
|
||||||
dy: item.y - playerPosition.y,
|
|
||||||
});
|
});
|
||||||
this.tryStartEmitPlayback(itemId, output.element);
|
this.tryStartEmitPlayback(itemId, output.element);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
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';
|
import { applySpatialMixToNodes, resolveSpatialMix } from './spatial';
|
||||||
import { applySpatialOutput, createSpatialOutputRuntime, disconnectSpatialOutputRuntime, type SpatialOutputRuntime } from './spatialOutput';
|
|
||||||
import { volumePercentToGain } from './volume';
|
import { volumePercentToGain } from './volume';
|
||||||
import { freshRadioPlaybackUrl, getProxyUrlForMedia, shouldProxyRadioStreamUrl } from './mediaUrl';
|
|
||||||
|
|
||||||
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];
|
||||||
|
const APP_BASE_PATH = import.meta.env.BASE_URL ?? '/';
|
||||||
|
|
||||||
type SharedRadioSource = {
|
type SharedRadioSource = {
|
||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
@@ -30,7 +29,7 @@ type ItemRadioOutput = {
|
|||||||
effect: EffectId;
|
effect: EffectId;
|
||||||
effectValue: number;
|
effectValue: number;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
spatialOutput: SpatialOutputRuntime;
|
panner: StereoPannerNode | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeRadioEffect(effect: unknown): EffectId {
|
export function normalizeRadioEffect(effect: unknown): EffectId {
|
||||||
@@ -112,12 +111,50 @@ function connectRadioChannelSource(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns whether a hostname belongs to Dropbox domains that need proxy support. */
|
||||||
|
function isDropboxHost(hostname: string): boolean {
|
||||||
|
const host = hostname.toLowerCase();
|
||||||
|
return host.endsWith('dropbox.com') || host.endsWith('dropboxusercontent.com');
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldProxyStreamUrl(streamUrl: string): boolean {
|
export function shouldProxyStreamUrl(streamUrl: string): boolean {
|
||||||
return shouldProxyRadioStreamUrl(streamUrl);
|
try {
|
||||||
|
const parsed = new URL(streamUrl);
|
||||||
|
if (
|
||||||
|
parsed.origin === window.location.origin &&
|
||||||
|
parsed.pathname.toLowerCase().endsWith('/media_proxy.php')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parsed.protocol === 'http:') return true;
|
||||||
|
if (parsed.protocol === 'https:' && isDropboxHost(parsed.hostname)) return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProxyUrlForStream(streamUrl: string): string {
|
export function getProxyUrlForStream(streamUrl: string): string {
|
||||||
return getProxyUrlForMedia(streamUrl);
|
const normalizedBase = APP_BASE_PATH.endsWith('/') ? APP_BASE_PATH : `${APP_BASE_PATH}/`;
|
||||||
|
const proxy = new URL(`${normalizedBase}media_proxy.php`, window.location.origin);
|
||||||
|
proxy.searchParams.set('url', streamUrl);
|
||||||
|
return proxy.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends a cache-buster query parameter to avoid stale stream buffers between sessions. */
|
||||||
|
function freshStreamUrl(streamUrl: string): string {
|
||||||
|
const playbackSource = shouldProxyStreamUrl(streamUrl) ? getProxyUrlForStream(streamUrl) : streamUrl;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(playbackSource);
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
if (hostname.endsWith('dropbox.com') || hostname.endsWith('dropboxusercontent.com')) {
|
||||||
|
return playbackSource;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Leave non-URL strings to the generic cache-buster behavior below.
|
||||||
|
}
|
||||||
|
const separator = playbackSource.includes('?') ? '&' : '?';
|
||||||
|
return `${playbackSource}${separator}chgrid_start=${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RadioSpatialConfig = {
|
type RadioSpatialConfig = {
|
||||||
@@ -170,7 +207,7 @@ export class RadioStationRuntime {
|
|||||||
output.effectInput.disconnect();
|
output.effectInput.disconnect();
|
||||||
disconnectEffectRuntime(output.effectRuntime);
|
disconnectEffectRuntime(output.effectRuntime);
|
||||||
output.gain.disconnect();
|
output.gain.disconnect();
|
||||||
disconnectSpatialOutputRuntime(output.spatialOutput);
|
output.panner?.disconnect();
|
||||||
this.itemRadioOutputs.delete(itemId);
|
this.itemRadioOutputs.delete(itemId);
|
||||||
this.releaseSharedSource(output.streamUrl);
|
this.releaseSharedSource(output.streamUrl);
|
||||||
}
|
}
|
||||||
@@ -266,15 +303,13 @@ export class RadioStationRuntime {
|
|||||||
rearGain: 0.4,
|
rearGain: 0.4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
applySpatialOutput({
|
applySpatialMixToNodes({
|
||||||
audioCtx,
|
audioCtx,
|
||||||
runtime: output.spatialOutput,
|
|
||||||
gainNode: output.gain,
|
gainNode: output.gain,
|
||||||
|
pannerNode: output.panner,
|
||||||
mix,
|
mix,
|
||||||
outputMode: this.audio.getOutputMode(),
|
outputMode: this.audio.getOutputMode(),
|
||||||
transition: 'target',
|
transition: 'target',
|
||||||
dx: item.x - playerPosition.x,
|
|
||||||
dy: item.y - playerPosition.y,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,7 +352,7 @@ export class RadioStationRuntime {
|
|||||||
}
|
}
|
||||||
const audioCtx = this.audio.context;
|
const audioCtx = this.audio.context;
|
||||||
if (!audioCtx) return null;
|
if (!audioCtx) return null;
|
||||||
const element = new Audio(freshRadioPlaybackUrl(streamUrl));
|
const element = new Audio(freshStreamUrl(streamUrl));
|
||||||
element.crossOrigin = 'anonymous';
|
element.crossOrigin = 'anonymous';
|
||||||
element.loop = true;
|
element.loop = true;
|
||||||
element.preload = 'none';
|
element.preload = 'none';
|
||||||
@@ -405,13 +440,13 @@ export class RadioStationRuntime {
|
|||||||
const effectValue = normalizeRadioEffectValue(item.params.mediaEffectValue);
|
const effectValue = normalizeRadioEffectValue(item.params.mediaEffectValue);
|
||||||
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
|
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
|
||||||
const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination;
|
const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination;
|
||||||
const spatialOutput = createSpatialOutputRuntime({
|
let panner: StereoPannerNode | null = null;
|
||||||
audioCtx,
|
if (this.audio.supportsStereoPanner()) {
|
||||||
inputNode: gain,
|
panner = audioCtx.createStereoPanner();
|
||||||
destination,
|
gain.connect(panner).connect(destination);
|
||||||
outputMode: this.audio.getOutputMode(),
|
} else {
|
||||||
spatialMode: this.audio.getSpatialMode(),
|
gain.connect(destination);
|
||||||
});
|
}
|
||||||
this.itemRadioOutputs.set(item.id, {
|
this.itemRadioOutputs.set(item.id, {
|
||||||
streamUrl,
|
streamUrl,
|
||||||
channel,
|
channel,
|
||||||
@@ -426,7 +461,7 @@ export class RadioStationRuntime {
|
|||||||
effect,
|
effect,
|
||||||
effectValue,
|
effectValue,
|
||||||
gain,
|
gain,
|
||||||
spatialOutput,
|
panner,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import { SPATIAL_RAMP_SECONDS, SPATIAL_TIME_CONSTANT_SECONDS, type SpatialMixResult } from './spatial';
|
|
||||||
|
|
||||||
export type SpatialOutputMode = 'mono' | 'stereo';
|
|
||||||
export type SpatialRenderMode = 'classic' | 'hrtf';
|
|
||||||
|
|
||||||
export type SpatialOutputRuntime =
|
|
||||||
| { kind: 'none' }
|
|
||||||
| { kind: 'classic'; node: StereoPannerNode }
|
|
||||||
| { kind: 'hrtf'; node: PannerNode };
|
|
||||||
|
|
||||||
type CreateSpatialOutputOptions = {
|
|
||||||
audioCtx: AudioContext;
|
|
||||||
inputNode: AudioNode;
|
|
||||||
destination: AudioNode;
|
|
||||||
outputMode: SpatialOutputMode;
|
|
||||||
spatialMode: SpatialRenderMode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ApplySpatialOutputOptions = {
|
|
||||||
audioCtx: AudioContext;
|
|
||||||
runtime: SpatialOutputRuntime;
|
|
||||||
gainNode: GainNode;
|
|
||||||
mix: SpatialMixResult | null;
|
|
||||||
outputMode: SpatialOutputMode;
|
|
||||||
transition: 'linear' | 'target';
|
|
||||||
dx?: number;
|
|
||||||
dy?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Creates one spatial output stage using either stereo pan or HRTF panning. */
|
|
||||||
export function createSpatialOutputRuntime(options: CreateSpatialOutputOptions): SpatialOutputRuntime {
|
|
||||||
const { audioCtx, inputNode, destination, outputMode, spatialMode } = options;
|
|
||||||
if (outputMode === 'mono') {
|
|
||||||
inputNode.connect(destination);
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (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.coneInnerAngle = 360;
|
|
||||||
panner.coneOuterAngle = 360;
|
|
||||||
panner.coneOuterGain = 1;
|
|
||||||
panner.positionX.setValueAtTime(0, audioCtx.currentTime);
|
|
||||||
panner.positionY.setValueAtTime(0, audioCtx.currentTime);
|
|
||||||
panner.positionZ.setValueAtTime(-1, audioCtx.currentTime);
|
|
||||||
inputNode.connect(panner).connect(destination);
|
|
||||||
return { kind: 'hrtf', node: panner };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof audioCtx.createStereoPanner === 'function') {
|
|
||||||
const panner = audioCtx.createStereoPanner();
|
|
||||||
inputNode.connect(panner).connect(destination);
|
|
||||||
return { kind: 'classic', node: panner };
|
|
||||||
}
|
|
||||||
|
|
||||||
inputNode.connect(destination);
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Disconnects the current spatial output stage. */
|
|
||||||
export function disconnectSpatialOutputRuntime(runtime: SpatialOutputRuntime): void {
|
|
||||||
if (runtime.kind === 'none') return;
|
|
||||||
runtime.node.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies one resolved spatial mix to either stereo or HRTF output nodes. */
|
|
||||||
export function applySpatialOutput(options: ApplySpatialOutputOptions): void {
|
|
||||||
const { audioCtx, runtime, gainNode, mix, outputMode, transition, dx = 0, dy = 0 } = options;
|
|
||||||
const gainValue = mix?.gain ?? 0;
|
|
||||||
|
|
||||||
if (transition === 'linear') {
|
|
||||||
gainNode.gain.cancelScheduledValues(audioCtx.currentTime);
|
|
||||||
gainNode.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + SPATIAL_RAMP_SECONDS);
|
|
||||||
} else {
|
|
||||||
gainNode.gain.setTargetAtTime(gainValue, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runtime.kind === 'none') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runtime.kind === 'classic') {
|
|
||||||
const panValue = outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, mix?.pan ?? 0));
|
|
||||||
if (transition === 'linear') {
|
|
||||||
runtime.node.pan.cancelScheduledValues(audioCtx.currentTime);
|
|
||||||
runtime.node.pan.linearRampToValueAtTime(panValue, audioCtx.currentTime + SPATIAL_RAMP_SECONDS);
|
|
||||||
} else {
|
|
||||||
runtime.node.pan.setTargetAtTime(panValue, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetX = dx;
|
|
||||||
const targetZ = -dy;
|
|
||||||
if (transition === 'linear') {
|
|
||||||
runtime.node.positionX.cancelScheduledValues(audioCtx.currentTime);
|
|
||||||
runtime.node.positionZ.cancelScheduledValues(audioCtx.currentTime);
|
|
||||||
runtime.node.positionX.linearRampToValueAtTime(targetX, audioCtx.currentTime + SPATIAL_RAMP_SECONDS);
|
|
||||||
runtime.node.positionZ.linearRampToValueAtTime(targetZ, audioCtx.currentTime + SPATIAL_RAMP_SECONDS);
|
|
||||||
} else {
|
|
||||||
runtime.node.positionX.setTargetAtTime(targetX, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS);
|
|
||||||
runtime.node.positionZ.setTargetAtTime(targetZ, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS);
|
|
||||||
}
|
|
||||||
runtime.node.positionY.setValueAtTime(0, audioCtx.currentTime);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ export type MainModeCommand =
|
|||||||
| 'editNickname'
|
| 'editNickname'
|
||||||
| 'toggleMute'
|
| 'toggleMute'
|
||||||
| 'toggleOutputMode'
|
| 'toggleOutputMode'
|
||||||
| 'toggleSpatialMode'
|
|
||||||
| 'toggleLoopback'
|
| 'toggleLoopback'
|
||||||
| 'toggleVoiceLayer'
|
| 'toggleVoiceLayer'
|
||||||
| 'toggleItemLayer'
|
| 'toggleItemLayer'
|
||||||
@@ -46,7 +45,6 @@ export type MainModeCommand =
|
|||||||
*/
|
*/
|
||||||
export function resolveMainModeCommand(code: string, shiftKey: boolean): MainModeCommand | null {
|
export function resolveMainModeCommand(code: string, shiftKey: boolean): MainModeCommand | null {
|
||||||
if (code === 'KeyN') return shiftKey ? null : 'editNickname';
|
if (code === 'KeyN') return shiftKey ? null : 'editNickname';
|
||||||
if (code === 'KeyH') return shiftKey ? null : 'toggleSpatialMode';
|
|
||||||
if (code === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute';
|
if (code === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute';
|
||||||
if (code === 'Digit1') return shiftKey ? 'toggleLoopback' : 'toggleVoiceLayer';
|
if (code === 'Digit1') return shiftKey ? 'toggleLoopback' : 'toggleVoiceLayer';
|
||||||
if (code === 'Digit2') return shiftKey ? null : 'toggleItemLayer';
|
if (code === 'Digit2') return shiftKey ? null : 'toggleItemLayer';
|
||||||
|
|||||||
@@ -38,14 +38,6 @@ const MAIN_MODE_COMMANDS: MainModeCommandDescriptor[] = [
|
|||||||
section: 'Audio',
|
section: 'Audio',
|
||||||
isAvailable: () => true,
|
isAvailable: () => true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'toggleSpatialMode',
|
|
||||||
label: 'Toggle classic or HRTF spatial audio',
|
|
||||||
shortcut: 'H',
|
|
||||||
tooltip: 'Switch between classic stereo panning and HRTF spatial audio.',
|
|
||||||
section: 'Audio',
|
|
||||||
isAvailable: () => true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'toggleOutputMode',
|
id: 'toggleOutputMode',
|
||||||
label: 'Toggle stereo or mono output',
|
label: 'Toggle stereo or mono output',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
} from './audio/effects';
|
} from './audio/effects';
|
||||||
import {
|
import {
|
||||||
RadioStationRuntime,
|
RadioStationRuntime,
|
||||||
|
getProxyUrlForStream,
|
||||||
|
shouldProxyStreamUrl,
|
||||||
} from './audio/radioStationRuntime';
|
} from './audio/radioStationRuntime';
|
||||||
import { getProxyUrlForMedia, shouldProxyExternalMediaUrl } from './audio/mediaUrl';
|
import { getProxyUrlForMedia, shouldProxyExternalMediaUrl } from './audio/mediaUrl';
|
||||||
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
||||||
@@ -259,7 +261,6 @@ let lastFocusedElement: Element | null = null;
|
|||||||
let lastAnnouncementText = '';
|
let lastAnnouncementText = '';
|
||||||
let lastAnnouncementAt = 0;
|
let lastAnnouncementAt = 0;
|
||||||
let outputMode = settings.loadOutputMode();
|
let outputMode = settings.loadOutputMode();
|
||||||
let spatialMode = settings.loadSpatialMode();
|
|
||||||
let activeGridName = DEFAULT_GRID_NAME;
|
let activeGridName = DEFAULT_GRID_NAME;
|
||||||
let activeWelcomeMessage = DEFAULT_WELCOME_MESSAGE;
|
let activeWelcomeMessage = DEFAULT_WELCOME_MESSAGE;
|
||||||
const messageBuffer: string[] = [];
|
const messageBuffer: string[] = [];
|
||||||
@@ -354,7 +355,6 @@ const itemBehaviorRegistry = new ItemBehaviorRegistry({
|
|||||||
});
|
});
|
||||||
|
|
||||||
audio.setOutputMode(outputMode);
|
audio.setOutputMode(outputMode);
|
||||||
audio.setSpatialMode(spatialMode);
|
|
||||||
|
|
||||||
loadEffectLevels();
|
loadEffectLevels();
|
||||||
loadAudioLayerState();
|
loadAudioLayerState();
|
||||||
@@ -716,17 +716,6 @@ async function applyAudioLayerState(): Promise<void> {
|
|||||||
await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values(), listenerPosition);
|
await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values(), listenerPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuilds active spatial audio node graphs after output or spatial rendering mode changes. */
|
|
||||||
async function rebuildSpatialAudioGraphs(): Promise<void> {
|
|
||||||
peerManager.suspendRemoteAudio();
|
|
||||||
if (audioLayers.voice) {
|
|
||||||
await peerManager.resumeRemoteAudio();
|
|
||||||
}
|
|
||||||
radioRuntime.cleanupAll();
|
|
||||||
itemEmitRuntime.cleanupAll();
|
|
||||||
await refreshAudioSubscriptionsAt({ x: state.player.x, y: state.player.y }, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refreshes distance-gated radio/item stream subscriptions for a listener position. */
|
/** Refreshes distance-gated radio/item stream subscriptions for a listener position. */
|
||||||
async function refreshAudioSubscriptionsAt(listenerPosition: { x: number; y: number }, force = false): Promise<void> {
|
async function refreshAudioSubscriptionsAt(listenerPosition: { x: number; y: number }, force = false): Promise<void> {
|
||||||
await refreshAudioSubscriptionsForListeners([listenerPosition], force);
|
await refreshAudioSubscriptionsForListeners([listenerPosition], force);
|
||||||
@@ -1747,15 +1736,6 @@ function toggleOutputModeCommand(): void {
|
|||||||
mediaSession.saveOutputMode(outputMode);
|
mediaSession.saveOutputMode(outputMode);
|
||||||
updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.');
|
updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.');
|
||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
void rebuildSpatialAudioGraphs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSpatialModeCommand(): void {
|
|
||||||
spatialMode = audio.toggleSpatialMode();
|
|
||||||
settings.saveSpatialMode(spatialMode);
|
|
||||||
updateStatus(spatialMode === 'hrtf' ? 'HRTF spatial audio.' : 'Classic spatial audio.');
|
|
||||||
audio.sfxUiBlip();
|
|
||||||
void rebuildSpatialAudioGraphs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLoopbackCommand(): void {
|
function toggleLoopbackCommand(): void {
|
||||||
@@ -2065,7 +2045,6 @@ function escapeCommand(): void {
|
|||||||
const mainModeCommandHandlers: Record<MainModeCommand, () => void> = {
|
const mainModeCommandHandlers: Record<MainModeCommand, () => void> = {
|
||||||
editNickname: openNicknameEditor,
|
editNickname: openNicknameEditor,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
toggleSpatialMode: toggleSpatialModeCommand,
|
|
||||||
toggleOutputMode: toggleOutputModeCommand,
|
toggleOutputMode: toggleOutputModeCommand,
|
||||||
toggleLoopback: toggleLoopbackCommand,
|
toggleLoopback: toggleLoopbackCommand,
|
||||||
toggleVoiceLayer: () => toggleAudioLayer('voice'),
|
toggleVoiceLayer: () => toggleAudioLayer('voice'),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const AUDIO_OUTPUT_STORAGE_KEY = 'chatGridAudioOutputDeviceId';
|
|||||||
const AUDIO_INPUT_NAME_STORAGE_KEY = 'chatGridAudioInputDeviceName';
|
const AUDIO_INPUT_NAME_STORAGE_KEY = 'chatGridAudioInputDeviceName';
|
||||||
const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName';
|
const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName';
|
||||||
const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode';
|
const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode';
|
||||||
const AUDIO_SPATIAL_MODE_STORAGE_KEY = 'chatGridAudioSpatialMode';
|
|
||||||
const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers';
|
const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers';
|
||||||
const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain';
|
const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain';
|
||||||
const MASTER_VOLUME_STORAGE_KEY = 'chatGridMasterVolume';
|
const MASTER_VOLUME_STORAGE_KEY = 'chatGridMasterVolume';
|
||||||
@@ -147,14 +146,6 @@ export class SettingsStore {
|
|||||||
localStorage.setItem(AUDIO_OUTPUT_MODE_STORAGE_KEY, value);
|
localStorage.setItem(AUDIO_OUTPUT_MODE_STORAGE_KEY, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSpatialMode(): 'classic' | 'hrtf' {
|
|
||||||
return localStorage.getItem(AUDIO_SPATIAL_MODE_STORAGE_KEY) === 'hrtf' ? 'hrtf' : 'classic';
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSpatialMode(value: 'classic' | 'hrtf'): void {
|
|
||||||
localStorage.setItem(AUDIO_SPATIAL_MODE_STORAGE_KEY, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAudioDevicePreferences(): AudioDevicePreferences {
|
loadAudioDevicePreferences(): AudioDevicePreferences {
|
||||||
return {
|
return {
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ This document is the authoritative keymap for the client.
|
|||||||
- `V`: Set microphone gain
|
- `V`: Set microphone gain
|
||||||
- `Shift+V`: Microphone calibration
|
- `Shift+V`: Microphone calibration
|
||||||
- `M`: Mute/unmute local microphone
|
- `M`: Mute/unmute local microphone
|
||||||
- `H`: Toggle classic/HRTF spatial audio
|
|
||||||
- `Shift+M`: Toggle stereo/mono output
|
- `Shift+M`: Toggle stereo/mono output
|
||||||
- `Shift+1` (`!`): Toggle loopback monitor
|
- `Shift+1` (`!`): Toggle loopback monitor
|
||||||
- `1`: Toggle voice layer
|
- `1`: Toggle voice layer
|
||||||
|
|||||||
Reference in New Issue
Block a user