refactor: extract session/settings flows and tighten shifted key commands
This commit is contained in:
@@ -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 R157";
|
window.CHGRID_WEB_VERSION = "2026.02.22 R158";
|
||||||
// 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";
|
||||||
|
|||||||
@@ -36,24 +36,24 @@ export type MainModeCommand =
|
|||||||
* Maps raw key events to a semantic command for main mode handling.
|
* Maps raw key events to a semantic command for main mode handling.
|
||||||
*/
|
*/
|
||||||
export function resolveMainModeCommand(code: string, shiftKey: boolean): MainModeCommand | null {
|
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 === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute';
|
||||||
if (code === 'Digit1') return shiftKey ? 'toggleLoopback' : 'toggleVoiceLayer';
|
if (code === 'Digit1') return shiftKey ? 'toggleLoopback' : 'toggleVoiceLayer';
|
||||||
if (code === 'Digit2') return 'toggleItemLayer';
|
if (code === 'Digit2') return 'toggleItemLayer';
|
||||||
if (code === 'Digit3') return 'toggleMediaLayer';
|
if (code === 'Digit3') return 'toggleMediaLayer';
|
||||||
if (code === 'Digit4') return 'toggleWorldLayer';
|
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 === 'Equal' || code === 'NumpadAdd') return 'effectValueUp';
|
||||||
if (code === 'Minus' || code === 'NumpadSubtract') return 'effectValueDown';
|
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 === 'KeyV') return shiftKey ? 'calibrateMicrophone' : 'openMicGainEdit';
|
||||||
if (code === 'Enter') return 'useItem';
|
if (code === 'Enter') return 'useItem';
|
||||||
if (code === 'KeyU') return 'speakUsers';
|
if (code === 'KeyU') return shiftKey ? null : 'speakUsers';
|
||||||
if (code === 'KeyA') return 'addItem';
|
if (code === 'KeyA') return shiftKey ? null : 'addItem';
|
||||||
if (code === 'KeyI') return 'locateOrListItems';
|
if (code === 'KeyI') return 'locateOrListItems';
|
||||||
if (code === 'KeyD') return 'pickupDropOrDelete';
|
if (code === 'KeyD') return 'pickupDropOrDelete';
|
||||||
if (code === 'KeyO') return 'editOrInspectItem';
|
if (code === 'KeyO') return 'editOrInspectItem';
|
||||||
if (code === 'KeyP') return 'pingServer';
|
if (code === 'KeyP') return shiftKey ? null : 'pingServer';
|
||||||
if (code === 'KeyL') return 'locateOrListUsers';
|
if (code === 'KeyL') return 'locateOrListUsers';
|
||||||
if (code === 'Slash') return shiftKey ? 'openHelp' : 'openChat';
|
if (code === 'Slash') return shiftKey ? 'openHelp' : 'openChat';
|
||||||
if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev';
|
if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev';
|
||||||
|
|||||||
27
client/src/input/modeDispatcher.ts
Normal file
27
client/src/input/modeDispatcher.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { GameMode } from '../state/gameState';
|
||||||
|
|
||||||
|
type ModeHandler = (code: string, key: string, ctrlKey: boolean) => void;
|
||||||
|
|
||||||
|
type ModeHandlers = Partial<Record<GameMode, ModeHandler>>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
shouldReplaceCurrentText,
|
shouldReplaceCurrentText,
|
||||||
} from './input/textInput';
|
} from './input/textInput';
|
||||||
import { resolveMainModeCommand } from './input/mainCommandRouter';
|
import { resolveMainModeCommand } from './input/mainCommandRouter';
|
||||||
|
import { dispatchModeInput } from './input/modeDispatcher';
|
||||||
import { handleListControlKey } from './input/listController';
|
import { handleListControlKey } from './input/listController';
|
||||||
import { getEditSessionAction } from './input/editSession';
|
import { getEditSessionAction } from './input/editSession';
|
||||||
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
||||||
@@ -59,19 +60,14 @@ import {
|
|||||||
itemTypeLabel,
|
itemTypeLabel,
|
||||||
} from './items/itemRegistry';
|
} from './items/itemRegistry';
|
||||||
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
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 { setupUiHandlers as setupDomUiHandlers } from './ui/domBindings';
|
||||||
import { PeerManager } from './webrtc/peerManager';
|
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 DEFAULT_DISPLAY_TIME_ZONE = 'America/Detroit';
|
||||||
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
|
|
||||||
const NICKNAME_MAX_LENGTH = 32;
|
const NICKNAME_MAX_LENGTH = 32;
|
||||||
const MIC_CALIBRATION_DURATION_MS = 5000;
|
const MIC_CALIBRATION_DURATION_MS = 5000;
|
||||||
const MIC_CALIBRATION_SAMPLE_INTERVAL_MS = 50;
|
const MIC_CALIBRATION_SAMPLE_INTERVAL_MS = 50;
|
||||||
@@ -156,13 +152,6 @@ type HelpData = {
|
|||||||
sections: HelpSection[];
|
sections: HelpSection[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type AudioLayerState = {
|
|
||||||
voice: boolean;
|
|
||||||
item: boolean;
|
|
||||||
media: boolean;
|
|
||||||
world: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim();
|
const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim();
|
||||||
const DISPLAY_TIME_ZONE = resolveDisplayTimeZone();
|
const DISPLAY_TIME_ZONE = resolveDisplayTimeZone();
|
||||||
dom.appVersion.textContent = APP_VERSION
|
dom.appVersion.textContent = APP_VERSION
|
||||||
@@ -187,20 +176,14 @@ const WALL_SOUND_URL = withBase('sounds/wall.ogg');
|
|||||||
const state = createInitialState();
|
const state = createInitialState();
|
||||||
const renderer = new CanvasRenderer(dom.canvas);
|
const renderer = new CanvasRenderer(dom.canvas);
|
||||||
const audio = new AudioEngine();
|
const audio = new AudioEngine();
|
||||||
|
const settings = new SettingsStore();
|
||||||
let worldGridSize = GRID_SIZE;
|
let worldGridSize = GRID_SIZE;
|
||||||
let lastWallCollisionDirection: string | null = null;
|
let lastWallCollisionDirection: string | null = null;
|
||||||
let localStream: MediaStream | null = null;
|
|
||||||
let outboundStream: MediaStream | null = null;
|
|
||||||
let statusTimeout: number | null = null;
|
let statusTimeout: number | null = null;
|
||||||
let lastFocusedElement: Element | null = null;
|
let lastFocusedElement: Element | null = null;
|
||||||
let lastAnnouncementText = '';
|
let lastAnnouncementText = '';
|
||||||
let lastAnnouncementAt = 0;
|
let lastAnnouncementAt = 0;
|
||||||
let preferredInputDeviceId = localStorage.getItem(AUDIO_INPUT_STORAGE_KEY) || '';
|
let outputMode = settings.loadOutputMode();
|
||||||
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;
|
|
||||||
const messageBuffer: string[] = [];
|
const messageBuffer: string[] = [];
|
||||||
let messageCursor = -1;
|
let messageCursor = -1;
|
||||||
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
||||||
@@ -210,7 +193,6 @@ let replaceTextOnNextType = false;
|
|||||||
let pendingEscapeDisconnect = false;
|
let pendingEscapeDisconnect = false;
|
||||||
let helpViewerLines: string[] = [];
|
let helpViewerLines: string[] = [];
|
||||||
let helpViewerIndex = 0;
|
let helpViewerIndex = 0;
|
||||||
let calibratingMicInput = false;
|
|
||||||
let audioLayers: AudioLayerState = {
|
let audioLayers: AudioLayerState = {
|
||||||
voice: true,
|
voice: true,
|
||||||
item: true,
|
item: true,
|
||||||
@@ -227,9 +209,25 @@ const peerManager = new PeerManager(
|
|||||||
(targetId, payload) => {
|
(targetId, payload) => {
|
||||||
signaling.send({ type: 'signal', targetId, ...payload });
|
signaling.send({ type: 'signal', targetId, ...payload });
|
||||||
},
|
},
|
||||||
() => outboundStream,
|
() => mediaSession.getOutboundStream(),
|
||||||
updateStatus,
|
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);
|
audio.setOutputMode(outputMode);
|
||||||
|
|
||||||
loadEffectLevels();
|
loadEffectLevels();
|
||||||
@@ -416,50 +414,30 @@ function updateConnectAvailability(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasNickname = sanitizeName(dom.preconnectNickname.value).length > 0;
|
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. */
|
/** Restores persisted outbound effect levels from local storage. */
|
||||||
function loadEffectLevels(): void {
|
function loadEffectLevels(): void {
|
||||||
const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY);
|
const parsed = settings.loadEffectLevels();
|
||||||
if (!raw) return;
|
if (!parsed) return;
|
||||||
try {
|
audio.setEffectLevels(parsed);
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists current outbound effect levels to local storage. */
|
/** Persists current outbound effect levels to local storage. */
|
||||||
function persistEffectLevels(): void {
|
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. */
|
/** Restores local audio-layer toggles and applies initial voice-layer state. */
|
||||||
function loadAudioLayerState(): void {
|
function loadAudioLayerState(): void {
|
||||||
const raw = localStorage.getItem(AUDIO_LAYER_STATE_STORAGE_KEY);
|
audioLayers = settings.loadAudioLayers();
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as Partial<AudioLayerState>;
|
|
||||||
audioLayers = {
|
|
||||||
voice: parsed.voice !== false,
|
|
||||||
item: parsed.item !== false,
|
|
||||||
media: parsed.media !== false,
|
|
||||||
world: parsed.world !== false,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed persisted values.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
audio.setVoiceLayerEnabled(audioLayers.voice);
|
audio.setVoiceLayerEnabled(audioLayers.voice);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists current audio-layer toggles to local storage. */
|
/** Persists current audio-layer toggles to local storage. */
|
||||||
function persistAudioLayerState(): void {
|
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. */
|
/** 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. */
|
/** Loads persisted microphone input gain and applies default when missing. */
|
||||||
function loadMicInputGain(): void {
|
function loadMicInputGain(): void {
|
||||||
const raw = localStorage.getItem(MIC_INPUT_GAIN_STORAGE_KEY);
|
const parsed = settings.loadMicInputGain();
|
||||||
if (!raw) {
|
if (parsed === null) {
|
||||||
audio.setOutboundInputGain(2);
|
audio.setOutboundInputGain(2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parsed = Number(raw);
|
|
||||||
audio.setOutboundInputGain(clampMicInputGain(parsed));
|
audio.setOutboundInputGain(clampMicInputGain(parsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists microphone input gain to local storage. */
|
/** Persists microphone input gain to local storage. */
|
||||||
function persistMicInputGain(value: number): void {
|
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. */
|
/** 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. */
|
/** Updates compact input/output device summary labels in the pre-connect UI. */
|
||||||
function updateDeviceSummary(): void {
|
function updateDeviceSummary(): void {
|
||||||
if (preferredInputDeviceId) {
|
mediaSession.updateDeviceSummary();
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns peer nicknames currently occupying the given grid cell. */
|
/** 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. */
|
/** Checks microphone permission state when Permissions API support is available. */
|
||||||
async function checkMicPermission(): Promise<boolean> {
|
async function checkMicPermission(): Promise<boolean> {
|
||||||
const permissionApi = navigator.permissions;
|
return mediaSession.checkMicPermission();
|
||||||
if (!permissionApi?.query) return true;
|
|
||||||
try {
|
|
||||||
const result = await permissionApi.query({ name: 'microphone' as PermissionName });
|
|
||||||
return result.state !== 'denied';
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts local microphone capture and rebuilds the outbound track pipeline. */
|
/** Starts local microphone capture and rebuilds the outbound track pipeline. */
|
||||||
async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
|
async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
|
||||||
stopLocalMedia();
|
await mediaSession.setupLocalMedia(audioDeviceId);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs a short RMS sample to estimate and apply a usable microphone input gain. */
|
/** Runs a short RMS sample to estimate and apply a usable microphone input gain. */
|
||||||
async function calibrateMicInputGain(): Promise<void> {
|
async function calibrateMicInputGain(): Promise<void> {
|
||||||
if (calibratingMicInput) {
|
await mediaSession.calibrateMicInputGain(clampMicInputGain, persistMicInputGain);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stops local capture tracks and clears outbound stream references. */
|
/** Stops local capture tracks and clears outbound stream references. */
|
||||||
function stopLocalMedia(): void {
|
function stopLocalMedia(): void {
|
||||||
if (localStream) {
|
mediaSession.stopLocalMedia();
|
||||||
localStream.getTracks().forEach((track) => track.stop());
|
|
||||||
localStream = null;
|
|
||||||
}
|
|
||||||
outboundStream = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps browser media/capture errors to user-facing remediation text. */
|
/** Maps browser media/capture errors to user-facing remediation text. */
|
||||||
function describeMediaError(error: unknown): string {
|
function describeMediaError(error: unknown): string {
|
||||||
if (error instanceof DOMException) {
|
return mediaSession.describeMediaError(error);
|
||||||
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.';
|
/** Builds dependencies shared by connect/disconnect flow helpers. */
|
||||||
if (error.name === 'OverconstrainedError') return 'Selected audio device is unavailable. Choose another input device.';
|
function getConnectionFlowDeps(): ConnectFlowDeps {
|
||||||
if (error.name === 'SecurityError') return 'Microphone access requires a secure context (HTTPS) in production.';
|
return {
|
||||||
}
|
state,
|
||||||
return 'Audio setup failed. Check browser permissions and selected input device.';
|
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<void>),
|
||||||
|
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. */
|
/** Performs end-to-end connect flow: validation, media setup, then signaling connection. */
|
||||||
async function connect(): Promise<void> {
|
async function connect(): Promise<void> {
|
||||||
if (connecting || state.running) {
|
await runConnectFlow(getConnectionFlowDeps());
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tears down active session state, media, peers, and UI back to pre-connect mode. */
|
/** Tears down active session state, media, peers, and UI back to pre-connect mode. */
|
||||||
function disconnect(): void {
|
function disconnect(): void {
|
||||||
const wasRunning = state.running;
|
runDisconnectFlow(getConnectionFlowDeps());
|
||||||
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;
|
|
||||||
pendingEscapeDisconnect = false;
|
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({
|
const onMessage = createOnMessageHandler({
|
||||||
@@ -1304,7 +1074,8 @@ const onMessage = createOnMessageHandler({
|
|||||||
worldGridSize = size;
|
worldGridSize = size;
|
||||||
},
|
},
|
||||||
setConnecting: (value) => {
|
setConnecting: (value) => {
|
||||||
connecting = value;
|
mediaSession.setConnecting(value);
|
||||||
|
updateConnectAvailability();
|
||||||
},
|
},
|
||||||
rendererSetGridSize: (size) => renderer.setGridSize(size),
|
rendererSetGridSize: (size) => renderer.setGridSize(size),
|
||||||
applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters<typeof applyServerItemUiDefinitions>[0]),
|
applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters<typeof applyServerItemUiDefinitions>[0]),
|
||||||
@@ -1352,10 +1123,7 @@ const onMessage = createOnMessageHandler({
|
|||||||
/** Toggles local microphone track mute state. */
|
/** Toggles local microphone track mute state. */
|
||||||
function toggleMute(): void {
|
function toggleMute(): void {
|
||||||
state.isMuted = !state.isMuted;
|
state.isMuted = !state.isMuted;
|
||||||
if (localStream) {
|
mediaSession.applyMuteToTrack(state.isMuted);
|
||||||
const track = localStream.getAudioTracks()[0];
|
|
||||||
if (track) track.enabled = !state.isMuted;
|
|
||||||
}
|
|
||||||
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.');
|
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1381,7 +1149,7 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
|||||||
return;
|
return;
|
||||||
case 'toggleOutputMode':
|
case 'toggleOutputMode':
|
||||||
outputMode = audio.toggleOutputMode();
|
outputMode = audio.toggleOutputMode();
|
||||||
localStorage.setItem(AUDIO_OUTPUT_MODE_STORAGE_KEY, outputMode);
|
mediaSession.saveOutputMode(outputMode);
|
||||||
updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.');
|
updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.');
|
||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
return;
|
return;
|
||||||
@@ -2111,33 +1879,30 @@ function setupInputHandlers(): void {
|
|||||||
|
|
||||||
if (isTypingKey(code) && state.keysPressed[code]) return;
|
if (isTypingKey(code) && state.keysPressed[code]) return;
|
||||||
|
|
||||||
if (state.mode === 'nickname') {
|
dispatchModeInput({
|
||||||
handleNicknameModeInput(code, event.key, event.ctrlKey);
|
mode: state.mode,
|
||||||
} else if (state.mode === 'chat') {
|
code,
|
||||||
handleChatModeInput(code, event.key, event.ctrlKey);
|
key: event.key,
|
||||||
} else if (state.mode === 'micGainEdit') {
|
ctrlKey: event.ctrlKey,
|
||||||
handleMicGainEditModeInput(code, event.key, event.ctrlKey);
|
shiftKey: event.shiftKey,
|
||||||
} else if (state.mode === 'effectSelect') {
|
handlers: {
|
||||||
handleEffectSelectModeInput(code, event.key);
|
nickname: handleNicknameModeInput,
|
||||||
} else if (state.mode === 'helpView') {
|
chat: handleChatModeInput,
|
||||||
handleHelpViewModeInput(code);
|
micGainEdit: handleMicGainEditModeInput,
|
||||||
} else if (state.mode === 'listUsers') {
|
effectSelect: (currentCode, currentKey) => handleEffectSelectModeInput(currentCode, currentKey),
|
||||||
handleListModeInput(code, event.key);
|
helpView: (currentCode) => handleHelpViewModeInput(currentCode),
|
||||||
} else if (state.mode === 'listItems') {
|
listUsers: (currentCode, currentKey) => handleListModeInput(currentCode, currentKey),
|
||||||
handleListItemsModeInput(code, event.key);
|
listItems: (currentCode, currentKey) => handleListItemsModeInput(currentCode, currentKey),
|
||||||
} else if (state.mode === 'addItem') {
|
addItem: (currentCode, currentKey) => handleAddItemModeInput(currentCode, currentKey),
|
||||||
handleAddItemModeInput(code, event.key);
|
selectItem: (currentCode, currentKey) => handleSelectItemModeInput(currentCode, currentKey),
|
||||||
} else if (state.mode === 'selectItem') {
|
itemProperties: (currentCode, currentKey) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey),
|
||||||
handleSelectItemModeInput(code, event.key);
|
itemPropertyEdit: (currentCode, currentKey, currentCtrlKey) =>
|
||||||
} else if (state.mode === 'itemProperties') {
|
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
|
||||||
itemPropertyEditor.handleItemPropertiesModeInput(code, event.key);
|
itemPropertyOptionSelect: (currentCode, currentKey) =>
|
||||||
} else if (state.mode === 'itemPropertyEdit') {
|
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey),
|
||||||
itemPropertyEditor.handleItemPropertyEditModeInput(code, event.key, event.ctrlKey);
|
},
|
||||||
} else if (state.mode === 'itemPropertyOptionSelect') {
|
onNormalMode: handleNormalModeInput,
|
||||||
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(code, event.key);
|
});
|
||||||
} else {
|
|
||||||
handleNormalModeInput(code, event.shiftKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.keysPressed[code] = true;
|
state.keysPressed[code] = true;
|
||||||
});
|
});
|
||||||
@@ -2158,52 +1923,7 @@ function setupInputHandlers(): void {
|
|||||||
|
|
||||||
/** Enumerates audio devices, updates selectors, and persists preferred choices. */
|
/** Enumerates audio devices, updates selectors, and persists preferred choices. */
|
||||||
async function populateAudioDevices(): Promise<void> {
|
async function populateAudioDevices(): Promise<void> {
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
await mediaSession.populateAudioDevices();
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens settings modal and focuses device controls. */
|
/** Opens settings modal and focuses device controls. */
|
||||||
@@ -2239,16 +1959,10 @@ function setupUiHandlers(): void {
|
|||||||
sfxUiBlip: () => audio.sfxUiBlip(),
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
||||||
setupLocalMedia,
|
setupLocalMedia,
|
||||||
setPreferredInput: (id, name) => {
|
setPreferredInput: (id, name) => {
|
||||||
preferredInputDeviceId = id;
|
mediaSession.setPreferredInput(id, name);
|
||||||
preferredInputDeviceName = name || preferredInputDeviceName;
|
|
||||||
localStorage.setItem(AUDIO_INPUT_STORAGE_KEY, preferredInputDeviceId);
|
|
||||||
localStorage.setItem(AUDIO_INPUT_NAME_STORAGE_KEY, preferredInputDeviceName);
|
|
||||||
},
|
},
|
||||||
setPreferredOutput: (id, name) => {
|
setPreferredOutput: (id, name) => {
|
||||||
preferredOutputDeviceId = id;
|
mediaSession.setPreferredOutput(id, name);
|
||||||
preferredOutputDeviceName = name || preferredOutputDeviceName;
|
|
||||||
localStorage.setItem(AUDIO_OUTPUT_STORAGE_KEY, preferredOutputDeviceId);
|
|
||||||
localStorage.setItem(AUDIO_OUTPUT_NAME_STORAGE_KEY, preferredOutputDeviceName);
|
|
||||||
},
|
},
|
||||||
updateDeviceSummary,
|
updateDeviceSummary,
|
||||||
setOutputDevice: (id) => peerManager.setOutputDevice(id),
|
setOutputDevice: (id) => peerManager.setOutputDevice(id),
|
||||||
@@ -2261,7 +1975,7 @@ function setupUiHandlers(): void {
|
|||||||
|
|
||||||
setupInputHandlers();
|
setupInputHandlers();
|
||||||
setupUiHandlers();
|
setupUiHandlers();
|
||||||
const storedNickname = sanitizeName(localStorage.getItem(NICKNAME_STORAGE_KEY) || '');
|
const storedNickname = sanitizeName(settings.loadNickname());
|
||||||
dom.preconnectNickname.value = storedNickname;
|
dom.preconnectNickname.value = storedNickname;
|
||||||
if (storedNickname) {
|
if (storedNickname) {
|
||||||
state.player.nickname = storedNickname;
|
state.player.nickname = storedNickname;
|
||||||
|
|||||||
162
client/src/session/connectionFlow.ts
Normal file
162
client/src/session/connectionFlow.ts
Normal file
@@ -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<boolean>;
|
||||||
|
mediaPopulateAudioDevices: () => Promise<void>;
|
||||||
|
mediaGetPreferredInputDeviceId: () => string;
|
||||||
|
mediaSetupLocalMedia: (audioDeviceId: string) => Promise<void>;
|
||||||
|
mediaDescribeError: (error: unknown) => string;
|
||||||
|
mediaStopLocalMedia: () => void;
|
||||||
|
signalingConnect: (onMessage: (message: unknown) => Promise<void>) => Promise<void>;
|
||||||
|
signalingDisconnect: () => void;
|
||||||
|
onMessage: (message: unknown) => Promise<void>;
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
324
client/src/session/mediaSession.ts
Normal file
324
client/src/session/mediaSession.ts
Normal file
@@ -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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
114
client/src/settings/settingsStore.ts
Normal file
114
client/src/settings/settingsStore.ts
Normal file
@@ -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<Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number>> | null {
|
||||||
|
const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as Partial<Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number>>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEffectLevels(levels: Record<string, number>): 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<AudioLayerState>;
|
||||||
|
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 };
|
||||||
6
client/src/types/audio.ts
Normal file
6
client/src/types/audio.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type AudioLayerState = {
|
||||||
|
voice: boolean;
|
||||||
|
item: boolean;
|
||||||
|
media: boolean;
|
||||||
|
world: boolean;
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ This document is the authoritative keymap for the client.
|
|||||||
### Users, Nickname, Chat
|
### Users, Nickname, Chat
|
||||||
- `L`: Locate nearest user
|
- `L`: Locate nearest user
|
||||||
- `Shift+L`: List users and teleport to selected user with `Enter`
|
- `Shift+L`: List users and teleport to selected user with `Enter`
|
||||||
- `Shift+U`: Speak connected users
|
- `U`: Speak connected users
|
||||||
- `N`: Edit nickname
|
- `N`: Edit nickname
|
||||||
- `/`: Start chat
|
- `/`: Start chat
|
||||||
- `,` / `.`: Previous/next message
|
- `,` / `.`: Previous/next message
|
||||||
@@ -27,10 +27,12 @@ This document is the authoritative keymap for the client.
|
|||||||
- `Shift+O`: Inspect all item properties
|
- `Shift+O`: Inspect all item properties
|
||||||
- `D`: Pick up/drop item
|
- `D`: Pick up/drop item
|
||||||
- `Shift+D`: Delete item
|
- `Shift+D`: Delete item
|
||||||
- `U`: Use item
|
- `Enter`: Use item
|
||||||
|
|
||||||
### Audio
|
### Audio
|
||||||
- `P`: Ping server
|
- `P`: Ping server
|
||||||
|
- `V`: Set microphone gain
|
||||||
|
- `Shift+V`: Microphone calibration
|
||||||
- `M`: Mute/unmute local microphone
|
- `M`: Mute/unmute local microphone
|
||||||
- `Shift+M`: Toggle stereo/mono output
|
- `Shift+M`: Toggle stereo/mono output
|
||||||
- `Shift+1` (`!`): Toggle loopback monitor
|
- `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)
|
- `Ctrl+ArrowLeft` / `Ctrl+ArrowRight`: Move cursor by word (notepad-style)
|
||||||
- `Home` / `End`: Move to start/end
|
- `Home` / `End`: Move to start/end
|
||||||
- `Backspace`: Delete previous character
|
- `Backspace`: Delete previous character
|
||||||
|
- `Delete`: Delete current character
|
||||||
- `Ctrl+A`: Select all (replace-on-next-type)
|
- `Ctrl+A`: Select all (replace-on-next-type)
|
||||||
- `Ctrl+C`: Copy current text
|
- `Ctrl+C`: Copy current text
|
||||||
- `Ctrl+X`: Cut current text
|
- `Ctrl+X`: Cut current text
|
||||||
- `Ctrl+V`: Paste
|
- `Ctrl+V`: Paste
|
||||||
|
|
||||||
|
## Numeric Edit Fields
|
||||||
|
|
||||||
|
- `ArrowUp` / `ArrowDown`: Step value
|
||||||
|
- `PageUp` / `PageDown`: Step by 10 increments
|
||||||
|
|
||||||
## Menu/List Navigation Modes
|
## Menu/List Navigation Modes
|
||||||
|
|
||||||
Applies to effect select, user/item list modes, item selection, item property list, and property option select.
|
Applies to effect select, user/item list modes, item selection, item property list, and property option select.
|
||||||
|
|||||||
Reference in New Issue
Block a user