| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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_SECONDS = 1.2 |
|
|
| |
| |
| INTERRUPT_CONFIRM_TIMEOUT = 1.5 |
|
|
| |
| |
| INTERRUPT_SHORT_MEANINGFUL = { |
| "ہاں", "نہیں", "جی", "ٹھیک", "ہاں جی", "نہیں جی", |
| "رکو", "بس", "اچھا", "ٹھیک ہے", "سمجھ گیا", "سمجھ گئی", |
| "han", "nahi", "ji", "theek", "ruko", "bas", "acha", |
| } |
|
|
| |
| INTERRUPT_NOISE_WORDS = { |
| "موسیقی", "میوزک", "ساز", "واہ", "ہم", "مرحبا", |
| } |
|
|
| |
| BRIDGE_PHRASES_FULL = [ |
| "جی، بتائیں۔", |
| "جی فرمائیں۔", |
| "ہاں، بتائیں۔", |
| ] |
| BRIDGE_PHRASES_SHORT = [ |
| "جی۔", |
| "ہاں۔", |
| ] |
|
|
| |
| |
|
|
|
|
| class VoiceState(Enum): |
| LISTENING = "listening" |
| PROCESSING = "processing" |
| THINKING = "thinking" |
| SPEAKING = "speaking" |
| INTERRUPT_PENDING = "interrupt_pending" |
|
|
|
|
| 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) |
|
|
| |
| if word_count == 1 and words[0] in INTERRUPT_NOISE_WORDS: |
| return "accidental" |
|
|
| |
| if word_count == 1: |
| if words[0] in INTERRUPT_SHORT_MEANINGFUL: |
| return "short_meaningful" |
| |
| |
| return "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 |
|
|
| |
| self._pending_finals_during_turn: asyncio.Queue = asyncio.Queue() |
|
|
| |
| |
| self._interrupt_pending_tts: List[Tuple[str, bytes, str]] = [] |
| |
| self._interrupt_resolved_event: Optional[asyncio.Event] = None |
| |
| self._interrupt_resolution: Optional[str] = None |
| |
| 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") |
|
|
| |
| |
| if is_correction: |
| corrected_text = event.get("text", "").strip() |
| if corrected_text and self.state == VoiceState.THINKING: |
| |
| 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 |
|
|
| |
| 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() |
| |
| continue |
|
|
| |
| 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: |
| |
| |
| |
| 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: |
| |
| logger.info("Barge-in text during THINKING — halting turn.") |
| await self.interrupt_turn() |
| elif is_final: |
| |
| 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) |
|
|
| |
| 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"}) |
|
|
| |
| |
| |
| |
| await self.send_json({"type": "clear_audio"}) |
|
|
| self._interrupt_resolved_event = asyncio.Event() |
| self._interrupt_resolution = None |
| self._interrupt_caller_text = None |
|
|
| |
| 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" |
| ) |
| |
| |
| async with self.lock: |
| self.state = VoiceState.SPEAKING |
| await self.send_json({"type": "state", "state": "speaking"}) |
| await self.send_json({"type": "interrupt_cancelled"}) |
|
|
| |
| |
| |
| |
| 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: |
| |
| |
| |
| logger.info( |
| f"[VoiceSession {self.session.session_id}] " |
| f"Interrupt resolved as '{resolution}' — graceful handover" |
| ) |
| self._interrupt_pending_tts.clear() |
|
|
| |
| 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() |
|
|
| await self.send_json({"type": "interrupt"}) |
| await self.send_json({"type": "clear_audio"}) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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): |
| |
| 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}") |