From 0f4a693b3494e5bd00881556303cf14020bccb8f Mon Sep 17 00:00:00 2001 From: PRAKHAR PANDEY Date: Thu, 11 Jun 2026 02:00:11 +0530 Subject: [PATCH] fix: macOS meeting auto-stop via track-state polling (#612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add track-state polling for macOS meeting auto-stop On macOS (ScreenCaptureKit/Electron 39) the system-audio track does not fire 'ended' or 'mute' events when the meeting ends. Add a 3-second polling interval that checks track.readyState and track.muted directly. Auto-stops after the track is muted for 3 consecutive polls (~9 seconds), or immediately if readyState becomes 'ended'. Windows behavior unchanged (existing 'ended' event listener kept intact). * fix: gate macOS meeting muted-poll auto-stop on scheduled calendar end On macOS the system-audio track reports muted=true both when the meeting ends and during any silent stretch of a still-live meeting, so the unconditional ~9s muted hard-stop could cut a quiet-but-live meeting short with no warning. Only hard-stop on sustained mute once we're past the linked calendar event's scheduled end (a strong "it's really over" signal); otherwise let the existing silence nudge + backstop handle it. Gate the whole poll to macOS — Windows already auto-stops via the track "ended" event — and drop the noisy per-poll debug log. --------- Co-authored-by: Gagancreates --- .../src/hooks/useMeetingTranscription.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 2295b9c0..96f42412 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -40,6 +40,20 @@ const POST_CALENDAR_END_SILENCE_MS = 2 * 60 * 1000; // How often the silence checker runs. const SILENCE_CHECK_INTERVAL_MS = 5 * 1000; +// On macOS (ScreenCaptureKit) the system-audio track never fires "ended"/"mute" +// when the meeting ends, and its readyState stays "live" — only track.muted flips +// to true. But muted is ambiguous: it also goes true whenever no system audio is +// playing (a quiet but live meeting), so muted alone can't safely trigger a stop. +// See the poll in start() for how the muted signal is gated on the scheduled +// calendar end so a quiet stretch never cuts a live meeting short. +const TRACK_POLL_INTERVAL_MS = 3 * 1000; +const MUTE_POLLS_TO_STOP = 3; + +// The ScreenCaptureKit quirk above is macOS-only; on Windows the track's "ended" +// event fires normally (handled by the listener in start()), so the poll below is +// gated to macOS. +const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac'); + // --------------------------------------------------------------------------- // Headphone detection // --------------------------------------------------------------------------- @@ -142,6 +156,10 @@ export function useMeetingTranscription(onAutoStop?: () => void) { const silenceCheckRef = useRef | null>(null); const calendarEndMsRef = useRef(null); const nudgeToastIdRef = useRef(null); + // On macOS (ScreenCaptureKit) the system-audio track doesn't reliably fire + // "ended"/"mute" when the meeting ends, so we poll its readyState/muted + // state instead. + const trackPollingRef = useRef | null>(null); const onAutoStopRef = useRef(onAutoStop); onAutoStopRef.current = onAutoStop; const dateRef = useRef(''); @@ -191,6 +209,10 @@ export function useMeetingTranscription(onAutoStop?: () => void) { toast.dismiss(nudgeToastIdRef.current); nudgeToastIdRef.current = null; } + if (trackPollingRef.current) { + clearInterval(trackPollingRef.current); + trackPollingRef.current = null; + } if (processorRef.current) { processorRef.current.disconnect(); processorRef.current = null; @@ -355,6 +377,45 @@ export function useMeetingTranscription(onAutoStop?: () => void) { }); }); + // On macOS the system-audio track's "ended"/"mute" events don't fire when + // the meeting ends, so poll its state instead. (On Windows the "ended" + // listener above already covers this, so the poll is macOS-only.) + // + // - readyState === 'ended' is unambiguous (the source is gone) → stop now. + // It never actually fires on macOS (readyState stays 'live'); it's just + // a safety net should polling ever observe the track ending. + // - muted is ambiguous on macOS: it flips true both when the meeting ends + // AND when nothing is playing system audio (a quiet but live meeting). + // So we only treat sustained mute as "meeting over" once we're past the + // linked event's scheduled end — a dead audio track after the meeting + // was due to finish is a strong signal. With no calendar event, or + // before the scheduled end, we DON'T hard-stop on mute; the silence + // checker's nudge + backstop handles it, so a quiet stretch can never + // silently cut a live meeting short. + const pollTrack = systemStream.getAudioTracks()[0]; + if (isMac && pollTrack) { + let mutedPolls = 0; + if (trackPollingRef.current) clearInterval(trackPollingRef.current); + trackPollingRef.current = setInterval(() => { + if (pollTrack.readyState === 'ended') { + console.log('[meeting] system-audio track ended (poll) — auto-stopping'); + onAutoStopRef.current?.(); + return; + } + if (pollTrack.muted) { + mutedPolls++; + const endMs = calendarEndMsRef.current; + const pastCalendarEnd = endMs != null && Date.now() > endMs; + if (pastCalendarEnd && mutedPolls >= MUTE_POLLS_TO_STOP) { + console.log('[meeting] system-audio track muted past scheduled end (poll) — auto-stopping'); + onAutoStopRef.current?.(); + } + } else { + mutedPolls = 0; + } + }, TRACK_POLL_INTERVAL_MS); + } + // ----- Audio pipeline ----- const audioCtx = new AudioContext({ sampleRate: 16000 }); audioCtxRef.current = audioCtx;