Optimize media stream subscriptions by distance and movement

This commit is contained in:
Jage9
2026-02-22 19:31:44 -05:00
parent c4494f2f99
commit 7ba67c3707
5 changed files with 132 additions and 28 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // 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. // 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";

View File

@@ -24,6 +24,8 @@ type EmitSpatialConfig = {
}; };
const ITEM_EMIT_BASE_GAIN = 0.3; 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. */ /** Maps a 0-100 speed control to playback-rate range used by emitted audio. */
function resolveEmitPlaybackRate(raw: unknown): number { function resolveEmitPlaybackRate(raw: unknown): number {
@@ -60,6 +62,7 @@ function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitc
export class ItemEmitRuntime { export class ItemEmitRuntime {
private readonly outputs = new Map<string, EmitOutput>(); private readonly outputs = new Map<string, EmitOutput>();
private layerEnabled = true; private layerEnabled = true;
private listenerPosition: { x: number; y: number } | null = null;
constructor( constructor(
private readonly audio: AudioEngine, private readonly audio: AudioEngine,
@@ -86,30 +89,39 @@ export class ItemEmitRuntime {
} }
} }
async setLayerEnabled(enabled: boolean, items: Iterable<WorldItem>): Promise<void> { async setLayerEnabled(
enabled: boolean,
items: Iterable<WorldItem>,
listenerPosition: { x: number; y: number } | null = null,
): Promise<void> {
this.layerEnabled = enabled; this.layerEnabled = enabled;
if (listenerPosition) {
this.listenerPosition = { ...listenerPosition };
}
if (!enabled) { if (!enabled) {
this.cleanupAll(); this.cleanupAll();
return; return;
} }
await this.sync(items); await this.sync(items, this.listenerPosition);
} }
async sync(items: Iterable<WorldItem>): Promise<void> { async sync(items: Iterable<WorldItem>, listenerPosition: { x: number; y: number } | null = null): Promise<void> {
if (!this.layerEnabled) { if (!this.layerEnabled) {
this.cleanupAll(); this.cleanupAll();
return; return;
} }
if (listenerPosition) {
this.listenerPosition = { ...listenerPosition };
}
const listener = this.listenerPosition;
const validIds = new Set<string>(); const validIds = new Set<string>();
await this.audio.ensureContext(); let audioCtx = this.audio.context;
const audioCtx = this.audio.context;
if (!audioCtx) return;
for (const item of items) { for (const item of items) {
const emitSound = String(item.params.emitSound ?? item.emitSound ?? '').trim(); const emitSound = String(item.params.emitSound ?? item.emitSound ?? '').trim();
const enabled = item.params.enabled !== false; const enabled = item.params.enabled !== false;
const soundUrl = enabled ? this.resolveSoundUrl(emitSound) : ''; 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); this.cleanup(item.id);
continue; continue;
} }
@@ -121,6 +133,13 @@ export class ItemEmitRuntime {
if (existing) { if (existing) {
this.cleanup(item.id); this.cleanup(item.id);
} }
if (!audioCtx) {
await this.audio.ensureContext();
audioCtx = this.audio.context;
}
if (!audioCtx) {
continue;
}
const element = new Audio(soundUrl); const element = new Audio(soundUrl);
element.loop = true; element.loop = true;
element.preload = 'none'; 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;
}
} }

View File

@@ -162,10 +162,14 @@ type RadioSpatialConfig = {
facingDeg: number; facingDeg: number;
}; };
const SUBSCRIBE_PRELOAD_SQUARES = 5;
const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8;
export class RadioStationRuntime { export class RadioStationRuntime {
private readonly sharedRadioSources = new Map<string, SharedRadioSource>(); private readonly sharedRadioSources = new Map<string, SharedRadioSource>();
private readonly itemRadioOutputs = new Map<string, ItemRadioOutput>(); private readonly itemRadioOutputs = new Map<string, ItemRadioOutput>();
private layerEnabled = true; private layerEnabled = true;
private listenerPosition: { x: number; y: number } | null = null;
constructor( constructor(
private readonly audio: AudioEngine, private readonly audio: AudioEngine,
@@ -207,24 +211,39 @@ export class RadioStationRuntime {
} }
} }
async setLayerEnabled(enabled: boolean, items: Iterable<WorldItem>): Promise<void> { async setLayerEnabled(
enabled: boolean,
items: Iterable<WorldItem>,
listenerPosition: { x: number; y: number } | null = null,
): Promise<void> {
this.layerEnabled = enabled; this.layerEnabled = enabled;
if (listenerPosition) {
this.listenerPosition = { ...listenerPosition };
}
if (!enabled) { if (!enabled) {
this.cleanupAll(); this.cleanupAll();
return; return;
} }
await this.sync(items); await this.sync(items, this.listenerPosition);
} }
async sync(items: Iterable<WorldItem>): Promise<void> { async sync(items: Iterable<WorldItem>, listenerPosition: { x: number; y: number } | null = null): Promise<void> {
if (!this.layerEnabled) { if (!this.layerEnabled) {
this.cleanupAll(); this.cleanupAll();
return; return;
} }
if (listenerPosition) {
this.listenerPosition = { ...listenerPosition };
}
const listener = this.listenerPosition;
const validIds = new Set<string>(); const validIds = new Set<string>();
for (const item of items) { for (const item of items) {
if (item.type !== 'radio_station') continue; if (item.type !== 'radio_station') continue;
validIds.add(item.id); validIds.add(item.id);
if (!this.shouldKeepRuntime(item, listener, this.itemRadioOutputs.has(item.id))) {
this.cleanup(item.id);
continue;
}
await this.ensureRuntime(item); await this.ensureRuntime(item);
} }
for (const id of Array.from(this.itemRadioOutputs.keys())) { for (const id of Array.from(this.itemRadioOutputs.keys())) {
@@ -386,4 +405,19 @@ export class RadioStationRuntime {
panner, 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;
}
} }

View File

@@ -4,7 +4,6 @@ import {
EFFECT_IDS, EFFECT_IDS,
EFFECT_SEQUENCE, EFFECT_SEQUENCE,
clampEffectLevel, clampEffectLevel,
type EffectId,
} from './audio/effects'; } from './audio/effects';
import { import {
RadioStationRuntime, RadioStationRuntime,
@@ -80,6 +79,7 @@ const MIC_INPUT_GAIN_STEP = 0.05;
const HEARTBEAT_INTERVAL_MS = 10_000; const HEARTBEAT_INTERVAL_MS = 10_000;
const RECONNECT_DELAY_MS = 5_000; const RECONNECT_DELAY_MS = 5_000;
const RECONNECT_MAX_ATTEMPTS = 3; const RECONNECT_MAX_ATTEMPTS = 3;
const AUDIO_SUBSCRIPTION_REFRESH_MS = 500;
const AUTO_RECONNECT_AFTER_RELOAD_KEY = 'chatGridAutoReconnectAfterReload'; const AUTO_RECONNECT_AFTER_RELOAD_KEY = 'chatGridAutoReconnectAfterReload';
const SELF_LIST_ENTRY_ID = '__self__'; const SELF_LIST_ENTRY_ID = '__self__';
@@ -200,7 +200,6 @@ let micGainLoopbackRestoreState: boolean | null = null;
let helpViewerLines: string[] = []; let helpViewerLines: string[] = [];
let helpViewerIndex = 0; let helpViewerIndex = 0;
let heartbeatTimerId: number | null = null; let heartbeatTimerId: number | null = null;
let heartbeatLastPongAt = 0;
let heartbeatNextPingId = -1; let heartbeatNextPingId = -1;
let heartbeatAwaitingPong = false; let heartbeatAwaitingPong = false;
let reconnectInFlight = false; let reconnectInFlight = false;
@@ -213,6 +212,11 @@ let audioLayers: AudioLayerState = {
media: true, media: true,
world: 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 signalingProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const signalingUrl = `${signalingProtocol}://${window.location.host}/ws`; const signalingUrl = `${signalingProtocol}://${window.location.host}/ws`;
@@ -551,8 +555,38 @@ async function applyAudioLayerState(): Promise<void> {
} else { } else {
peerManager.suspendRemoteAudio(); peerManager.suspendRemoteAudio();
} }
await radioRuntime.setLayerEnabled(audioLayers.media, state.items.values()); const listenerPosition = { x: state.player.x, y: state.player.y };
await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values()); 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<void> {
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. */ /** Toggles a single audio layer and applies the change immediately. */
@@ -1057,6 +1091,7 @@ function randomFootstepUrl(): string {
function gameLoop(): void { function gameLoop(): void {
if (!state.running) return; if (!state.running) return;
handleMovement(); handleMovement();
void refreshAudioSubscriptions();
audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y }); audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y });
radioRuntime.updateSpatialAudio(state.items, { 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 }); itemEmitRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y });
@@ -1100,6 +1135,7 @@ function handleMovement(): void {
lastWallCollisionDirection = null; lastWallCollisionDirection = null;
persistPlayerPosition(); persistPlayerPosition();
state.player.lastMoveTime = now; state.player.lastMoveTime = now;
void refreshAudioSubscriptions(true);
void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN); void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN);
signaling.send({ type: 'update_position', x: nextX, y: nextY }); signaling.send({ type: 'update_position', x: nextX, y: nextY });
@@ -1159,7 +1195,6 @@ function stopHeartbeat(): void {
window.clearInterval(heartbeatTimerId); window.clearInterval(heartbeatTimerId);
heartbeatTimerId = null; heartbeatTimerId = null;
} }
heartbeatLastPongAt = 0;
heartbeatAwaitingPong = false; heartbeatAwaitingPong = false;
} }
@@ -1173,7 +1208,6 @@ function sendHeartbeatPing(): void {
/** Starts heartbeat timer for stale-connection detection. */ /** Starts heartbeat timer for stale-connection detection. */
function startHeartbeat(): void { function startHeartbeat(): void {
stopHeartbeat(); stopHeartbeat();
heartbeatLastPongAt = Date.now();
heartbeatAwaitingPong = false; heartbeatAwaitingPong = false;
sendHeartbeatPing(); sendHeartbeatPing();
heartbeatTimerId = window.setInterval(() => { heartbeatTimerId = window.setInterval(() => {
@@ -1272,6 +1306,9 @@ function disconnect(): void {
runDisconnectFlow(getConnectionFlowDeps()); runDisconnectFlow(getConnectionFlowDeps());
pendingEscapeDisconnect = false; pendingEscapeDisconnect = false;
restoreLoopbackAfterMicGainEdit(); restoreLoopbackAfterMicGainEdit();
subscriptionRefreshPending = false;
subscriptionRefreshInFlight = false;
lastSubscriptionRefreshAt = 0;
} }
const onAppMessage = createOnMessageHandler({ const onAppMessage = createOnMessageHandler({
@@ -1289,8 +1326,11 @@ const onAppMessage = createOnMessageHandler({
dom, dom,
signalingSend: (message) => signaling.send(message as OutgoingMessage), signalingSend: (message) => signaling.send(message as OutgoingMessage),
peerManager, peerManager,
radioRuntime, refreshAudioSubscriptions,
itemEmitRuntime, cleanupItemAudio: (itemId) => {
radioRuntime.cleanup(itemId);
itemEmitRuntime.cleanup(itemId);
},
applyAudioLayerState, applyAudioLayerState,
gameLoop, gameLoop,
sanitizeName, sanitizeName,
@@ -1329,7 +1369,6 @@ const onAppMessage = createOnMessageHandler({
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */ /** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
async function onSignalingMessage(message: IncomingMessage): Promise<void> { async function onSignalingMessage(message: IncomingMessage): Promise<void> {
if (message.type === 'pong' && message.clientSentAt < 0) { if (message.type === 'pong' && message.clientSentAt < 0) {
heartbeatLastPongAt = Date.now();
heartbeatAwaitingPong = false; heartbeatAwaitingPong = false;
return; return;
} }
@@ -1946,6 +1985,7 @@ function handleListModeInput(code: string, key: string): void {
state.player.x = entry.x; state.player.x = entry.x;
state.player.y = entry.y; state.player.y = entry.y;
persistPlayerPosition(); persistPlayerPosition();
void refreshAudioSubscriptions(true);
void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN); void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN);
signaling.send({ type: 'update_position', x: entry.x, y: entry.y }); signaling.send({ type: 'update_position', x: entry.x, y: entry.y });
state.mode = 'normal'; state.mode = 'normal';
@@ -1993,6 +2033,7 @@ function handleListItemsModeInput(code: string, key: string): void {
state.player.x = item.x; state.player.x = item.x;
state.player.y = item.y; state.player.y = item.y;
persistPlayerPosition(); persistPlayerPosition();
void refreshAudioSubscriptions(true);
void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN); void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN);
signaling.send({ type: 'update_position', x: item.x, y: item.y }); signaling.send({ type: 'update_position', x: item.x, y: item.y });
state.mode = 'normal'; state.mode = 'normal';

View File

@@ -39,8 +39,8 @@ type MessageHandlerDeps = {
setPeerNickname: (id: string, nickname: string) => void; setPeerNickname: (id: string, nickname: string) => void;
removePeer: (id: string) => void; removePeer: (id: string) => void;
}; };
radioRuntime: { sync: (items: Iterable<WorldItem>) => Promise<void>; cleanup: (itemId: string) => void }; refreshAudioSubscriptions: (force?: boolean) => Promise<void>;
itemEmitRuntime: { sync: (items: Iterable<WorldItem>) => Promise<void>; cleanup: (itemId: string) => void }; cleanupItemAudio: (itemId: string) => void;
applyAudioLayerState: () => Promise<void>; applyAudioLayerState: () => Promise<void>;
gameLoop: () => void; gameLoop: () => void;
sanitizeName: (value: string) => string; sanitizeName: (value: string) => string;
@@ -106,8 +106,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
carrierId: item.carrierId ?? null, carrierId: item.carrierId ?? null,
}); });
} }
await deps.radioRuntime.sync(deps.state.items.values()); await deps.refreshAudioSubscriptions(true);
await deps.itemEmitRuntime.sync(deps.state.items.values());
await deps.applyAudioLayerState(); await deps.applyAudioLayerState();
deps.gameLoop(); deps.gameLoop();
break; break;
@@ -208,16 +207,15 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`); deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`);
} }
} }
await deps.radioRuntime.sync(deps.state.items.values()); await deps.refreshAudioSubscriptions(true);
await deps.itemEmitRuntime.sync(deps.state.items.values());
break; break;
} }
case 'item_remove': { case 'item_remove': {
deps.state.items.delete(message.itemId); deps.state.items.delete(message.itemId);
deps.state.carriedItemId = deps.getCarriedItemId(); deps.state.carriedItemId = deps.getCarriedItemId();
deps.radioRuntime.cleanup(message.itemId); deps.cleanupItemAudio(message.itemId);
deps.itemEmitRuntime.cleanup(message.itemId); await deps.refreshAudioSubscriptions(true);
break; break;
} }