Add Shift+C microphone input calibration
This commit is contained in:
@@ -129,6 +129,10 @@
|
|||||||
{
|
{
|
||||||
"keys": "Dash or Equals",
|
"keys": "Dash or Equals",
|
||||||
"description": "Lower/raise active effect value"
|
"description": "Lower/raise active effect value"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": "Shift+C",
|
||||||
|
"description": "Calibrate mic input gain over 5 seconds"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 R147";
|
window.CHGRID_WEB_VERSION = "2026.02.22 R148";
|
||||||
// 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";
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class AudioEngine {
|
|||||||
|
|
||||||
private outboundSource: MediaStreamAudioSourceNode | null = null;
|
private outboundSource: MediaStreamAudioSourceNode | null = null;
|
||||||
private outboundInputGain: GainNode | null = null;
|
private outboundInputGain: GainNode | null = null;
|
||||||
|
private outboundInputGainValue = 1;
|
||||||
private outboundDestination: MediaStreamAudioDestinationNode | null = null;
|
private outboundDestination: MediaStreamAudioDestinationNode | null = null;
|
||||||
private outboundEffectRuntime: EffectRuntime | null = null;
|
private outboundEffectRuntime: EffectRuntime | null = null;
|
||||||
private loopbackEnabled = false;
|
private loopbackEnabled = false;
|
||||||
@@ -97,6 +98,7 @@ export class AudioEngine {
|
|||||||
if (!this.outboundInputGain) {
|
if (!this.outboundInputGain) {
|
||||||
this.outboundInputGain = this.audioCtx.createGain();
|
this.outboundInputGain = this.audioCtx.createGain();
|
||||||
}
|
}
|
||||||
|
this.outboundInputGain.gain.value = this.outboundInputGainValue;
|
||||||
if (!this.outboundDestination) {
|
if (!this.outboundDestination) {
|
||||||
this.outboundDestination = this.audioCtx.createMediaStreamDestination();
|
this.outboundDestination = this.audioCtx.createMediaStreamDestination();
|
||||||
}
|
}
|
||||||
@@ -183,6 +185,19 @@ export class AudioEngine {
|
|||||||
return this.voiceLayerEnabled;
|
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 {
|
toggleLoopback(): boolean {
|
||||||
this.loopbackEnabled = !this.loopbackEnabled;
|
this.loopbackEnabled = !this.loopbackEnabled;
|
||||||
this.rebuildOutboundEffectGraph();
|
this.rebuildOutboundEffectGraph();
|
||||||
|
|||||||
@@ -62,9 +62,16 @@ const AUDIO_INPUT_NAME_STORAGE_KEY = 'chatGridAudioInputDeviceName';
|
|||||||
const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName';
|
const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName';
|
||||||
const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode';
|
const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode';
|
||||||
const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers';
|
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_STORAGE_KEY = 'spatialChatNickname';
|
||||||
const NICKNAME_MAX_LENGTH = 32;
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -193,6 +200,7 @@ 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,
|
||||||
@@ -216,6 +224,7 @@ audio.setOutputMode(outputMode);
|
|||||||
|
|
||||||
loadEffectLevels();
|
loadEffectLevels();
|
||||||
loadAudioLayerState();
|
loadAudioLayerState();
|
||||||
|
loadMicInputGain();
|
||||||
void loadHelp();
|
void loadHelp();
|
||||||
void loadChangelog();
|
void loadChangelog();
|
||||||
|
|
||||||
@@ -428,6 +437,25 @@ function persistAudioLayerState(): void {
|
|||||||
localStorage.setItem(AUDIO_LAYER_STATE_STORAGE_KEY, JSON.stringify(audioLayers));
|
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<void> {
|
async function applyAudioLayerState(): Promise<void> {
|
||||||
audio.setVoiceLayerEnabled(audioLayers.voice);
|
audio.setVoiceLayerEnabled(audioLayers.voice);
|
||||||
if (audioLayers.voice) {
|
if (audioLayers.voice) {
|
||||||
@@ -986,6 +1014,84 @@ async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
|
|||||||
await peerManager.replaceOutgoingTrack(outboundStream);
|
await peerManager.replaceOutgoingTrack(outboundStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function calibrateMicInputGain(): Promise<void> {
|
||||||
|
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 {
|
function stopLocalMedia(): void {
|
||||||
if (localStream) {
|
if (localStream) {
|
||||||
localStream.getTracks().forEach((track) => track.stop());
|
localStream.getTracks().forEach((track) => track.stop());
|
||||||
@@ -1399,6 +1505,10 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (code === 'KeyC') {
|
if (code === 'KeyC') {
|
||||||
|
if (shiftKey) {
|
||||||
|
void calibrateMicInputGain();
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateStatus(`${state.player.x}, ${state.player.y}`);
|
updateStatus(`${state.player.x}, ${state.player.y}`);
|
||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user