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

393 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).