Sanad_lite/README.md
Kassam Dakhlalah 9b7ef7d939 Device selection, cross-browser Live audio, and a rewritten README
Live Gemini could not use an external speaker or headset. An AudioContext
is bound to whichever output was default when it was created, and
getUserMedia({audio:true}) takes the system default input, so plugging a
device in afterwards left audio going to the old one — silently, with no
error to explain it.

  * Explicit Mic and Speaker pickers. Capture opens the chosen deviceId
    exactly and can be switched mid-session; playback is routed through a
    MediaStreamAudioDestinationNode into a hidden <audio> element so
    setSinkId() can move it to the chosen sink. Both lists refresh on
    devicechange and are remembered in localStorage.
  * Windows reports each device three times (default, communications, and
    the real one), so an Anker would have appeared three times with no way
    to tell them apart. The pseudo-devices are now collapsed.
  * Capture moved to an AudioWorklet (Blob-built, no extra file served)
    with the ScriptProcessor kept as a fallback.
  * A compatibility line reports what the browser actually supports, and
    the diagnostics line now shows the output sink and capture kind — the
    difference between "not listening" and "not speaking" without a
    debugger.

Verified in both engines with Playwright (chromium PASS, firefox PASS: no
page errors, personas and device lists populated, test tone plays) and
against real hardware, where setSinkId matched the selection and a named
microphone opened by deviceId. Two engine-specific bugs fell out of that
run and are fixed here: reading AudioContext.prototype.audioWorklet invokes
the getter and throws in both browsers, which aborted init and left the tab
empty, and the init steps are now isolated so one failure cannot take the
rest down.

Also in this commit:

  * README rewritten against what the code does today — the voice-fidelity
    rationale and its three gates, why words used to cut off, the Live tab
    and why the browser talks to Google directly, personas, device
    selection, recordings search/filter, sign-in history, a current API
    list, the cPanel production setup, and the known limits.
  * shell_scripts/start_cpanel.sh — the launcher that actually keeps the
    site up (HTTP health check, not a TCP probe) was only on the host.
  * data/live_personas.json — the persona library (G1, R1, Agibot, T800)
    existed only on the server; it is user-written content worth keeping.
  * .gitignore covers runtime state (logins, generated WAVs, .env, backups).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 23:33:28 +04:00

448 lines
22 KiB
Markdown
Raw 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.

# Sanad_lite
A small FastAPI dashboard that **speaks in the exact voices of the robots**
Unitree G1, Unitree R1 and Agibot x2 — and lets you hold a **live spoken
conversation** with any of them from a browser.
It is a stripped fork of [Sanad](../Sanad/): the arm, macros, camera, wake
words and on-robot audio are gone. What remains is text → speech, saved
recordings, and a realtime voice tab — with **all audio in the user's own
browser**, never on the host.
Live at <https://sanadlite.yslootahtech.com> (cPanel host, see *Production*).
```
┌──────────────────────────────────────────────────────────────────────┐
│ Dashboard (FastAPI) │
│ ├─ /login Cookie session + sign-in history │
│ ├─ Voice & Audio Typed Replay — type text, hear the robot │
│ ├─ Live Gemini Talk to a robot voice in realtime │
│ ├─ Recordings Saved WAVs: search, filter by robot, play │
│ └─ Settings & Logs Persona/rule files, log tail, sign-in log │
└──────────────────────────────────────────────────────────────────────┘
```
| Robot | Gemini voice | Character |
|---|---|---|
| Unitree G1 | `Charon` | Sanad / Bousandah — male, Emirati |
| Unitree R1 | `Puck` | Super Dubai — male, Emirati |
| Agibot x2 | `Kore` | Muza — female, Emirati |
## The one thing this project is about
The site must sound **identical** to the robots. Sanadv3 (G1), SanadR1 (R1)
and the Agibot stack each open a Gemini **Live** session and speak the reply
audio. If the dashboard renders the same sentence through a different engine —
even one that offers the same voice *name* — it does not sound like the robot.
Measured: the REST TTS model's "Charon" sits roughly **50 Hz above** the Live
one.
So typed replay uses the robots' own path and refuses to quietly downgrade:
```
Gemini Live (same model, voice and prompt as the robot) ← up to 6 attempts
└─ each take is verified before it is served
REST TTS (gemini-2.5-flash-preview-tts) ← only if Live
returned NO audio
```
Every Live take is checked three ways before it is accepted
(`voice/typed_replay.py`):
| Gate | What it catches | Default |
|---|---|---|
| **Coverage** — output transcript vs the text you typed | a take that stops mid-sentence | `1.0` (all of it) |
| **Believable duration** — chars ÷ seconds | audio too short to contain the words | `14` chars/s Arabic, `20` Latin |
| **Pitch band** — median F0 of the take | the wrong voice / wrong engine | Charon & Puck 100140 Hz, Kore 165225 Hz |
A take that fails is retried. If no attempt passes, the **fullest Live take**
is served — the robot's voice with a missing word beats a clean read in the
wrong voice — and the UI shows a warning naming the gate that failed. If a take
is complete but ends early, up to 3 **continuations** are requested and joined.
### Why words used to cut off
The Live socket sends `generationComplete` *before* the last audio frames have
arrived; `turnComplete` is the real end of turn. Breaking on the first one
truncated tails and left stale frames in the socket for the next request to
pick up. `gemini/client.py` now breaks only on `turnComplete`, keeps a **3.0 s
post-generation grace** window, and drains the socket before closing.
> The robots (Sanadv3 / SanadR1 / Sanad_Package_4) still carry the original
> `generationComplete` behaviour. Porting this fix to them has not been done.
### Why every replay opens a fresh session
`fresh_session_per_replay: true` in `config/voice_config.json`. A warm session
carries conversational context and the model drifts — same text, same voice,
different tone and a weaker Emirati accent. One session per replay removed the
drift. Voices that should keep a warm session can be listed in
`warm_session_voices`.
`voice/pitch.py` is a dependency-free median-F0 estimator (autocorrelation over
48 frames, decimated to 8 kHz) — there is no numpy on the cPanel host. It was
validated to within ~4 Hz of a numpy reference, ~48 ms worst case.
## Quick start (laptop)
```bash
pip install --user fastapi 'uvicorn[standard]' itsdangerous python-multipart \
pydantic websockets
cd Project/Other/Sanad_lite
SANAD_DASHBOARD_HOST=127.0.0.1 python3 main.py
```
Open <http://127.0.0.1:8000>. Credentials live in `config/core_config.json`
under `auth` — see *Security* below.
Omit `SANAD_DASHBOARD_HOST` to auto-bind to `wlan0`'s IP so colleagues on the
LAN can reach `http://<your-ip>:8000`.
**A Gemini API key is required and none ships with the repo.**
`gemini_defaults.api_key` in `config/core_config.json` is deliberately empty.
Supply one by:
- pasting it in **Voice & Audio → Gemini API Key** (hot-swap, no restart), or
- `export SANAD_GEMINI_API_KEY=...` before starting, or
- filling `gemini_defaults.api_key`.
Get one at <https://aistudio.google.com/apikey>. Keys beginning `AQ.` are AI
Studio express keys — they work, with one consequence for the Live tab (below).
`pyaudio`, `torch` and `transformers` appear in `requirements.txt` but are
**not needed**; they are leftovers from full Sanad and their import failures
are swallowed at startup.
## Where the audio actually plays
Two different paths, and the distinction matters:
| Feature | Audio generated by | Audio played by |
|---|---|---|
| Typed Replay → Generate & Play | server (Gemini Live → WAV) | the viewer's browser |
| Typed Replay → Replay Last | cached WAV on the server | the viewer's browser |
| Recordings → Play / Raw / Download | server file | the viewer's browser |
| **Live Gemini** | **Google, direct to the browser** | the viewer's browser |
Server-side ALSA/PulseAudio is never touched. If you host on machine **A** and
a colleague opens `http://A:8000` from machine **B**, the sound comes out of
**B's** speakers.
## Live Gemini tab
Pick a robot, press **Connect**, and talk. Speech goes to Gemini and the reply
comes back in that robot's voice, in realtime — the same interaction as Sanad
Package 5 on the G1, but in a browser.
### Why the browser talks to Google directly
This deployment sits behind an Apache `[P]` rewrite that **cannot upgrade a
WebSocket** — measured: the same handshake answers `101` straight to uvicorn
and `404` through the proxy. A server-side relay is therefore impossible here,
so the page opens its own socket to Gemini.
The intended way to do that is an **ephemeral auth token** minted by
`/api/live/token`, so the real key never reaches the page. That path is
implemented (`v1beta` + `access_token`) but Google **refuses tokens minted from
`AQ.` express keys** — verified in all four documented forms. With such a key,
set `live.allow_direct_key: true` in `config/voice_config.json` and the server
hands the key itself to the browser (`v1alpha` + `key`).
> **While `allow_direct_key` is on, anyone who can sign in to the dashboard can
> read the Gemini API key.** It is on in this deployment, deliberately. Turn it
> off the moment the key becomes a standard `AIza...` key.
### Personas
A persona library, not a single editable prompt:
- 3 built-ins in code (`dashboard/routes/live_personas.py`) — one per robot.
- Any number of saved personas in `data/live_personas.json`: **Save**, **Save
as new**, **Delete**, and **Use for this robot** to bind one to a voice.
- The active persona is shown above the picker and persists across restarts.
- Shipped saved personas: `unitree-g1`, `unitree-r1`, `agibot-x2`, plus `t800`
— an English-speaking persona written for the G1 voice.
### Microphone and speaker selection
Live audio broke on external speakers and headsets for a structural reason: an
`AudioContext` is bound to whichever output was default when it was created,
and `getUserMedia({audio:true})` takes the system default input. Plug a device
in afterwards and audio keeps going to the old one — silently, with no error.
The tab now has explicit **Mic** and **Speaker** pickers:
- Capture opens the chosen `deviceId` exactly, and can be switched mid-session
without dropping the connection.
- Playback is routed through a `MediaStreamAudioDestinationNode` into a hidden
`<audio>` element so `setSinkId()` can move it to the chosen speaker.
- Both lists refresh on `devicechange`, and Windows' duplicate `default` /
`communications` pseudo-devices are collapsed so each device appears once.
- **Allow microphone** re-requests permission if it was dismissed or denied;
**Test speaker** plays a tone so output is proven before you spend credits.
- Choices are remembered in `localStorage`.
Capture uses an **AudioWorklet** (off the main thread; built from a Blob, so no
extra file has to be served) and falls back to `ScriptProcessorNode` where it
is unavailable. A compatibility line under the controls reports what the
current browser actually supports, and a diagnostics line shows
`sent / peak / received / played / mic ctx / out ctx / out sink / capture kind`
— enough to tell "not listening" from "not speaking" without a debugger.
### Browser support
Verified end to end with Playwright in both engines (`chromium` and `firefox`):
tab renders, 3 robots, model loaded, persona library populated, device lists
populated, test tone plays, **no page errors**. Also verified against real
hardware: `setSinkId` matched the selection, and a named microphone opened by
`deviceId`.
Caveats that are not code bugs:
- Bluetooth speakerphones switch from A2DP to HFP when a mic opens; the device
may briefly disappear and return as a different endpoint. Prefer USB.
- Browser echo cancellation is weakest when output is routed to a non-default
sink. A speakerphone with hardware AEC (e.g. Anker PowerConf) handles it; a
plain speaker may echo — lower the volume or use the system default output.
## Recordings
Saved WAVs from **Save Last**, with:
- a **search box** matching name *or* the original text. Arabic is folded
before matching (diacritics stripped, alef forms unified), so a search for
`سلام` also matches `السلام` and `سَلام`;
- a **filter by robot** — G1 / R1 / Agibot — built from the `voice` recorded
with each entry;
- a count line (`Showing N of M`), **Clear**, and per-record Play / Raw /
Download / Rename / Delete, plus **Delete All**.
Each record stores `voice`, `voice_label`, `pitch_hz` and any `voice_warning`
from the gates, so an old file can be traced back to how it was produced.
## Settings & Logs
- Persona and rule script editor (`scripts/*.txt`).
- Live log tail. The WebSocket (`/ws/logs`) cannot cross the production proxy,
so the UI falls back to cursor-based polling of `/api/logs/live`.
- **Sign-in history** — who signed in, when, from which device and IP
(`data/logins.json`; the device string is parsed from the user agent, and the
IP prefers `cf-connecting-ip` because the site is behind Cloudflare).
Exportable as **CSV** (UTF-8 with BOM, so Excel renders Arabic) or **JSON**.
## Configuration
### Environment variables
| Var | Default | Effect |
|---|---|---|
| `SANAD_DASHBOARD_HOST` | `wlan0`'s IP | Bind address. `127.0.0.1` for localhost only. |
| `SANAD_DASHBOARD_INTERFACE` | `wlan0` | Which interface to take the IP from. |
| `SANAD_GEMINI_API_KEY` | *(empty)* | Gemini API key. |
| `PORT` | `8000` | Listen port. |
### Keys worth knowing
`config/core_config.json → gemini_defaults`
| Key | Meaning |
|---|---|
| `model_live` | Live model — `gemini-2.5-flash-native-audio-preview-12-2025`. |
| `voice_name` | Default voice (`Charon`). |
| `voice_options` | The robot ↔ voice table used by both tabs. |
| `default_system_prompt` | Emirati persona used for TTS. |
| `voice_system_prompts` | Per-voice overrides. **Charon and Puck are deliberately absent** so they keep the robots' verbatim prompt — that is what makes the site match Sanadv3/SanadR1. |
| `auth` | Dashboard username/password (cleartext). |
`config/voice_config.json`
| Key | Meaning |
|---|---|
| `typed_replay.fresh_session_per_replay` | One Live session per replay (tone stability). |
| `typed_replay.warm_session_voices` | Voices exempt from that. |
| `live.allow_direct_key` | Hand the API key to the browser for the Live tab. |
`config/gemini_config.json → client`
| Key | Meaning |
|---|---|
| `post_generation_grace_sec` | Wait after `generationComplete` for trailing audio (**3.0** — 1.2 was measurably too short and the cuts came back). |
| `output_transcription` | Ask for the spoken transcript, which the coverage gate needs. |
| `tts_preamble`, `tts_language_code` | Emirati steering for the REST fallback (`ar-AE`). |
Gate thresholds (`live_attempts`, `min_spoken_coverage`, `max_chars_per_sec_ar`,
`max_chars_per_sec_latin`, `max_continuations`, `pitch_gate`, `pitch_bands`,
`short_text_chars`) all live under `voice.typed_replay` and fall back to the
defaults in `voice/typed_replay.py`.
## API
```
GET / dashboard SPA (auth-gated, no-store)
GET /login login page
POST /api/auth/login {username,password} -> session cookie
POST /api/auth/logout
GET /api/auth/me
GET /api/auth/logins?limit=20 sign-in history
GET /api/auth/logins/export?format=csv|json
GET /api/health {status, brain}
GET /api/status brain + voice
GET /api/system/info host / interfaces / subsystems
GET /api/voice/status Gemini connection state
POST /api/voice/connect | /disconnect
GET /api/voice/api-key masked
POST /api/voice/api-key {key} -> persisted, hot-swapped
GET /api/voice/voices robot <-> voice table
POST /api/voice/voice {voice} -> switch (reconnects)
POST /api/voice/generate legacy offline TTS route
POST /api/typed-replay/say {text,record,record_name}
GET /api/typed-replay/audio/last streams the cached WAV
POST /api/typed-replay/replay-last
POST /api/typed-replay/save-last
GET /api/typed-replay/status engine, session, voice, pitch, warning
GET /api/typed-replay/records
GET /api/typed-replay/records/{name}
POST /api/typed-replay/records/{name}/rename
DELETE /api/typed-replay/records/{name}
GET /api/records/ saved records + voice metadata
GET /api/records/audio/{name}?kind=speaker|raw
POST /api/records/rename | /delete | /delete-all
GET /api/live/config voices, model, active personas
GET /api/live/personas
POST /api/live/personas save / save-as-new
POST /api/live/personas/select bind a persona to a voice
POST /api/live/personas/delete
POST /api/live/token {voice} -> token + api_version + auth_param
GET /api/scripts/ persona/rule files
POST /api/scripts/load | /save | /create | /rename | /delete
GET /api/prompt/ resolved system prompt
POST /api/prompt/update | /reload
GET /api/prompt/rule the Voice Rule text
POST /api/prompt/rule
GET /api/logs/ available logs
GET /api/logs/live?cursor=&limit= polling tail (proxy-safe)
GET /api/logs/tail/{filename}?lines=
POST /api/logs/snapshot
GET /api/logs/bundle zip of all logs
GET /api/audio/* server-side device info (informational)
WS /ws/logs live log stream (LAN only — see above)
```
## Layout
| Path | Contents |
|---|---|
| `main.py` | Entry point — boots subsystems, then the dashboard. |
| `config.py` | Runtime constants derived from `config/*.json`. |
| `config/` | `core`, `voice`, `gemini`, `dashboard` JSON. |
| `core/` | Brain (callback whitelist + status), skill registry, event bus, config loader, logger, asyncio compat. |
| `gemini/client.py` | Gemini Live WebSocket client + REST TTS fallback + quota detection. |
| `voice/typed_replay.py` | Generation, the three gates, continuations, records. |
| `voice/pitch.py` | Dependency-free median-F0 estimator. |
| `voice/` (rest) | `audio_manager.py`, `audio_devices.py`, `local_tts.py`, `text_utils.py` — vestigial on this deployment, degrade silently. |
| `dashboard/app.py` | FastAPI app, session middleware, auth gate, route registry, cache headers. |
| `dashboard/routes/` | `auth`, `health`, `system`, `voice`, `logs`, `audio_control`, `scripts`, `records`, `prompt`, `typed_replay`, `live`, `live_personas`. |
| `dashboard/static/` | `index.html` (SPA), `live.js` (browser Gemini client), `login.html`. |
| `dashboard/websockets/log_stream.py` | Log ring buffer + WS endpoint + `recent_since()` for polling. |
| `scripts/` | `sanad_script.txt` (persona), `sanad_rule.txt` (voice rule). |
| `data/audio/` | Saved WAVs + `records.json`. |
| `data/live_personas.json` | Persona library. |
| `data/logins.json` | Sign-in history (runtime, gitignored). |
| `shell_scripts/start_cpanel.sh` | Production launcher / self-heal (below). |
| `tests/test_smoke.py` | Brain whitelist, skill registry, atomic IO, audio devices, isolation. |
## Production (cPanel)
The host cannot run a normal service manager, so:
- **uvicorn** runs on `127.0.0.1:8000` from the CloudLinux virtualenv
(`~/virtualenv/sanadlite/3.8`), started by `shell_scripts/start_cpanel.sh`.
- **Apache** reverse-proxies `https://sanadlite.yslootahtech.com` to it. A `[P]`
rewrite cannot upgrade WebSockets — hence log polling and the browser-direct
Live socket.
- **cron every 2 minutes** re-runs the launcher. It is idempotent and does a
*real HTTP health check*, not a TCP probe: a wedged uvicorn with a frozen
asyncio loop still holds the port and answers a TCP connect, so a
connect-only check once reported "up" while the site was down for ~2 days. If
the port is bound but `/api/health` does not answer, the frozen process is
killed and relaunched.
- **Environment variables** set in cPanel's *Setup Python App* form are stored
in `~/.cl.selector/python-selector.json` and are **not** re-exported by the
venv's `activate`. The launcher parses and exports them itself — that is the
store the Gemini API key must go into for a restart to keep it.
- `passenger_wsgi.py` is the alternative Passenger/WSGI entry point. It works,
but WSGI has no WebSockets at all; the uvicorn path above is what runs.
Deployment is `scp` of changed files plus a launcher restart. Static assets are
served with a 7-day `max-age` and the page itself with `no-store`, so
`index.html` references `live.js?v=<mtime>` — **bump that query string when you
change a static file**, or browsers will keep the old one.
## Security
- `config/core_config.json → auth` holds the dashboard username and password in
**cleartext**, and that file is committed. Change both before any deployment
you do not control, and consider replacing the check in
`dashboard/routes/auth.py` with a bcrypt hash.
- The session cookie is signed with a secret regenerated at every start, so a
restart signs everyone out.
- `live.allow_direct_key` exposes the Gemini API key to any signed-in user.
- No API key is committed; `gemini_defaults.api_key` ships empty.
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Replay sounds like a different voice | The REST fallback ran — Live returned no audio at all. Check `logs/` for `served REST TTS`. Usually quota, or a very short input. |
| "NOT the robot voice — … Hz outside …" warning | The pitch gate caught an off-voice take. Generate again; if it persists, the Live path is failing and everything is coming from REST. |
| Words cut off at the end | Should be fixed (`post_generation_grace_sec: 3.0`). If it returns, raise that value before touching anything else. |
| Tone/accent drifts between generations | A warm session leaked context — confirm `fresh_session_per_replay` is `true` and the voice is not in `warm_session_voices`. |
| Everything 503s with a quota message | `GeminiQuotaExhausted` — the key is out of credits. It fails fast and says so rather than retrying six times. |
| Live tab connects but never replies | Read the diagnostics line. `peak` near 0 = the mic is not being heard (wrong input device, or muted). `received` climbing but `played` flat = output routing. |
| Live tab: token minted but connect fails | An `AQ.` key with `allow_direct_key: false`. Ephemeral tokens do not work for those keys. |
| A UI fix "did not deploy" | Browser cache. Bump the `?v=` on the changed asset in `index.html`. |
| Arabic in the CSV export looks like mojibake | Open it through Excel's import dialog as UTF-8; the file already carries a BOM. |
| `ModuleNotFoundError: itsdangerous` / `websockets` | `pip install itsdangerous websockets` — required by SessionMiddleware and `gemini/client.py`. |
| `Failed to construct audio_mgr — pyaudio not installed` | Harmless. Nothing user-facing needs PyAudio. |
| Redirected to `/login` on every call | The server restarted and the cookie secret changed. Sign in again. |
## Known limits
- **Accent quality is not measured.** Pitch and coverage are verified
automatically; how Emirati a take *sounds* is not. That needs a blind
listening test.
- **Typed replay is single-user.** The session, the selected voice and
`audio/last` are process globals — two people generating at once will
interfere.
- **Very short inputs** (a single word) can still fall through to the REST
engine, because the Live model sometimes returns text instead of audio for
them.
- The `generationComplete` truncation fix has **not** been ported to the robots.
## Attribution
Internal project for **YS Lootah Robotics**. Trimmed from Sanad; the original
reuses patterns from `SanadVoice/gemini_interact` and Unitree `unitree_sdk2py`.