2026-09-03 00:10:18 +04:00

366 lines
14 KiB
Python

"""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