"""Keyboard-driven turn taking: nothing is heard until you say so. Voice activity detection has to guess when you've finished a sentence, and it guesses slowly — Silero waits 0.2s of silence, then the turn strategy waits another 0.6s in case you say more. A key removes both the waiting and the guessing: the turn ends the moment you say it does. It also makes echo structurally impossible. Transcription only runs over audio captured while the key is engaged, so the agent cannot hear its own voice however loud the speakers are. Two input modes: - **hold** (default): a Quartz event tap watches a modifier key system-wide, so it works whatever app has focus, and reports releases as well as presses — real hold-to-talk. Needs Input Monitoring permission. - **toggle**: reads stdin. Works with no permissions, but only while the terminal has focus, and a terminal never sees key releases — so it's press to start, press again to send. """ import asyncio import atexit import sys import termios import tty from loguru import logger from pipecat.frames.frames import ( CancelFrame, EndFrame, Frame, StartFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor class PushToTalk(FrameProcessor): """Turn a key into the microphone's on-air switch. Emits the VAD frames the segmented STT uses to cut audio into utterances, and the user-turn frames the external turn strategies use to open and close a turn — so one key drives both. Args: hold_key: Modifier to hold, from ``global_hotkey.HOLD_KEYS``. None selects the stdin toggle instead. toggle_key: Character that toggles talking in stdin mode. """ def __init__(self, *, hold_key: str | None = "fn", toggle_key: str = " ", **kwargs): super().__init__(**kwargs) self._hold_key = hold_key self._toggle_key = toggle_key self._talking = False self._monitor = None self._fd: int | None = None self._saved_term: list | None = None async def process_frame(self, frame: Frame, direction: FrameDirection): await super().process_frame(frame, direction) if isinstance(frame, StartFrame): await self.push_frame(frame, direction) self._start_input() elif isinstance(frame, (EndFrame, CancelFrame)): self._stop_input() await self.push_frame(frame, direction) else: await self.push_frame(frame, direction) # ------------------------------------------------------------------ input def _start_input(self): holding = bool(self._hold_key) and self._start_hold_mode() # Always arm the toggle as well. Without Input Monitoring, macOS still # hands back a valid-looking event tap and simply never delivers events # to it — so hold mode can fail silently, and this guarantees there is # always some way to talk. self._start_toggle_mode(primary=not holding) def _start_hold_mode(self) -> bool: from global_hotkey import HoldKeyMonitor, permission_granted, request_permission if not permission_granted(): logger.warning("Input Monitoring permission is not granted; requesting it.") request_permission() if not permission_granted(): logger.warning( "Still not granted. Add your terminal under System Settings > " "Privacy & Security > Input Monitoring, then restart it. " "Falling back to the spacebar toggle for now." ) return False monitor = HoldKeyMonitor( key=self._hold_key, on_change=self._on_hold_change, loop=self.get_event_loop(), ) if not monitor.start(): logger.warning("Could not create the keyboard event tap; using the toggle instead.") return False self._monitor = monitor label = "🌐 fn" if self._hold_key == "fn" else self._hold_key logger.info(f"Hold {label} to talk — works in any app, release to send.") if self._hold_key == "fn": logger.info( "If fn also opens the emoji picker, set System Settings > Keyboard > " "'Press 🌐 to' → Do Nothing." ) return True def _start_toggle_mode(self, *, primary: bool = True): if not sys.stdin.isatty(): if primary: logger.warning("stdin is not a terminal; push-to-talk is disabled.") return self._fd = sys.stdin.fileno() self._saved_term = termios.tcgetattr(self._fd) # Restore the terminal even on an unhandled exception, or the shell is # left with echo off and no line editing. atexit.register(self._restore_terminal) tty.setcbreak(self._fd) self.get_event_loop().add_reader(self._fd, self._on_stdin_readable) label = "SPACE" if self._toggle_key == " " else repr(self._toggle_key) if primary: logger.info(f"Push to talk: press {label} to start speaking, {label} again to send.") else: logger.info(f"({label} also works as a toggle, if the hold key goes quiet.)") def _stop_input(self): if self._monitor: self._monitor.stop() self._monitor = None if self._fd is not None: try: self.get_event_loop().remove_reader(self._fd) except Exception: pass self._restore_terminal() def _restore_terminal(self): if self._fd is not None and self._saved_term is not None: termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved_term) self._saved_term = None def _on_hold_change(self, down: bool): self.create_task(self._start_talking() if down else self._stop_talking()) def _on_stdin_readable(self): try: char = sys.stdin.read(1) except (OSError, ValueError): return if not char: return # Ctrl-C doesn't raise KeyboardInterrupt in cbreak mode; deliver it. if char == "\x03": self._restore_terminal() raise KeyboardInterrupt if char == self._toggle_key: self.create_task(self._toggle()) # ------------------------------------------------------------------ turns async def _start_talking(self): if self._talking: return self._talking = True logger.info("🎤 listening") # ExternalUserTurnStartStrategy hardcodes enable_interruptions=False, on # the assumption that whatever drives it externally handles this. That's # us: without this, talking over Claude doesn't stop him. await self.broadcast_interruption() await self.push_frame(VADUserStartedSpeakingFrame()) await self.push_frame(UserStartedSpeakingFrame()) async def _stop_talking(self): if not self._talking: return self._talking = False logger.info("… sent") await self.push_frame(VADUserStoppedSpeakingFrame()) await self.push_frame(UserStoppedSpeakingFrame()) async def _toggle(self): if self._talking: await self._stop_talking() else: await self._start_talking()