From f3a7cc90a748459bc8cf2c3b2ef02f751d14069a Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sun, 22 Feb 2026 21:37:15 -0500 Subject: [PATCH] Track spatial one-shots against listener movement --- client/public/version.js | 2 +- client/src/audio/audioEngine.ts | 86 +++++++++++++++++++++++++++------ client/src/main.ts | 6 ++- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index a9fe6d1..619caf5 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.22 R193"; +window.CHGRID_WEB_VERSION = "2026.02.22 R194"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index cfb4710..a8b2b8b 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -32,6 +32,14 @@ type OutputMode = 'stereo' | 'mono'; const SPATIAL_RAMP_SECONDS = 0.2; const SPATIAL_TIME_CONSTANT_SECONDS = SPATIAL_RAMP_SECONDS / 3; const ONE_SHOT_ATTACK_SECONDS = 0.02; +type ActiveSpatialSampleRuntime = { + sourceX: number; + sourceY: number; + baseGain: number; + gainNode: GainNode; + pannerNode: StereoPannerNode | null; + sourceNode: AudioBufferSourceNode; +}; export class AudioEngine { private audioCtx: AudioContext | null = null; @@ -39,6 +47,7 @@ export class AudioEngine { private sfxGainNode: GainNode | null = null; private readonly sampleCache = new Map(); private readonly sampleLoaders = new Map>(); + private readonly activeSpatialSamples = new Set(); private outboundSource: MediaStreamAudioSourceNode | null = null; private outboundInputGain: GainNode | null = null; @@ -311,6 +320,14 @@ export class AudioEngine { } } + /** Updates active one-shot spatial sample gain/pan against current listener position. */ + updateSpatialSamples(playerPosition: { x: number; y: number }): void { + if (!this.audioCtx) return; + for (const sample of Array.from(this.activeSpatialSamples)) { + this.applySpatialSampleRuntime(sample, playerPosition); + } + } + sfxLocate(peer: { x: number; y: number }): void { this.playSound({ freq: 880, duration: 0.2, type: 'sine', gain: 0.5, sourcePosition: peer }); } @@ -339,34 +356,50 @@ export class AudioEngine { this.playSound({ freq: 880, duration: 0.12, type: 'sine', gain: 0.45 }); } - async playSpatialSample(url: string, sourcePosition: { x: number; y: number }, gain = 1): Promise { + async playSpatialSample( + url: string, + sourcePosition: { x: number; y: number }, + playerPosition: { x: number; y: number }, + gain = 1, + ): Promise { await this.ensureContext(); const { audioCtx, sfxGainNode } = this; if (!audioCtx || !sfxGainNode) return; - const resolved = resolveSpatialMix({ - dx: sourcePosition.x, - dy: sourcePosition.y, - range: HEARING_RADIUS, - baseGain: gain, - }); - if (!resolved) return; - try { const buffer = await this.getSampleBuffer(url); const source = audioCtx.createBufferSource(); source.buffer = buffer; const gainNode = audioCtx.createGain(); gainNode.gain.setValueAtTime(0, audioCtx.currentTime); - gainNode.gain.setTargetAtTime(resolved.gain, audioCtx.currentTime, ONE_SHOT_ATTACK_SECONDS); source.connect(gainNode); - if (resolved.pan !== undefined && this.supportsStereoPanner() && this.outputMode === 'stereo') { - const panner = audioCtx.createStereoPanner(); - panner.pan.setValueAtTime(resolved.pan, audioCtx.currentTime); - gainNode.connect(panner).connect(sfxGainNode); + let pannerNode: StereoPannerNode | null = null; + if (this.supportsStereoPanner() && this.outputMode === 'stereo') { + pannerNode = audioCtx.createStereoPanner(); + gainNode.connect(pannerNode).connect(sfxGainNode); } else { gainNode.connect(sfxGainNode); } + const runtime: ActiveSpatialSampleRuntime = { + sourceX: sourcePosition.x, + sourceY: sourcePosition.y, + baseGain: gain, + gainNode, + pannerNode, + sourceNode: source, + }; + this.activeSpatialSamples.add(runtime); + this.applySpatialSampleRuntime(runtime, playerPosition, true); + source.onended = () => { + this.activeSpatialSamples.delete(runtime); + try { + source.disconnect(); + } catch { + // Ignore stale graph disconnects. + } + gainNode.disconnect(); + pannerNode?.disconnect(); + }; source.start(); } catch { // Ignore sample decode/load errors. @@ -523,6 +556,31 @@ export class AudioEngine { oscillator.stop(startTime + spec.duration); } + private applySpatialSampleRuntime( + sample: ActiveSpatialSampleRuntime, + playerPosition: { x: number; y: number }, + initial = false, + ): void { + if (!this.audioCtx) return; + const mix = resolveSpatialMix({ + dx: sample.sourceX - playerPosition.x, + dy: sample.sourceY - playerPosition.y, + range: HEARING_RADIUS, + baseGain: sample.baseGain, + }); + const gainValue = mix?.gain ?? 0; + if (initial) { + sample.gainNode.gain.setTargetAtTime(gainValue, this.audioCtx.currentTime, ONE_SHOT_ATTACK_SECONDS); + } else { + sample.gainNode.gain.setTargetAtTime(gainValue, this.audioCtx.currentTime, SPATIAL_TIME_CONSTANT_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.setTargetAtTime(resolvedPan, this.audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS); + } + } + private async getSampleBuffer(url: string): Promise { if (!this.audioCtx) { throw new Error('Audio context not initialized'); diff --git a/client/src/main.ts b/client/src/main.ts index 6cfe611..f31ee03 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1212,6 +1212,7 @@ function gameLoop(): void { void refreshAudioSubscriptions(); } audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y }); + audio.updateSpatialSamples({ x: state.player.x, y: state.player.y }); radioRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y }); itemEmitRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y }); state.cursorVisible = Math.floor(Date.now() / 500) % 2 === 0; @@ -1470,7 +1471,8 @@ const onAppMessage = createOnMessageHandler({ const gain = url === TELEPORT_START_SOUND_URL ? TELEPORT_START_GAIN : FOOTSTEP_GAIN; void audio.playSpatialSample( url, - { x: peerX - state.player.x, y: peerY - state.player.y }, + { x: peerX, y: peerY }, + { x: state.player.x, y: state.player.y }, gain, ); }, @@ -1496,7 +1498,7 @@ const onAppMessage = createOnMessageHandler({ playLocateToneAt: (x, y) => audio.sfxLocate({ x: x - state.player.x, y: y - state.player.y }), resolveIncomingSoundUrl, playIncomingItemUseSound: (url, x, y) => { - void audio.playSpatialSample(url, { x: x - state.player.x, y: y - state.player.y }, 1); + void audio.playSpatialSample(url, { x, y }, { x: state.player.x, y: state.player.y }, 1); }, });