fix: macOS meeting auto-stop via track-state polling (#612)

* 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 <gaganp000999@gmail.com>
This commit is contained in:
PRAKHAR PANDEY 2026-06-11 02:00:11 +05:30 committed by GitHub
parent 9453e0d550
commit 0f4a693b34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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<ReturnType<typeof setInterval> | null>(null);
const calendarEndMsRef = useRef<number | null>(null);
const nudgeToastIdRef = useRef<string | number | null>(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<ReturnType<typeof setInterval> | null>(null);
const onAutoStopRef = useRef(onAutoStop);
onAutoStopRef.current = onAutoStop;
const dateRef = useRef<string>('');
@ -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;