Update 2026-09-03 00:10
This commit is contained in:
commit
6e939ce6c3
276
.env.example
Normal file
276
.env.example
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# AGIBOT A3 - Voice Control | configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to `.env` and edit that copy. Nothing in the source code needs
|
||||||
|
# to change to switch between the simulator and the real robot.
|
||||||
|
#
|
||||||
|
# Restart the server after editing, or press "Reload config" in the dashboard.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 1. WEB SERVER (this PC)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 127.0.0.1 = only this PC can open the dashboard (recommended).
|
||||||
|
# 0.0.0.0 = other devices on your LAN can open it too (tablet, phone).
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=8000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Only needed if you serve the frontend from a different origin. Usually empty.
|
||||||
|
CORS_ORIGINS=
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 2. ROBOT MODE <<< THIS IS THE SWITCH >>>
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# mock = simulated robot, no hardware required (default; use this today)
|
||||||
|
# real = talk to a physical AGIBOT A3 over the network
|
||||||
|
ROBOT_MODE=mock
|
||||||
|
|
||||||
|
ROBOT_NAME=AGIBOT A3
|
||||||
|
ROBOT_MODEL=AgiBot A3
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 3. ROBOT ADDRESS <<< PUT THE ROBOT IP HERE WHEN YOU HAVE IT >>>
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Leave ROBOT_IP empty while ROBOT_MODE=mock.
|
||||||
|
# When the robot is powered on and on the same LAN:
|
||||||
|
# ROBOT_MODE=real
|
||||||
|
# ROBOT_IP=192.168.1.50 <- the robot's actual IP on YOUR network
|
||||||
|
# ROBOT_PORT=59301 <- AgiBot's documented A3 TTS RPC port (HDU)
|
||||||
|
#
|
||||||
|
# NOTE: AgiBot's own examples use 10.42.10.10 - that is the robot's INTERNAL
|
||||||
|
# address for its head unit (HDU) and is not reachable from your PC. Use the
|
||||||
|
# HDU's address on your WiFi/LAN. Find it with: python scripts/discover_robot.py
|
||||||
|
ROBOT_IP=
|
||||||
|
ROBOT_PORT=59301
|
||||||
|
ROBOT_USE_TLS=false
|
||||||
|
|
||||||
|
# Timing (seconds). Keep connect timeout short so the UI never feels stuck.
|
||||||
|
ROBOT_CONNECT_TIMEOUT=3.0
|
||||||
|
ROBOT_REQUEST_TIMEOUT=8.0
|
||||||
|
ROBOT_HEALTH_INTERVAL=5.0
|
||||||
|
ROBOT_RECONNECT_MIN_DELAY=1.0
|
||||||
|
ROBOT_RECONNECT_MAX_DELAY=15.0
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 4. HOW TO TALK TO THE A3
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Only used when ROBOT_MODE=real.
|
||||||
|
#
|
||||||
|
# aimdk - AgiBot's documented A3 speech RPC <<< USE THIS, it is the default >>>
|
||||||
|
# http - a generic HTTP/REST endpoint you specify yourself
|
||||||
|
# ws - a WebSocket endpoint you specify yourself
|
||||||
|
# ros2 - a ROS 2 topic or service (requires ROS 2 on this PC)
|
||||||
|
# ssh - run a command on the robot over SSH (last-resort fallback)
|
||||||
|
#
|
||||||
|
# The `aimdk` transport implements the interface AgiBot documents for the A3:
|
||||||
|
# POST http://<robot>:59301/rpc/aimdk.protocol.TTSService/PlayTTS
|
||||||
|
# Content-Type: application/json
|
||||||
|
# {"text": "...", "priority_level": "INTERACTION_L6", "domain": "...",
|
||||||
|
# "trace_id": "...", "is_interrupted": true}
|
||||||
|
# Docs: https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play
|
||||||
|
#
|
||||||
|
# The defaults below come from that documentation, but they are still settings
|
||||||
|
# rather than hard-coded values: AgiBot does not guarantee ports or names across
|
||||||
|
# firmware, and there is no endpoint-discovery API. If your unit differs, change
|
||||||
|
# it here - never in the source. See docs/AGIBOT_A3_INTEGRATION.md.
|
||||||
|
A3_TRANSPORT=aimdk
|
||||||
|
|
||||||
|
|
||||||
|
# --- 4a. AimDK transport (recommended) ---------------------------------------
|
||||||
|
# Uses ROBOT_IP and ROBOT_PORT above.
|
||||||
|
A3_AIMDK_SERVICE=aimdk.protocol.TTSService
|
||||||
|
A3_AIMDK_PLAY_METHOD=PlayTTS
|
||||||
|
A3_AIMDK_STOP_METHOD=StopTTSTraceId
|
||||||
|
A3_AIMDK_STATUS_METHOD=GetAudioStatus
|
||||||
|
|
||||||
|
# Playback priority. Only INTERACTION_L6 appears in AgiBot's A3 examples.
|
||||||
|
A3_AIMDK_PRIORITY=INTERACTION_L6
|
||||||
|
|
||||||
|
# Free-form caller tag sent with each request, so robot-side logs show who spoke.
|
||||||
|
A3_AIMDK_DOMAIN=voice_control
|
||||||
|
|
||||||
|
# true = a new utterance interrupts whatever is currently playing.
|
||||||
|
A3_AIMDK_INTERRUPT=true
|
||||||
|
|
||||||
|
# Hard request limit documented by AgiBot: 1024 BYTES of UTF-8 (not characters).
|
||||||
|
# Longer text is split automatically on sentence boundaries and sent in order.
|
||||||
|
A3_AIMDK_MAX_BYTES=1024
|
||||||
|
|
||||||
|
|
||||||
|
# --- 4b. Generic HTTP / REST transport ---------------------------------------
|
||||||
|
# Only needed if your unit does NOT use the AimDK interface above.
|
||||||
|
# Path is relative to http://ROBOT_IP:ROBOT_PORT
|
||||||
|
# Example shape only - replace with the documented endpoint:
|
||||||
|
# A3_HTTP_SPEAK_PATH=/api/v1/tts/speak
|
||||||
|
A3_HTTP_SPEAK_PATH=
|
||||||
|
A3_HTTP_SPEAK_METHOD=POST
|
||||||
|
|
||||||
|
# JSON body template. {text} is replaced with the operator's text.
|
||||||
|
# Placeholders: {text} {id} {voice} {language} {volume} {speed}
|
||||||
|
# A key whose value resolves to nothing is dropped from the request.
|
||||||
|
A3_HTTP_SPEAK_PAYLOAD={"text": "{text}"}
|
||||||
|
|
||||||
|
# Optional: endpoint that interrupts speech.
|
||||||
|
A3_HTTP_STOP_PATH=
|
||||||
|
A3_HTTP_STOP_METHOD=POST
|
||||||
|
A3_HTTP_STOP_PAYLOAD={}
|
||||||
|
|
||||||
|
# Optional: cheap endpoint polled for the connection indicator.
|
||||||
|
# If left empty the app falls back to a TCP connect test on ROBOT_PORT.
|
||||||
|
A3_HTTP_STATUS_PATH=
|
||||||
|
A3_HTTP_STATUS_METHOD=GET
|
||||||
|
|
||||||
|
# Optional: extra headers and auth.
|
||||||
|
A3_HTTP_HEADERS={}
|
||||||
|
A3_HTTP_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# Optional: dotted path to a success flag in the JSON response, e.g. "code" or
|
||||||
|
# "result.success". Leave empty to trust the HTTP status code alone.
|
||||||
|
A3_HTTP_SUCCESS_FIELD=
|
||||||
|
|
||||||
|
|
||||||
|
# --- 4c. WebSocket transport --------------------------------------------------
|
||||||
|
A3_WS_PATH=
|
||||||
|
A3_WS_SPEAK_PAYLOAD={"text": "{text}"}
|
||||||
|
A3_WS_STOP_PAYLOAD={}
|
||||||
|
A3_WS_PING_INTERVAL=20
|
||||||
|
|
||||||
|
# Optional: how the robot announces "finished speaking". With these set, the
|
||||||
|
# dashboard shows real completion instead of an estimate.
|
||||||
|
# A3_WS_DONE_FIELD=event
|
||||||
|
# A3_WS_DONE_VALUE=speech_end
|
||||||
|
A3_WS_DONE_FIELD=
|
||||||
|
A3_WS_DONE_VALUE=
|
||||||
|
|
||||||
|
|
||||||
|
# --- 4d. ROS 2 transport ------------------------------------------------------
|
||||||
|
# Requires ROS 2 installed on this PC and the same ROS_DOMAIN_ID as the robot.
|
||||||
|
A3_ROS_DOMAIN_ID=0
|
||||||
|
A3_ROS_SPEAK_TOPIC=
|
||||||
|
A3_ROS_SPEAK_MSG_TYPE=std_msgs/msg/String
|
||||||
|
A3_ROS_SPEAK_MSG_FIELD=data
|
||||||
|
A3_ROS_STOP_TOPIC=
|
||||||
|
A3_ROS_USE_SERVICE=false
|
||||||
|
A3_ROS_SERVICE_NAME=
|
||||||
|
A3_ROS_SERVICE_TYPE=
|
||||||
|
|
||||||
|
|
||||||
|
# --- 4e. SSH transport (last resort) --------------------------------------------
|
||||||
|
# Runs a command on the robot's own Linux computer. {text} is shell-quoted.
|
||||||
|
# A3_SSH_SPEAK_COMMAND=<the command that makes your robot speak> {text}
|
||||||
|
A3_SSH_USER=root
|
||||||
|
A3_SSH_PORT=22
|
||||||
|
A3_SSH_KEY_PATH=
|
||||||
|
A3_SSH_PASSWORD=
|
||||||
|
A3_SSH_SPEAK_COMMAND=
|
||||||
|
A3_SSH_STOP_COMMAND=
|
||||||
|
A3_SSH_PROBE_COMMAND=true
|
||||||
|
|
||||||
|
|
||||||
|
# --- 4f. Optional TTS parameters (sent only when set) -------------------------
|
||||||
|
A3_VOICE=
|
||||||
|
A3_LANGUAGE=
|
||||||
|
A3_VOLUME=
|
||||||
|
A3_SPEED=
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 5. SPEECH BEHAVIOUR
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
SPEECH_MAX_LENGTH=1000
|
||||||
|
SPEECH_MIN_LENGTH=1
|
||||||
|
# true = pressing Speak while talking interrupts and says the new text
|
||||||
|
# false = pressing Speak while talking is rejected with "already speaking"
|
||||||
|
SPEECH_ALLOW_INTERRUPT=true
|
||||||
|
SPEECH_HISTORY_LIMIT=100
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 6. MOCK ROBOT (ROBOT_MODE=mock only)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
MOCK_CONNECT_DELAY_MS=350
|
||||||
|
MOCK_NETWORK_LATENCY_MS=45
|
||||||
|
MOCK_PROCESSING_MS=180
|
||||||
|
MOCK_WORDS_PER_MINUTE=150
|
||||||
|
|
||||||
|
# Set to 0.2 to make 1 in 5 utterances fail, for testing error handling.
|
||||||
|
MOCK_FAILURE_RATE=0.0
|
||||||
|
# Set to true to make the simulated link drop occasionally, for testing reconnect.
|
||||||
|
MOCK_FLAKY_CONNECTION=false
|
||||||
|
# --- PC speaker playback (simulator only) ------------------------------------
|
||||||
|
# true = the simulator actually SPEAKS the text through this laptop's speakers,
|
||||||
|
# so you can rehearse a demo before the robot is on the network.
|
||||||
|
# When this is on, the real audio drives the UI: "Completed" appears exactly when
|
||||||
|
# the sound stops, and Stop cuts the voice mid-word.
|
||||||
|
#
|
||||||
|
# Windows uses the built-in SAPI voices - nothing to install.
|
||||||
|
# macOS uses `say`. Linux needs: sudo apt install espeak-ng
|
||||||
|
MOCK_LOCAL_AUDIO=true
|
||||||
|
|
||||||
|
# Which PC voice to use (matched against the voice name, case-insensitive).
|
||||||
|
# python scripts/voices.py list what is installed
|
||||||
|
# python scripts/voices.py --demo hear each one
|
||||||
|
#
|
||||||
|
# NOTE: this is the SIMULATOR's voice, not the robot's - the real A3 synthesises
|
||||||
|
# speech on-board. The robot's configured voice is "Yunxiao": a MALE TEENAGER,
|
||||||
|
# multi-language. Windows ships no teenage voice, so the closest approximation is
|
||||||
|
# its lighter adult male voice (Mark) pitched up a little. It is a stand-in, not
|
||||||
|
# a match. Run `python scripts/voices.py --demo` to hear the alternatives.
|
||||||
|
MOCK_VOICE=Mark
|
||||||
|
|
||||||
|
# Speaking rate, -10 (slowest) to 10 (fastest). Try -1 or -2 for a noisy room.
|
||||||
|
MOCK_SPEECH_RATE=0
|
||||||
|
|
||||||
|
# Volume, 0-100.
|
||||||
|
MOCK_SPEECH_VOLUME=100
|
||||||
|
|
||||||
|
# Pitch, -10 (deepest) to 10 (highest). Left at 0: raising it makes the voice
|
||||||
|
# sound affected rather than natural, which is the opposite of what a service
|
||||||
|
# robot should sound like.
|
||||||
|
MOCK_SPEECH_PITCH=0
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 7. GEMINI CLOUD VOICE (simulator only - nothing here touches the real robot)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The built-in Windows voice is instant but robotic. Gemini gives the simulator a
|
||||||
|
# natural neural voice, at the cost of a network round trip.
|
||||||
|
#
|
||||||
|
# system = built-in OS voice - instant, robotic
|
||||||
|
# gemini = Gemini neural voice - natural, ~4s to synthesise new text
|
||||||
|
MOCK_VOICE_ENGINE=system
|
||||||
|
|
||||||
|
# Get a key at https://aistudio.google.com/apikey
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
|
GEMINI_TTS_MODEL=gemini-3.1-flash-tts-preview
|
||||||
|
|
||||||
|
# Prebuilt voice. Iapetus ("Clear") is the default: professional and straight,
|
||||||
|
# which is how a tour-guide robot actually speaks. Charon ("Informative") is the
|
||||||
|
# same register but deeper and more adult - swap it in if you prefer that.
|
||||||
|
# Puck / Fenrir read younger but bouncier. Hear them all:
|
||||||
|
# python scripts/voices.py
|
||||||
|
GEMINI_VOICE=Iapetus
|
||||||
|
|
||||||
|
# Optional acting direction. LEAVE THIS EMPTY. Anything here makes the model
|
||||||
|
# *perform* the line instead of simply saying it, which sounds theatrical and
|
||||||
|
# fake for a service robot. Only set it for a deliberate stage character.
|
||||||
|
GEMINI_TTS_STYLE=
|
||||||
|
|
||||||
|
# 0 = never split. Every line is spoken as ONE clip, in one continuous take.
|
||||||
|
# Set a character count (e.g. 280) only if you want very long paragraphs to
|
||||||
|
# start speaking before the whole thing is synthesised - the pieces are then
|
||||||
|
# synthesised separately, and the join between them can be audible.
|
||||||
|
GEMINI_CHUNK_CHARS=0
|
||||||
|
|
||||||
|
# LATENCY: new text takes ~4s (short) to ~8s (paragraph) to synthesise. Audio is
|
||||||
|
# cached on disk in .voice-cache/, so a repeated line is INSTANT and stays
|
||||||
|
# instant across restarts. Before a live demo, warm your lines:
|
||||||
|
# python scripts/warm_voice.py --file demo_lines.txt
|
||||||
|
# If Gemini is unreachable the simulator falls back to the built-in voice, so a
|
||||||
|
# network problem never leaves you with silence.
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Local configuration - contains the robot address and any credentials.
|
||||||
|
.env
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
|
||||||
|
# Saved speech audio (regenerated on demand).
|
||||||
|
audio_library/
|
||||||
392
README.md
Normal file
392
README.md
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
# AGIBOT A3 · Voice Control
|
||||||
|
|
||||||
|
A local web dashboard for making an **AGIBOT A3** humanoid speak typed text through
|
||||||
|
its own built-in speaker.
|
||||||
|
|
||||||
|
```
|
||||||
|
PC Browser → Local Web App → Robot Service Layer → A3 Adapter → AGIBOT A3 → Speaker
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs today, with **no robot attached**, against a built-in simulator. When the
|
||||||
|
robot arrives you change the IP in one file and switch one setting.
|
||||||
|
|
||||||
|
**The A3's speech interface is already implemented.** AgiBot publicly documents a
|
||||||
|
native text-to-speech HTTP endpoint on the robot, and this project speaks it:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST http://<robot>:59301/rpc/aimdk.protocol.TTSService/PlayTTS
|
||||||
|
Content-Type: application/json
|
||||||
|
{"text": "...", "priority_level": "INTERACTION_L6", "domain": "voice_control",
|
||||||
|
"trace_id": "...", "is_interrupted": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Text goes in, the robot's own TTS comes out of its own speaker. **No audio is
|
||||||
|
generated or transferred by the PC**, which is what makes it fast. Full detail,
|
||||||
|
sources and open questions: **[docs/AGIBOT_A3_INTEGRATION.md](docs/AGIBOT_A3_INTEGRATION.md)**.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bat
|
||||||
|
start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
…then open **http://localhost:8000**.
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python backend/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The app starts in **Mock Mode**: the dashboard, the API, the status pipeline, the
|
||||||
|
history and every error path work exactly as they will with the real robot — the
|
||||||
|
robot itself is simulated.
|
||||||
|
|
||||||
|
**You will hear it.** The simulator speaks each utterance through this PC's
|
||||||
|
speakers, so you can rehearse a demo — wording, pacing, the Stop button — before
|
||||||
|
the A3 is on the network. The real audio drives the UI: *Completed* appears when
|
||||||
|
the sound actually stops, and **Stop** cuts the voice mid-word.
|
||||||
|
|
||||||
|
Two voice engines, set by `MOCK_VOICE_ENGINE` in `.env`:
|
||||||
|
|
||||||
|
| | `system` | `gemini` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sound | robotic, built-in OS voice | **natural neural voice** |
|
||||||
|
| New text | instant | ~4 s (sentence) – 8 s (paragraph) |
|
||||||
|
| Repeated text | instant | **instant** — cached on disk |
|
||||||
|
| Needs | nothing | `GEMINI_API_KEY` + internet |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/voices.py # list both engines' voices
|
||||||
|
python scripts/voices.py --demo # hear the system ones
|
||||||
|
```
|
||||||
|
|
||||||
|
### Saved audio
|
||||||
|
|
||||||
|
Every line spoken with the neural voice is saved to **`audio_library/`** as an
|
||||||
|
ordinary `.wav` named after its text:
|
||||||
|
|
||||||
|
```
|
||||||
|
audio_library/
|
||||||
|
welcome-to-our-showroom-78e24feb.wav
|
||||||
|
please-follow-me-to-the-first-exhibit-653c14ff.wav
|
||||||
|
index.json
|
||||||
|
```
|
||||||
|
|
||||||
|
That means a line spoken once **replays instantly** — no synthesis, no network,
|
||||||
|
no wait — and the files are yours: play them in any media player, drop them into
|
||||||
|
a video edit, or put them on a stand's playlist.
|
||||||
|
|
||||||
|
The dashboard's **Saved audio** panel lists them with play and download buttons,
|
||||||
|
and history rows that already have audio get a ▶ for instant replay.
|
||||||
|
|
||||||
|
**Before a live demo, warm your lines** so nothing waits on synthesis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/warm_voice.py --file demo_lines.txt
|
||||||
|
python scripts/warm_voice.py --stats
|
||||||
|
```
|
||||||
|
|
||||||
|
If Gemini is unreachable — no network, bad key, quota — the simulator silently
|
||||||
|
falls back to the built-in voice, so a network problem never leaves you with
|
||||||
|
silence.
|
||||||
|
|
||||||
|
Pick one with `MOCK_VOICE=` in `.env`; silence it entirely with
|
||||||
|
`MOCK_LOCAL_AUDIO=false`.
|
||||||
|
|
||||||
|
This is the *simulator's* voice — the real A3 synthesises its own speech
|
||||||
|
on-board. The robot's configured voice is **"Yunxiao"** (teenager, male,
|
||||||
|
multi-language). Windows ships no teenage voice, so the default here is its
|
||||||
|
lighter adult male voice (**Mark**) pitched up (`MOCK_SPEECH_PITCH=3`) to read
|
||||||
|
younger. It is an approximation for rehearsal, not a match.
|
||||||
|
|
||||||
|
Verify the whole stack end to end at any time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/selftest.py # 31 checks: REST, WebSocket, speak, stop, errors, history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing the real robot path before the robot exists
|
||||||
|
|
||||||
|
Mock mode tests the *application*. To exercise the actual A3 wire protocol —
|
||||||
|
the URL, the JSON body, the `trace_id` round trip, byte-limit chunking, Stop —
|
||||||
|
run the local stand-in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fake_a3_server.py # implements the documented A3 contract on :59301
|
||||||
|
```
|
||||||
|
|
||||||
|
then set `ROBOT_MODE=real`, `ROBOT_IP=127.0.0.1` in `.env`. Every utterance
|
||||||
|
prints in the stand-in's console. This project's `aimdk` transport passes all 31
|
||||||
|
checks against it.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Using the dashboard
|
||||||
|
|
||||||
|
| Action | How |
|
||||||
|
| --- | --- |
|
||||||
|
| Speak | Type, then click **Speak** or press **Ctrl + Enter** |
|
||||||
|
| Stop | **Stop** button or **Esc** |
|
||||||
|
| Clear the box | **Clear** button |
|
||||||
|
| Repeat something | Click any entry in **History** — it goes back in the box |
|
||||||
|
| Reconnect | The ↻ button in the header |
|
||||||
|
|
||||||
|
`Enter` inserts a newline and does **not** send — a half-typed sentence should
|
||||||
|
never reach the robot's speaker mid-demo.
|
||||||
|
|
||||||
|
The header always states the truth about the connection: green *Robot Connected*,
|
||||||
|
amber *Connecting…*, red *Robot Disconnected*, plus the measured round-trip
|
||||||
|
latency to the robot.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
A3_text_to_speach/
|
||||||
|
│
|
||||||
|
├── frontend/ no build step - plain ES modules, served by the backend
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── styles/main.css
|
||||||
|
│ └── js/
|
||||||
|
│ ├── app.js wiring + speech lifecycle
|
||||||
|
│ ├── api.js REST client
|
||||||
|
│ ├── socket.js WebSocket client (auto-reconnect, RTT ping)
|
||||||
|
│ └── ui.js all DOM rendering
|
||||||
|
│
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py entry point: app factory, static serving, error mapping
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── routes.py REST endpoints
|
||||||
|
│ │ ├── websocket.py live status/lifecycle channel
|
||||||
|
│ │ └── schemas.py
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── speech_service.py validation, single-flight, interrupt, latency
|
||||||
|
│ │ └── history.py
|
||||||
|
│ ├── robot/
|
||||||
|
│ │ ├── base.py ← the only contract the app depends on
|
||||||
|
│ │ ├── factory.py mode → adapter
|
||||||
|
│ │ ├── manager.py connect / health-poll / backoff reconnect
|
||||||
|
│ │ ├── mock_robot.py the simulator
|
||||||
|
│ │ ├── agibot_a3.py ← THE INTEGRATION POINT
|
||||||
|
│ │ └── transports/
|
||||||
|
│ │ ├── aimdk_transport.py ← AgiBot's documented A3 speech RPC (default)
|
||||||
|
│ │ └── http · ws · ros2 · ssh fallbacks, fully config-driven
|
||||||
|
│ ├── core/ event bus, text utils, logging
|
||||||
|
│ └── config/settings.py every tunable, loaded from .env
|
||||||
|
│
|
||||||
|
├── docs/
|
||||||
|
│ ├── AGIBOT_A3_INTEGRATION.md how A3 speech works + what to fill in
|
||||||
|
│ └── NETWORK.md PC ↔ robot networking and troubleshooting
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│ ├── voices.py list / audition the simulator's voices
|
||||||
|
│ ├── warm_voice.py pre-synthesise demo lines (instant playback)
|
||||||
|
│ ├── selftest.py end-to-end test (31 checks)
|
||||||
|
│ ├── discover_robot.py probe a robot IP for its speech interface
|
||||||
|
│ └── fake_a3_server.py local stand-in for the A3's RPC, to test the real path
|
||||||
|
│
|
||||||
|
├── audio_library/ saved .wav files + index (instant replay)
|
||||||
|
├── pronunciation.json how the simulator says tricky names
|
||||||
|
├── .env ← YOUR CONFIGURATION (robot IP goes here)
|
||||||
|
├── .env.example documented template
|
||||||
|
├── requirements.txt
|
||||||
|
└── start.bat / start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why it is layered this way
|
||||||
|
|
||||||
|
Each arrow is a seam you can replace without touching the others:
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
│ REST for commands, WebSocket for state
|
||||||
|
Backend API (api/)
|
||||||
|
│ domain objects only - no HTTP, no sockets
|
||||||
|
Speech Service (services/)
|
||||||
|
│ RobotAdapter interface
|
||||||
|
Robot Adapter (robot/mock_robot.py | robot/agibot_a3.py)
|
||||||
|
│ SpeechTransport interface
|
||||||
|
Transport (robot/transports/*)
|
||||||
|
│
|
||||||
|
AGIBOT A3
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing above `robot/` knows a robot SDK exists. Adding an AgiBot X2, a second
|
||||||
|
robot, or a different TTS engine means writing one adapter and adding one line to
|
||||||
|
`factory.py` — the UI, the API and the service layer are untouched.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Everything lives in **`.env`** (created from `.env.example` on first run). The
|
||||||
|
source code contains no IP address, port or endpoint.
|
||||||
|
|
||||||
|
```env
|
||||||
|
ROBOT_MODE=mock # mock | real ← the switch
|
||||||
|
ROBOT_IP= # ← the robot's IP goes here
|
||||||
|
ROBOT_PORT=59301 # AgiBot's documented A3 TTS RPC port
|
||||||
|
A3_TRANSPORT=aimdk # aimdk | http | ws | ros2 | ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
After editing `.env`, either restart the server or call:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/config/reload
|
||||||
|
```
|
||||||
|
|
||||||
|
which rebuilds the robot adapter in place — no restart, no code change.
|
||||||
|
|
||||||
|
### Mock-mode knobs worth knowing
|
||||||
|
|
||||||
|
| Setting | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| `MOCK_FAILURE_RATE=0.3` | 3 in 10 utterances fail — exercises the error UI |
|
||||||
|
| `MOCK_FLAKY_CONNECTION=true` | the link drops periodically — exercises reconnect |
|
||||||
|
| `MOCK_LOCAL_AUDIO=true` | **on by default** — actually speaks through this PC's speakers |
|
||||||
|
| `MOCK_VOICE_ENGINE=gemini` | natural neural voice instead of the robotic built-in one |
|
||||||
|
| `GEMINI_VOICE=Puck` | which neural voice (Puck/Fenrir read youngest + male) |
|
||||||
|
| `MOCK_VOICE=Mark` | which *system* voice to use, when engine is `system` |
|
||||||
|
| `MOCK_SPEECH_PITCH=3` | raise the pitch — approximates the robot's young male voice |
|
||||||
|
| `MOCK_SPEECH_RATE=-2` | slow the simulator down for a noisy room (−10…10) |
|
||||||
|
| `SPEECH_ALLOW_INTERRUPT=false` | a second Speak while talking is rejected instead of interrupting |
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/health` | web app liveness (always 200, even with the robot offline) |
|
||||||
|
| `GET` | `/api/robot/status` | connection state, latency, uptime, config problems |
|
||||||
|
| `POST` | `/api/robot/speak` | `{"text": "..."}` → speak it |
|
||||||
|
| `POST` | `/api/robot/stop` | interrupt the current utterance |
|
||||||
|
| `POST` | `/api/robot/reconnect` | retry the connection immediately |
|
||||||
|
| `GET` | `/api/robot/diagnostics` | adapter + transport detail, for troubleshooting |
|
||||||
|
| `GET` | `/api/speech/history` | recent utterances |
|
||||||
|
| `DELETE` | `/api/speech/history` | clear it |
|
||||||
|
| `GET` | `/api/audio` | saved clips + library stats |
|
||||||
|
| `GET` | `/api/audio/{id}/file` | the `.wav` itself (play or download) |
|
||||||
|
| `DELETE` | `/api/audio/{id}` | delete one clip |
|
||||||
|
| `DELETE` | `/api/audio` | delete all saved audio |
|
||||||
|
| `GET` | `/api/config` | non-secret configuration the UI needs |
|
||||||
|
| `POST` | `/api/config/reload` | re-read `.env` and rebuild the adapter |
|
||||||
|
| `WS` | `/ws` | live status, speech lifecycle, history updates |
|
||||||
|
|
||||||
|
Interactive docs: **http://localhost:8000/api/docs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/robot/speak \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"text\": \"Hello, welcome to our showroom.\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true, "status": "processing",
|
||||||
|
"requestId": "3c7cfe41cf7d", "ackLatencyMs": 56 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**`/api/robot/speak` returns when the robot has *accepted* the utterance, not when
|
||||||
|
it stops talking.** A 12-second sentence must not look like a 12-second-slow
|
||||||
|
button. The rest of the lifecycle — `speaking`, `completed`, failures — arrives on
|
||||||
|
the WebSocket.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## How the speed is achieved
|
||||||
|
|
||||||
|
| Decision | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| Text is sent to the robot; audio is never generated on the PC | no synthesis, no file, no upload — one small HTTP request |
|
||||||
|
| One persistent connection, opened at startup and reused | no TCP/TLS handshake per utterance |
|
||||||
|
| HTTP response returns at acknowledgement | button feels instant; speaking progress streams separately |
|
||||||
|
| WebSocket push, zero polling | the UI changes the moment the robot does |
|
||||||
|
| Health probe is separate from the speech path | a slow health check can never delay a Speak |
|
||||||
|
| Short connect timeout (3 s), long request timeout (8 s) | an offline robot fails fast; a busy one is not cut off |
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Situation | What you see |
|
||||||
|
| --- | --- |
|
||||||
|
| Robot offline | `Robot is offline. Check the robot IP address and network connection.` |
|
||||||
|
| `ROBOT_IP` empty while `ROBOT_MODE=real` | a configuration banner naming the exact `.env` key |
|
||||||
|
| Invalid IP / hostname | configuration error before any connection is attempted |
|
||||||
|
| Connection timeout | fails after 3 s, UI stays responsive, retry continues in the background |
|
||||||
|
| TTS failure | `Speech request failed.` + the robot's own reason in the server log |
|
||||||
|
| Empty text | Speak is disabled; the API returns `400` |
|
||||||
|
| Text too long | rejected with the actual limit named |
|
||||||
|
| Disconnect mid-utterance | status flips immediately and the utterance is marked failed |
|
||||||
|
| Backend stopped | the page says so rather than showing a stale green light |
|
||||||
|
|
||||||
|
The robot being unreachable is treated as normal, not exceptional: the server
|
||||||
|
keeps serving the dashboard and retries with capped exponential backoff.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The browser never talks to the robot.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → local backend (127.0.0.1) → robot
|
||||||
|
```
|
||||||
|
|
||||||
|
- The server binds to `127.0.0.1` by default — nothing is exposed to the LAN.
|
||||||
|
- Robot credentials stay in `.env` on the PC; `/api/config` returns a filtered
|
||||||
|
view with no tokens, passwords or key paths.
|
||||||
|
- No robot port is proxied or forwarded to the page.
|
||||||
|
|
||||||
|
Set `HOST=0.0.0.0` only if you deliberately want to open the dashboard from a
|
||||||
|
tablet on the same network.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Connecting the real robot
|
||||||
|
|
||||||
|
Full procedure: **[docs/AGIBOT_A3_INTEGRATION.md](docs/AGIBOT_A3_INTEGRATION.md)**
|
||||||
|
· Networking and troubleshooting: **[docs/NETWORK.md](docs/NETWORK.md)**
|
||||||
|
|
||||||
|
Short version, once you have the robot's IP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ping 192.168.1.50 # 1. is it reachable
|
||||||
|
python scripts/discover_robot.py 192.168.1.50 # 2. what does it expose
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 3. the decisive test - this makes the robot talk
|
||||||
|
python scripts/discover_robot.py 192.168.1.50 --speak "Hello, I am Expedition A3"
|
||||||
|
```
|
||||||
|
|
||||||
|
then in `.env` — **three lines, no code changes**:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ROBOT_MODE=real
|
||||||
|
ROBOT_IP=192.168.1.50
|
||||||
|
ROBOT_PORT=59301
|
||||||
|
```
|
||||||
|
|
||||||
|
and restart (or `curl -X POST http://localhost:8000/api/config/reload`).
|
||||||
|
|
||||||
|
> Do **not** use `10.42.10.10`. It appears throughout AgiBot's examples but is
|
||||||
|
> the robot's *internal* address — your PC cannot reach it. Use the head unit's
|
||||||
|
> address on your own network.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Python 3.9+** (developed and tested on 3.11)
|
||||||
|
- Windows, macOS or Linux
|
||||||
|
- A browser from the last few years
|
||||||
|
|
||||||
|
Runtime dependencies: `fastapi`, `uvicorn`, `python-dotenv`, `pydantic`, `httpx`,
|
||||||
|
`websockets`. Optional: `pyttsx3` (mock audio), `paramiko` (SSH password auth),
|
||||||
|
`rclpy` (ROS 2 transport — comes from a ROS 2 install, not pip).
|
||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
172
backend/api/routes.py
Normal file
172
backend/api/routes.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
"""HTTP API.
|
||||||
|
|
||||||
|
The browser talks only to this backend; the backend talks to the robot. No robot
|
||||||
|
address, port or credential ever reaches the page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from ..config.settings import reload_settings
|
||||||
|
from ..core.events import EventType
|
||||||
|
from ..robot.manager import RobotManager
|
||||||
|
from ..services.audio_library import get_audio_library
|
||||||
|
from ..services.speech_service import SpeechService
|
||||||
|
from .schemas import SimpleResponse, SpeakRequest, SpeakResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["robot"])
|
||||||
|
|
||||||
|
STARTED_AT = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(request: Request) -> RobotManager:
|
||||||
|
return request.app.state.robot_manager
|
||||||
|
|
||||||
|
|
||||||
|
def _speech(request: Request) -> SpeechService:
|
||||||
|
return request.app.state.speech_service
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# health / config
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/health")
|
||||||
|
async def health(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Liveness of the *web app* - always 200, even with the robot offline."""
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"uptimeSeconds": round(time.time() - STARTED_AT, 1),
|
||||||
|
"python": platform.python_version(),
|
||||||
|
"mode": request.app.state.settings.robot.mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_config(request: Request) -> Dict[str, Any]:
|
||||||
|
return request.app.state.settings.public_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/reload")
|
||||||
|
async def post_config_reload(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Re-read .env and rebuild the robot adapter without restarting the server.
|
||||||
|
|
||||||
|
This is what you call after pasting the robot's IP into .env.
|
||||||
|
"""
|
||||||
|
settings = reload_settings()
|
||||||
|
request.app.state.settings = settings
|
||||||
|
_speech(request).update_settings(settings)
|
||||||
|
await _manager(request).rebuild(settings)
|
||||||
|
request.app.state.event_bus.publish(EventType.CONFIG_UPDATED, settings.public_dict())
|
||||||
|
logger.info("configuration reloaded: mode=%s address=%s",
|
||||||
|
settings.robot.mode, settings.robot.address)
|
||||||
|
return {"success": True, "config": settings.public_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# robot
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/robot/status")
|
||||||
|
async def robot_status(request: Request) -> Dict[str, Any]:
|
||||||
|
status = _manager(request).status_dict()
|
||||||
|
status["busy"] = _speech(request).is_busy
|
||||||
|
status["activeRequestId"] = _speech(request).active_request_id
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/robot/diagnostics")
|
||||||
|
async def robot_diagnostics(request: Request) -> Dict[str, Any]:
|
||||||
|
manager = _manager(request)
|
||||||
|
detail = await manager.adapter.describe()
|
||||||
|
return {
|
||||||
|
"status": manager.status_dict(),
|
||||||
|
"adapter": detail,
|
||||||
|
"config": request.app.state.settings.public_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/robot/speak", response_model=SpeakResponse)
|
||||||
|
async def robot_speak(payload: SpeakRequest, request: Request) -> Dict[str, Any]:
|
||||||
|
"""Send text to the robot.
|
||||||
|
|
||||||
|
Returns once the robot has *acknowledged* the utterance. Speaking progress and
|
||||||
|
completion arrive over the WebSocket at /ws.
|
||||||
|
"""
|
||||||
|
return await _speech(request).speak(payload.text, payload.voice, payload.language)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/robot/stop", response_model=SimpleResponse)
|
||||||
|
async def robot_stop(request: Request) -> Dict[str, Any]:
|
||||||
|
return await _speech(request).stop()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/robot/reconnect")
|
||||||
|
async def robot_reconnect(request: Request) -> Dict[str, Any]:
|
||||||
|
manager = _manager(request)
|
||||||
|
manager.request_reconnect()
|
||||||
|
return {"success": True, "status": manager.state.value}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# history
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/speech/history")
|
||||||
|
async def speech_history(request: Request) -> Dict[str, Any]:
|
||||||
|
service = _speech(request)
|
||||||
|
items = service.annotate_saved(service.history.list())
|
||||||
|
return {"items": items, "count": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/speech/history")
|
||||||
|
async def speech_history_delete(request: Request) -> Dict[str, Any]:
|
||||||
|
return _speech(request).clear_history()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/speech/history/clear")
|
||||||
|
async def speech_history_clear(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Same as DELETE - kept because it is easier to call from a plain form/curl."""
|
||||||
|
return _speech(request).clear_history()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# saved audio
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/audio")
|
||||||
|
async def audio_list() -> Dict[str, Any]:
|
||||||
|
"""Every line that has been synthesised and saved as a .wav."""
|
||||||
|
library = get_audio_library()
|
||||||
|
return {"items": library.list(), "stats": library.stats()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audio/{audio_id}/file")
|
||||||
|
async def audio_file(audio_id: str):
|
||||||
|
"""The .wav itself - playable in the browser, or downloadable."""
|
||||||
|
entry = get_audio_library().get(audio_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No saved audio with that id.")
|
||||||
|
path = get_audio_library().root / entry["file"]
|
||||||
|
return FileResponse(
|
||||||
|
str(path), media_type="audio/wav", filename=entry["file"],
|
||||||
|
headers={"Cache-Control": "public, max-age=31536000"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/audio/{audio_id}")
|
||||||
|
async def audio_delete(audio_id: str) -> Dict[str, Any]:
|
||||||
|
if not get_audio_library().delete(audio_id):
|
||||||
|
raise HTTPException(status_code=404, detail="No saved audio with that id.")
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/audio")
|
||||||
|
async def audio_clear() -> Dict[str, Any]:
|
||||||
|
"""Delete every saved clip. They are re-created on demand."""
|
||||||
|
return {"success": True, "removed": get_audio_library().clear()}
|
||||||
32
backend/api/schemas.py
Normal file
32
backend/api/schemas.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""Request/response models for the HTTP API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakRequest(BaseModel):
|
||||||
|
text: str = Field(..., description="What the robot should say.")
|
||||||
|
voice: Optional[str] = Field(None, description="Optional voice id, if the robot supports it.")
|
||||||
|
language: Optional[str] = Field(None, description="Optional language/locale hint.")
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
status: str
|
||||||
|
requestId: str
|
||||||
|
text: str
|
||||||
|
ackLatencyMs: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
success: bool = False
|
||||||
|
error: str
|
||||||
|
errorCode: str
|
||||||
114
backend/api/websocket.py
Normal file
114
backend/api/websocket.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
"""Real-time channel to the dashboard.
|
||||||
|
|
||||||
|
One WebSocket replaces all polling: connection state, speech lifecycle, errors and
|
||||||
|
history updates are pushed the moment they happen. The socket also answers
|
||||||
|
client pings so the UI can display an honest browser->backend round-trip time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from ..core.events import Event, EventBus, EventType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ws_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@ws_router.websocket("/ws")
|
||||||
|
async def dashboard_socket(websocket: WebSocket) -> None:
|
||||||
|
await websocket.accept()
|
||||||
|
app = websocket.app
|
||||||
|
bus: EventBus = app.state.event_bus
|
||||||
|
manager = app.state.robot_manager
|
||||||
|
speech = app.state.speech_service
|
||||||
|
settings = app.state.settings
|
||||||
|
|
||||||
|
subscription = bus.subscribe(name="ws")
|
||||||
|
logger.debug("dashboard socket opened (subscribers=%d)", bus.subscriber_count)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prime the page with everything it needs for a correct first paint.
|
||||||
|
await _send(
|
||||||
|
websocket,
|
||||||
|
{
|
||||||
|
"type": "hello",
|
||||||
|
"data": {
|
||||||
|
"config": settings.public_dict(),
|
||||||
|
"status": _status(manager, speech),
|
||||||
|
"history": speech.annotate_saved(speech.history.list()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
pump = asyncio.create_task(
|
||||||
|
_pump(websocket, subscription, speech), name="ws-pump"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
raw = await websocket.receive_text()
|
||||||
|
await _handle_client_message(websocket, raw, manager, speech)
|
||||||
|
finally:
|
||||||
|
pump.cancel()
|
||||||
|
try:
|
||||||
|
await pump
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("dashboard socket closed by client")
|
||||||
|
except Exception as exc: # pragma: no cover - transport level
|
||||||
|
logger.debug("dashboard socket error: %s", exc)
|
||||||
|
finally:
|
||||||
|
subscription.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _pump(websocket: WebSocket, subscription, speech) -> None:
|
||||||
|
"""Forward bus events to this browser until cancelled."""
|
||||||
|
while True:
|
||||||
|
event: Event = await subscription.get()
|
||||||
|
if event is None: # pragma: no cover
|
||||||
|
continue
|
||||||
|
payload = event.to_dict()
|
||||||
|
if event.type == EventType.ROBOT_STATUS:
|
||||||
|
# The robot layer does not know about the speech queue; add it here so
|
||||||
|
# every status the page sees carries a current busy flag.
|
||||||
|
payload["data"] = dict(payload["data"])
|
||||||
|
payload["data"]["busy"] = speech.is_busy
|
||||||
|
payload["data"]["activeRequestId"] = speech.active_request_id
|
||||||
|
await _send(websocket, payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_client_message(websocket: WebSocket, raw: str, manager, speech) -> None:
|
||||||
|
try:
|
||||||
|
message: Dict[str, Any] = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
kind = message.get("type")
|
||||||
|
if kind == "ping":
|
||||||
|
# Echo the client's timestamp so it can compute RTT without clock sync.
|
||||||
|
await _send(websocket, {"type": "pong", "data": {"t": message.get("t")}})
|
||||||
|
elif kind == "status":
|
||||||
|
await _send(
|
||||||
|
websocket, {"type": EventType.ROBOT_STATUS, "data": _status(manager, speech)}
|
||||||
|
)
|
||||||
|
elif kind == "reconnect":
|
||||||
|
manager.request_reconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def _status(manager, speech) -> Dict[str, Any]:
|
||||||
|
status = manager.status_dict()
|
||||||
|
status["busy"] = speech.is_busy
|
||||||
|
status["activeRequestId"] = speech.active_request_id
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
async def _send(websocket: WebSocket, payload: Dict[str, Any]) -> None:
|
||||||
|
await websocket.send_text(json.dumps(payload, ensure_ascii=False, default=str))
|
||||||
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
540
backend/config/settings.py
Normal file
540
backend/config/settings.py
Normal file
@ -0,0 +1,540 @@
|
|||||||
|
"""Typed application configuration, loaded once from the environment / .env file.
|
||||||
|
|
||||||
|
Everything that could differ between "my laptop with no robot" and "the show floor
|
||||||
|
with a live AGIBOT A3" lives here. No module outside this package reads os.environ
|
||||||
|
directly, and no robot address is ever hard-coded in source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
ENV_FILE = PROJECT_ROOT / ".env"
|
||||||
|
|
||||||
|
RobotMode = Literal["mock", "real"]
|
||||||
|
Transport = Literal["aimdk", "http", "ws", "ros2", "ssh"]
|
||||||
|
|
||||||
|
TRANSPORTS = ("aimdk", "http", "ws", "ros2", "ssh")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# env helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _raw(key: str) -> Optional[str]:
|
||||||
|
value = os.environ.get(key)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
# Treat `KEY=` and quoted-empty as "unset" rather than "empty string".
|
||||||
|
if value in ('', '""', "''"):
|
||||||
|
return None
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||||
|
value = value[1:-1]
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def env_str(key: str, default: str = "") -> str:
|
||||||
|
value = _raw(key)
|
||||||
|
return default if value is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def env_opt(key: str) -> Optional[str]:
|
||||||
|
return _raw(key)
|
||||||
|
|
||||||
|
|
||||||
|
def env_int(key: str, default: int) -> int:
|
||||||
|
value = _raw(key)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def env_float(key: str, default: float) -> float:
|
||||||
|
value = _raw(key)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(key: str, default: bool) -> bool:
|
||||||
|
value = _raw(key)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.lower() in ("1", "true", "yes", "on", "y")
|
||||||
|
|
||||||
|
|
||||||
|
def env_json(key: str, default: Any) -> Any:
|
||||||
|
"""Parse a JSON-valued env var; fall back to `default` on malformed input."""
|
||||||
|
value = _raw(key)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def env_list(key: str, default: Optional[List[str]] = None) -> List[str]:
|
||||||
|
value = _raw(key)
|
||||||
|
if value is None:
|
||||||
|
return list(default or [])
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# configuration issues (surfaced in the UI instead of crashing the app)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfigIssue:
|
||||||
|
level: Literal["error", "warning"]
|
||||||
|
key: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, str]:
|
||||||
|
return {"level": self.level, "key": self.key, "message": self.message}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# settings groups
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ServerSettings:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
log_level: str
|
||||||
|
cors_origins: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RobotSettings:
|
||||||
|
"""Vendor-neutral robot connection settings."""
|
||||||
|
|
||||||
|
mode: RobotMode
|
||||||
|
name: str
|
||||||
|
model: str
|
||||||
|
ip: Optional[str]
|
||||||
|
port: int
|
||||||
|
use_tls: bool
|
||||||
|
connect_timeout: float
|
||||||
|
request_timeout: float
|
||||||
|
health_interval: float
|
||||||
|
reconnect_min_delay: float
|
||||||
|
reconnect_max_delay: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheme(self) -> str:
|
||||||
|
return "https" if self.use_tls else "http"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ws_scheme(self) -> str:
|
||||||
|
return "wss" if self.use_tls else "ws"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
return "{0}://{1}:{2}".format(self.scheme, self.ip, self.port)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ws_base_url(self) -> str:
|
||||||
|
return "{0}://{1}:{2}".format(self.ws_scheme, self.ip, self.port)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def address(self) -> str:
|
||||||
|
return "{0}:{1}".format(self.ip, self.port) if self.ip else "(not configured)"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class A3Settings:
|
||||||
|
"""AGIBOT A3 adapter settings.
|
||||||
|
|
||||||
|
IMPORTANT: every endpoint below is *configuration*, never a guess baked into
|
||||||
|
code. See docs/AGIBOT_A3_INTEGRATION.md - once the interface for your unit is
|
||||||
|
confirmed you fill these in and the adapter works unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
transport: Transport
|
||||||
|
|
||||||
|
# -- AimDK transport (AgiBot's documented A3 speech RPC) ------------------
|
||||||
|
# POST http://<robot>:<port>/rpc/<service>/<method>
|
||||||
|
# https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play
|
||||||
|
aimdk_service: str
|
||||||
|
aimdk_play_method: str
|
||||||
|
aimdk_stop_method: str
|
||||||
|
aimdk_status_method: str
|
||||||
|
aimdk_priority: str
|
||||||
|
aimdk_domain: str
|
||||||
|
aimdk_interrupt: bool
|
||||||
|
aimdk_max_bytes: int
|
||||||
|
|
||||||
|
# -- HTTP / REST transport ------------------------------------------------
|
||||||
|
http_speak_path: str
|
||||||
|
http_speak_method: str
|
||||||
|
http_speak_payload: Any
|
||||||
|
http_stop_path: str
|
||||||
|
http_stop_method: str
|
||||||
|
http_stop_payload: Any
|
||||||
|
http_status_path: str
|
||||||
|
http_status_method: str
|
||||||
|
http_headers: Dict[str, str]
|
||||||
|
http_auth_token: Optional[str]
|
||||||
|
http_success_field: Optional[str]
|
||||||
|
|
||||||
|
# -- WebSocket transport --------------------------------------------------
|
||||||
|
ws_path: str
|
||||||
|
ws_speak_payload: Any
|
||||||
|
ws_stop_payload: Any
|
||||||
|
ws_ping_interval: float
|
||||||
|
ws_done_field: Optional[str]
|
||||||
|
ws_done_value: Optional[str]
|
||||||
|
|
||||||
|
# -- ROS 2 transport ------------------------------------------------------
|
||||||
|
ros_domain_id: int
|
||||||
|
ros_speak_topic: str
|
||||||
|
ros_speak_msg_type: str
|
||||||
|
ros_speak_msg_field: str
|
||||||
|
ros_stop_topic: str
|
||||||
|
ros_use_service: bool
|
||||||
|
ros_service_name: str
|
||||||
|
ros_service_type: str
|
||||||
|
|
||||||
|
# -- SSH / on-robot command transport -------------------------------------
|
||||||
|
ssh_user: str
|
||||||
|
ssh_password: Optional[str]
|
||||||
|
ssh_key_path: Optional[str]
|
||||||
|
ssh_port: int
|
||||||
|
ssh_speak_command: str
|
||||||
|
ssh_stop_command: str
|
||||||
|
ssh_probe_command: str
|
||||||
|
|
||||||
|
# -- shared TTS parameters (only sent when set) ---------------------------
|
||||||
|
voice: Optional[str]
|
||||||
|
language: Optional[str]
|
||||||
|
volume: Optional[float]
|
||||||
|
speed: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MockSettings:
|
||||||
|
connect_delay_ms: int
|
||||||
|
network_latency_ms: int
|
||||||
|
processing_ms: int
|
||||||
|
words_per_minute: int
|
||||||
|
failure_rate: float
|
||||||
|
local_audio: bool
|
||||||
|
flaky_connection: bool
|
||||||
|
voice: Optional[str]
|
||||||
|
speech_rate: int
|
||||||
|
speech_volume: int
|
||||||
|
speech_pitch: int
|
||||||
|
voice_engine: str
|
||||||
|
gemini_api_key: Optional[str]
|
||||||
|
gemini_model: str
|
||||||
|
gemini_voice: str
|
||||||
|
gemini_style: str
|
||||||
|
gemini_chunk_chars: int
|
||||||
|
pronunciation: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SpeechSettings:
|
||||||
|
max_length: int
|
||||||
|
min_length: int
|
||||||
|
allow_interrupt: bool
|
||||||
|
history_limit: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
server: ServerSettings
|
||||||
|
robot: RobotSettings
|
||||||
|
a3: A3Settings
|
||||||
|
mock: MockSettings
|
||||||
|
speech: SpeechSettings
|
||||||
|
env_file: str
|
||||||
|
issues: List[ConfigIssue] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_mock(self) -> bool:
|
||||||
|
return self.robot.mode == "mock"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_blocking_issue(self) -> bool:
|
||||||
|
return any(issue.level == "error" for issue in self.issues)
|
||||||
|
|
||||||
|
def public_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Safe-to-expose subset for the browser. Never leaks credentials."""
|
||||||
|
return {
|
||||||
|
"mode": self.robot.mode,
|
||||||
|
"robotName": self.robot.name,
|
||||||
|
"robotModel": self.robot.model,
|
||||||
|
"transport": self.a3.transport if self.robot.mode == "real" else "mock",
|
||||||
|
"address": self.robot.address,
|
||||||
|
"ipConfigured": bool(self.robot.ip),
|
||||||
|
"maxLength": self.speech.max_length,
|
||||||
|
"allowInterrupt": self.speech.allow_interrupt,
|
||||||
|
"historyLimit": self.speech.history_limit,
|
||||||
|
"healthInterval": self.robot.health_interval,
|
||||||
|
"envFile": self.env_file,
|
||||||
|
"issues": [issue.to_dict() for issue in self.issues],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# validation
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _looks_like_host(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(value)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Permissive hostname check - mDNS names such as "agibot-a3.local" are valid.
|
||||||
|
return bool(re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9\-._]{0,251}[A-Za-z0-9])?", value))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(robot: RobotSettings, a3: A3Settings, speech: SpeechSettings) -> List[ConfigIssue]:
|
||||||
|
issues: List[ConfigIssue] = []
|
||||||
|
|
||||||
|
if robot.mode not in ("mock", "real"):
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue("error", "ROBOT_MODE", "Unknown ROBOT_MODE '%s'. Use 'mock' or 'real'." % robot.mode)
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
if robot.mode == "real":
|
||||||
|
if not robot.ip:
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue(
|
||||||
|
"error",
|
||||||
|
"ROBOT_IP",
|
||||||
|
"ROBOT_MODE=real but ROBOT_IP is empty. Set the robot's LAN IP address in .env.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif not _looks_like_host(robot.ip):
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue("error", "ROBOT_IP", "'%s' is not a valid IP address or hostname." % robot.ip)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not 0 < robot.port < 65536:
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue("error", "ROBOT_PORT", "ROBOT_PORT must be 1-65535, got %s." % robot.port)
|
||||||
|
)
|
||||||
|
|
||||||
|
if a3.transport not in TRANSPORTS:
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue(
|
||||||
|
"error",
|
||||||
|
"A3_TRANSPORT",
|
||||||
|
"Unknown A3_TRANSPORT '%s'. Use one of: %s."
|
||||||
|
% (a3.transport, ", ".join(TRANSPORTS)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif a3.transport == "aimdk":
|
||||||
|
if not a3.aimdk_service or not a3.aimdk_play_method:
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue(
|
||||||
|
"error",
|
||||||
|
"A3_AIMDK_SERVICE",
|
||||||
|
"AimDK transport needs a service name and a play method.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if robot.port != 59301:
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue(
|
||||||
|
"warning",
|
||||||
|
"ROBOT_PORT",
|
||||||
|
"AgiBot documents the A3 TTS RPC on port 59301; ROBOT_PORT is %s. "
|
||||||
|
"Confirm the port for your firmware." % robot.port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif a3.transport == "http" and not a3.http_speak_path:
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue(
|
||||||
|
"error",
|
||||||
|
"A3_HTTP_SPEAK_PATH",
|
||||||
|
"HTTP transport selected but no speak endpoint is configured. "
|
||||||
|
"Fill it in from the robot's API reference - see docs/AGIBOT_A3_INTEGRATION.md.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif a3.transport == "ws" and not a3.ws_path:
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue("error", "A3_WS_PATH", "WebSocket transport selected but A3_WS_PATH is empty.")
|
||||||
|
)
|
||||||
|
elif a3.transport == "ros2" and not (a3.ros_speak_topic or a3.ros_service_name):
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue(
|
||||||
|
"error",
|
||||||
|
"A3_ROS_SPEAK_TOPIC",
|
||||||
|
"ROS 2 transport selected but neither a topic nor a service name is configured.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif a3.transport == "ssh":
|
||||||
|
if not a3.ssh_speak_command:
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue(
|
||||||
|
"error",
|
||||||
|
"A3_SSH_SPEAK_COMMAND",
|
||||||
|
"SSH transport selected but A3_SSH_SPEAK_COMMAND is empty.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not a3.ssh_password and not a3.ssh_key_path:
|
||||||
|
issues.append(
|
||||||
|
ConfigIssue(
|
||||||
|
"warning",
|
||||||
|
"A3_SSH_PASSWORD",
|
||||||
|
"No SSH password or key configured; authentication will rely on an ssh agent.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if speech.max_length < 1:
|
||||||
|
issues.append(ConfigIssue("error", "SPEECH_MAX_LENGTH", "SPEECH_MAX_LENGTH must be >= 1."))
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# loading
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def load_settings(env_file: Optional[Path] = None, override: bool = True) -> Settings:
|
||||||
|
"""Read .env + process environment into an immutable Settings object."""
|
||||||
|
path = Path(env_file) if env_file else ENV_FILE
|
||||||
|
if path.exists():
|
||||||
|
load_dotenv(path, override=override)
|
||||||
|
|
||||||
|
server = ServerSettings(
|
||||||
|
host=env_str("HOST", "127.0.0.1"),
|
||||||
|
port=env_int("PORT", 8000),
|
||||||
|
log_level=env_str("LOG_LEVEL", "info").lower(),
|
||||||
|
cors_origins=env_list("CORS_ORIGINS", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
robot = RobotSettings(
|
||||||
|
mode=env_str("ROBOT_MODE", "mock").lower(), # type: ignore[arg-type]
|
||||||
|
name=env_str("ROBOT_NAME", "AGIBOT A3"),
|
||||||
|
model=env_str("ROBOT_MODEL", "AgiBot A3"),
|
||||||
|
ip=env_opt("ROBOT_IP"),
|
||||||
|
port=env_int("ROBOT_PORT", 59301),
|
||||||
|
use_tls=env_bool("ROBOT_USE_TLS", False),
|
||||||
|
connect_timeout=env_float("ROBOT_CONNECT_TIMEOUT", 3.0),
|
||||||
|
request_timeout=env_float("ROBOT_REQUEST_TIMEOUT", 8.0),
|
||||||
|
health_interval=env_float("ROBOT_HEALTH_INTERVAL", 5.0),
|
||||||
|
reconnect_min_delay=env_float("ROBOT_RECONNECT_MIN_DELAY", 1.0),
|
||||||
|
reconnect_max_delay=env_float("ROBOT_RECONNECT_MAX_DELAY", 15.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
a3 = A3Settings(
|
||||||
|
transport=env_str("A3_TRANSPORT", "aimdk").lower(), # type: ignore[arg-type]
|
||||||
|
aimdk_service=env_str("A3_AIMDK_SERVICE", "aimdk.protocol.TTSService"),
|
||||||
|
aimdk_play_method=env_str("A3_AIMDK_PLAY_METHOD", "PlayTTS"),
|
||||||
|
aimdk_stop_method=env_str("A3_AIMDK_STOP_METHOD", "StopTTSTraceId"),
|
||||||
|
aimdk_status_method=env_str("A3_AIMDK_STATUS_METHOD", "GetAudioStatus"),
|
||||||
|
aimdk_priority=env_str("A3_AIMDK_PRIORITY", "INTERACTION_L6"),
|
||||||
|
aimdk_domain=env_str("A3_AIMDK_DOMAIN", "voice_control"),
|
||||||
|
aimdk_interrupt=env_bool("A3_AIMDK_INTERRUPT", True),
|
||||||
|
aimdk_max_bytes=env_int("A3_AIMDK_MAX_BYTES", 1024),
|
||||||
|
http_speak_path=env_str("A3_HTTP_SPEAK_PATH", ""),
|
||||||
|
http_speak_method=env_str("A3_HTTP_SPEAK_METHOD", "POST").upper(),
|
||||||
|
http_speak_payload=env_json("A3_HTTP_SPEAK_PAYLOAD", {"text": "{text}"}),
|
||||||
|
http_stop_path=env_str("A3_HTTP_STOP_PATH", ""),
|
||||||
|
http_stop_method=env_str("A3_HTTP_STOP_METHOD", "POST").upper(),
|
||||||
|
http_stop_payload=env_json("A3_HTTP_STOP_PAYLOAD", {}),
|
||||||
|
http_status_path=env_str("A3_HTTP_STATUS_PATH", ""),
|
||||||
|
http_status_method=env_str("A3_HTTP_STATUS_METHOD", "GET").upper(),
|
||||||
|
http_headers=env_json("A3_HTTP_HEADERS", {}),
|
||||||
|
http_auth_token=env_opt("A3_HTTP_AUTH_TOKEN"),
|
||||||
|
http_success_field=env_opt("A3_HTTP_SUCCESS_FIELD"),
|
||||||
|
ws_path=env_str("A3_WS_PATH", ""),
|
||||||
|
ws_speak_payload=env_json("A3_WS_SPEAK_PAYLOAD", {"text": "{text}"}),
|
||||||
|
ws_stop_payload=env_json("A3_WS_STOP_PAYLOAD", {}),
|
||||||
|
ws_ping_interval=env_float("A3_WS_PING_INTERVAL", 20.0),
|
||||||
|
ws_done_field=env_opt("A3_WS_DONE_FIELD"),
|
||||||
|
ws_done_value=env_opt("A3_WS_DONE_VALUE"),
|
||||||
|
ros_domain_id=env_int("A3_ROS_DOMAIN_ID", 0),
|
||||||
|
ros_speak_topic=env_str("A3_ROS_SPEAK_TOPIC", ""),
|
||||||
|
ros_speak_msg_type=env_str("A3_ROS_SPEAK_MSG_TYPE", "std_msgs/msg/String"),
|
||||||
|
ros_speak_msg_field=env_str("A3_ROS_SPEAK_MSG_FIELD", "data"),
|
||||||
|
ros_stop_topic=env_str("A3_ROS_STOP_TOPIC", ""),
|
||||||
|
ros_use_service=env_bool("A3_ROS_USE_SERVICE", False),
|
||||||
|
ros_service_name=env_str("A3_ROS_SERVICE_NAME", ""),
|
||||||
|
ros_service_type=env_str("A3_ROS_SERVICE_TYPE", ""),
|
||||||
|
ssh_user=env_str("A3_SSH_USER", "root"),
|
||||||
|
ssh_password=env_opt("A3_SSH_PASSWORD"),
|
||||||
|
ssh_key_path=env_opt("A3_SSH_KEY_PATH"),
|
||||||
|
ssh_port=env_int("A3_SSH_PORT", 22),
|
||||||
|
ssh_speak_command=env_str("A3_SSH_SPEAK_COMMAND", ""),
|
||||||
|
ssh_stop_command=env_str("A3_SSH_STOP_COMMAND", ""),
|
||||||
|
ssh_probe_command=env_str("A3_SSH_PROBE_COMMAND", "true"),
|
||||||
|
voice=env_opt("A3_VOICE"),
|
||||||
|
language=env_opt("A3_LANGUAGE"),
|
||||||
|
volume=env_float("A3_VOLUME", 1.0) if env_opt("A3_VOLUME") else None,
|
||||||
|
speed=env_float("A3_SPEED", 1.0) if env_opt("A3_SPEED") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock = MockSettings(
|
||||||
|
connect_delay_ms=env_int("MOCK_CONNECT_DELAY_MS", 350),
|
||||||
|
network_latency_ms=env_int("MOCK_NETWORK_LATENCY_MS", 45),
|
||||||
|
processing_ms=env_int("MOCK_PROCESSING_MS", 180),
|
||||||
|
words_per_minute=env_int("MOCK_WORDS_PER_MINUTE", 150),
|
||||||
|
failure_rate=env_float("MOCK_FAILURE_RATE", 0.0),
|
||||||
|
local_audio=env_bool("MOCK_LOCAL_AUDIO", False),
|
||||||
|
flaky_connection=env_bool("MOCK_FLAKY_CONNECTION", False),
|
||||||
|
voice=env_opt("MOCK_VOICE"),
|
||||||
|
speech_rate=env_int("MOCK_SPEECH_RATE", 0),
|
||||||
|
speech_volume=env_int("MOCK_SPEECH_VOLUME", 100),
|
||||||
|
speech_pitch=env_int("MOCK_SPEECH_PITCH", 0),
|
||||||
|
voice_engine=env_str("MOCK_VOICE_ENGINE", "system").lower(),
|
||||||
|
gemini_api_key=env_opt("GEMINI_API_KEY"),
|
||||||
|
gemini_model=env_str("GEMINI_TTS_MODEL", "gemini-3.1-flash-tts-preview"),
|
||||||
|
gemini_voice=env_str("GEMINI_VOICE", "Puck"),
|
||||||
|
gemini_style=env_str("GEMINI_TTS_STYLE", ""),
|
||||||
|
gemini_chunk_chars=env_int("GEMINI_CHUNK_CHARS", 0),
|
||||||
|
pronunciation=env_bool("SPEECH_PRONUNCIATION", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
speech = SpeechSettings(
|
||||||
|
max_length=env_int("SPEECH_MAX_LENGTH", 1000),
|
||||||
|
min_length=env_int("SPEECH_MIN_LENGTH", 1),
|
||||||
|
allow_interrupt=env_bool("SPEECH_ALLOW_INTERRUPT", True),
|
||||||
|
history_limit=env_int("SPEECH_HISTORY_LIMIT", 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Settings(
|
||||||
|
server=server,
|
||||||
|
robot=robot,
|
||||||
|
a3=a3,
|
||||||
|
mock=mock,
|
||||||
|
speech=speech,
|
||||||
|
env_file=str(path),
|
||||||
|
issues=_validate(robot, a3, speech),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_settings: Optional[Settings] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
global _settings
|
||||||
|
if _settings is None:
|
||||||
|
_settings = load_settings()
|
||||||
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
|
def reload_settings() -> Settings:
|
||||||
|
"""Re-read .env from disk (used by the /api/config/reload endpoint)."""
|
||||||
|
global _settings
|
||||||
|
_settings = load_settings(override=True)
|
||||||
|
return _settings
|
||||||
0
backend/core/__init__.py
Normal file
0
backend/core/__init__.py
Normal file
125
backend/core/events.py
Normal file
125
backend/core/events.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""A tiny in-process publish/subscribe bus.
|
||||||
|
|
||||||
|
The robot layer publishes; the WebSocket layer subscribes and forwards to the
|
||||||
|
browser. Keeping this in the middle means the robot adapters never know that a
|
||||||
|
browser exists, and the API layer never polls the robot.
|
||||||
|
|
||||||
|
Delivery is best-effort and non-blocking: a slow or wedged browser tab can never
|
||||||
|
stall the robot loop. If a subscriber's queue overflows we drop its oldest event
|
||||||
|
and mark the stream as lossy rather than applying backpressure upstream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_QUEUE = 256
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(str):
|
||||||
|
"""String constants - kept as plain strings so they serialise for free."""
|
||||||
|
|
||||||
|
ROBOT_STATUS = "robot.status"
|
||||||
|
SPEECH_PROGRESS = "speech.progress"
|
||||||
|
SPEECH_RESULT = "speech.result"
|
||||||
|
HISTORY_UPDATED = "history.updated"
|
||||||
|
CONFIG_UPDATED = "config.updated"
|
||||||
|
NOTICE = "notice"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Event:
|
||||||
|
type: str
|
||||||
|
data: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {"type": self.type, "data": self.data, "at": self.at}
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription:
|
||||||
|
"""An async iterator over bus events, scoped to one subscriber."""
|
||||||
|
|
||||||
|
def __init__(self, bus: "EventBus", name: str) -> None:
|
||||||
|
self._bus = bus
|
||||||
|
self._queue: asyncio.Queue = asyncio.Queue(maxsize=MAX_QUEUE)
|
||||||
|
self.name = name
|
||||||
|
self.dropped = 0
|
||||||
|
|
||||||
|
def _offer(self, event: Event) -> None:
|
||||||
|
try:
|
||||||
|
self._queue.put_nowait(event)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
self.dropped += 1
|
||||||
|
try: # make room, keep the newest - stale status is worse than none
|
||||||
|
self._queue.get_nowait()
|
||||||
|
self._queue.put_nowait(event)
|
||||||
|
except (asyncio.QueueEmpty, asyncio.QueueFull): # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get(self, timeout: Optional[float] = None) -> Optional[Event]:
|
||||||
|
if timeout is None:
|
||||||
|
return await self._queue.get()
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(self._queue.get(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __enter__(self) -> "Subscription":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc: Any) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._bus.unsubscribe(self)
|
||||||
|
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._subscribers: List[Subscription] = []
|
||||||
|
self._last: Dict[str, Event] = {}
|
||||||
|
|
||||||
|
# -- subscription -------------------------------------------------------- #
|
||||||
|
def subscribe(self, name: str = "anonymous") -> Subscription:
|
||||||
|
sub = Subscription(self, name)
|
||||||
|
self._subscribers.append(sub)
|
||||||
|
logger.debug("event subscriber added: %s (total=%d)", name, len(self._subscribers))
|
||||||
|
return sub
|
||||||
|
|
||||||
|
def unsubscribe(self, sub: Subscription) -> None:
|
||||||
|
if sub in self._subscribers:
|
||||||
|
self._subscribers.remove(sub)
|
||||||
|
logger.debug("event subscriber removed: %s (total=%d)", sub.name, len(self._subscribers))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subscriber_count(self) -> int:
|
||||||
|
return len(self._subscribers)
|
||||||
|
|
||||||
|
# -- publishing ---------------------------------------------------------- #
|
||||||
|
def publish(self, event_type: str, data: Optional[Dict[str, Any]] = None) -> Event:
|
||||||
|
event = Event(type=event_type, data=data or {})
|
||||||
|
self._last[event_type] = event
|
||||||
|
for sub in list(self._subscribers):
|
||||||
|
sub._offer(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def last(self, event_type: str) -> Optional[Event]:
|
||||||
|
"""Most recent event of a type - used to prime a newly-opened socket."""
|
||||||
|
return self._last.get(event_type)
|
||||||
|
|
||||||
|
|
||||||
|
_bus: Optional[EventBus] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_bus() -> EventBus:
|
||||||
|
global _bus
|
||||||
|
if _bus is None:
|
||||||
|
_bus = EventBus()
|
||||||
|
return _bus
|
||||||
43
backend/core/logging.py
Normal file
43
backend/core/logging.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Console logging setup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_FORMAT = "%(asctime)s %(levelname)-7s %(name)-28s %(message)s"
|
||||||
|
_DATEFMT = "%H:%M:%S"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_utf8_stdout() -> None:
|
||||||
|
"""Make stdout/stderr able to print non-ASCII on a Windows console.
|
||||||
|
|
||||||
|
Windows terminals default to a legacy code page (cp1252 in Western locales),
|
||||||
|
and printing a single Chinese character raises UnicodeEncodeError. The robot's
|
||||||
|
own documentation and error strings are Chinese, so an error response would
|
||||||
|
otherwise crash the log call that was trying to report it - turning a handled
|
||||||
|
failure into an unhandled one.
|
||||||
|
"""
|
||||||
|
for stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
if stream is not None and hasattr(stream, "reconfigure"):
|
||||||
|
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||||
|
except Exception: # pragma: no cover - never let logging setup fail
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "info") -> None:
|
||||||
|
ensure_utf8_stdout()
|
||||||
|
numeric = getattr(logging, level.upper(), logging.INFO)
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATEFMT))
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers = [handler]
|
||||||
|
root.setLevel(numeric)
|
||||||
|
|
||||||
|
# Access logs for a single-user local dashboard are noise.
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("websockets").setLevel(logging.WARNING)
|
||||||
112
backend/core/pronunciation.py
Normal file
112
backend/core/pronunciation.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""Pronunciation fixes for the simulator's speech.
|
||||||
|
|
||||||
|
The built-in OS voices are clear and instant, but they mispronounce brand names
|
||||||
|
and initialisms - "AGIBOT" is the obvious one. Rather than abandoning a voice
|
||||||
|
that otherwise sounds good, we respell the problem words phonetically just
|
||||||
|
before they are spoken.
|
||||||
|
|
||||||
|
The text the *robot* receives is never touched. This rewrite happens at the very
|
||||||
|
last step, inside the PC audio layer, so:
|
||||||
|
|
||||||
|
* the dashboard, history and API all keep the operator's original wording;
|
||||||
|
* the real AGIBOT A3 always gets the real text - it has its own TTS and its
|
||||||
|
own idea of how its name sounds.
|
||||||
|
|
||||||
|
Rules live in `pronunciation.json` at the project root so they can be corrected
|
||||||
|
without touching code:
|
||||||
|
|
||||||
|
{
|
||||||
|
"AGIBOT": "Ah-jee-bot",
|
||||||
|
"A3": "A three"
|
||||||
|
}
|
||||||
|
|
||||||
|
Matching is case-insensitive and respects word boundaries, so "AGIBOT" will not
|
||||||
|
corrupt a longer word that happens to contain it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RULES_FILE = "pronunciation.json"
|
||||||
|
|
||||||
|
#: Shipped defaults. Deliberately small - guessing at pronunciations nobody
|
||||||
|
#: asked for is worse than leaving a word alone. Extend via pronunciation.json.
|
||||||
|
DEFAULT_RULES: Dict[str, str] = {
|
||||||
|
"AGIBOT": "Ah-jee-bot",
|
||||||
|
"AgiBot": "Ah-jee-bot",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Pronouncer:
|
||||||
|
"""Applies respelling rules to text just before synthesis."""
|
||||||
|
|
||||||
|
def __init__(self, rules: Optional[Dict[str, str]] = None) -> None:
|
||||||
|
self._compiled: List[Tuple[re.Pattern, str]] = []
|
||||||
|
self.rules: Dict[str, str] = {}
|
||||||
|
self.load(rules if rules is not None else DEFAULT_RULES)
|
||||||
|
|
||||||
|
def load(self, rules: Dict[str, str]) -> None:
|
||||||
|
self.rules = dict(rules or {})
|
||||||
|
self._compiled = []
|
||||||
|
# Longest first, so a specific phrase wins over a word inside it.
|
||||||
|
for phrase in sorted(self.rules, key=len, reverse=True):
|
||||||
|
replacement = self.rules[phrase]
|
||||||
|
if not phrase:
|
||||||
|
continue
|
||||||
|
# \b does not anchor against digits/symbols the way we need for
|
||||||
|
# names like "A3", so use explicit look-around on word characters.
|
||||||
|
pattern = re.compile(
|
||||||
|
r"(?<![0-9A-Za-z]){0}(?![0-9A-Za-z])".format(re.escape(phrase)),
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
self._compiled.append((pattern, replacement))
|
||||||
|
|
||||||
|
def apply(self, text: str) -> str:
|
||||||
|
if not text or not self._compiled:
|
||||||
|
return text
|
||||||
|
result = text
|
||||||
|
for pattern, replacement in self._compiled:
|
||||||
|
result = pattern.sub(replacement, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.rules)
|
||||||
|
|
||||||
|
|
||||||
|
def load_rules(project_root: Path) -> Dict[str, str]:
|
||||||
|
"""Read pronunciation.json, falling back to the shipped defaults."""
|
||||||
|
path = Path(project_root) / RULES_FILE
|
||||||
|
if not path.exists():
|
||||||
|
return dict(DEFAULT_RULES)
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
logger.warning("%s is not valid JSON (%s); using default pronunciations", path, exc)
|
||||||
|
return dict(DEFAULT_RULES)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning("%s must contain a JSON object of {word: respelling}", path)
|
||||||
|
return dict(DEFAULT_RULES)
|
||||||
|
|
||||||
|
# Keys starting with "_" are comments, so the file can document itself.
|
||||||
|
rules = {
|
||||||
|
str(k): str(v)
|
||||||
|
for k, v in data.items()
|
||||||
|
if str(k).strip() and not str(k).startswith("_")
|
||||||
|
}
|
||||||
|
logger.info("loaded %d pronunciation rule(s) from %s", len(rules), path.name)
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
def build(project_root: Path, enabled: bool = True) -> Optional[Pronouncer]:
|
||||||
|
"""A Pronouncer, or None when the feature is switched off."""
|
||||||
|
if not enabled:
|
||||||
|
return None
|
||||||
|
return Pronouncer(load_rules(project_root))
|
||||||
39
backend/core/text.py
Normal file
39
backend/core/text.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Text helpers shared by the adapters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_WHITESPACE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def normalise(text: str) -> str:
|
||||||
|
"""Collapse whitespace and trim. Keeps the robot from reading stray newlines."""
|
||||||
|
return _WHITESPACE.sub(" ", text or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_cjk(char: str) -> bool:
|
||||||
|
code = ord(char)
|
||||||
|
return (
|
||||||
|
0x4E00 <= code <= 0x9FFF # CJK unified ideographs
|
||||||
|
or 0x3400 <= code <= 0x4DBF # extension A
|
||||||
|
or 0x3040 <= code <= 0x30FF # kana
|
||||||
|
or 0xAC00 <= code <= 0xD7AF # hangul
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_speech_seconds(text: str, words_per_minute: int = 150) -> float:
|
||||||
|
"""Rough spoken duration.
|
||||||
|
|
||||||
|
Used only when the robot does not report utterance completion itself, so the
|
||||||
|
dashboard can still return to "Ready" at a believable moment instead of
|
||||||
|
hanging on "Speaking..." forever.
|
||||||
|
"""
|
||||||
|
cleaned = normalise(text)
|
||||||
|
if not cleaned:
|
||||||
|
return 0.0
|
||||||
|
cjk_chars = sum(1 for ch in cleaned if is_cjk(ch))
|
||||||
|
latin_words = len([w for w in cleaned.split() if any(not is_cjk(c) for c in w)])
|
||||||
|
# ~2.5 CJK characters per second is a typical TTS cadence.
|
||||||
|
seconds = (latin_words / max(60, words_per_minute)) * 60.0 + (cjk_chars / 2.5)
|
||||||
|
return max(0.8, seconds)
|
||||||
175
backend/main.py
Normal file
175
backend/main.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
"""Application entry point.
|
||||||
|
|
||||||
|
python backend/main.py -> http://localhost:8000
|
||||||
|
|
||||||
|
Serves the dashboard, the JSON API and the WebSocket from one process, so there
|
||||||
|
is nothing to orchestrate on the demo machine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Allow `python backend/main.py` as well as `python -m backend.main`.
|
||||||
|
if __package__ in (None, ""): # pragma: no cover
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request # noqa: E402
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||||
|
from fastapi.responses import JSONResponse # noqa: E402
|
||||||
|
from fastapi.staticfiles import StaticFiles # noqa: E402
|
||||||
|
|
||||||
|
from backend.api.routes import router as api_router # noqa: E402
|
||||||
|
from backend.api.websocket import ws_router # noqa: E402
|
||||||
|
from backend.config.settings import PROJECT_ROOT, get_settings # noqa: E402
|
||||||
|
from backend.core.events import get_event_bus # noqa: E402
|
||||||
|
from backend.core.logging import configure_logging # noqa: E402
|
||||||
|
from backend.robot.base import ( # noqa: E402
|
||||||
|
RobotBusy,
|
||||||
|
RobotError,
|
||||||
|
RobotNotConfigured,
|
||||||
|
RobotTimeout,
|
||||||
|
RobotUnreachable,
|
||||||
|
SpeechFailed,
|
||||||
|
TransportNotAvailable,
|
||||||
|
)
|
||||||
|
from backend.robot.manager import RobotManager # noqa: E402
|
||||||
|
from backend.services.speech_service import SpeechService, ValidationError # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger("agibot.app")
|
||||||
|
|
||||||
|
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
||||||
|
|
||||||
|
# HTTP status for each robot-layer failure. Anything unmapped becomes 500.
|
||||||
|
STATUS_FOR_ERROR = {
|
||||||
|
ValidationError: 400,
|
||||||
|
RobotBusy: 409,
|
||||||
|
TransportNotAvailable: 501,
|
||||||
|
SpeechFailed: 502,
|
||||||
|
RobotNotConfigured: 503,
|
||||||
|
RobotUnreachable: 503,
|
||||||
|
RobotTimeout: 504,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _status_for(exc: RobotError) -> int:
|
||||||
|
for error_type, status in STATUS_FOR_ERROR.items():
|
||||||
|
if isinstance(exc, error_type):
|
||||||
|
return status
|
||||||
|
return 500
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
settings = app.state.settings
|
||||||
|
manager: RobotManager = app.state.robot_manager
|
||||||
|
|
||||||
|
banner(settings)
|
||||||
|
await manager.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await app.state.speech_service.shutdown()
|
||||||
|
await manager.stop()
|
||||||
|
logger.info("shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = get_settings()
|
||||||
|
configure_logging(settings.server.log_level)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="AGIBOT A3 Voice Control",
|
||||||
|
description="Local dashboard for sending speech to an AGIBOT A3 humanoid.",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/api/docs",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
bus = get_event_bus()
|
||||||
|
manager = RobotManager(settings, bus)
|
||||||
|
app.state.settings = settings
|
||||||
|
app.state.event_bus = bus
|
||||||
|
app.state.robot_manager = manager
|
||||||
|
app.state.speech_service = SpeechService(settings, manager, bus)
|
||||||
|
|
||||||
|
if settings.server.cors_origins:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.server.cors_origins,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(RobotError)
|
||||||
|
async def robot_error_handler(request: Request, exc: RobotError) -> JSONResponse:
|
||||||
|
status = _status_for(exc)
|
||||||
|
if status >= 500:
|
||||||
|
logger.warning("%s -> %s (%s)", request.url.path, exc.code, exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status,
|
||||||
|
content={"success": False, "error": exc.user_message, "errorCode": exc.code},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api_router)
|
||||||
|
app.include_router(ws_router)
|
||||||
|
|
||||||
|
if FRONTEND_DIR.is_dir():
|
||||||
|
# Mounted last so /api/* and /ws win.
|
||||||
|
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
|
||||||
|
else: # pragma: no cover
|
||||||
|
logger.error("frontend directory missing: %s", FRONTEND_DIR)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def banner(settings) -> None:
|
||||||
|
mode = settings.robot.mode.upper()
|
||||||
|
target = "local simulation" if settings.is_mock else "{0} via {1}".format(
|
||||||
|
settings.robot.address, settings.a3.transport
|
||||||
|
)
|
||||||
|
url = "http://{0}:{1}".format(
|
||||||
|
"localhost" if settings.server.host in ("0.0.0.0", "127.0.0.1") else settings.server.host,
|
||||||
|
settings.server.port,
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
" AGIBOT A3 - Voice Control",
|
||||||
|
" " + "-" * 46,
|
||||||
|
" Mode : {0}".format(mode),
|
||||||
|
" Robot : {0}".format(target),
|
||||||
|
" Dashboard : {0}".format(url),
|
||||||
|
" Config : {0}".format(settings.env_file),
|
||||||
|
]
|
||||||
|
for issue in settings.issues:
|
||||||
|
lines.append(" {0:<10}: [{1}] {2}".format("Config", issue.level.upper(), issue.message))
|
||||||
|
if settings.is_mock:
|
||||||
|
lines.append(" Note : running against the MOCK robot - no hardware needed.")
|
||||||
|
lines.append("")
|
||||||
|
print("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
uvicorn.run(
|
||||||
|
"backend.main:app",
|
||||||
|
host=settings.server.host,
|
||||||
|
port=settings.server.port,
|
||||||
|
reload=os.environ.get("DEV_RELOAD", "").lower() in ("1", "true", "yes"),
|
||||||
|
log_level=settings.server.log_level,
|
||||||
|
access_log=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
backend/robot/__init__.py
Normal file
0
backend/robot/__init__.py
Normal file
299
backend/robot/agibot_a3.py
Normal file
299
backend/robot/agibot_a3.py
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
"""AGIBOT A3 adapter.
|
||||||
|
|
||||||
|
============================================================================
|
||||||
|
THIS FILE IS THE INTEGRATION POINT.
|
||||||
|
============================================================================
|
||||||
|
Everything else in this project is finished and testable today. When the robot's
|
||||||
|
speech interface is confirmed, the change is limited to:
|
||||||
|
|
||||||
|
1. .env - transport, address, endpoint/topic/command and payload
|
||||||
|
2. (only if the wire format is unusual) a new file under robot/transports/
|
||||||
|
|
||||||
|
No endpoint, port, ROS topic or SDK symbol is invented in this code. The adapter
|
||||||
|
deliberately holds *no* opinion about the A3's protocol; it holds the parts that
|
||||||
|
are true regardless of protocol:
|
||||||
|
|
||||||
|
- one persistent connection, reused across utterances (latency)
|
||||||
|
- ack-latency measurement, so "how fast is it really" is answerable
|
||||||
|
- the SENDING -> PROCESSING -> SPEAKING -> COMPLETED lifecycle the UI renders
|
||||||
|
- completion by robot event when the robot reports it, by estimate when it does not
|
||||||
|
- interruption, timeouts and error mapping that never wedge the web app
|
||||||
|
|
||||||
|
See docs/AGIBOT_A3_INTEGRATION.md for the discovery procedure to run against the
|
||||||
|
real unit, and for the exact questions to put to AgiBot support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from ..config.settings import Settings
|
||||||
|
from ..core.text import estimate_speech_seconds
|
||||||
|
from .base import (
|
||||||
|
ProgressCallback,
|
||||||
|
RobotAdapter,
|
||||||
|
RobotCapabilities,
|
||||||
|
RobotError,
|
||||||
|
RobotInfo,
|
||||||
|
RobotNotConfigured,
|
||||||
|
RobotUnreachable,
|
||||||
|
SpeechProgress,
|
||||||
|
SpeechRequest,
|
||||||
|
SpeechResult,
|
||||||
|
SpeechStage,
|
||||||
|
)
|
||||||
|
from .transports import create_transport
|
||||||
|
from .transports.base import SpeechTransport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TICK = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
class AgibotA3(RobotAdapter):
|
||||||
|
"""Real-robot adapter, delegating the wire protocol to a configured transport."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._robot = settings.robot
|
||||||
|
self._a3 = settings.a3
|
||||||
|
self._transport: Optional[SpeechTransport] = None
|
||||||
|
self._connected = False
|
||||||
|
self._speaking = False
|
||||||
|
self._cancel = asyncio.Event()
|
||||||
|
self._last_error: Optional[str] = None
|
||||||
|
|
||||||
|
# -- identity ------------------------------------------------------------ #
|
||||||
|
@property
|
||||||
|
def info(self) -> RobotInfo:
|
||||||
|
transport = self._transport
|
||||||
|
return RobotInfo(
|
||||||
|
name=self._robot.name,
|
||||||
|
model=self._robot.model,
|
||||||
|
mode="real",
|
||||||
|
transport=self._a3.transport,
|
||||||
|
address=self._robot.address,
|
||||||
|
capabilities=RobotCapabilities(
|
||||||
|
native_tts=True,
|
||||||
|
stop=self._can_stop(),
|
||||||
|
progress_events=True,
|
||||||
|
reports_completion=bool(transport and transport.reports_completion),
|
||||||
|
voice_selection=bool(self._a3.voice),
|
||||||
|
volume_control=self._a3.volume is not None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _can_stop(self) -> bool:
|
||||||
|
"""Only advertise Stop when a stop path is actually configured."""
|
||||||
|
transport = self._a3.transport
|
||||||
|
if transport == "aimdk":
|
||||||
|
return bool(self._a3.aimdk_stop_method)
|
||||||
|
if transport == "http":
|
||||||
|
return bool(self._a3.http_stop_path)
|
||||||
|
if transport == "ws":
|
||||||
|
return bool(self._a3.ws_stop_payload)
|
||||||
|
if transport == "ros2":
|
||||||
|
return bool(self._a3.ros_stop_topic)
|
||||||
|
if transport == "ssh":
|
||||||
|
return bool(self._a3.ssh_stop_command)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_speaking(self) -> bool:
|
||||||
|
return self._speaking
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> Optional[str]:
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------- #
|
||||||
|
async def connect(self) -> None:
|
||||||
|
if not self._robot.ip:
|
||||||
|
raise RobotNotConfigured()
|
||||||
|
if self._settings.has_blocking_issue:
|
||||||
|
first = next(i for i in self._settings.issues if i.level == "error")
|
||||||
|
raise RobotError(first.message, user_message=first.message)
|
||||||
|
|
||||||
|
if self._transport is None:
|
||||||
|
self._transport = create_transport(self._settings)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._transport.open()
|
||||||
|
ok = await self._transport.probe()
|
||||||
|
except RobotError:
|
||||||
|
self._connected = False
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self._connected = False
|
||||||
|
raise RobotUnreachable(
|
||||||
|
"transport {0} failed to open: {1}".format(self._a3.transport, exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
self._connected = False
|
||||||
|
raise RobotUnreachable(
|
||||||
|
"probe against {0} failed".format(self._robot.address)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._connected = True
|
||||||
|
self._last_error = None
|
||||||
|
logger.info(
|
||||||
|
"AGIBOT A3 connected via %s at %s", self._a3.transport, self._robot.address
|
||||||
|
)
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
self._cancel.set()
|
||||||
|
self._connected = False
|
||||||
|
self._speaking = False
|
||||||
|
if self._transport is not None:
|
||||||
|
try:
|
||||||
|
await self._transport.close()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
logger.debug("transport close raised", exc_info=True)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
if self._transport is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ok = await self._transport.probe()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("health check raised: %s", exc)
|
||||||
|
ok = False
|
||||||
|
self._connected = ok
|
||||||
|
return ok
|
||||||
|
|
||||||
|
# -- speech -------------------------------------------------------------- #
|
||||||
|
async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> SpeechResult:
|
||||||
|
if self._transport is None or not self._connected:
|
||||||
|
raise RobotUnreachable("robot is not connected")
|
||||||
|
|
||||||
|
self._cancel.clear()
|
||||||
|
self._speaking = True
|
||||||
|
started = time.perf_counter()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ack = await self._transport.speak(request, on_progress)
|
||||||
|
ack_ms = ack.ack_latency_ms
|
||||||
|
|
||||||
|
await on_progress(
|
||||||
|
SpeechProgress(
|
||||||
|
request.id,
|
||||||
|
SpeechStage.PROCESSING,
|
||||||
|
"Robot accepted the request",
|
||||||
|
detail=ack.detail,
|
||||||
|
elapsed_ms=ack_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
estimated = estimate_speech_seconds(request.text)
|
||||||
|
await on_progress(
|
||||||
|
SpeechProgress(
|
||||||
|
request.id,
|
||||||
|
SpeechStage.SPEAKING,
|
||||||
|
"Speaking...",
|
||||||
|
detail=None if self._reports_completion else "~{0:.1f}s".format(estimated),
|
||||||
|
elapsed_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
cancelled = await self._await_end(estimated)
|
||||||
|
total_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
|
||||||
|
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=total_ms,
|
||||||
|
error_code="cancelled",
|
||||||
|
error="Stopped by operator.",
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
except RobotError as exc:
|
||||||
|
self._last_error = str(exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._speaking = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _reports_completion(self) -> bool:
|
||||||
|
return bool(self._transport and self._transport.reports_completion)
|
||||||
|
|
||||||
|
async def _await_end(self, estimated_seconds: float) -> bool:
|
||||||
|
"""Wait for the utterance to finish. Returns True if it was cancelled.
|
||||||
|
|
||||||
|
If the transport delivers a real completion event we use it; otherwise we
|
||||||
|
fall back to the estimated duration so the UI still returns to Ready.
|
||||||
|
"""
|
||||||
|
transport = self._transport
|
||||||
|
if self._reports_completion and hasattr(transport, "await_completion"):
|
||||||
|
budget = max(estimated_seconds * 3, 10.0)
|
||||||
|
done_task = asyncio.ensure_future(transport.await_completion(budget)) # type: ignore[union-attr]
|
||||||
|
cancel_task = asyncio.ensure_future(self._cancel.wait())
|
||||||
|
try:
|
||||||
|
await asyncio.wait(
|
||||||
|
{done_task, cancel_task}, return_when=asyncio.FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
return cancel_task.done() and not done_task.done()
|
||||||
|
finally:
|
||||||
|
for task in (done_task, cancel_task):
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
if self._reports_completion:
|
||||||
|
# e.g. SSH: transport.speak() already blocked until playback ended.
|
||||||
|
return self._cancel.is_set()
|
||||||
|
|
||||||
|
deadline = time.perf_counter() + estimated_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))
|
||||||
|
|
||||||
|
async def stop_speaking(self) -> bool:
|
||||||
|
self._cancel.set()
|
||||||
|
if self._transport is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return await self._transport.stop()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("stop failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def describe(self) -> Dict[str, Any]:
|
||||||
|
detail: Dict[str, Any] = {
|
||||||
|
"transport": self._a3.transport,
|
||||||
|
"address": self._robot.address,
|
||||||
|
"connected": self._connected,
|
||||||
|
"lastError": self._last_error,
|
||||||
|
}
|
||||||
|
if self._transport is not None:
|
||||||
|
try:
|
||||||
|
detail["transportDetail"] = await self._transport.describe()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
return detail
|
||||||
242
backend/robot/base.py
Normal file
242
backend/robot/base.py
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
"""Robot abstraction layer.
|
||||||
|
|
||||||
|
This is the ONLY contract the rest of the application knows about. Nothing above
|
||||||
|
this layer (services, API, frontend) may import a vendor SDK, an HTTP client or a
|
||||||
|
ROS package. Adding a new robot means adding one file that implements
|
||||||
|
`RobotAdapter` and registering it in `factory.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# enums
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class RobotState(str, Enum):
|
||||||
|
DISCONNECTED = "disconnected"
|
||||||
|
CONNECTING = "connecting"
|
||||||
|
CONNECTED = "connected"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechStage(str, Enum):
|
||||||
|
"""Lifecycle of a single utterance, mirrored 1:1 in the UI."""
|
||||||
|
|
||||||
|
QUEUED = "queued"
|
||||||
|
SENDING = "sending" # request leaving the PC
|
||||||
|
PROCESSING = "processing" # robot accepted it, synthesising
|
||||||
|
SPEAKING = "speaking" # audio is coming out of the speaker
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_STAGES = {SpeechStage.COMPLETED, SpeechStage.CANCELLED, SpeechStage.FAILED}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# errors
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class RobotError(Exception):
|
||||||
|
"""Base class for every robot-layer failure.
|
||||||
|
|
||||||
|
`user_message` is safe to show in the browser; `str(exc)` may contain detail
|
||||||
|
useful in the server log but noisy for an operator during a live demo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
code = "robot_error"
|
||||||
|
user_message = "The robot reported an error."
|
||||||
|
|
||||||
|
def __init__(self, message: str = "", user_message: Optional[str] = None) -> None:
|
||||||
|
super().__init__(message or self.user_message)
|
||||||
|
if user_message:
|
||||||
|
self.user_message = user_message
|
||||||
|
|
||||||
|
|
||||||
|
class RobotNotConfigured(RobotError):
|
||||||
|
code = "not_configured"
|
||||||
|
user_message = "Robot is not configured. Set ROBOT_IP in the .env file."
|
||||||
|
|
||||||
|
|
||||||
|
class RobotUnreachable(RobotError):
|
||||||
|
code = "unreachable"
|
||||||
|
user_message = "Robot is offline. Check the robot IP address and network connection."
|
||||||
|
|
||||||
|
|
||||||
|
class RobotTimeout(RobotError):
|
||||||
|
code = "timeout"
|
||||||
|
user_message = "The robot did not respond in time."
|
||||||
|
|
||||||
|
|
||||||
|
class RobotBusy(RobotError):
|
||||||
|
code = "busy"
|
||||||
|
user_message = "The robot is already speaking."
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechFailed(RobotError):
|
||||||
|
code = "speech_failed"
|
||||||
|
user_message = "Speech request failed."
|
||||||
|
|
||||||
|
|
||||||
|
class TransportNotAvailable(RobotError):
|
||||||
|
code = "transport_unavailable"
|
||||||
|
user_message = "The selected robot transport is not available on this PC."
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# value objects
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RobotCapabilities:
|
||||||
|
"""What this adapter can actually do, so the UI never offers a dead control."""
|
||||||
|
|
||||||
|
native_tts: bool = True
|
||||||
|
stop: bool = True
|
||||||
|
progress_events: bool = True
|
||||||
|
reports_completion: bool = True
|
||||||
|
voice_selection: bool = False
|
||||||
|
volume_control: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RobotInfo:
|
||||||
|
name: str
|
||||||
|
model: str
|
||||||
|
mode: str
|
||||||
|
transport: str
|
||||||
|
address: str
|
||||||
|
capabilities: RobotCapabilities
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"model": self.model,
|
||||||
|
"mode": self.mode,
|
||||||
|
"transport": self.transport,
|
||||||
|
"address": self.address,
|
||||||
|
"capabilities": {
|
||||||
|
"nativeTts": self.capabilities.native_tts,
|
||||||
|
"stop": self.capabilities.stop,
|
||||||
|
"progressEvents": self.capabilities.progress_events,
|
||||||
|
"reportsCompletion": self.capabilities.reports_completion,
|
||||||
|
"voiceSelection": self.capabilities.voice_selection,
|
||||||
|
"volumeControl": self.capabilities.volume_control,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpeechRequest:
|
||||||
|
text: str
|
||||||
|
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
||||||
|
voice: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preview(self) -> str:
|
||||||
|
text = " ".join(self.text.split())
|
||||||
|
return text if len(text) <= 80 else text[:77] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpeechProgress:
|
||||||
|
"""One lifecycle update for an utterance, pushed straight to the browser."""
|
||||||
|
|
||||||
|
request_id: str
|
||||||
|
stage: SpeechStage
|
||||||
|
message: str = ""
|
||||||
|
detail: Optional[str] = None
|
||||||
|
error_code: Optional[str] = None
|
||||||
|
elapsed_ms: Optional[int] = None
|
||||||
|
at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"requestId": self.request_id,
|
||||||
|
"stage": self.stage.value,
|
||||||
|
"message": self.message,
|
||||||
|
"detail": self.detail,
|
||||||
|
"errorCode": self.error_code,
|
||||||
|
"elapsedMs": self.elapsed_ms,
|
||||||
|
"at": self.at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpeechResult:
|
||||||
|
request_id: str
|
||||||
|
success: bool
|
||||||
|
stage: SpeechStage
|
||||||
|
# Time from "user pressed Speak" to the robot acknowledging the request. This
|
||||||
|
# is the number that matters for a live demo - it is the perceived delay.
|
||||||
|
ack_latency_ms: Optional[int] = None
|
||||||
|
total_ms: Optional[int] = None
|
||||||
|
error_code: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"requestId": self.request_id,
|
||||||
|
"success": self.success,
|
||||||
|
"stage": self.stage.value,
|
||||||
|
"ackLatencyMs": self.ack_latency_ms,
|
||||||
|
"totalMs": self.total_ms,
|
||||||
|
"errorCode": self.error_code,
|
||||||
|
"error": self.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ProgressCallback = Callable[[SpeechProgress], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# adapter contract
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class RobotAdapter(abc.ABC):
|
||||||
|
"""Every robot backend implements exactly this."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def info(self) -> RobotInfo:
|
||||||
|
"""Static description of the robot / connection."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Establish (or verify) the connection. Raises RobotError on failure."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Release sockets/clients. Must never raise."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Cheap liveness probe used by the connection manager."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> SpeechResult:
|
||||||
|
"""Send text to the robot and report progress until the utterance ends."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def stop_speaking(self) -> bool:
|
||||||
|
"""Interrupt the current utterance. Returns True if a stop was issued."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Last known connection state - must not perform I/O."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_speaking(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def describe(self) -> Dict[str, Any]:
|
||||||
|
"""Optional richer diagnostics for the /api/robot/diagnostics endpoint."""
|
||||||
|
return {}
|
||||||
46
backend/robot/factory.py
Normal file
46
backend/robot/factory.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""Robot adapter registry.
|
||||||
|
|
||||||
|
Adding a robot (AgiBot X2, another vendor, a different TTS backend) means writing
|
||||||
|
one adapter and adding one line here. Nothing above this layer changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable, Dict
|
||||||
|
|
||||||
|
from ..config.settings import Settings
|
||||||
|
from .base import RobotAdapter, RobotError
|
||||||
|
|
||||||
|
|
||||||
|
def _mock(settings: Settings) -> RobotAdapter:
|
||||||
|
from .mock_robot import MockRobot
|
||||||
|
|
||||||
|
return MockRobot(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _agibot_a3(settings: Settings) -> RobotAdapter:
|
||||||
|
from .agibot_a3 import AgibotA3
|
||||||
|
|
||||||
|
return AgibotA3(settings)
|
||||||
|
|
||||||
|
|
||||||
|
_REGISTRY: Dict[str, Callable[[Settings], RobotAdapter]] = {
|
||||||
|
"mock": _mock,
|
||||||
|
"real": _agibot_a3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def available_modes() -> list:
|
||||||
|
return sorted(_REGISTRY)
|
||||||
|
|
||||||
|
|
||||||
|
def create_robot(settings: Settings) -> RobotAdapter:
|
||||||
|
builder = _REGISTRY.get(settings.robot.mode)
|
||||||
|
if builder is None:
|
||||||
|
raise RobotError(
|
||||||
|
"unknown ROBOT_MODE '{0}'".format(settings.robot.mode),
|
||||||
|
user_message="Unknown ROBOT_MODE '{0}'. Valid values: {1}.".format(
|
||||||
|
settings.robot.mode, ", ".join(available_modes())
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return builder(settings)
|
||||||
433
backend/robot/gemini_voice.py
Normal file
433
backend/robot/gemini_voice.py
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
"""Gemini cloud voice for the simulator.
|
||||||
|
|
||||||
|
Gives the mock robot a natural neural voice instead of the flat built-in Windows
|
||||||
|
one, so a rehearsal sounds like the real thing.
|
||||||
|
|
||||||
|
**Simulator only.** The real AGIBOT A3 synthesises its own speech on-board;
|
||||||
|
nothing here is ever used against the robot.
|
||||||
|
|
||||||
|
API (verified against a live call on 2026-09-02, not copied from a doc summary):
|
||||||
|
|
||||||
|
POST https://generativelanguage.googleapis.com/v1beta/interactions
|
||||||
|
x-goog-api-key: <key>
|
||||||
|
{"model": "...-tts-preview", "input": "...",
|
||||||
|
"response_format": {"type": "audio"},
|
||||||
|
"generation_config": {"speech_config": [{"voice": "Puck"}]}}
|
||||||
|
|
||||||
|
-> steps[0].content[0].data base64 PCM
|
||||||
|
steps[0].content[0].mime_type audio/l16; rate=24000; channels=1
|
||||||
|
|
||||||
|
The audio lives under `steps[]`, not the `output_audio` field some docs describe
|
||||||
|
- that path was checked against a real response.
|
||||||
|
|
||||||
|
THE LATENCY PROBLEM, AND THE TWO THINGS DONE ABOUT IT
|
||||||
|
-----------------------------------------------------
|
||||||
|
Measured on this account: ~4 s to synthesise a short sentence, ~8-10 s for a long
|
||||||
|
paragraph. Unusable for a live demo if taken naively. So:
|
||||||
|
|
||||||
|
1. **Saved audio.** Every line is written to `audio_library/` as an ordinary
|
||||||
|
.wav named after its text, so a repeated line replays instantly, survives
|
||||||
|
restarts, works with no internet, and can be played outside this app.
|
||||||
|
Rehearsed lines can be built ahead of time - see scripts/warm_voice.py.
|
||||||
|
2. **One clip per utterance.** Text is NOT split by default: the whole line is
|
||||||
|
synthesised in a single take, so the delivery is continuous and the library
|
||||||
|
holds one file per thing you said. Optional sentence pipelining is available
|
||||||
|
for very long text via GEMINI_CHUNK_CHARS, at the cost of an audible seam.
|
||||||
|
|
||||||
|
And it must never break the demo: any failure - no network, bad key, quota -
|
||||||
|
falls back to the built-in system voice rather than producing silence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from ..services.audio_library import AudioLibrary
|
||||||
|
from .local_audio import LocalVoice
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/interactions"
|
||||||
|
|
||||||
|
#: Prebuilt voices with the tone Google documents for each.
|
||||||
|
VOICES: Dict[str, str] = {
|
||||||
|
"Zephyr": "Bright", "Puck": "Upbeat", "Charon": "Informative", "Kore": "Firm",
|
||||||
|
"Fenrir": "Excitable", "Leda": "Youthful", "Orus": "Firm", "Aoede": "Breezy",
|
||||||
|
"Callirrhoe": "Easy-going", "Autonoe": "Bright", "Enceladus": "Breathy",
|
||||||
|
"Iapetus": "Clear", "Umbriel": "Easy-going", "Algieba": "Smooth",
|
||||||
|
"Despina": "Smooth", "Erinome": "Clear", "Algenib": "Gravelly",
|
||||||
|
"Rasalgethi": "Informative", "Laomedeia": "Upbeat", "Achernar": "Soft",
|
||||||
|
"Alnilam": "Firm", "Schedar": "Even", "Gacrux": "Mature",
|
||||||
|
"Pulcherrima": "Forward", "Achird": "Friendly", "Zubenelgenubi": "Casual",
|
||||||
|
"Vindemiatrix": "Gentle", "Sadachbia": "Lively", "Sadaltager": "Knowledgeable",
|
||||||
|
"Sulafat": "Warm",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 0 = never split. Every utterance is synthesised as ONE clip, in one take.
|
||||||
|
#:
|
||||||
|
#: Splitting was an optimisation: a request costs ~3.5 s fixed plus ~45 ms per
|
||||||
|
#: character, so starting playback after the first sentence reaches audio sooner
|
||||||
|
#: on long text. But each piece is a separate synthesis - separate delivery,
|
||||||
|
#: separate file - and that is audible. One continuous take is worth more than
|
||||||
|
#: a second or two of head start.
|
||||||
|
#:
|
||||||
|
#: Set GEMINI_CHUNK_CHARS in .env to a character count to re-enable pipelining
|
||||||
|
#: for very long text.
|
||||||
|
#:
|
||||||
|
#: NOTE: unrelated to the robot's own 1024-BYTE limit in aimdk_transport.py.
|
||||||
|
#: That one is a hard API constraint; this is a latency choice.
|
||||||
|
_CHUNK_TARGET_CHARS = 0
|
||||||
|
|
||||||
|
_SENTENCE_END = re.compile(r"(?<=[.!?。!?;:])\s+|\n+")
|
||||||
|
|
||||||
|
|
||||||
|
def wav_from_pcm(pcm: bytes, sample_rate: int = 24000, channels: int = 1,
|
||||||
|
bits: int = 16) -> bytes:
|
||||||
|
"""Wrap raw PCM in a 44-byte WAV header so ordinary players accept it."""
|
||||||
|
byte_rate = sample_rate * channels * bits // 8
|
||||||
|
block_align = channels * bits // 8
|
||||||
|
return b"".join([
|
||||||
|
b"RIFF", struct.pack("<I", 36 + len(pcm)), b"WAVE",
|
||||||
|
b"fmt ", struct.pack("<IHHIIHH", 16, 1, channels, sample_rate,
|
||||||
|
byte_rate, block_align, bits),
|
||||||
|
b"data", struct.pack("<I", len(pcm)), pcm,
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def split_sentences(text: str, target: int = _CHUNK_TARGET_CHARS) -> List[str]:
|
||||||
|
"""Split into speakable chunks on sentence boundaries.
|
||||||
|
|
||||||
|
`target <= 0` disables splitting entirely: the whole utterance is synthesised
|
||||||
|
as ONE clip, in one continuous take. That is the default, because a split
|
||||||
|
line is synthesised as separate requests - each piece gets its own delivery
|
||||||
|
and its own file, and the join between them can be audible.
|
||||||
|
"""
|
||||||
|
text = " ".join((text or "").split())
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
if target <= 0 or len(text) <= target:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
pieces = [p.strip() for p in _SENTENCE_END.split(text) if p and p.strip()]
|
||||||
|
chunks: List[str] = []
|
||||||
|
current = ""
|
||||||
|
for piece in pieces:
|
||||||
|
if not current:
|
||||||
|
current = piece
|
||||||
|
elif len(current) + 1 + len(piece) <= target:
|
||||||
|
current += " " + piece
|
||||||
|
else:
|
||||||
|
chunks.append(current)
|
||||||
|
current = piece
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# playback
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class _Player:
|
||||||
|
"""Plays one WAV without blocking; can be stopped mid-playback."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._windows = platform.system().lower().startswith("win")
|
||||||
|
self._process: Optional[subprocess.Popen] = None
|
||||||
|
self._ends_at = 0.0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def play(self, path: str, duration: float) -> None:
|
||||||
|
if self._windows:
|
||||||
|
import winsound
|
||||||
|
|
||||||
|
winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
|
||||||
|
# winsound has no completion callback, but the exact duration is
|
||||||
|
# known from the PCM length, so the deadline is precise.
|
||||||
|
with self._lock:
|
||||||
|
self._ends_at = time.monotonic() + duration
|
||||||
|
return
|
||||||
|
|
||||||
|
binary = "afplay" if platform.system().lower() == "darwin" else "aplay"
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
[binary, path], stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("no audio player found (%s); install it to hear the simulator", binary)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self._windows:
|
||||||
|
try:
|
||||||
|
import winsound
|
||||||
|
|
||||||
|
winsound.PlaySound(None, winsound.SND_PURGE)
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
with self._lock:
|
||||||
|
self._ends_at = 0.0
|
||||||
|
return
|
||||||
|
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_playing(self) -> bool:
|
||||||
|
if self._windows:
|
||||||
|
with self._lock:
|
||||||
|
return time.monotonic() < self._ends_at
|
||||||
|
with self._lock:
|
||||||
|
process = self._process
|
||||||
|
return process is not None and process.poll() is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# the voice
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class GeminiVoice(LocalVoice):
|
||||||
|
"""Neural TTS through the Gemini API, saved to disk, with a local fallback."""
|
||||||
|
|
||||||
|
name = "gemini"
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str, voice: str, style: str = "",
|
||||||
|
timeout: float = 60.0, cache_dir: Optional[str] = None,
|
||||||
|
library: Optional[AudioLibrary] = None,
|
||||||
|
chunk_chars: int = _CHUNK_TARGET_CHARS,
|
||||||
|
fallback: Optional[LocalVoice] = None) -> None:
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("GEMINI_API_KEY is empty")
|
||||||
|
self._key = api_key
|
||||||
|
self._model = model
|
||||||
|
self._voice = voice
|
||||||
|
self._style = style
|
||||||
|
self._timeout = timeout
|
||||||
|
# 0 (the default) means never split - one utterance, one clip.
|
||||||
|
self._chunk_chars = max(0, int(chunk_chars))
|
||||||
|
self._fallback = fallback
|
||||||
|
self._using_fallback = False
|
||||||
|
self._warned = False
|
||||||
|
|
||||||
|
# Audio is saved as ordinary .wav files, so a line spoken once can be
|
||||||
|
# replayed instantly, played outside this app, or used offline.
|
||||||
|
if library is not None:
|
||||||
|
self.library = library
|
||||||
|
else:
|
||||||
|
root = cache_dir or os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"audio_library",
|
||||||
|
)
|
||||||
|
self.library = AudioLibrary(Path(root))
|
||||||
|
|
||||||
|
self._player = _Player()
|
||||||
|
self._sequence_stop = threading.Event()
|
||||||
|
self._sequence: Optional[threading.Thread] = None
|
||||||
|
self._active = threading.Event()
|
||||||
|
|
||||||
|
# -- LocalVoice ---------------------------------------------------------- #
|
||||||
|
def prepare(self, text: str) -> bool:
|
||||||
|
"""Synthesise the first chunk (and warm the rest in the background).
|
||||||
|
|
||||||
|
Returns False when the cloud voice is unusable, so playback falls back
|
||||||
|
to the system voice.
|
||||||
|
"""
|
||||||
|
chunks = split_sentences(text, self._chunk_chars)
|
||||||
|
if not chunks:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Start the later chunks BEFORE synthesising the first one, so they are
|
||||||
|
# fetched in parallel. Doing it afterwards leaves an audible gap between
|
||||||
|
# sentences on a cold run, because chunk 2 only starts once chunk 1 is
|
||||||
|
# already playing and finishes after the audio has run out.
|
||||||
|
prefetch: Optional[threading.Thread] = None
|
||||||
|
if len(chunks) > 1:
|
||||||
|
prefetch = threading.Thread(
|
||||||
|
target=self._warm_rest, args=(chunks[1:],),
|
||||||
|
name="gemini-prefetch", daemon=True,
|
||||||
|
)
|
||||||
|
prefetch.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ensure(chunks[0])
|
||||||
|
except Exception as exc:
|
||||||
|
self._using_fallback = True
|
||||||
|
if not self._warned:
|
||||||
|
self._warned = True
|
||||||
|
logger.warning(
|
||||||
|
"Gemini voice unavailable (%s) - falling back to the built-in "
|
||||||
|
"system voice. The demo continues.", exc)
|
||||||
|
else:
|
||||||
|
logger.debug("Gemini synthesis failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._using_fallback = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def start(self, text: str) -> None:
|
||||||
|
self.stop()
|
||||||
|
chunks = split_sentences(text, self._chunk_chars)
|
||||||
|
if not chunks:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._using_fallback or not self._is_cached(chunks[0]):
|
||||||
|
if not self.prepare(text):
|
||||||
|
if self._fallback is not None:
|
||||||
|
self._using_fallback = True
|
||||||
|
self._fallback.start(text)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._sequence_stop.clear()
|
||||||
|
self._active.set()
|
||||||
|
self._sequence = threading.Thread(
|
||||||
|
target=self._play_sequence, args=(chunks,),
|
||||||
|
name="gemini-playback", daemon=True,
|
||||||
|
)
|
||||||
|
self._sequence.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._sequence_stop.set()
|
||||||
|
self._player.stop()
|
||||||
|
self._active.clear()
|
||||||
|
if self._fallback is not None:
|
||||||
|
try:
|
||||||
|
self._fallback.stop()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_speaking(self) -> bool:
|
||||||
|
if self._using_fallback and self._fallback is not None:
|
||||||
|
return self._fallback.is_speaking()
|
||||||
|
return self._active.is_set()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.stop()
|
||||||
|
if self._fallback is not None:
|
||||||
|
try:
|
||||||
|
self._fallback.close()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- playback sequencing ------------------------------------------------- #
|
||||||
|
def _play_sequence(self, chunks: List[str]) -> None:
|
||||||
|
"""Play each chunk in turn, waiting for later ones to finish synthesis."""
|
||||||
|
try:
|
||||||
|
for chunk in chunks:
|
||||||
|
if self._sequence_stop.is_set():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
path, duration = self._ensure(chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Gemini synthesis failed mid-utterance: %s", exc)
|
||||||
|
return
|
||||||
|
if self._sequence_stop.is_set():
|
||||||
|
return
|
||||||
|
self._player.play(path, duration)
|
||||||
|
while self._player.is_playing():
|
||||||
|
if self._sequence_stop.is_set():
|
||||||
|
self._player.stop()
|
||||||
|
return
|
||||||
|
time.sleep(0.03)
|
||||||
|
finally:
|
||||||
|
self._active.clear()
|
||||||
|
|
||||||
|
def _warm_rest(self, chunks: List[str]) -> None:
|
||||||
|
for chunk in chunks:
|
||||||
|
if self._sequence_stop.is_set():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._ensure(chunk)
|
||||||
|
except Exception as exc: # pragma: no cover - retried at play time
|
||||||
|
logger.debug("prefetch failed: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
# -- saved audio + synthesis --------------------------------------------- #
|
||||||
|
def _is_cached(self, text: str) -> bool:
|
||||||
|
return self.library.find(text, self._voice, self._model, self._style) is not None
|
||||||
|
|
||||||
|
def _ensure(self, text: str) -> Tuple[str, float]:
|
||||||
|
"""Return (wav path, duration), synthesising only if not already saved."""
|
||||||
|
entry = self.library.find(text, self._voice, self._model, self._style)
|
||||||
|
if entry is None:
|
||||||
|
pcm, sample_rate, channels = self._synthesise(text)
|
||||||
|
entry = self.library.save(
|
||||||
|
text, wav_from_pcm(pcm, sample_rate, channels),
|
||||||
|
self._voice, self._model, self._style,
|
||||||
|
)
|
||||||
|
return str(self.library.root / entry["file"]), entry["durationSeconds"]
|
||||||
|
|
||||||
|
def _synthesise(self, text: str) -> Tuple[bytes, int, int]:
|
||||||
|
prompt = "{0} {1}".format(self._style, text).strip() if self._style else text
|
||||||
|
body = json.dumps({
|
||||||
|
"model": self._model,
|
||||||
|
"input": prompt,
|
||||||
|
"response_format": {"type": "audio"},
|
||||||
|
"generation_config": {"speech_config": [{"voice": self._voice}]},
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
request = urllib.request.Request(
|
||||||
|
ENDPOINT, data=body,
|
||||||
|
headers={"x-goog-api-key": self._key, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
||||||
|
payload = json.loads(response.read())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", "replace")[:300]
|
||||||
|
raise RuntimeError("HTTP {0}: {1}".format(exc.code, detail)) from exc
|
||||||
|
|
||||||
|
pcm, sample_rate, channels = _extract_audio(payload)
|
||||||
|
logger.info(
|
||||||
|
"Gemini '%s': %.1fs of audio in %.2fs for %d chars",
|
||||||
|
self._voice, len(pcm) / float(sample_rate * channels * 2),
|
||||||
|
time.perf_counter() - started, len(text),
|
||||||
|
)
|
||||||
|
return pcm, sample_rate, channels
|
||||||
|
|
||||||
|
# -- helpers ------------------------------------------------------------- #
|
||||||
|
def warm(self, text: str) -> int:
|
||||||
|
"""Pre-synthesise every chunk of `text`. Returns how many were fetched."""
|
||||||
|
fetched = 0
|
||||||
|
for chunk in split_sentences(text, self._chunk_chars):
|
||||||
|
if not self._is_cached(chunk):
|
||||||
|
self._ensure(chunk)
|
||||||
|
fetched += 1
|
||||||
|
return fetched
|
||||||
|
|
||||||
|
def cache_stats(self) -> Dict[str, object]:
|
||||||
|
stats = self.library.stats()
|
||||||
|
return {"entries": stats["count"], "bytes": stats["bytes"], "dir": stats["dir"]}
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
self.library.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_audio(payload: dict) -> Tuple[bytes, int, int]:
|
||||||
|
"""Pull PCM out of an interactions response.
|
||||||
|
|
||||||
|
Walks the steps rather than indexing a fixed path, so an extra step or a
|
||||||
|
reordered response does not break playback.
|
||||||
|
"""
|
||||||
|
for step in payload.get("steps", []) or []:
|
||||||
|
for part in step.get("content", []) or []:
|
||||||
|
if part.get("type") != "audio" or not part.get("data"):
|
||||||
|
continue
|
||||||
|
pcm = base64.b64decode(part["data"])
|
||||||
|
return pcm, int(part.get("sample_rate") or 24000), int(part.get("channels") or 1)
|
||||||
|
raise RuntimeError("no audio in response (status={0})".format(payload.get("status")))
|
||||||
453
backend/robot/local_audio.py
Normal file
453
backend/robot/local_audio.py
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
"""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
|
||||||
197
backend/robot/manager.py
Normal file
197
backend/robot/manager.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
"""Connection supervisor.
|
||||||
|
|
||||||
|
Owns the adapter's lifecycle so the web app never has to care whether the robot
|
||||||
|
is up. One background task:
|
||||||
|
|
||||||
|
disconnected -> connecting -> connected -> (health poll) -> disconnected -> ...
|
||||||
|
|
||||||
|
Connection failures are normal, not exceptional: they are published as status
|
||||||
|
events and retried with capped exponential backoff. The HTTP server keeps serving
|
||||||
|
the dashboard throughout, which is the whole point of the offline-first design.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from ..config.settings import Settings
|
||||||
|
from ..core.events import EventBus, EventType
|
||||||
|
from .base import RobotAdapter, RobotError, RobotState
|
||||||
|
from .factory import create_robot
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RobotManager:
|
||||||
|
def __init__(self, settings: Settings, bus: EventBus) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._bus = bus
|
||||||
|
self._adapter: RobotAdapter = create_robot(settings)
|
||||||
|
self._state = RobotState.DISCONNECTED
|
||||||
|
self._error: Optional[str] = None
|
||||||
|
self._error_code: Optional[str] = None
|
||||||
|
self._latency_ms: Optional[float] = None
|
||||||
|
self._connected_since: Optional[float] = None
|
||||||
|
self._attempts = 0
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._wake = asyncio.Event()
|
||||||
|
self._closing = False
|
||||||
|
|
||||||
|
# -- accessors ----------------------------------------------------------- #
|
||||||
|
@property
|
||||||
|
def adapter(self) -> RobotAdapter:
|
||||||
|
return self._adapter
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> RobotState:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._state == RobotState.CONNECTED and self._adapter.is_connected
|
||||||
|
|
||||||
|
def status_dict(self) -> Dict[str, Any]:
|
||||||
|
info = self._adapter.info
|
||||||
|
return {
|
||||||
|
"state": self._state.value,
|
||||||
|
"connected": self.is_connected,
|
||||||
|
"speaking": self._adapter.is_speaking,
|
||||||
|
"robot": info.to_dict(),
|
||||||
|
"latencyMs": round(self._latency_ms, 1) if self._latency_ms is not None else None,
|
||||||
|
"uptimeSeconds": (
|
||||||
|
round(time.time() - self._connected_since, 1) if self._connected_since else None
|
||||||
|
),
|
||||||
|
"attempts": self._attempts,
|
||||||
|
"error": self._error,
|
||||||
|
"errorCode": self._error_code,
|
||||||
|
"configIssues": [issue.to_dict() for issue in self._settings.issues],
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------- #
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._closing = False
|
||||||
|
self._task = asyncio.create_task(self._supervise(), name="robot-supervisor")
|
||||||
|
self._publish()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._closing = True
|
||||||
|
self._wake.set()
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
try:
|
||||||
|
await self._adapter.disconnect()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
logger.debug("adapter disconnect raised", exc_info=True)
|
||||||
|
self._set_state(RobotState.DISCONNECTED)
|
||||||
|
|
||||||
|
def request_reconnect(self) -> None:
|
||||||
|
"""Ask the supervisor to retry immediately (used by the API)."""
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
async def rebuild(self, settings: Settings) -> None:
|
||||||
|
"""Swap in a new configuration without restarting the process."""
|
||||||
|
await self.stop()
|
||||||
|
self._settings = settings
|
||||||
|
self._adapter = create_robot(settings)
|
||||||
|
self._error = None
|
||||||
|
self._error_code = None
|
||||||
|
self._latency_ms = None
|
||||||
|
self._attempts = 0
|
||||||
|
await self.start()
|
||||||
|
|
||||||
|
# -- supervisor ---------------------------------------------------------- #
|
||||||
|
async def _supervise(self) -> None:
|
||||||
|
delay = self._settings.robot.reconnect_min_delay
|
||||||
|
while not self._closing:
|
||||||
|
try:
|
||||||
|
if not self._adapter.is_connected:
|
||||||
|
self._attempts += 1
|
||||||
|
self._set_state(RobotState.CONNECTING)
|
||||||
|
started = time.perf_counter()
|
||||||
|
await self._adapter.connect()
|
||||||
|
self._latency_ms = (time.perf_counter() - started) * 1000
|
||||||
|
self._connected_since = time.time()
|
||||||
|
self._error = None
|
||||||
|
self._error_code = None
|
||||||
|
delay = self._settings.robot.reconnect_min_delay
|
||||||
|
self._set_state(RobotState.CONNECTED)
|
||||||
|
logger.info("robot connected (%.0f ms)", self._latency_ms)
|
||||||
|
|
||||||
|
await self._sleep_or_wake(self._settings.robot.health_interval)
|
||||||
|
if self._closing:
|
||||||
|
break
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
healthy = await self._adapter.health_check()
|
||||||
|
elapsed = (time.perf_counter() - started) * 1000
|
||||||
|
if healthy:
|
||||||
|
# Smooth the latency reading so the UI badge does not flicker.
|
||||||
|
self._latency_ms = (
|
||||||
|
elapsed if self._latency_ms is None else self._latency_ms * 0.7 + elapsed * 0.3
|
||||||
|
)
|
||||||
|
if self._state != RobotState.CONNECTED:
|
||||||
|
self._set_state(RobotState.CONNECTED)
|
||||||
|
else:
|
||||||
|
self._publish() # keep latency fresh in the dashboard
|
||||||
|
else:
|
||||||
|
logger.warning("robot health check failed - reconnecting")
|
||||||
|
self._connected_since = None
|
||||||
|
self._error = "Lost connection to the robot."
|
||||||
|
self._error_code = "unreachable"
|
||||||
|
self._set_state(RobotState.DISCONNECTED)
|
||||||
|
await self._safe_disconnect()
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except RobotError as exc:
|
||||||
|
self._on_failure(exc.user_message, exc.code)
|
||||||
|
delay = await self._backoff(delay)
|
||||||
|
except Exception as exc: # never let the supervisor die
|
||||||
|
logger.exception("unexpected supervisor error")
|
||||||
|
self._on_failure("Unexpected robot error: {0}".format(exc), "internal")
|
||||||
|
delay = await self._backoff(delay)
|
||||||
|
|
||||||
|
def _on_failure(self, message: str, code: str) -> None:
|
||||||
|
self._connected_since = None
|
||||||
|
self._latency_ms = None
|
||||||
|
self._error = message
|
||||||
|
self._error_code = code
|
||||||
|
self._set_state(RobotState.ERROR if code == "not_configured" else RobotState.DISCONNECTED)
|
||||||
|
|
||||||
|
async def _backoff(self, delay: float) -> float:
|
||||||
|
await self._sleep_or_wake(delay)
|
||||||
|
return min(delay * 2, self._settings.robot.reconnect_max_delay)
|
||||||
|
|
||||||
|
async def _sleep_or_wake(self, seconds: float) -> None:
|
||||||
|
"""Sleep, but return early if someone calls request_reconnect()."""
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._wake.wait(), timeout=max(0.1, seconds))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
self._wake.clear()
|
||||||
|
|
||||||
|
async def _safe_disconnect(self) -> None:
|
||||||
|
try:
|
||||||
|
await self._adapter.disconnect()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
logger.debug("disconnect during recovery raised", exc_info=True)
|
||||||
|
|
||||||
|
# -- events -------------------------------------------------------------- #
|
||||||
|
def _set_state(self, state: RobotState) -> None:
|
||||||
|
changed = state != self._state
|
||||||
|
self._state = state
|
||||||
|
if changed:
|
||||||
|
logger.info("robot state -> %s", state.value)
|
||||||
|
self._publish()
|
||||||
|
|
||||||
|
def _publish(self) -> None:
|
||||||
|
self._bus.publish(EventType.ROBOT_STATUS, self.status_dict())
|
||||||
365
backend/robot/mock_robot.py
Normal file
365
backend/robot/mock_robot.py
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
"""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
|
||||||
67
backend/robot/transports/__init__.py
Normal file
67
backend/robot/transports/__init__.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""Transport registry for the AGIBOT A3 adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable, Dict
|
||||||
|
|
||||||
|
from ...config.settings import Settings
|
||||||
|
from ..base import RobotError
|
||||||
|
from .base import SpeechTransport, TransportAck
|
||||||
|
|
||||||
|
_BUILDERS: Dict[str, Callable[[Settings], SpeechTransport]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _aimdk(settings: Settings) -> SpeechTransport:
|
||||||
|
from .aimdk_transport import AimdkTransport
|
||||||
|
|
||||||
|
return AimdkTransport(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _http(settings: Settings) -> SpeechTransport:
|
||||||
|
from .http_transport import HttpTransport
|
||||||
|
|
||||||
|
return HttpTransport(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _ws(settings: Settings) -> SpeechTransport:
|
||||||
|
from .ws_transport import WebSocketTransport
|
||||||
|
|
||||||
|
return WebSocketTransport(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _ros2(settings: Settings) -> SpeechTransport:
|
||||||
|
from .ros2_transport import Ros2Transport
|
||||||
|
|
||||||
|
return Ros2Transport(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh(settings: Settings) -> SpeechTransport:
|
||||||
|
from .ssh_transport import SshTransport
|
||||||
|
|
||||||
|
return SshTransport(settings)
|
||||||
|
|
||||||
|
|
||||||
|
_BUILDERS.update(
|
||||||
|
{"aimdk": _aimdk, "http": _http, "ws": _ws, "ros2": _ros2, "ssh": _ssh}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def available_transports() -> list:
|
||||||
|
return sorted(_BUILDERS)
|
||||||
|
|
||||||
|
|
||||||
|
def create_transport(settings: Settings) -> SpeechTransport:
|
||||||
|
"""Instantiate the transport named by A3_TRANSPORT (imported lazily)."""
|
||||||
|
name = settings.a3.transport
|
||||||
|
builder = _BUILDERS.get(name)
|
||||||
|
if builder is None:
|
||||||
|
raise RobotError(
|
||||||
|
"unknown transport '{0}'".format(name),
|
||||||
|
user_message="Unknown A3_TRANSPORT '{0}'. Valid values: {1}.".format(
|
||||||
|
name, ", ".join(available_transports())
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return builder(settings)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SpeechTransport", "TransportAck", "create_transport", "available_transports"]
|
||||||
354
backend/robot/transports/aimdk_transport.py
Normal file
354
backend/robot/transports/aimdk_transport.py
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
"""AimDK HTTP JSON-RPC transport - the documented AGIBOT A3 speech path.
|
||||||
|
|
||||||
|
WHAT THIS IMPLEMENTS
|
||||||
|
--------------------
|
||||||
|
AgiBot's A3 developer guide documents an HTTP JSON-RPC interface served by the
|
||||||
|
robot's HDU (head unit) through their AimRT runtime:
|
||||||
|
|
||||||
|
POST http://<robot>:<port>/rpc/<service_name>/<method_name>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
and, on that interface, a text-to-speech service:
|
||||||
|
|
||||||
|
POST http://<robot>:59301/rpc/aimdk.protocol.TTSService/PlayTTS
|
||||||
|
{"text": "...", "priority_level": "INTERACTION_L6", "domain": "...",
|
||||||
|
"trace_id": "...", "is_interrupted": true}
|
||||||
|
|
||||||
|
-> {"trace_id": "<yours>_<suffix>", "is_sucess": true, "error_message": "", ...}
|
||||||
|
|
||||||
|
Docs (public, no login required):
|
||||||
|
https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play
|
||||||
|
https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/03-second_develop_interface_overview
|
||||||
|
|
||||||
|
This is the fastest available path: text in, robot's own TTS out. No audio is
|
||||||
|
generated or transferred by this PC.
|
||||||
|
|
||||||
|
FOUR TRAPS THIS FILE HANDLES DELIBERATELY
|
||||||
|
-----------------------------------------
|
||||||
|
1. `is_sucess` - the success flag is spelled with one 'c' in AgiBot's docs. Both
|
||||||
|
spellings are read, so a corrected firmware does not break us.
|
||||||
|
2. The **returned** trace_id differs from the one you send (a random suffix is
|
||||||
|
appended). Stop only works with the returned value, so it is what we keep.
|
||||||
|
3. A 1024-byte request limit on `text`. Long input is chunked on sentence
|
||||||
|
boundaries by UTF-8 byte length - a 1000-character Chinese sentence is ~3000
|
||||||
|
bytes and would otherwise be rejected.
|
||||||
|
4. AgiBot's constraints page limits status polling to <= 0.2 Hz and warns that
|
||||||
|
high-frequency RPC can destabilise the robot. So the liveness probe is a TCP
|
||||||
|
connect, never an RPC, and completion is estimated rather than polled.
|
||||||
|
|
||||||
|
EVERYTHING IS STILL CONFIGURABLE. Service and method names, the port, the
|
||||||
|
priority level and the byte limit are all .env keys, because AimRT publishes no
|
||||||
|
endpoint-discovery route and AgiBot does not guarantee port stability across
|
||||||
|
firmware. If your unit differs, you edit .env - not this file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from ...config.settings import Settings
|
||||||
|
from ..base import (
|
||||||
|
ProgressCallback,
|
||||||
|
RobotTimeout,
|
||||||
|
RobotUnreachable,
|
||||||
|
SpeechFailed,
|
||||||
|
SpeechProgress,
|
||||||
|
SpeechRequest,
|
||||||
|
SpeechStage,
|
||||||
|
TransportNotAvailable,
|
||||||
|
)
|
||||||
|
from .base import SpeechTransport, TransportAck, now_ms
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
httpx = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
# Sentence-ish boundaries, most preferred first. Covers CJK punctuation because
|
||||||
|
# the robot's own examples are Chinese.
|
||||||
|
_BREAKS = ("\n", "。", "!", "?", ". ", "! ", "? ", ";", "; ", ",", ", ", " ")
|
||||||
|
|
||||||
|
# Values AgiBot's docs treat as "no error" in the RPC envelope.
|
||||||
|
_OK_CODES = ("", "0", 0, None)
|
||||||
|
|
||||||
|
|
||||||
|
class AimdkTransport(SpeechTransport):
|
||||||
|
"""AGIBOT A3 native TTS over AimRT HTTP JSON-RPC."""
|
||||||
|
|
||||||
|
name = "aimdk"
|
||||||
|
reports_completion = False # PlayTTS acks acceptance, not end-of-audio
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._robot = settings.robot
|
||||||
|
self._a3 = settings.a3
|
||||||
|
self._client: Optional["httpx.AsyncClient"] = None
|
||||||
|
#: trace_id the robot gave us for the utterance in flight - Stop needs it
|
||||||
|
self._active_trace: Optional[str] = None
|
||||||
|
self._last_response: Any = None
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------- #
|
||||||
|
async def open(self) -> None:
|
||||||
|
if httpx is None: # pragma: no cover
|
||||||
|
raise TransportNotAvailable(
|
||||||
|
"httpx is not installed",
|
||||||
|
user_message="This transport requires 'httpx'. Run: pip install -r requirements.txt",
|
||||||
|
)
|
||||||
|
if self._client is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json", # mandatory per AgiBot's docs
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
# AimRT honours a whole-second Timeout header; its own default is 5 s.
|
||||||
|
"Timeout": str(max(1, int(self._robot.request_timeout))),
|
||||||
|
}
|
||||||
|
headers.update(self._a3.http_headers or {})
|
||||||
|
if self._a3.http_auth_token:
|
||||||
|
headers.setdefault("Authorization", "Bearer " + self._a3.http_auth_token)
|
||||||
|
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self._robot.base_url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=httpx.Timeout(self._robot.request_timeout, connect=self._robot.connect_timeout),
|
||||||
|
limits=httpx.Limits(max_keepalive_connections=4, max_connections=8),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"AimDK transport ready: %s/rpc/%s/%s",
|
||||||
|
self._robot.base_url, self._a3.aimdk_service, self._a3.aimdk_play_method,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
try:
|
||||||
|
await self._client.aclose()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
# -- health -------------------------------------------------------------- #
|
||||||
|
async def probe(self) -> bool:
|
||||||
|
"""TCP connect only.
|
||||||
|
|
||||||
|
Deliberately not an RPC call: AgiBot's constraints page caps status RPC
|
||||||
|
polling at 0.2 Hz, and this runs on the health-check interval. A socket
|
||||||
|
connect proves the service port is listening without adding RPC load.
|
||||||
|
"""
|
||||||
|
if not self._robot.ip:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
fut = asyncio.open_connection(self._robot.ip, self._robot.port)
|
||||||
|
_, writer = await asyncio.wait_for(fut, timeout=self._robot.connect_timeout)
|
||||||
|
writer.close()
|
||||||
|
try:
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("AimDK probe to %s failed: %s", self._robot.address, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- speech -------------------------------------------------------------- #
|
||||||
|
async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> TransportAck:
|
||||||
|
if self._client is None:
|
||||||
|
await self.open()
|
||||||
|
|
||||||
|
chunks = chunk_text(request.text, self._a3.aimdk_max_bytes)
|
||||||
|
if not chunks:
|
||||||
|
raise SpeechFailed("nothing to say", user_message="Please enter some text.")
|
||||||
|
|
||||||
|
await on_progress(SpeechProgress(request.id, SpeechStage.SENDING, "Sending to robot..."))
|
||||||
|
started = time.perf_counter()
|
||||||
|
ack_ms: Optional[int] = None
|
||||||
|
detail: Optional[str] = None
|
||||||
|
|
||||||
|
for index, chunk in enumerate(chunks):
|
||||||
|
payload = {
|
||||||
|
"text": chunk,
|
||||||
|
"priority_level": self._a3.aimdk_priority,
|
||||||
|
"domain": self._a3.aimdk_domain,
|
||||||
|
"trace_id": "{0}{1}".format(request.id, "" if index == 0 else "-%d" % index),
|
||||||
|
# Only the first chunk may interrupt whatever was playing; the
|
||||||
|
# rest must queue behind it or the utterance cuts itself off.
|
||||||
|
"is_interrupted": self._a3.aimdk_interrupt if index == 0 else False,
|
||||||
|
}
|
||||||
|
trace, chunk_detail = await self._call(
|
||||||
|
self._a3.aimdk_play_method, payload, what="PlayTTS"
|
||||||
|
)
|
||||||
|
if index == 0:
|
||||||
|
ack_ms = now_ms(started)
|
||||||
|
detail = chunk_detail
|
||||||
|
if len(chunks) > 1:
|
||||||
|
detail = "{0} parts".format(len(chunks))
|
||||||
|
if trace:
|
||||||
|
self._active_trace = trace
|
||||||
|
|
||||||
|
return TransportAck(
|
||||||
|
accepted=True,
|
||||||
|
ack_latency_ms=ack_ms if ack_ms is not None else now_ms(started),
|
||||||
|
detail=detail,
|
||||||
|
raw=self._last_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> bool:
|
||||||
|
"""Stop the utterance using the trace_id the robot returned to us."""
|
||||||
|
trace = self._active_trace
|
||||||
|
if not trace:
|
||||||
|
logger.info("no active trace_id - nothing to stop")
|
||||||
|
return False
|
||||||
|
if self._client is None:
|
||||||
|
await self.open()
|
||||||
|
try:
|
||||||
|
await self._call(self._a3.aimdk_stop_method, {"trace_id": trace}, what="StopTTS")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("StopTTS failed: %s", exc)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
self._active_trace = None
|
||||||
|
|
||||||
|
async def describe(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"baseUrl": self._robot.base_url,
|
||||||
|
"service": self._a3.aimdk_service,
|
||||||
|
"playMethod": self._a3.aimdk_play_method,
|
||||||
|
"stopMethod": self._a3.aimdk_stop_method,
|
||||||
|
"priority": self._a3.aimdk_priority,
|
||||||
|
"domain": self._a3.aimdk_domain,
|
||||||
|
"maxBytesPerChunk": self._a3.aimdk_max_bytes,
|
||||||
|
"activeTraceId": self._active_trace,
|
||||||
|
"lastResponse": self._last_response,
|
||||||
|
"docs": "https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play",
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- internals ----------------------------------------------------------- #
|
||||||
|
def _route(self, method: str) -> str:
|
||||||
|
# AimRT strips the "pb:/" func-name prefix when building the HTTP route -
|
||||||
|
# the URL is /rpc/<service>/<method> and must never contain "pb:/".
|
||||||
|
return "/rpc/{0}/{1}".format(self._a3.aimdk_service.lstrip("/"), method)
|
||||||
|
|
||||||
|
async def _call(self, method: str, payload: Dict[str, Any], what: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""One RPC. Returns (trace_id, human detail). Raises on any failure."""
|
||||||
|
assert self._client is not None
|
||||||
|
route = self._route(method)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._client.post(route, json=payload)
|
||||||
|
except Exception as exc:
|
||||||
|
if httpx is not None and isinstance(exc, httpx.TimeoutException):
|
||||||
|
raise RobotTimeout(
|
||||||
|
"timeout calling {0}{1}".format(self._robot.base_url, route)
|
||||||
|
) from exc
|
||||||
|
raise RobotUnreachable(
|
||||||
|
"cannot reach {0}{1}: {2}".format(self._robot.base_url, route, exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# AimRT maps an RPC-level failure to HTTP 500 and an unknown route to 404.
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise SpeechFailed(
|
||||||
|
"route {0} not found (HTTP 404)".format(route),
|
||||||
|
user_message=(
|
||||||
|
"The robot does not expose {0} at {1}. Check A3_AIMDK_SERVICE / "
|
||||||
|
"A3_AIMDK_PLAY_METHOD and the robot's firmware version."
|
||||||
|
).format(method, route),
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise SpeechFailed(
|
||||||
|
"{0} returned HTTP {1}: {2}".format(what, response.status_code, response.text[:300]),
|
||||||
|
user_message="Speech request failed (robot returned HTTP {0}).".format(
|
||||||
|
response.status_code
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except Exception:
|
||||||
|
body = {"raw": response.text[:300]}
|
||||||
|
self._last_response = body
|
||||||
|
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Envelope check, when the firmware wraps replies in a header block.
|
||||||
|
header = body.get("header")
|
||||||
|
if isinstance(header, dict):
|
||||||
|
code = header.get("code")
|
||||||
|
if code not in _OK_CODES:
|
||||||
|
raise SpeechFailed(
|
||||||
|
"{0} rejected: code={1} msg={2}".format(what, code, header.get("msg")),
|
||||||
|
user_message="Speech request failed: {0}".format(
|
||||||
|
header.get("msg") or "robot returned error code {0}".format(code)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# AgiBot's documented field is `is_sucess` (one 'c'). Read both spellings
|
||||||
|
# so a future firmware that fixes the typo keeps working.
|
||||||
|
success = body.get("is_sucess", body.get("is_success"))
|
||||||
|
if success is False:
|
||||||
|
raise SpeechFailed(
|
||||||
|
"{0} rejected: {1}".format(what, body.get("error_message")),
|
||||||
|
user_message="Speech request failed: {0}".format(
|
||||||
|
body.get("error_message") or "the robot rejected the utterance."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
trace = body.get("trace_id")
|
||||||
|
return (trace if isinstance(trace, str) else None), None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# chunking
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def chunk_text(text: str, max_bytes: int) -> List[str]:
|
||||||
|
"""Split `text` into pieces of at most `max_bytes` UTF-8 bytes.
|
||||||
|
|
||||||
|
The limit AgiBot documents is a *byte* limit, so counting characters is not
|
||||||
|
enough - Chinese text is three bytes per character. Splits prefer sentence
|
||||||
|
ends, then clause punctuation, then spaces, and finally cut mid-word rather
|
||||||
|
than emit an over-length chunk.
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
budget = max(64, int(max_bytes) - 2) # small margin for encoding edge cases
|
||||||
|
if len(text.encode("utf-8")) <= budget:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
chunks: List[str] = []
|
||||||
|
remaining = text
|
||||||
|
while remaining:
|
||||||
|
if len(remaining.encode("utf-8")) <= budget:
|
||||||
|
chunks.append(remaining.strip())
|
||||||
|
break
|
||||||
|
|
||||||
|
window = _prefix_within_bytes(remaining, budget)
|
||||||
|
split_at = -1
|
||||||
|
for marker in _BREAKS:
|
||||||
|
found = window.rfind(marker)
|
||||||
|
if found > budget // 4: # avoid pathologically tiny chunks
|
||||||
|
split_at = found + len(marker)
|
||||||
|
break
|
||||||
|
if split_at <= 0:
|
||||||
|
split_at = len(window)
|
||||||
|
|
||||||
|
piece = remaining[:split_at].strip()
|
||||||
|
if piece:
|
||||||
|
chunks.append(piece)
|
||||||
|
remaining = remaining[split_at:].lstrip()
|
||||||
|
|
||||||
|
return [chunk for chunk in chunks if chunk]
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix_within_bytes(text: str, budget: int) -> str:
|
||||||
|
"""Longest prefix of `text` that fits in `budget` UTF-8 bytes."""
|
||||||
|
encoded = text.encode("utf-8")
|
||||||
|
if len(encoded) <= budget:
|
||||||
|
return text
|
||||||
|
# Decode back, dropping any partial trailing character.
|
||||||
|
return encoded[:budget].decode("utf-8", "ignore")
|
||||||
136
backend/robot/transports/base.py
Normal file
136
backend/robot/transports/base.py
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"""Transport contract for the AGIBOT A3 adapter.
|
||||||
|
|
||||||
|
A *transport* knows how to move one "speak this text" command onto the wire and
|
||||||
|
back. It knows nothing about history, the UI, or the event bus.
|
||||||
|
|
||||||
|
Why the payloads are templates rather than code
|
||||||
|
-----------------------------------------------
|
||||||
|
The exact request body an A3 expects is defined by AgiBot's SDK/API reference for
|
||||||
|
your specific unit and firmware. Inventing one would be worse than useless, so the
|
||||||
|
shape is supplied as configuration:
|
||||||
|
|
||||||
|
A3_HTTP_SPEAK_PATH=/some/documented/path
|
||||||
|
A3_HTTP_SPEAK_PAYLOAD={"text": "{text}", "voice": "{voice}"}
|
||||||
|
|
||||||
|
Placeholders available in any template string:
|
||||||
|
{text} {id} {voice} {language} {volume} {speed}
|
||||||
|
|
||||||
|
A template string that is *exactly* one placeholder keeps the value's native type
|
||||||
|
(so {volume} becomes a JSON number, not the string "0.8"). Keys whose rendered
|
||||||
|
value is null are dropped, so unset optional parameters are simply not sent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from ..base import ProgressCallback, SpeechRequest
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TransportAck:
|
||||||
|
"""What the robot said when it accepted (or rejected) the utterance."""
|
||||||
|
|
||||||
|
accepted: bool
|
||||||
|
ack_latency_ms: int
|
||||||
|
detail: Optional[str] = None
|
||||||
|
raw: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechTransport(abc.ABC):
|
||||||
|
"""One wire protocol for delivering text to the robot."""
|
||||||
|
|
||||||
|
name: str = "transport"
|
||||||
|
|
||||||
|
#: True when the robot itself tells us the utterance finished. When False the
|
||||||
|
#: adapter estimates the speaking duration instead of guessing completion.
|
||||||
|
reports_completion: bool = False
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def open(self) -> None:
|
||||||
|
"""Set up any persistent client/socket. Raises RobotError on failure."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Tear down. Must never raise."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def probe(self) -> bool:
|
||||||
|
"""Cheap liveness check against the robot."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def speak(
|
||||||
|
self, request: SpeechRequest, on_progress: ProgressCallback
|
||||||
|
) -> TransportAck:
|
||||||
|
"""Deliver the utterance. Return as soon as the robot acknowledges it."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def stop(self) -> bool:
|
||||||
|
"""Ask the robot to stop speaking. Returns True if a stop was delivered."""
|
||||||
|
|
||||||
|
async def describe(self) -> Dict[str, Any]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# payload templating
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def build_context(request: SpeechRequest, defaults: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
ctx: Dict[str, Any] = {
|
||||||
|
"text": request.text,
|
||||||
|
"id": request.id,
|
||||||
|
"voice": request.voice if request.voice is not None else defaults.get("voice"),
|
||||||
|
"language": request.language if request.language is not None else defaults.get("language"),
|
||||||
|
"volume": defaults.get("volume"),
|
||||||
|
"speed": defaults.get("speed"),
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def render_payload(template: Any, ctx: Dict[str, Any], drop_null: bool = True) -> Any:
|
||||||
|
"""Recursively substitute placeholders into a parsed-JSON template."""
|
||||||
|
if isinstance(template, str):
|
||||||
|
stripped = template.strip()
|
||||||
|
if stripped.startswith("{") and stripped.endswith("}"):
|
||||||
|
key = stripped[1:-1]
|
||||||
|
if key in ctx:
|
||||||
|
return ctx[key] # preserve native type
|
||||||
|
rendered = template
|
||||||
|
for key, value in ctx.items():
|
||||||
|
token = "{" + key + "}"
|
||||||
|
if token in rendered:
|
||||||
|
rendered = rendered.replace(token, "" if value is None else str(value))
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
if isinstance(template, dict):
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
for key, value in template.items():
|
||||||
|
resolved = render_payload(value, ctx, drop_null)
|
||||||
|
if drop_null and resolved is None:
|
||||||
|
continue # unset optional parameter - do not send the key at all
|
||||||
|
out[key] = resolved
|
||||||
|
return out
|
||||||
|
|
||||||
|
if isinstance(template, list):
|
||||||
|
items = [render_payload(item, ctx, drop_null) for item in template]
|
||||||
|
return [item for item in items if not (drop_null and item is None)]
|
||||||
|
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
def render_path(template: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
"""Render a URL path/query template with percent-encoded values."""
|
||||||
|
rendered = template
|
||||||
|
for key, value in ctx.items():
|
||||||
|
token = "{" + key + "}"
|
||||||
|
if token in rendered:
|
||||||
|
rendered = rendered.replace(token, quote("" if value is None else str(value), safe=""))
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def now_ms(since: float) -> int:
|
||||||
|
return int((time.perf_counter() - since) * 1000)
|
||||||
232
backend/robot/transports/http_transport.py
Normal file
232
backend/robot/transports/http_transport.py
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
"""HTTP/REST transport.
|
||||||
|
|
||||||
|
Lowest-friction path when the robot exposes an HTTP API: one keep-alive
|
||||||
|
connection is opened at startup and reused for every utterance, so a Speak press
|
||||||
|
costs a single request/response round trip with no TCP or TLS handshake.
|
||||||
|
|
||||||
|
The endpoint, method and body come from .env - see transports/base.py for why.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from ...config.settings import Settings
|
||||||
|
from ..base import (
|
||||||
|
ProgressCallback,
|
||||||
|
RobotTimeout,
|
||||||
|
RobotUnreachable,
|
||||||
|
SpeechFailed,
|
||||||
|
SpeechProgress,
|
||||||
|
SpeechRequest,
|
||||||
|
SpeechStage,
|
||||||
|
TransportNotAvailable,
|
||||||
|
)
|
||||||
|
from .base import SpeechTransport, TransportAck, build_context, now_ms, render_path, render_payload
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try: # httpx is a hard requirement for this transport only
|
||||||
|
import httpx
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
httpx = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
class HttpTransport(SpeechTransport):
|
||||||
|
name = "http"
|
||||||
|
reports_completion = False # a REST ack rarely means "finished speaking"
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._robot = settings.robot
|
||||||
|
self._a3 = settings.a3
|
||||||
|
self._client: Optional["httpx.AsyncClient"] = None
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------- #
|
||||||
|
async def open(self) -> None:
|
||||||
|
if httpx is None:
|
||||||
|
raise TransportNotAvailable(
|
||||||
|
"httpx is not installed",
|
||||||
|
user_message="HTTP transport requires the 'httpx' package. Run: pip install -r requirements.txt",
|
||||||
|
)
|
||||||
|
if self._client is not None:
|
||||||
|
return
|
||||||
|
headers = {"Content-Type": "application/json", "Connection": "keep-alive"}
|
||||||
|
headers.update(self._a3.http_headers or {})
|
||||||
|
if self._a3.http_auth_token:
|
||||||
|
headers.setdefault("Authorization", "Bearer " + self._a3.http_auth_token)
|
||||||
|
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self._robot.base_url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=httpx.Timeout(
|
||||||
|
self._robot.request_timeout, connect=self._robot.connect_timeout
|
||||||
|
),
|
||||||
|
# Keep the socket hot between utterances - this is the latency win.
|
||||||
|
limits=httpx.Limits(max_keepalive_connections=4, max_connections=8),
|
||||||
|
verify=False if self._robot.use_tls else True,
|
||||||
|
)
|
||||||
|
logger.info("HTTP transport ready for %s", self._robot.base_url)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
try:
|
||||||
|
await self._client.aclose()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
# -- health -------------------------------------------------------------- #
|
||||||
|
async def probe(self) -> bool:
|
||||||
|
"""Prefer a documented status endpoint; fall back to a TCP connect."""
|
||||||
|
if self._a3.http_status_path and self._client is not None:
|
||||||
|
try:
|
||||||
|
response = await self._client.request(
|
||||||
|
self._a3.http_status_method,
|
||||||
|
self._a3.http_status_path,
|
||||||
|
timeout=self._robot.connect_timeout,
|
||||||
|
)
|
||||||
|
return response.status_code < 500
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("HTTP probe failed: %s", exc)
|
||||||
|
return False
|
||||||
|
return await self._tcp_probe()
|
||||||
|
|
||||||
|
async def _tcp_probe(self) -> bool:
|
||||||
|
"""No status endpoint configured - at least verify the port is open."""
|
||||||
|
if not self._robot.ip:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
fut = asyncio.open_connection(self._robot.ip, self._robot.port)
|
||||||
|
reader, writer = await asyncio.wait_for(fut, timeout=self._robot.connect_timeout)
|
||||||
|
writer.close()
|
||||||
|
try:
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("TCP probe %s failed: %s", self._robot.address, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- speech -------------------------------------------------------------- #
|
||||||
|
async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> TransportAck:
|
||||||
|
if self._client is None:
|
||||||
|
await self.open()
|
||||||
|
assert self._client is not None
|
||||||
|
|
||||||
|
ctx = build_context(request, self._defaults())
|
||||||
|
path = render_path(self._a3.http_speak_path, ctx)
|
||||||
|
payload = render_payload(self._a3.http_speak_payload, ctx)
|
||||||
|
method = self._a3.http_speak_method
|
||||||
|
|
||||||
|
await on_progress(SpeechProgress(request.id, SpeechStage.SENDING, "Sending to robot..."))
|
||||||
|
started = time.perf_counter()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if method in ("GET", "DELETE", "HEAD"):
|
||||||
|
response = await self._client.request(method, path)
|
||||||
|
else:
|
||||||
|
response = await self._client.request(method, path, json=payload)
|
||||||
|
except Exception as exc: # httpx timeout/connect errors
|
||||||
|
if httpx is not None and isinstance(exc, httpx.TimeoutException):
|
||||||
|
raise RobotTimeout(
|
||||||
|
"timeout calling {0}{1}: {2}".format(self._robot.base_url, path, exc)
|
||||||
|
) from exc
|
||||||
|
raise RobotUnreachable(
|
||||||
|
"cannot reach {0}{1}: {2}".format(self._robot.base_url, path, exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
ack_ms = now_ms(started)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise SpeechFailed(
|
||||||
|
"robot returned HTTP {0}: {1}".format(response.status_code, response.text[:400]),
|
||||||
|
user_message="Speech request failed (robot returned HTTP {0}).".format(
|
||||||
|
response.status_code
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
raw: Any = None
|
||||||
|
try:
|
||||||
|
raw = response.json()
|
||||||
|
except Exception:
|
||||||
|
raw = response.text[:400]
|
||||||
|
|
||||||
|
# Optional: a documented success flag inside the JSON body.
|
||||||
|
if self._a3.http_success_field and isinstance(raw, dict):
|
||||||
|
value = _dig(raw, self._a3.http_success_field)
|
||||||
|
if value is not None and not _truthy(value):
|
||||||
|
raise SpeechFailed(
|
||||||
|
"robot reported failure: {0}".format(raw),
|
||||||
|
user_message="Speech request failed. The robot rejected the utterance.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return TransportAck(accepted=True, ack_latency_ms=ack_ms, raw=raw)
|
||||||
|
|
||||||
|
async def stop(self) -> bool:
|
||||||
|
if not self._a3.http_stop_path:
|
||||||
|
logger.info("no A3_HTTP_STOP_PATH configured - cannot stop robot-side playback")
|
||||||
|
return False
|
||||||
|
if self._client is None:
|
||||||
|
await self.open()
|
||||||
|
assert self._client is not None
|
||||||
|
|
||||||
|
ctx = build_context(SpeechRequest(text=""), self._defaults())
|
||||||
|
path = render_path(self._a3.http_stop_path, ctx)
|
||||||
|
payload = render_payload(self._a3.http_stop_payload, ctx)
|
||||||
|
try:
|
||||||
|
if self._a3.http_stop_method in ("GET", "DELETE", "HEAD"):
|
||||||
|
response = await self._client.request(self._a3.http_stop_method, path)
|
||||||
|
else:
|
||||||
|
response = await self._client.request(self._a3.http_stop_method, path, json=payload)
|
||||||
|
return response.status_code < 400
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("stop request failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def describe(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"baseUrl": self._robot.base_url,
|
||||||
|
"speak": "{0} {1}".format(self._a3.http_speak_method, self._a3.http_speak_path or "(unset)"),
|
||||||
|
"stop": "{0} {1}".format(self._a3.http_stop_method, self._a3.http_stop_path or "(unset)"),
|
||||||
|
"status": "{0} {1}".format(self._a3.http_status_method, self._a3.http_status_path or "(TCP probe)"),
|
||||||
|
"keepAlive": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _defaults(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"voice": self._a3.voice,
|
||||||
|
"language": self._a3.language,
|
||||||
|
"volume": self._a3.volume,
|
||||||
|
"speed": self._a3.speed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dig(data: Dict[str, Any], dotted: str) -> Any:
|
||||||
|
node: Any = data
|
||||||
|
for part in dotted.split("."):
|
||||||
|
if not isinstance(node, dict) or part not in node:
|
||||||
|
return None
|
||||||
|
node = node[part]
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy(value: Any) -> bool:
|
||||||
|
"""Interpret a success field.
|
||||||
|
|
||||||
|
Deliberately liberal: robot APIs variously signal success as `true`, `"ok"`,
|
||||||
|
`0` (a result code) or `200` (an embedded HTTP status).
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value in (0, 200, 1)
|
||||||
|
if isinstance(value, float):
|
||||||
|
return value in (0.0, 1.0, 200.0)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in ("0", "1", "200", "true", "ok", "success", "succeeded", "yes")
|
||||||
|
return bool(value)
|
||||||
236
backend/robot/transports/ros2_transport.py
Normal file
236
backend/robot/transports/ros2_transport.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"""ROS 2 transport.
|
||||||
|
|
||||||
|
Used when the robot exposes its speech interface as a ROS 2 topic or service on
|
||||||
|
the shared DDS network rather than as a web API.
|
||||||
|
|
||||||
|
`rclpy` is imported lazily: the rest of this project runs on a plain Windows PC
|
||||||
|
with no ROS installation, and only this file needs one. The message/service type
|
||||||
|
is resolved from its ROS type string at runtime, so no interface is hard-coded:
|
||||||
|
|
||||||
|
A3_ROS_SPEAK_TOPIC=/the/documented/topic
|
||||||
|
A3_ROS_SPEAK_MSG_TYPE=std_msgs/msg/String
|
||||||
|
A3_ROS_SPEAK_MSG_FIELD=data
|
||||||
|
|
||||||
|
DDS discovery is multicast-based, so the PC and the robot must be on the same L2
|
||||||
|
network segment and share ROS_DOMAIN_ID. See docs/NETWORK.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from ...config.settings import Settings
|
||||||
|
from ..base import (
|
||||||
|
ProgressCallback,
|
||||||
|
RobotUnreachable,
|
||||||
|
SpeechFailed,
|
||||||
|
SpeechProgress,
|
||||||
|
SpeechRequest,
|
||||||
|
SpeechStage,
|
||||||
|
TransportNotAvailable,
|
||||||
|
)
|
||||||
|
from .base import SpeechTransport, TransportAck, build_context, now_ms
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_type(type_string: str) -> Any:
|
||||||
|
"""'std_msgs/msg/String' -> the std_msgs.msg.String class."""
|
||||||
|
parts = type_string.replace(".", "/").split("/")
|
||||||
|
if len(parts) == 2: # tolerate 'pkg/Type'
|
||||||
|
package, name = parts
|
||||||
|
module_path = package + ".msg"
|
||||||
|
elif len(parts) == 3:
|
||||||
|
package, kind, name = parts
|
||||||
|
module_path = package + "." + kind
|
||||||
|
else:
|
||||||
|
raise SpeechFailed(
|
||||||
|
"cannot parse ROS type '{0}'".format(type_string),
|
||||||
|
user_message="Invalid ROS message type in configuration: " + type_string,
|
||||||
|
)
|
||||||
|
module = importlib.import_module(module_path)
|
||||||
|
return getattr(module, name)
|
||||||
|
|
||||||
|
|
||||||
|
class Ros2Transport(SpeechTransport):
|
||||||
|
name = "ros2"
|
||||||
|
reports_completion = False
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._a3 = settings.a3
|
||||||
|
self._robot = settings.robot
|
||||||
|
self._rclpy: Any = None
|
||||||
|
self._node: Any = None
|
||||||
|
self._publisher: Any = None
|
||||||
|
self._stop_publisher: Any = None
|
||||||
|
self._client: Any = None
|
||||||
|
self._executor: Any = None
|
||||||
|
self._spin_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------- #
|
||||||
|
async def open(self) -> None:
|
||||||
|
if self._node is not None:
|
||||||
|
return
|
||||||
|
await asyncio.get_running_loop().run_in_executor(None, self._open_blocking)
|
||||||
|
|
||||||
|
def _open_blocking(self) -> None:
|
||||||
|
try:
|
||||||
|
import rclpy
|
||||||
|
from rclpy.executors import SingleThreadedExecutor
|
||||||
|
from rclpy.qos import QoSProfile, ReliabilityPolicy
|
||||||
|
except ImportError as exc:
|
||||||
|
raise TransportNotAvailable(
|
||||||
|
"rclpy not importable: {0}".format(exc),
|
||||||
|
user_message=(
|
||||||
|
"ROS 2 transport selected but rclpy is not installed on this PC. "
|
||||||
|
"Install ROS 2 and run the app from a sourced ROS environment, "
|
||||||
|
"or choose a different A3_TRANSPORT."
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
os.environ.setdefault("ROS_DOMAIN_ID", str(self._a3.ros_domain_id))
|
||||||
|
self._rclpy = rclpy
|
||||||
|
if not rclpy.ok():
|
||||||
|
rclpy.init(args=None)
|
||||||
|
|
||||||
|
self._node = rclpy.create_node("agibot_voice_bridge")
|
||||||
|
qos = QoSProfile(depth=10, reliability=ReliabilityPolicy.RELIABLE)
|
||||||
|
|
||||||
|
if self._a3.ros_use_service:
|
||||||
|
service_type = _resolve_type(self._a3.ros_service_type)
|
||||||
|
self._client = self._node.create_client(service_type, self._a3.ros_service_name)
|
||||||
|
else:
|
||||||
|
msg_type = _resolve_type(self._a3.ros_speak_msg_type)
|
||||||
|
self._publisher = self._node.create_publisher(msg_type, self._a3.ros_speak_topic, qos)
|
||||||
|
if self._a3.ros_stop_topic:
|
||||||
|
self._stop_publisher = self._node.create_publisher(
|
||||||
|
msg_type, self._a3.ros_stop_topic, qos
|
||||||
|
)
|
||||||
|
|
||||||
|
self._executor = SingleThreadedExecutor()
|
||||||
|
self._executor.add_node(self._node)
|
||||||
|
self._spin_thread = threading.Thread(
|
||||||
|
target=self._executor.spin, name="rclpy-spin", daemon=True
|
||||||
|
)
|
||||||
|
self._spin_thread.start()
|
||||||
|
logger.info("ROS 2 transport ready (domain %s)", os.environ.get("ROS_DOMAIN_ID"))
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
node, executor, rclpy = self._node, self._executor, self._rclpy
|
||||||
|
self._node = self._executor = self._publisher = self._client = None
|
||||||
|
if executor is not None:
|
||||||
|
try:
|
||||||
|
executor.shutdown()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
if node is not None:
|
||||||
|
try:
|
||||||
|
node.destroy_node()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
if rclpy is not None:
|
||||||
|
try:
|
||||||
|
if rclpy.ok():
|
||||||
|
rclpy.shutdown()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- health -------------------------------------------------------------- #
|
||||||
|
async def probe(self) -> bool:
|
||||||
|
if self._node is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if self._client is not None:
|
||||||
|
return bool(self._client.service_is_ready())
|
||||||
|
if self._publisher is not None:
|
||||||
|
# A subscriber on the speech topic means the robot side is alive.
|
||||||
|
return self._publisher.get_subscription_count() > 0
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("ros2 probe failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- speech -------------------------------------------------------------- #
|
||||||
|
async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> TransportAck:
|
||||||
|
if self._node is None:
|
||||||
|
await self.open()
|
||||||
|
|
||||||
|
ctx = build_context(request, self._defaults())
|
||||||
|
await on_progress(SpeechProgress(request.id, SpeechStage.SENDING, "Publishing to ROS 2..."))
|
||||||
|
started = time.perf_counter()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, self._publish_blocking, ctx)
|
||||||
|
except TransportNotAvailable:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RobotUnreachable("ROS 2 publish failed: {0}".format(exc)) from exc
|
||||||
|
|
||||||
|
return TransportAck(accepted=True, ack_latency_ms=now_ms(started))
|
||||||
|
|
||||||
|
def _publish_blocking(self, ctx: Dict[str, Any]) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
request_type = _resolve_type(self._a3.ros_service_type).Request
|
||||||
|
req = request_type()
|
||||||
|
setattr(req, self._a3.ros_speak_msg_field, ctx["text"])
|
||||||
|
if not self._client.wait_for_service(timeout_sec=self._robot.connect_timeout):
|
||||||
|
raise RobotUnreachable(
|
||||||
|
"ROS service {0} not available".format(self._a3.ros_service_name)
|
||||||
|
)
|
||||||
|
future = self._client.call_async(req)
|
||||||
|
deadline = time.time() + self._robot.request_timeout
|
||||||
|
while not future.done() and time.time() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
return
|
||||||
|
|
||||||
|
assert self._publisher is not None
|
||||||
|
msg_type = _resolve_type(self._a3.ros_speak_msg_type)
|
||||||
|
msg = msg_type()
|
||||||
|
field = self._a3.ros_speak_msg_field
|
||||||
|
if not hasattr(msg, field):
|
||||||
|
raise SpeechFailed(
|
||||||
|
"message type {0} has no field '{1}'".format(self._a3.ros_speak_msg_type, field),
|
||||||
|
user_message="Configured ROS message field '{0}' does not exist on {1}.".format(
|
||||||
|
field, self._a3.ros_speak_msg_type
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setattr(msg, field, ctx["text"])
|
||||||
|
self._publisher.publish(msg)
|
||||||
|
|
||||||
|
async def stop(self) -> bool:
|
||||||
|
if self._stop_publisher is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
msg_type = _resolve_type(self._a3.ros_speak_msg_type)
|
||||||
|
msg = msg_type()
|
||||||
|
setattr(msg, self._a3.ros_speak_msg_field, "")
|
||||||
|
self._stop_publisher.publish(msg)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ros2 stop failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def describe(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"domainId": self._a3.ros_domain_id,
|
||||||
|
"mode": "service" if self._a3.ros_use_service else "topic",
|
||||||
|
"topic": self._a3.ros_speak_topic,
|
||||||
|
"messageType": self._a3.ros_speak_msg_type,
|
||||||
|
"service": self._a3.ros_service_name,
|
||||||
|
"nodeUp": self._node is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _defaults(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"voice": self._a3.voice,
|
||||||
|
"language": self._a3.language,
|
||||||
|
"volume": self._a3.volume,
|
||||||
|
"speed": self._a3.speed,
|
||||||
|
}
|
||||||
192
backend/robot/transports/ssh_transport.py
Normal file
192
backend/robot/transports/ssh_transport.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
"""SSH / on-robot command transport.
|
||||||
|
|
||||||
|
The universal fallback. Every AgiBot humanoid runs a Linux compute module, so if
|
||||||
|
no network speech API is exposed, the robot can still be made to speak by running
|
||||||
|
its own TTS or audio-playback command over SSH.
|
||||||
|
|
||||||
|
Nothing here is A3-specific - you supply the command that actually works on your
|
||||||
|
unit, discovered by logging in once and trying it by hand:
|
||||||
|
|
||||||
|
A3_SSH_SPEAK_COMMAND=<the command that speaks on your robot> {text}
|
||||||
|
|
||||||
|
`{text}` is shell-quoted before substitution, so arbitrary operator input cannot
|
||||||
|
break out of the argument. Authentication uses the OpenSSH client (key or agent);
|
||||||
|
password auth is used only when `paramiko` is installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import shlex
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from ...config.settings import Settings
|
||||||
|
from ..base import (
|
||||||
|
ProgressCallback,
|
||||||
|
RobotTimeout,
|
||||||
|
RobotUnreachable,
|
||||||
|
SpeechFailed,
|
||||||
|
SpeechProgress,
|
||||||
|
SpeechRequest,
|
||||||
|
SpeechStage,
|
||||||
|
)
|
||||||
|
from .base import SpeechTransport, TransportAck, now_ms
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paramiko # optional - only needed for password authentication
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
paramiko = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
class SshTransport(SpeechTransport):
|
||||||
|
name = "ssh"
|
||||||
|
reports_completion = True # the command returns when playback ends
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._robot = settings.robot
|
||||||
|
self._a3 = settings.a3
|
||||||
|
self._current: Optional[asyncio.subprocess.Process] = None
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------- #
|
||||||
|
async def open(self) -> None:
|
||||||
|
if not await self.probe():
|
||||||
|
raise RobotUnreachable(
|
||||||
|
"SSH probe to {0}@{1} failed".format(self._a3.ssh_user, self._robot.ip)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
process = self._current
|
||||||
|
self._current = None
|
||||||
|
if process is not None and process.returncode is None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def probe(self) -> bool:
|
||||||
|
try:
|
||||||
|
code, _, _ = await self._run(
|
||||||
|
self._a3.ssh_probe_command or "true", timeout=self._robot.connect_timeout + 2
|
||||||
|
)
|
||||||
|
return code == 0
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("ssh probe failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- speech -------------------------------------------------------------- #
|
||||||
|
async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> TransportAck:
|
||||||
|
command = self._a3.ssh_speak_command.replace("{text}", shlex.quote(request.text))
|
||||||
|
command = command.replace("{id}", shlex.quote(request.id))
|
||||||
|
|
||||||
|
await on_progress(SpeechProgress(request.id, SpeechStage.SENDING, "Sending to robot..."))
|
||||||
|
started = time.perf_counter()
|
||||||
|
|
||||||
|
code, out, err = await self._run(command, timeout=self._robot.request_timeout + 60)
|
||||||
|
if code != 0:
|
||||||
|
raise SpeechFailed(
|
||||||
|
"remote command exited {0}: {1}".format(code, (err or out)[:400]),
|
||||||
|
user_message="Speech request failed. The robot's speech command returned an error.",
|
||||||
|
)
|
||||||
|
return TransportAck(accepted=True, ack_latency_ms=now_ms(started), detail=(out or "")[:200])
|
||||||
|
|
||||||
|
async def stop(self) -> bool:
|
||||||
|
if not self._a3.ssh_stop_command:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
code, _, _ = await self._run(self._a3.ssh_stop_command, timeout=10)
|
||||||
|
return code == 0
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ssh stop failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def describe(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"host": "{0}@{1}:{2}".format(self._a3.ssh_user, self._robot.ip, self._a3.ssh_port),
|
||||||
|
"auth": "paramiko-password" if self._use_paramiko else "openssh-key/agent",
|
||||||
|
"speakCommand": self._a3.ssh_speak_command or "(unset)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- internals ----------------------------------------------------------- #
|
||||||
|
@property
|
||||||
|
def _use_paramiko(self) -> bool:
|
||||||
|
return bool(self._a3.ssh_password) and paramiko is not None
|
||||||
|
|
||||||
|
async def _run(self, command: str, timeout: float):
|
||||||
|
if not self._robot.ip:
|
||||||
|
raise RobotUnreachable("ROBOT_IP is not set")
|
||||||
|
if self._use_paramiko:
|
||||||
|
return await asyncio.get_running_loop().run_in_executor(
|
||||||
|
None, self._run_paramiko, command, timeout
|
||||||
|
)
|
||||||
|
return await self._run_openssh(command, timeout)
|
||||||
|
|
||||||
|
def _ssh_argv(self, command: str) -> List[str]:
|
||||||
|
argv = [
|
||||||
|
"ssh",
|
||||||
|
"-p",
|
||||||
|
str(self._a3.ssh_port),
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=accept-new",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout={0}".format(int(max(1, self._robot.connect_timeout))),
|
||||||
|
]
|
||||||
|
if self._a3.ssh_key_path:
|
||||||
|
argv += ["-i", self._a3.ssh_key_path]
|
||||||
|
argv.append("{0}@{1}".format(self._a3.ssh_user, self._robot.ip))
|
||||||
|
argv.append(command)
|
||||||
|
return argv
|
||||||
|
|
||||||
|
async def _run_openssh(self, command: str, timeout: float):
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*self._ssh_argv(command),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise RobotUnreachable(
|
||||||
|
"the 'ssh' client was not found on this PC",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._current = process
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
process.kill()
|
||||||
|
raise RobotTimeout("remote command timed out after {0}s".format(timeout)) from exc
|
||||||
|
finally:
|
||||||
|
self._current = None
|
||||||
|
return (
|
||||||
|
process.returncode,
|
||||||
|
stdout.decode("utf-8", "replace"),
|
||||||
|
stderr.decode("utf-8", "replace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_paramiko(self, command: str, timeout: float): # pragma: no cover - needs a host
|
||||||
|
assert paramiko is not None
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
hostname=self._robot.ip,
|
||||||
|
port=self._a3.ssh_port,
|
||||||
|
username=self._a3.ssh_user,
|
||||||
|
password=self._a3.ssh_password,
|
||||||
|
key_filename=self._a3.ssh_key_path,
|
||||||
|
timeout=self._robot.connect_timeout,
|
||||||
|
allow_agent=False,
|
||||||
|
look_for_keys=False,
|
||||||
|
)
|
||||||
|
_, stdout, stderr = client.exec_command(command, timeout=timeout)
|
||||||
|
out = stdout.read().decode("utf-8", "replace")
|
||||||
|
err = stderr.read().decode("utf-8", "replace")
|
||||||
|
return stdout.channel.recv_exit_status(), out, err
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
226
backend/robot/transports/ws_transport.py
Normal file
226
backend/robot/transports/ws_transport.py
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
"""WebSocket transport.
|
||||||
|
|
||||||
|
The lowest-latency option when the robot offers it: the socket is opened once at
|
||||||
|
startup and held, so pressing Speak costs one frame write - no handshake, no
|
||||||
|
connection setup. It is also the only transport that can naturally deliver
|
||||||
|
*unsolicited* robot events ("speaking started", "speech finished") back to the UI.
|
||||||
|
|
||||||
|
If the robot's event schema is known, set:
|
||||||
|
|
||||||
|
A3_WS_DONE_FIELD=event # dotted path inside the JSON message
|
||||||
|
A3_WS_DONE_VALUE=speech_end # value that means "utterance finished"
|
||||||
|
|
||||||
|
and the dashboard will show true completion instead of an estimate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from ...config.settings import Settings
|
||||||
|
from ..base import (
|
||||||
|
ProgressCallback,
|
||||||
|
RobotTimeout,
|
||||||
|
RobotUnreachable,
|
||||||
|
SpeechProgress,
|
||||||
|
SpeechRequest,
|
||||||
|
SpeechStage,
|
||||||
|
TransportNotAvailable,
|
||||||
|
)
|
||||||
|
from .base import SpeechTransport, TransportAck, build_context, now_ms, render_payload
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from websockets.asyncio.client import connect as ws_connect # websockets >= 13
|
||||||
|
except Exception: # pragma: no cover - older releases
|
||||||
|
try:
|
||||||
|
from websockets.client import connect as ws_connect # type: ignore[no-redef]
|
||||||
|
except Exception:
|
||||||
|
ws_connect = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketTransport(SpeechTransport):
|
||||||
|
name = "ws"
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._robot = settings.robot
|
||||||
|
self._a3 = settings.a3
|
||||||
|
self._ws: Any = None
|
||||||
|
self._reader: Optional[asyncio.Task] = None
|
||||||
|
self._completion: Optional[asyncio.Future] = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._last_message: Any = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reports_completion(self) -> bool: # type: ignore[override]
|
||||||
|
return bool(self._a3.ws_done_field)
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------- #
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
path = self._a3.ws_path or "/"
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
return self._robot.ws_base_url + path
|
||||||
|
|
||||||
|
async def open(self) -> None:
|
||||||
|
if ws_connect is None: # pragma: no cover
|
||||||
|
raise TransportNotAvailable(
|
||||||
|
"websockets package missing",
|
||||||
|
user_message="WebSocket transport requires the 'websockets' package.",
|
||||||
|
)
|
||||||
|
if self._ws is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._ws = await asyncio.wait_for(
|
||||||
|
ws_connect(
|
||||||
|
self.url,
|
||||||
|
ping_interval=self._a3.ws_ping_interval or None,
|
||||||
|
ping_timeout=self._robot.request_timeout,
|
||||||
|
open_timeout=self._robot.connect_timeout,
|
||||||
|
max_queue=32,
|
||||||
|
),
|
||||||
|
timeout=self._robot.connect_timeout + 1,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise RobotTimeout("timed out opening {0}".format(self.url)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise RobotUnreachable("cannot open {0}: {1}".format(self.url, exc)) from exc
|
||||||
|
|
||||||
|
self._reader = asyncio.create_task(self._read_loop(), name="a3-ws-reader")
|
||||||
|
logger.info("WebSocket transport connected to %s", self.url)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._reader is not None:
|
||||||
|
self._reader.cancel()
|
||||||
|
try:
|
||||||
|
await self._reader
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
self._reader = None
|
||||||
|
if self._ws is not None:
|
||||||
|
try:
|
||||||
|
await self._ws.close()
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
self._ws = None
|
||||||
|
|
||||||
|
async def probe(self) -> bool:
|
||||||
|
if self._ws is None:
|
||||||
|
return False
|
||||||
|
# A closed socket surfaces here without an extra round trip.
|
||||||
|
reader = self._reader
|
||||||
|
if reader is not None and reader.done():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
pong = await self._ws.ping()
|
||||||
|
await asyncio.wait_for(pong, timeout=self._robot.connect_timeout)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("ws probe failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- speech -------------------------------------------------------------- #
|
||||||
|
async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> TransportAck:
|
||||||
|
async with self._lock:
|
||||||
|
if self._ws is None:
|
||||||
|
await self.open()
|
||||||
|
assert self._ws is not None
|
||||||
|
|
||||||
|
ctx = build_context(request, self._defaults())
|
||||||
|
payload = render_payload(self._a3.ws_speak_payload, ctx)
|
||||||
|
message = json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._completion = loop.create_future() if self.reports_completion else None
|
||||||
|
|
||||||
|
await on_progress(SpeechProgress(request.id, SpeechStage.SENDING, "Sending to robot..."))
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
await self._ws.send(message)
|
||||||
|
except Exception as exc:
|
||||||
|
await self.close()
|
||||||
|
raise RobotUnreachable("websocket send failed: {0}".format(exc)) from exc
|
||||||
|
|
||||||
|
return TransportAck(accepted=True, ack_latency_ms=now_ms(started))
|
||||||
|
|
||||||
|
async def await_completion(self, timeout: float) -> bool:
|
||||||
|
"""Block until the robot reports the utterance finished (if it does)."""
|
||||||
|
if self._completion is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(self._completion), timeout=timeout)
|
||||||
|
return True
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def stop(self) -> bool:
|
||||||
|
if self._ws is None or not self._a3.ws_stop_payload:
|
||||||
|
return False
|
||||||
|
ctx = build_context(SpeechRequest(text=""), self._defaults())
|
||||||
|
payload = render_payload(self._a3.ws_stop_payload, ctx)
|
||||||
|
try:
|
||||||
|
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ws stop failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def describe(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"url": self.url,
|
||||||
|
"connected": self._ws is not None,
|
||||||
|
"reportsCompletion": self.reports_completion,
|
||||||
|
"lastMessage": self._last_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- internals ----------------------------------------------------------- #
|
||||||
|
async def _read_loop(self) -> None:
|
||||||
|
assert self._ws is not None
|
||||||
|
try:
|
||||||
|
async for raw in self._ws:
|
||||||
|
self._last_message = raw if isinstance(raw, str) else "<binary>"
|
||||||
|
if not self._a3.ws_done_field:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
message = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
value = _dig(message, self._a3.ws_done_field)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
expected = self._a3.ws_done_value
|
||||||
|
if expected is None or str(value) == expected:
|
||||||
|
fut = self._completion
|
||||||
|
if fut is not None and not fut.done():
|
||||||
|
fut.set_result(True)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("websocket reader closed: %s", exc)
|
||||||
|
self._ws = None
|
||||||
|
|
||||||
|
def _defaults(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"voice": self._a3.voice,
|
||||||
|
"language": self._a3.language,
|
||||||
|
"volume": self._a3.volume,
|
||||||
|
"speed": self._a3.speed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dig(data: Any, dotted: str) -> Any:
|
||||||
|
node = data
|
||||||
|
for part in dotted.split("."):
|
||||||
|
if not isinstance(node, dict) or part not in node:
|
||||||
|
return None
|
||||||
|
node = node[part]
|
||||||
|
return node
|
||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
257
backend/services/audio_library.py
Normal file
257
backend/services/audio_library.py
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"""Saved speech audio.
|
||||||
|
|
||||||
|
Every line the cloud voice synthesises is written here as an ordinary `.wav`
|
||||||
|
file with a readable name, plus an `index.json` describing it. That gives three
|
||||||
|
things a hidden cache could not:
|
||||||
|
|
||||||
|
* **Instant replay.** A saved line plays with no synthesis, no network, no
|
||||||
|
wait - just read the file and play it.
|
||||||
|
* **Files you can actually use.** Open the folder, double-click a `.wav`, drop
|
||||||
|
one into a video edit or a stand's playlist. Nothing is locked in.
|
||||||
|
* **Works offline.** Once a line is saved, the demo no longer needs internet
|
||||||
|
for that line.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
audio_library/
|
||||||
|
index.json
|
||||||
|
good-afternoon-and-welcome-to-our-a1b2c3d4.wav
|
||||||
|
please-follow-me-to-the-first-9f8e7d6c.wav
|
||||||
|
|
||||||
|
The id is a hash of (text, voice, model, style), so the same line spoken by a
|
||||||
|
different voice is a different entry, and re-saving an identical line is free.
|
||||||
|
|
||||||
|
NOTE: only the cloud voice produces files. The built-in OS voices play straight
|
||||||
|
to the sound card and never hand us audio, so utterances spoken by them are not
|
||||||
|
saved. `MOCK_VOICE_ENGINE=gemini` is what fills this library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INDEX_FILE = "index.json"
|
||||||
|
_SLUG_STRIP = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(text: str, limit: int = 40) -> str:
|
||||||
|
"""A short, filesystem-safe, human-readable stem for a filename."""
|
||||||
|
ascii_text = text.encode("ascii", "ignore").decode("ascii").lower()
|
||||||
|
slug = _SLUG_STRIP.sub("-", ascii_text).strip("-")
|
||||||
|
if len(slug) > limit:
|
||||||
|
slug = slug[:limit].rsplit("-", 1)[0] or slug[:limit]
|
||||||
|
return slug or "utterance"
|
||||||
|
|
||||||
|
|
||||||
|
def wav_duration(path: Path) -> float:
|
||||||
|
"""Duration of a 16-bit PCM WAV, from its header."""
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
header = handle.read(44)
|
||||||
|
if len(header) < 44:
|
||||||
|
return 0.0
|
||||||
|
channels, sample_rate = struct.unpack("<HI", header[22:28])
|
||||||
|
data_bytes = struct.unpack("<I", header[40:44])[0]
|
||||||
|
return round(data_bytes / float(max(1, sample_rate * channels * 2)), 2)
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class AudioLibrary:
|
||||||
|
"""Saved `.wav` files plus their index. Safe to share across threads."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = Path(root)
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._index: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._stamp: Optional[tuple] = None
|
||||||
|
self._load_index()
|
||||||
|
|
||||||
|
# -- identity ------------------------------------------------------------ #
|
||||||
|
@staticmethod
|
||||||
|
def make_id(text: str, voice: str, model: str, style: str = "") -> str:
|
||||||
|
raw = "\x00".join([model or "", voice or "", style or "", text or ""])
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
# -- index --------------------------------------------------------------- #
|
||||||
|
@property
|
||||||
|
def index_path(self) -> Path:
|
||||||
|
return self.root / INDEX_FILE
|
||||||
|
|
||||||
|
def _stamp_of_index(self) -> Optional[tuple]:
|
||||||
|
try:
|
||||||
|
stat = self.index_path.stat()
|
||||||
|
return (stat.st_mtime_ns, stat.st_size)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
"""Re-read the index if another process changed it.
|
||||||
|
|
||||||
|
`warm_voice.py` and the server both open this library. Without this the
|
||||||
|
server would keep a stale copy in memory and write it back over the
|
||||||
|
other process's changes - resurrecting entries whose files were deleted.
|
||||||
|
"""
|
||||||
|
if self._stamp_of_index() != self._stamp:
|
||||||
|
self._load_index()
|
||||||
|
|
||||||
|
def _load_index(self) -> None:
|
||||||
|
self._stamp = self._stamp_of_index()
|
||||||
|
if not self.index_path.exists():
|
||||||
|
self._index = {}
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(self.index_path.read_text(encoding="utf-8"))
|
||||||
|
entries = data.get("entries", data) if isinstance(data, dict) else {}
|
||||||
|
if isinstance(entries, dict):
|
||||||
|
# Drop entries whose file was deleted by hand.
|
||||||
|
self._index = {
|
||||||
|
key: value for key, value in entries.items()
|
||||||
|
if (self.root / value.get("file", "")).exists()
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
logger.warning("audio index unreadable (%s); starting a fresh one", exc)
|
||||||
|
self._index = {}
|
||||||
|
|
||||||
|
def _save_index(self) -> None:
|
||||||
|
payload = {"version": 1, "entries": self._index}
|
||||||
|
temporary = self.index_path.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, indent=1, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
os.replace(temporary, self.index_path)
|
||||||
|
self._stamp = self._stamp_of_index()
|
||||||
|
except OSError as exc: # pragma: no cover
|
||||||
|
logger.warning("could not write audio index: %s", exc)
|
||||||
|
|
||||||
|
# -- lookup -------------------------------------------------------------- #
|
||||||
|
def get(self, audio_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
self._refresh()
|
||||||
|
entry = self._index.get(audio_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if not (self.root / entry["file"]).exists():
|
||||||
|
self._index.pop(audio_id, None)
|
||||||
|
self._save_index()
|
||||||
|
return None
|
||||||
|
return dict(entry)
|
||||||
|
|
||||||
|
def find(self, text: str, voice: str, model: str, style: str = "") -> Optional[Dict[str, Any]]:
|
||||||
|
return self.get(self.make_id(text, voice, model, style))
|
||||||
|
|
||||||
|
def path_of(self, audio_id: str) -> Optional[Path]:
|
||||||
|
entry = self.get(audio_id)
|
||||||
|
return self.root / entry["file"] if entry else None
|
||||||
|
|
||||||
|
def list(self, newest_first: bool = True) -> List[Dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
self._refresh()
|
||||||
|
# Only list clips whose file is really there - the panel offers a
|
||||||
|
# play button per row, and a row that 404s is worse than no row.
|
||||||
|
alive, missing = [], []
|
||||||
|
for key, value in self._index.items():
|
||||||
|
(alive if (self.root / value.get("file", "")).exists() else missing).append(
|
||||||
|
(key, value)
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
for key, _ in missing:
|
||||||
|
self._index.pop(key, None)
|
||||||
|
self._save_index()
|
||||||
|
items = [dict(value) for _, value in alive]
|
||||||
|
items.sort(key=lambda item: item.get("createdAt", 0), reverse=newest_first)
|
||||||
|
return items
|
||||||
|
|
||||||
|
# -- writing ------------------------------------------------------------- #
|
||||||
|
def save(self, text: str, wav_bytes: bytes, voice: str, model: str,
|
||||||
|
style: str = "") -> Dict[str, Any]:
|
||||||
|
audio_id = self.make_id(text, voice, model, style)
|
||||||
|
with self._lock:
|
||||||
|
self._refresh()
|
||||||
|
existing = self.get(audio_id)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
filename = "{0}-{1}.wav".format(slugify(text), audio_id[:8])
|
||||||
|
destination = self.root / filename
|
||||||
|
temporary = destination.with_suffix(".part")
|
||||||
|
temporary.write_bytes(wav_bytes)
|
||||||
|
os.replace(temporary, destination) # never index a half-written file
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"id": audio_id,
|
||||||
|
"text": text,
|
||||||
|
"file": filename,
|
||||||
|
"voice": voice,
|
||||||
|
"model": model,
|
||||||
|
"style": style,
|
||||||
|
"createdAt": time.time(),
|
||||||
|
"durationSeconds": wav_duration(destination),
|
||||||
|
"bytes": destination.stat().st_size,
|
||||||
|
}
|
||||||
|
with self._lock:
|
||||||
|
self._index[audio_id] = entry
|
||||||
|
self._save_index()
|
||||||
|
return dict(entry)
|
||||||
|
|
||||||
|
# -- housekeeping -------------------------------------------------------- #
|
||||||
|
def delete(self, audio_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
self._refresh()
|
||||||
|
entry = self._index.pop(audio_id, None)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
self._save_index()
|
||||||
|
try:
|
||||||
|
(self.root / entry["file"]).unlink()
|
||||||
|
except OSError: # pragma: no cover
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
self._refresh()
|
||||||
|
entries = list(self._index.values())
|
||||||
|
self._index = {}
|
||||||
|
self._save_index()
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
(self.root / entry["file"]).unlink()
|
||||||
|
except OSError: # pragma: no cover
|
||||||
|
pass
|
||||||
|
return len(entries)
|
||||||
|
|
||||||
|
def stats(self) -> Dict[str, Any]:
|
||||||
|
items = self.list()
|
||||||
|
return {
|
||||||
|
"count": len(items),
|
||||||
|
"bytes": sum(item.get("bytes", 0) for item in items),
|
||||||
|
"seconds": round(sum(item.get("durationSeconds", 0) for item in items), 1),
|
||||||
|
"dir": str(self.root),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_library: Optional[AudioLibrary] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_library(root: Optional[Path] = None) -> AudioLibrary:
|
||||||
|
"""Process-wide library, so the API and the voice share one index."""
|
||||||
|
global _library
|
||||||
|
if _library is None:
|
||||||
|
if root is None:
|
||||||
|
root = Path(__file__).resolve().parents[2] / "audio_library"
|
||||||
|
_library = AudioLibrary(root)
|
||||||
|
return _library
|
||||||
63
backend/services/history.py
Normal file
63
backend/services/history.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"""Speech history.
|
||||||
|
|
||||||
|
Deliberately in-memory and bounded: this is an operator convenience for a live
|
||||||
|
demo ("say that again"), not an audit log. It resets when the server restarts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from typing import Any, Deque, Dict, List, Optional
|
||||||
|
|
||||||
|
from ..robot.base import SpeechStage
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechHistory:
|
||||||
|
def __init__(self, limit: int = 100) -> None:
|
||||||
|
self._items: Deque[Dict[str, Any]] = deque(maxlen=max(1, limit))
|
||||||
|
self._index: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def add(self, request_id: str, text: str) -> Dict[str, Any]:
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"id": request_id,
|
||||||
|
"text": text,
|
||||||
|
"at": time.time(),
|
||||||
|
"stage": SpeechStage.SENDING.value,
|
||||||
|
"success": None,
|
||||||
|
"ackLatencyMs": None,
|
||||||
|
"totalMs": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
with self._lock:
|
||||||
|
if len(self._items) == self._items.maxlen:
|
||||||
|
# Newest is at the left, so the deque drops the rightmost entry.
|
||||||
|
self._index.pop(self._items[-1]["id"], None)
|
||||||
|
self._items.appendleft(entry)
|
||||||
|
self._index[request_id] = entry
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def update(self, request_id: str, **fields: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
entry = self._index.get(request_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
entry.update(fields)
|
||||||
|
return dict(entry)
|
||||||
|
|
||||||
|
def list(self) -> List[Dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
return [dict(item) for item in self._items]
|
||||||
|
|
||||||
|
def clear(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
count = len(self._items)
|
||||||
|
self._items.clear()
|
||||||
|
self._index.clear()
|
||||||
|
return count
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._items)
|
||||||
319
backend/services/speech_service.py
Normal file
319
backend/services/speech_service.py
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
"""Speech orchestration.
|
||||||
|
|
||||||
|
The one deliberate design decision worth stating: POST /api/robot/speak returns as
|
||||||
|
soon as the *robot has acknowledged* the utterance - not when speaking finishes.
|
||||||
|
The remaining lifecycle (speaking -> completed) streams over the WebSocket.
|
||||||
|
|
||||||
|
That matters for a live demo. Blocking the HTTP response until the robot stops
|
||||||
|
talking would make a 12-second sentence look like a 12-second-slow button, and it
|
||||||
|
would tie a browser request to the speaking duration for no benefit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from ..config.settings import Settings
|
||||||
|
from ..core.events import EventBus, EventType
|
||||||
|
from ..core.text import normalise
|
||||||
|
from ..robot.base import (
|
||||||
|
RobotBusy,
|
||||||
|
RobotError,
|
||||||
|
RobotUnreachable,
|
||||||
|
SpeechProgress,
|
||||||
|
SpeechRequest,
|
||||||
|
SpeechResult,
|
||||||
|
SpeechStage,
|
||||||
|
)
|
||||||
|
from ..robot.manager import RobotManager
|
||||||
|
from .history import SpeechHistory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(RobotError):
|
||||||
|
code = "invalid_text"
|
||||||
|
user_message = "Please enter some text for the robot to say."
|
||||||
|
|
||||||
|
|
||||||
|
def _swallow_exception(task: "asyncio.Task") -> None:
|
||||||
|
"""Retrieve a finished task's exception so asyncio stops complaining."""
|
||||||
|
if not task.cancelled():
|
||||||
|
task.exception()
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechService:
|
||||||
|
def __init__(self, settings: Settings, manager: RobotManager, bus: EventBus) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._manager = manager
|
||||||
|
self._bus = bus
|
||||||
|
self.history = SpeechHistory(settings.speech.history_limit)
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._active_task: Optional[asyncio.Task] = None
|
||||||
|
self._active_id: Optional[str] = None
|
||||||
|
|
||||||
|
def update_settings(self, settings: Settings) -> None:
|
||||||
|
"""Adopt reloaded configuration (limits, interrupt policy) in place."""
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
|
# -- state --------------------------------------------------------------- #
|
||||||
|
@property
|
||||||
|
def is_busy(self) -> bool:
|
||||||
|
return self._active_task is not None and not self._active_task.done()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_request_id(self) -> Optional[str]:
|
||||||
|
return self._active_id if self.is_busy else None
|
||||||
|
|
||||||
|
# -- validation ---------------------------------------------------------- #
|
||||||
|
def validate(self, text: Optional[str]) -> str:
|
||||||
|
cleaned = normalise(text or "")
|
||||||
|
limits = self._settings.speech
|
||||||
|
if len(cleaned) < max(1, limits.min_length):
|
||||||
|
raise ValidationError("empty text")
|
||||||
|
if len(cleaned) > limits.max_length:
|
||||||
|
raise ValidationError(
|
||||||
|
"text too long: {0} chars".format(len(cleaned)),
|
||||||
|
user_message=(
|
||||||
|
"Text is too long ({0} characters). The limit is {1}. "
|
||||||
|
"Split it into shorter sentences.".format(len(cleaned), limits.max_length)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
# -- speak --------------------------------------------------------------- #
|
||||||
|
async def speak(self, text: Optional[str], voice: Optional[str] = None,
|
||||||
|
language: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
cleaned = self.validate(text)
|
||||||
|
|
||||||
|
if not self._manager.is_connected:
|
||||||
|
status = self._manager.status_dict()
|
||||||
|
raise RobotUnreachable(
|
||||||
|
"robot not connected (state={0})".format(status["state"]),
|
||||||
|
user_message=(
|
||||||
|
status.get("error")
|
||||||
|
or "Robot is offline. Check the robot IP address and network connection."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
if self.is_busy:
|
||||||
|
if not self._settings.speech.allow_interrupt:
|
||||||
|
raise RobotBusy()
|
||||||
|
logger.info("interrupting utterance %s", self._active_id)
|
||||||
|
await self._cancel_active()
|
||||||
|
|
||||||
|
request = SpeechRequest(text=cleaned, voice=voice, language=language)
|
||||||
|
entry = self.history.add(request.id, cleaned)
|
||||||
|
self._bus.publish(EventType.HISTORY_UPDATED, {"entry": entry, "action": "add"})
|
||||||
|
|
||||||
|
ack = asyncio.Event()
|
||||||
|
state: Dict[str, Any] = {"ackLatencyMs": None, "stage": SpeechStage.SENDING}
|
||||||
|
|
||||||
|
async def on_progress(progress: SpeechProgress) -> None:
|
||||||
|
state["stage"] = progress.stage
|
||||||
|
if progress.stage is not SpeechStage.SENDING:
|
||||||
|
if state["ackLatencyMs"] is None:
|
||||||
|
state["ackLatencyMs"] = progress.elapsed_ms
|
||||||
|
ack.set()
|
||||||
|
self._bus.publish(EventType.SPEECH_PROGRESS, progress.to_dict())
|
||||||
|
self.history.update(request.id, stage=progress.stage.value)
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
self._active_id = request.id
|
||||||
|
self._active_task = asyncio.create_task(
|
||||||
|
self._run(request, on_progress), name="speak-" + request.id
|
||||||
|
)
|
||||||
|
# The HTTP call usually returns before the task finishes, so nobody is
|
||||||
|
# left to await it. Consume any exception here to keep asyncio from
|
||||||
|
# logging "Task exception was never retrieved" - it is already
|
||||||
|
# reported to the browser as a speech.progress / speech.result event.
|
||||||
|
self._active_task.add_done_callback(_swallow_exception)
|
||||||
|
|
||||||
|
# Wait only for the acknowledgement, then hand control back to the UI.
|
||||||
|
ack_wait = asyncio.ensure_future(ack.wait())
|
||||||
|
done, _ = await asyncio.wait(
|
||||||
|
{ack_wait, self._active_task},
|
||||||
|
timeout=self._settings.robot.request_timeout + 2,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
if not ack_wait.done():
|
||||||
|
ack_wait.cancel()
|
||||||
|
|
||||||
|
# The utterance failed before it was ever acknowledged - surface it now
|
||||||
|
# as an HTTP error rather than only as a WebSocket event.
|
||||||
|
if self._active_task in done:
|
||||||
|
exc = self._active_task.exception()
|
||||||
|
if exc is not None:
|
||||||
|
raise exc
|
||||||
|
result = self._active_task.result()
|
||||||
|
return self._response(request, result.stage, result.ack_latency_ms, started)
|
||||||
|
|
||||||
|
if not ack.is_set():
|
||||||
|
await self._cancel_active()
|
||||||
|
raise RobotError(
|
||||||
|
"no acknowledgement within timeout",
|
||||||
|
user_message="The robot did not respond in time. The request was cancelled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._response(request, state["stage"], state["ackLatencyMs"], started)
|
||||||
|
|
||||||
|
def _response(self, request: SpeechRequest, stage: Any, ack_ms: Optional[int],
|
||||||
|
started: float) -> Dict[str, Any]:
|
||||||
|
stage_value = stage.value if hasattr(stage, "value") else str(stage)
|
||||||
|
return {
|
||||||
|
"success": stage_value not in (SpeechStage.FAILED.value, SpeechStage.CANCELLED.value),
|
||||||
|
"status": stage_value,
|
||||||
|
"requestId": request.id,
|
||||||
|
"text": request.text,
|
||||||
|
"ackLatencyMs": ack_ms if ack_ms is not None else int((time.perf_counter() - started) * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _run(self, request: SpeechRequest, on_progress) -> SpeechResult:
|
||||||
|
"""Drive the utterance to completion and record the outcome."""
|
||||||
|
try:
|
||||||
|
result = await self._manager.adapter.speak(request, on_progress)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
entry = self.history.update(
|
||||||
|
request.id, stage=SpeechStage.CANCELLED.value, success=False, error="Stopped."
|
||||||
|
)
|
||||||
|
self._publish_history(entry)
|
||||||
|
raise
|
||||||
|
except RobotError as exc:
|
||||||
|
logger.warning("speech %s failed: %s", request.id, exc)
|
||||||
|
await on_progress(
|
||||||
|
SpeechProgress(
|
||||||
|
request.id,
|
||||||
|
SpeechStage.FAILED,
|
||||||
|
exc.user_message,
|
||||||
|
error_code=exc.code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entry = self.history.update(
|
||||||
|
request.id,
|
||||||
|
stage=SpeechStage.FAILED.value,
|
||||||
|
success=False,
|
||||||
|
error=exc.user_message,
|
||||||
|
)
|
||||||
|
self._publish_history(entry)
|
||||||
|
self._bus.publish(
|
||||||
|
EventType.SPEECH_RESULT,
|
||||||
|
{
|
||||||
|
"requestId": request.id,
|
||||||
|
"success": False,
|
||||||
|
"stage": SpeechStage.FAILED.value,
|
||||||
|
"errorCode": exc.code,
|
||||||
|
"error": exc.user_message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.exception("unexpected speech failure")
|
||||||
|
await on_progress(
|
||||||
|
SpeechProgress(request.id, SpeechStage.FAILED, "Speech request failed.",
|
||||||
|
error_code="internal", detail=str(exc))
|
||||||
|
)
|
||||||
|
entry = self.history.update(
|
||||||
|
request.id, stage=SpeechStage.FAILED.value, success=False,
|
||||||
|
error="Speech request failed.",
|
||||||
|
)
|
||||||
|
self._publish_history(entry)
|
||||||
|
raise
|
||||||
|
|
||||||
|
entry = self.history.update(
|
||||||
|
request.id,
|
||||||
|
stage=result.stage.value,
|
||||||
|
success=result.success,
|
||||||
|
ackLatencyMs=result.ack_latency_ms,
|
||||||
|
totalMs=result.total_ms,
|
||||||
|
error=result.error,
|
||||||
|
)
|
||||||
|
self._publish_history(entry)
|
||||||
|
self._bus.publish(EventType.SPEECH_RESULT, result.to_dict())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _publish_history(self, entry: Optional[Dict[str, Any]]) -> None:
|
||||||
|
if entry is not None:
|
||||||
|
self._bus.publish(EventType.HISTORY_UPDATED, {"entry": entry, "action": "update"})
|
||||||
|
|
||||||
|
# -- stop ---------------------------------------------------------------- #
|
||||||
|
async def stop(self) -> Dict[str, Any]:
|
||||||
|
stopped_robot = False
|
||||||
|
try:
|
||||||
|
stopped_robot = await self._manager.adapter.stop_speaking()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("robot stop failed: %s", exc)
|
||||||
|
|
||||||
|
had_active = self.is_busy
|
||||||
|
if had_active:
|
||||||
|
# Give the adapter a moment to unwind cleanly before force-cancelling.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(self._active_task), timeout=1.5)
|
||||||
|
except (asyncio.TimeoutError, Exception):
|
||||||
|
await self._cancel_active()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "stopped",
|
||||||
|
"hadActiveSpeech": had_active,
|
||||||
|
"robotAcknowledged": stopped_robot,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _cancel_active(self) -> None:
|
||||||
|
task = self._active_task
|
||||||
|
if task is None or task.done():
|
||||||
|
self._active_task = None
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._active_task = None
|
||||||
|
|
||||||
|
# -- history ------------------------------------------------------------- #
|
||||||
|
def annotate_saved(self, items):
|
||||||
|
"""Mark history entries whose audio is already on disk.
|
||||||
|
|
||||||
|
Lets the dashboard show a replay button only where playback really is
|
||||||
|
instant, instead of promising something it cannot deliver.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .audio_library import get_audio_library
|
||||||
|
|
||||||
|
library = get_audio_library()
|
||||||
|
mock = self._settings.mock
|
||||||
|
if mock.voice_engine != "gemini":
|
||||||
|
return items
|
||||||
|
from ..core.pronunciation import build as build_pronouncer
|
||||||
|
from ..config.settings import PROJECT_ROOT
|
||||||
|
from ..robot.gemini_voice import split_sentences
|
||||||
|
|
||||||
|
pronouncer = build_pronouncer(PROJECT_ROOT, mock.pronunciation)
|
||||||
|
for item in items:
|
||||||
|
text = item.get("text") or ""
|
||||||
|
if pronouncer is not None:
|
||||||
|
text = pronouncer.apply(text)
|
||||||
|
chunks = split_sentences(text)
|
||||||
|
entries = [
|
||||||
|
library.find(c, mock.gemini_voice, mock.gemini_model, mock.gemini_style)
|
||||||
|
for c in chunks
|
||||||
|
]
|
||||||
|
found = [e for e in entries if e]
|
||||||
|
item["audioSaved"] = bool(chunks) and len(found) == len(chunks)
|
||||||
|
item["audioIds"] = [e["id"] for e in found]
|
||||||
|
except Exception: # pragma: no cover - never break history over this
|
||||||
|
logger.debug("could not annotate saved audio", exc_info=True)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def clear_history(self) -> Dict[str, Any]:
|
||||||
|
removed = self.history.clear()
|
||||||
|
self._bus.publish(EventType.HISTORY_UPDATED, {"action": "clear"})
|
||||||
|
return {"success": True, "removed": removed}
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
await self._cancel_active()
|
||||||
382
docs/AGIBOT_A3_INTEGRATION.md
Normal file
382
docs/AGIBOT_A3_INTEGRATION.md
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
# AGIBOT A3 — how the speech integration works
|
||||||
|
|
||||||
|
**Short answer: the A3 has a documented, LAN-callable, native text-to-speech HTTP
|
||||||
|
endpoint, and this project talks to it directly.** No SDK, no login, no ROS, no
|
||||||
|
audio file transfer.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → this backend → HTTP JSON-RPC → A3 head unit (HDU) → built-in TTS → speaker
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything below is sourced. Claims that could not be verified are tagged
|
||||||
|
**[UNVERIFIED]** or **[UNKNOWN]** rather than smoothed over, and anything found on
|
||||||
|
a *different* AgiBot model is labelled as such — an A2/X2/A3-Ultra fact is not an
|
||||||
|
A3 fact.
|
||||||
|
|
||||||
|
> Research date: **2 September 2026**, against AgiBot doc tree **A3 v3.1 / v3.2**.
|
||||||
|
> If your unit's firmware differs, re-run the discovery procedure in §6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The interface
|
||||||
|
|
||||||
|
AgiBot's A3 developer guide documents two developer transports on the robot:
|
||||||
|
|
||||||
|
| Transport | AgiBot's stated use | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **HTTP JSON-RPC** | "low-frequency, many-to-one calls" | `POST http://{IP}:{PORT}/rpc/{service}/{method}`, `Content-Type: application/json` **mandatory** |
|
||||||
|
| **ROS 2 topic** | "high-frequency, many-to-many calls" | ROS 2 Jazzy on Fast DDS; default QoS `keep_last`/depth 10/**`best_effort`** |
|
||||||
|
|
||||||
|
Source: [03-second_develop_interface_overview](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/03-second_develop_interface_overview)
|
||||||
|
|
||||||
|
The same page explicitly sanctions calling it from another machine:
|
||||||
|
|
||||||
|
> *"For remote calls, replace it with the reachable IP of the corresponding
|
||||||
|
> device in the current network."*
|
||||||
|
> *"…there are no restrictions [on language]. As long as you can send and receive
|
||||||
|
> HTTP requests, you can call all HTTP JSON RPC interfaces."*
|
||||||
|
|
||||||
|
**There is no documented gRPC or WebSocket interface on the robot.** (WebSocket
|
||||||
|
appears only in AgiBot's separate *cloud* LinkSoul agent SDK — see §3.)
|
||||||
|
|
||||||
|
### The TTS call
|
||||||
|
|
||||||
|
On the **HDU** (the head unit — it owns all audio), port **59301**:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST http://<ROBOT_IP>:59301/rpc/aimdk.protocol.TTSService/PlayTTS
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"text": "Hello, welcome to our showroom.",
|
||||||
|
"priority_level": "INTERACTION_L6",
|
||||||
|
"domain": "voice_control",
|
||||||
|
"trace_id": "abc123",
|
||||||
|
"is_interrupted": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"text": "...", "priority_level": "INTERACTION_L6", "priority_weight": 0,
|
||||||
|
"domain": "voice_control", "trace_id": "abc123_18bZZLTk5VfJGSy8Cylsu4",
|
||||||
|
"is_sucess": true, "error_message": "", "estimated_duration": 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [07-02-audio_play](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play)
|
||||||
|
· runnable `curl` in AgiBot's own [quick start](https://open.agibot.com/docs/en/aimdk/a3/v3_1/dev_guide/06-second_develop_quick_start)
|
||||||
|
|
||||||
|
Companion methods on the same host and port:
|
||||||
|
|
||||||
|
| Method | Body | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `StopTTSTraceId` | `{"trace_id": "..."}` | interrupt an utterance |
|
||||||
|
| `GetAudioStatus` | `{"trace_id": "..."}` | `TTSStatusType_Begin\|_Playing\|_End\|_Stop\|_Error\|_InQue\|_NOTInQue` |
|
||||||
|
| `PlayMediaFile` | `{"file_name": "..."}` | play a file from `/agibot/data/var/interaction/audio/` |
|
||||||
|
|
||||||
|
Volume lives on a **different port, 56666**, service `aimdk.protocol.HalAudioService`
|
||||||
|
(`GetAudioVolume` / `SetAudioVolume`, range 0–100).
|
||||||
|
|
||||||
|
> ⚠️ **AgiBot's own hardware warning: do not set volume above 70 — "exceeding
|
||||||
|
> this range may damage the speaker."** This project does not change volume, so
|
||||||
|
> it cannot trip that. If you add volume control later, clamp it at 70.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Four traps, and how this project handles each
|
||||||
|
|
||||||
|
These are the things that make a naive client fail. All four are handled in
|
||||||
|
[backend/robot/transports/aimdk_transport.py](../backend/robot/transports/aimdk_transport.py).
|
||||||
|
|
||||||
|
| # | The trap | What we do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | The success flag is spelled **`is_sucess`** — one `c` — in AgiBot's documentation. | Read `is_sucess` **and** `is_success`, so a corrected firmware still works. |
|
||||||
|
| 2 | The **returned `trace_id` is not the one you sent** — the robot appends a random suffix. Stop only works with the returned value. | The returned id is captured and is what `Stop` uses. |
|
||||||
|
| 3 | **1024 *bytes*** of UTF-8 is a hard limit (~200 characters). A 1000-character Chinese sentence is ~3000 bytes and is rejected outright. | Text is chunked on sentence boundaries by **byte** length; only the first chunk may interrupt, the rest queue behind it. |
|
||||||
|
| 4 | Status polling is capped at **≤ 0.2 Hz**; AgiBot warns high-frequency RPC can destabilise the robot. | The liveness probe is a **TCP connect, never an RPC**, and completion is estimated rather than polled. |
|
||||||
|
|
||||||
|
Two more behaviours worth knowing, from reading AimRT's own source
|
||||||
|
([http_rpc_backend.cc](https://raw.githubusercontent.com/AimRT/AimRT/main/src/plugins/net_plugin/http/http_rpc_backend.cc)):
|
||||||
|
|
||||||
|
- The route is `"/rpc" + <func name without prefix>` — the `pb:/` prefix seen in
|
||||||
|
AimRT function names is **stripped**. Never put `pb:/` in a URL.
|
||||||
|
- **An RPC-level failure is HTTP 500, not a JSON error.** Unknown route is 404.
|
||||||
|
**HTTP 200 means the transport worked, not that the robot spoke** —
|
||||||
|
`is_sucess` only confirms the priority check passed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Why this path, and not the others
|
||||||
|
|
||||||
|
| Path | Latency | Reliability | Complexity | Verdict |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **1. HTTP JSON-RPC `PlayTTS`** (this project) | 1 request RTT to enqueue (< 20 ms LAN); time-to-first-audio dominated by the robot's own synthesis | Highest — AgiBot's own primary documented path | Near zero — one HTTP POST | **✅ Chosen** |
|
||||||
|
| 2. Own TTS → WAV → SCP → `PlayFile` | 1–4 s cold; ~1 RTT for pre-cached phrases | Good; documented offline fallback | Moderate — SSH creds, file lifecycle | Fallback if #1 needs internet and you are air-gapped |
|
||||||
|
| 3. Own TTS → PCM → ROS 2 `/audiohal/audio/playback` | Best possible — tens of ms, true streaming | High ceiling, high risk (best-effort QoS, focus arbitration) | **High** — ROS 2 Jazzy on the PC, login-gated aarch64 packages, robot reboot into `only_voice` | Only if you need barge-in streaming |
|
||||||
|
| 4. LinkSoul cloud agent SDK (WebSocket) | Worst — PC → AgiBot cloud → robot | n/a | Commercial credentials, cloud dependency | Wrong tool: a conversational agent layer, not a "say this now" primitive |
|
||||||
|
| 5. SSH + direct ALSA write | — | — | — | **Do not ship.** No AgiBot document mentions ALSA. `hal_audio` owns the device behind an explicit focus/priority protocol; bypassing it also bypasses the volume damage limit. |
|
||||||
|
|
||||||
|
Path 1 wins on every axis the brief asked for: lowest latency, highest
|
||||||
|
reliability, simplest deployment, best-documented A3 compatibility, and it
|
||||||
|
scales because it is stateless HTTP.
|
||||||
|
|
||||||
|
**Critically, it sends *text*.** The robot synthesises. Nothing is rendered,
|
||||||
|
encoded or transferred by the PC — which is exactly the architecture the
|
||||||
|
"fast speech" requirement asks for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What this project does with it
|
||||||
|
|
||||||
|
`A3_TRANSPORT=aimdk` (the default) selects
|
||||||
|
[aimdk_transport.py](../backend/robot/transports/aimdk_transport.py):
|
||||||
|
|
||||||
|
- one **keep-alive** `httpx` client opened at startup and reused for every
|
||||||
|
utterance — no handshake per Speak;
|
||||||
|
- a `Timeout:` header (AimRT honours whole seconds) **and** a client-side
|
||||||
|
timeout, so a wedged robot cannot hang the dashboard;
|
||||||
|
- ack latency measured and reported to the UI;
|
||||||
|
- `Stop` via `StopTTSTraceId` using the robot-returned `trace_id`;
|
||||||
|
- errors mapped to messages an operator can act on.
|
||||||
|
|
||||||
|
**Every documented value is a setting, not a literal**, because AimRT has no
|
||||||
|
service-discovery endpoint and AgiBot does not guarantee port or name stability
|
||||||
|
across firmware:
|
||||||
|
|
||||||
|
```env
|
||||||
|
A3_AIMDK_SERVICE=aimdk.protocol.TTSService
|
||||||
|
A3_AIMDK_PLAY_METHOD=PlayTTS
|
||||||
|
A3_AIMDK_STOP_METHOD=StopTTSTraceId
|
||||||
|
A3_AIMDK_PRIORITY=INTERACTION_L6
|
||||||
|
A3_AIMDK_MAX_BYTES=1024
|
||||||
|
```
|
||||||
|
|
||||||
|
If your firmware renames something, you edit `.env`. If it uses a wholly
|
||||||
|
different shape, `A3_TRANSPORT=http` gives you a fully templated HTTP client, and
|
||||||
|
`ws` / `ros2` / `ssh` are there as further fallbacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Connecting the robot — the exact steps
|
||||||
|
|
||||||
|
### 5.1 Where the IP goes
|
||||||
|
|
||||||
|
**One file: `.env`, in the project root.** Three lines:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ROBOT_MODE=real
|
||||||
|
ROBOT_IP=192.168.1.50 ← the robot's IP on YOUR network
|
||||||
|
ROBOT_PORT=59301
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Do not use `10.42.10.10`.** That address appears throughout AgiBot's
|
||||||
|
> examples, but it is the HDU's address on the robot's *internal* `eth_hdu`
|
||||||
|
> network. Your PC cannot reach it. Use the HDU's address on your WiFi/LAN.
|
||||||
|
|
||||||
|
Then either restart the server, or without restarting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/config/reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 The order to do things in
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. reachable at all?
|
||||||
|
ping 192.168.1.50
|
||||||
|
|
||||||
|
# 2. what does it expose? (probes the documented ports and routes, read-only)
|
||||||
|
python scripts/discover_robot.py 192.168.1.50
|
||||||
|
|
||||||
|
# 3. the decisive test - does it actually speak?
|
||||||
|
python scripts/discover_robot.py 192.168.1.50 --speak "Hello, I am Expedition A3"
|
||||||
|
|
||||||
|
# 4. point the app at it, then
|
||||||
|
python scripts/selftest.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 What each failure means
|
||||||
|
|
||||||
|
| Result of step 3 | Meaning | Next step |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **The robot speaks** | Path 1 works. Done. | Set `.env`, restart, demo. |
|
||||||
|
| Connection refused / timeout | 59301 is not bound on the WLAN interface | §6 Step 2 — SSH in and check `ss -ltnp` |
|
||||||
|
| **HTTP 404** | Port is right, route is wrong | Check `A3_AIMDK_SERVICE` / `A3_AIMDK_PLAY_METHOD`; confirm firmware version |
|
||||||
|
| **HTTP 500** | Route exists, the RPC failed — **the port is reachable** | Read the response body; check `priority_level` |
|
||||||
|
| `is_sucess: false` | The robot rejected it | Read `error_message` — usually priority or text length |
|
||||||
|
| 200 + success but silence | Accepted but not played | Check robot volume, and whether TTS needs internet (§7) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Discovery procedure for the live robot
|
||||||
|
|
||||||
|
`scripts/discover_robot.py` automates the PC-side checks. The rest needs an SSH
|
||||||
|
session on the robot.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the PC - which of the documented ports answer?
|
||||||
|
python scripts/discover_robot.py <ROBOT_IP>
|
||||||
|
|
||||||
|
# Equivalent by hand. A 404 proves an AimRT HTTP server is listening;
|
||||||
|
# connection-refused proves it is not.
|
||||||
|
for p in 59301 56666 51049; do
|
||||||
|
curl -s -o /dev/null -w "$p -> %{http_code}\n" -m 3 \
|
||||||
|
"http://<ROBOT_IP>:$p/rpc/does.not.Exist/Nope"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
On the robot (username `agi` is **[UNVERIFIED]** — confirm with AgiBot):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Is the RPC server bound to 0.0.0.0 or only to the internal 10.42.10.10?
|
||||||
|
ss -ltnp | grep -E '59301|56666|51049'
|
||||||
|
ip -br a # the HDU's real WLAN address
|
||||||
|
|
||||||
|
# The authoritative port map, from firmware config
|
||||||
|
grep -rn 'listen_port' /agibot/software/v0/ 2>/dev/null | head -50
|
||||||
|
|
||||||
|
# ROS environment (resolves ROS_DOMAIN_ID, only needed for the ros2 transport)
|
||||||
|
source /agibot/software/v0/entry/env/env.sh || source /agibot/software/v0/entry/env.sh
|
||||||
|
echo "DOMAIN=$ROS_DOMAIN_ID RMW=$RMW_IMPLEMENTATION"
|
||||||
|
|
||||||
|
# The real audio surface — this substitutes for the missing discovery API
|
||||||
|
ros2 topic list | grep -Ei 'audio|tts|interaction'
|
||||||
|
ros2 topic echo --once /interaction/tts_status # while a PlayTTS is running
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Do not put any of these in a loop.** AgiBot's
|
||||||
|
> [constraints page](https://open.agibot.com/docs/aimdk/a3/v3_2/dev_guide/04-second_develop_constraints_overview)
|
||||||
|
> caps status RPCs at ≤ 0.2 Hz, forbids deploying on the MDU ("risks the robot
|
||||||
|
> falling"), forbids `apt` install/uninstall, and states violations "may void
|
||||||
|
> warranty."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open questions — send these to AgiBot
|
||||||
|
|
||||||
|
The five that actually affect this project, in priority order. (Chinese versions
|
||||||
|
follow, since AgiBot support answers faster in Chinese.)
|
||||||
|
|
||||||
|
1. **Is port 59301 on the HDU bound to all interfaces and reachable from a PC on
|
||||||
|
the same WiFi/LAN using the HDU's WLAN IP — or only on the internal
|
||||||
|
`eth_hdu` 10.42.10.x segment?**
|
||||||
|
*HDU 的 59301 端口是否监听在所有网络接口上?能否从同一局域网的 PC 通过 HDU 的 WLAN IP 访问?还是仅绑定在内部 eth_hdu (10.42.10.x) 网段?*
|
||||||
|
|
||||||
|
2. **Does `TTSService/PlayTTS` require internet access?** Your English v3.2 page
|
||||||
|
says it "requires network connection". Which engine performs synthesis, and is
|
||||||
|
there a fully on-device/offline TTS mode? What happens with LAN but no internet?
|
||||||
|
*`PlayTTS` 是否必须联网?语音合成由哪家引擎提供?是否有完全离线的端侧 TTS 方案?只有局域网、没有外网时会怎样?*
|
||||||
|
|
||||||
|
3. **Is there any authentication, token or IP allow-list on the `/rpc/`
|
||||||
|
endpoints?** If not, what do you recommend on a shared network? (AimRT sets
|
||||||
|
`Access-Control-Allow-Origin: *` unconditionally, so any page on the LAN can
|
||||||
|
call these.)
|
||||||
|
*`/rpc/` 接口是否有鉴权、token 或 IP 白名单?如果没有,在共享网络下推荐如何防护?*
|
||||||
|
|
||||||
|
4. **Can we select voice/timbre, language, speaking rate or emotion per
|
||||||
|
`PlayTTS` call?** Is `SetTtsParameters` or an equivalent available on the A3,
|
||||||
|
and at which endpoint? **Our unit's voice is currently set to "Yunxiao"
|
||||||
|
(teenager, male, multi-language) — is that selectable per request, or only in
|
||||||
|
the console?**
|
||||||
|
*能否在每次 PlayTTS 调用时指定音色、语言、语速或情感?A3 是否提供 SetTtsParameters?接口地址是什么?
|
||||||
|
我们的机器人当前音色为"Yunxiao"(少年、男声、多语言),该音色能否在每次调用时指定,还是只能在控制台配置?*
|
||||||
|
|
||||||
|
> **Operator-reported, 2026-09-02:** the robot's configured voice is
|
||||||
|
> **"Yunxiao"** — labelled *teenager | male*, *multi-language*, and marked as
|
||||||
|
> the default (默). It was shown in a voice-library UI offering per-voice
|
||||||
|
> "Speech Synthesis", which is consistent with AgiBot's LinkSoul timbre
|
||||||
|
> library (30–40 timbres + voice cloning) — though that link is inference, not
|
||||||
|
> something any published document states. This is **not** in AgiBot's docs;
|
||||||
|
> it comes from the console for this specific unit. It suggests voice
|
||||||
|
> selection happens on a platform/console rather than in the `PlayTTS` body,
|
||||||
|
> which is exactly what question 4 needs to settle.
|
||||||
|
|
||||||
|
5. **Please confirm the complete `priority_level` enum** accepted by A3
|
||||||
|
`PlayTTS`, and the semantics of `priority_weight` and `is_interrupted`. Your
|
||||||
|
X2 documentation publishes seven levels — do the same apply to the A3?
|
||||||
|
*请提供 A3 PlayTTS 支持的完整 priority_level 枚举,以及 priority_weight 和 is_interrupted 的语义。X2 文档公布了七个等级,A3 是否相同?*
|
||||||
|
|
||||||
|
Lower priority, worth asking in the same message: the response envelope
|
||||||
|
(is there a `header.code`/`header.msg` wrapper?); whether the AimDK SDK download
|
||||||
|
needs a verified-customer account; whether ports 59301/56666/51049 are stable
|
||||||
|
across firmware; and the `PlayMediaFile` sample rate — **your English v3.2 page
|
||||||
|
says 24 kHz and your Chinese v3.2 page says 16 kHz for the same call.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Known contradictions in AgiBot's own documentation
|
||||||
|
|
||||||
|
Recorded so nobody wastes an afternoon on them.
|
||||||
|
|
||||||
|
| # | Contradiction | Impact here |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `PlayMediaFile` sample rate: **EN v3.2 says 24 kHz, ZH v3.2 says 16 kHz**, same page | None — we send text, not files |
|
||||||
|
| 2 | Microphone: v3.1 and v3.2 dev guides say the built-in mic is "not yet configured or enabled"; the User Manual and product page advertise an 8-mic array | None — output only |
|
||||||
|
| 3 | `env.sh` path: `/agibot/software/v0/entry/env.sh` vs `…/entry/env/env.sh` | Only matters for the `ros2` transport |
|
||||||
|
| 4 | ROS 2 distro: AgiBot says the A3 is **Jazzy**; AimRT's `ros2_plugin` doc says Humble | Only matters for the `ros2` transport |
|
||||||
|
| 5 | SDK wheel: the A3 v3.2 guide instructs `a2_aimdk-3.0.0…whl` in most places and `a3_aimdk-3.0.0…whl` in one — an **A2 artifact inside the A3 doc tree** | None — Path 1 needs no SDK |
|
||||||
|
|
||||||
|
**`ROS_DOMAIN_ID` for the A3 is [UNKNOWN]** — it is not printed in any A3
|
||||||
|
document. A2 uses 232 (**different model**), and one third-party A3-Ultra
|
||||||
|
deployment also uses 232 — suggestive, not proof. Read it from `env.sh` on the
|
||||||
|
robot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Security note worth raising internally
|
||||||
|
|
||||||
|
No authentication scheme is documented for the `/rpc/` endpoints, and AimRT sets
|
||||||
|
`Access-Control-Allow-Origin: *` unconditionally with OPTIONS preflight handled.
|
||||||
|
**Taken together: any host — or any web page opened in any browser — on the same
|
||||||
|
LAN as the robot can make it speak.**
|
||||||
|
|
||||||
|
This project does not widen that exposure. The browser talks only to
|
||||||
|
`127.0.0.1:8000`; robot credentials and addresses stay server-side. But on a
|
||||||
|
shared or public network, treat the robot's RPC ports as unauthenticated and
|
||||||
|
segment the network accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing the real path before the robot arrives
|
||||||
|
|
||||||
|
`ROBOT_MODE=mock` tests the application. To test **this transport** — the URL
|
||||||
|
shape, the JSON body, the `trace_id` round trip, chunking, Stop, and the error
|
||||||
|
paths — run the local stand-in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fake_a3_server.py # implements the documented contract on :59301
|
||||||
|
```
|
||||||
|
|
||||||
|
then in `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ROBOT_MODE=real
|
||||||
|
ROBOT_IP=127.0.0.1
|
||||||
|
ROBOT_PORT=59301
|
||||||
|
```
|
||||||
|
|
||||||
|
and use the dashboard normally. Every utterance prints in the stand-in's console.
|
||||||
|
|
||||||
|
> It is a **test double written from public documentation**, not the robot.
|
||||||
|
> Passing against it proves the client is well-formed — not that the firmware
|
||||||
|
> behaves identically. Verify on the real unit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Sources
|
||||||
|
|
||||||
|
**Official AgiBot, A3-specific**
|
||||||
|
- Audio interfaces (TTS, status, stop, volume, ROS 2 audio) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play) · [ZH](https://open.agibot.com/docs/aimdk/a3/v3_2/dev_guide/07-02-audio_play)
|
||||||
|
- Interface overview (the two transports, URL shape, remote-call sanction) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/03-second_develop_interface_overview)
|
||||||
|
- Quick start (the runnable `PlayTTS` curl) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_1/dev_guide/06-second_develop_quick_start)
|
||||||
|
- Resource manager (`PlayFile`, WAV/PCM rules, resource dirs) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-05-resource_manager)
|
||||||
|
- Interactive guide (`audio_msgs/msg/AudioPlayback`, focus services, `only_voice`) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/08-interactive_guide)
|
||||||
|
- Development constraints (polling limits, MDU, apt, warranty) — [ZH](https://open.agibot.com/docs/aimdk/a3/v3_2/dev_guide/04-second_develop_constraints_overview)
|
||||||
|
- Hardware/software overview (HDU/MDU, internal IPs, Ubuntu 24.04) — [ZH](https://open.agibot.com/docs/aimdk/a3/v3_1/dev_guide/01-a3_overview)
|
||||||
|
- AimDK docs root — https://open.agibot.com/docs/aimdk (SDK downloads are login-gated)
|
||||||
|
|
||||||
|
**AimRT framework** (transport semantics, read from source)
|
||||||
|
- [net_plugin docs](https://docs.aimrt.org/tutorials/plugins/net_plugin.html) · [http_rpc_backend.cc](https://raw.githubusercontent.com/AimRT/AimRT/main/src/plugins/net_plugin/http/http_rpc_backend.cc)
|
||||||
|
|
||||||
|
**Different model — do not assume it applies to the A3**
|
||||||
|
- X2 voice interface, full 7-value priority enum — https://x2-aimdk.agibot.com/en/dev/Interface/interactor/voice.html
|
||||||
|
|
||||||
|
**Third-party corroboration** (an A3 **Ultra** deployment, not a specification)
|
||||||
|
- [tzj-hub/agibot_A3_sells_cars](https://github.com/tzj-hub/agibot_A3_sells_cars) — same base URL, methods and payload shape; reads `is_sucess` with an `is_success` fallback; its `a3_interfaces.py` is the only source anywhere for the `header.code`/`header.msg` error envelope, which is why this project reads that envelope defensively rather than depending on it.
|
||||||
196
docs/NETWORK.md
Normal file
196
docs/NETWORK.md
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
# Network setup: PC ↔ AGIBOT A3
|
||||||
|
|
||||||
|
Everything here is standard LAN networking. Nothing in this file assumes anything
|
||||||
|
about the A3's software — that lives in
|
||||||
|
[AGIBOT_A3_INTEGRATION.md](AGIBOT_A3_INTEGRATION.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ Your PC │ │ AGIBOT A3 │
|
||||||
|
│ │ same LAN / subnet │ │
|
||||||
|
│ browser │ ───────────────────────► │ speech svc │
|
||||||
|
│ ↓ │ 192.168.x.x │ ↓ │
|
||||||
|
│ backend │ │ speaker │
|
||||||
|
└──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The PC and the robot must be able to reach each other by IP. That is the whole
|
||||||
|
requirement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Put both machines on the same network
|
||||||
|
|
||||||
|
Ranked by how well they work in practice:
|
||||||
|
|
||||||
|
| Setup | Latency | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Wired Ethernet, same switch** | best, ~0.2–1 ms | Ideal for a demo. Nothing to go wrong. |
|
||||||
|
| **Both on the same Wi-Fi AP** | ~2–20 ms, variable | Fine, but a crowded venue Wi-Fi is the #1 cause of a bad demo. |
|
||||||
|
| **PC wired, robot Wi-Fi (same subnet)** | mixed | Works. Check they are on the same subnet, not two VLANs. |
|
||||||
|
| **Direct cable PC ↔ robot** | best | Needs static IPs on both, or link-local. Useful when there is no venue network. |
|
||||||
|
| Different subnets / guest Wi-Fi | — | Usually blocked. Guest networks isolate clients from each other. |
|
||||||
|
|
||||||
|
> **Demo advice:** if you can run a cable, run a cable. Client isolation on a
|
||||||
|
> conference Wi-Fi will silently block PC→robot traffic while both devices show
|
||||||
|
> "connected to the internet".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Find the robot's IP address
|
||||||
|
|
||||||
|
Any of these, easiest first:
|
||||||
|
|
||||||
|
1. **The robot's own screen / app / teach pendant** — usually shows the IP in a
|
||||||
|
network or system settings page. Most reliable.
|
||||||
|
2. **Your router's DHCP client list** — log into the router (often
|
||||||
|
`192.168.1.1`), look for a newly-connected device.
|
||||||
|
3. **Scan the subnet from the PC.** First find your own subnet:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ipconfig # look at IPv4 Address, e.g. 192.168.1.23
|
||||||
|
```
|
||||||
|
|
||||||
|
Then sweep it:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows: ping every host, then read the ARP table
|
||||||
|
1..254 | ForEach-Object { Start-Process -WindowStyle Hidden ping "192.168.1.$_" -ArgumentList "-n 1 -w 200" }
|
||||||
|
Start-Sleep 5
|
||||||
|
arp -a
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# if you have nmap (any OS) - much better
|
||||||
|
nmap -sn 192.168.1.0/24
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **mDNS**, if the robot advertises itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ping agibot-a3.local
|
||||||
|
```
|
||||||
|
|
||||||
|
A hostname works anywhere this project asks for `ROBOT_IP`.
|
||||||
|
|
||||||
|
> Ask whoever commissions the robot to give it a **DHCP reservation** (a fixed
|
||||||
|
> IP tied to its MAC address). Otherwise the IP can change on reboot and you will
|
||||||
|
> be editing `.env` before every demo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Test reachability
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ping 192.168.1.50
|
||||||
|
```
|
||||||
|
|
||||||
|
| Result | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| Replies with a time | The robot is reachable. Continue. |
|
||||||
|
| `Request timed out` | Wrong IP, robot off, different subnet, or ICMP blocked (see below). |
|
||||||
|
| `Destination host unreachable` | No route — you are on a different subnet. |
|
||||||
|
|
||||||
|
**ICMP being blocked does not mean the robot is unreachable.** Some robots drop
|
||||||
|
ping but still answer on their service port. Test the port directly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-NetConnection 192.168.1.50 -Port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nc -vz 192.168.1.50 8080 # Linux/macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
Then let this project probe it properly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/discover_robot.py 192.168.1.50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Configure this app
|
||||||
|
|
||||||
|
In `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ROBOT_MODE=real
|
||||||
|
ROBOT_IP=192.168.1.50
|
||||||
|
ROBOT_PORT=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the server, or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/config/reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The header pill turns green within a few seconds if the connection succeeds. If
|
||||||
|
it does not, `GET /api/robot/diagnostics` says exactly what was attempted and
|
||||||
|
what failed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Firewall
|
||||||
|
|
||||||
|
The PC makes **outbound** connections to the robot, so a Windows Firewall inbound
|
||||||
|
rule is usually unnecessary. Two exceptions:
|
||||||
|
|
||||||
|
- **`HOST=0.0.0.0`** — you want to open the dashboard from another device.
|
||||||
|
Windows will prompt to allow Python on private networks the first time; allow
|
||||||
|
it for *Private* networks only, never *Public*.
|
||||||
|
- **ROS 2 / DDS transport** — DDS uses multicast discovery and a wide range of
|
||||||
|
UDP ports **inbound** to the PC. This is the one case where the firewall
|
||||||
|
usually needs a rule:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# run as Administrator, only if you use A3_TRANSPORT=ros2
|
||||||
|
New-NetFirewallRule -DisplayName "ROS2 DDS" -Direction Inbound -Protocol UDP `
|
||||||
|
-LocalPort 7400-7600 -Action Allow -Profile Private
|
||||||
|
```
|
||||||
|
|
||||||
|
DDS discovery is multicast and does **not** cross subnets or most Wi-Fi APs.
|
||||||
|
ROS 2 realistically requires the PC and robot on the same wired L2 segment,
|
||||||
|
with the same `ROS_DOMAIN_ID`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Latency expectations
|
||||||
|
|
||||||
|
Measured by this app and shown in the header. What to expect on a healthy LAN:
|
||||||
|
|
||||||
|
| Hop | Typical |
|
||||||
|
| --- | --- |
|
||||||
|
| Browser → local backend | < 2 ms (loopback) |
|
||||||
|
| Backend → robot, wired | 0.5–3 ms |
|
||||||
|
| Backend → robot, Wi-Fi | 2–30 ms, occasionally spiking |
|
||||||
|
| Robot's own TTS synthesis start | the dominant term — tens to hundreds of ms |
|
||||||
|
|
||||||
|
The network is almost never the bottleneck; the robot's speech synthesis is. That
|
||||||
|
is exactly why this project sends **text** and lets the robot synthesise, instead
|
||||||
|
of generating audio on the PC and transferring it.
|
||||||
|
|
||||||
|
If the header shows latency above ~50 ms on Wi-Fi, move to Ethernet before
|
||||||
|
blaming the software.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pill stuck on *Connecting…* | Wrong IP or port, robot booting | `ping`, then `discover_robot.py` |
|
||||||
|
| `Robot is offline` immediately | Nothing listening on `ROBOT_PORT` | Confirm the port with the discovery script |
|
||||||
|
| Ping works, app says offline | Right host, wrong port or path | Check `ROBOT_PORT` and `A3_HTTP_SPEAK_PATH` |
|
||||||
|
| Works, then drops after minutes | Wi-Fi roaming, DHCP lease change | Wired connection + DHCP reservation |
|
||||||
|
| Connects but nothing is heard | Robot volume, muted speaker, wrong audio sink | Check the robot's own volume first |
|
||||||
|
| Fine on the bench, fails at the venue | Client isolation on guest Wi-Fi | Bring your own switch/router |
|
||||||
|
| ROS 2 sees no topics | Different `ROS_DOMAIN_ID`, multicast blocked | Same domain ID, same wired segment |
|
||||||
|
|
||||||
|
Two commands answer most questions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/discover_robot.py <ROBOT_IP> # what the robot exposes
|
||||||
|
curl http://localhost:8000/api/robot/diagnostics # what this app tried and saw
|
||||||
|
```
|
||||||
168
frontend/index.html
Normal file
168
frontend/index.html
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-loading="true">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>AGIBOT A3 · Voice Control</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
|
||||||
|
<link rel="stylesheet" href="/styles/main.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ================= HEADER ================= -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar__brand">
|
||||||
|
<span class="brand__mark" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="4" y="8" width="16" height="12" rx="3" />
|
||||||
|
<path d="M12 4v4" /><circle cx="12" cy="3" r="1.4" />
|
||||||
|
<circle cx="9" cy="14" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="15" cy="14" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
<path d="M9.5 17.5h5" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="brand__text">
|
||||||
|
<h1>AGIBOT A3</h1>
|
||||||
|
<p>Voice Control</p>
|
||||||
|
</div>
|
||||||
|
<span class="mode-badge" id="modeBadge" title="Robot mode">--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar__status">
|
||||||
|
<!-- starts amber, not red: before the socket opens we genuinely do not
|
||||||
|
know the robot's state, and a red flash on every load reads as a fault -->
|
||||||
|
<div class="conn" id="connPill" data-state="connecting" title="Robot connection">
|
||||||
|
<span class="conn__dot" aria-hidden="true"></span>
|
||||||
|
<span class="conn__label" id="connLabel">Starting…</span>
|
||||||
|
</div>
|
||||||
|
<div class="chip" id="latencyChip" hidden title="Round-trip time to the robot">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8Z"/></svg>
|
||||||
|
<span id="latencyValue">–</span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" id="reconnectBtn" title="Reconnect to robot" aria-label="Reconnect to robot">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.64-6.36" /><path d="M21 3v6h-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ================= CONFIG BANNER ================= -->
|
||||||
|
<div class="banner" id="configBanner" hidden role="alert">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/></svg>
|
||||||
|
<div class="banner__body"><strong id="bannerTitle">Configuration</strong><span id="bannerText"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= MAIN ================= -->
|
||||||
|
<main class="layout">
|
||||||
|
|
||||||
|
<!-- ---------- speech panel ---------- -->
|
||||||
|
<section class="panel panel--speech" aria-label="Speech">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2>Say something</h2>
|
||||||
|
<span class="panel__hint">The robot speaks through its built-in speaker</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<textarea
|
||||||
|
id="textInput"
|
||||||
|
class="composer__input"
|
||||||
|
placeholder="Type what you want the robot to say…"
|
||||||
|
spellcheck="true"
|
||||||
|
autocomplete="off"
|
||||||
|
rows="7"
|
||||||
|
maxlength="1000"></textarea>
|
||||||
|
|
||||||
|
<div class="composer__meta">
|
||||||
|
<span class="kbd-hint"><kbd>Ctrl</kbd><span>+</span><kbd>Enter</kbd> to speak · <kbd>Esc</kbd> to stop</span>
|
||||||
|
<span class="counter" id="counter"><b id="counterNow">0</b> / <span id="counterMax">1000</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn--speak" id="speakBtn" disabled>
|
||||||
|
<span class="btn__icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M11 5 6 9H2v6h4l5 4V5Z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 5.5a9 9 0 0 1 0 13" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="btn__label" id="speakLabel">Speak</span>
|
||||||
|
<span class="btn__spinner" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn--ghost" id="stopBtn" disabled>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn--ghost" id="clearBtn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="m19 6-1 14H6L5 6"/></svg>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- pipeline -->
|
||||||
|
<div class="pipeline" id="pipeline" data-stage="idle">
|
||||||
|
<div class="pipeline__track" aria-hidden="true"><i id="pipelineFill"></i></div>
|
||||||
|
<ol class="pipeline__steps">
|
||||||
|
<li data-step="sending"><span class="dot"></span>Sending</li>
|
||||||
|
<li data-step="processing"><span class="dot"></span>Processing</li>
|
||||||
|
<li data-step="speaking"><span class="dot"></span>Playing</li>
|
||||||
|
<li data-step="completed"><span class="dot"></span>Completed</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- live status -->
|
||||||
|
<div class="live" id="live" data-tone="idle" role="status" aria-live="polite">
|
||||||
|
<span class="live__wave" id="liveWave" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
|
||||||
|
<span class="live__text" id="liveText">Ready</span>
|
||||||
|
<span class="live__detail" id="liveDetail"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ---------- sidebar ---------- -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
|
||||||
|
<section class="panel panel--status" aria-label="Robot status">
|
||||||
|
<div class="panel__head"><h2>Robot</h2></div>
|
||||||
|
<dl class="facts">
|
||||||
|
<div class="fact"><dt>State</dt><dd id="factState">–</dd></div>
|
||||||
|
<div class="fact"><dt>Mode</dt><dd id="factMode">–</dd></div>
|
||||||
|
<div class="fact"><dt>Link</dt><dd id="factTransport">–</dd></div>
|
||||||
|
<div class="fact"><dt>Address</dt><dd id="factAddress" class="mono">–</dd></div>
|
||||||
|
<div class="fact"><dt>Latency</dt><dd id="factLatency">–</dd></div>
|
||||||
|
<div class="fact"><dt>Uptime</dt><dd id="factUptime">–</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="panel__note" id="statusNote" hidden></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel--audio" aria-label="Saved audio">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2>Saved audio <span class="count" id="audioCount">0</span></h2>
|
||||||
|
<button class="link-btn" id="clearAudioBtn">Clear</button>
|
||||||
|
</div>
|
||||||
|
<p class="panel__note" id="audioNote">
|
||||||
|
Every line spoken with the neural voice is saved as a .wav file, so it
|
||||||
|
replays instantly with no synthesis and no internet.
|
||||||
|
</p>
|
||||||
|
<ul class="audio-list" id="audioList"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel--history" aria-label="Speech history">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2>History <span class="count" id="historyCount">0</span></h2>
|
||||||
|
<button class="link-btn" id="clearHistoryBtn">Clear</button>
|
||||||
|
</div>
|
||||||
|
<ul class="history" id="historyList"></ul>
|
||||||
|
<p class="empty" id="historyEmpty">Nothing spoken yet. Your recent utterances appear here — click one to reuse it.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toasts" id="toasts" aria-live="assertive"></div>
|
||||||
|
|
||||||
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
69
frontend/js/api.js
Normal file
69
frontend/js/api.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Thin REST client for the local backend.
|
||||||
|
* The page never talks to the robot directly - only to this origin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(message, code, status) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.code = code || 'error';
|
||||||
|
this.status = status || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(path, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The backend itself is unreachable - a different failure from "robot offline".
|
||||||
|
throw new ApiError(
|
||||||
|
'Cannot reach the local server. Is backend/main.py still running?',
|
||||||
|
'backend_down',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = null;
|
||||||
|
const text = await response.text();
|
||||||
|
if (text) {
|
||||||
|
try { body = JSON.parse(text); } catch { body = { error: text }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
(body && (body.error || body.detail)) ||
|
||||||
|
`Request failed (HTTP ${response.status})`;
|
||||||
|
throw new ApiError(
|
||||||
|
typeof message === 'string' ? message : JSON.stringify(message),
|
||||||
|
(body && body.errorCode) || 'http_error',
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
status: () => request('/api/robot/status'),
|
||||||
|
config: () => request('/api/config'),
|
||||||
|
diagnostics: () => request('/api/robot/diagnostics'),
|
||||||
|
reloadConfig: () => request('/api/config/reload', { method: 'POST' }),
|
||||||
|
reconnect: () => request('/api/robot/reconnect', { method: 'POST' }),
|
||||||
|
speak: (text) => request('/api/robot/speak', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
}),
|
||||||
|
stop: () => request('/api/robot/stop', { method: 'POST' }),
|
||||||
|
history: () => request('/api/speech/history'),
|
||||||
|
clearHistory: () => request('/api/speech/history', { method: 'DELETE' }),
|
||||||
|
audio: () => request('/api/audio'),
|
||||||
|
audioUrl: (id) => `/api/audio/${id}/file`,
|
||||||
|
deleteAudio: (id) => request(`/api/audio/${id}`, { method: 'DELETE' }),
|
||||||
|
clearAudio: () => request('/api/audio', { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ApiError };
|
||||||
373
frontend/js/app.js
Normal file
373
frontend/js/app.js
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* Application wiring.
|
||||||
|
*
|
||||||
|
* Flow: user text -> POST /api/robot/speak -> backend -> robot
|
||||||
|
* robot lifecycle -> backend event bus -> WebSocket -> this file -> UI
|
||||||
|
*
|
||||||
|
* The POST returns as soon as the robot acknowledges the utterance; everything
|
||||||
|
* after that ("speaking", "completed", failures) arrives over the socket.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api, ApiError } from './api.js';
|
||||||
|
import { RobotSocket } from './socket.js';
|
||||||
|
import * as ui from './ui.js';
|
||||||
|
|
||||||
|
const DRAFT_KEY = 'agibot-a3:draft';
|
||||||
|
const RESET_DELAY = 2200;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
config: { maxLength: 1000, mode: 'mock' },
|
||||||
|
status: { state: 'connecting', connected: false },
|
||||||
|
history: [],
|
||||||
|
audio: { items: [], stats: {} },
|
||||||
|
auditioning: null,
|
||||||
|
activeRequestId: null,
|
||||||
|
stage: 'idle',
|
||||||
|
resetTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket = new RobotSocket();
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// derived UI state
|
||||||
|
// =========================================================================
|
||||||
|
function syncControls() {
|
||||||
|
const length = ui.el.textInput.value.trim().length;
|
||||||
|
const busy = Boolean(state.activeRequestId);
|
||||||
|
const connected = Boolean(state.status.connected);
|
||||||
|
|
||||||
|
ui.setControls({
|
||||||
|
canSpeak: connected && length > 0 && length <= state.config.maxLength,
|
||||||
|
busy,
|
||||||
|
canStop: busy,
|
||||||
|
});
|
||||||
|
ui.renderCounter(ui.el.textInput.value.length, state.config.maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatus(status) {
|
||||||
|
state.status = { ...state.status, ...status };
|
||||||
|
ui.renderConnection(state.status);
|
||||||
|
ui.renderFacts(state.status);
|
||||||
|
|
||||||
|
// The robot vanished mid-utterance: stop pretending it is still speaking.
|
||||||
|
if (!state.status.connected && state.activeRequestId) {
|
||||||
|
finishUtterance('error', 'Robot disconnected during speech.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page opened (or reloaded) while the robot is mid-sentence - adopt it rather
|
||||||
|
// than showing "Ready" over a talking robot.
|
||||||
|
if (state.status.busy && state.status.activeRequestId && !state.activeRequestId) {
|
||||||
|
state.activeRequestId = state.status.activeRequestId;
|
||||||
|
state.stage = 'speaking';
|
||||||
|
clearTimeout(state.resetTimer);
|
||||||
|
ui.renderPipeline('speaking');
|
||||||
|
ui.renderLive('speaking', 'Speaking…');
|
||||||
|
}
|
||||||
|
|
||||||
|
syncControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// speech lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
const STAGE_VIEW = {
|
||||||
|
queued: { tone: 'busy', text: 'Queued…' },
|
||||||
|
sending: { tone: 'busy', text: 'Sending to robot…' },
|
||||||
|
processing: { tone: 'busy', text: 'Robot is preparing speech…' },
|
||||||
|
speaking: { tone: 'speaking', text: 'Speaking…' },
|
||||||
|
completed: { tone: 'ok', text: 'Completed' },
|
||||||
|
cancelled: { tone: 'error', text: 'Stopped' },
|
||||||
|
failed: { tone: 'error', text: 'Speech failed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function onProgress(progress) {
|
||||||
|
if (state.activeRequestId && progress.requestId !== state.activeRequestId) return;
|
||||||
|
state.activeRequestId = progress.requestId;
|
||||||
|
|
||||||
|
const stage = progress.stage;
|
||||||
|
state.stage = stage;
|
||||||
|
ui.renderPipeline(stage);
|
||||||
|
|
||||||
|
const view = STAGE_VIEW[stage] || { tone: 'busy', text: stage };
|
||||||
|
const detail = progress.detail
|
||||||
|
|| (typeof progress.elapsedMs === 'number' ? `${progress.elapsedMs} ms` : '');
|
||||||
|
ui.renderLive(view.tone, progress.message || view.text, detail);
|
||||||
|
|
||||||
|
if (['completed', 'failed', 'cancelled'].includes(stage)) {
|
||||||
|
if (stage === 'completed') refreshAudio();
|
||||||
|
if (stage === 'failed') ui.toast(progress.message || 'Speech request failed.', 'error');
|
||||||
|
finishUtterance(view.tone, progress.message || view.text, detail);
|
||||||
|
}
|
||||||
|
syncControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishUtterance(tone, message, detail = '') {
|
||||||
|
state.activeRequestId = null;
|
||||||
|
ui.renderLive(tone, message, detail);
|
||||||
|
syncControls();
|
||||||
|
|
||||||
|
clearTimeout(state.resetTimer);
|
||||||
|
state.resetTimer = setTimeout(() => {
|
||||||
|
if (state.activeRequestId) return; // a new utterance already started
|
||||||
|
state.stage = 'idle';
|
||||||
|
ui.renderPipeline('idle');
|
||||||
|
ui.renderLive('idle', state.status.connected ? 'Ready' : 'Robot offline');
|
||||||
|
}, RESET_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// history
|
||||||
|
// =========================================================================
|
||||||
|
function applyHistoryEvent(payload) {
|
||||||
|
if (payload.action === 'clear') {
|
||||||
|
state.history = [];
|
||||||
|
} else if (payload.entry) {
|
||||||
|
const index = state.history.findIndex((item) => item.id === payload.entry.id);
|
||||||
|
if (index === -1) state.history.unshift(payload.entry);
|
||||||
|
else state.history[index] = payload.entry;
|
||||||
|
const limit = state.config.historyLimit || 100;
|
||||||
|
if (state.history.length > limit) state.history.length = limit;
|
||||||
|
}
|
||||||
|
ui.renderHistory(state.history, useHistoryText, replayLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speak a history line again. Its audio is saved, so this is instant. */
|
||||||
|
async function replayLine(item) {
|
||||||
|
ui.el.textInput.value = item.text;
|
||||||
|
saveDraft();
|
||||||
|
await speak();
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHistoryText(text) {
|
||||||
|
ui.el.textInput.value = text;
|
||||||
|
ui.el.textInput.focus();
|
||||||
|
ui.el.textInput.setSelectionRange(text.length, text.length);
|
||||||
|
saveDraft();
|
||||||
|
syncControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// actions
|
||||||
|
// =========================================================================
|
||||||
|
async function speak() {
|
||||||
|
const text = ui.el.textInput.value.trim();
|
||||||
|
if (!text) {
|
||||||
|
ui.toast('Type something for the robot to say first.', 'error');
|
||||||
|
ui.el.textInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.status.connected) {
|
||||||
|
ui.toast(
|
||||||
|
state.status.error || 'Robot is offline. Check the robot IP and network connection.',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(state.resetTimer);
|
||||||
|
ui.renderPipeline('sending');
|
||||||
|
ui.renderLive('busy', 'Sending to robot…');
|
||||||
|
ui.setControls({ canSpeak: false, busy: true, canStop: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.speak(text);
|
||||||
|
state.activeRequestId = result.requestId;
|
||||||
|
syncControls();
|
||||||
|
} catch (err) {
|
||||||
|
state.activeRequestId = null;
|
||||||
|
const message = err instanceof ApiError ? err.message : 'Speech request failed.';
|
||||||
|
ui.toast(message, 'error');
|
||||||
|
ui.renderPipeline('failed');
|
||||||
|
finishUtterance('error', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
try {
|
||||||
|
await api.stop();
|
||||||
|
ui.renderLive('error', 'Stopping…');
|
||||||
|
} catch (err) {
|
||||||
|
ui.toast(err.message || 'Could not stop the robot.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearText() {
|
||||||
|
ui.el.textInput.value = '';
|
||||||
|
saveDraft();
|
||||||
|
ui.el.textInput.focus();
|
||||||
|
syncControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// saved audio
|
||||||
|
// =========================================================================
|
||||||
|
async function refreshAudio() {
|
||||||
|
try {
|
||||||
|
const data = await api.audio();
|
||||||
|
state.audio = { items: data.items || [], stats: data.stats || {} };
|
||||||
|
ui.renderAudio(state.audio.items, state.audio.stats, {
|
||||||
|
fileUrl: api.audioUrl,
|
||||||
|
onPlayState: (audio) => {
|
||||||
|
// Only one clip auditions at a time.
|
||||||
|
if (state.auditioning && state.auditioning !== audio) {
|
||||||
|
state.auditioning.pause();
|
||||||
|
state.auditioning.currentTime = 0;
|
||||||
|
}
|
||||||
|
state.auditioning = audio;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* the panel is a convenience; never let it break the page */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAudio() {
|
||||||
|
try {
|
||||||
|
const result = await api.clearAudio();
|
||||||
|
ui.toast(`Deleted ${result.removed} saved clip(s).`, 'ok', 3000);
|
||||||
|
await refreshAudio();
|
||||||
|
// Rows can no longer promise instant replay.
|
||||||
|
state.history = state.history.map((item) => ({ ...item, audioSaved: false }));
|
||||||
|
ui.renderHistory(state.history, useHistoryText, replayLine);
|
||||||
|
} catch (err) {
|
||||||
|
ui.toast(err.message || 'Could not clear saved audio.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearHistory() {
|
||||||
|
try {
|
||||||
|
await api.clearHistory();
|
||||||
|
state.history = [];
|
||||||
|
ui.renderHistory(state.history, useHistoryText, replayLine);
|
||||||
|
} catch (err) {
|
||||||
|
ui.toast(err.message || 'Could not clear history.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconnect() {
|
||||||
|
ui.el.reconnectBtn.dataset.spin = 'true';
|
||||||
|
try {
|
||||||
|
await api.reconnect();
|
||||||
|
ui.toast('Reconnecting to the robot…', 'info', 2500);
|
||||||
|
} catch (err) {
|
||||||
|
ui.toast(err.message || 'Reconnect failed.', 'error');
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => { ui.el.reconnectBtn.dataset.spin = 'false'; }, 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// draft persistence (per-browser convenience only)
|
||||||
|
// =========================================================================
|
||||||
|
function saveDraft() {
|
||||||
|
try { localStorage.setItem(DRAFT_KEY, ui.el.textInput.value); } catch { /* private mode */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDraft() {
|
||||||
|
try {
|
||||||
|
const draft = localStorage.getItem(DRAFT_KEY);
|
||||||
|
if (draft) ui.el.textInput.value = draft;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// events
|
||||||
|
// =========================================================================
|
||||||
|
function bindDom() {
|
||||||
|
ui.el.speakBtn.addEventListener('click', speak);
|
||||||
|
ui.el.stopBtn.addEventListener('click', stop);
|
||||||
|
ui.el.clearBtn.addEventListener('click', clearText);
|
||||||
|
ui.el.clearHistoryBtn.addEventListener('click', clearHistory);
|
||||||
|
ui.el.clearAudioBtn.addEventListener('click', clearAudio);
|
||||||
|
ui.el.reconnectBtn.addEventListener('click', reconnect);
|
||||||
|
|
||||||
|
ui.el.textInput.addEventListener('input', () => { saveDraft(); syncControls(); });
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Enter speaks. A bare Enter inserts a newline, on purpose - nobody
|
||||||
|
// wants a half-typed sentence going out of the robot's speaker mid-demo.
|
||||||
|
ui.el.textInput.addEventListener('keydown', (event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
speak();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && state.activeRequestId) {
|
||||||
|
event.preventDefault();
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSocket() {
|
||||||
|
socket.addEventListener('hello', (event) => {
|
||||||
|
const { config, status, history } = event.detail;
|
||||||
|
state.config = { ...state.config, ...config };
|
||||||
|
ui.renderConfig(state.config);
|
||||||
|
applyStatus(status);
|
||||||
|
state.history = history || [];
|
||||||
|
ui.renderHistory(state.history, useHistoryText, replayLine);
|
||||||
|
refreshAudio();
|
||||||
|
document.documentElement.removeAttribute('data-loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('robot.status', (event) => applyStatus(event.detail));
|
||||||
|
socket.addEventListener('speech.progress', (event) => onProgress(event.detail));
|
||||||
|
socket.addEventListener('history.updated', (event) => applyHistoryEvent(event.detail));
|
||||||
|
socket.addEventListener('config.updated', (event) => {
|
||||||
|
state.config = { ...state.config, ...event.detail };
|
||||||
|
ui.renderConfig(state.config);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('rtt', (event) => {
|
||||||
|
const rtt = event.detail.rtt;
|
||||||
|
if (typeof rtt === 'number') {
|
||||||
|
ui.el.latencyChip.title = `Robot round-trip. Browser to backend: ${rtt} ms`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('link', (event) => {
|
||||||
|
if (event.detail.up) return;
|
||||||
|
// The backend went away - say so rather than leaving a stale green light.
|
||||||
|
applyStatus({
|
||||||
|
state: 'disconnected',
|
||||||
|
connected: false,
|
||||||
|
error: 'Lost connection to the local server.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// boot
|
||||||
|
// =========================================================================
|
||||||
|
async function boot() {
|
||||||
|
bindDom();
|
||||||
|
bindSocket();
|
||||||
|
loadDraft();
|
||||||
|
syncControls();
|
||||||
|
socket.connect();
|
||||||
|
|
||||||
|
// Fallback for the rare case the socket cannot open at all.
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (socket.isOpen) return;
|
||||||
|
try {
|
||||||
|
const [config, status, history] = await Promise.all([
|
||||||
|
api.config(), api.status(), api.history(),
|
||||||
|
]);
|
||||||
|
state.config = { ...state.config, ...config };
|
||||||
|
ui.renderConfig(state.config);
|
||||||
|
applyStatus(status);
|
||||||
|
state.history = history.items || [];
|
||||||
|
ui.renderHistory(state.history, useHistoryText, replayLine);
|
||||||
|
refreshAudio();
|
||||||
|
document.documentElement.removeAttribute('data-loading');
|
||||||
|
} catch {
|
||||||
|
ui.toast('Cannot reach the local server. Is backend/main.py running?', 'error', 12000);
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
ui.el.textInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
111
frontend/js/socket.js
Normal file
111
frontend/js/socket.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket client for live robot state.
|
||||||
|
*
|
||||||
|
* Replaces polling entirely: the backend pushes connection state, speech
|
||||||
|
* lifecycle and history updates. Reconnects on its own with capped backoff, so
|
||||||
|
* restarting the server does not require reloading the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PING_INTERVAL = 5000;
|
||||||
|
const MIN_BACKOFF = 500;
|
||||||
|
const MAX_BACKOFF = 8000;
|
||||||
|
|
||||||
|
export class RobotSocket extends EventTarget {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.ws = null;
|
||||||
|
this.backoff = MIN_BACKOFF;
|
||||||
|
this.pingTimer = null;
|
||||||
|
this.rtt = null;
|
||||||
|
this.closedByUs = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get url() {
|
||||||
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
return `${scheme}://${location.host}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOpen() {
|
||||||
|
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.closedByUs = false;
|
||||||
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(this.url);
|
||||||
|
} catch {
|
||||||
|
this.#scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.backoff = MIN_BACKOFF;
|
||||||
|
this.#emit('link', { up: true });
|
||||||
|
this.#startPing();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
let message;
|
||||||
|
try { message = JSON.parse(event.data); } catch { return; }
|
||||||
|
|
||||||
|
if (message.type === 'pong') {
|
||||||
|
const sent = message.data && message.data.t;
|
||||||
|
if (typeof sent === 'number') this.rtt = Math.max(0, Math.round(performance.now() - sent));
|
||||||
|
this.#emit('rtt', { rtt: this.rtt });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#emit(message.type, message.data || {});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.#stopPing();
|
||||||
|
this.#emit('link', { up: false });
|
||||||
|
if (!this.closedByUs) this.#scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => { /* onclose always follows; handled there */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closedByUs = true;
|
||||||
|
this.#stopPing();
|
||||||
|
if (this.ws) this.ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
send(type, data = {}) {
|
||||||
|
if (!this.isOpen) return false;
|
||||||
|
this.ws.send(JSON.stringify({ type, ...data }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestStatus() { this.send('status'); }
|
||||||
|
requestReconnect() { this.send('reconnect'); }
|
||||||
|
|
||||||
|
// -- internals ------------------------------------------------------------
|
||||||
|
#emit(type, detail) {
|
||||||
|
this.dispatchEvent(new CustomEvent(type, { detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#startPing() {
|
||||||
|
this.#stopPing();
|
||||||
|
const ping = () => this.send('ping', { t: performance.now() });
|
||||||
|
ping();
|
||||||
|
this.pingTimer = setInterval(ping, PING_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#stopPing() {
|
||||||
|
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#scheduleReconnect() {
|
||||||
|
const delay = this.backoff;
|
||||||
|
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF);
|
||||||
|
setTimeout(() => this.connect(), delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
367
frontend/js/ui.js
Normal file
367
frontend/js/ui.js
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
/** DOM rendering. All element lookups and mutations live here. */
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
export const el = {
|
||||||
|
modeBadge: $('modeBadge'),
|
||||||
|
connPill: $('connPill'),
|
||||||
|
connLabel: $('connLabel'),
|
||||||
|
latencyChip: $('latencyChip'),
|
||||||
|
latencyValue: $('latencyValue'),
|
||||||
|
reconnectBtn: $('reconnectBtn'),
|
||||||
|
|
||||||
|
banner: $('configBanner'),
|
||||||
|
bannerTitle: $('bannerTitle'),
|
||||||
|
bannerText: $('bannerText'),
|
||||||
|
|
||||||
|
textInput: $('textInput'),
|
||||||
|
counter: $('counter'),
|
||||||
|
counterNow: $('counterNow'),
|
||||||
|
counterMax: $('counterMax'),
|
||||||
|
|
||||||
|
speakBtn: $('speakBtn'),
|
||||||
|
speakLabel: $('speakLabel'),
|
||||||
|
stopBtn: $('stopBtn'),
|
||||||
|
clearBtn: $('clearBtn'),
|
||||||
|
|
||||||
|
pipeline: $('pipeline'),
|
||||||
|
pipelineFill: $('pipelineFill'),
|
||||||
|
|
||||||
|
live: $('live'),
|
||||||
|
liveText: $('liveText'),
|
||||||
|
liveDetail: $('liveDetail'),
|
||||||
|
|
||||||
|
factState: $('factState'),
|
||||||
|
factMode: $('factMode'),
|
||||||
|
factTransport: $('factTransport'),
|
||||||
|
factAddress: $('factAddress'),
|
||||||
|
factLatency: $('factLatency'),
|
||||||
|
factUptime: $('factUptime'),
|
||||||
|
statusNote: $('statusNote'),
|
||||||
|
|
||||||
|
audioList: $('audioList'),
|
||||||
|
audioCount: $('audioCount'),
|
||||||
|
audioNote: $('audioNote'),
|
||||||
|
clearAudioBtn: $('clearAudioBtn'),
|
||||||
|
|
||||||
|
historyList: $('historyList'),
|
||||||
|
historyEmpty: $('historyEmpty'),
|
||||||
|
historyCount: $('historyCount'),
|
||||||
|
clearHistoryBtn: $('clearHistoryBtn'),
|
||||||
|
|
||||||
|
toasts: $('toasts'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTION_LABEL = {
|
||||||
|
connected: 'Robot Connected',
|
||||||
|
connecting: 'Connecting…',
|
||||||
|
disconnected: 'Robot Disconnected',
|
||||||
|
error: 'Connection Error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PIPELINE_PROGRESS = {
|
||||||
|
idle: 0, queued: 8, sending: 26, processing: 52, speaking: 80,
|
||||||
|
completed: 100, failed: 100, cancelled: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_ORDER = ['sending', 'processing', 'speaking', 'completed'];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// header / connection
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
export function renderConnection(status) {
|
||||||
|
const state = status.state || 'disconnected';
|
||||||
|
el.connPill.dataset.state = state;
|
||||||
|
el.connLabel.textContent = CONNECTION_LABEL[state] || state;
|
||||||
|
|
||||||
|
const latency = status.latencyMs;
|
||||||
|
if (typeof latency === 'number' && state === 'connected') {
|
||||||
|
el.latencyChip.hidden = false;
|
||||||
|
el.latencyValue.textContent = `${latency < 10 ? latency.toFixed(1) : Math.round(latency)} ms`;
|
||||||
|
} else {
|
||||||
|
el.latencyChip.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderConfig(config) {
|
||||||
|
if (!config) return;
|
||||||
|
const mode = (config.mode || 'mock').toLowerCase();
|
||||||
|
el.modeBadge.textContent = mode === 'real' ? 'Live Robot' : 'Mock Mode';
|
||||||
|
el.modeBadge.dataset.mode = mode;
|
||||||
|
|
||||||
|
const max = config.maxLength || 1000;
|
||||||
|
el.counterMax.textContent = String(max);
|
||||||
|
el.textInput.maxLength = max;
|
||||||
|
|
||||||
|
const issues = config.issues || [];
|
||||||
|
if (issues.length) {
|
||||||
|
const worst = issues.find((i) => i.level === 'error') || issues[0];
|
||||||
|
el.banner.hidden = false;
|
||||||
|
el.banner.dataset.level = worst.level;
|
||||||
|
el.bannerTitle.textContent =
|
||||||
|
worst.level === 'error' ? 'Configuration error' : 'Configuration warning';
|
||||||
|
el.bannerText.textContent = `${worst.message} (${worst.key} in ${config.envFile || '.env'})`;
|
||||||
|
} else {
|
||||||
|
el.banner.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFacts(status) {
|
||||||
|
const robot = status.robot || {};
|
||||||
|
const state = status.state || 'disconnected';
|
||||||
|
|
||||||
|
el.factState.textContent = CONNECTION_LABEL[state] || state;
|
||||||
|
el.factState.dataset.tone =
|
||||||
|
state === 'connected' ? 'ok' : state === 'connecting' ? 'warn' : 'error';
|
||||||
|
|
||||||
|
el.factMode.textContent = robot.mode === 'real' ? 'Live robot' : 'Simulation';
|
||||||
|
el.factTransport.textContent = (robot.transport || '–').toUpperCase();
|
||||||
|
el.factAddress.textContent = robot.address || '–';
|
||||||
|
el.factAddress.title = robot.address || '';
|
||||||
|
|
||||||
|
el.factLatency.textContent =
|
||||||
|
typeof status.latencyMs === 'number' ? `${Math.round(status.latencyMs)} ms` : '–';
|
||||||
|
el.factUptime.textContent =
|
||||||
|
typeof status.uptimeSeconds === 'number' ? formatDuration(status.uptimeSeconds) : '–';
|
||||||
|
|
||||||
|
if (status.error) {
|
||||||
|
el.statusNote.hidden = false;
|
||||||
|
el.statusNote.textContent = status.error;
|
||||||
|
} else {
|
||||||
|
el.statusNote.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// composer
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
export function renderCounter(length, max) {
|
||||||
|
el.counterNow.textContent = String(length);
|
||||||
|
const ratio = max ? length / max : 0;
|
||||||
|
el.counter.dataset.level = ratio >= 1 ? 'over' : ratio > 0.85 ? 'warn' : 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setControls({ canSpeak, busy, canStop }) {
|
||||||
|
el.speakBtn.disabled = !canSpeak;
|
||||||
|
el.speakBtn.dataset.busy = busy ? 'true' : 'false';
|
||||||
|
el.speakLabel.textContent = busy ? 'Speaking…' : 'Speak';
|
||||||
|
el.stopBtn.disabled = !canStop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// pipeline + live status
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
export function renderPipeline(stage) {
|
||||||
|
const key = PIPELINE_PROGRESS[stage] === undefined ? 'idle' : stage;
|
||||||
|
el.pipeline.dataset.stage = key;
|
||||||
|
el.pipelineFill.style.inset = `0 ${100 - PIPELINE_PROGRESS[key]}% 0 0`;
|
||||||
|
|
||||||
|
const activeIndex = STEP_ORDER.indexOf(
|
||||||
|
key === 'queued' ? 'sending' : key === 'failed' || key === 'cancelled' ? 'speaking' : key,
|
||||||
|
);
|
||||||
|
el.pipeline.querySelectorAll('li').forEach((li, index) => {
|
||||||
|
if (key === 'idle') { li.removeAttribute('data-on'); return; }
|
||||||
|
if (index < activeIndex) li.dataset.on = 'done';
|
||||||
|
else if (index === activeIndex) li.dataset.on = 'active';
|
||||||
|
else li.removeAttribute('data-on');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLive(tone, text, detail = '') {
|
||||||
|
el.live.dataset.tone = tone;
|
||||||
|
el.liveText.textContent = text;
|
||||||
|
el.liveDetail.textContent = detail || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// history
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
export function renderHistory(items, onPick, onReplay = () => {}) {
|
||||||
|
el.historyCount.textContent = String(items.length);
|
||||||
|
el.historyEmpty.hidden = items.length > 0;
|
||||||
|
el.historyList.innerHTML = '';
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'hitem';
|
||||||
|
li.dataset.ok = item.success === null || item.success === undefined
|
||||||
|
? (isTerminal(item.stage) ? 'false' : 'pending')
|
||||||
|
: String(Boolean(item.success));
|
||||||
|
li.title = 'Click to put this text back in the box';
|
||||||
|
li.tabIndex = 0;
|
||||||
|
|
||||||
|
const bar = document.createElement('span');
|
||||||
|
bar.className = 'hitem__bar';
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'hitem__body';
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'hitem__text';
|
||||||
|
text.textContent = item.text;
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'hitem__meta';
|
||||||
|
meta.append(spanOf(formatTime(item.at)));
|
||||||
|
if (typeof item.ackLatencyMs === 'number') meta.append(spanOf(`${item.ackLatencyMs} ms`));
|
||||||
|
if (item.error) {
|
||||||
|
const err = spanOf(item.error);
|
||||||
|
err.className = 'err';
|
||||||
|
meta.append(err);
|
||||||
|
} else if (!isTerminal(item.stage)) {
|
||||||
|
meta.append(spanOf(item.stage));
|
||||||
|
}
|
||||||
|
|
||||||
|
body.append(text, meta);
|
||||||
|
li.append(bar, body);
|
||||||
|
|
||||||
|
if (item.audioSaved) {
|
||||||
|
const replay = document.createElement('button');
|
||||||
|
replay.className = 'hitem__replay';
|
||||||
|
replay.title = 'Play the saved audio instantly';
|
||||||
|
replay.setAttribute('aria-label', 'Replay this line');
|
||||||
|
replay.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
|
||||||
|
replay.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation(); // do not also load it into the box
|
||||||
|
onReplay(item);
|
||||||
|
});
|
||||||
|
li.append(replay);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = () => onPick(item.text);
|
||||||
|
li.addEventListener('click', pick);
|
||||||
|
li.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); pick(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
el.historyList.append(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminal(stage) {
|
||||||
|
return ['completed', 'failed', 'cancelled'].includes(stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spanOf(text) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = text;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// toasts
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
export function toast(message, tone = 'info', ttl = 5000) {
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.className = 'toast';
|
||||||
|
node.dataset.tone = tone;
|
||||||
|
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'toast__dot';
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.textContent = message;
|
||||||
|
|
||||||
|
node.append(dot, text);
|
||||||
|
el.toasts.append(node);
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
node.classList.add('is-out');
|
||||||
|
setTimeout(() => node.remove(), 220);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(remove, ttl);
|
||||||
|
node.addEventListener('click', () => { clearTimeout(timer); remove(); });
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
export function formatTime(epochSeconds) {
|
||||||
|
if (!epochSeconds) return '';
|
||||||
|
return new Date(epochSeconds * 1000).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(seconds) {
|
||||||
|
const total = Math.max(0, Math.round(seconds));
|
||||||
|
if (total < 60) return `${total}s`;
|
||||||
|
const minutes = Math.floor(total / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ${total % 60}s`;
|
||||||
|
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// saved audio
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const PLAY_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
|
||||||
|
const STOP_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
|
||||||
|
const DOWNLOAD_ICON =
|
||||||
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/>' +
|
||||||
|
'<path d="m7 12 5 5 5-5"/><path d="M5 21h14"/></svg>';
|
||||||
|
|
||||||
|
export function renderAudio(items, stats, { fileUrl, onPlayState }) {
|
||||||
|
el.audioCount.textContent = String(items.length);
|
||||||
|
el.audioList.innerHTML = '';
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
el.audioNote.textContent =
|
||||||
|
'Nothing saved yet. Speak a line with the neural voice and its audio is ' +
|
||||||
|
'kept here as a .wav, so it replays instantly with no synthesis.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep the note to one short line; the full path would overflow the panel,
|
||||||
|
// so it goes in the tooltip instead.
|
||||||
|
el.audioNote.textContent =
|
||||||
|
`${items.length} clip(s) · ${(stats.seconds || 0).toFixed(0)}s · ` +
|
||||||
|
`${((stats.bytes || 0) / 1048576).toFixed(1)} MB · audio_library/`;
|
||||||
|
el.audioNote.title = stats.dir || '';
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'aitem';
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'aitem__play';
|
||||||
|
button.innerHTML = PLAY_ICON;
|
||||||
|
button.title = 'Play in this browser';
|
||||||
|
|
||||||
|
// Played in the page, not through the backend: this is you auditioning a
|
||||||
|
// saved clip, not the robot speaking. Keeping them separate means the
|
||||||
|
// dashboard's Speaking state never lies.
|
||||||
|
const audio = new Audio(fileUrl(item.id));
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
if (!audio.paused) { audio.pause(); audio.currentTime = 0; return; }
|
||||||
|
onPlayState(audio);
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
});
|
||||||
|
audio.addEventListener('play', () => { button.dataset.playing = 'true'; button.innerHTML = STOP_ICON; });
|
||||||
|
const reset = () => { button.dataset.playing = 'false'; button.innerHTML = PLAY_ICON; };
|
||||||
|
audio.addEventListener('pause', reset);
|
||||||
|
audio.addEventListener('ended', reset);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'aitem__body';
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'aitem__text';
|
||||||
|
text.textContent = item.text;
|
||||||
|
text.title = item.text;
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'aitem__meta';
|
||||||
|
meta.textContent = `${(item.durationSeconds || 0).toFixed(1)}s · ${item.voice} · ${item.file}`;
|
||||||
|
meta.title = item.file;
|
||||||
|
body.append(text, meta);
|
||||||
|
|
||||||
|
const download = document.createElement('a');
|
||||||
|
download.className = 'aitem__dl';
|
||||||
|
download.href = fileUrl(item.id);
|
||||||
|
download.download = item.file;
|
||||||
|
download.title = 'Download this .wav';
|
||||||
|
download.innerHTML = DOWNLOAD_ICON;
|
||||||
|
|
||||||
|
li.append(button, body, download);
|
||||||
|
el.audioList.append(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
565
frontend/styles/main.css
Normal file
565
frontend/styles/main.css
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
/* =========================================================================
|
||||||
|
AGIBOT A3 · Voice Control
|
||||||
|
A dark, single-purpose control surface. Committed to one theme on purpose:
|
||||||
|
this is meant to be projected or shown on a stand next to the robot.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* surfaces */
|
||||||
|
--bg: #080a0f;
|
||||||
|
--bg-glow-a: rgba(34, 211, 238, 0.10);
|
||||||
|
--bg-glow-b: rgba(59, 130, 246, 0.08);
|
||||||
|
--surface: #11151f;
|
||||||
|
--surface-2: #161b28;
|
||||||
|
--surface-3: #1c2230;
|
||||||
|
--border: rgba(255, 255, 255, 0.075);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.14);
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
--text: #e8ecf4;
|
||||||
|
--text-dim: #9aa4bb;
|
||||||
|
--text-faint: #626c85;
|
||||||
|
|
||||||
|
/* brand + state */
|
||||||
|
--accent: #22d3ee;
|
||||||
|
--accent-deep: #0891b2;
|
||||||
|
--accent-soft: rgba(34, 211, 238, 0.14);
|
||||||
|
--ok: #34d399;
|
||||||
|
--warn: #fbbf24;
|
||||||
|
--danger: #f87171;
|
||||||
|
--busy: #818cf8;
|
||||||
|
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 12px 32px rgba(0,0,0,.32);
|
||||||
|
|
||||||
|
--font: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
--mono: "JetBrains Mono", "Cascadia Mono", "SF Mono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* Author `display` rules below outrank the UA stylesheet's [hidden], so restate
|
||||||
|
it with priority - otherwise .banner / .chip stay visible when hidden=true. */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 620px at 12% -10%, var(--bg-glow-a), transparent 60%),
|
||||||
|
radial-gradient(900px 520px at 92% 0%, var(--bg-glow-b), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
background-attachment: fixed;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 { margin: 0; font-weight: 600; letter-spacing: -0.015em; }
|
||||||
|
.mono { font-family: var(--mono); font-size: 13px; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
HEADER
|
||||||
|
========================================================================= */
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 40;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||||
|
padding: 14px clamp(16px, 4vw, 40px);
|
||||||
|
background: rgba(8, 10, 15, 0.72);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__brand { display: flex; align-items: center; gap: 13px; min-width: 0; }
|
||||||
|
|
||||||
|
.brand__mark {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 40px; height: 40px; flex: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: linear-gradient(160deg, var(--surface-3), var(--surface));
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,.05);
|
||||||
|
}
|
||||||
|
.brand__mark svg { width: 23px; height: 23px; }
|
||||||
|
|
||||||
|
.brand__text h1 { font-size: 16px; letter-spacing: 0.02em; }
|
||||||
|
.brand__text p { margin: 0; font-size: 12px; color: var(--text-faint); letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.mode-badge {
|
||||||
|
margin-left: 6px; padding: 3px 9px;
|
||||||
|
font-size: 10.5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--warn);
|
||||||
|
background: rgba(251, 191, 36, .12);
|
||||||
|
border: 1px solid rgba(251, 191, 36, .3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mode-badge[data-mode="real"] { color: var(--ok); background: rgba(52,211,153,.12); border-color: rgba(52,211,153,.3); }
|
||||||
|
|
||||||
|
.topbar__status { display: flex; align-items: center; gap: 10px; }
|
||||||
|
|
||||||
|
/* connection pill */
|
||||||
|
.conn {
|
||||||
|
display: inline-flex; align-items: center; gap: 9px;
|
||||||
|
padding: 7px 14px 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px; font-weight: 500; white-space: nowrap;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color .25s, background .25s, color .25s;
|
||||||
|
}
|
||||||
|
.conn__dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--text-faint);
|
||||||
|
box-shadow: 0 0 0 0 transparent;
|
||||||
|
transition: background .25s;
|
||||||
|
}
|
||||||
|
.conn[data-state="connected"] { color: var(--ok); border-color: rgba(52,211,153,.32); background: rgba(52,211,153,.09); }
|
||||||
|
.conn[data-state="connected"] .conn__dot { background: var(--ok); box-shadow: 0 0 10px rgba(52,211,153,.75); }
|
||||||
|
.conn[data-state="connecting"] { color: var(--warn); border-color: rgba(251,191,36,.32); background: rgba(251,191,36,.09); }
|
||||||
|
.conn[data-state="connecting"] .conn__dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
|
||||||
|
.conn[data-state="disconnected"] { color: var(--danger); border-color: rgba(248,113,113,.3); background: rgba(248,113,113,.08); }
|
||||||
|
.conn[data-state="disconnected"] .conn__dot { background: var(--danger); }
|
||||||
|
.conn[data-state="error"] { color: var(--danger); border-color: rgba(248,113,113,.45); background: rgba(248,113,113,.12); }
|
||||||
|
.conn[data-state="error"] .conn__dot { background: var(--danger); animation: pulse 1.4s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: .35; transform: scale(.82); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 11px; border-radius: 999px;
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--text-dim);
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.chip svg { width: 12px; height: 12px; color: var(--accent); }
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 36px; height: 36px; flex: none;
|
||||||
|
border-radius: 10px; cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: color .18s, border-color .18s, background .18s, transform .18s;
|
||||||
|
}
|
||||||
|
.icon-btn svg { width: 17px; height: 17px; }
|
||||||
|
.icon-btn:hover { color: var(--accent); border-color: var(--border-strong); background: var(--surface-3); }
|
||||||
|
.icon-btn:active { transform: scale(.94); }
|
||||||
|
.icon-btn[data-spin="true"] svg { animation: spin .9s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
CONFIG BANNER
|
||||||
|
========================================================================= */
|
||||||
|
.banner {
|
||||||
|
display: flex; align-items: flex-start; gap: 11px;
|
||||||
|
margin: 16px clamp(16px, 4vw, 40px) 0;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #fde68a;
|
||||||
|
background: rgba(251, 191, 36, .07);
|
||||||
|
border: 1px solid rgba(251, 191, 36, .28);
|
||||||
|
}
|
||||||
|
.banner[data-level="error"] { color: #fecaca; background: rgba(248,113,113,.08); border-color: rgba(248,113,113,.3); }
|
||||||
|
.banner svg { width: 17px; height: 17px; flex: none; margin-top: 1px; }
|
||||||
|
.banner__body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.banner__body strong { font-weight: 650; }
|
||||||
|
.banner__body span { color: inherit; opacity: .88; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
LAYOUT
|
||||||
|
========================================================================= */
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.65fr) minmax(300px, 0.85fr);
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 22px clamp(16px, 4vw, 40px) 48px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.sidebar { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.panel--speech { padding: 22px; }
|
||||||
|
|
||||||
|
.panel__head {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.panel__head h2 { font-size: 15px; letter-spacing: -0.01em; }
|
||||||
|
.panel__hint { font-size: 12.5px; color: var(--text-faint); }
|
||||||
|
.panel__note {
|
||||||
|
margin: 12px 0 0; font-size: 12.5px; color: var(--text-faint); line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere; /* long file paths must not push the panel wide */
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
display: inline-block; margin-left: 6px; padding: 1px 7px;
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--text-dim);
|
||||||
|
background: var(--surface-3); border-radius: 999px; border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
COMPOSER
|
||||||
|
========================================================================= */
|
||||||
|
.composer {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #0d111a;
|
||||||
|
transition: border-color .2s, box-shadow .2s;
|
||||||
|
}
|
||||||
|
.composer:focus-within {
|
||||||
|
border-color: rgba(34, 211, 238, .45);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__input {
|
||||||
|
display: block; width: 100%;
|
||||||
|
padding: 16px 17px 10px;
|
||||||
|
border: 0; outline: none; resize: vertical;
|
||||||
|
min-height: 150px;
|
||||||
|
font-family: inherit; font-size: 16.5px; line-height: 1.6;
|
||||||
|
color: var(--text); background: transparent;
|
||||||
|
}
|
||||||
|
.composer__input::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.composer__meta {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 9px 15px 11px;
|
||||||
|
font-size: 12px; color: var(--text-faint);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.kbd-hint { display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||||
|
kbd {
|
||||||
|
padding: 2px 6px; border-radius: 5px;
|
||||||
|
font-family: var(--mono); font-size: 10.5px; color: var(--text-dim);
|
||||||
|
background: var(--surface-3); border: 1px solid var(--border-strong);
|
||||||
|
box-shadow: 0 1px 0 rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.counter { font-family: var(--mono); font-size: 12px; white-space: nowrap; }
|
||||||
|
.counter b { color: var(--text-dim); font-weight: 500; }
|
||||||
|
.counter[data-level="warn"] b { color: var(--warn); }
|
||||||
|
.counter[data-level="over"] b { color: var(--danger); }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
ACTIONS
|
||||||
|
========================================================================= */
|
||||||
|
.actions { display: flex; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
||||||
|
padding: 0 20px; height: 46px;
|
||||||
|
font-family: inherit; font-size: 14.5px; font-weight: 550;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 11px; border: 1px solid var(--border-strong);
|
||||||
|
background: var(--surface-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .18s, border-color .18s, color .18s, transform .12s, opacity .18s;
|
||||||
|
}
|
||||||
|
.btn svg { width: 17px; height: 17px; }
|
||||||
|
.btn:hover:not(:disabled) { background: #232a3a; border-color: rgba(255,255,255,.2); }
|
||||||
|
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.btn:disabled { opacity: .42; cursor: not-allowed; }
|
||||||
|
.btn--ghost { color: var(--text-dim); }
|
||||||
|
.btn--ghost:hover:not(:disabled) { color: var(--text); }
|
||||||
|
|
||||||
|
/* the one button that matters */
|
||||||
|
.btn--speak {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
height: 54px;
|
||||||
|
padding: 0 30px;
|
||||||
|
font-size: 16px; font-weight: 650; letter-spacing: .01em;
|
||||||
|
color: #04222a;
|
||||||
|
border: 0;
|
||||||
|
background: linear-gradient(135deg, #67e8f9 0%, var(--accent) 45%, var(--accent-deep) 100%);
|
||||||
|
box-shadow: 0 6px 22px rgba(34, 211, 238, .26), inset 0 1px 0 rgba(255,255,255,.35);
|
||||||
|
}
|
||||||
|
.btn--speak:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.07);
|
||||||
|
box-shadow: 0 8px 30px rgba(34, 211, 238, .36), inset 0 1px 0 rgba(255,255,255,.4);
|
||||||
|
}
|
||||||
|
.btn--speak:disabled { background: var(--surface-3); color: var(--text-faint); box-shadow: none; }
|
||||||
|
.btn__icon { display: inline-grid; place-items: center; }
|
||||||
|
.btn__spinner { display: none; width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
border: 2px solid rgba(4,34,42,.28); border-top-color: #04222a; animation: spin .7s linear infinite; }
|
||||||
|
|
||||||
|
.btn--speak[data-busy="true"] .btn__icon { display: none; }
|
||||||
|
.btn--speak[data-busy="true"] .btn__spinner { display: block; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
PIPELINE
|
||||||
|
========================================================================= */
|
||||||
|
.pipeline { margin-top: 22px; }
|
||||||
|
|
||||||
|
.pipeline__track {
|
||||||
|
position: relative; height: 3px; border-radius: 3px;
|
||||||
|
background: var(--surface-3); overflow: hidden;
|
||||||
|
}
|
||||||
|
.pipeline__track i {
|
||||||
|
position: absolute; inset: 0 100% 0 0;
|
||||||
|
background: linear-gradient(90deg, var(--accent-deep), var(--accent));
|
||||||
|
transition: inset .45s cubic-bezier(.4, 0, .2, 1);
|
||||||
|
}
|
||||||
|
.pipeline[data-stage="failed"] .pipeline__track i,
|
||||||
|
.pipeline[data-stage="cancelled"] .pipeline__track i { background: var(--danger); }
|
||||||
|
|
||||||
|
.pipeline__steps {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||||
|
margin: 11px 0 0; padding: 0; list-style: none;
|
||||||
|
font-size: 12.5px; color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.pipeline__steps li { display: flex; align-items: center; gap: 7px; transition: color .3s; }
|
||||||
|
.pipeline__steps li:last-child { justify-content: flex-end; }
|
||||||
|
.pipeline__steps li:nth-child(2) { justify-content: center; }
|
||||||
|
.pipeline__steps li:nth-child(3) { justify-content: center; }
|
||||||
|
.pipeline__steps .dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: var(--surface-3); border: 1px solid var(--border-strong);
|
||||||
|
transition: background .3s, border-color .3s, box-shadow .3s;
|
||||||
|
}
|
||||||
|
.pipeline__steps li[data-on="done"] { color: var(--text-dim); }
|
||||||
|
.pipeline__steps li[data-on="done"] .dot { background: var(--accent-deep); border-color: var(--accent-deep); }
|
||||||
|
.pipeline__steps li[data-on="active"] { color: var(--accent); font-weight: 550; }
|
||||||
|
.pipeline__steps li[data-on="active"] .dot { background: var(--accent); border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 4px var(--accent-soft); }
|
||||||
|
.pipeline[data-stage="failed"] li[data-on="active"],
|
||||||
|
.pipeline[data-stage="cancelled"] li[data-on="active"] { color: var(--danger); }
|
||||||
|
.pipeline[data-stage="failed"] li[data-on="active"] .dot,
|
||||||
|
.pipeline[data-stage="cancelled"] li[data-on="active"] .dot {
|
||||||
|
background: var(--danger); border-color: var(--danger); box-shadow: 0 0 0 4px rgba(248,113,113,.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
LIVE STATUS
|
||||||
|
========================================================================= */
|
||||||
|
.live {
|
||||||
|
display: flex; align-items: center; gap: 11px;
|
||||||
|
margin-top: 18px; padding: 13px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color .3s, background .3s;
|
||||||
|
}
|
||||||
|
.live__text { font-weight: 550; }
|
||||||
|
.live__detail { color: var(--text-faint); font-size: 12.5px; font-family: var(--mono); margin-left: auto; }
|
||||||
|
|
||||||
|
.live[data-tone="idle"] { color: var(--text-dim); }
|
||||||
|
.live[data-tone="busy"] { color: var(--accent); border-color: rgba(34,211,238,.3); background: rgba(34,211,238,.05); }
|
||||||
|
.live[data-tone="speaking"] { color: var(--accent); border-color: rgba(34,211,238,.45); background: rgba(34,211,238,.07); }
|
||||||
|
.live[data-tone="ok"] { color: var(--ok); border-color: rgba(52,211,153,.28); background: rgba(52,211,153,.05); }
|
||||||
|
.live[data-tone="error"] { color: var(--danger); border-color: rgba(248,113,113,.32); background: rgba(248,113,113,.06); }
|
||||||
|
|
||||||
|
/* speaking waveform */
|
||||||
|
.live__wave { display: none; align-items: flex-end; gap: 3px; height: 18px; }
|
||||||
|
.live[data-tone="speaking"] .live__wave { display: flex; }
|
||||||
|
.live__wave i {
|
||||||
|
display: block; width: 3px; border-radius: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
animation: wave 900ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.live__wave i:nth-child(1) { height: 7px; animation-delay: 0ms; }
|
||||||
|
.live__wave i:nth-child(2) { height: 14px; animation-delay: 120ms; }
|
||||||
|
.live__wave i:nth-child(3) { height: 18px; animation-delay: 240ms; }
|
||||||
|
.live__wave i:nth-child(4) { height: 11px; animation-delay: 360ms; }
|
||||||
|
.live__wave i:nth-child(5) { height: 6px; animation-delay: 480ms; }
|
||||||
|
@keyframes wave {
|
||||||
|
0%, 100% { transform: scaleY(.35); opacity: .55; }
|
||||||
|
50% { transform: scaleY(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
FACTS
|
||||||
|
========================================================================= */
|
||||||
|
.facts { margin: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.fact {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.045);
|
||||||
|
}
|
||||||
|
.fact:last-child { border-bottom: 0; }
|
||||||
|
.fact dt { font-size: 12.5px; color: var(--text-faint); }
|
||||||
|
.fact dd { margin: 0; font-size: 13px; font-weight: 500; text-align: right;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 62%; }
|
||||||
|
.fact dd[data-tone="ok"] { color: var(--ok); }
|
||||||
|
.fact dd[data-tone="warn"] { color: var(--warn); }
|
||||||
|
.fact dd[data-tone="error"] { color: var(--danger); }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
HISTORY
|
||||||
|
========================================================================= */
|
||||||
|
.link-btn {
|
||||||
|
padding: 3px 8px; border: 0; border-radius: 6px; cursor: pointer;
|
||||||
|
font-family: inherit; font-size: 12.5px; color: var(--text-faint);
|
||||||
|
background: transparent; transition: color .18s, background .18s;
|
||||||
|
}
|
||||||
|
.link-btn:hover { color: var(--danger); background: rgba(248,113,113,.09); }
|
||||||
|
|
||||||
|
.history {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 7px;
|
||||||
|
max-height: 340px; overflow-y: auto;
|
||||||
|
scrollbar-width: thin; scrollbar-color: var(--surface-3) transparent;
|
||||||
|
}
|
||||||
|
.history::-webkit-scrollbar { width: 7px; }
|
||||||
|
.history::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 4px; }
|
||||||
|
|
||||||
|
.hitem {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .16s, background .16s, transform .16s;
|
||||||
|
}
|
||||||
|
.hitem:hover { border-color: var(--border-strong); background: var(--surface-3); }
|
||||||
|
.hitem:active { transform: scale(.99); }
|
||||||
|
.hitem__bar { width: 3px; align-self: stretch; border-radius: 2px; background: var(--text-faint); flex: none; }
|
||||||
|
.hitem[data-ok="true"] .hitem__bar { background: var(--ok); }
|
||||||
|
.hitem[data-ok="false"] .hitem__bar { background: var(--danger); }
|
||||||
|
.hitem[data-ok="pending"] .hitem__bar { background: var(--accent); animation: pulse 1s ease-in-out infinite; }
|
||||||
|
|
||||||
|
.hitem__body { min-width: 0; flex: 1; }
|
||||||
|
.hitem__text {
|
||||||
|
font-size: 13px; line-height: 1.45; color: var(--text);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.hitem__meta {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-top: 4px;
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.hitem__meta .err { color: var(--danger); font-family: var(--font); }
|
||||||
|
|
||||||
|
.empty { margin: 4px 0 0; font-size: 12.5px; color: var(--text-faint); line-height: 1.55; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
TOASTS
|
||||||
|
========================================================================= */
|
||||||
|
.toasts {
|
||||||
|
position: fixed; right: 20px; bottom: 20px; z-index: 90;
|
||||||
|
display: flex; flex-direction: column; gap: 9px;
|
||||||
|
max-width: min(380px, calc(100vw - 40px));
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13.5px; line-height: 1.45;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: toast-in .25s cubic-bezier(.2, .8, .3, 1);
|
||||||
|
}
|
||||||
|
.toast[data-tone="error"] { border-color: rgba(248,113,113,.42); background: #1d1316; }
|
||||||
|
.toast[data-tone="ok"] { border-color: rgba(52,211,153,.38); background: #0f1a17; }
|
||||||
|
.toast__dot { width: 7px; height: 7px; border-radius: 50%; margin-top: 6px; flex: none; background: var(--text-faint); }
|
||||||
|
.toast[data-tone="error"] .toast__dot { background: var(--danger); }
|
||||||
|
.toast[data-tone="ok"] .toast__dot { background: var(--ok); }
|
||||||
|
.toast.is-out { animation: toast-out .2s ease-in forwards; }
|
||||||
|
|
||||||
|
@keyframes toast-in { from { opacity: 0; transform: translateY(10px) scale(.97); } }
|
||||||
|
@keyframes toast-out { to { opacity: 0; transform: translateY(6px) scale(.98); } }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
RESPONSIVE
|
||||||
|
========================================================================= */
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.layout { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { order: 2; }
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.topbar { flex-wrap: wrap; gap: 10px; }
|
||||||
|
.brand__text p { display: none; }
|
||||||
|
.panel__hint { display: none; }
|
||||||
|
.actions { flex-direction: column; }
|
||||||
|
.btn { width: 100%; }
|
||||||
|
.pipeline__steps { font-size: 11.5px; }
|
||||||
|
.toasts { right: 12px; left: 12px; bottom: 12px; max-width: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
SAVED AUDIO
|
||||||
|
========================================================================= */
|
||||||
|
.audio-list {
|
||||||
|
list-style: none; margin: 12px 0 0; padding: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
max-height: 260px; overflow-y: auto;
|
||||||
|
scrollbar-width: thin; scrollbar-color: var(--surface-3) transparent;
|
||||||
|
}
|
||||||
|
.audio-list::-webkit-scrollbar { width: 7px; }
|
||||||
|
.audio-list::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 4px; }
|
||||||
|
|
||||||
|
.aitem {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
transition: border-color .16s, background .16s;
|
||||||
|
}
|
||||||
|
.aitem:hover { border-color: var(--border-strong); background: var(--surface-3); }
|
||||||
|
|
||||||
|
.aitem__play {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 26px; height: 26px; flex: none;
|
||||||
|
border-radius: 50%; cursor: pointer;
|
||||||
|
color: #04222a; background: var(--accent); border: 0;
|
||||||
|
transition: filter .15s, transform .12s;
|
||||||
|
}
|
||||||
|
.aitem__play svg { width: 12px; height: 12px; }
|
||||||
|
.aitem__play:hover { filter: brightness(1.1); }
|
||||||
|
.aitem__play:active { transform: scale(.92); }
|
||||||
|
.aitem__play[data-playing="true"] { background: var(--warn); }
|
||||||
|
|
||||||
|
.aitem__body { min-width: 0; flex: 1; }
|
||||||
|
.aitem__text {
|
||||||
|
font-size: 12.5px; line-height: 1.4; color: var(--text);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.aitem__meta {
|
||||||
|
font-family: var(--mono); font-size: 10.5px; color: var(--text-faint); margin-top: 2px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.aitem__dl {
|
||||||
|
flex: none; display: grid; place-items: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 6px;
|
||||||
|
color: var(--text-faint); text-decoration: none;
|
||||||
|
transition: color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.aitem__dl svg { width: 13px; height: 13px; }
|
||||||
|
.aitem__dl:hover { color: var(--accent); background: var(--surface-3); }
|
||||||
|
|
||||||
|
/* replay marker on history rows whose audio is already saved */
|
||||||
|
.hitem__replay {
|
||||||
|
flex: none; display: grid; place-items: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 50%;
|
||||||
|
border: 0; cursor: pointer;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
transition: filter .15s, transform .12s;
|
||||||
|
}
|
||||||
|
.hitem__replay svg { width: 11px; height: 11px; }
|
||||||
|
.hitem__replay:hover { filter: brightness(1.25); }
|
||||||
|
.hitem__replay:active { transform: scale(.9); }
|
||||||
6
pronunciation.json
Normal file
6
pronunciation.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"_comment": "How the SIMULATOR should pronounce tricky words. Edit freely, then restart. The real robot never sees these - it always gets your original text. Matching is case-insensitive, on whole words only.",
|
||||||
|
|
||||||
|
"AGIBOT": "A G I bot",
|
||||||
|
"AgiBot": "A G I bot"
|
||||||
|
}
|
||||||
28
requirements.txt
Normal file
28
requirements.txt
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# AGIBOT A3 - Voice Control : Python dependencies
|
||||||
|
# Install with: pip install -r requirements.txt
|
||||||
|
|
||||||
|
# --- core (always needed) ---------------------------------------------------
|
||||||
|
fastapi>=0.110
|
||||||
|
uvicorn[standard]>=0.27
|
||||||
|
python-dotenv>=1.0
|
||||||
|
pydantic>=2.5
|
||||||
|
|
||||||
|
# --- transports -------------------------------------------------------------
|
||||||
|
httpx>=0.27 # HTTP/REST transport (keep-alive client)
|
||||||
|
websockets>=12.0 # WebSocket transport + uvicorn ws support
|
||||||
|
|
||||||
|
# --- simulator audio (MOCK_LOCAL_AUDIO=true) --------------------------------
|
||||||
|
# Lets the simulator speak through this PC's speakers. Windows only; on other
|
||||||
|
# systems the app uses built-in commands and needs nothing installed.
|
||||||
|
comtypes>=1.2 ; sys_platform == "win32"
|
||||||
|
|
||||||
|
# --- optional ---------------------------------------------------------------
|
||||||
|
# paramiko>=3.4 # A3_TRANSPORT=ssh with password authentication
|
||||||
|
#
|
||||||
|
# Simulator audio without comtypes: Windows falls back to PowerShell's
|
||||||
|
# System.Speech (always present, just slower to start). macOS uses `say`.
|
||||||
|
# Linux needs: sudo apt install espeak-ng
|
||||||
|
#
|
||||||
|
# ROS 2 transport (A3_TRANSPORT=ros2) needs `rclpy`, which is NOT installed via
|
||||||
|
# pip - it comes from a ROS 2 installation. Run the app from a sourced ROS 2
|
||||||
|
# environment if you use that transport.
|
||||||
303
scripts/discover_robot.py
Normal file
303
scripts/discover_robot.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
"""Probe an AGIBOT A3 to find out what it actually exposes.
|
||||||
|
|
||||||
|
Run this the moment you have the robot's IP, before touching .env:
|
||||||
|
|
||||||
|
python scripts/discover_robot.py 192.168.1.50
|
||||||
|
|
||||||
|
It is READ-ONLY by default: it pings, checks the ports AgiBot documents, and asks
|
||||||
|
each one for a deliberately nonexistent route. A **404 proves an AimRT HTTP
|
||||||
|
server is listening** on that port; connection-refused proves it is not. Nothing
|
||||||
|
is played and nothing is changed.
|
||||||
|
|
||||||
|
To run the decisive end-to-end test - which makes the robot actually talk:
|
||||||
|
|
||||||
|
python scripts/discover_robot.py 192.168.1.50 --speak "Hello, I am Expedition A3"
|
||||||
|
|
||||||
|
Port and route expectations come from AgiBot's A3 developer guide:
|
||||||
|
https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play
|
||||||
|
https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/03-second_develop_interface_overview
|
||||||
|
|
||||||
|
They are documented facts for A3 v3.1/v3.2 firmware, not guarantees: AgiBot does
|
||||||
|
not promise port stability, and AimRT has no service-discovery endpoint. Anything
|
||||||
|
this script cannot confirm, it reports as unknown rather than guessing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("Install dependencies first: pip install -r requirements.txt")
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
def _use_utf8_console() -> None:
|
||||||
|
"""Windows consoles default to a legacy code page; the robot speaks Chinese."""
|
||||||
|
for stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
if stream is not None and hasattr(stream, "reconfigure"):
|
||||||
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_use_utf8_console()
|
||||||
|
|
||||||
|
|
||||||
|
# Ports with published evidence. Audio ones first; the motion/mapping ports are
|
||||||
|
# probed only so the report is an honest map - they have nothing to do with TTS.
|
||||||
|
PORTS: List[Tuple[int, str, str]] = [
|
||||||
|
(59301, "TTS / agent RPC (HDU)", "audio"),
|
||||||
|
(56666, "HalAudioService - volume, PlayFile (HDU)", "audio"),
|
||||||
|
(51049, "ResourceService - audio resources (HDU)", "audio"),
|
||||||
|
(56444, "MotionCommandService (MDU)", "other"),
|
||||||
|
(56322, "motion control (MDU)", "other"),
|
||||||
|
(22, "SSH", "other"),
|
||||||
|
(8080, "common HTTP alternative", "other"),
|
||||||
|
]
|
||||||
|
|
||||||
|
TTS_SERVICE = "aimdk.protocol.TTSService"
|
||||||
|
NONEXISTENT_ROUTE = "/rpc/does.not.Exist/Nope"
|
||||||
|
|
||||||
|
OK = " [ OK ]"
|
||||||
|
NO = " [ -- ]"
|
||||||
|
WARN = " [ !! ]"
|
||||||
|
|
||||||
|
|
||||||
|
def head(title: str) -> None:
|
||||||
|
print("\n" + title)
|
||||||
|
print(" " + "-" * (len(title) + 2))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 1. ICMP
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def ping(host: str) -> Optional[float]:
|
||||||
|
flag = "-n" if platform.system().lower().startswith("win") else "-c"
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ping", flag, "2", host],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=12,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return (time.perf_counter() - started) * 1000 / 2
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 2. TCP
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
async def tcp_open(host: str, port: int, timeout: float = 2.0) -> Optional[float]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
fut = asyncio.open_connection(host, port)
|
||||||
|
_, writer = await asyncio.wait_for(fut, timeout=timeout)
|
||||||
|
writer.close()
|
||||||
|
try:
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return (time.perf_counter() - started) * 1000
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 3. Is it an AimRT HTTP server?
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
async def aimrt_probe(client: "httpx.AsyncClient", host: str, port: int) -> Dict[str, Any]:
|
||||||
|
"""Ask for a route that cannot exist.
|
||||||
|
|
||||||
|
AimRT answers 404 for an unknown route, which is a positive identification
|
||||||
|
without invoking anything.
|
||||||
|
"""
|
||||||
|
url = "http://{0}:{1}{2}".format(host, port, NONEXISTENT_ROUTE)
|
||||||
|
try:
|
||||||
|
response = await client.post(url, json={}, headers={"Content-Type": "application/json"})
|
||||||
|
except Exception as exc:
|
||||||
|
return {"reachable": False, "detail": type(exc).__name__}
|
||||||
|
return {
|
||||||
|
"reachable": True,
|
||||||
|
"status": response.status_code,
|
||||||
|
"aimrt": response.status_code in (404, 500),
|
||||||
|
"cors": response.headers.get("access-control-allow-origin"),
|
||||||
|
"server": response.headers.get("server"),
|
||||||
|
"body": response.text[:160],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 4. The decisive test
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
async def try_speak(client: "httpx.AsyncClient", host: str, port: int, text: str) -> Dict[str, Any]:
|
||||||
|
url = "http://{0}:{1}/rpc/{2}/PlayTTS".format(host, port, TTS_SERVICE)
|
||||||
|
payload = {
|
||||||
|
"text": text,
|
||||||
|
"priority_level": "INTERACTION_L6",
|
||||||
|
"domain": "discovery_probe",
|
||||||
|
"trace_id": "probe{0}".format(int(time.time())),
|
||||||
|
"is_interrupted": True,
|
||||||
|
}
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await client.post(url, json=payload, headers={"Content-Type": "application/json"})
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": "{0}: {1}".format(type(exc).__name__, exc)}
|
||||||
|
|
||||||
|
elapsed = (time.perf_counter() - started) * 1000
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except Exception:
|
||||||
|
body = {"raw": response.text[:300]}
|
||||||
|
success = None
|
||||||
|
if isinstance(body, dict):
|
||||||
|
success = body.get("is_sucess", body.get("is_success"))
|
||||||
|
return {
|
||||||
|
"ok": response.status_code == 200 and success is not False,
|
||||||
|
"status": response.status_code,
|
||||||
|
"ms": round(elapsed, 1),
|
||||||
|
"success_flag": success,
|
||||||
|
"trace_id": body.get("trace_id") if isinstance(body, dict) else None,
|
||||||
|
"sent_trace": payload["trace_id"],
|
||||||
|
"has_header_envelope": isinstance(body, dict) and isinstance(body.get("header"), dict),
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# main
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
async def run(host: str, text: Optional[str], tts_port: int) -> int:
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
print(" AGIBOT A3 discovery -> {0}".format(host))
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
# ---- 1. ping -----------------------------------------------------------
|
||||||
|
head("1. Reachability")
|
||||||
|
rtt = ping(host)
|
||||||
|
if rtt is None:
|
||||||
|
print(NO + " ping got no reply.")
|
||||||
|
print(" Not conclusive - some robots drop ICMP but still serve their ports.")
|
||||||
|
else:
|
||||||
|
print(OK + " ping replies (~{0:.0f} ms round trip)".format(rtt))
|
||||||
|
|
||||||
|
# ---- 2. ports ----------------------------------------------------------
|
||||||
|
head("2. Ports")
|
||||||
|
open_ports: List[int] = []
|
||||||
|
results = await asyncio.gather(*[tcp_open(host, port) for port, _, _ in PORTS])
|
||||||
|
for (port, label, kind), elapsed in zip(PORTS, results):
|
||||||
|
if elapsed is None:
|
||||||
|
print("{0} {1:<6} closed / filtered {2}".format(NO, port, label))
|
||||||
|
else:
|
||||||
|
open_ports.append(port)
|
||||||
|
print("{0} {1:<6} OPEN ({2:.0f} ms) {3}".format(OK, port, elapsed, label))
|
||||||
|
|
||||||
|
if not open_ports:
|
||||||
|
print("\n" + WARN + " Nothing answered. Check the IP, that the robot has finished")
|
||||||
|
print(" booting, and that both machines are on the same subnet.")
|
||||||
|
print(" See docs/NETWORK.md.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ---- 3. AimRT identification ------------------------------------------
|
||||||
|
head("3. Which ports speak AimRT HTTP JSON-RPC?")
|
||||||
|
aimrt_ports: List[int] = []
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
|
||||||
|
for port in open_ports:
|
||||||
|
if port == 22:
|
||||||
|
continue
|
||||||
|
info = await aimrt_probe(client, host, port)
|
||||||
|
if not info.get("reachable"):
|
||||||
|
print("{0} {1:<6} no HTTP response ({2})".format(NO, port, info.get("detail")))
|
||||||
|
continue
|
||||||
|
if info.get("aimrt"):
|
||||||
|
aimrt_ports.append(port)
|
||||||
|
extra = ""
|
||||||
|
if info.get("cors"):
|
||||||
|
extra = " CORS: {0}".format(info["cors"])
|
||||||
|
print("{0} {1:<6} AimRT RPC server (HTTP {2} for an unknown route){3}".format(
|
||||||
|
OK, port, info["status"], extra))
|
||||||
|
else:
|
||||||
|
print("{0} {1:<6} HTTP {2}, but not an AimRT RPC route".format(
|
||||||
|
NO, port, info.get("status")))
|
||||||
|
|
||||||
|
# ---- 4. summary / speak -------------------------------------------
|
||||||
|
head("4. Verdict")
|
||||||
|
if tts_port in aimrt_ports:
|
||||||
|
print(OK + " Port {0} looks like the A3 TTS RPC service.".format(tts_port))
|
||||||
|
print("")
|
||||||
|
print(" Put this in .env:")
|
||||||
|
print(" ROBOT_MODE=real")
|
||||||
|
print(" ROBOT_IP={0}".format(host))
|
||||||
|
print(" ROBOT_PORT={0}".format(tts_port))
|
||||||
|
print(" A3_TRANSPORT=aimdk")
|
||||||
|
elif aimrt_ports:
|
||||||
|
print(WARN + " AimRT RPC found on {0}, but not on the documented TTS port {1}.".format(
|
||||||
|
", ".join(str(p) for p in aimrt_ports), tts_port))
|
||||||
|
print(" Try each with --speak, and set ROBOT_PORT to whichever talks.")
|
||||||
|
else:
|
||||||
|
print(WARN + " No AimRT RPC server found.")
|
||||||
|
print(" Most likely the RPC ports are bound only to the robot's internal")
|
||||||
|
print(" 10.42.10.x network. SSH in and check: ss -ltnp | grep 59301")
|
||||||
|
print(" See docs/AGIBOT_A3_INTEGRATION.md section 6.")
|
||||||
|
|
||||||
|
if text:
|
||||||
|
head("5. Speech test (the robot should talk now)")
|
||||||
|
print(' Sending: "{0}"'.format(text))
|
||||||
|
result = await try_speak(client, host, tts_port, text)
|
||||||
|
if result.get("ok"):
|
||||||
|
print(OK + " Accepted in {0} ms.".format(result["ms"]))
|
||||||
|
print(" is_sucess : {0}".format(result["success_flag"]))
|
||||||
|
print(" trace_id sent : {0}".format(result["sent_trace"]))
|
||||||
|
print(" trace_id back : {0}".format(result["trace_id"]))
|
||||||
|
if result["trace_id"] and result["trace_id"] != result["sent_trace"]:
|
||||||
|
print(" (differs, as documented - Stop must use the returned id)")
|
||||||
|
if result["has_header_envelope"]:
|
||||||
|
print(" header envelope: present")
|
||||||
|
print("")
|
||||||
|
print(" If you heard the robot, the integration is confirmed.")
|
||||||
|
print(" If it was silent, check the robot's volume and whether its")
|
||||||
|
print(" TTS needs internet access - see docs/AGIBOT_A3_INTEGRATION.md section 7.")
|
||||||
|
else:
|
||||||
|
print(WARN + " Speech request failed.")
|
||||||
|
for key in ("status", "error", "success_flag"):
|
||||||
|
if result.get(key) is not None:
|
||||||
|
print(" {0:<14}: {1}".format(key, result[key]))
|
||||||
|
if result.get("status") == 404:
|
||||||
|
print(" 404 = the port is right but the route is wrong. Check the")
|
||||||
|
print(" service/method names against your firmware's documentation.")
|
||||||
|
if result.get("body"):
|
||||||
|
print(" body : {0}".format(
|
||||||
|
json.dumps(result["body"], ensure_ascii=False)[:300]))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n" + "=" * 72 + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Probe an AGIBOT A3 for its speech interface.",
|
||||||
|
epilog="Read-only unless --speak is given.",
|
||||||
|
)
|
||||||
|
parser.add_argument("ip", help="The robot's IP address on your network.")
|
||||||
|
parser.add_argument("--speak", metavar="TEXT", default=None,
|
||||||
|
help="Also send this text - THE ROBOT WILL TALK.")
|
||||||
|
parser.add_argument("--port", type=int, default=59301,
|
||||||
|
help="TTS RPC port (default: 59301, AgiBot's documented port).")
|
||||||
|
args = parser.parse_args()
|
||||||
|
raise SystemExit(asyncio.run(run(args.ip, args.speak, args.port)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
194
scripts/fake_a3_server.py
Normal file
194
scripts/fake_a3_server.py
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
"""A stand-in for the AGIBOT A3's speech RPC, for testing the REAL adapter path.
|
||||||
|
|
||||||
|
The mock robot (ROBOT_MODE=mock) tests the app. This tests the *wire protocol* -
|
||||||
|
it speaks the interface AgiBot documents for the A3, so you can run the app in
|
||||||
|
ROBOT_MODE=real against 127.0.0.1 and exercise `aimdk_transport.py` itself:
|
||||||
|
the URL shape, the JSON body, the trace_id round trip, chunking, Stop, and the
|
||||||
|
error paths. When the real robot arrives, only ROBOT_IP changes.
|
||||||
|
|
||||||
|
It mirrors the documented contract, including its quirks:
|
||||||
|
* route POST /rpc/<service>/<method>, Content-Type: application/json
|
||||||
|
* success flag `is_sucess` (one 'c' - as printed in AgiBot's docs)
|
||||||
|
* trace_id the reply appends a random suffix to the one you sent
|
||||||
|
* size limit 1024 bytes of UTF-8 on `text`
|
||||||
|
* unknown route 404; RPC-level failure 500
|
||||||
|
|
||||||
|
Docs: https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python scripts/fake_a3_server.py # listens on 127.0.0.1:59301
|
||||||
|
|
||||||
|
Then in .env:
|
||||||
|
ROBOT_MODE=real
|
||||||
|
ROBOT_IP=127.0.0.1
|
||||||
|
ROBOT_PORT=59301
|
||||||
|
|
||||||
|
NOTE: this is a TEST DOUBLE written from public documentation. It is not the
|
||||||
|
robot, and passing against it proves the client is well-formed - not that the
|
||||||
|
robot's firmware behaves identically. Verify against the real unit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("Install dependencies first: pip install -r requirements.txt")
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
def _use_utf8_console() -> None:
|
||||||
|
"""Windows consoles default to a legacy code page; the robot speaks Chinese."""
|
||||||
|
for stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
if stream is not None and hasattr(stream, "reconfigure"):
|
||||||
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_use_utf8_console()
|
||||||
|
|
||||||
|
|
||||||
|
SERVICE = "aimdk.protocol.TTSService"
|
||||||
|
MAX_BYTES = 1024
|
||||||
|
|
||||||
|
app = FastAPI(title="Fake AGIBOT A3 speech RPC", docs_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
STATE: Dict[str, Any] = {"utterances": {}, "active": None, "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _log(kind: str, message: str) -> None:
|
||||||
|
print(" {0} {1:<9} {2}".format(time.strftime("%H:%M:%S"), kind, message), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/rpc/{service}/{method}")
|
||||||
|
async def rpc(service: str, method: str, request: Request) -> Response:
|
||||||
|
if service != SERVICE:
|
||||||
|
_log("404", "unknown service {0}".format(service))
|
||||||
|
return JSONResponse({"error": "no such service"}, status_code=404)
|
||||||
|
|
||||||
|
# AimRT requires this header and rejects anything else.
|
||||||
|
content_type = (request.headers.get("content-type") or "").split(";")[0].strip()
|
||||||
|
if content_type != "application/json":
|
||||||
|
_log("500", "bad Content-Type: {0!r}".format(content_type))
|
||||||
|
return JSONResponse({"error": "unsupported content type"}, status_code=500)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.loads(await request.body() or b"{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JSONResponse({"error": "malformed json"}, status_code=500)
|
||||||
|
|
||||||
|
handler = {
|
||||||
|
"PlayTTS": _play_tts,
|
||||||
|
"StopTTSTraceId": _stop_tts,
|
||||||
|
"GetAudioStatus": _get_status,
|
||||||
|
}.get(method)
|
||||||
|
|
||||||
|
if handler is None:
|
||||||
|
_log("404", "unknown method {0}".format(method))
|
||||||
|
return JSONResponse({"error": "no such method"}, status_code=404)
|
||||||
|
|
||||||
|
return handler(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _play_tts(body: Dict[str, Any]) -> Response:
|
||||||
|
text = body.get("text") or ""
|
||||||
|
size = len(text.encode("utf-8"))
|
||||||
|
|
||||||
|
if size > MAX_BYTES:
|
||||||
|
_log("REJECT", "text is {0} bytes (limit {1})".format(size, MAX_BYTES))
|
||||||
|
return JSONResponse(_reply(body, False, "text exceeds 1024 bytes"), status_code=200)
|
||||||
|
if not text.strip():
|
||||||
|
_log("REJECT", "empty text")
|
||||||
|
return JSONResponse(_reply(body, False, "empty text"), status_code=200)
|
||||||
|
|
||||||
|
trace = "{0}_{1}".format(body.get("trace_id") or "trace", secrets.token_urlsafe(16))
|
||||||
|
STATE["utterances"][trace] = {"text": text, "started": time.time(), "stopped": False}
|
||||||
|
STATE["active"] = trace
|
||||||
|
STATE["count"] += 1
|
||||||
|
|
||||||
|
_log("SPEAK", '"{0}" [{1} bytes, priority={2}, interrupt={3}]'.format(
|
||||||
|
text if len(text) <= 70 else text[:67] + "...",
|
||||||
|
size, body.get("priority_level"), body.get("is_interrupted"),
|
||||||
|
))
|
||||||
|
return JSONResponse(_reply(body, True, "", trace), status_code=200)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_tts(body: Dict[str, Any]) -> Response:
|
||||||
|
trace = body.get("trace_id")
|
||||||
|
entry = STATE["utterances"].get(trace)
|
||||||
|
if entry is None:
|
||||||
|
_log("STOP", "unknown trace_id {0!r}".format(trace))
|
||||||
|
return JSONResponse({"is_sucess": False, "error_message": "unknown trace_id"}, status_code=200)
|
||||||
|
entry["stopped"] = True
|
||||||
|
if STATE["active"] == trace:
|
||||||
|
STATE["active"] = None
|
||||||
|
_log("STOP", "stopped {0}".format(trace))
|
||||||
|
return JSONResponse({"is_sucess": True, "error_message": "", "trace_id": trace}, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_status(body: Dict[str, Any]) -> Response:
|
||||||
|
trace = body.get("trace_id")
|
||||||
|
entry = STATE["utterances"].get(trace)
|
||||||
|
if entry is None:
|
||||||
|
status = "TTSStatusType_NOTInQue"
|
||||||
|
elif entry["stopped"]:
|
||||||
|
status = "TTSStatusType_Stop"
|
||||||
|
elif time.time() - entry["started"] < _estimate(entry["text"]):
|
||||||
|
status = "TTSStatusType_Playing"
|
||||||
|
else:
|
||||||
|
status = "TTSStatusType_End"
|
||||||
|
return JSONResponse({"trace_id": trace, "tts_status": status, "is_sucess": True}, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
|
def _reply(body: Dict[str, Any], ok: bool, error: str, trace: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"text": body.get("text", ""),
|
||||||
|
"priority_level": body.get("priority_level", ""),
|
||||||
|
"priority_weight": 0,
|
||||||
|
"domain": body.get("domain", ""),
|
||||||
|
"trace_id": trace or body.get("trace_id", ""),
|
||||||
|
"is_sucess": ok, # the documented spelling - do not "fix" it
|
||||||
|
"error_message": error,
|
||||||
|
"estimated_duration": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate(text: str) -> float:
|
||||||
|
words = max(1, len(text.split()))
|
||||||
|
return max(1.0, words / 150 * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Fake AGIBOT A3 speech RPC endpoint.")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=59301)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(" Fake AGIBOT A3 speech RPC")
|
||||||
|
print(" " + "-" * 52)
|
||||||
|
print(" Listening : http://{0}:{1}".format(args.host, args.port))
|
||||||
|
print(" Endpoint : POST /rpc/{0}/PlayTTS".format(SERVICE))
|
||||||
|
print("")
|
||||||
|
print(" Point the app at it with:")
|
||||||
|
print(" ROBOT_MODE=real")
|
||||||
|
print(" ROBOT_IP={0}".format(args.host))
|
||||||
|
print(" ROBOT_PORT={0}".format(args.port))
|
||||||
|
print("")
|
||||||
|
print(" This is a TEST DOUBLE built from public docs - not the robot.")
|
||||||
|
print("")
|
||||||
|
uvicorn.run(app, host=args.host, port=args.port, log_level="warning", access_log=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
267
scripts/selftest.py
Normal file
267
scripts/selftest.py
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
"""End-to-end self test.
|
||||||
|
|
||||||
|
Exercises the whole stack the way the browser does - REST for commands, WebSocket
|
||||||
|
for the lifecycle - and prints a pass/fail report.
|
||||||
|
|
||||||
|
1. start the server: python backend/main.py
|
||||||
|
2. in another window: python scripts/selftest.py
|
||||||
|
|
||||||
|
Works against mock mode out of the box. Against a real robot it also works, but
|
||||||
|
the robot will actually speak, so only run it when that is fine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("Install dependencies first: pip install -r requirements.txt")
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from websockets.asyncio.client import connect as ws_connect
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
from websockets.client import connect as ws_connect # type: ignore
|
||||||
|
|
||||||
|
def _use_utf8_console() -> None:
|
||||||
|
"""Windows consoles default to a legacy code page; the robot speaks Chinese."""
|
||||||
|
for stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
if stream is not None and hasattr(stream, "reconfigure"):
|
||||||
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_use_utf8_console()
|
||||||
|
|
||||||
|
|
||||||
|
PASS = " [PASS]"
|
||||||
|
FAIL = " [FAIL]"
|
||||||
|
INFO = " [ .. ]"
|
||||||
|
|
||||||
|
results: List[tuple] = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, condition: bool, detail: str = "") -> bool:
|
||||||
|
results.append((name, condition, detail))
|
||||||
|
print("{0} {1}{2}".format(PASS if condition else FAIL, name, " - " + detail if detail else ""))
|
||||||
|
return condition
|
||||||
|
|
||||||
|
|
||||||
|
class Listener:
|
||||||
|
"""Collects WebSocket events in the background, like the dashboard does."""
|
||||||
|
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self.url = url
|
||||||
|
self.events: List[Dict[str, Any]] = []
|
||||||
|
self.hello: Optional[Dict[str, Any]] = None
|
||||||
|
self._ws: Any = None
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "Listener":
|
||||||
|
self._ws = await ws_connect(self.url)
|
||||||
|
self._task = asyncio.create_task(self._read())
|
||||||
|
for _ in range(50):
|
||||||
|
if self.hello is not None:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: Any) -> None:
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
if self._ws:
|
||||||
|
await self._ws.close()
|
||||||
|
|
||||||
|
async def _read(self) -> None:
|
||||||
|
async for raw in self._ws:
|
||||||
|
message = json.loads(raw)
|
||||||
|
if message.get("type") == "hello":
|
||||||
|
self.hello = message["data"]
|
||||||
|
self.events.append(message)
|
||||||
|
|
||||||
|
def stages(self, request_id: Optional[str] = None) -> List[str]:
|
||||||
|
out = []
|
||||||
|
for event in self.events:
|
||||||
|
if event.get("type") != "speech.progress":
|
||||||
|
continue
|
||||||
|
data = event["data"]
|
||||||
|
if request_id and data.get("requestId") != request_id:
|
||||||
|
continue
|
||||||
|
out.append(data["stage"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def wait_for_event(self, event_type: str, timeout: float = 10.0) -> bool:
|
||||||
|
"""Wait for the next event of a type to arrive (ignoring earlier ones)."""
|
||||||
|
seen = sum(1 for e in self.events if e.get("type") == event_type)
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if sum(1 for e in self.events if e.get("type") == event_type) > seen:
|
||||||
|
return True
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def wait_for_stage(self, stage: str, request_id: str, timeout: float = 30.0) -> bool:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if stage in self.stages(request_id):
|
||||||
|
return True
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def run(base: str) -> int:
|
||||||
|
ws_url = base.replace("http://", "ws://").replace("https://", "wss://") + "/ws"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(base_url=base, timeout=20.0) as client:
|
||||||
|
print("\n=== 1. Server & configuration " + "=" * 40)
|
||||||
|
try:
|
||||||
|
health = (await client.get("/api/health")).json()
|
||||||
|
except Exception as exc:
|
||||||
|
print(FAIL + " server unreachable at {0} ({1})".format(base, exc))
|
||||||
|
print("\n Start it first: python backend/main.py\n")
|
||||||
|
return 1
|
||||||
|
check("GET /api/health returns ok", health.get("status") == "ok", "mode=" + str(health.get("mode")))
|
||||||
|
|
||||||
|
config = (await client.get("/api/config")).json()
|
||||||
|
check("GET /api/config returns a mode", config.get("mode") in ("mock", "real"))
|
||||||
|
check("no blocking config errors",
|
||||||
|
not [i for i in config.get("issues", []) if i["level"] == "error"],
|
||||||
|
str(config.get("issues")))
|
||||||
|
|
||||||
|
print("\n=== 2. Dashboard is served " + "=" * 43)
|
||||||
|
page = await client.get("/")
|
||||||
|
check("GET / serves the dashboard", page.status_code == 200 and "AGIBOT" in page.text)
|
||||||
|
for asset in ("/styles/main.css", "/js/app.js", "/js/api.js", "/js/socket.js", "/js/ui.js"):
|
||||||
|
response = await client.get(asset)
|
||||||
|
check("GET {0}".format(asset), response.status_code == 200)
|
||||||
|
|
||||||
|
print("\n=== 3. Robot connection " + "=" * 46)
|
||||||
|
status = None
|
||||||
|
for _ in range(40): # allow the supervisor a moment to connect
|
||||||
|
status = (await client.get("/api/robot/status")).json()
|
||||||
|
if status.get("connected"):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
check("robot reports connected", bool(status and status.get("connected")),
|
||||||
|
"state=" + str(status.get("state") if status else "?"))
|
||||||
|
|
||||||
|
async with Listener(ws_url) as listener:
|
||||||
|
check("WebSocket accepts connection and sends hello", listener.hello is not None)
|
||||||
|
if listener.hello:
|
||||||
|
check("hello carries config + status + history",
|
||||||
|
all(k in listener.hello for k in ("config", "status", "history")))
|
||||||
|
|
||||||
|
print("\n=== 4. Speak (the main workflow) " + "=" * 37)
|
||||||
|
text = "Hello, welcome to our company."
|
||||||
|
started = time.perf_counter()
|
||||||
|
response = await client.post("/api/robot/speak", json={"text": text})
|
||||||
|
http_ms = (time.perf_counter() - started) * 1000
|
||||||
|
check("POST /api/robot/speak accepted", response.status_code == 200,
|
||||||
|
"HTTP {0}".format(response.status_code))
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(" body:", response.text[:300])
|
||||||
|
return 1
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
request_id = body["requestId"]
|
||||||
|
check("response reports success", body.get("success") is True, json.dumps(body))
|
||||||
|
check("HTTP call returns before speech ends (< 3s)", http_ms < 3000,
|
||||||
|
"{0:.0f} ms".format(http_ms))
|
||||||
|
check("ack latency reported", isinstance(body.get("ackLatencyMs"), int),
|
||||||
|
"{0} ms".format(body.get("ackLatencyMs")))
|
||||||
|
|
||||||
|
got_speaking = await listener.wait_for_stage("speaking", request_id, timeout=10)
|
||||||
|
check("lifecycle reaches 'speaking'", got_speaking)
|
||||||
|
got_completed = await listener.wait_for_stage("completed", request_id, timeout=60)
|
||||||
|
check("lifecycle reaches 'completed'", got_completed)
|
||||||
|
stages = listener.stages(request_id)
|
||||||
|
check("full stage sequence observed",
|
||||||
|
["sending", "processing", "speaking", "completed"] == [
|
||||||
|
s for s in stages if s in ("sending", "processing", "speaking", "completed")
|
||||||
|
],
|
||||||
|
" -> ".join(stages))
|
||||||
|
|
||||||
|
print("\n=== 5. Stop " + "=" * 58)
|
||||||
|
long_text = "This is a much longer sentence used to verify that the stop button " \
|
||||||
|
"interrupts an utterance while the robot is still speaking it out loud."
|
||||||
|
body2 = (await client.post("/api/robot/speak", json={"text": long_text})).json()
|
||||||
|
rid2 = body2["requestId"]
|
||||||
|
await listener.wait_for_stage("speaking", rid2, timeout=10)
|
||||||
|
stop_response = await client.post("/api/robot/stop")
|
||||||
|
check("POST /api/robot/stop accepted", stop_response.status_code == 200)
|
||||||
|
cancelled = await listener.wait_for_stage("cancelled", rid2, timeout=10)
|
||||||
|
check("utterance reports 'cancelled'", cancelled, " -> ".join(listener.stages(rid2)))
|
||||||
|
|
||||||
|
print("\n=== 6. Error handling " + "=" * 48)
|
||||||
|
empty = await client.post("/api/robot/speak", json={"text": " "})
|
||||||
|
check("empty text rejected with 400", empty.status_code == 400,
|
||||||
|
"HTTP {0} {1}".format(empty.status_code, empty.text[:120]))
|
||||||
|
|
||||||
|
too_long = await client.post(
|
||||||
|
"/api/robot/speak", json={"text": "x " * (config.get("maxLength", 1000) + 50)}
|
||||||
|
)
|
||||||
|
check("over-long text rejected with 400", too_long.status_code == 400,
|
||||||
|
"HTTP {0}".format(too_long.status_code))
|
||||||
|
|
||||||
|
missing = await client.post("/api/robot/speak", json={})
|
||||||
|
check("malformed body rejected", missing.status_code in (400, 422),
|
||||||
|
"HTTP {0}".format(missing.status_code))
|
||||||
|
|
||||||
|
print("\n=== 7. History " + "=" * 55)
|
||||||
|
history = (await client.get("/api/speech/history")).json()
|
||||||
|
check("history contains the utterances", history.get("count", 0) >= 2,
|
||||||
|
"count={0}".format(history.get("count")))
|
||||||
|
first = history["items"][0] if history.get("items") else {}
|
||||||
|
check("history entries carry stage + latency",
|
||||||
|
"stage" in first and "ackLatencyMs" in first)
|
||||||
|
|
||||||
|
cleared = (await client.delete("/api/speech/history")).json()
|
||||||
|
check("history cleared", cleared.get("success") is True,
|
||||||
|
"removed={0}".format(cleared.get("removed")))
|
||||||
|
after = (await client.get("/api/speech/history")).json()
|
||||||
|
check("history is empty afterwards", after.get("count") == 0)
|
||||||
|
|
||||||
|
print("\n=== 8. Diagnostics & recovery " + "=" * 40)
|
||||||
|
diagnostics = (await client.get("/api/robot/diagnostics")).json()
|
||||||
|
check("GET /api/robot/diagnostics works", "status" in diagnostics and "adapter" in diagnostics)
|
||||||
|
reconnect = (await client.post("/api/robot/reconnect")).json()
|
||||||
|
check("POST /api/robot/reconnect works", reconnect.get("success") is True)
|
||||||
|
|
||||||
|
# Status is pushed once per health interval, so wait for one rather
|
||||||
|
# than assuming the earlier checks took long enough to see it.
|
||||||
|
budget = float(config.get("healthInterval") or 5.0) + 4.0
|
||||||
|
saw_status_event = await listener.wait_for_event("robot.status", timeout=budget)
|
||||||
|
check("robot.status events pushed over WebSocket", saw_status_event,
|
||||||
|
"waited up to {0:.0f}s".format(budget))
|
||||||
|
|
||||||
|
passed = sum(1 for _, ok, _ in results if ok)
|
||||||
|
total = len(results)
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
print(" {0}/{1} checks passed".format(passed, total))
|
||||||
|
if passed != total:
|
||||||
|
print("\n Failures:")
|
||||||
|
for name, ok, detail in results:
|
||||||
|
if not ok:
|
||||||
|
print(" - {0} {1}".format(name, detail))
|
||||||
|
print("=" * 72 + "\n")
|
||||||
|
return 0 if passed == total else 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="End-to-end test for the A3 voice dashboard.")
|
||||||
|
parser.add_argument("--url", default="http://127.0.0.1:8000", help="Base URL of the running app.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
raise SystemExit(asyncio.run(run(args.url.rstrip("/"))))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
130
scripts/voices.py
Normal file
130
scripts/voices.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""List and audition the PC voices available to the simulator.
|
||||||
|
|
||||||
|
python scripts/voices.py # list what is installed
|
||||||
|
python scripts/voices.py --demo # speak a sample line in each voice
|
||||||
|
python scripts/voices.py --try Zira # hear one voice, with .env's rate/volume
|
||||||
|
|
||||||
|
Whichever you prefer goes in .env:
|
||||||
|
|
||||||
|
MOCK_LOCAL_AUDIO=true
|
||||||
|
MOCK_VOICE=Zira
|
||||||
|
|
||||||
|
IMPORTANT - this is the *simulator's* voice, not the robot's. The real AGIBOT A3
|
||||||
|
synthesises speech on-board; AgiBot does not publish which engine or timbre it
|
||||||
|
uses, so no PC voice can be claimed to match it. This is a stand-in for
|
||||||
|
rehearsing a demo. See docs/AGIBOT_A3_INTEGRATION.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _use_utf8_console() -> None:
|
||||||
|
for stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
if stream is not None and hasattr(stream, "reconfigure"):
|
||||||
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_use_utf8_console()
|
||||||
|
|
||||||
|
from backend.config.settings import get_settings # noqa: E402
|
||||||
|
from backend.robot.local_audio import create_local_voice, list_local_voices # noqa: E402
|
||||||
|
|
||||||
|
SAMPLE = "Good afternoon, and welcome to our showroom. I am the AGIBOT A3 humanoid robot."
|
||||||
|
|
||||||
|
|
||||||
|
def speak_with(hint, text, rate, volume, pitch=0) -> None:
|
||||||
|
voice = create_local_voice(voice_hint=hint, rate=rate, volume=volume, pitch=pitch)
|
||||||
|
if voice is None:
|
||||||
|
print(" No speech engine available on this PC.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
voice.start(text)
|
||||||
|
while voice.is_speaking():
|
||||||
|
time.sleep(0.05)
|
||||||
|
finally:
|
||||||
|
voice.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="List and audition simulator voices.")
|
||||||
|
parser.add_argument("--demo", action="store_true", help="Speak a sample in every voice.")
|
||||||
|
parser.add_argument("--try", dest="which", metavar="NAME", help="Speak a sample in one voice.")
|
||||||
|
parser.add_argument("--text", default=SAMPLE, help="Say this instead of the default sample.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
rate, volume = settings.mock.speech_rate, settings.mock.speech_volume
|
||||||
|
pitch = settings.mock.speech_pitch
|
||||||
|
|
||||||
|
if args.which:
|
||||||
|
print('\n Voice "{0}" (rate {1}, volume {2}, pitch {3})'.format(
|
||||||
|
args.which, rate, volume, pitch))
|
||||||
|
print(" " + args.text + "\n")
|
||||||
|
speak_with(args.which, args.text, rate, volume, pitch)
|
||||||
|
return
|
||||||
|
|
||||||
|
if settings.mock.voice_engine == "gemini":
|
||||||
|
from backend.robot.gemini_voice import VOICES as GEMINI_VOICES
|
||||||
|
|
||||||
|
current = (settings.mock.gemini_voice or "").lower()
|
||||||
|
print("\n Gemini neural voices (active: MOCK_VOICE_ENGINE=gemini)")
|
||||||
|
print(" " + "-" * 58)
|
||||||
|
for name, tone in sorted(GEMINI_VOICES.items()):
|
||||||
|
mark = "*" if name.lower() == current else " "
|
||||||
|
note = " <- young + male, closest to the robot's Yunxiao" \
|
||||||
|
if name in ("Puck", "Fenrir") else ""
|
||||||
|
print(" {0} {1:<16} {2}{3}".format(mark, name, tone, note))
|
||||||
|
print("")
|
||||||
|
print(" * = current GEMINI_VOICE. Change it in .env, then warm your lines:")
|
||||||
|
print(' python scripts/warm_voice.py "Welcome to our showroom."')
|
||||||
|
|
||||||
|
voices = list_local_voices()
|
||||||
|
print("\n System voices on this PC (used when MOCK_VOICE_ENGINE=system,")
|
||||||
|
print(" and as the fallback if Gemini fails)")
|
||||||
|
print(" " + "-" * 58)
|
||||||
|
if not voices:
|
||||||
|
if platform.system().lower().startswith("win"):
|
||||||
|
print(" None found.")
|
||||||
|
else:
|
||||||
|
print(" Listing is Windows-only; on macOS the simulator uses `say`,")
|
||||||
|
print(" on Linux `espeak-ng`.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for description, source in voices:
|
||||||
|
marker = "*" if settings.mock.voice and settings.mock.voice.lower() in description.lower() else " "
|
||||||
|
print(" {0} {1:<48} [{2}]".format(marker, description, source))
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(" * = currently selected by MOCK_VOICE={0}".format(settings.mock.voice or "(unset)"))
|
||||||
|
print(" 'onecore' voices are Windows' newer, better-sounding set.")
|
||||||
|
print("")
|
||||||
|
print(" Hear one: python scripts/voices.py --try Zira")
|
||||||
|
print(" Hear all: python scripts/voices.py --demo")
|
||||||
|
print("")
|
||||||
|
print(" Want more voices (including Mandarin, to match the A3's demo language)?")
|
||||||
|
print(" Windows Settings > Time & language > Speech > Manage voices > Add voices.")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
if args.demo:
|
||||||
|
for description, _ in voices:
|
||||||
|
short = description.replace("Microsoft ", "").split(" - ")[0]
|
||||||
|
print(" ->", description)
|
||||||
|
speak_with(description, "{0} speaking. {1}".format(short, args.text),
|
||||||
|
rate, volume, pitch)
|
||||||
|
time.sleep(0.4)
|
||||||
|
print("")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
125
scripts/warm_voice.py
Normal file
125
scripts/warm_voice.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""Pre-synthesise demo lines so the Gemini voice speaks instantly.
|
||||||
|
|
||||||
|
Cloud TTS costs a network round trip - roughly 4 s for a sentence, 8 s for a
|
||||||
|
paragraph. That is fine while rehearsing and painful in front of an audience.
|
||||||
|
This warms the on-disk cache ahead of time, so a warmed line plays with **no
|
||||||
|
delay at all**.
|
||||||
|
|
||||||
|
python scripts/warm_voice.py "Good afternoon, welcome to our showroom."
|
||||||
|
python scripts/warm_voice.py --file demo_lines.txt
|
||||||
|
python scripts/warm_voice.py --stats
|
||||||
|
python scripts/warm_voice.py --clear
|
||||||
|
|
||||||
|
Audio is saved in audio_library/ as ordinary .wav files with readable names, so
|
||||||
|
it survives restarts, plays in any media player, and needs no internet once
|
||||||
|
saved. The server reads the same folder - warm before you present.
|
||||||
|
|
||||||
|
Only relevant when MOCK_VOICE_ENGINE=gemini.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _use_utf8_console() -> None:
|
||||||
|
for stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
if stream is not None and hasattr(stream, "reconfigure"):
|
||||||
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_use_utf8_console()
|
||||||
|
|
||||||
|
from backend.config.settings import get_settings # noqa: E402
|
||||||
|
from backend.robot.gemini_voice import GeminiVoice # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def build(settings) -> GeminiVoice:
|
||||||
|
return GeminiVoice(
|
||||||
|
api_key=settings.mock.gemini_api_key or "",
|
||||||
|
model=settings.mock.gemini_model,
|
||||||
|
voice=settings.mock.gemini_voice,
|
||||||
|
style=settings.mock.gemini_style,
|
||||||
|
chunk_chars=settings.mock.gemini_chunk_chars,
|
||||||
|
fallback=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Pre-synthesise lines for the Gemini voice.")
|
||||||
|
parser.add_argument("lines", nargs="*", help="Lines to warm.")
|
||||||
|
parser.add_argument("--file", help="Text file, one line per utterance.")
|
||||||
|
parser.add_argument("--stats", action="store_true", help="Show library size and exit.")
|
||||||
|
parser.add_argument("--clear", action="store_true", help="Delete all saved audio and exit.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.mock.gemini_api_key:
|
||||||
|
print("\n GEMINI_API_KEY is not set in .env - nothing to warm.\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
voice = build(settings)
|
||||||
|
|
||||||
|
if args.clear:
|
||||||
|
voice.clear_cache()
|
||||||
|
print("\n Cache cleared.\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.stats or not (args.lines or args.file):
|
||||||
|
stats = voice.cache_stats()
|
||||||
|
print("\n Saved audio")
|
||||||
|
print(" " + "-" * 52)
|
||||||
|
print(" Entries : {0}".format(stats["entries"]))
|
||||||
|
print(" Size : {0:.1f} MB".format(stats["bytes"] / 1_048_576))
|
||||||
|
print(" Location: {0}".format(stats["dir"]))
|
||||||
|
print(" Voice : {0} ({1})".format(settings.mock.gemini_voice, settings.mock.gemini_model))
|
||||||
|
if not (args.lines or args.file):
|
||||||
|
print("")
|
||||||
|
print(' Warm a line: python scripts/warm_voice.py "Welcome to our showroom."')
|
||||||
|
print(" Warm a file: python scripts/warm_voice.py --file demo_lines.txt")
|
||||||
|
print("")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = list(args.lines)
|
||||||
|
if args.file:
|
||||||
|
path = Path(args.file)
|
||||||
|
if not path.exists():
|
||||||
|
print("\n No such file: {0}\n".format(path))
|
||||||
|
raise SystemExit(1)
|
||||||
|
lines += [ln.strip() for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||||
|
|
||||||
|
print("\n Warming {0} line(s) with voice '{1}'...".format(len(lines), settings.mock.gemini_voice))
|
||||||
|
print(" " + "-" * 60)
|
||||||
|
total_new = 0
|
||||||
|
started = time.time()
|
||||||
|
for index, line in enumerate(lines, 1):
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
fetched = voice.warm(line)
|
||||||
|
except Exception as exc:
|
||||||
|
print(" {0:>3}. FAILED {1}".format(index, exc))
|
||||||
|
continue
|
||||||
|
total_new += fetched
|
||||||
|
state = "cached already" if fetched == 0 else "fetched {0} chunk(s)".format(fetched)
|
||||||
|
preview = line if len(line) <= 48 else line[:45] + "..."
|
||||||
|
print(" {0:>3}. {1:<50} {2:>16} {3:5.1f}s".format(
|
||||||
|
index, preview, state, time.time() - t0))
|
||||||
|
|
||||||
|
stats = voice.cache_stats()
|
||||||
|
print(" " + "-" * 60)
|
||||||
|
print(" {0} new clip(s) in {1:.1f}s. Library now holds {2} clips ({3:.1f} MB).".format(
|
||||||
|
total_new, time.time() - started, stats["entries"], stats["bytes"] / 1_048_576))
|
||||||
|
print(" These lines will now speak instantly.\n")
|
||||||
|
voice.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
63
start.bat
Normal file
63
start.bat
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
@echo off
|
||||||
|
REM ===========================================================================
|
||||||
|
REM AGIBOT A3 - Voice Control | Windows launcher
|
||||||
|
REM Double-click this file, then open http://localhost:8000
|
||||||
|
REM ===========================================================================
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo AGIBOT A3 - Voice Control
|
||||||
|
echo ----------------------------------------------
|
||||||
|
|
||||||
|
REM --- locate Python ---------------------------------------------------------
|
||||||
|
set "PY=python"
|
||||||
|
where python >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
where py >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Python was not found on this PC.
|
||||||
|
echo Install Python 3.9+ from https://python.org and tick
|
||||||
|
echo "Add python.exe to PATH" during setup.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
set "PY=py -3"
|
||||||
|
)
|
||||||
|
|
||||||
|
REM --- first-run config ------------------------------------------------------
|
||||||
|
if not exist ".env" (
|
||||||
|
echo Creating .env from .env.example ...
|
||||||
|
copy /y ".env.example" ".env" >nul
|
||||||
|
)
|
||||||
|
|
||||||
|
REM --- dependencies ----------------------------------------------------------
|
||||||
|
%PY% -c "import fastapi, uvicorn, dotenv, httpx" >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Installing dependencies ^(first run only^) ...
|
||||||
|
%PY% -m pip install --disable-pip-version-check -q -r requirements.txt
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Dependency installation failed. Run this by hand:
|
||||||
|
echo %PY% -m pip install -r requirements.txt
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
REM --- run -------------------------------------------------------------------
|
||||||
|
echo Starting server ... http://localhost:8000
|
||||||
|
echo Press Ctrl+C to stop.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Open the browser a few seconds late, so the page loads against a server that
|
||||||
|
REM is already listening instead of showing a connection error.
|
||||||
|
start "" /min cmd /c "timeout /t 4 /nobreak >nul & start "" http://localhost:8000"
|
||||||
|
|
||||||
|
%PY% backend\main.py
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Server stopped.
|
||||||
|
pause
|
||||||
|
endlocal
|
||||||
32
start.sh
Normal file
32
start.sh
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ===========================================================================
|
||||||
|
# AGIBOT A3 - Voice Control | Linux / macOS launcher
|
||||||
|
# ./start.sh then open http://localhost:8000
|
||||||
|
# ===========================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " AGIBOT A3 - Voice Control"
|
||||||
|
echo " ----------------------------------------------"
|
||||||
|
|
||||||
|
PY="${PYTHON:-python3}"
|
||||||
|
if ! command -v "$PY" >/dev/null 2>&1; then
|
||||||
|
echo " [ERROR] python3 not found. Install Python 3.9 or newer."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo " Creating .env from .env.example ..."
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! "$PY" -c "import fastapi, uvicorn, dotenv, httpx" >/dev/null 2>&1; then
|
||||||
|
echo " Installing dependencies (first run only) ..."
|
||||||
|
"$PY" -m pip install --disable-pip-version-check -q -r requirements.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Starting server ... open http://localhost:8000"
|
||||||
|
echo " Press Ctrl+C to stop."
|
||||||
|
echo
|
||||||
|
exec "$PY" backend/main.py
|
||||||
Loading…
x
Reference in New Issue
Block a user