From 9707a5716924fde5ebcc12060efab23662df1ab4 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sun, 22 Feb 2026 16:16:16 -0500 Subject: [PATCH] Add Shift+C microphone input calibration --- client/public/help.json | 6 +- client/public/version.js | 2 +- client/src/audio/audioEngine.ts | 15 +++++ client/src/main.ts | 110 ++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/client/public/help.json b/client/public/help.json index 017a840..79217e6 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -129,8 +129,12 @@ { "keys": "Dash or Equals", "description": "Lower/raise active effect value" + }, + { + "keys": "Shift+C", + "description": "Calibrate mic input gain over 5 seconds" } ] } ] -} \ No newline at end of file +} diff --git a/client/public/version.js b/client/public/version.js index 8a637b4..6623c48 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 R147"; +window.CHGRID_WEB_VERSION = "2026.02.22 R148"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index ba445cb..9f8a68f 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -37,6 +37,7 @@ export class AudioEngine { private outboundSource: MediaStreamAudioSourceNode | null = null; private outboundInputGain: GainNode | null = null; + private outboundInputGainValue = 1; private outboundDestination: MediaStreamAudioDestinationNode | null = null; private outboundEffectRuntime: EffectRuntime | null = null; private loopbackEnabled = false; @@ -97,6 +98,7 @@ export class AudioEngine { if (!this.outboundInputGain) { this.outboundInputGain = this.audioCtx.createGain(); } + this.outboundInputGain.gain.value = this.outboundInputGainValue; if (!this.outboundDestination) { this.outboundDestination = this.audioCtx.createMediaStreamDestination(); } @@ -183,6 +185,19 @@ export class AudioEngine { return this.voiceLayerEnabled; } + setOutboundInputGain(value: number): number { + const next = Math.max(0.01, Number.isFinite(value) ? value : 1); + this.outboundInputGainValue = next; + if (this.outboundInputGain && this.audioCtx) { + this.outboundInputGain.gain.setValueAtTime(next, this.audioCtx.currentTime); + } + return next; + } + + getOutboundInputGain(): number { + return this.outboundInputGainValue; + } + toggleLoopback(): boolean { this.loopbackEnabled = !this.loopbackEnabled; this.rebuildOutboundEffectGraph(); diff --git a/client/src/main.ts b/client/src/main.ts index 91e2c2f..b4c32f6 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -62,9 +62,16 @@ 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; +const MIC_CALIBRATION_MIN_GAIN = 0.25; +const MIC_CALIBRATION_MAX_GAIN = 3; +const MIC_CALIBRATION_TARGET_RMS = 0.12; +const MIC_CALIBRATION_ACTIVE_RMS_THRESHOLD = 0.003; declare global { interface Window { @@ -193,6 +200,7 @@ let replaceTextOnNextType = false; let pendingEscapeDisconnect = false; let helpViewerLines: string[] = []; let helpViewerIndex = 0; +let calibratingMicInput = false; let audioLayers: AudioLayerState = { voice: true, item: true, @@ -216,6 +224,7 @@ audio.setOutputMode(outputMode); loadEffectLevels(); loadAudioLayerState(); +loadMicInputGain(); void loadHelp(); void loadChangelog(); @@ -428,6 +437,25 @@ function persistAudioLayerState(): void { localStorage.setItem(AUDIO_LAYER_STATE_STORAGE_KEY, JSON.stringify(audioLayers)); } +function clampMicInputGain(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(MIC_CALIBRATION_MIN_GAIN, Math.min(MIC_CALIBRATION_MAX_GAIN, value)); +} + +function loadMicInputGain(): void { + const raw = localStorage.getItem(MIC_INPUT_GAIN_STORAGE_KEY); + if (!raw) { + audio.setOutboundInputGain(1); + return; + } + const parsed = Number(raw); + audio.setOutboundInputGain(clampMicInputGain(parsed)); +} + +function persistMicInputGain(value: number): void { + localStorage.setItem(MIC_INPUT_GAIN_STORAGE_KEY, String(value)); +} + async function applyAudioLayerState(): Promise { audio.setVoiceLayerEnabled(audioLayers.voice); if (audioLayers.voice) { @@ -986,6 +1014,84 @@ async function setupLocalMedia(audioDeviceId = ''): Promise { await peerManager.replaceOutgoingTrack(outboundStream); } +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('Calibration failed. Speak continuously and 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('Calibration failed. Speak continuously and try again.'); + audio.sfxUiCancel(); + return; + } + + const calibratedGain = clampMicInputGain(MIC_CALIBRATION_TARGET_RMS / observedRms); + const appliedGain = audio.setOutboundInputGain(calibratedGain); + persistMicInputGain(appliedGain); + updateStatus(`Mic calibration set to ${appliedGain.toFixed(2)}x.`); + audio.sfxUiConfirm(); +} + function stopLocalMedia(): void { if (localStream) { localStream.getTracks().forEach((track) => track.stop()); @@ -1399,6 +1505,10 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { } if (code === 'KeyC') { + if (shiftKey) { + void calibrateMicInputGain(); + return; + } updateStatus(`${state.player.x}, ${state.player.y}`); audio.sfxUiBlip(); return;