(frontend) add a silent-microphone watcher on join and room screens

Introduce a watcher that listens to the microphone stream and detects
when it stays silent, which is often a sign of an underlying issue:
missing OS permissions, a faulty device, or a hardware lock (e.g. a
physical mute switch).

Wire the watcher on both the join and room screens, so users get a
signal that something is off before it turns into an actual meeting
problem.
This commit is contained in:
lebaudantoine
2026-08-11 18:39:50 +02:00
committed by aleb_the_flash
parent e1a28f315d
commit 22ab89994b
15 changed files with 395 additions and 24 deletions
+1
View File
@@ -17,6 +17,7 @@ and this project adheres to
- ⚗️(frontend) capture console.error in PostHog
- 📈(frontend) snapshot media devices on the happy path
- 🚸(frontend) guide users when the OS blocks browser media access
- ✨(frontend) add a silent-microphone watcher on join and room screens
### Changed
@@ -136,6 +136,9 @@ export const captureMediaEvent = async (
| 'media-device-success'
| 'device-not-found'
| 'permissions-denied'
| 'silent-mic-detected'
| 'silent-mic-analyser-unavailable'
| 'silent-mic-recovered'
| 'visit-room'
| 'connection-event',
props: Record<string, unknown>
@@ -30,6 +30,7 @@ import {
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
import { SilentMicDetector } from './SilentMicDetector'
const styles = {
page: css({
@@ -460,6 +461,7 @@ export const Join = ({
return (
<Screen footer={false}>
<SilentMicDetector track={audioTrack} context="join" />
<div className={styles.page}>
<div className={styles.previewColumn}>
<div className={styles.previewStack}>
@@ -0,0 +1,123 @@
import { useEffect, useRef } from 'react'
import { useSnapshot } from 'valtio'
import { createAudioAnalyser, LocalAudioTrack } from 'livekit-client'
import { useLocalParticipant } from '@livekit/components-react'
import { reportMicSample, silentMicStore } from '@/stores/silentMic'
import { captureMediaEvent } from '@/features/analytics/telemetry'
import { useIsTrackMuted } from '../livekit/hooks/useIsTrackMuted'
// A live microphone always has a noise floor; only a signal pinned to
// zero counts as silent (no audio data flowing at all).
const SILENT_VOLUME_EPSILON = 0.0001
const TICK_MS = 1_000
type SilentMicContext = 'join' | 'room'
const ActiveDetector = ({
track,
context,
}: {
track: LocalAudioTrack
context: SilentMicContext
}) => {
const isMuted = useIsTrackMuted(track)
// The interval reads through refs so state updates never re-arm the
// timer or the analyser.
const mutedRef = useRef(isMuted)
mutedRef.current = isMuted
const contextRef = useRef(context)
contextRef.current = context
useEffect(() => {
let audioAnalyser: ReturnType<typeof createAudioAnalyser>
try {
audioAnalyser = createAudioAnalyser(track, {
fftSize: 256,
smoothingTimeConstant: 0.7,
})
} catch {
void captureMediaEvent('silent-mic-analyser-unavailable', {
context: contextRef.current,
})
return
}
const { analyser, calculateVolume, cleanup } = audioAnalyser
const tick = () => {
// Zero volume is only evidence of silence when audio data is
// actually flowing. Skip the sample when:
// - the mic is intentionally muted;
// - the tab is backgrounded (suspended AudioContext reads as zero);
// - the AudioContext is not running yet — Chrome keeps it
// 'suspended' until a user gesture on pages loaded without
// activation, and a suspended analyser reports zeros for a
// perfectly healthy microphone.
if (
mutedRef.current ||
document.visibilityState !== 'visible' ||
analyser.context.state !== 'running'
) {
return
}
const result = reportMicSample({
trackId: track.mediaStreamTrack?.id,
silent: calculateVolume() <= SILENT_VOLUME_EPSILON,
deltaMs: TICK_MS,
})
if (result === 'silent-detected') {
void captureMediaEvent('silent-mic-detected', {
context: contextRef.current,
media_stream_track_muted: track.mediaStreamTrack?.muted ?? null,
})
} else if (result === 'recovered') {
void captureMediaEvent('silent-mic-recovered', {
context: contextRef.current,
})
}
}
const interval = window.setInterval(tick, TICK_MS)
return () => {
window.clearInterval(interval)
void cleanup()
}
}, [track])
return null
}
/**
* One-shot silent-mic check (see stores/silentMic.ts). Renders nothing;
* mounts the volume watcher only while the check is still undecided so
* the analyser goes away as soon as the outcome is known.
*/
export const SilentMicDetector = ({
track,
context,
}: {
track?: LocalAudioTrack
context: SilentMicContext
}) => {
const { status } = useSnapshot(silentMicStore)
if ((status !== 'watching' && status !== 'silent') || !track) {
return null
}
return (
<ActiveDetector
key={track.mediaStreamTrack?.id}
track={track}
context={context}
/>
)
}
/** Room-side variant: watches the published local microphone track. */
export const RoomSilentMicDetector = () => {
const { microphoneTrack } = useLocalParticipant()
const track =
microphoneTrack?.track instanceof LocalAudioTrack
? microphoneTrack.track
: undefined
return <SilentMicDetector track={track} context="room" />
}
@@ -0,0 +1,64 @@
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { Button, Dialog, H, P } from '@/primitives'
import {
closeSilentMicDialog,
discardSilentMicDetection,
silentMicStore,
} from '@/stores/silentMic'
/**
* Opened from the "!" badge on the microphone toggle when the silent-mic
* check tripped (see stores/silentMic.ts). Explains the likely causes
* and lets the user opt out of the detection for good.
*/
export const SilentMicDialog = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'silentMic.dialog' })
const { isDialogOpen } = useSnapshot(silentMicStore)
return (
<Dialog
isOpen={isDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t('title')}
onClose={closeSilentMicDialog}
>
<div
className={css({
maxWidth: '500px',
})}
>
<H lvl={1}>{t('title')}</H>
<P>{t('intro')}</P>
<ul className={css({ listStyle: 'disc', paddingLeft: '24px' })}>
<li>{t('causes.system')}</li>
<li>{t('causes.hardware')}</li>
<li>{t('causes.wrongDevice')}</li>
</ul>
<P>{t('hint')}</P>
<div
className={css({
marginTop: '1.5rem',
display: 'flex',
gap: '1rem',
flexWrap: 'wrap',
})}
>
<Button variant="primary" size="sm" onPress={closeSilentMicDialog}>
{t('close')}
</Button>
<Button
variant="tertiary"
size="sm"
onPress={discardSilentMicDetection}
>
{t('discard')}
</Button>
</div>
</div>
</Dialog>
)
}
@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react'
import { LocalAudioTrack, TrackEvent } from 'livekit-client'
import { LocalAudioTrack } from 'livekit-client'
import { useTrackVolume } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Text } from '@/primitives'
import { useIsTrackMuted } from '../../../hooks/useIsTrackMuted'
const StyledContainer = styled('div', {
base: {
@@ -77,28 +77,6 @@ type AudioLevelGaugeProps = {
variant?: Theme
}
const useIsTrackMuted = (track?: LocalAudioTrack) => {
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])
return isMuted
}
const LevelBar = ({
track,
theme,
@@ -17,6 +17,8 @@ import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceEr
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
import { useSnapshot } from 'valtio'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
@@ -95,6 +97,12 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
const { status: silentMicStatus } = useSnapshot(silentMicStore)
const silentMicWarning =
kind === 'audioinput' &&
silentMicStatus === 'silent' &&
!cannotUseDevice &&
!deviceMissing
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
@@ -178,6 +186,12 @@ export const ToggleDevice = <T extends ToggleSource>({
}
/>
)}
{silentMicWarning && (
<PermissionNeededButton
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
onPress={openSilentMicDialog}
/>
)}
<ToggleButton
isSelected={!enabled}
isDisabled={isDisabled}
@@ -0,0 +1,24 @@
import { type LocalAudioTrack, TrackEvent } from 'livekit-client'
import { useEffect, useState } from 'react'
export const useIsTrackMuted = (track?: LocalAudioTrack) => {
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])
return isMuted
}
@@ -26,6 +26,7 @@ import { StageLayout } from '@/features/layout/components/StageLayout'
import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
/**
* @public
@@ -65,6 +66,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<RoomMetadataSynchronizer />
<ConnectionObserver />
<SyncDevicePreferences />
<RoomSilentMicDetector />
<MediaStateObserver />
<ChatProvider />
<VideoResolutionSubscription />
@@ -7,6 +7,7 @@ import { useUser } from '@/features/auth/api/useUser'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Permissions } from '../components/Permissions'
import { SilentMicDialog } from '../components/SilentMicDialog'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import {
isRoomValid,
@@ -21,6 +22,7 @@ const BaseRoom = ({ children }: { children: ReactNode }) => {
return (
<UserAware>
<Permissions />
<SilentMicDialog />
{children}
</UserAware>
)
+15
View File
@@ -186,6 +186,21 @@
},
"openSettings": "Systemeinstellungen öffnen"
},
"silentMic": {
"tooltip": "Ihr Mikrofon ist aktiviert, aber es wird kein Ton erkannt. Klicken Sie für weitere Informationen.",
"dialog": {
"title": "Kein Ton erkannt",
"intro": "Ihr Mikrofon ist aktiviert, aber seit einiger Zeit wird kein Ton erkannt. Überprüfen Sie Folgendes:",
"causes": {
"system": "Der Zugriff auf das Mikrofon wird möglicherweise durch die Datenschutzeinstellungen Ihres Systems blockiert.",
"hardware": "Möglicherweise ist ein Stummschalter aktiviert oder das Mikrofon ist nicht angeschlossen oder defekt.",
"wrongDevice": "Möglicherweise ist das falsche Mikrofon ausgewählt. Wählen Sie im Mikrofonmenü ein anderes aus."
},
"hint": "Sprechen Sie erneut, nachdem Sie diese Punkte überprüft haben. Die Warnung verschwindet, sobald ein Ton erkannt wird.",
"close": "Verstanden",
"discard": "Erkennung eines stummen Mikrofons deaktivieren"
}
},
"permissionsButton": {
"tooltip": "Mehr Infos",
"ariaLabel": "Problem mit Berechtigungen. Mehr Infos anzeigen"
+15
View File
@@ -186,6 +186,21 @@
},
"openSettings": "Open system settings"
},
"silentMic": {
"tooltip": "Your microphone is on, but no sound is being detected. Click to learn more.",
"dialog": {
"title": "No sound detected",
"intro": "Your microphone is on, but we haven't detected any sound for a while. Check the following:",
"causes": {
"system": "Your system's privacy settings may be blocking microphone access.",
"hardware": "A mute switch may be turned on, or your microphone may be disconnected or faulty.",
"wrongDevice": "The wrong microphone may be selected. Try choosing another one from the microphone menu."
},
"hint": "Speak again after checking these settings. This warning will disappear as soon as sound is detected.",
"close": "Got it",
"discard": "Turn off silent microphone detection"
}
},
"permissionsButton": {
"tooltip": "More info",
"ariaLabel": "Permissions issue. Show more info"
+15
View File
@@ -186,6 +186,21 @@
},
"openSettings": "Ouvrir les réglages système"
},
"silentMic": {
"tooltip": "Votre micro est activé, mais aucun son n'est détecté. Cliquez pour en savoir plus.",
"dialog": {
"title": "Aucun son détecté",
"intro": "Votre micro est activé, mais aucun son n'est détecté depuis un moment. Vérifiez les points suivants :",
"causes": {
"system": "L'accès au micro est peut-être bloqué dans les réglages de confidentialité de votre système.",
"hardware": "Un bouton ou interrupteur coupe peut-être le micro, ou celui-ci est débranché ou défectueux.",
"wrongDevice": "Un autre micro est peut-être sélectionné. Essayez d'en choisir un autre dans le menu du micro."
},
"hint": "Parlez à nouveau après avoir vérifié ces points. L'avertissement disparaîtra dès qu'un son sera détecté.",
"close": "Compris",
"discard": "Désactiver cette détection"
}
},
"permissionsButton": {
"tooltip": "Plus d'infos",
"ariaLabel": "Problème de permissions. Afficher plus d'infos"
+15
View File
@@ -186,6 +186,21 @@
},
"openSettings": "Systeeminstellingen openen"
},
"silentMic": {
"tooltip": "Je microfoon staat aan, maar er wordt geen geluid gedetecteerd. Klik voor meer informatie.",
"dialog": {
"title": "Geen geluid gedetecteerd",
"intro": "Je microfoon staat aan, maar er is al een tijdje geen geluid gedetecteerd. Controleer het volgende:",
"causes": {
"system": "De privacyinstellingen van je systeem blokkeren mogelijk de toegang tot je microfoon.",
"hardware": "Mogelijk staat er een mute-schakelaar aan, of is je microfoon niet aangesloten of defect.",
"wrongDevice": "Mogelijk is de verkeerde microfoon geselecteerd. Kies een andere microfoon in het microfoonmenu."
},
"hint": "Spreek opnieuw nadat je dit hebt gecontroleerd. De waarschuwing verdwijnt zodra er geluid wordt gedetecteerd.",
"close": "Begrepen",
"discard": "Detectie van een stille microfoon uitschakelen"
}
},
"permissionsButton": {
"tooltip": "Meer info",
"ariaLabel": "Probleem met machtigingen. Meer info weergeven"
+98
View File
@@ -0,0 +1,98 @@
import { proxy } from 'valtio'
import { captureEvent } from '@/features/analytics/telemetry'
const DISCARD_STORAGE_KEY = 'silent-mic-detection-discarded'
/**
* One-shot UX check: a live microphone always produces at least a noise
* floor, so a mic that stays pinned to zero for a whole minute is not a
* quiet room — it is an OS-level permission block, a hardware mute
* switch, or a dead device. The check runs on the join screen and in
* the room until it settles:
*
* watching → passed first non-zero sample (check is over, no UI)
* watching → silent 45s of accumulated zero signal (warning badge)
* silent → passed sound finally arrives (warning auto-clears)
* any → discarded user opts out (persisted on this browser)
*/
export type SilentMicStatus = 'watching' | 'silent' | 'passed' | 'discarded'
const isDiscardPersisted = (): boolean => {
try {
return localStorage.getItem(DISCARD_STORAGE_KEY) === '1'
} catch {
return false
}
}
export const silentMicStore = proxy({
status: (isDiscardPersisted() ? 'discarded' : 'watching') as SilentMicStatus,
isDialogOpen: false,
})
const SILENT_DURATION_MS = 10_000
// Accumulation is module state, not store state: it changes every tick
// and must survive the join → room transition without re-rendering
// anything.
let silentMs = 0
let watchedTrackId: string | undefined
export type SilentMicSampleResult = 'silent-detected' | 'recovered' | null
/**
* Feed one detection sample. Returns the transition it caused, so the
* caller can attach telemetry with its own context.
*/
export const reportMicSample = ({
trackId,
silent,
deltaMs,
}: {
trackId?: string
silent: boolean
deltaMs: number
}): SilentMicSampleResult => {
const { status } = silentMicStore
if (status === 'discarded' || status === 'passed') return null
if (trackId !== watchedTrackId) {
// New capture (device switch, re-acquire): fresh window.
watchedTrackId = trackId
silentMs = 0
}
if (!silent) {
silentMs = 0
silentMicStore.status = 'passed'
silentMicStore.isDialogOpen = false
return status === 'silent' ? 'recovered' : null
}
if (status !== 'watching') return null
silentMs += deltaMs
if (silentMs >= SILENT_DURATION_MS) {
silentMicStore.status = 'silent'
return 'silent-detected'
}
return null
}
export const openSilentMicDialog = () => {
silentMicStore.isDialogOpen = true
}
export const closeSilentMicDialog = () => {
silentMicStore.isDialogOpen = false
}
export const discardSilentMicDetection = () => {
silentMicStore.status = 'discarded'
silentMicStore.isDialogOpen = false
try {
localStorage.setItem(DISCARD_STORAGE_KEY, '1')
} catch {
/* private mode: the opt-out just won't persist */
}
captureEvent('silent-mic-discarded')
}