diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 6cc127bd..3b4506dd 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -484,7 +484,7 @@ function FixedSidebarToggle({ )} style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }} > - {meetingSummarizing ? ( + {meetingSummarizing || meetingState === 'connecting' ? ( ) : meetingState === 'recording' ? ( @@ -494,7 +494,7 @@ function FixedSidebarToggle({ - {meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'} + {meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'connecting' ? 'Starting transcription...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'} )} diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 35a0a703..f45b83d9 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -187,52 +187,83 @@ export function useMeetingTranscription(onAutoStop?: () => void) { if (state !== 'idle') return null; setState('connecting'); - // Detect headphones vs speakers - const usingHeadphones = await detectHeadphones(); - console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`); - - // Rowboat WebSocket + bearer token when signed in; else local Deepgram API key - let ws: WebSocket; - try { - const account = await refreshRowboatAccount(); - if ( - account?.signedIn && - account.accessToken && - account.config?.websocketApiUrl - ) { - const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS); - console.log('[meeting] Using Rowboat WebSocket'); - ws = new WebSocket(listenUrl, ['bearer', account.accessToken]); - } else { - const config = await window.ipc.invoke('voice:getConfig', null); - if (!config?.deepgram) { - console.error('[meeting] No Deepgram config available'); - setState('idle'); - return null; + // Run independent setup steps in parallel for faster startup + const [headphoneResult, wsResult, micResult, systemResult] = await Promise.allSettled([ + // 1. Detect headphones vs speakers + detectHeadphones(), + // 2. Set up Deepgram WebSocket (account refresh + connect + wait for open) + (async () => { + const account = await refreshRowboatAccount(); + let ws: WebSocket; + if ( + account?.signedIn && + account.accessToken && + account.config?.websocketApiUrl + ) { + const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS); + console.log('[meeting] Using Rowboat WebSocket'); + ws = new WebSocket(listenUrl, ['bearer', account.accessToken]); + } else { + const config = await window.ipc.invoke('voice:getConfig', null); + if (!config?.deepgram) { + throw new Error('No Deepgram config available'); + } + console.log('[meeting] Using Deepgram API key'); + ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]); } - console.log('[meeting] Using Deepgram API key'); - ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]); - } - } catch (err) { - console.error('[meeting] Failed to connect Deepgram:', err); - setState('idle'); - return null; - } - wsRef.current = ws; + const ok = await new Promise((resolve) => { + ws.onopen = () => resolve(true); + ws.onerror = () => resolve(false); + setTimeout(() => resolve(false), 5000); + }); + if (!ok) throw new Error('WebSocket failed to connect'); + console.log('[meeting] WebSocket connected'); + return ws; + })(), + // 3. Get mic stream + navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }), + // 4. Get system audio via getDisplayMedia (loopback) + (async () => { + const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }); + stream.getVideoTracks().forEach(t => t.stop()); + if (stream.getAudioTracks().length === 0) { + stream.getTracks().forEach(t => t.stop()); + throw new Error('No audio track from getDisplayMedia'); + } + console.log('[meeting] System audio captured'); + return stream; + })(), + ]); - // Wait for WS open - const wsOk = await new Promise((resolve) => { - ws.onopen = () => resolve(true); - ws.onerror = () => resolve(false); - setTimeout(() => resolve(false), 5000); - }); - if (!wsOk) { - console.error('[meeting] WebSocket failed to connect'); + // Check for failures — clean up any successful resources if something failed + const failed = wsResult.status === 'rejected' + || micResult.status === 'rejected' + || systemResult.status === 'rejected'; + + if (failed) { + if (wsResult.status === 'rejected') console.error('[meeting] WebSocket setup failed:', wsResult.reason); + if (micResult.status === 'rejected') console.error('[meeting] Microphone access denied:', micResult.reason); + if (systemResult.status === 'rejected') console.error('[meeting] System audio access denied:', systemResult.reason); + // Clean up any resources that did succeed + if (wsResult.status === 'fulfilled') { wsResult.value.close(); } + if (micResult.status === 'fulfilled') { micResult.value.getTracks().forEach(t => t.stop()); } + if (systemResult.status === 'fulfilled') { systemResult.value.getTracks().forEach(t => t.stop()); } cleanup(); setState('idle'); return null; } - console.log('[meeting] WebSocket connected'); + + const usingHeadphones = headphoneResult.status === 'fulfilled' ? headphoneResult.value : false; + console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`); + + const ws = wsResult.value; + wsRef.current = ws; // Set up WS message handler transcriptRef.current = []; @@ -283,43 +314,10 @@ export function useMeetingTranscription(onAutoStop?: () => void) { wsRef.current = null; }; - // Get mic stream - let micStream: MediaStream; - try { - micStream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, - }); - } catch (err) { - console.error('[meeting] Microphone access denied:', err); - cleanup(); - setState('idle'); - return null; - } + const micStream = micResult.value; micStreamRef.current = micStream; - // Get system audio via getDisplayMedia (loopback) - let systemStream: MediaStream; - try { - systemStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }); - systemStream.getVideoTracks().forEach(t => t.stop()); - } catch (err) { - console.error('[meeting] System audio access denied:', err); - cleanup(); - setState('idle'); - return null; - } - if (systemStream.getAudioTracks().length === 0) { - console.error('[meeting] No audio track from getDisplayMedia'); - systemStream.getTracks().forEach(t => t.stop()); - cleanup(); - setState('idle'); - return null; - } - console.log('[meeting] System audio captured'); + const systemStream = systemResult.value; systemStreamRef.current = systemStream; // ----- Audio pipeline -----