diff --git a/client/public/version.js b/client/public/version.js index 9c94a1a..dc9b0bb 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 R172"; +window.CHGRID_WEB_VERSION = "2026.02.22 R173"; // 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/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index d433357..aed906d 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -24,6 +24,8 @@ type EmitSpatialConfig = { }; const ITEM_EMIT_BASE_GAIN = 0.3; +const SUBSCRIBE_PRELOAD_SQUARES = 5; +const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8; /** Maps a 0-100 speed control to playback-rate range used by emitted audio. */ function resolveEmitPlaybackRate(raw: unknown): number { @@ -60,6 +62,7 @@ function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitc export class ItemEmitRuntime { private readonly outputs = new Map(); private layerEnabled = true; + private listenerPosition: { x: number; y: number } | null = null; constructor( private readonly audio: AudioEngine, @@ -86,30 +89,39 @@ export class ItemEmitRuntime { } } - async setLayerEnabled(enabled: boolean, items: Iterable): Promise { + async setLayerEnabled( + enabled: boolean, + items: Iterable, + listenerPosition: { x: number; y: number } | null = null, + ): Promise { this.layerEnabled = enabled; + if (listenerPosition) { + this.listenerPosition = { ...listenerPosition }; + } if (!enabled) { this.cleanupAll(); return; } - await this.sync(items); + await this.sync(items, this.listenerPosition); } - async sync(items: Iterable): Promise { + async sync(items: Iterable, listenerPosition: { x: number; y: number } | null = null): Promise { if (!this.layerEnabled) { this.cleanupAll(); return; } + if (listenerPosition) { + this.listenerPosition = { ...listenerPosition }; + } + const listener = this.listenerPosition; const validIds = new Set(); - await this.audio.ensureContext(); - const audioCtx = this.audio.context; - if (!audioCtx) return; + let audioCtx = this.audio.context; for (const item of items) { const emitSound = String(item.params.emitSound ?? item.emitSound ?? '').trim(); const enabled = item.params.enabled !== false; const soundUrl = enabled ? this.resolveSoundUrl(emitSound) : ''; - if (!soundUrl || item.carrierId) { + if (!soundUrl || item.carrierId || !this.shouldKeepRuntime(item, listener, this.outputs.has(item.id))) { this.cleanup(item.id); continue; } @@ -121,6 +133,13 @@ export class ItemEmitRuntime { if (existing) { this.cleanup(item.id); } + if (!audioCtx) { + await this.audio.ensureContext(); + audioCtx = this.audio.context; + } + if (!audioCtx) { + continue; + } const element = new Audio(soundUrl); element.loop = true; element.preload = 'none'; @@ -208,4 +227,16 @@ export class ItemEmitRuntime { } } } + + private shouldKeepRuntime( + item: WorldItem, + listenerPosition: { x: number; y: number } | null, + currentlyActive: boolean, + ): boolean { + if (!listenerPosition) return false; + const spatialConfig = this.getSpatialConfig(item); + const baseRange = Math.max(1, spatialConfig.range || HEARING_RADIUS); + const threshold = baseRange + (currentlyActive ? UNSUBSCRIBE_HYSTERESIS_SQUARES : SUBSCRIBE_PRELOAD_SQUARES); + return Math.hypot(item.x - listenerPosition.x, item.y - listenerPosition.y) <= threshold; + } } diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts index 3086ee5..102f346 100644 --- a/client/src/audio/radioStationRuntime.ts +++ b/client/src/audio/radioStationRuntime.ts @@ -162,10 +162,14 @@ type RadioSpatialConfig = { facingDeg: number; }; +const SUBSCRIBE_PRELOAD_SQUARES = 5; +const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8; + export class RadioStationRuntime { private readonly sharedRadioSources = new Map(); private readonly itemRadioOutputs = new Map(); private layerEnabled = true; + private listenerPosition: { x: number; y: number } | null = null; constructor( private readonly audio: AudioEngine, @@ -207,24 +211,39 @@ export class RadioStationRuntime { } } - async setLayerEnabled(enabled: boolean, items: Iterable): Promise { + async setLayerEnabled( + enabled: boolean, + items: Iterable, + listenerPosition: { x: number; y: number } | null = null, + ): Promise { this.layerEnabled = enabled; + if (listenerPosition) { + this.listenerPosition = { ...listenerPosition }; + } if (!enabled) { this.cleanupAll(); return; } - await this.sync(items); + await this.sync(items, this.listenerPosition); } - async sync(items: Iterable): Promise { + async sync(items: Iterable, listenerPosition: { x: number; y: number } | null = null): Promise { if (!this.layerEnabled) { this.cleanupAll(); return; } + if (listenerPosition) { + this.listenerPosition = { ...listenerPosition }; + } + const listener = this.listenerPosition; const validIds = new Set(); for (const item of items) { if (item.type !== 'radio_station') continue; validIds.add(item.id); + if (!this.shouldKeepRuntime(item, listener, this.itemRadioOutputs.has(item.id))) { + this.cleanup(item.id); + continue; + } await this.ensureRuntime(item); } for (const id of Array.from(this.itemRadioOutputs.keys())) { @@ -386,4 +405,19 @@ export class RadioStationRuntime { panner, }); } + + private shouldKeepRuntime( + item: WorldItem, + listenerPosition: { x: number; y: number } | null, + currentlyActive: boolean, + ): boolean { + const streamUrl = String(item.params.streamUrl ?? '').trim(); + if (!streamUrl || item.params.enabled === false || !listenerPosition) { + return false; + } + const spatialConfig = this.getSpatialConfig(item); + const baseRange = Math.max(1, spatialConfig.range || HEARING_RADIUS); + const threshold = baseRange + (currentlyActive ? UNSUBSCRIBE_HYSTERESIS_SQUARES : SUBSCRIBE_PRELOAD_SQUARES); + return Math.hypot(item.x - listenerPosition.x, item.y - listenerPosition.y) <= threshold; + } } diff --git a/client/src/main.ts b/client/src/main.ts index 7c5cd79..6581b70 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -4,7 +4,6 @@ import { EFFECT_IDS, EFFECT_SEQUENCE, clampEffectLevel, - type EffectId, } from './audio/effects'; import { RadioStationRuntime, @@ -80,6 +79,7 @@ const MIC_INPUT_GAIN_STEP = 0.05; const HEARTBEAT_INTERVAL_MS = 10_000; const RECONNECT_DELAY_MS = 5_000; const RECONNECT_MAX_ATTEMPTS = 3; +const AUDIO_SUBSCRIPTION_REFRESH_MS = 500; const AUTO_RECONNECT_AFTER_RELOAD_KEY = 'chatGridAutoReconnectAfterReload'; const SELF_LIST_ENTRY_ID = '__self__'; @@ -200,7 +200,6 @@ let micGainLoopbackRestoreState: boolean | null = null; let helpViewerLines: string[] = []; let helpViewerIndex = 0; let heartbeatTimerId: number | null = null; -let heartbeatLastPongAt = 0; let heartbeatNextPingId = -1; let heartbeatAwaitingPong = false; let reconnectInFlight = false; @@ -213,6 +212,11 @@ let audioLayers: AudioLayerState = { media: true, world: true, }; +let lastSubscriptionRefreshAt = 0; +let lastSubscriptionRefreshX = state.player.x; +let lastSubscriptionRefreshY = state.player.y; +let subscriptionRefreshInFlight = false; +let subscriptionRefreshPending = false; const signalingProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const signalingUrl = `${signalingProtocol}://${window.location.host}/ws`; @@ -551,8 +555,38 @@ async function applyAudioLayerState(): Promise { } else { peerManager.suspendRemoteAudio(); } - await radioRuntime.setLayerEnabled(audioLayers.media, state.items.values()); - await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values()); + const listenerPosition = { x: state.player.x, y: state.player.y }; + await radioRuntime.setLayerEnabled(audioLayers.media, state.items.values(), listenerPosition); + await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values(), listenerPosition); +} + +/** Refreshes distance-gated radio/item stream subscriptions on movement or timer cadence. */ +async function refreshAudioSubscriptions(force = false): Promise { + if (!state.running) return; + const now = Date.now(); + const moved = state.player.x !== lastSubscriptionRefreshX || state.player.y !== lastSubscriptionRefreshY; + if (!force && !moved && now - lastSubscriptionRefreshAt < AUDIO_SUBSCRIPTION_REFRESH_MS) { + return; + } + if (subscriptionRefreshInFlight) { + subscriptionRefreshPending = true; + return; + } + subscriptionRefreshInFlight = true; + lastSubscriptionRefreshAt = now; + lastSubscriptionRefreshX = state.player.x; + lastSubscriptionRefreshY = state.player.y; + const listenerPosition = { x: state.player.x, y: state.player.y }; + try { + await radioRuntime.sync(state.items.values(), listenerPosition); + await itemEmitRuntime.sync(state.items.values(), listenerPosition); + } finally { + subscriptionRefreshInFlight = false; + if (subscriptionRefreshPending) { + subscriptionRefreshPending = false; + void refreshAudioSubscriptions(true); + } + } } /** Toggles a single audio layer and applies the change immediately. */ @@ -1057,6 +1091,7 @@ function randomFootstepUrl(): string { function gameLoop(): void { if (!state.running) return; handleMovement(); + void refreshAudioSubscriptions(); audio.updateSpatialAudio(peerManager.getPeers(), { 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 }); @@ -1100,6 +1135,7 @@ function handleMovement(): void { lastWallCollisionDirection = null; persistPlayerPosition(); state.player.lastMoveTime = now; + void refreshAudioSubscriptions(true); void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN); signaling.send({ type: 'update_position', x: nextX, y: nextY }); @@ -1159,7 +1195,6 @@ function stopHeartbeat(): void { window.clearInterval(heartbeatTimerId); heartbeatTimerId = null; } - heartbeatLastPongAt = 0; heartbeatAwaitingPong = false; } @@ -1173,7 +1208,6 @@ function sendHeartbeatPing(): void { /** Starts heartbeat timer for stale-connection detection. */ function startHeartbeat(): void { stopHeartbeat(); - heartbeatLastPongAt = Date.now(); heartbeatAwaitingPong = false; sendHeartbeatPing(); heartbeatTimerId = window.setInterval(() => { @@ -1272,6 +1306,9 @@ function disconnect(): void { runDisconnectFlow(getConnectionFlowDeps()); pendingEscapeDisconnect = false; restoreLoopbackAfterMicGainEdit(); + subscriptionRefreshPending = false; + subscriptionRefreshInFlight = false; + lastSubscriptionRefreshAt = 0; } const onAppMessage = createOnMessageHandler({ @@ -1289,8 +1326,11 @@ const onAppMessage = createOnMessageHandler({ dom, signalingSend: (message) => signaling.send(message as OutgoingMessage), peerManager, - radioRuntime, - itemEmitRuntime, + refreshAudioSubscriptions, + cleanupItemAudio: (itemId) => { + radioRuntime.cleanup(itemId); + itemEmitRuntime.cleanup(itemId); + }, applyAudioLayerState, gameLoop, sanitizeName, @@ -1329,7 +1369,6 @@ const onAppMessage = createOnMessageHandler({ /** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */ async function onSignalingMessage(message: IncomingMessage): Promise { if (message.type === 'pong' && message.clientSentAt < 0) { - heartbeatLastPongAt = Date.now(); heartbeatAwaitingPong = false; return; } @@ -1946,6 +1985,7 @@ function handleListModeInput(code: string, key: string): void { state.player.x = entry.x; state.player.y = entry.y; persistPlayerPosition(); + void refreshAudioSubscriptions(true); void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN); signaling.send({ type: 'update_position', x: entry.x, y: entry.y }); state.mode = 'normal'; @@ -1993,6 +2033,7 @@ function handleListItemsModeInput(code: string, key: string): void { state.player.x = item.x; state.player.y = item.y; persistPlayerPosition(); + void refreshAudioSubscriptions(true); void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN); signaling.send({ type: 'update_position', x: item.x, y: item.y }); state.mode = 'normal'; diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index 70691d5..c561fd7 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -39,8 +39,8 @@ type MessageHandlerDeps = { setPeerNickname: (id: string, nickname: string) => void; removePeer: (id: string) => void; }; - radioRuntime: { sync: (items: Iterable) => Promise; cleanup: (itemId: string) => void }; - itemEmitRuntime: { sync: (items: Iterable) => Promise; cleanup: (itemId: string) => void }; + refreshAudioSubscriptions: (force?: boolean) => Promise; + cleanupItemAudio: (itemId: string) => void; applyAudioLayerState: () => Promise; gameLoop: () => void; sanitizeName: (value: string) => string; @@ -106,8 +106,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco carrierId: item.carrierId ?? null, }); } - await deps.radioRuntime.sync(deps.state.items.values()); - await deps.itemEmitRuntime.sync(deps.state.items.values()); + await deps.refreshAudioSubscriptions(true); await deps.applyAudioLayerState(); deps.gameLoop(); break; @@ -208,16 +207,15 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`); } } - await deps.radioRuntime.sync(deps.state.items.values()); - await deps.itemEmitRuntime.sync(deps.state.items.values()); + await deps.refreshAudioSubscriptions(true); break; } case 'item_remove': { deps.state.items.delete(message.itemId); deps.state.carriedItemId = deps.getCarriedItemId(); - deps.radioRuntime.cleanup(message.itemId); - deps.itemEmitRuntime.cleanup(message.itemId); + deps.cleanupItemAudio(message.itemId); + await deps.refreshAudioSubscriptions(true); break; }