wip add an audio gauge in the input menu

This commit is contained in:
lebaudantoine
2026-08-07 23:53:42 +02:00
parent 71ea6421dd
commit 1fca767855
9 changed files with 199 additions and 2 deletions
@@ -729,6 +729,7 @@ export const Join = ({
<SelectDevice
kind="audioinput"
id={audioDeviceId}
track={audioTrack}
onSubmit={async (id) => {
try {
saveAudioInputDeviceId(id)
@@ -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}
/>
</div>
@@ -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<HTMLDivElement>(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<void>) | 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 (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.625rem 0.75rem',
marginTop: '0.25rem',
borderTop: '1px solid',
minHeight: '2.5rem',
})}
style={{
borderColor: variant === 'dark' ? 'rgba(255 255 255 / 0.2)' : '#e0e0e0',
color: variant === 'dark' ? 'rgba(255 255 255 / 0.7)' : '#5f6368',
}}
>
{showMutedHint ? (
<>
<RiMicOffLine size={18} aria-hidden="true" />
<span
className={css({
fontSize: '0.875rem',
})}
>
{t('audioinput.muteTest')}
</span>
</>
) : (
<>
<RiMicLine size={18} aria-hidden="true" />
<div
role="img"
aria-label={t('audioinput.level')}
className={css({
flexGrow: 1,
height: '0.375rem',
borderRadius: '0.1875rem',
overflow: 'hidden',
})}
style={{
backgroundColor:
variant === 'dark' ? 'rgba(255 255 255 / 0.25)' : '#e0e0e0',
}}
>
<div
ref={fillRef}
className={css({
width: '100%',
height: '100%',
borderRadius: 'inherit',
transformOrigin: 'left center',
transform: 'scaleX(0)',
transition: 'transform 0.06s linear',
})}
style={{
backgroundColor: variant === 'dark' ? '#CACAFB' : '#6A6AF4',
}}
/>
</div>
</>
)}
</div>
)
}
@@ -5,6 +5,8 @@ import { Select, SelectProps } from '@/primitives/Select'
import type { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
import type { LocalAudioTrack } from 'livekit-client'
import { AudioLevelGauge } from './AudioLevelGauge'
type DeviceItems = Array<{ value: string; label: string }>
@@ -18,6 +20,7 @@ type SelectDeviceProps = {
onSubmit?: (id: string) => void
kind: MediaDeviceKind
context?: 'join' | 'room'
track?: LocalAudioTrack
}
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
@@ -28,6 +31,7 @@ const SelectDevicePermissions = <T extends string | number>({
kind,
onSubmit,
iconComponent,
track,
...props
}: SelectDevicePermissionsProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -74,6 +78,11 @@ const SelectDevicePermissions = <T extends string | number>({
await setActiveMediaDevice(key as string)
onSubmit?.(key as string)
}}
menuFooter={
kind === 'audioinput' ? (
<AudioLevelGauge track={track} variant={props.variant} />
) : undefined
}
{...props}
/>
)
@@ -84,6 +93,7 @@ export const SelectDevice = ({
onSubmit,
kind,
context = 'join',
track,
}: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -116,6 +126,7 @@ export const SelectDevice = ({
id={id}
onSubmit={onSubmit}
kind={kind}
track={track}
iconComponent={deviceIcons.select}
{...contextProps}
/>
+2
View File
@@ -29,6 +29,8 @@
},
"audioinput": {
"choose": "Mikrofon auswählen",
"level": "Mikrofon-Eingangspegel",
"muteTest": "Aktivieren Sie Ihr Mikrofon, um es zu testen",
"permissionsNeeded": "Mikrofon auswählen Berechtigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
+2
View File
@@ -29,6 +29,8 @@
},
"audioinput": {
"choose": "Select microphone",
"level": "Microphone input level",
"muteTest": "Turn on your microphone to test it",
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
+2
View File
@@ -29,6 +29,8 @@
},
"audioinput": {
"choose": "Choisir le micro",
"level": "Niveau d'entrée du micro",
"muteTest": "Activez votre micro pour le tester",
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
+2
View File
@@ -29,6 +29,8 @@
},
"audioinput": {
"choose": "Selecteer microfoon",
"level": "Microfoon-ingangsniveau",
"muteTest": "Zet je microfoon aan om deze te testen",
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
+3
View File
@@ -99,6 +99,7 @@ export type SelectProps<T> = Omit<
errors?: ReactNode
placement?: Placement
variant?: 'light' | 'dark'
menuFooter?: ReactNode
}
export const Select = <T extends string | number>({
@@ -108,6 +109,7 @@ export const Select = <T extends string | number>({
errors,
placement,
variant = 'light',
menuFooter,
...props
}: SelectProps<T>) => {
const IconComponent = iconComponent
@@ -155,6 +157,7 @@ export const Select = <T extends string | number>({
</ListBoxItem>
))}
</ListBox>
{menuFooter}
</Box>
</StyledPopover>
{errors}