fix(call): Wave-1 audit fixes (calls host side)
- C-H1: forceState only on FIRST join; on EC reconnect re-arm the fork handlers (resendForkState — deafen+quality only) instead of clobbering live mic/video/ deafen back to the join-time snapshot. - C-H2: AFK auto-mute reads the fork's io.lotus.call_state VAD of the LOCAL published track instead of getUserMedia on the browser DEFAULT mic (which could measure silence while the user spoke on another device → auto-mute an active speaker). Fails safe (never mutes) when call_state is null OR empty. - C-H3: control observer re-binds after EC re-renders (body subtree:true + 100ms debounce) with an early-return so unchanged state doesn't re-render. - C-M3 setQuality join-gated; C-M4 hangup 4s fallback dispose (idempotent); C-M5 PTT no longer silently un-deafens; C-M6 screenshare-audio mute resets on stop; C-L4 deafen key works in the iframe; C-L6 setState-after-unmount guards. Reviewed (C-H2 [] fail-safe + C-H3 re-render guard applied). tsc/eslint/prettier clean, build OK, 677 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,84 +4,86 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
||||
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.
|
||||
* Monitors microphone activity while in a call. If the mic stays unmuted but
|
||||
* the user is not speaking 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.
|
||||
* [C-H2] Activity is read from the EC fork's `io.lotus.call_state` stream
|
||||
* (getLotusParticipants) — i.e. the VAD state of the user's ACTUAL published
|
||||
* track on their SELECTED input device. The previous implementation opened its
|
||||
* own `getUserMedia({ audio: true })`, which captured the browser DEFAULT mic
|
||||
* (not necessarily the device EC publishes from): it could measure silence
|
||||
* while the user spoke on a different device (auto-muting an active speaker) and
|
||||
* lit a second OS microphone indicator. Sourcing from the fork removes both
|
||||
* problems and needs no extra capture.
|
||||
*
|
||||
* If the fork hasn't reported call-state yet (getLotusParticipants() === null —
|
||||
* e.g. plain EC, or immediately after join), we cannot tell whether the user is
|
||||
* publishing, so we fail SAFE and never auto-mute during that window.
|
||||
*/
|
||||
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
||||
const mx = useMatrixClient();
|
||||
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).
|
||||
// Only monitor while in a call, enabled, AND unmuted — there is nothing to
|
||||
// auto-mute once you are already muted.
|
||||
if (!callEmbed || !enabled || !microphone) return undefined;
|
||||
|
||||
let stream: MediaStream | undefined;
|
||||
let audioCtx: AudioContext | undefined;
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
const localUserId = mx.getSafeUserId();
|
||||
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||
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);
|
||||
// undefined = fork hasn't reported call-state yet (can't tell — fail safe).
|
||||
const isLocalSpeaking = (): boolean | undefined => {
|
||||
const participants = callEmbed.getLotusParticipants();
|
||||
// null = fork not reported; [] = malformed/spurious payload (CallEmbed
|
||||
// stores [] for a non-array). You are ALWAYS present in your own joined
|
||||
// call, so an empty list means "no usable data", NOT "silent" — matching
|
||||
// useCallSpeakers / useRemoteAllMuted. Treating [] as silent would let the
|
||||
// timer mute an active speaker. Fail safe on both.
|
||||
if (participants === null || participants.length === 0) return undefined;
|
||||
return participants.some((p) => p.userId === localUserId && p.audioEnabled && p.speaking);
|
||||
};
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
if (!active) return;
|
||||
analyser.getFloatTimeDomainData(buffer);
|
||||
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
|
||||
const intervalId = setInterval(() => {
|
||||
if (!active) return;
|
||||
const speaking = isLocalSpeaking();
|
||||
|
||||
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);
|
||||
if (speaking === undefined) {
|
||||
// No usable signal — don't risk muting an active speaker.
|
||||
silenceStart = null;
|
||||
} else if (speaking) {
|
||||
// Voice detected on the published track — 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);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (intervalId !== undefined) clearInterval(intervalId);
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
audioCtx?.close().catch(() => undefined);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone]);
|
||||
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone, mx]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user