"""Mock AGIBOT A3. A stand-in that reproduces the *shape and timing* of a real robot TTS exchange: a network hop, a synthesis pause, a speaking window proportional to the text, then completion - all interruptible. The frontend cannot tell the difference, which is the point: everything except the wire protocol gets tested before the robot lands. Optional: set MOCK_LOCAL_AUDIO=true to hear the text through the PC's own speakers, so the whole demo can be rehearsed before the robot is on the network. When that is on, the *real* playback drives the timeline - "Completed" appears exactly when the sound stops, and Stop cuts the audio mid-word. See local_audio.py. """ from __future__ import annotations import asyncio import logging import random import time from typing import Any, Dict, Optional from ..config.settings import Settings from .local_audio import LocalVoice, create_local_voice from .base import ( ProgressCallback, RobotAdapter, RobotCapabilities, RobotInfo, RobotUnreachable, SpeechFailed, SpeechProgress, SpeechRequest, SpeechResult, SpeechStage, ) logger = logging.getLogger(__name__) # Granularity of the simulated speaking window. Small enough that Stop feels # instant, large enough not to spin the event loop. _TICK = 0.05 class MockRobot(RobotAdapter): """Simulated robot. Never touches the network.""" def __init__(self, settings: Settings) -> None: self._settings = settings self._mock = settings.mock self._connected = False self._speaking = False self._cancel = asyncio.Event() self._health_calls = 0 self._rng = random.Random(20260902) self._local_voice: Optional[LocalVoice] = None self._voice_ready = False # -- identity ------------------------------------------------------------ # @property def info(self) -> RobotInfo: return RobotInfo( name=self._settings.robot.name, model=self._settings.robot.model + " (simulated)", mode="mock", transport="mock", address="local simulation", capabilities=RobotCapabilities( native_tts=True, stop=True, progress_events=True, reports_completion=True, voice_selection=False, volume_control=False, ), ) @property def is_connected(self) -> bool: return self._connected @property def is_speaking(self) -> bool: return self._speaking # -- lifecycle ----------------------------------------------------------- # async def connect(self) -> None: await asyncio.sleep(self._mock.connect_delay_ms / 1000) if self._mock.flaky_connection and self._rng.random() < 0.25: self._connected = False raise RobotUnreachable("simulated connection failure") self._connected = True logger.info("MockRobot connected (simulated)") async def disconnect(self) -> None: self._cancel.set() self._connected = False self._speaking = False if self._local_voice is not None: try: self._local_voice.close() except Exception: # pragma: no cover pass self._local_voice = None self._voice_ready = False logger.info("MockRobot disconnected (simulated)") async def health_check(self) -> bool: self._health_calls += 1 # Cost a plausible round trip so the dashboard's latency readout looks # like a network number rather than a suspicious 0 ms. await asyncio.sleep(self._mock.network_latency_ms / 2000) # Simulate an intermittent link so reconnect logic gets exercised offline. if self._mock.flaky_connection and self._health_calls % 17 == 0: self._connected = False return False return self._connected # -- speech -------------------------------------------------------------- # async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> SpeechResult: if not self._connected: raise RobotUnreachable("mock robot is not connected") self._cancel.clear() self._speaking = True started = time.perf_counter() ack_ms: Optional[int] = None try: # 1. request leaves the PC await on_progress( SpeechProgress(request.id, SpeechStage.SENDING, "Sending to robot...") ) await self._sleep(self._mock.network_latency_ms / 1000) if self._mock.failure_rate and self._rng.random() < self._mock.failure_rate: raise SpeechFailed( "simulated TTS failure", user_message="Speech request failed. The robot rejected the utterance.", ) # 2. robot has the text and is synthesising ack_ms = int((time.perf_counter() - started) * 1000) await on_progress( SpeechProgress( request.id, SpeechStage.PROCESSING, "Robot is preparing speech...", elapsed_ms=ack_ms, ) ) await self._sleep(self._mock.processing_ms / 1000) # A cloud voice needs a network round trip. Do it here, while the UI # still says "preparing speech", so that "Speaking..." is only shown # once audio can actually start. voice = self._voice() if voice is not None and not self._cancel.is_set(): await asyncio.get_running_loop().run_in_executor( None, voice.prepare, request.text ) # 3. audio starts duration = self._estimate_duration(request.text) await on_progress( SpeechProgress( request.id, SpeechStage.SPEAKING, "Speaking...", detail="~{0:.1f}s".format(duration), elapsed_ms=int((time.perf_counter() - started) * 1000), ) ) # With PC audio on, the real playback drives the timeline, so the UI # returns to Ready exactly when the sound stops. Otherwise fall back # to the estimate. if self._start_local_audio(request.text): cancelled = await self._wait_for_audio(duration) else: cancelled = await self._sleep(duration) if cancelled: await on_progress( SpeechProgress(request.id, SpeechStage.CANCELLED, "Speech stopped.") ) return SpeechResult( request_id=request.id, success=False, stage=SpeechStage.CANCELLED, ack_latency_ms=ack_ms, total_ms=int((time.perf_counter() - started) * 1000), error_code="cancelled", error="Stopped by operator.", ) total_ms = int((time.perf_counter() - started) * 1000) await on_progress( SpeechProgress(request.id, SpeechStage.COMPLETED, "Ready", elapsed_ms=total_ms) ) return SpeechResult( request_id=request.id, success=True, stage=SpeechStage.COMPLETED, ack_latency_ms=ack_ms, total_ms=total_ms, ) finally: self._speaking = False async def stop_speaking(self) -> bool: if not self._speaking: return False self._cancel.set() # Cut the audio now rather than waiting for the polling loop, so the # speakers go quiet the instant Stop is pressed. if self._local_voice is not None: try: self._local_voice.stop() except Exception: # pragma: no cover pass return True async def describe(self) -> Dict[str, Any]: return { "simulation": { "connectDelayMs": self._mock.connect_delay_ms, "networkLatencyMs": self._mock.network_latency_ms, "processingMs": self._mock.processing_ms, "wordsPerMinute": self._mock.words_per_minute, "failureRate": self._mock.failure_rate, "flakyConnection": self._mock.flaky_connection, "localAudio": self._mock.local_audio, "voiceBackend": self._local_voice.name if self._local_voice else None, "voiceHint": self._mock.voice, "voicePitch": self._mock.speech_pitch, }, "healthChecks": self._health_calls, } # -- internals ----------------------------------------------------------- # async def _sleep(self, seconds: float) -> bool: """Sleep, but wake immediately on Stop. Returns True if interrupted.""" deadline = time.perf_counter() + seconds while True: remaining = deadline - time.perf_counter() if remaining <= 0: return False if self._cancel.is_set(): return True await asyncio.sleep(min(_TICK, remaining)) def _estimate_duration(self, text: str) -> float: """Approximate how long a human-sounding voice would take to read `text`.""" words = len(text.split()) cjk = sum(1 for ch in text if "一" <= ch <= "鿿") if cjk > words: # Chinese/Japanese text has few whitespace-delimited words words = max(words, cjk // 2) wpm = max(60, self._mock.words_per_minute) return max(0.8, (words / wpm) * 60.0) def _start_local_audio(self, text: str) -> bool: """Begin PC-speaker playback. Returns True if audio actually started.""" voice = self._voice() if voice is None: return False try: voice.start(text) return True except Exception as exc: # pragma: no cover - depends on host audio logger.warning("local audio failed, falling back to silent timing: %s", exc) return False def _voice(self) -> Optional[LocalVoice]: """Create the PC voice on first use; never fail the utterance over it.""" if not self._mock.local_audio: return None if self._voice_ready: return self._local_voice self._voice_ready = True try: system_voice = create_local_voice( voice_hint=self._mock.voice, rate=self._mock.speech_rate, volume=self._mock.speech_volume, pitch=self._mock.speech_pitch, ) # Respell once, around whichever engine ends up in front, so both # the cloud voice and the fallback say names the same way. self._local_voice = self._wrap_pronunciation(self._wrap_cloud(system_voice)) if self._local_voice is None: logger.warning( "MOCK_LOCAL_AUDIO is on but this PC has no usable speech engine" ) except Exception as exc: # pragma: no cover logger.warning("local audio unavailable: %s", exc) self._local_voice = None return self._local_voice def _wrap_pronunciation(self, voice): """Fix brand-name pronunciation just before synthesis, for any engine.""" if voice is None or not self._mock.pronunciation: return voice from ..config.settings import PROJECT_ROOT from ..core.pronunciation import build as build_pronouncer from .local_audio import Respelling pronouncer = build_pronouncer(PROJECT_ROOT, True) if pronouncer is None or not len(pronouncer): return voice return Respelling(voice, pronouncer) def _wrap_cloud(self, system_voice): """Put the Gemini voice in front of the system voice, if configured. The system voice stays as the fallback so a network or quota problem degrades to a robotic voice rather than to silence mid-demo. """ if self._mock.voice_engine != "gemini": return system_voice if not self._mock.gemini_api_key: logger.warning( "MOCK_VOICE_ENGINE=gemini but GEMINI_API_KEY is not set; " "using the built-in system voice" ) return system_voice try: from ..services.audio_library import get_audio_library from .gemini_voice import GeminiVoice voice = GeminiVoice( library=get_audio_library(), api_key=self._mock.gemini_api_key, model=self._mock.gemini_model, voice=self._mock.gemini_voice, style=self._mock.gemini_style, chunk_chars=self._mock.gemini_chunk_chars, fallback=system_voice, ) logger.info( "local audio: Gemini voice '%s' (%s), fallback=%s", self._mock.gemini_voice, self._mock.gemini_model, system_voice.name if system_voice else "none", ) return voice except Exception as exc: logger.warning("Gemini voice unavailable (%s); using the system voice", exc) return system_voice async def _wait_for_audio(self, fallback_seconds: float) -> bool: """Wait until the speakers go quiet. Returns True if Stop interrupted it.""" voice = self._local_voice assert voice is not None # Never wait forever if a backend misreports its state. deadline = time.perf_counter() + max(fallback_seconds * 4, 30.0) while time.perf_counter() < deadline: if self._cancel.is_set(): try: voice.stop() except Exception: # pragma: no cover pass return True if not voice.is_speaking(): return False await asyncio.sleep(_TICK) return False