diff --git a/apps/x/VIDEO_MODE.md b/apps/x/VIDEO_MODE.md index 63b9621e..653521cb 100644 --- a/apps/x/VIDEO_MODE.md +++ b/apps/x/VIDEO_MODE.md @@ -44,9 +44,19 @@ starts anyway as a voice call, with a toast linking to System Settings. Practice/coaching is always an explicit choice — expanding to full screen never turns the coach on. -In-call controls (identical bar on both surfaces): camera toggle (silhouette -avatar while off, no webcam frames captured), screen share toggle, mascot ⇄ -"R" letter avatar, end call. While the assistant is thinking or speaking, a +In-call controls (identical bar on both surfaces): mic mute, camera toggle +(silhouette avatar while off, no webcam frames captured), screen share +toggle, mascot ⇄ "R" letter avatar, end call. **Mute is a full input +pause**, not just audio — mic audio stops reaching Deepgram +(`useVoiceMode.setPaused`, OR'd with the automatic thinking/speaking pause) +AND camera/screen frame capture stops (`useVideoMode.setCapturePaused`; +`collectFrames()` returns nothing while muted, so typed messages carry no +frames either), letting the user talk to someone in the room without the +assistant listening in. Devices stay acquired for instant unmute (camera +light and macOS share indicator stay on — the pill's share badge switches to +"Sharing paused"), the status chip shows "Muted" instead of "Listening", +and assistant output is unaffected (in-flight speech keeps playing; Stop +handles that). Mute resets to off at call start/end. While the assistant is thinking or speaking, a red **Stop** button appears on the mascot tile — it silences TTS instantly, skips queued voice segments, and aborts the run if it's still generating (stopping a run from anywhere, including the composer, also silences TTS). Captions of the in-progress utterance and the diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 520ed370..a558f4ae 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -395,6 +395,7 @@ let lastVideoPopoutState: { ttsState: 'idle' | 'synthesizing' | 'speaking'; status: 'listening' | 'thinking' | 'speaking' | null; cameraOn: boolean; + micMuted: boolean; screenSharing: boolean; interimText: string | null; } | null = null; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 17518155..f0e4dfcf 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1061,6 +1061,11 @@ function App() { const inCallRef = useRef(false) // User explicitly shrank the full-screen call to the floating pill. const [callMinimized, setCallMinimized] = useState(false) + // In-call mute: a full input pause, not just audio — mic audio stops + // reaching Deepgram AND camera/screen frame capture stops, so nothing said + // or shown while muted ever reaches the assistant. Output is untouched + // (in-flight speech keeps playing; the Stop control handles that). + const [micMuted, setMicMuted] = useState(false) // Practice preset: adds the coaching persona to the system prompt. const [practiceMode, setPracticeMode] = useState(false) const practiceModeRef = useRef(false) @@ -1200,6 +1205,7 @@ function App() { setPracticeMode(preset === 'practice') practiceModeRef.current = preset === 'practice' + setMicMuted(false) // Pill-first presets start minimized; face-to-face presets start expanded. setCallMinimized(preset === 'voice' || preset === 'share') inCallRef.current = true @@ -1217,17 +1223,26 @@ function App() { video.stop() setPracticeMode(false) practiceModeRef.current = false + setMicMuted(false) setCallMinimized(false) inCallRef.current = false setInCall(false) }, [video]) // During a call, mute the mic while the assistant is thinking or speaking - // so its own TTS (or a half-turn) never gets transcribed back at it. + // so its own TTS (or a half-turn) never gets transcribed back at it — and + // whenever the user muted themselves. useEffect(() => { if (!inCall) return - voiceRef.current.setPaused(activeIsProcessing || tts.state !== 'idle') - }, [inCall, activeIsProcessing, tts.state]) + voiceRef.current.setPaused(micMuted || activeIsProcessing || tts.state !== 'idle') + }, [inCall, micMuted, activeIsProcessing, tts.state]) + + // The user-mute half that lives in the video pipeline: stop sampling + // camera/screen frames while muted (see useVideoMode.setCapturePaused). + const setCapturePaused = video.setCapturePaused + useEffect(() => { + setCapturePaused(micMuted) + }, [micMuted, setCapturePaused]) // Screen sharing: frames of the shared screen ride along with each message // next to the webcam frames. The surface change (full screen → pill) falls @@ -1248,6 +1263,14 @@ function App() { void video.setCameraEnabled(!video.cameraOn) }, [video]) + // Zoom-style mute button, except it pauses ALL input (mic + frames) so the + // user can talk to someone in the room without the assistant listening in. + // Devices stay acquired (camera light and share indicator stay on) so + // unmuting is instant. + const handleToggleMic = useCallback(() => { + setMicMuted((m) => !m) + }, []) + // Minimizing the full-screen call drops you back to working — and the pill // exists to work *together*, so sharing starts automatically (the symmetric // twin of expand, which stops it). If capture fails (permission), the call @@ -1327,11 +1350,12 @@ function App() { ttsState: tts.state, status: videoCallStatus, cameraOn: video.cameraOn, + micMuted, screenSharing: video.screenState === 'live', interimText: voice.interimText || null, }) .catch(() => {}) - }, [inCall, tts.state, videoCallStatus, video.cameraOn, video.screenState, voice.interimText]) + }, [inCall, tts.state, videoCallStatus, video.cameraOn, micMuted, video.screenState, voice.interimText]) // Execute popout control-bar actions (the popout window has no access to // the call's mic/camera/capture — they live here). 'expand' goes full @@ -1339,7 +1363,8 @@ function App() { // process already refocused the app window. useEffect(() => { return window.ipc.on('video:popout-action', ({ action }) => { - if (action === 'toggle-camera') handleToggleCamera() + if (action === 'toggle-mic') handleToggleMic() + else if (action === 'toggle-camera') handleToggleCamera() else if (action === 'toggle-share') void handleToggleScreenShare() else if (action === 'stop-speaking') handleInterruptAssistant() else if (action === 'end-call') endCall() @@ -1348,7 +1373,7 @@ function App() { setCallMinimized(false) } }) - }, [handleToggleCamera, handleToggleScreenShare, handleInterruptAssistant, endCall, video]) + }, [handleToggleMic, handleToggleCamera, handleToggleScreenShare, handleInterruptAssistant, endCall, video]) // Enter to submit voice input, Escape to cancel useEffect(() => { @@ -6835,6 +6860,8 @@ function App() { onToggleScreenShare={handleToggleScreenShare} cameraOn={video.cameraOn} onToggleCamera={handleToggleCamera} + micMuted={micMuted} + onToggleMic={handleToggleMic} practiceMode={practiceMode} onMinimize={() => void handleMinimizeCall()} onInterrupt={handleInterruptAssistant} diff --git a/apps/x/apps/renderer/src/components/video-call-view.tsx b/apps/x/apps/renderer/src/components/video-call-view.tsx index 38031b68..2ef64a49 100644 --- a/apps/x/apps/renderer/src/components/video-call-view.tsx +++ b/apps/x/apps/renderer/src/components/video-call-view.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { Minimize2, MonitorUp, PhoneOff, Presentation, Square, User, Video, VideoOff } from 'lucide-react' +import { Mic, MicOff, Minimize2, MonitorUp, PhoneOff, Presentation, Square, User, Video, VideoOff } from 'lucide-react' import { MascotFaceIcon, TalkingHead } from '@/components/talking-head' import type { TTSState } from '@/hooks/useVoiceTTS' @@ -12,6 +12,9 @@ interface VideoCallViewProps { streamRef: React.MutableRefObject cameraOn: boolean onToggleCamera: () => void + /** User mute = full input pause: no mic audio AND no frame capture. */ + micMuted: boolean + onToggleMic: () => void /** Starting a share collapses this view into the floating popout (the * surface is derived from devices — see App.tsx). */ onToggleScreenShare: () => void @@ -51,6 +54,8 @@ export function VideoCallView({ streamRef, cameraOn, onToggleCamera, + micMuted, + onToggleMic, onToggleScreenShare, practiceMode, onMinimize, @@ -128,6 +133,12 @@ export function VideoCallView({ You + {micMuted && ( + + + Muted — nothing is heard or captured + + )} {/* Assistant */} @@ -185,9 +196,34 @@ export function VideoCallView({ {/* Control bar */}
- - {STATUS_DISPLAY[status].label} + {/* Muted overrides "Listening" — the green pulse would be a lie. + Thinking/speaking still show: output continues while muted. */} + {micMuted && status === 'listening' ? ( + <> + + Muted + + ) : ( + <> + + {STATUS_DISPLAY[status].label} + + )} +
@@ -127,8 +136,18 @@ export function VideoPopout() { {statusDisplay && ( - - {statusDisplay.label} + {/* Muted overrides "Listening" — the green pulse would be a lie. */} + {state.micMuted && state.status === 'listening' ? ( + <> + + Muted + + ) : ( + <> + + {statusDisplay.label} + + )} )} {(state.status === 'speaking' || state.status === 'thinking') && ( @@ -157,6 +176,19 @@ export function VideoPopout() { {/* Control bar — actions execute in the main app window */}
+