diff --git a/client/public/version.js b/client/public/version.js index 002f19c..4b92972 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.25 R277"; +window.CHGRID_WEB_VERSION = "2026.02.25 R278"; // 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 b08fe0a..85abc45 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -7,7 +7,7 @@ import { type EffectId, type EffectRuntime, } from './effects'; -import { resolveSpatialMix } from './spatial'; +import { applySpatialMixToNodes, resolveSpatialMix, SPATIAL_RAMP_SECONDS, SPATIAL_TIME_CONSTANT_SECONDS } from './spatial'; export type SpatialPeerRuntime = { nickname: string; @@ -29,8 +29,6 @@ type SoundSpec = { }; 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; @@ -310,14 +308,16 @@ export class AudioEngine { nearFieldDistance: 1.5, nearFieldGain: 1, }); - const gainValue = mix?.gain ?? 0; const listenGain = Number.isFinite(peer.listenGain) ? Math.max(0, peer.listenGain as number) : 1; - const panValue = mix?.pan ?? 0; - peer.gain.gain.setTargetAtTime(gainValue * listenGain, this.audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS); - if (peer.panner) { - const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); - peer.panner.pan.setValueAtTime(resolvedPan, this.audioCtx.currentTime); - } + const scaledMix = mix ? { ...mix, gain: mix.gain * listenGain } : null; + applySpatialMixToNodes({ + audioCtx: this.audioCtx, + gainNode: peer.gain, + pannerNode: peer.panner ?? null, + mix: scaledMix, + outputMode: this.outputMode, + transition: 'target', + }); } } @@ -627,19 +627,24 @@ export class AudioEngine { range: sample.range, baseGain: sample.baseGain, }); - const gainValue = mix?.gain ?? 0; if (initial) { + const gainValue = mix?.gain ?? 0; sample.gainNode.gain.setTargetAtTime(gainValue, this.audioCtx.currentTime, ONE_SHOT_ATTACK_SECONDS); - } else { - sample.gainNode.gain.cancelScheduledValues(this.audioCtx.currentTime); - sample.gainNode.gain.linearRampToValueAtTime(gainValue, this.audioCtx.currentTime + SPATIAL_RAMP_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.cancelScheduledValues(this.audioCtx.currentTime); - sample.pannerNode.pan.linearRampToValueAtTime(resolvedPan, this.audioCtx.currentTime + SPATIAL_RAMP_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); + } + return; } + applySpatialMixToNodes({ + audioCtx: this.audioCtx, + gainNode: sample.gainNode, + pannerNode: sample.pannerNode, + mix, + outputMode: this.outputMode, + transition: 'linear', + }); } private async getSampleBuffer(url: string): Promise { diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index 3a6ccb1..0672d3e 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -3,7 +3,7 @@ import { getItemTypeGlobalProperties } from '../items/itemRegistry'; import { AudioEngine } from './audioEngine'; import { connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects'; import { normalizeRadioEffect, normalizeRadioEffectValue } from './radioStationRuntime'; -import { resolveSpatialMix } from './spatial'; +import { applySpatialMixToNodes, resolveSpatialMix } from './spatial'; import { volumePercentToGain } from './volume'; type EmitOutput = { @@ -27,8 +27,6 @@ type EmitSpatialConfig = { const ITEM_EMIT_BASE_GAIN = 1; const SUBSCRIBE_PRELOAD_SQUARES = 5; const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8; -const SPATIAL_RAMP_SECONDS = 0.2; -const SPATIAL_TIME_CONSTANT_SECONDS = SPATIAL_RAMP_SECONDS / 3; const STREAM_PLAY_RETRY_MS = 5000; const STREAM_PLAY_MAX_RETRIES = 6; const STREAM_PLAY_RESET_COOLDOWN_MS = 60000; @@ -231,15 +229,17 @@ export class ItemEmitRuntime { rearGain: 0.4, }, }); - const gainValue = mix?.gain ?? 0; - const panValue = mix?.pan ?? 0; const emitVolume = volumePercentToGain(item.params.emitVolume, 100); - output.gain.gain.setTargetAtTime(gainValue * emitVolume, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS); + const scaledMix = mix ? { ...mix, gain: mix.gain * emitVolume } : null; + applySpatialMixToNodes({ + audioCtx, + gainNode: output.gain, + pannerNode: output.panner, + mix: scaledMix, + outputMode: this.audio.getOutputMode(), + transition: 'linear', + }); this.tryStartEmitPlayback(itemId, output.element); - if (output.panner) { - const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); - output.panner.pan.setTargetAtTime(resolvedPan, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS); - } } } diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts index 392ea99..53257dc 100644 --- a/client/src/audio/radioStationRuntime.ts +++ b/client/src/audio/radioStationRuntime.ts @@ -1,7 +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'; +import { applySpatialMixToNodes, resolveSpatialMix } from './spatial'; import { volumePercentToGain } from './volume'; export const RADIO_CHANNEL_OPTIONS = ['stereo', 'mono', 'left', 'right'] as const; @@ -165,8 +165,6 @@ type RadioSpatialConfig = { const SUBSCRIBE_PRELOAD_SQUARES = 5; const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8; -const SPATIAL_RAMP_SECONDS = 0.2; -const SPATIAL_TIME_CONSTANT_SECONDS = SPATIAL_RAMP_SECONDS / 3; const STREAM_PLAY_RETRY_MS = 5000; const STREAM_PLAY_MAX_RETRIES = 6; const STREAM_PLAY_RESET_COOLDOWN_MS = 60000; @@ -305,13 +303,14 @@ export class RadioStationRuntime { rearGain: 0.4, }, }); - const gainValue = mix?.gain ?? 0; - const panValue = mix?.pan ?? 0; - output.gain.gain.setTargetAtTime(gainValue, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS); - if (output.panner) { - const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); - output.panner.pan.setTargetAtTime(resolvedPan, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS); - } + applySpatialMixToNodes({ + audioCtx, + gainNode: output.gain, + pannerNode: output.panner, + mix, + outputMode: this.audio.getOutputMode(), + transition: 'linear', + }); } } diff --git a/client/src/audio/spatial.ts b/client/src/audio/spatial.ts index e81cd06..fe6bda2 100644 --- a/client/src/audio/spatial.ts +++ b/client/src/audio/spatial.ts @@ -20,6 +20,45 @@ export type SpatialMixResult = { pan: number; }; +export const SPATIAL_RAMP_SECONDS = 0.2; +export const SPATIAL_TIME_CONSTANT_SECONDS = SPATIAL_RAMP_SECONDS / 3; + +type SpatialOutputMode = 'stereo' | 'mono'; + +type ApplySpatialNodeOptions = { + audioCtx: AudioContext; + gainNode: GainNode; + pannerNode: StereoPannerNode | null; + mix: SpatialMixResult | null; + outputMode: SpatialOutputMode; + transition: 'linear' | 'target'; +}; + +/** + * Applies one resolved spatial mix to gain/pan nodes with a shared transition profile. + */ +export function applySpatialMixToNodes(options: ApplySpatialNodeOptions): void { + const { audioCtx, gainNode, pannerNode, mix, outputMode, transition } = options; + const gainValue = mix?.gain ?? 0; + const panValue = mix?.pan ?? 0; + const resolvedPan = outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); + + if (transition === 'linear') { + gainNode.gain.cancelScheduledValues(audioCtx.currentTime); + gainNode.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + SPATIAL_RAMP_SECONDS); + if (pannerNode) { + pannerNode.pan.cancelScheduledValues(audioCtx.currentTime); + pannerNode.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + SPATIAL_RAMP_SECONDS); + } + return; + } + + gainNode.gain.setTargetAtTime(gainValue, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS); + if (pannerNode) { + pannerNode.pan.setTargetAtTime(resolvedPan, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS); + } +} + type DirectionalProfile = { attenuationFactor: number; offAxisRatio: number;