import { useEffect } from 'react'; import { useSetAtom } from 'jotai'; import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { toastQueueAtom } from '../state/toast'; const SILENCE_RMS_THRESHOLD = 0.008; const CHECK_INTERVAL_MS = 500; /** * Monitors microphone audio while in a call. If the mic stays unmuted but * silent for longer than the configured timeout, the mic is muted and a toast * is shown. * * The level-monitoring capture (`getUserMedia`) is opened ONLY while the mic is * unmuted — there is nothing to auto-mute once you are already muted, so * holding the capture would keep the OS recording indicator lit even though the * UI shows you as muted (N95). Muting therefore releases our stream; unmuting * re-acquires it. The AudioContext + stream are also torn down on unmount. */ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void { const [enabled] = useSetting(settingsAtom, 'afkAutoMute'); const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes'); const setToast = useSetAtom(toastQueueAtom); const { microphone } = useCallControlState(callEmbed?.control); useEffect(() => { // Only capture while in a call, enabled, AND unmuted (see N95 note above). if (!callEmbed || !enabled || !microphone) return undefined; let stream: MediaStream | undefined; let audioCtx: AudioContext | undefined; let intervalId: ReturnType | undefined; let silenceStart: number | null = null; let active = true; const timeoutMs = timeoutMinutes * 60 * 1000; navigator.mediaDevices .getUserMedia({ audio: true, video: false }) .then((s) => { if (!active) { s.getTracks().forEach((t) => t.stop()); return; } stream = s; audioCtx = new AudioContext(); const source = audioCtx.createMediaStreamSource(stream); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 256; source.connect(analyser); const buffer = new Float32Array(analyser.fftSize); intervalId = setInterval(() => { if (!active) return; analyser.getFloatTimeDomainData(buffer); const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length); if (rms > SILENCE_RMS_THRESHOLD) { // Audio detected — reset the silence timer. silenceStart = null; } else if (silenceStart === null) { // Mic is unmuted (effect only runs while unmuted) but silent — start the timer. silenceStart = Date.now(); } else if (Date.now() - silenceStart >= timeoutMs) { callEmbed.control.setMicrophone(false); setToast({ id: `afk-mute-${Date.now()}`, displayName: 'Lotus Chat', body: 'Your microphone was muted after inactivity.', roomName: 'Voice call', roomId: callEmbed.roomId, }); silenceStart = null; } }, CHECK_INTERVAL_MS); }) .catch(() => undefined); return () => { active = false; if (intervalId !== undefined) clearInterval(intervalId); stream?.getTracks().forEach((t) => t.stop()); audioCtx?.close().catch(() => undefined); }; }, [callEmbed, enabled, timeoutMinutes, setToast, microphone]); }