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
2026-05-13 14:41:51 +04:00
2026-05-13 11:30:29 +04:00
2026-05-13 11:30:29 +04:00
2026-07-04 23:29:23 +04:00
2026-05-13 14:41:51 +04:00
2026-05-13 14:41:51 +04:00

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

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.

Description
No description provided
Readme
Languages
Python 77.9%
HTML 12.3%
JavaScript 7.1%
Shell 2.7%