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>
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: 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 100–140 Hz, Kore 165–225 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
generationCompletebehaviour. 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)
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_keyis 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 standardAIza...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, plust800— 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
deviceIdexactly, and can be switched mid-session without dropping the connection. - Playback is routed through a
MediaStreamAudioDestinationNodeinto a hidden<audio>element sosetSinkId()can move it to the chosen speaker. - Both lists refresh on
devicechange, and Windows' duplicatedefault/communicationspseudo-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
voicerecorded 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 preferscf-connecting-ipbecause 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:8000from the CloudLinux virtualenv (~/virtualenv/sanadlite/3.8), started byshell_scripts/start_cpanel.sh. - Apache reverse-proxies
https://sanadlite.yslootahtech.comto 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/healthdoes 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.jsonand are not re-exported by the venv'sactivate. 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.pyis 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 → authholds 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 indashboard/routes/auth.pywith a bcrypt hash.- The session cookie is signed with a secret regenerated at every start, so a restart signs everyone out.
live.allow_direct_keyexposes the Gemini API key to any signed-in user.- No API key is committed;
gemini_defaults.api_keyships 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/lastare 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
generationCompletetruncation 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.