feat(voice): add a live input level meter to voice menus (#2404)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-02 17:39:01 +02:00
1 parent 6155eec804
commit 7ee4fb37d6
36 files changed
+821 -544

No files matched your search

@@ -10,3 +10,19 @@
align-items: center;
gap: 0.5rem;
}
.inputLevelItem {
padding: 0.375rem 0.5rem;
border: 0.0625rem solid transparent;
}
.inputLevelLabel {
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 500;
}
.inputLevelMeter {
height: 1.5rem;
margin-top: 0.25rem;
}
@@ -13,6 +13,7 @@ import {MenuItemSlider} from '@app/features/ui/action_menu/MenuItemSlider';
import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {AudioLevelMeter} from '@app/features/user/components/modals/tabs/components/AudioLevelMeter';
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
import Users from '@app/features/user/state/Users';
import * as CallCommands from '@app/features/voice/commands/CallCommands';
@@ -23,6 +24,10 @@ import {CameraPreviewModalInRoom} from '@app/features/voice/components/modals/Ca
import {HideOwnCameraConfirmModal} from '@app/features/voice/components/modals/HideOwnCameraConfirmModal';
import styles from '@app/features/voice/components/VoiceSettingsMenus.module.css';
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
import {
getLocalPublicationMediaStreamTrack,
getPrimaryLocalMicrophonePublication,
} from '@app/features/voice/engine/VoiceTrackPublicationUtils';
import CallState from '@app/features/voice/state/CallState';
import VoiceCallLayout from '@app/features/voice/state/VoiceCallLayout';
import VoicePrompts from '@app/features/voice/state/VoicePrompts';
@@ -92,6 +97,10 @@ const VOICE_REGION_DESCRIPTOR = msg({
message: 'Voice region',
comment: 'Section header in the voice settings menu for the voice region picker.',
});
const INPUT_LEVEL_DESCRIPTOR = msg({
message: 'Input level',
comment: 'Read-only microphone input level shown in the voice device menu.',
});
const VOICE_SETTINGS_DESCRIPTOR = msg({
message: 'Voice settings',
comment: 'Menu action that opens voice settings.',
@@ -106,6 +115,9 @@ const MIRROR_CAMERA_DESCRIPTOR = msg({
});
const logger = new Logger('VoiceSettingsMenus');
const INPUT_LEVEL_MIN_DB = -60;
const INPUT_LEVEL_MAX_DB = 0;
type VoiceVideoSettingsSection = 'audio' | 'video';
export function openVoiceVideoSettings(onClose: () => void, section?: VoiceVideoSettingsSection): void {
@@ -207,6 +219,83 @@ const VoiceInputProfileSubmenu: React.FC = observer(() => {
);
});
const VoiceInputLevelItem: React.FC = () => {
const {i18n} = useLingui();
useMediaEngineVersion();
const room = MediaEngine.room;
const micPublication = room ? getPrimaryLocalMicrophonePublication(room.localParticipant) : null;
const micTrack = micPublication ? getLocalPublicationMediaStreamTrack(micPublication) : null;
const [level, setLevel] = useState(0);
useEffect(() => {
if (!micTrack) {
setLevel(0);
return;
}
const AudioContextClass =
window.AudioContext ?? (window as unknown as {webkitAudioContext?: typeof AudioContext}).webkitAudioContext;
if (!AudioContextClass) {
return;
}
const audioContext = new AudioContextClass();
const source = audioContext.createMediaStreamSource(new MediaStream([micTrack]));
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.2;
source.connect(analyser);
const timeDomain = new Float32Array(analyser.fftSize);
let frame = 0;
let disposed = false;
void audioContext.resume().catch(() => undefined);
const sampleLevel = () => {
if (disposed) {
return;
}
analyser.getFloatTimeDomainData(timeDomain);
let sumOfSquares = 0;
for (let i = 0; i < timeDomain.length; i++) {
sumOfSquares += timeDomain[i] * timeDomain[i];
}
const rms = Math.sqrt(sumOfSquares / timeDomain.length);
const decibels = 20 * Math.log10(Math.max(rms, 1e-10));
const normalized = Math.max(
0,
Math.min(1, (decibels - INPUT_LEVEL_MIN_DB) / (INPUT_LEVEL_MAX_DB - INPUT_LEVEL_MIN_DB)),
);
setLevel(normalized);
frame = requestAnimationFrame(sampleLevel);
};
frame = requestAnimationFrame(sampleLevel);
return () => {
disposed = true;
cancelAnimationFrame(frame);
source.disconnect();
analyser.disconnect();
void audioContext.close().catch(() => undefined);
};
}, [micTrack]);
const label = i18n._(INPUT_LEVEL_DESCRIPTOR);
return (
<div className={styles.inputLevelItem} data-flx="voice.voice-settings-menus.input-level">
<div
className={styles.inputLevelLabel}
data-flx="voice.voice-settings-menus.voice-input-level-item.input-level-label"
>
{label}
</div>
<AudioLevelMeter
level={level}
className={styles.inputLevelMeter}
role="meter"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(level * 100)}
data-flx="voice.voice-settings-menus.voice-input-level-item.input-level-meter"
/>
</div>
);
};
const VoiceInputVolumeItems: React.FC = observer(() => {
const {i18n} = useLingui();
return (
@@ -220,6 +309,7 @@ const VoiceInputVolumeItems: React.FC = observer(() => {
onFormat={(value) => `${Math.round(value)}%`}
data-flx="voice.voice-settings-menus.input-volume"
/>
<VoiceInputLevelItem data-flx="voice.voice-settings-menus.voice-input-volume-items.voice-input-level-item" />
</>
);
});