diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 3d83c767..f7ee31d6 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -729,6 +729,7 @@ export const Join = ({ { try { saveAudioInputDeviceId(id) diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx index b6b52815..914760c8 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx @@ -1,8 +1,12 @@ import { useTranslation } from 'react-i18next' -import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react' +import { + useLocalParticipant, + useTrackToggle, + UseTrackToggleProps, +} from '@livekit/components-react' import { Button, Popover } from '@/primitives' import { RiArrowUpSLine } from '@remixicon/react' -import { Track } from 'livekit-client' +import { LocalAudioTrack, Track } from 'livekit-client' import { ToggleDevice } from './ToggleDevice' import { css } from '@/styled-system/css' @@ -51,6 +55,12 @@ export const AudioDevicesControl = ({ ...props, }) + const { microphoneTrack } = useLocalParticipant() + const localAudioTrack = + microphoneTrack?.track instanceof LocalAudioTrack + ? microphoneTrack.track + : undefined + const kind = 'audioinput' const cannotUseDevice = useCannotUseDevice(kind) const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`) @@ -111,6 +121,7 @@ export const AudioDevicesControl = ({ context="room" kind={kind} id={audioDeviceId} + track={localAudioTrack} onSubmit={saveAudioInputDeviceId} /> diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioLevelGauge.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioLevelGauge.tsx new file mode 100644 index 00000000..f5e20cb4 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioLevelGauge.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef, useState } from 'react' +import { + createAudioAnalyser, + LocalAudioTrack, + TrackEvent, +} from 'livekit-client' +import { useTranslation } from 'react-i18next' +import { RiMicLine, RiMicOffLine } from '@remixicon/react' +import { css } from '@/styled-system/css' + +/** + * Boost factor applied to the raw analyser volume so that normal speech + * animates the indicator visibly (Google Meet style sensitivity). + */ +const LEVEL_BOOST = 1.2 + +/** How fast the gauge falls back down between words (per frame). */ +const DECAY_PER_FRAME = 0.04 + +type AudioLevelGaugeProps = { + track?: LocalAudioTrack + variant?: 'light' | 'dark' +} + +export const AudioLevelGauge = ({ + track, + variant = 'light', +}: AudioLevelGaugeProps) => { + const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) + const fillRef = useRef(null) + + const [isMuted, setIsMuted] = useState(() => track?.isMuted ?? true) + + useEffect(() => { + if (!track) { + setIsMuted(true) + return + } + setIsMuted(track.isMuted) + const onMuted = () => setIsMuted(true) + const onUnmuted = () => setIsMuted(false) + track.on(TrackEvent.Muted, onMuted) + track.on(TrackEvent.Unmuted, onUnmuted) + return () => { + track.off(TrackEvent.Muted, onMuted) + track.off(TrackEvent.Unmuted, onUnmuted) + } + }, [track]) + + useEffect(() => { + if (!track || isMuted || !track.mediaStreamTrack) return + + let rafId: number + let smoothed = 0 + let calculateVolume: (() => number) | undefined + let cleanupAnalyser: (() => Promise) | undefined + + const setupAnalyser = () => { + cleanupAnalyser?.() + try { + const analyser = createAudioAnalyser(track, { + fftSize: 256, + smoothingTimeConstant: 0.7, + }) + calculateVolume = analyser.calculateVolume + cleanupAnalyser = analyser.cleanup + } catch (e) { + console.error('Failed to create audio analyser', e) + } + } + + setupAnalyser() + + // Re-bind the analyser when the underlying MediaStreamTrack is replaced + // (e.g. after switching to another input device). + track.on(TrackEvent.Restarted, setupAnalyser) + + const update = () => { + const volume = calculateVolume?.() ?? 0 + // Fast attack, slow release, like Google Meet. + smoothed = + volume > smoothed ? volume : Math.max(0, smoothed - DECAY_PER_FRAME) + const level = Math.min(1, smoothed * LEVEL_BOOST) + if (fillRef.current) { + fillRef.current.style.transform = `scaleX(${level})` + } + rafId = requestAnimationFrame(update) + } + rafId = requestAnimationFrame(update) + + return () => { + cancelAnimationFrame(rafId) + track.off(TrackEvent.Restarted, setupAnalyser) + cleanupAnalyser?.() + } + }, [track, isMuted]) + + const showMutedHint = !track || isMuted + + return ( +
+ {showMutedHint ? ( + <> +