Download python-services/voice_orchestrator.py from 010Abid/the-brain: direct link, hf CLI and curl.
- Browser
- Download file 33 kB
-
https://huggingface.co/spaces/010Abid/the-brain/resolve/main/python-services/voice_orchestrator.py
- Command line
-
hf download hf://spaces/010Abid/the-brain/python-services/voice_orchestrator.py
-
curl -L -o voice_orchestrator.py https://huggingface.co/spaces/010Abid/the-brain/resolve/main/python-services/voice_orchestrator.py
33 kB
| # voice_orchestrator.py | |
| # Real-time state machine coordinating telephony socket framing, STT, LLM streaming, and TTS playout | |
| # | |
| # v5 β Issue 3 fix: instant audio stop on barge-in (was waiting 1.1-2.0s) | |
| # | |
| # Previous (v4) behaviour: when a candidate interrupt was detected, | |
| # the orchestrator transitioned to INTERRUPT_PENDING but did NOT stop | |
| # audio playback β 'clear_audio' was only sent once the full 1.5s STT | |
| # confirmation window resolved as 'legitimate'. Combined with VAD | |
| # debounce + silence window + STT processing, this meant the agent | |
| # kept talking for 1.1-2.0 SECONDS after the caller started speaking | |
| # over them β defeating the entire purpose of barge-in. | |
| # | |
| # Fix: 'clear_audio' is now sent IMMEDIATELY in _enter_interrupt_pending(), | |
| # the instant a candidate interrupt is detected (within the existing | |
| # 0.2s VAD debounce). Confirmed via frontend scan (useVoiceWebSocket.ts) | |
| # that 'clear_audio' already does exactly what's needed β stops the | |
| # active Audio element and empties the playback queue β so NO frontend | |
| # changes were required. The STT confirmation window (Phase 2) still | |
| # runs underneath to decide whether to fully cancel the turn (resolved | |
| # legitimate) or resume playback (resolved accidental) β only the | |
| # AUDIO STOP MOMENT moved earlier, not the decision logic itself. | |
| # | |
| # v4 β Issue B correction handler + Issue C smart interrupt system | |
| # | |
| # Issue B (same utterance answered twice): | |
| # STT now emits is_correction=True / corrects_pending=True instead of | |
| # a second is_final when Groq provides a same-WC spelling improvement. | |
| # The-brain handles this in run_stt_loop: if state==THINKING and a | |
| # correction arrives, the last user message in session history is | |
| # silently updated with the better text. No new turn is triggered. | |
| # This eliminates all double-reply occurrences from the last session. | |
| # | |
| # Issue C (smart interrupt system): | |
| # Previous: any VAD blip passing 0.2s debounce β hard cancel β silence. | |
| # No recovery from accidental interrupts. No graceful handover. | |
| # | |
| # New: three-phase interrupt system. | |
| # | |
| # Phase 1 β Detection (unchanged): | |
| # VAD speech_start Γ 2 within 0.2s β candidate interrupt. | |
| # State transitions to INTERRUPT_PENDING (new sub-state of SPEAKING). | |
| # Current TTS sentence continues to completion β agent never cut off | |
| # mid-word. New TTS sentences are paused but queued in | |
| # _interrupt_pending_tts so they can be resumed if accidental. | |
| # | |
| # Phase 2 β STT Confirmation Gate (1.5s window): | |
| # Wait for is_final from STT. Classify the confirmed speech: | |
| # | |
| # LEGITIMATE (β₯2 words, or 1 meaningful word in INTERRUPT_MEANINGFUL): | |
| # β Graceful stop after current sentence | |
| # β Emit bridge phrase "Ψ¬ΫΨ Ψ¨ΨͺΨ§Ψ¦ΫΪΊ" / "Ψ¬Ϋ ΩΨ±Ω Ψ§Ψ¦ΫΪΊ" to fill silence | |
| # β Start new LLM turn with the caller's text | |
| # | |
| # SHORT_MEANINGFUL (1 word in INTERRUPT_SHORT_MEANINGFUL like ΫΨ§ΪΊ/ΩΫΫΪΊ/Ψ¬Ϋ/ΩΉΪΎΫΪ©): | |
| # β Same as LEGITIMATE but uses a shorter bridge "Ψ¬Ϋ" | |
| # β Caller signalled agreement/disagreement, handle as normal turn | |
| # | |
| # ACCIDENTAL (filler/noise word, or timeout with no is_final): | |
| # β Resume dispatching the paused TTS queue from where we stopped | |
| # β Agent continues speaking as if nothing happened | |
| # β Log "Interrupt resolved as accidental β resuming TTS" | |
| # | |
| # Phase 3 β Graceful handover: | |
| # Bridge phrase is synthesised and emitted before the LLM turn fires. | |
| # This fills the ~1-2s gap while Groq generates the response. | |
| # Feels natural β like a human agent saying "yes, go ahead" before | |
| # answering. | |
| import json | |
| import logging | |
| import asyncio | |
| import threading | |
| import time | |
| from enum import Enum | |
| from typing import Optional, List, Tuple | |
| from conversation_manager import ConversationSession | |
| from stt_client import SttClient | |
| import voice_pipeline | |
| import neon_client | |
| import summary | |
| logger = logging.getLogger("voice_orchestrator") | |
| # ββ Echo guard (time-window only, content-overlap removed in v3) ββββββββββββββ | |
| ECHO_GUARD_SECONDS = 1.2 | |
| # ββ Smart interrupt constants βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # How long to wait for STT confirmation after a candidate interrupt | |
| INTERRUPT_CONFIRM_TIMEOUT = 1.5 # seconds | |
| # Single meaningful words that constitute a SHORT_MEANINGFUL interrupt | |
| # (caller said "yes"/"no"/"okay" etc. β still a real turn, just short) | |
| INTERRUPT_SHORT_MEANINGFUL = { | |
| "ΫΨ§ΪΊ", "ΩΫΫΪΊ", "Ψ¬Ϋ", "ΩΉΪΎΫΪ©", "ΫΨ§ΪΊ Ψ¬Ϋ", "ΩΫΫΪΊ Ψ¬Ϋ", | |
| "Ψ±Ϊ©Ω", "Ψ¨Ψ³", "Ψ§ΪΪΎΨ§", "ΩΉΪΎΫΪ© ΫΫ", "Ψ³Ω Ψ¬ΪΎ Ϊ―ΫΨ§", "Ψ³Ω Ψ¬ΪΎ Ϊ―Ψ¦Ϋ", | |
| "han", "nahi", "ji", "theek", "ruko", "bas", "acha", | |
| } | |
| # Hard filler/noise words β if interrupt text is ONLY these, it's accidental | |
| INTERRUPT_NOISE_WORDS = { | |
| "Ω ΩΨ³ΫΩΫ", "Ω ΫΩΨ²Ϊ©", "Ψ³Ψ§Ψ²", "ΩΨ§Ϋ", "ΫΩ ", "Ω Ψ±ΨΨ¨Ψ§", | |
| } | |
| # Bridge phrases emitted after a legitimate interrupt to fill the LLM gap | |
| BRIDGE_PHRASES_FULL = [ | |
| "Ψ¬ΫΨ Ψ¨ΨͺΨ§Ψ¦ΫΪΊΫ", | |
| "Ψ¬Ϋ ΩΨ±Ω Ψ§Ψ¦ΫΪΊΫ", | |
| "ΫΨ§ΪΊΨ Ψ¨ΨͺΨ§Ψ¦ΫΪΊΫ", | |
| ] | |
| BRIDGE_PHRASES_SHORT = [ | |
| "Ψ¬ΫΫ", | |
| "ΫΨ§ΪΊΫ", | |
| ] | |
| # ββ DISABLED (Issue 2 v3) β content-overlap echo guard βββββββββββββββββββββββ | |
| # Preserved as comments. See v3 header for rationale. | |
| class VoiceState(Enum): | |
| LISTENING = "listening" | |
| PROCESSING = "processing" | |
| THINKING = "thinking" | |
| SPEAKING = "speaking" | |
| INTERRUPT_PENDING = "interrupt_pending" # Issue C: new state | |
| def _classify_interrupt_text(text: str) -> str: | |
| """ | |
| Issue C: Classify STT text during interrupt confirmation window. | |
| Returns: 'legitimate', 'short_meaningful', or 'accidental' | |
| """ | |
| if not text or not text.strip(): | |
| return "accidental" | |
| words = text.strip().split() | |
| word_count = len(words) | |
| # Pure noise/artifact single word | |
| if word_count == 1 and words[0] in INTERRUPT_NOISE_WORDS: | |
| return "accidental" | |
| # 1 meaningful word | |
| if word_count == 1: | |
| if words[0] in INTERRUPT_SHORT_MEANINGFUL: | |
| return "short_meaningful" | |
| # Single content word (not in noise, not in short_meaningful) | |
| # Could be a genuine short command β treat as legitimate | |
| return "legitimate" | |
| # 2+ words β always legitimate | |
| return "legitimate" | |
| def _pick_bridge_phrase(interrupt_class: str, turn_count: int) -> str: | |
| """Pick a natural bridge phrase based on interrupt type.""" | |
| phrases = BRIDGE_PHRASES_SHORT if interrupt_class == "short_meaningful" else BRIDGE_PHRASES_FULL | |
| return phrases[turn_count % len(phrases)] | |
| class VoiceOrchestratorSession: | |
| def __init__(self, websocket, session: ConversationSession, stt_client: SttClient): | |
| self.websocket = websocket | |
| self.session = session | |
| self.stt_client = stt_client | |
| self.state = VoiceState.LISTENING | |
| self.cancel_event = threading.Event() | |
| self.active_pipeline_task = None | |
| self.stt_loop_task = None | |
| self.lock = asyncio.Lock() | |
| self.current_summary = "" | |
| self.is_configured = True | |
| self._pending_turns = asyncio.Queue() | |
| self.keep_alive_task = None | |
| self.last_speech_start_time = 0.0 | |
| self.last_listening_start_time = 0.0 | |
| # Issue 6 (v3): finals queued during THINKING/SPEAKING | |
| self._pending_finals_during_turn: asyncio.Queue = asyncio.Queue() | |
| # Issue C: TTS sentences queued but not yet dispatched during | |
| # INTERRUPT_PENDING β resumed if interrupt is accidental | |
| self._interrupt_pending_tts: List[Tuple[str, bytes, str]] = [] | |
| # asyncio.Event set when interrupt resolution is known | |
| self._interrupt_resolved_event: Optional[asyncio.Event] = None | |
| # Resolution: 'legitimate', 'short_meaningful', 'accidental', or None | |
| self._interrupt_resolution: Optional[str] = None | |
| # Text from the caller that caused the interrupt | |
| self._interrupt_caller_text: Optional[str] = None | |
| async def set_state(self, new_state: VoiceState): | |
| async with self.lock: | |
| self.state = new_state | |
| logger.info(f"[VoiceSession {self.session.session_id}] State changed to: {new_state.value}") | |
| if new_state == VoiceState.LISTENING: | |
| self.last_listening_start_time = time.time() | |
| await self.send_json({"type": "state", "state": new_state.value}) | |
| async def send_json(self, data: dict): | |
| try: | |
| await self.websocket.send_json(data) | |
| except Exception as e: | |
| logger.warning(f"Error sending json to client: {e}") | |
| async def send_binary(self, data: bytes): | |
| try: | |
| await self.websocket.send_bytes(data) | |
| except Exception as e: | |
| logger.warning(f"Error sending binary to client: {e}") | |
| async def start(self): | |
| self.stt_client.start() | |
| self.stt_loop_task = asyncio.create_task(self.run_stt_loop()) | |
| self.keep_alive_task = asyncio.create_task(self.run_keep_alive_loop()) | |
| await self.send_json({"type": "state", "state": self.state.value}) | |
| async def run_keep_alive_loop(self): | |
| try: | |
| while True: | |
| await asyncio.sleep(30.0) | |
| await self.send_json({"type": "ping"}) | |
| except asyncio.CancelledError: | |
| pass | |
| async def run_ingress_loop(self): | |
| try: | |
| while True: | |
| message = await self.websocket.receive() | |
| if "bytes" in message: | |
| pcm_data = message["bytes"] | |
| if self.is_configured and self.stt_client.connected: | |
| self.stt_client.send_pcm(pcm_data) | |
| elif "text" in message: | |
| try: | |
| data = json.loads(message["text"]) | |
| msg_type = data.get("type") | |
| if msg_type == "session_end": | |
| logger.info(f"Client requested session end: {self.session.session_id}") | |
| break | |
| elif msg_type == "text_message": | |
| text = data.get("message", "") | |
| if text.strip(): | |
| await self.trigger_assistant_turn(text) | |
| except Exception as parse_err: | |
| logger.warning(f"Failed to parse text message from client: {parse_err}") | |
| else: | |
| break | |
| except Exception as e: | |
| logger.info(f"Ingress loop exception: {e}") | |
| finally: | |
| await self.cleanup() | |
| def _is_echo(self, text: str) -> bool: | |
| elapsed = time.time() - self.last_listening_start_time | |
| if self.last_listening_start_time > 0 and elapsed < ECHO_GUARD_SECONDS: | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] Echo guard (time window, " | |
| f"{elapsed:.2f}s < {ECHO_GUARD_SECONDS}s) β discarding: '{text[:60]}'" | |
| ) | |
| return True | |
| return False | |
| def _get_last_assistant_message(self) -> str: | |
| for msg in reversed(self.session.messages): | |
| if msg.get("role") == "assistant": | |
| return msg.get("content", "") | |
| return "" | |
| async def run_stt_loop(self): | |
| """Reads events from upstream STT space and drives state machine.""" | |
| try: | |
| while True: | |
| event = await self.stt_client.get_event() | |
| is_vad_start = (event.get("type") == "vad" and event.get("event") == "speech_start") | |
| is_partial = (not event.get("is_final") and event.get("text") and not event.get("is_correction")) | |
| is_final = event.get("is_final") and not event.get("is_correction") | |
| is_correction = event.get("is_correction") and event.get("corrects_pending") | |
| # ββ Issue B: handle STT correction event βββββββββββββββββββββ | |
| # Update the last user message text silently β no new turn. | |
| if is_correction: | |
| corrected_text = event.get("text", "").strip() | |
| if corrected_text and self.state == VoiceState.THINKING: | |
| # Find and update the last user message in session history | |
| for i in range(len(self.session.messages) - 1, -1, -1): | |
| if self.session.messages[i].get("role") == "user": | |
| old_text = self.session.messages[i]["content"] | |
| self.session.messages[i]["content"] = corrected_text | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] " | |
| f"Correction applied: '{old_text}' β '{corrected_text}'" | |
| ) | |
| break | |
| continue # corrections never trigger new turns | |
| # ββ INTERRUPT_PENDING state: waiting for STT confirmation βββββ | |
| if self.state == VoiceState.INTERRUPT_PENDING: | |
| if is_final: | |
| text = event.get("text", "").strip() | |
| if text: | |
| resolution = _classify_interrupt_text(text) | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] " | |
| f"Interrupt confirmed as '{resolution}': '{text[:60]}'" | |
| ) | |
| self._interrupt_caller_text = text | |
| self._interrupt_resolution = resolution | |
| if self._interrupt_resolved_event: | |
| self._interrupt_resolved_event.set() | |
| # If text is empty keep waiting until timeout | |
| continue # don't fall through to THINKING/SPEAKING/LISTENING | |
| # ββ THINKING / SPEAKING states ββββββββββββββββββββββββββββββββ | |
| if self.state in (VoiceState.THINKING, VoiceState.SPEAKING): | |
| if is_vad_start: | |
| now = time.time() | |
| if now - self.last_speech_start_time <= 0.2: | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] " | |
| "Barge-in candidate β entering INTERRUPT_PENDING" | |
| ) | |
| await self._enter_interrupt_pending() | |
| else: | |
| logger.info("First VAD speech_start detected. Waiting for confirmation.") | |
| self.last_speech_start_time = now | |
| elif is_partial: | |
| # Partial text during speaking is a strong signal. | |
| # Treat directly as legitimate interrupt candidate β | |
| # enter INTERRUPT_PENDING so we still gate it. | |
| if self.state == VoiceState.SPEAKING: | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] " | |
| "Partial text during SPEAKING β entering INTERRUPT_PENDING" | |
| ) | |
| await self._enter_interrupt_pending() | |
| else: | |
| # THINKING β old behaviour: immediate interrupt | |
| logger.info("Barge-in text during THINKING β halting turn.") | |
| await self.interrupt_turn() | |
| elif is_final: | |
| # Issue 6 (v3): queue instead of dropping | |
| text = event.get("text", "").strip() | |
| if text: | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] Final received " | |
| f"while {self.state.value} β queueing: '{text[:60]}'" | |
| ) | |
| await self._pending_finals_during_turn.put(text) | |
| # ββ LISTENING state βββββββββββββββββββββββββββββββββββββββββββ | |
| elif self.state == VoiceState.LISTENING: | |
| if is_partial: | |
| await self.send_json({"type": "partial", "text": event["text"]}) | |
| elif is_final: | |
| text = event.get("text", "").strip() | |
| if text: | |
| if self._is_echo(text): | |
| continue | |
| await self.send_json({"type": "user_final", "text": text}) | |
| await self.trigger_assistant_turn(text) | |
| except asyncio.CancelledError: | |
| pass | |
| except Exception as e: | |
| logger.error(f"STT loop error: {e}") | |
| async def _enter_interrupt_pending(self): | |
| """ | |
| Issue C Phase 1: transition to INTERRUPT_PENDING. | |
| Issue 3 fix (v5): Previously, audio playback only stopped when | |
| the FULL interrupt was resolved as 'legitimate' β after the | |
| 1.5s STT confirmation window (debounce + silence window + STT | |
| processing = 1.1s-2.0s of the agent continuing to talk over the | |
| caller). This contradicted the entire purpose of barge-in. | |
| Now: 'clear_audio' is sent IMMEDIATELY here, the instant a | |
| candidate interrupt is detected (VAD debounce, <250ms from the | |
| caller's first sound). The frontend's flushAudioQueue() stops | |
| the currently-playing chunk and empties the queue within | |
| milliseconds β true "instant" barge-in, matching user | |
| expectation. We do NOT send 'interrupt' yet (that signals a | |
| full pipeline cancel) β only 'clear_audio' (audio playback | |
| stop). The LLM pipeline keeps running underneath; on_audio() | |
| already checks `self.state == INTERRUPT_PENDING` and queues | |
| any further synthesised sentences into _interrupt_pending_tts | |
| instead of sending them, so nothing more reaches the frontend | |
| until resolution. | |
| If the interrupt resolves as ACCIDENTAL, _resolve_interrupt() | |
| simply re-sends the queued sentences as fresh audio_header + | |
| binary messages β the frontend's existing message handling | |
| naturally re-queues and plays them, no special "resume" | |
| protocol needed (confirmed via useVoiceWebSocket.ts: every | |
| audio_header+binary pair is queued via the same code path | |
| regardless of when it arrives). | |
| If resolved as LEGITIMATE/SHORT_MEANINGFUL, the full pipeline | |
| cancel + bridge-phrase flow proceeds exactly as before β the | |
| only change is that perceived audio silence now starts | |
| immediately instead of after the full confirmation window. | |
| """ | |
| async with self.lock: | |
| self.state = VoiceState.INTERRUPT_PENDING | |
| logger.info(f"[VoiceSession {self.session.session_id}] State changed to: interrupt_pending") | |
| await self.send_json({"type": "state", "state": "interrupt_pending"}) | |
| # Issue 3 fix: stop audio playback NOW, not after the 1.5s | |
| # confirmation window. This is the actual "barge-in" moment β | |
| # the caller should hear silence within milliseconds, not | |
| # seconds, regardless of how the interrupt eventually resolves. | |
| await self.send_json({"type": "clear_audio"}) | |
| self._interrupt_resolved_event = asyncio.Event() | |
| self._interrupt_resolution = None | |
| self._interrupt_caller_text = None | |
| # Start timeout task β if no is_final arrives in time β accidental | |
| asyncio.create_task(self._interrupt_confirmation_timeout()) | |
| async def _interrupt_confirmation_timeout(self): | |
| """ | |
| Issue C Phase 2: wait for STT confirmation. | |
| Times out after INTERRUPT_CONFIRM_TIMEOUT seconds β accidental. | |
| """ | |
| try: | |
| await asyncio.wait_for( | |
| self._interrupt_resolved_event.wait(), | |
| timeout=INTERRUPT_CONFIRM_TIMEOUT, | |
| ) | |
| except asyncio.TimeoutError: | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] " | |
| f"Interrupt timeout ({INTERRUPT_CONFIRM_TIMEOUT}s) β resolving as accidental" | |
| ) | |
| self._interrupt_resolution = "accidental" | |
| if self._interrupt_resolved_event: | |
| self._interrupt_resolved_event.set() | |
| await self._resolve_interrupt() | |
| async def _resolve_interrupt(self): | |
| """ | |
| Issue C Phase 3: act on the confirmed interrupt resolution. | |
| Issue 3 fix (v5): audio playback was ALREADY stopped immediately | |
| in _enter_interrupt_pending() (clear_audio sent there). This | |
| method now only decides what happens NEXT: | |
| - accidental: resume by re-sending the paused sentences | |
| - legitimate/short_meaningful: cancel pipeline + bridge phrase | |
| The 'interrupt'/'clear_audio' sends below for the legitimate | |
| path are now redundant-but-harmless (frontend queue is already | |
| empty) β kept for protocol clarity and to handle any sentence | |
| that slipped through between detection and this resolution. | |
| """ | |
| resolution = self._interrupt_resolution or "accidental" | |
| caller_text = self._interrupt_caller_text | |
| if resolution == "accidental": | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] " | |
| "Interrupt resolved as accidental β resuming TTS" | |
| ) | |
| # Audio was already stopped in _enter_interrupt_pending(). | |
| # Resume by replaying the paused queue from where we stopped. | |
| async with self.lock: | |
| self.state = VoiceState.SPEAKING | |
| await self.send_json({"type": "state", "state": "speaking"}) | |
| await self.send_json({"type": "interrupt_cancelled"}) | |
| # Replay any TTS sentences that were queued during INTERRUPT_PENDING. | |
| # These re-enter the frontend's normal audio_header+binary handling | |
| # path (useVoiceWebSocket.ts) and queue/play exactly like any other | |
| # turn's audio β no special resume protocol required. | |
| for sentence, audio_bytes, tts_lang in self._interrupt_pending_tts: | |
| if not self.cancel_event.is_set(): | |
| await self.send_json({ | |
| "type": "audio_header", | |
| "sentence": sentence, | |
| "lang": tts_lang, | |
| }) | |
| await self.send_binary(audio_bytes) | |
| self._interrupt_pending_tts.clear() | |
| else: | |
| # LEGITIMATE or SHORT_MEANINGFUL β graceful stop | |
| # (audio already stopped in _enter_interrupt_pending(); this | |
| # just cancels the underlying pipeline and starts the new turn) | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] " | |
| f"Interrupt resolved as '{resolution}' β graceful handover" | |
| ) | |
| self._interrupt_pending_tts.clear() | |
| # Hard cancel the pipeline | |
| self.cancel_event.set() | |
| if self.active_pipeline_task and not self.active_pipeline_task.done(): | |
| self.active_pipeline_task.cancel() | |
| self.cancel_event = threading.Event() | |
| # Clear pending finals from the old turn | |
| while not self._pending_finals_during_turn.empty(): | |
| self._pending_finals_during_turn.get_nowait() | |
| await self.send_json({"type": "interrupt"}) | |
| await self.send_json({"type": "clear_audio"}) | |
| # Emit bridge phrase to fill the LLM gap | |
| if caller_text: | |
| bridge = _pick_bridge_phrase(resolution, self.session.turn_count) | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] " | |
| f"Emitting bridge phrase: '{bridge}'" | |
| ) | |
| await self.send_json({"type": "state", "state": "speaking"}) | |
| bridge_audio, bridge_lang = await voice_pipeline.fire_tts( | |
| bridge, "ur", self.session.agent_gender | |
| ) | |
| if bridge_audio: | |
| await self.send_json({ | |
| "type": "audio_header", | |
| "sentence": bridge, | |
| "lang": bridge_lang, | |
| }) | |
| await self.send_binary(bridge_audio) | |
| # Now start the real turn with caller's text | |
| await self.set_state(VoiceState.THINKING) | |
| self.cancel_event = threading.Event() | |
| self.active_pipeline_task = asyncio.create_task( | |
| self._execute_turn(caller_text) | |
| ) | |
| else: | |
| await self.set_state(VoiceState.LISTENING) | |
| # Clean up interrupt state | |
| self._interrupt_resolved_event = None | |
| self._interrupt_resolution = None | |
| self._interrupt_caller_text = None | |
| async def _maybe_process_pending_final(self): | |
| """Issue 6 (v3): drain STT finals queued during THINKING/SPEAKING.""" | |
| if self._pending_finals_during_turn.empty(): | |
| return | |
| text: Optional[str] = None | |
| while not self._pending_finals_during_turn.empty(): | |
| text = await self._pending_finals_during_turn.get() | |
| if not text: | |
| return | |
| logger.info( | |
| f"[VoiceSession {self.session.session_id}] Processing queued final " | |
| f"(arrived during prior turn): '{text[:60]}'" | |
| ) | |
| await self.send_json({"type": "user_final", "text": text}) | |
| await self.trigger_assistant_turn(text) | |
| async def interrupt_turn(self): | |
| """Hard interrupt β used during THINKING state (pre-speech).""" | |
| self.cancel_event.set() | |
| if self.active_pipeline_task and not self.active_pipeline_task.done(): | |
| self.active_pipeline_task.cancel() | |
| self.cancel_event = threading.Event() | |
| while not self._pending_finals_during_turn.empty(): | |
| self._pending_finals_during_turn.get_nowait() | |
| self._interrupt_pending_tts.clear() | |
| await self.send_json({"type": "interrupt"}) | |
| await self.send_json({"type": "clear_audio"}) | |
| await self.set_state(VoiceState.LISTENING) | |
| async def trigger_assistant_turn(self, text: str): | |
| if not self.is_configured: | |
| logger.info(f"Session not yet configured. Queueing turn: {text}") | |
| await self._pending_turns.put(text) | |
| asyncio.create_task(self._drain_pending_turns()) | |
| return | |
| if self.state in (VoiceState.THINKING, VoiceState.SPEAKING): | |
| await self.interrupt_turn() | |
| await self.set_state(VoiceState.THINKING) | |
| self.cancel_event = threading.Event() | |
| self.active_pipeline_task = asyncio.create_task(self._execute_turn(text)) | |
| async def _drain_pending_turns(self): | |
| while not self.is_configured: | |
| await asyncio.sleep(0.05) | |
| while not self._pending_turns.empty(): | |
| text = await self._pending_turns.get() | |
| if self.state in (VoiceState.THINKING, VoiceState.SPEAKING): | |
| await self.interrupt_turn() | |
| await self.set_state(VoiceState.THINKING) | |
| self.cancel_event = threading.Event() | |
| self.active_pipeline_task = asyncio.create_task(self._execute_turn(text)) | |
| async def _execute_turn(self, text: str): | |
| try: | |
| async def on_token(token: str): | |
| await self.send_json({"type": "token", "text": token}) | |
| async def on_audio(sentence: str, audio_bytes: bytes, tts_lang: str): | |
| # Issue C: if INTERRUPT_PENDING, queue audio instead of sending | |
| if self.state == VoiceState.INTERRUPT_PENDING: | |
| logger.debug( | |
| f"[VoiceSession {self.session.session_id}] " | |
| f"TTS queued during INTERRUPT_PENDING: '{sentence[:40]}'" | |
| ) | |
| self._interrupt_pending_tts.append((sentence, audio_bytes, tts_lang)) | |
| return | |
| if self.state == VoiceState.THINKING: | |
| await self.set_state(VoiceState.SPEAKING) | |
| await self.send_json({ | |
| "type": "audio_header", | |
| "sentence": sentence, | |
| "lang": tts_lang, | |
| }) | |
| await self.send_binary(audio_bytes) | |
| async def on_done(full_text: str, language: str, active_summary: str, elapsed_ms: int): | |
| self.current_summary = active_summary | |
| await self.send_json({ | |
| "type": "done", | |
| "session_id": self.session.session_id, | |
| "full_text": full_text, | |
| "language": language, | |
| "language_locked": self.session.language_detector.is_locked(), | |
| "turn_count": self.session.turn_count, | |
| "caller_info": self.session.caller_info.to_dict(), | |
| "activeSummary": active_summary, | |
| "elapsed_ms": elapsed_ms, | |
| }) | |
| await self.set_state(VoiceState.LISTENING) | |
| await self._maybe_process_pending_final() | |
| await voice_pipeline.run_turn( | |
| session=self.session, | |
| message=text, | |
| cancel_event=self.cancel_event, | |
| on_token=on_token, | |
| on_audio=on_audio, | |
| on_done=on_done, | |
| agent_gender=self.session.agent_gender, | |
| current_summary=self.current_summary, | |
| session_start=int(self.session.created_at * 1000), | |
| ) | |
| except Exception as err: | |
| logger.error(f"Error during assistant turn execution: {err}") | |
| await self.send_json({"type": "error", "detail": str(err)}) | |
| await self.set_state(VoiceState.LISTENING) | |
| async def cleanup(self): | |
| self.cancel_event.set() | |
| if self.active_pipeline_task and not self.active_pipeline_task.done(): | |
| self.active_pipeline_task.cancel() | |
| if self.stt_loop_task and not self.stt_loop_task.done(): | |
| self.stt_loop_task.cancel() | |
| if self.keep_alive_task and not self.keep_alive_task.done(): | |
| self.keep_alive_task.cancel() | |
| await self.stt_client.close() | |
| transcript_turns = [ | |
| f"{'Caller' if m['role'] == 'user' else 'Agent'}: {m['content']}" | |
| for m in self.session.messages | |
| ] | |
| full_transcript = "\n".join(transcript_turns) | |
| fields_spec = self.session.caller_collection_fields | |
| extracted_data = self.session.caller_info.to_dict() | |
| if fields_spec and full_transcript.strip(): | |
| llm_extracted = await summary.extract_caller_data_from_transcript( | |
| full_transcript, fields_spec | |
| ) | |
| self.session.caller_info.update_from_llm_extraction(llm_extracted) | |
| extracted_data = self.session.caller_info.to_dict() | |
| import time | |
| duration = int(time.time() - self.session.created_at) | |
| if getattr(self.session, "company_id", None): | |
| await neon_client.insert_call_log( | |
| call_id=self.session.session_id, | |
| company_id=self.session.company_id, | |
| phone_line_id=getattr(self.session, "phone_line_id", None), | |
| full_transcript=full_transcript, | |
| extracted_caller_data=extracted_data, | |
| duration_seconds=duration, | |
| status="completed", | |
| ) | |
| try: | |
| await self.send_json({ | |
| "type": "call_summary", | |
| "session_id": self.session.session_id, | |
| "extracted_caller_data": extracted_data, | |
| "duration_seconds": duration, | |
| "transcript_turns": len(self.session.messages), | |
| }) | |
| except Exception: | |
| pass | |
| logger.info(f"Orchestration session closed and cleaned up: {self.session.session_id}") |