454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""PC-speaker playback for the simulator.
|
|
|
|
Lets the mock robot actually *say* the text out of this laptop's speakers, so the
|
|
whole demo can be rehearsed - wording, pacing, the Stop button - before the A3 is
|
|
on the network.
|
|
|
|
This is simulation only. It has nothing to do with the real robot: the A3
|
|
synthesises its own speech on-board and no audio ever leaves the PC (see
|
|
docs/AGIBOT_A3_INTEGRATION.md).
|
|
|
|
Backends, in order of preference:
|
|
|
|
Windows SAPI5 through comtypes - in-process, ~20 ms to start, real interrupt
|
|
Windows PowerShell System.Speech - fallback, no packages needed at all
|
|
macOS `say`
|
|
Linux `espeak-ng` / `espeak` / `spd-say`
|
|
|
|
Every backend supports three things the simulator needs: start without blocking,
|
|
report whether audio is still playing, and **stop immediately**. Without that last
|
|
one the Stop button would lie - the UI would say "stopped" while the laptop kept
|
|
talking.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import platform
|
|
import queue
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
from typing import List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# SAPI ISpVoice::Speak flags
|
|
_SVSF_ASYNC = 1
|
|
_SVSF_PURGE_BEFORE_SPEAK = 2
|
|
_SVSF_IS_XML = 8
|
|
|
|
|
|
def _xml_escape(text: str) -> str:
|
|
"""Escape text going into SAPI/SSML markup.
|
|
|
|
Without this, an operator typing `Tom & Jerry` or `a < b` would produce
|
|
malformed markup and the utterance would be mangled or dropped.
|
|
"""
|
|
return (
|
|
text.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """)
|
|
)
|
|
|
|
|
|
class LocalVoice:
|
|
"""A speech engine available to the simulator."""
|
|
|
|
name = "none"
|
|
|
|
def prepare(self, text: str) -> bool:
|
|
"""Optional: do slow work (e.g. a cloud round trip) before playback.
|
|
|
|
Called during the "Processing" stage so that "Speaking" is only
|
|
announced once audio can really start. Local engines need nothing.
|
|
"""
|
|
return True
|
|
|
|
def start(self, text: str) -> None:
|
|
raise NotImplementedError
|
|
|
|
def stop(self) -> None:
|
|
raise NotImplementedError
|
|
|
|
def is_speaking(self) -> bool:
|
|
raise NotImplementedError
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Windows - SAPI5 via COM
|
|
# --------------------------------------------------------------------------- #
|
|
class SapiVoice(LocalVoice):
|
|
"""Windows SAPI5.
|
|
|
|
COM objects are apartment-bound, so one dedicated thread creates the voice
|
|
and is the only thread that ever touches it. Public methods just hand it work
|
|
through a queue and read plain flags, which makes them safe to call from the
|
|
asyncio loop.
|
|
"""
|
|
|
|
name = "sapi"
|
|
|
|
def __init__(self, voice_hint: Optional[str] = None, rate: int = 0,
|
|
volume: int = 100, pitch: int = 0) -> None:
|
|
self._voice_hint = voice_hint
|
|
self._rate = max(-10, min(10, rate))
|
|
self._volume = max(0, min(100, volume))
|
|
# SAPI pitch: -10..10. Raising it turns an adult male voice into a
|
|
# younger-sounding one, which is the closest a stock Windows voice gets
|
|
# to a teenage character voice.
|
|
self._pitch = max(-10, min(10, pitch))
|
|
|
|
self._commands: "queue.Queue" = queue.Queue()
|
|
self._speaking = threading.Event()
|
|
self._stop_flag = threading.Event()
|
|
self._ready = threading.Event()
|
|
self._error: Optional[str] = None
|
|
self._closing = False
|
|
|
|
self._thread = threading.Thread(target=self._run, name="sapi-voice", daemon=True)
|
|
self._thread.start()
|
|
self._ready.wait(timeout=8)
|
|
if self._error:
|
|
raise RuntimeError(self._error)
|
|
|
|
# -- public API ---------------------------------------------------------- #
|
|
def start(self, text: str) -> None:
|
|
self._stop_flag.clear()
|
|
# Set the flag here, not on the worker thread: a caller that polls
|
|
# is_speaking() immediately must never see "idle" before we begin.
|
|
self._speaking.set()
|
|
self._commands.put(text)
|
|
|
|
def stop(self) -> None:
|
|
self._stop_flag.set()
|
|
|
|
def is_speaking(self) -> bool:
|
|
return self._speaking.is_set()
|
|
|
|
def close(self) -> None:
|
|
self._closing = True
|
|
self._stop_flag.set()
|
|
self._commands.put(None)
|
|
|
|
# -- worker thread ------------------------------------------------------- #
|
|
def _run(self) -> None:
|
|
try:
|
|
import comtypes
|
|
import comtypes.client
|
|
except ImportError as exc: # pragma: no cover - checked by the factory
|
|
self._error = "comtypes not installed: {0}".format(exc)
|
|
self._ready.set()
|
|
return
|
|
|
|
try:
|
|
comtypes.CoInitialize()
|
|
except Exception: # pragma: no cover - already initialised is fine
|
|
pass
|
|
|
|
try:
|
|
voice = comtypes.client.CreateObject("SAPI.SpVoice")
|
|
self._select_voice(voice)
|
|
voice.Rate = self._rate
|
|
voice.Volume = self._volume
|
|
except Exception as exc:
|
|
self._error = "cannot create SAPI voice: {0}".format(exc)
|
|
self._ready.set()
|
|
return
|
|
|
|
self._ready.set()
|
|
logger.info("local audio: Windows SAPI ready (rate=%s volume=%s)", self._rate, self._volume)
|
|
|
|
while not self._closing:
|
|
text = self._commands.get()
|
|
if text is None:
|
|
break
|
|
try:
|
|
if self._pitch:
|
|
voice.Speak(
|
|
'<pitch middle="{0}">{1}</pitch>'.format(self._pitch, _xml_escape(text)),
|
|
_SVSF_ASYNC | _SVSF_IS_XML,
|
|
)
|
|
else:
|
|
voice.Speak(text, _SVSF_ASYNC)
|
|
# Poll rather than block, so a Stop lands within ~50 ms.
|
|
while not voice.WaitUntilDone(50):
|
|
if self._stop_flag.is_set():
|
|
voice.Speak("", _SVSF_PURGE_BEFORE_SPEAK)
|
|
break
|
|
except Exception as exc: # pragma: no cover - device may vanish
|
|
logger.warning("local audio playback failed: %s", exc)
|
|
finally:
|
|
self._speaking.clear()
|
|
|
|
try:
|
|
import comtypes
|
|
|
|
comtypes.CoUninitialize()
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
|
|
def _select_voice(self, voice) -> None:
|
|
"""Pick the voice whose description matches the configured hint."""
|
|
if not self._voice_hint:
|
|
return
|
|
hint = self._voice_hint.lower()
|
|
try:
|
|
for description, token, source in _sapi_tokens():
|
|
if hint in description.lower():
|
|
voice.Voice = token
|
|
logger.info("local audio: using voice '%s' (%s)", description, source)
|
|
return
|
|
logger.warning(
|
|
"local audio: no voice matching '%s'; using the system default. "
|
|
"Run 'python scripts/voices.py' to see what is installed.",
|
|
self._voice_hint,
|
|
)
|
|
except Exception as exc: # pragma: no cover
|
|
logger.debug("voice selection failed: %s", exc)
|
|
|
|
|
|
#: Windows keeps two voice registries. The "OneCore" set is the newer, better
|
|
#: sounding one and is invisible to SpVoice.GetVoices(), so look there first.
|
|
_ONECORE_KEY = r"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech_OneCore\Voices"
|
|
|
|
|
|
def _sapi_tokens():
|
|
"""[(description, token, source)] for every SAPI voice, best set first.
|
|
|
|
Must be called on a thread where COM is initialised.
|
|
"""
|
|
import comtypes.client
|
|
|
|
found = []
|
|
seen = set()
|
|
|
|
try:
|
|
category = comtypes.client.CreateObject("SAPI.SpObjectTokenCategory")
|
|
category.SetId(_ONECORE_KEY, False)
|
|
tokens = category.EnumerateTokens()
|
|
for index in range(tokens.Count):
|
|
token = tokens.Item(index)
|
|
description = token.GetDescription()
|
|
found.append((description, token, "onecore"))
|
|
seen.add(description)
|
|
except Exception as exc: # pragma: no cover - older Windows
|
|
logger.debug("OneCore voices unavailable: %s", exc)
|
|
|
|
try:
|
|
voice = comtypes.client.CreateObject("SAPI.SpVoice")
|
|
tokens = voice.GetVoices()
|
|
for index in range(tokens.Count):
|
|
token = tokens.Item(index)
|
|
description = token.GetDescription()
|
|
if description not in seen:
|
|
found.append((description, token, "classic"))
|
|
except Exception as exc: # pragma: no cover
|
|
logger.debug("classic voices unavailable: %s", exc)
|
|
|
|
return found
|
|
|
|
|
|
def list_local_voices():
|
|
"""[(description, source)] - for `python scripts/voices.py`. Never raises."""
|
|
if not platform.system().lower().startswith("win"):
|
|
return []
|
|
try:
|
|
import comtypes
|
|
|
|
try:
|
|
comtypes.CoInitialize()
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
return [(description, source) for description, _, source in _sapi_tokens()]
|
|
except Exception: # pragma: no cover
|
|
return []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Everything else - an external command we can kill
|
|
# --------------------------------------------------------------------------- #
|
|
class CommandVoice(LocalVoice):
|
|
"""Speak by running a command. Stop = kill the process."""
|
|
|
|
def __init__(self, name: str, argv_builder) -> None:
|
|
self.name = name
|
|
self._argv = argv_builder
|
|
self._process: Optional[subprocess.Popen] = None
|
|
self._lock = threading.Lock()
|
|
|
|
def start(self, text: str) -> None:
|
|
self.stop()
|
|
argv = self._argv(text)
|
|
try:
|
|
with self._lock:
|
|
self._process = subprocess.Popen(
|
|
argv,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
stdin=subprocess.DEVNULL,
|
|
)
|
|
except Exception as exc: # pragma: no cover - depends on host
|
|
logger.warning("local audio command failed (%s): %s", argv[0], exc)
|
|
self._process = None
|
|
|
|
def stop(self) -> None:
|
|
with self._lock:
|
|
process = self._process
|
|
self._process = None
|
|
if process is not None and process.poll() is None:
|
|
try:
|
|
process.kill()
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
|
|
def is_speaking(self) -> bool:
|
|
with self._lock:
|
|
process = self._process
|
|
return process is not None and process.poll() is None
|
|
|
|
def close(self) -> None:
|
|
self.stop()
|
|
|
|
|
|
def _powershell_voice(rate: int, volume: int, pitch: int = 0,
|
|
voice_hint: Optional[str] = None) -> CommandVoice:
|
|
"""Windows fallback using System.Speech - present on every Windows install."""
|
|
rate = max(-10, min(10, rate))
|
|
volume = max(0, min(100, volume))
|
|
pitch = max(-10, min(10, pitch))
|
|
|
|
def argv(text: str) -> List[str]:
|
|
select = ""
|
|
if voice_hint:
|
|
# SelectVoiceByHints has no substring form; fall back silently when
|
|
# the named voice is absent rather than throwing.
|
|
select = (
|
|
"try {{ $s.SelectVoice(($s.GetInstalledVoices() | "
|
|
"Where-Object {{ $_.VoiceInfo.Name -like '*{0}*' }} | "
|
|
"Select-Object -First 1).VoiceInfo.Name) }} catch {{}};"
|
|
).format(voice_hint.replace("'", "''"))
|
|
|
|
if pitch:
|
|
# System.Speech exposes pitch only through SSML prosody.
|
|
body = (
|
|
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' "
|
|
"xml:lang='en-US'><prosody pitch='{0:+d}%'>{1}</prosody></speak>"
|
|
).format(pitch * 5, _xml_escape(text))
|
|
speak = "$s.SpeakSsml('{0}')".format(body.replace("'", "''"))
|
|
else:
|
|
speak = "$s.Speak('{0}')".format(text.replace("'", "''"))
|
|
|
|
script = (
|
|
"Add-Type -AssemblyName System.Speech;"
|
|
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
|
"{select}$s.Rate = {rate}; $s.Volume = {volume};{speak}"
|
|
).format(select=select, rate=rate, volume=volume, speak=speak)
|
|
return ["powershell", "-NoProfile", "-NonInteractive", "-Command", script]
|
|
|
|
return CommandVoice("powershell", argv)
|
|
|
|
|
|
def _macos_voice(rate: int) -> CommandVoice:
|
|
# `say` takes words per minute; map the -10..10 scale onto a sane range.
|
|
wpm = max(90, min(320, 180 + rate * 12))
|
|
|
|
def argv(text: str) -> List[str]:
|
|
return ["say", "-r", str(wpm), text]
|
|
|
|
return CommandVoice("say", argv)
|
|
|
|
|
|
def _linux_voice(rate: int, volume: int) -> Optional[CommandVoice]:
|
|
if shutil.which("espeak-ng") or shutil.which("espeak"):
|
|
binary = "espeak-ng" if shutil.which("espeak-ng") else "espeak"
|
|
wpm = max(80, min(320, 175 + rate * 12))
|
|
amplitude = max(0, min(200, int(volume * 2)))
|
|
|
|
def argv(text: str) -> List[str]:
|
|
return [binary, "-s", str(wpm), "-a", str(amplitude), text]
|
|
|
|
return CommandVoice(binary, argv)
|
|
|
|
if shutil.which("spd-say"):
|
|
def argv(text: str) -> List[str]:
|
|
return ["spd-say", "-w", "-r", str(max(-100, min(100, rate * 10))), text]
|
|
|
|
return CommandVoice("spd-say", argv)
|
|
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# factory
|
|
# --------------------------------------------------------------------------- #
|
|
def create_local_voice(
|
|
voice_hint: Optional[str] = None, rate: int = 0, volume: int = 100, pitch: int = 0,
|
|
pronouncer=None,
|
|
) -> Optional[LocalVoice]:
|
|
"""Best available PC voice, or None if this machine cannot speak.
|
|
|
|
Never raises: local audio is a convenience, and losing it must not stop the
|
|
simulator from running.
|
|
"""
|
|
voice = _create(voice_hint, rate, volume, pitch)
|
|
if voice is not None and pronouncer is not None:
|
|
# OS voices mispronounce brand names; fix the text at the last moment.
|
|
return Respelling(voice, pronouncer)
|
|
return voice
|
|
|
|
|
|
class Respelling(LocalVoice):
|
|
"""Wraps a voice, respelling text on its way to the synthesiser."""
|
|
|
|
def __init__(self, inner: LocalVoice, pronouncer) -> None:
|
|
self._inner = inner
|
|
self._pronouncer = pronouncer
|
|
self.name = inner.name
|
|
|
|
def prepare(self, text: str) -> bool:
|
|
return self._inner.prepare(self._pronouncer.apply(text))
|
|
|
|
def start(self, text: str) -> None:
|
|
self._inner.start(self._pronouncer.apply(text))
|
|
|
|
def stop(self) -> None:
|
|
self._inner.stop()
|
|
|
|
def is_speaking(self) -> bool:
|
|
return self._inner.is_speaking()
|
|
|
|
def close(self) -> None:
|
|
self._inner.close()
|
|
|
|
|
|
def _create(voice_hint, rate, volume, pitch):
|
|
system = platform.system().lower()
|
|
|
|
if system.startswith("win"):
|
|
try:
|
|
return SapiVoice(voice_hint, rate, volume, pitch)
|
|
except Exception as exc:
|
|
logger.info("SAPI unavailable (%s); falling back to PowerShell", exc)
|
|
if shutil.which("powershell"):
|
|
return _powershell_voice(rate, volume, pitch, voice_hint)
|
|
logger.warning("local audio: no speech engine found on this Windows PC")
|
|
return None
|
|
|
|
if system == "darwin":
|
|
if shutil.which("say"):
|
|
return _macos_voice(rate)
|
|
logger.warning("local audio: 'say' not found")
|
|
return None
|
|
|
|
voice = _linux_voice(rate, volume)
|
|
if voice is None:
|
|
logger.warning(
|
|
"local audio: install espeak-ng (apt install espeak-ng) to hear the simulator"
|
|
)
|
|
return voice
|