From 22ab89994bce7ce21e504ec42a4397f16d1f1b55 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 11 Aug 2026 18:39:50 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20a=20silent-micropho?= =?UTF-8?q?ne=20watcher=20on=20join=20and=20room=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + .../src/features/analytics/telemetry.ts | 3 + .../src/features/rooms/components/Join.tsx | 2 + .../rooms/components/SilentMicDetector.tsx | 123 ++++++++++++++++++ .../rooms/components/SilentMicDialog.tsx | 64 +++++++++ .../controls/Device/AudioLevelGauge.tsx | 26 +--- .../controls/Device/ToggleDevice.tsx | 14 ++ .../rooms/livekit/hooks/useIsTrackMuted.ts | 24 ++++ .../rooms/livekit/prefabs/VideoConference.tsx | 2 + .../src/features/rooms/routes/Room.tsx | 2 + src/frontend/src/locales/de/rooms.json | 15 +++ src/frontend/src/locales/en/rooms.json | 15 +++ src/frontend/src/locales/fr/rooms.json | 15 +++ src/frontend/src/locales/nl/rooms.json | 15 +++ src/frontend/src/stores/silentMic.ts | 98 ++++++++++++++ 15 files changed, 395 insertions(+), 24 deletions(-) create mode 100644 src/frontend/src/features/rooms/components/SilentMicDetector.tsx create mode 100644 src/frontend/src/features/rooms/components/SilentMicDialog.tsx create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useIsTrackMuted.ts create mode 100644 src/frontend/src/stores/silentMic.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce6bd595..02839772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/src/features/analytics/telemetry.ts b/src/frontend/src/features/analytics/telemetry.ts index 20b0dc69..5181b8ab 100644 --- a/src/frontend/src/features/analytics/telemetry.ts +++ b/src/frontend/src/features/analytics/telemetry.ts @@ -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 diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 75d03942..1c943367 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -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 ( +
diff --git a/src/frontend/src/features/rooms/components/SilentMicDetector.tsx b/src/frontend/src/features/rooms/components/SilentMicDetector.tsx new file mode 100644 index 00000000..19023d15 --- /dev/null +++ b/src/frontend/src/features/rooms/components/SilentMicDetector.tsx @@ -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 + 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 ( + + ) +} + +/** 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 +} diff --git a/src/frontend/src/features/rooms/components/SilentMicDialog.tsx b/src/frontend/src/features/rooms/components/SilentMicDialog.tsx new file mode 100644 index 00000000..90b185e9 --- /dev/null +++ b/src/frontend/src/features/rooms/components/SilentMicDialog.tsx @@ -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 ( + +
+ {t('title')} +

{t('intro')}

+
    +
  • {t('causes.system')}
  • +
  • {t('causes.hardware')}
  • +
  • {t('causes.wrongDevice')}
  • +
+

{t('hint')}

+
+ + +
+
+
+ ) +} 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 index b3da87a7..76efd208 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioLevelGauge.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioLevelGauge.tsx @@ -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, diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx index 5a57ab82..69aed962 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx @@ -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 = ({ 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 = ({ } /> )} + {silentMicWarning && ( + + )} { + 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 +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 55b145ce..53f42f67 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -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) { + diff --git a/src/frontend/src/features/rooms/routes/Room.tsx b/src/frontend/src/features/rooms/routes/Room.tsx index 2b398a68..8dad5b58 100644 --- a/src/frontend/src/features/rooms/routes/Room.tsx +++ b/src/frontend/src/features/rooms/routes/Room.tsx @@ -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 ( + {children} ) diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 0e908f6a..25380d2c 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -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" diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 0d8fb4d0..34e4758b 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -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" diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index ccacbf06..df55f557 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -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" diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 209da973..f28f727f 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -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" diff --git a/src/frontend/src/stores/silentMic.ts b/src/frontend/src/stores/silentMic.ts new file mode 100644 index 00000000..bd98b8b8 --- /dev/null +++ b/src/frontend/src/stores/silentMic.ts @@ -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') +}