From fe230bd53d4fc4f6b79a6d6d3e61275268fe0470 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sun, 22 Feb 2026 19:15:03 -0500 Subject: [PATCH] users: add shift-u alphabetical list with per-user listen volume controls --- client/public/help.json | 4 ++ client/public/version.js | 2 +- client/src/audio/audioEngine.ts | 4 +- client/src/input/mainCommandRouter.ts | 3 +- client/src/main.ts | 74 ++++++++++++++++++++++++++- client/src/settings/settingsStore.ts | 22 ++++++++ client/src/webrtc/peerManager.ts | 13 +++++ docs/controls.md | 2 + 8 files changed, 120 insertions(+), 4 deletions(-) diff --git a/client/public/help.json b/client/public/help.json index d66f2d3..a81d417 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -36,6 +36,10 @@ "keys": "U", "description": "Speak connected users" }, + { + "keys": "Shift+U", + "description": "List users alphabetically; Left/Right adjusts selected user volume" + }, { "keys": "Slash", "description": "Start chat" diff --git a/client/public/version.js b/client/public/version.js index cc91bca..9fe64e7 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 R170"; +window.CHGRID_WEB_VERSION = "2026.02.22 R171"; // 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 db86bc0..f2f62c9 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -13,6 +13,7 @@ export type SpatialPeerRuntime = { nickname: string; x: number; y: number; + listenGain?: number; gain?: GainNode; panner?: StereoPannerNode; audioElement?: HTMLAudioElement; @@ -297,8 +298,9 @@ export class AudioEngine { 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.linearRampToValueAtTime(gainValue, this.audioCtx.currentTime + 0.1); + peer.gain.gain.linearRampToValueAtTime(gainValue * listenGain, this.audioCtx.currentTime + 0.1); if (peer.panner) { const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); peer.panner.pan.setValueAtTime(resolvedPan, this.audioCtx.currentTime); diff --git a/client/src/input/mainCommandRouter.ts b/client/src/input/mainCommandRouter.ts index d2a5477..1677954 100644 --- a/client/src/input/mainCommandRouter.ts +++ b/client/src/input/mainCommandRouter.ts @@ -20,6 +20,7 @@ export type MainModeCommand = | 'calibrateMicrophone' | 'useItem' | 'speakUsers' + | 'listUsersAlphabetical' | 'addItem' | 'locateOrListItems' | 'pickupDropOrDelete' @@ -52,7 +53,7 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod if (code === 'KeyC') return shiftKey ? null : 'speakCoordinates'; if (code === 'KeyV') return shiftKey ? 'calibrateMicrophone' : 'openMicGainEdit'; if (code === 'Enter') return 'useItem'; - if (code === 'KeyU') return shiftKey ? null : 'speakUsers'; + if (code === 'KeyU') return shiftKey ? 'listUsersAlphabetical' : 'speakUsers'; if (code === 'KeyA') return shiftKey ? null : 'addItem'; if (code === 'KeyI') return 'locateOrListItems'; if (code === 'KeyD') return 'pickupDropOrDelete'; diff --git a/client/src/main.ts b/client/src/main.ts index d1f87d2..bdf3386 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -205,6 +205,7 @@ let heartbeatAwaitingPong = false; let reconnectInFlight = false; let activeServerInstanceId: string | null = null; let reloadScheduledForVersionMismatch = false; +let peerListenGainByNickname = settings.loadPeerListenGains(); let audioLayers: AudioLayerState = { voice: true, item: true, @@ -489,6 +490,33 @@ function persistMasterVolume(value: number): void { settings.saveMasterVolume(value); } +/** Normalizes nickname for local per-user listen-gain preference keys. */ +function peerListenGainKey(nickname: string): string { + return nickname.trim().toLowerCase(); +} + +/** Returns configured listen gain for a nickname (default 1.0). */ +function getPeerListenGainForNickname(nickname: string): number { + const key = peerListenGainKey(nickname); + const raw = peerListenGainByNickname[key]; + if (!Number.isFinite(raw)) return 1; + return clampMicInputGain(raw); +} + +/** Persists local listen gain preference for a nickname. */ +function setPeerListenGainForNickname(nickname: string, gain: number): void { + const key = peerListenGainKey(nickname); + peerListenGainByNickname = { ...peerListenGainByNickname, [key]: clampMicInputGain(gain) }; + settings.savePeerListenGains(peerListenGainByNickname); +} + +/** Applies stored listen-gain preferences to currently known peer runtimes. */ +function applyConfiguredPeerListenGains(): void { + for (const [peerId, peerState] of state.peers.entries()) { + peerManager.setPeerListenGain(peerId, getPeerListenGainForNickname(peerState.nickname)); + } +} + /** Applies current layer toggles to peer voice, media streams, and item emitters. */ async function applyAudioLayerState(): Promise { audio.setVoiceLayerEnabled(audioLayers.voice); @@ -1308,6 +1336,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise { startHeartbeat(); } await onAppMessage(message); + applyConfiguredPeerListenGains(); if (restartAnnouncement) { pushChatMessage(restartAnnouncement); audio.sfxUiConfirm(); @@ -1440,6 +1469,30 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { audio.sfxUiBlip(); return; } + case 'listUsersAlphabetical': + if (state.peers.size === 0) { + updateStatus('No users to list.'); + audio.sfxUiCancel(); + return; + } + state.sortedPeerIds = Array.from(state.peers.entries()) + .sort((a, b) => a[1].nickname.localeCompare(b[1].nickname, undefined, { sensitivity: 'base' })) + .map(([id]) => id); + state.listIndex = 0; + state.mode = 'listUsers'; + { + const first = state.peers.get(state.sortedPeerIds[0]); + if (first) { + const userCount = state.sortedPeerIds.length; + const userLabelText = userCount === 1 ? 'user' : 'users'; + const gain = getPeerListenGainForNickname(first.nickname); + updateStatus( + `${userCount} ${userLabelText}. ${first.nickname}, volume ${formatSteppedNumber(gain, MIC_INPUT_GAIN_STEP)}.`, + ); + } + } + audio.sfxUiBlip(); + return; case 'addItem': { const itemTypeSequence = getItemTypeSequence(); if (itemTypeSequence.length === 0) { @@ -1813,13 +1866,32 @@ function handleListModeInput(code: string, key: string): void { return; } + if (code === 'ArrowLeft' || code === 'ArrowRight') { + const peer = state.peers.get(state.sortedPeerIds[state.listIndex]); + if (!peer) return; + const current = getPeerListenGainForNickname(peer.nickname); + const delta = code === 'ArrowRight' ? MIC_INPUT_GAIN_STEP : -MIC_INPUT_GAIN_STEP; + const attempted = snapNumberToStep(current + delta, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN); + const next = clampMicInputGain(attempted); + setPeerListenGainForNickname(peer.nickname, next); + peerManager.setPeerListenGain(peer.id, next); + updateStatus(`${peer.nickname} volume ${formatSteppedNumber(next, MIC_INPUT_GAIN_STEP)}.`); + if (Math.abs(next - current) < 1e-9 || Math.abs(next - attempted) > 1e-9) { + audio.sfxUiCancel(); + } else { + audio.sfxUiBlip(); + } + return; + } + const control = handleListControlKey(code, key, state.sortedPeerIds, state.listIndex, (peerId) => state.peers.get(peerId)?.nickname ?? ''); if (control.type === 'move') { state.listIndex = control.index; const peer = state.peers.get(state.sortedPeerIds[state.listIndex]); if (!peer) return; + const gain = getPeerListenGainForNickname(peer.nickname); updateStatus( - `${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`, + `${peer.nickname}, volume ${formatSteppedNumber(gain, MIC_INPUT_GAIN_STEP)}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`, ); if (control.reason === 'initial') { audio.sfxUiBlip(); diff --git a/client/src/settings/settingsStore.ts b/client/src/settings/settingsStore.ts index 84dfb5d..e5f9cd7 100644 --- a/client/src/settings/settingsStore.ts +++ b/client/src/settings/settingsStore.ts @@ -9,6 +9,7 @@ const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode'; const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers'; const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain'; const MASTER_VOLUME_STORAGE_KEY = 'chatGridMasterVolume'; +const PEER_LISTEN_GAINS_STORAGE_KEY = 'chatGridPeerListenGains'; const NICKNAME_STORAGE_KEY = 'spatialChatNickname'; type DevicePreference = { @@ -83,6 +84,27 @@ export class SettingsStore { localStorage.setItem(MASTER_VOLUME_STORAGE_KEY, String(value)); } + loadPeerListenGains(): Record { + const raw = localStorage.getItem(PEER_LISTEN_GAINS_STORAGE_KEY); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as Record; + const normalized: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + const numeric = Number(value); + if (!key || !Number.isFinite(numeric)) continue; + normalized[key] = numeric; + } + return normalized; + } catch { + return {}; + } + } + + savePeerListenGains(values: Record): void { + localStorage.setItem(PEER_LISTEN_GAINS_STORAGE_KEY, JSON.stringify(values)); + } + loadNickname(): string { return localStorage.getItem(NICKNAME_STORAGE_KEY) || ''; } diff --git a/client/src/webrtc/peerManager.ts b/client/src/webrtc/peerManager.ts index 7b80df7..42b70b2 100644 --- a/client/src/webrtc/peerManager.ts +++ b/client/src/webrtc/peerManager.ts @@ -41,6 +41,7 @@ export class PeerManager { nickname: userData.nickname ?? 'user...', x: userData.x ?? 20, y: userData.y ?? 20, + listenGain: 1, pc, }; @@ -145,6 +146,18 @@ export class PeerManager { peer.nickname = nickname; } + setPeerListenGain(id: string, gain: number): void { + const peer = this.peers.get(id); + if (!peer) return; + peer.listenGain = gain; + } + + getPeerListenGain(id: string): number { + const peer = this.peers.get(id); + if (!peer) return 1; + return Number.isFinite(peer.listenGain) ? Math.max(0, peer.listenGain as number) : 1; + } + async setOutputDevice(deviceId: string): Promise { this.outputDeviceId = deviceId; for (const peer of this.peers.values()) { diff --git a/docs/controls.md b/docs/controls.md index 7bc815b..0892796 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -14,6 +14,7 @@ This document is the authoritative keymap for the client. - `L`: Locate nearest user - `Shift+L`: List users and teleport to selected user with `Enter` - `U`: Speak connected users +- `Shift+U`: List users alphabetically - `N`: Edit nickname - `/`: Start chat - `,` / `.`: Previous/next message @@ -68,6 +69,7 @@ This document is the authoritative keymap for the client. Applies to effect select, user/item list modes, item selection, item property list, and property option select. - `ArrowUp` / `ArrowDown`: Move selection +- `ArrowLeft` / `ArrowRight` in user list: Lower/raise selected user listen volume (`0.5..4.0`) - `Enter`: Confirm selection - `Escape`: Exit/cancel - `Space`: Read tooltip/help for current option (where metadata is available)