diff --git a/client/public/version.js b/client/public/version.js index a0744d9..c56f8ba 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 R157"; +window.CHGRID_WEB_VERSION = "2026.02.22 R158"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/input/mainCommandRouter.ts b/client/src/input/mainCommandRouter.ts index a0fe1b8..5266531 100644 --- a/client/src/input/mainCommandRouter.ts +++ b/client/src/input/mainCommandRouter.ts @@ -36,24 +36,24 @@ export type MainModeCommand = * Maps raw key events to a semantic command for main mode handling. */ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainModeCommand | null { - if (code === 'KeyN') return 'editNickname'; + if (code === 'KeyN') return shiftKey ? null : 'editNickname'; if (code === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute'; if (code === 'Digit1') return shiftKey ? 'toggleLoopback' : 'toggleVoiceLayer'; if (code === 'Digit2') return 'toggleItemLayer'; if (code === 'Digit3') return 'toggleMediaLayer'; if (code === 'Digit4') return 'toggleWorldLayer'; - if (code === 'KeyE') return 'openEffectSelect'; + if (code === 'KeyE') return shiftKey ? null : 'openEffectSelect'; if (code === 'Equal' || code === 'NumpadAdd') return 'effectValueUp'; if (code === 'Minus' || code === 'NumpadSubtract') return 'effectValueDown'; - if (code === 'KeyC') return 'speakCoordinates'; + if (code === 'KeyC') return shiftKey ? null : 'speakCoordinates'; if (code === 'KeyV') return shiftKey ? 'calibrateMicrophone' : 'openMicGainEdit'; if (code === 'Enter') return 'useItem'; - if (code === 'KeyU') return 'speakUsers'; - if (code === 'KeyA') return 'addItem'; + if (code === 'KeyU') return shiftKey ? null : 'speakUsers'; + if (code === 'KeyA') return shiftKey ? null : 'addItem'; if (code === 'KeyI') return 'locateOrListItems'; if (code === 'KeyD') return 'pickupDropOrDelete'; if (code === 'KeyO') return 'editOrInspectItem'; - if (code === 'KeyP') return 'pingServer'; + if (code === 'KeyP') return shiftKey ? null : 'pingServer'; if (code === 'KeyL') return 'locateOrListUsers'; if (code === 'Slash') return shiftKey ? 'openHelp' : 'openChat'; if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev'; diff --git a/client/src/input/modeDispatcher.ts b/client/src/input/modeDispatcher.ts new file mode 100644 index 0000000..9341258 --- /dev/null +++ b/client/src/input/modeDispatcher.ts @@ -0,0 +1,27 @@ +import type { GameMode } from '../state/gameState'; + +type ModeHandler = (code: string, key: string, ctrlKey: boolean) => void; + +type ModeHandlers = Partial>; + +type DispatchOptions = { + mode: GameMode; + code: string; + key: string; + ctrlKey: boolean; + shiftKey: boolean; + handlers: ModeHandlers; + onNormalMode: (code: string, shiftKey: boolean) => void; +}; + +/** + * Routes key input to the handler for the current game mode. + */ +export function dispatchModeInput(options: DispatchOptions): void { + const modeHandler = options.handlers[options.mode]; + if (modeHandler) { + modeHandler(options.code, options.key, options.ctrlKey); + return; + } + options.onNormalMode(options.code, options.shiftKey); +} diff --git a/client/src/main.ts b/client/src/main.ts index ab76140..eb1cfcf 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -29,6 +29,7 @@ import { shouldReplaceCurrentText, } from './input/textInput'; import { resolveMainModeCommand } from './input/mainCommandRouter'; +import { dispatchModeInput } from './input/modeDispatcher'; import { handleListControlKey } from './input/listController'; import { getEditSessionAction } from './input/editSession'; import { formatSteppedNumber, snapNumberToStep } from './input/numeric'; @@ -59,19 +60,14 @@ import { itemTypeLabel, } from './items/itemRegistry'; import { createItemPropertyEditor } from './items/itemPropertyEditor'; +import { NICKNAME_STORAGE_KEY, SettingsStore } from './settings/settingsStore'; +import { runConnectFlow, runDisconnectFlow, type ConnectFlowDeps } from './session/connectionFlow'; +import { MediaSession } from './session/mediaSession'; +import { type AudioLayerState } from './types/audio'; import { setupUiHandlers as setupDomUiHandlers } from './ui/domBindings'; import { PeerManager } from './webrtc/peerManager'; -const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels'; -const AUDIO_INPUT_STORAGE_KEY = 'chatGridAudioInputDeviceId'; -const AUDIO_OUTPUT_STORAGE_KEY = 'chatGridAudioOutputDeviceId'; -const AUDIO_INPUT_NAME_STORAGE_KEY = 'chatGridAudioInputDeviceName'; -const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName'; -const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode'; -const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers'; -const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain'; const DEFAULT_DISPLAY_TIME_ZONE = 'America/Detroit'; -const NICKNAME_STORAGE_KEY = 'spatialChatNickname'; const NICKNAME_MAX_LENGTH = 32; const MIC_CALIBRATION_DURATION_MS = 5000; const MIC_CALIBRATION_SAMPLE_INTERVAL_MS = 50; @@ -156,13 +152,6 @@ type HelpData = { sections: HelpSection[]; }; -type AudioLayerState = { - voice: boolean; - item: boolean; - media: boolean; - world: boolean; -}; - const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim(); const DISPLAY_TIME_ZONE = resolveDisplayTimeZone(); dom.appVersion.textContent = APP_VERSION @@ -187,20 +176,14 @@ const WALL_SOUND_URL = withBase('sounds/wall.ogg'); const state = createInitialState(); const renderer = new CanvasRenderer(dom.canvas); const audio = new AudioEngine(); +const settings = new SettingsStore(); let worldGridSize = GRID_SIZE; let lastWallCollisionDirection: string | null = null; -let localStream: MediaStream | null = null; -let outboundStream: MediaStream | null = null; let statusTimeout: number | null = null; let lastFocusedElement: Element | null = null; let lastAnnouncementText = ''; let lastAnnouncementAt = 0; -let preferredInputDeviceId = localStorage.getItem(AUDIO_INPUT_STORAGE_KEY) || ''; -let preferredOutputDeviceId = localStorage.getItem(AUDIO_OUTPUT_STORAGE_KEY) || ''; -let preferredInputDeviceName = localStorage.getItem(AUDIO_INPUT_NAME_STORAGE_KEY) || ''; -let preferredOutputDeviceName = localStorage.getItem(AUDIO_OUTPUT_NAME_STORAGE_KEY) || ''; -let outputMode = localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono' ? 'mono' : 'stereo'; -let connecting = false; +let outputMode = settings.loadOutputMode(); const messageBuffer: string[] = []; let messageCursor = -1; const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig); @@ -210,7 +193,6 @@ let replaceTextOnNextType = false; let pendingEscapeDisconnect = false; let helpViewerLines: string[] = []; let helpViewerIndex = 0; -let calibratingMicInput = false; let audioLayers: AudioLayerState = { voice: true, item: true, @@ -227,9 +209,25 @@ const peerManager = new PeerManager( (targetId, payload) => { signaling.send({ type: 'signal', targetId, ...payload }); }, - () => outboundStream, + () => mediaSession.getOutboundStream(), updateStatus, ); +const mediaSession = new MediaSession({ + state, + audio, + peerManager, + settings, + dom, + updateStatus, + micCalibrationDurationMs: MIC_CALIBRATION_DURATION_MS, + micCalibrationSampleIntervalMs: MIC_CALIBRATION_SAMPLE_INTERVAL_MS, + micCalibrationMinGain: MIC_CALIBRATION_MIN_GAIN, + micCalibrationMaxGain: MIC_CALIBRATION_MAX_GAIN, + micCalibrationTargetRms: MIC_CALIBRATION_TARGET_RMS, + micCalibrationActiveRmsThreshold: MIC_CALIBRATION_ACTIVE_RMS_THRESHOLD, + micInputGainScaleMultiplier: MIC_INPUT_GAIN_SCALE_MULTIPLIER, + micInputGainStep: MIC_INPUT_GAIN_STEP, +}); audio.setOutputMode(outputMode); loadEffectLevels(); @@ -416,50 +414,30 @@ function updateConnectAvailability(): void { return; } const hasNickname = sanitizeName(dom.preconnectNickname.value).length > 0; - dom.connectButton.disabled = connecting || !hasNickname; + dom.connectButton.disabled = mediaSession.isConnecting() || !hasNickname; } /** Restores persisted outbound effect levels from local storage. */ function loadEffectLevels(): void { - const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY); - if (!raw) return; - try { - const parsed = JSON.parse(raw) as Partial< - Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number> - >; - audio.setEffectLevels(parsed); - } catch { - // Ignore malformed persisted values. - } + const parsed = settings.loadEffectLevels(); + if (!parsed) return; + audio.setEffectLevels(parsed); } /** Persists current outbound effect levels to local storage. */ function persistEffectLevels(): void { - localStorage.setItem(EFFECT_LEVELS_STORAGE_KEY, JSON.stringify(audio.getEffectLevels())); + settings.saveEffectLevels(audio.getEffectLevels()); } /** Restores local audio-layer toggles and applies initial voice-layer state. */ function loadAudioLayerState(): void { - const raw = localStorage.getItem(AUDIO_LAYER_STATE_STORAGE_KEY); - if (raw) { - try { - const parsed = JSON.parse(raw) as Partial; - audioLayers = { - voice: parsed.voice !== false, - item: parsed.item !== false, - media: parsed.media !== false, - world: parsed.world !== false, - }; - } catch { - // Ignore malformed persisted values. - } - } + audioLayers = settings.loadAudioLayers(); audio.setVoiceLayerEnabled(audioLayers.voice); } /** Persists current audio-layer toggles to local storage. */ function persistAudioLayerState(): void { - localStorage.setItem(AUDIO_LAYER_STATE_STORAGE_KEY, JSON.stringify(audioLayers)); + settings.saveAudioLayers(audioLayers); } /** Clamps microphone input gain to the supported calibration bounds. */ @@ -470,18 +448,17 @@ function clampMicInputGain(value: number): number { /** Loads persisted microphone input gain and applies default when missing. */ function loadMicInputGain(): void { - const raw = localStorage.getItem(MIC_INPUT_GAIN_STORAGE_KEY); - if (!raw) { + const parsed = settings.loadMicInputGain(); + if (parsed === null) { audio.setOutboundInputGain(2); return; } - const parsed = Number(raw); audio.setOutboundInputGain(clampMicInputGain(parsed)); } /** Persists microphone input gain to local storage. */ function persistMicInputGain(value: number): void { - localStorage.setItem(MIC_INPUT_GAIN_STORAGE_KEY, String(value)); + settings.saveMicInputGain(value); } /** Applies current layer toggles to peer voice, media streams, and item emitters. */ @@ -572,21 +549,7 @@ function navigateChatBuffer(target: 'prev' | 'next' | 'first' | 'last'): void { /** Updates compact input/output device summary labels in the pre-connect UI. */ function updateDeviceSummary(): void { - if (preferredInputDeviceId) { - const text = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName || 'Saved microphone'; - dom.audioInputCurrent.textContent = `Input: ${text}`; - dom.audioInputCurrent.classList.remove('hidden'); - } else { - dom.audioInputCurrent.classList.add('hidden'); - } - - if (preferredOutputDeviceId) { - const text = dom.audioOutputSelect.selectedOptions[0]?.text || preferredOutputDeviceName || 'Saved speakers'; - dom.audioOutputCurrent.textContent = `Output: ${text}`; - dom.audioOutputCurrent.classList.remove('hidden'); - } else { - dom.audioOutputCurrent.classList.add('hidden'); - } + mediaSession.updateDeviceSummary(); } /** Returns peer nicknames currently occupying the given grid cell. */ @@ -1040,262 +1003,69 @@ function handleMovement(): void { /** Checks microphone permission state when Permissions API support is available. */ async function checkMicPermission(): Promise { - const permissionApi = navigator.permissions; - if (!permissionApi?.query) return true; - try { - const result = await permissionApi.query({ name: 'microphone' as PermissionName }); - return result.state !== 'denied'; - } catch { - return true; - } + return mediaSession.checkMicPermission(); } /** Starts local microphone capture and rebuilds the outbound track pipeline. */ async function setupLocalMedia(audioDeviceId = ''): Promise { - stopLocalMedia(); - - await audio.ensureContext(); - - const constraints: MediaStreamConstraints = { - audio: { - deviceId: audioDeviceId ? { exact: audioDeviceId } : undefined, - sampleRate: 48000, - channelCount: 2, - echoCancellation: false, - noiseSuppression: false, - autoGainControl: false, - }, - video: false, - }; - - localStream = await navigator.mediaDevices.getUserMedia(constraints); - const audioTrack = localStream.getAudioTracks()[0]; - if (audioTrack) { - audioTrack.enabled = !state.isMuted; - } - outboundStream = await audio.configureOutboundStream(localStream); - await peerManager.replaceOutgoingTrack(outboundStream); + await mediaSession.setupLocalMedia(audioDeviceId); } /** Runs a short RMS sample to estimate and apply a usable microphone input gain. */ async function calibrateMicInputGain(): Promise { - if (calibratingMicInput) { - updateStatus('Mic calibration already running.'); - return; - } - if (!state.running || !localStream) { - updateStatus('Connect first, then use Shift+C to calibrate.'); - audio.sfxUiCancel(); - return; - } - const track = localStream.getAudioTracks()[0]; - if (!track || track.readyState !== 'live') { - updateStatus('No active microphone track for calibration.'); - audio.sfxUiCancel(); - return; - } - await audio.ensureContext(); - const audioContext = audio.context; - if (!audioContext) { - updateStatus('Audio context unavailable.'); - audio.sfxUiCancel(); - return; - } - - calibratingMicInput = true; - updateStatus('Speak for 5 seconds to calibrate your audio.'); - audio.sfxUiBlip(); - - const source = audioContext.createMediaStreamSource(new MediaStream([track])); - const analyser = audioContext.createAnalyser(); - analyser.fftSize = 2048; - analyser.smoothingTimeConstant = 0.2; - source.connect(analyser); - const samples = new Float32Array(analyser.fftSize); - const rmsValues: number[] = []; - - try { - const startedAt = performance.now(); - while (performance.now() - startedAt < MIC_CALIBRATION_DURATION_MS) { - analyser.getFloatTimeDomainData(samples); - let sumSquares = 0; - for (let i = 0; i < samples.length; i += 1) { - const sample = samples[i]; - sumSquares += sample * sample; - } - const rms = Math.sqrt(sumSquares / samples.length); - rmsValues.push(rms); - await new Promise((resolve) => window.setTimeout(resolve, MIC_CALIBRATION_SAMPLE_INTERVAL_MS)); - } - } finally { - source.disconnect(); - analyser.disconnect(); - calibratingMicInput = false; - } - - const activeRms = rmsValues.filter((value) => value >= MIC_CALIBRATION_ACTIVE_RMS_THRESHOLD); - if (activeRms.length < 10) { - updateStatus('No audio detected, please try again.'); - audio.sfxUiCancel(); - return; - } - - activeRms.sort((a, b) => a - b); - const percentileIndex = Math.min(activeRms.length - 1, Math.floor(activeRms.length * 0.9)); - const observedRms = activeRms[percentileIndex]; - if (!(observedRms > 0)) { - updateStatus('No audio detected, please try again.'); - audio.sfxUiCancel(); - return; - } - - const calibratedGain = clampMicInputGain((MIC_CALIBRATION_TARGET_RMS / observedRms) * MIC_INPUT_GAIN_SCALE_MULTIPLIER); - const roundedGain = clampMicInputGain(snapNumberToStep(calibratedGain, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN)); - const appliedGain = audio.setOutboundInputGain(roundedGain); - persistMicInputGain(appliedGain); - updateStatus(`Mic calibration set to ${formatSteppedNumber(appliedGain, MIC_INPUT_GAIN_STEP)}x.`); - audio.sfxUiConfirm(); + await mediaSession.calibrateMicInputGain(clampMicInputGain, persistMicInputGain); } /** Stops local capture tracks and clears outbound stream references. */ function stopLocalMedia(): void { - if (localStream) { - localStream.getTracks().forEach((track) => track.stop()); - localStream = null; - } - outboundStream = null; + mediaSession.stopLocalMedia(); } /** Maps browser media/capture errors to user-facing remediation text. */ function describeMediaError(error: unknown): string { - if (error instanceof DOMException) { - if (error.name === 'NotAllowedError') return 'Microphone blocked. Allow mic access in browser site settings.'; - if (error.name === 'NotFoundError') return 'No microphone found. Check that an input device is connected and enabled.'; - if (error.name === 'NotReadableError') return 'Microphone is busy or unavailable. Close other apps using the mic and retry.'; - if (error.name === 'OverconstrainedError') return 'Selected audio device is unavailable. Choose another input device.'; - if (error.name === 'SecurityError') return 'Microphone access requires a secure context (HTTPS) in production.'; - } - return 'Audio setup failed. Check browser permissions and selected input device.'; + return mediaSession.describeMediaError(error); +} + +/** Builds dependencies shared by connect/disconnect flow helpers. */ +function getConnectionFlowDeps(): ConnectFlowDeps { + return { + state, + dom, + sanitizeName, + updateStatus, + updateConnectAvailability, + settingsSaveNickname: (value) => settings.saveNickname(value), + mediaIsConnecting: () => mediaSession.isConnecting(), + mediaSetConnecting: (value) => mediaSession.setConnecting(value), + mediaCheckMicPermission: () => checkMicPermission(), + mediaPopulateAudioDevices: () => populateAudioDevices(), + mediaGetPreferredInputDeviceId: () => mediaSession.getPreferredInputDeviceId(), + mediaSetupLocalMedia: (audioDeviceId) => setupLocalMedia(audioDeviceId), + mediaDescribeError: (error) => describeMediaError(error), + mediaStopLocalMedia: () => stopLocalMedia(), + signalingConnect: (handler) => signaling.connect(handler as (message: IncomingMessage) => Promise), + signalingDisconnect: () => signaling.disconnect(), + onMessage: (message) => onMessage(message as IncomingMessage), + worldGridSize, + persistPlayerPosition, + peerManagerCleanupAll: () => peerManager.cleanupAll(), + radioCleanupAll: () => radioRuntime.cleanupAll(), + emitCleanupAll: () => itemEmitRuntime.cleanupAll(), + playLogoutSound: () => { + void audio.playSample(SYSTEM_SOUND_URLS.logout, 1); + }, + }; } /** Performs end-to-end connect flow: validation, media setup, then signaling connection. */ async function connect(): Promise { - if (connecting || state.running) { - return; - } - const nickname = sanitizeName(dom.preconnectNickname.value); - if (!nickname) { - updateStatus('Nickname is required.'); - updateConnectAvailability(); - return; - } - state.player.nickname = nickname; - dom.preconnectNickname.value = nickname; - localStorage.setItem(NICKNAME_STORAGE_KEY, nickname); - connecting = true; - updateConnectAvailability(); - const canProceed = await checkMicPermission(); - if (!canProceed) { - updateStatus('Microphone access is required.'); - connecting = false; - updateConnectAvailability(); - return; - } - - state.player.x = Math.floor(Math.random() * worldGridSize); - state.player.y = Math.floor(Math.random() * worldGridSize); - const storedPosition = localStorage.getItem('spatialChatPosition'); - if (storedPosition) { - try { - const parsed = JSON.parse(storedPosition) as { x?: number; y?: number }; - if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) { - const x = Math.floor(parsed.x as number); - const y = Math.floor(parsed.y as number); - if (x >= 0 && x < worldGridSize && y >= 0 && y < worldGridSize) { - state.player.x = x; - state.player.y = y; - } - } - } catch { - // Ignore malformed saved positions and keep randomized defaults. - } - } - - try { - await populateAudioDevices(); - if (dom.audioInputSelect.options.length === 0) { - updateStatus('No audio input device found. Open Settings or connect a microphone.'); - connecting = false; - updateConnectAvailability(); - return; - } - const inputDeviceId = dom.audioInputSelect.value || preferredInputDeviceId; - await setupLocalMedia(inputDeviceId); - } catch (error) { - console.error(error); - updateStatus(describeMediaError(error)); - connecting = false; - updateConnectAvailability(); - return; - } - - try { - await signaling.connect(onMessage); - } catch (error) { - console.error(error); - stopLocalMedia(); - updateStatus('Connect failed. Signaling server may be offline or unreachable.'); - connecting = false; - updateConnectAvailability(); - } + await runConnectFlow(getConnectionFlowDeps()); } /** Tears down active session state, media, peers, and UI back to pre-connect mode. */ function disconnect(): void { - const wasRunning = state.running; - if (state.running) { - persistPlayerPosition(); - } - - signaling.disconnect(); - stopLocalMedia(); - - peerManager.cleanupAll(); - radioRuntime.cleanupAll(); - itemEmitRuntime.cleanupAll(); - state.running = false; - state.keysPressed = {}; - state.peers.clear(); - state.items.clear(); - state.carriedItemId = null; - state.mode = 'normal'; - state.sortedItemIds = []; - state.itemListIndex = 0; - state.selectedItemIds = []; - state.selectionContext = null; - state.selectedItemIndex = 0; - state.selectedItemId = null; - state.itemPropertyKeys = []; - state.itemPropertyIndex = 0; - state.editingPropertyKey = null; - state.itemPropertyOptionValues = []; - state.itemPropertyOptionIndex = 0; - state.effectSelectIndex = 0; + runDisconnectFlow(getConnectionFlowDeps()); pendingEscapeDisconnect = false; - - connecting = false; - dom.nicknameContainer.classList.remove('hidden'); - dom.connectButton.classList.remove('hidden'); - dom.disconnectButton.classList.add('hidden'); - dom.focusGridButton.classList.add('hidden'); - dom.canvas.classList.add('hidden'); - dom.instructions.classList.add('hidden'); - updateConnectAvailability(); - - updateStatus('Disconnected.'); - if (wasRunning) { - void audio.playSample(SYSTEM_SOUND_URLS.logout, 1); - } } const onMessage = createOnMessageHandler({ @@ -1304,7 +1074,8 @@ const onMessage = createOnMessageHandler({ worldGridSize = size; }, setConnecting: (value) => { - connecting = value; + mediaSession.setConnecting(value); + updateConnectAvailability(); }, rendererSetGridSize: (size) => renderer.setGridSize(size), applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters[0]), @@ -1352,10 +1123,7 @@ const onMessage = createOnMessageHandler({ /** Toggles local microphone track mute state. */ function toggleMute(): void { state.isMuted = !state.isMuted; - if (localStream) { - const track = localStream.getAudioTracks()[0]; - if (track) track.enabled = !state.isMuted; - } + mediaSession.applyMuteToTrack(state.isMuted); updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.'); } @@ -1381,7 +1149,7 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { return; case 'toggleOutputMode': outputMode = audio.toggleOutputMode(); - localStorage.setItem(AUDIO_OUTPUT_MODE_STORAGE_KEY, outputMode); + mediaSession.saveOutputMode(outputMode); updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.'); audio.sfxUiBlip(); return; @@ -2111,33 +1879,30 @@ function setupInputHandlers(): void { if (isTypingKey(code) && state.keysPressed[code]) return; - if (state.mode === 'nickname') { - handleNicknameModeInput(code, event.key, event.ctrlKey); - } else if (state.mode === 'chat') { - handleChatModeInput(code, event.key, event.ctrlKey); - } else if (state.mode === 'micGainEdit') { - handleMicGainEditModeInput(code, event.key, event.ctrlKey); - } else if (state.mode === 'effectSelect') { - handleEffectSelectModeInput(code, event.key); - } else if (state.mode === 'helpView') { - handleHelpViewModeInput(code); - } else if (state.mode === 'listUsers') { - handleListModeInput(code, event.key); - } else if (state.mode === 'listItems') { - handleListItemsModeInput(code, event.key); - } else if (state.mode === 'addItem') { - handleAddItemModeInput(code, event.key); - } else if (state.mode === 'selectItem') { - handleSelectItemModeInput(code, event.key); - } else if (state.mode === 'itemProperties') { - itemPropertyEditor.handleItemPropertiesModeInput(code, event.key); - } else if (state.mode === 'itemPropertyEdit') { - itemPropertyEditor.handleItemPropertyEditModeInput(code, event.key, event.ctrlKey); - } else if (state.mode === 'itemPropertyOptionSelect') { - itemPropertyEditor.handleItemPropertyOptionSelectModeInput(code, event.key); - } else { - handleNormalModeInput(code, event.shiftKey); - } + dispatchModeInput({ + mode: state.mode, + code, + key: event.key, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + handlers: { + nickname: handleNicknameModeInput, + chat: handleChatModeInput, + micGainEdit: handleMicGainEditModeInput, + effectSelect: (currentCode, currentKey) => handleEffectSelectModeInput(currentCode, currentKey), + helpView: (currentCode) => handleHelpViewModeInput(currentCode), + listUsers: (currentCode, currentKey) => handleListModeInput(currentCode, currentKey), + listItems: (currentCode, currentKey) => handleListItemsModeInput(currentCode, currentKey), + addItem: (currentCode, currentKey) => handleAddItemModeInput(currentCode, currentKey), + selectItem: (currentCode, currentKey) => handleSelectItemModeInput(currentCode, currentKey), + itemProperties: (currentCode, currentKey) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey), + itemPropertyEdit: (currentCode, currentKey, currentCtrlKey) => + itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey), + itemPropertyOptionSelect: (currentCode, currentKey) => + itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey), + }, + onNormalMode: handleNormalModeInput, + }); state.keysPressed[code] = true; }); @@ -2158,52 +1923,7 @@ function setupInputHandlers(): void { /** Enumerates audio devices, updates selectors, and persists preferred choices. */ async function populateAudioDevices(): Promise { - if (!navigator.mediaDevices?.enumerateDevices) { - return; - } - - let temporaryStream: MediaStream | null = null; - try { - temporaryStream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const devices = await navigator.mediaDevices.enumerateDevices(); - - dom.audioInputSelect.innerHTML = ''; - dom.audioOutputSelect.innerHTML = ''; - - for (const device of devices) { - if (device.kind === 'audioinput') { - dom.audioInputSelect.add(new Option(device.label || `Microphone ${dom.audioInputSelect.length + 1}`, device.deviceId)); - } - if (device.kind === 'audiooutput') { - const option = new Option(device.label || `Speaker ${dom.audioOutputSelect.length + 1}`, device.deviceId); - dom.audioOutputSelect.add(option); - } - } - - if (preferredInputDeviceId && Array.from(dom.audioInputSelect.options).some((option) => option.value === preferredInputDeviceId)) { - dom.audioInputSelect.value = preferredInputDeviceId; - preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName; - } else if (dom.audioInputSelect.options.length > 0) { - preferredInputDeviceId = dom.audioInputSelect.value; - preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName; - localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, preferredInputDeviceId); - localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, preferredInputDeviceName); - } - - if (preferredOutputDeviceId && Array.from(dom.audioOutputSelect.options).some((option) => option.value === preferredOutputDeviceId)) { - dom.audioOutputSelect.value = preferredOutputDeviceId; - preferredOutputDeviceName = dom.audioOutputSelect.selectedOptions[0]?.text || preferredOutputDeviceName; - void peerManager.setOutputDevice(preferredOutputDeviceId); - } - - const sinkCapable = typeof (HTMLMediaElement.prototype as HTMLMediaElement & { setSinkId?: unknown }).setSinkId === 'function'; - dom.audioOutputSelect.disabled = !sinkCapable; - updateDeviceSummary(); - } catch { - updateStatus('Could not list devices.'); - } finally { - temporaryStream?.getTracks().forEach((track) => track.stop()); - } + await mediaSession.populateAudioDevices(); } /** Opens settings modal and focuses device controls. */ @@ -2239,16 +1959,10 @@ function setupUiHandlers(): void { sfxUiBlip: () => audio.sfxUiBlip(), setupLocalMedia, setPreferredInput: (id, name) => { - preferredInputDeviceId = id; - preferredInputDeviceName = name || preferredInputDeviceName; - localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, preferredInputDeviceId); - localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, preferredInputDeviceName); + mediaSession.setPreferredInput(id, name); }, setPreferredOutput: (id, name) => { - preferredOutputDeviceId = id; - preferredOutputDeviceName = name || preferredOutputDeviceName; - localStorage.setItem(AUDIO_OUTPUT_STORAGE_KEY, preferredOutputDeviceId); - localStorage.setItem(AUDIO_OUTPUT_NAME_STORAGE_KEY, preferredOutputDeviceName); + mediaSession.setPreferredOutput(id, name); }, updateDeviceSummary, setOutputDevice: (id) => peerManager.setOutputDevice(id), @@ -2261,7 +1975,7 @@ function setupUiHandlers(): void { setupInputHandlers(); setupUiHandlers(); -const storedNickname = sanitizeName(localStorage.getItem(NICKNAME_STORAGE_KEY) || ''); +const storedNickname = sanitizeName(settings.loadNickname()); dom.preconnectNickname.value = storedNickname; if (storedNickname) { state.player.nickname = storedNickname; diff --git a/client/src/session/connectionFlow.ts b/client/src/session/connectionFlow.ts new file mode 100644 index 0000000..5385fca --- /dev/null +++ b/client/src/session/connectionFlow.ts @@ -0,0 +1,162 @@ +import type { GameState } from '../state/gameState'; + +type DomRefs = { + preconnectNickname: HTMLInputElement; + nicknameContainer: HTMLDivElement; + connectButton: HTMLButtonElement; + disconnectButton: HTMLButtonElement; + focusGridButton: HTMLButtonElement; + canvas: HTMLCanvasElement; + instructions: HTMLDivElement; + audioInputSelect: HTMLSelectElement; +}; + +export type ConnectFlowDeps = { + state: GameState; + dom: DomRefs; + sanitizeName: (value: string) => string; + updateStatus: (message: string) => void; + updateConnectAvailability: () => void; + settingsSaveNickname: (value: string) => void; + mediaIsConnecting: () => boolean; + mediaSetConnecting: (value: boolean) => void; + mediaCheckMicPermission: () => Promise; + mediaPopulateAudioDevices: () => Promise; + mediaGetPreferredInputDeviceId: () => string; + mediaSetupLocalMedia: (audioDeviceId: string) => Promise; + mediaDescribeError: (error: unknown) => string; + mediaStopLocalMedia: () => void; + signalingConnect: (onMessage: (message: unknown) => Promise) => Promise; + signalingDisconnect: () => void; + onMessage: (message: unknown) => Promise; + worldGridSize: number; + persistPlayerPosition: () => void; + peerManagerCleanupAll: () => void; + radioCleanupAll: () => void; + emitCleanupAll: () => void; + playLogoutSound: () => void; +}; + +/** + * Runs connect flow: validate nickname, preflight mic/device setup, then signaling connect. + */ +export async function runConnectFlow(deps: ConnectFlowDeps): Promise { + if (deps.mediaIsConnecting() || deps.state.running) { + return; + } + const nickname = deps.sanitizeName(deps.dom.preconnectNickname.value); + if (!nickname) { + deps.updateStatus('Nickname is required.'); + deps.updateConnectAvailability(); + return; + } + deps.state.player.nickname = nickname; + deps.dom.preconnectNickname.value = nickname; + deps.settingsSaveNickname(nickname); + deps.mediaSetConnecting(true); + deps.updateConnectAvailability(); + + const canProceed = await deps.mediaCheckMicPermission(); + if (!canProceed) { + deps.updateStatus('Microphone access is required.'); + deps.mediaSetConnecting(false); + deps.updateConnectAvailability(); + return; + } + + deps.state.player.x = Math.floor(Math.random() * deps.worldGridSize); + deps.state.player.y = Math.floor(Math.random() * deps.worldGridSize); + const storedPosition = localStorage.getItem('spatialChatPosition'); + if (storedPosition) { + try { + const parsed = JSON.parse(storedPosition) as { x?: number; y?: number }; + if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) { + const x = Math.floor(parsed.x as number); + const y = Math.floor(parsed.y as number); + if (x >= 0 && x < deps.worldGridSize && y >= 0 && y < deps.worldGridSize) { + deps.state.player.x = x; + deps.state.player.y = y; + } + } + } catch { + // Ignore malformed saved positions. + } + } + + try { + await deps.mediaPopulateAudioDevices(); + if (deps.dom.audioInputSelect.options.length === 0) { + deps.updateStatus('No audio input device found. Open Settings or connect a microphone.'); + deps.mediaSetConnecting(false); + deps.updateConnectAvailability(); + return; + } + const inputDeviceId = deps.dom.audioInputSelect.value || deps.mediaGetPreferredInputDeviceId(); + await deps.mediaSetupLocalMedia(inputDeviceId); + } catch (error) { + console.error(error); + deps.updateStatus(deps.mediaDescribeError(error)); + deps.mediaSetConnecting(false); + deps.updateConnectAvailability(); + return; + } + + try { + await deps.signalingConnect(deps.onMessage); + } catch (error) { + console.error(error); + deps.mediaStopLocalMedia(); + deps.updateStatus('Connect failed. Signaling server may be offline or unreachable.'); + deps.mediaSetConnecting(false); + deps.updateConnectAvailability(); + } +} + +/** + * Runs disconnect flow and resets client runtime state back to pre-connect UI. + */ +export function runDisconnectFlow(deps: ConnectFlowDeps): void { + const wasRunning = deps.state.running; + if (deps.state.running) { + deps.persistPlayerPosition(); + } + + deps.signalingDisconnect(); + deps.mediaStopLocalMedia(); + deps.peerManagerCleanupAll(); + deps.radioCleanupAll(); + deps.emitCleanupAll(); + + deps.state.running = false; + deps.state.keysPressed = {}; + deps.state.peers.clear(); + deps.state.items.clear(); + deps.state.carriedItemId = null; + deps.state.mode = 'normal'; + deps.state.sortedItemIds = []; + deps.state.itemListIndex = 0; + deps.state.selectedItemIds = []; + deps.state.selectionContext = null; + deps.state.selectedItemIndex = 0; + deps.state.selectedItemId = null; + deps.state.itemPropertyKeys = []; + deps.state.itemPropertyIndex = 0; + deps.state.editingPropertyKey = null; + deps.state.itemPropertyOptionValues = []; + deps.state.itemPropertyOptionIndex = 0; + deps.state.effectSelectIndex = 0; + + deps.mediaSetConnecting(false); + deps.dom.nicknameContainer.classList.remove('hidden'); + deps.dom.connectButton.classList.remove('hidden'); + deps.dom.disconnectButton.classList.add('hidden'); + deps.dom.focusGridButton.classList.add('hidden'); + deps.dom.canvas.classList.add('hidden'); + deps.dom.instructions.classList.add('hidden'); + deps.updateConnectAvailability(); + + deps.updateStatus('Disconnected.'); + if (wasRunning) { + deps.playLogoutSound(); + } +} diff --git a/client/src/session/mediaSession.ts b/client/src/session/mediaSession.ts new file mode 100644 index 0000000..bdd6e1a --- /dev/null +++ b/client/src/session/mediaSession.ts @@ -0,0 +1,324 @@ +import { type GameState } from '../state/gameState'; +import { AudioEngine } from '../audio/audioEngine'; +import { PeerManager } from '../webrtc/peerManager'; +import { SettingsStore } from '../settings/settingsStore'; +import { formatSteppedNumber, snapNumberToStep } from '../input/numeric'; + +type DeviceDom = { + audioInputSelect: HTMLSelectElement; + audioOutputSelect: HTMLSelectElement; + audioInputCurrent: HTMLParagraphElement; + audioOutputCurrent: HTMLParagraphElement; +}; + +type SessionOptions = { + state: GameState; + audio: AudioEngine; + peerManager: PeerManager; + settings: SettingsStore; + dom: DeviceDom; + updateStatus: (message: string) => void; + micCalibrationDurationMs: number; + micCalibrationSampleIntervalMs: number; + micCalibrationMinGain: number; + micCalibrationMaxGain: number; + micCalibrationTargetRms: number; + micCalibrationActiveRmsThreshold: number; + micInputGainScaleMultiplier: number; + micInputGainStep: number; +}; + +/** + * Owns browser media/session lifecycle state and related device preference handling. + */ +export class MediaSession { + private localStream: MediaStream | null = null; + private outboundStream: MediaStream | null = null; + private connecting = false; + private calibratingMicInput = false; + private preferredInputDeviceId: string; + private preferredOutputDeviceId: string; + private preferredInputDeviceName: string; + private preferredOutputDeviceName: string; + + constructor(private readonly options: SessionOptions) { + const prefs = this.options.settings.loadAudioDevicePreferences(); + this.preferredInputDeviceId = prefs.input.id; + this.preferredOutputDeviceId = prefs.output.id; + this.preferredInputDeviceName = prefs.input.name; + this.preferredOutputDeviceName = prefs.output.name; + } + + /** Returns the current outbound stream used for peer send tracks. */ + getOutboundStream(): MediaStream | null { + return this.outboundStream; + } + + /** Returns whether a connect flow is currently in progress. */ + isConnecting(): boolean { + return this.connecting; + } + + /** Sets connecting flag for external message handlers. */ + setConnecting(value: boolean): void { + this.connecting = value; + } + + /** Returns stored preferred input device id, if any. */ + getPreferredInputDeviceId(): string { + return this.preferredInputDeviceId; + } + + /** Returns browser-selected audio output mode from persisted settings. */ + loadOutputMode(): 'mono' | 'stereo' { + return this.options.settings.loadOutputMode(); + } + + /** Persists audio output mode selection. */ + saveOutputMode(value: 'mono' | 'stereo'): void { + this.options.settings.saveOutputMode(value); + } + + /** Updates stored preferred input device and persists it. */ + setPreferredInput(id: string, name: string): void { + this.preferredInputDeviceId = id; + this.preferredInputDeviceName = name || this.preferredInputDeviceName; + this.options.settings.savePreferredInput(this.preferredInputDeviceId, this.preferredInputDeviceName); + } + + /** Updates stored preferred output device and persists it. */ + setPreferredOutput(id: string, name: string): void { + this.preferredOutputDeviceId = id; + this.preferredOutputDeviceName = name || this.preferredOutputDeviceName; + this.options.settings.savePreferredOutput(this.preferredOutputDeviceId, this.preferredOutputDeviceName); + } + + /** Applies saved device labels to pre-connect status summary rows. */ + updateDeviceSummary(): void { + const { dom } = this.options; + if (this.preferredInputDeviceId) { + const text = dom.audioInputSelect.selectedOptions[0]?.text || this.preferredInputDeviceName || 'Saved microphone'; + dom.audioInputCurrent.textContent = `Input: ${text}`; + dom.audioInputCurrent.classList.remove('hidden'); + } else { + dom.audioInputCurrent.classList.add('hidden'); + } + + if (this.preferredOutputDeviceId) { + const text = dom.audioOutputSelect.selectedOptions[0]?.text || this.preferredOutputDeviceName || 'Saved speakers'; + dom.audioOutputCurrent.textContent = `Output: ${text}`; + dom.audioOutputCurrent.classList.remove('hidden'); + } else { + dom.audioOutputCurrent.classList.add('hidden'); + } + } + + /** Enumerates audio input/output devices and restores saved choices where possible. */ + async populateAudioDevices(): Promise { + if (!navigator.mediaDevices?.enumerateDevices) { + return; + } + + let temporaryStream: MediaStream | null = null; + try { + temporaryStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const devices = await navigator.mediaDevices.enumerateDevices(); + const { dom } = this.options; + dom.audioInputSelect.innerHTML = ''; + dom.audioOutputSelect.innerHTML = ''; + + for (const device of devices) { + if (device.kind === 'audioinput') { + dom.audioInputSelect.add(new Option(device.label || `Microphone ${dom.audioInputSelect.length + 1}`, device.deviceId)); + } + if (device.kind === 'audiooutput') { + dom.audioOutputSelect.add(new Option(device.label || `Speaker ${dom.audioOutputSelect.length + 1}`, device.deviceId)); + } + } + + if (this.preferredInputDeviceId && Array.from(dom.audioInputSelect.options).some((option) => option.value === this.preferredInputDeviceId)) { + dom.audioInputSelect.value = this.preferredInputDeviceId; + this.preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || this.preferredInputDeviceName; + } else if (dom.audioInputSelect.options.length > 0) { + this.preferredInputDeviceId = dom.audioInputSelect.value; + this.preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || this.preferredInputDeviceName; + this.options.settings.savePreferredInput(this.preferredInputDeviceId, this.preferredInputDeviceName); + } + + if (this.preferredOutputDeviceId && Array.from(dom.audioOutputSelect.options).some((option) => option.value === this.preferredOutputDeviceId)) { + dom.audioOutputSelect.value = this.preferredOutputDeviceId; + this.preferredOutputDeviceName = dom.audioOutputSelect.selectedOptions[0]?.text || this.preferredOutputDeviceName; + void this.options.peerManager.setOutputDevice(this.preferredOutputDeviceId); + } + + const sinkCapable = typeof (HTMLMediaElement.prototype as HTMLMediaElement & { setSinkId?: unknown }).setSinkId === 'function'; + dom.audioOutputSelect.disabled = !sinkCapable; + this.updateDeviceSummary(); + } catch { + this.options.updateStatus('Could not list devices.'); + } finally { + temporaryStream?.getTracks().forEach((track) => track.stop()); + } + } + + /** Returns true when microphone permission is available or cannot be preflight-checked. */ + async checkMicPermission(): Promise { + const permissionApi = navigator.permissions; + if (!permissionApi?.query) return true; + try { + const result = await permissionApi.query({ name: 'microphone' as PermissionName }); + return result.state !== 'denied'; + } catch { + return true; + } + } + + /** Maps capture/setup exceptions to user-facing text. */ + describeMediaError(error: unknown): string { + if (error instanceof DOMException) { + if (error.name === 'NotAllowedError') return 'Microphone blocked. Allow mic access in browser site settings.'; + if (error.name === 'NotFoundError') return 'No microphone found. Check that an input device is connected and enabled.'; + if (error.name === 'NotReadableError') return 'Microphone is busy or unavailable. Close other apps using the mic and retry.'; + if (error.name === 'OverconstrainedError') return 'Selected audio device is unavailable. Choose another input device.'; + if (error.name === 'SecurityError') return 'Microphone access requires a secure context (HTTPS) in production.'; + } + return 'Audio setup failed. Check browser permissions and selected input device.'; + } + + /** Starts local capture and replaces outbound peer tracks. */ + async setupLocalMedia(audioDeviceId = ''): Promise { + this.stopLocalMedia(); + await this.options.audio.ensureContext(); + + const constraints: MediaStreamConstraints = { + audio: { + deviceId: audioDeviceId ? { exact: audioDeviceId } : undefined, + sampleRate: 48000, + channelCount: 2, + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + }, + video: false, + }; + + this.localStream = await navigator.mediaDevices.getUserMedia(constraints); + const audioTrack = this.localStream.getAudioTracks()[0]; + if (audioTrack) { + audioTrack.enabled = !this.options.state.isMuted; + } + this.outboundStream = await this.options.audio.configureOutboundStream(this.localStream); + await this.options.peerManager.replaceOutgoingTrack(this.outboundStream); + } + + /** Stops local media tracks and clears outbound references. */ + stopLocalMedia(): void { + if (this.localStream) { + this.localStream.getTracks().forEach((track) => track.stop()); + this.localStream = null; + } + this.outboundStream = null; + } + + /** Applies mute state to active local track when present. */ + applyMuteToTrack(isMuted: boolean): void { + if (!this.localStream) return; + const track = this.localStream.getAudioTracks()[0]; + if (track) { + track.enabled = !isMuted; + } + } + + /** Calibrates mic gain from a short speech sample and persists applied value. */ + async calibrateMicInputGain( + clampMicInputGain: (value: number) => number, + persistMicInputGain: (value: number) => void, + ): Promise { + const { + updateStatus, + audio, + micCalibrationDurationMs, + micCalibrationSampleIntervalMs, + micCalibrationActiveRmsThreshold, + micCalibrationTargetRms, + micInputGainScaleMultiplier, + micInputGainStep, + micCalibrationMinGain, + } = this.options; + if (this.calibratingMicInput) { + updateStatus('Mic calibration already running.'); + return; + } + if (!this.options.state.running || !this.localStream) { + updateStatus('Connect first, then use Shift+C to calibrate.'); + audio.sfxUiCancel(); + return; + } + const track = this.localStream.getAudioTracks()[0]; + if (!track || track.readyState !== 'live') { + updateStatus('No active microphone track for calibration.'); + audio.sfxUiCancel(); + return; + } + await audio.ensureContext(); + const audioContext = audio.context; + if (!audioContext) { + updateStatus('Audio context unavailable.'); + audio.sfxUiCancel(); + return; + } + + this.calibratingMicInput = true; + updateStatus('Speak for 5 seconds to calibrate your audio.'); + audio.sfxUiBlip(); + + const source = audioContext.createMediaStreamSource(new MediaStream([track])); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.2; + source.connect(analyser); + const samples = new Float32Array(analyser.fftSize); + const rmsValues: number[] = []; + + try { + const startedAt = performance.now(); + while (performance.now() - startedAt < micCalibrationDurationMs) { + analyser.getFloatTimeDomainData(samples); + let sumSquares = 0; + for (let i = 0; i < samples.length; i += 1) { + const sample = samples[i]; + sumSquares += sample * sample; + } + rmsValues.push(Math.sqrt(sumSquares / samples.length)); + await new Promise((resolve) => window.setTimeout(resolve, micCalibrationSampleIntervalMs)); + } + } finally { + source.disconnect(); + analyser.disconnect(); + this.calibratingMicInput = false; + } + + const activeRms = rmsValues.filter((value) => value >= micCalibrationActiveRmsThreshold); + if (activeRms.length < 10) { + updateStatus('No audio detected, please try again.'); + audio.sfxUiCancel(); + return; + } + + activeRms.sort((a, b) => a - b); + const percentileIndex = Math.min(activeRms.length - 1, Math.floor(activeRms.length * 0.9)); + const observedRms = activeRms[percentileIndex]; + if (!(observedRms > 0)) { + updateStatus('No audio detected, please try again.'); + audio.sfxUiCancel(); + return; + } + + const calibratedGain = clampMicInputGain((micCalibrationTargetRms / observedRms) * micInputGainScaleMultiplier); + const roundedGain = clampMicInputGain(snapNumberToStep(calibratedGain, micInputGainStep, micCalibrationMinGain)); + const appliedGain = audio.setOutboundInputGain(roundedGain); + persistMicInputGain(appliedGain); + updateStatus(`Mic calibration set to ${formatSteppedNumber(appliedGain, micInputGainStep)}x.`); + audio.sfxUiConfirm(); + } +} diff --git a/client/src/settings/settingsStore.ts b/client/src/settings/settingsStore.ts new file mode 100644 index 0000000..4f5cb1a --- /dev/null +++ b/client/src/settings/settingsStore.ts @@ -0,0 +1,114 @@ +import type { AudioLayerState } from '../types/audio'; + +const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels'; +const AUDIO_INPUT_STORAGE_KEY = 'chatGridAudioInputDeviceId'; +const AUDIO_OUTPUT_STORAGE_KEY = 'chatGridAudioOutputDeviceId'; +const AUDIO_INPUT_NAME_STORAGE_KEY = 'chatGridAudioInputDeviceName'; +const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName'; +const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode'; +const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers'; +const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain'; +const NICKNAME_STORAGE_KEY = 'spatialChatNickname'; + +type DevicePreference = { + id: string; + name: string; +}; + +type AudioDevicePreferences = { + input: DevicePreference; + output: DevicePreference; +}; + +/** + * Wraps localStorage reads/writes for client user settings. + */ +export class SettingsStore { + loadEffectLevels(): Partial> | null { + const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as Partial>; + } catch { + return null; + } + } + + saveEffectLevels(levels: Record): void { + localStorage.setItem(EFFECT_LEVELS_STORAGE_KEY, JSON.stringify(levels)); + } + + loadAudioLayers(): AudioLayerState { + const raw = localStorage.getItem(AUDIO_LAYER_STATE_STORAGE_KEY); + if (!raw) { + return { voice: true, item: true, media: true, world: true }; + } + try { + const parsed = JSON.parse(raw) as Partial; + return { + voice: parsed.voice !== false, + item: parsed.item !== false, + media: parsed.media !== false, + world: parsed.world !== false, + }; + } catch { + return { voice: true, item: true, media: true, world: true }; + } + } + + saveAudioLayers(layers: AudioLayerState): void { + localStorage.setItem(AUDIO_LAYER_STATE_STORAGE_KEY, JSON.stringify(layers)); + } + + loadMicInputGain(): number | null { + const raw = localStorage.getItem(MIC_INPUT_GAIN_STORAGE_KEY); + if (!raw) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } + + saveMicInputGain(value: number): void { + localStorage.setItem(MIC_INPUT_GAIN_STORAGE_KEY, String(value)); + } + + loadNickname(): string { + return localStorage.getItem(NICKNAME_STORAGE_KEY) || ''; + } + + saveNickname(value: string): void { + localStorage.setItem(NICKNAME_STORAGE_KEY, value); + } + + loadOutputMode(): 'mono' | 'stereo' { + return localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono' ? 'mono' : 'stereo'; + } + + saveOutputMode(value: 'mono' | 'stereo'): void { + localStorage.setItem(AUDIO_OUTPUT_MODE_STORAGE_KEY, value); + } + + loadAudioDevicePreferences(): AudioDevicePreferences { + return { + input: { + id: localStorage.getItem(AUDIO_INPUT_STORAGE_KEY) || '', + name: localStorage.getItem(AUDIO_INPUT_NAME_STORAGE_KEY) || '', + }, + output: { + id: localStorage.getItem(AUDIO_OUTPUT_STORAGE_KEY) || '', + name: localStorage.getItem(AUDIO_OUTPUT_NAME_STORAGE_KEY) || '', + }, + }; + } + + savePreferredInput(id: string, name: string): void { + localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, id); + localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, name); + } + + savePreferredOutput(id: string, name: string): void { + localStorage.setItem(AUDIO_OUTPUT_STORAGE_KEY, id); + localStorage.setItem(AUDIO_OUTPUT_NAME_STORAGE_KEY, name); + } +} + +export { NICKNAME_STORAGE_KEY }; diff --git a/client/src/types/audio.ts b/client/src/types/audio.ts new file mode 100644 index 0000000..ab0ecf1 --- /dev/null +++ b/client/src/types/audio.ts @@ -0,0 +1,6 @@ +export type AudioLayerState = { + voice: boolean; + item: boolean; + media: boolean; + world: boolean; +}; diff --git a/docs/controls.md b/docs/controls.md index 2872c8f..9070515 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -13,7 +13,7 @@ This document is the authoritative keymap for the client. ### Users, Nickname, Chat - `L`: Locate nearest user - `Shift+L`: List users and teleport to selected user with `Enter` -- `Shift+U`: Speak connected users +- `U`: Speak connected users - `N`: Edit nickname - `/`: Start chat - `,` / `.`: Previous/next message @@ -27,10 +27,12 @@ This document is the authoritative keymap for the client. - `Shift+O`: Inspect all item properties - `D`: Pick up/drop item - `Shift+D`: Delete item -- `U`: Use item +- `Enter`: Use item ### Audio - `P`: Ping server +- `V`: Set microphone gain +- `Shift+V`: Microphone calibration - `M`: Mute/unmute local microphone - `Shift+M`: Toggle stereo/mono output - `Shift+1` (`!`): Toggle loopback monitor @@ -49,11 +51,17 @@ This document is the authoritative keymap for the client. - `Ctrl+ArrowLeft` / `Ctrl+ArrowRight`: Move cursor by word (notepad-style) - `Home` / `End`: Move to start/end - `Backspace`: Delete previous character +- `Delete`: Delete current character - `Ctrl+A`: Select all (replace-on-next-type) - `Ctrl+C`: Copy current text - `Ctrl+X`: Cut current text - `Ctrl+V`: Paste +## Numeric Edit Fields + +- `ArrowUp` / `ArrowDown`: Step value +- `PageUp` / `PageDown`: Step by 10 increments + ## Menu/List Navigation Modes Applies to effect select, user/item list modes, item selection, item property list, and property option select.