366 lines
14 KiB
Python
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
|