"""Voice endpoints — Gemini interaction, local TTS, prompt management.""" from __future__ import annotations import asyncio from fastapi import APIRouter, HTTPException from pydantic import BaseModel from Project.Sanad.core.config_loader import section as _cfg_section from Project.Sanad.core.logger import get_logger log = get_logger("voice_route") router = APIRouter() _VR = _cfg_section("dashboard", "voice_route") _API = _cfg_section("dashboard", "api_input") # MAX_TEXT_LEN — SINGLE SOURCE in dashboard.api_input MAX_TEXT_LEN = _API.get("max_text_len", 2000) _API_KEY_MASK_VISIBLE = _VR.get("api_key_mask_visible", 4) def _mask_api_key(key: str) -> str: """Mask an API key for display — keeps 4 chars on each end. Examples: "" → "" "AIza123" → "*******" (≤8 chars = full mask) "AIzaSy...kqf8" → "AIza***...kqf8" (>8 chars = partial mask) """ if not key: return "" if len(key) <= 8: return "*" * len(key) return f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}" class TextPayload(BaseModel): text: str engine: str = "gemini" # "gemini" | "local" @router.get("/status") async def voice_status(): from Project.Sanad.main import voice_client, local_tts return { "gemini": voice_client.status() if voice_client else {}, "local_tts": local_tts.status() if local_tts else {}, } @router.post("/generate") async def generate_speech(payload: TextPayload): """Generate speech from text using Gemini or local TTS.""" if not payload.text.strip(): raise HTTPException(400, "Text cannot be empty.") if len(payload.text) > MAX_TEXT_LEN: raise HTTPException(413, f"Text too long (max {MAX_TEXT_LEN} chars).") from Project.Sanad.main import voice_client, local_tts, audio_mgr if payload.engine == "local": if local_tts is None: raise HTTPException(503, "Local TTS not available.") pcm = await asyncio.to_thread(local_tts.synthesize, payload.text) if audio_mgr: await asyncio.to_thread(audio_mgr.play_pcm, pcm, 1, 16000, 2) return { "ok": True, "engine": "local", "duration_sec": round(len(pcm) / (16000 * 2), 3), } else: if voice_client is None: raise HTTPException(503, "Voice client not initialized.") if not voice_client.connected: try: await voice_client.connect() except Exception: log.exception("Gemini reconnect failed in /generate") raise HTTPException(503, "Gemini not connected and reconnect failed.") # Check session ownership — TypedReplay or live loop may hold it if voice_client.session_owner is not None: raise HTTPException( 409, f"Voice session busy (owned by {voice_client.session_owner})", ) try: audio_bytes, text_parts = await voice_client.send_text( payload.text, owner="voice_route" ) except RuntimeError as exc: raise HTTPException(503, str(exc)) except Exception as exc: raise HTTPException(502, f"Gemini communication error: {exc}") if audio_bytes and audio_mgr: await asyncio.to_thread(audio_mgr.play_pcm, audio_bytes, 1, 24000, 2) return { "ok": True, "engine": "gemini", "has_audio": bool(audio_bytes), "text_response": text_parts, } @router.post("/connect") async def connect_gemini(): from Project.Sanad.main import voice_client if voice_client is None: raise HTTPException(503, "Voice client not initialized.") try: await voice_client.connect() except Exception as exc: raise HTTPException(502, f"Gemini connection failed: {exc}") return {"connected": voice_client.connected} @router.post("/disconnect") async def disconnect_gemini(): from Project.Sanad.main import voice_client if voice_client: await voice_client.disconnect() return {"connected": False} # ─────────────────────── Gemini API key management ─────────────────────── class ApiKeyPayload(BaseModel): api_key: str @router.get("/api-key") async def get_api_key(): """Return the current Gemini API key in masked form. The key now comes EXCLUSIVELY from the `api` env var (sourced from cPanel "Setup Python App → Environment variables"). This endpoint is read-only. """ import Project.Sanad.config as cfg_mod key = getattr(cfg_mod, "GEMINI_API_KEY", "") or "" return { "has_key": bool(key), "masked": _mask_api_key(key), "length": len(key), "source": "env:api", "read_only": True, } @router.post("/api-key") async def update_api_key(payload: ApiKeyPayload): """API key updates via dashboard are DISABLED. Source of truth is the cPanel "Setup Python App → Environment variables" form (env var `api`). To rotate the key, edit that form and restart uvicorn. The dashboard intentionally cannot bypass this. """ raise HTTPException( 403, "API key updates are disabled. Edit it in cPanel → Setup Python App " "→ Environment variables → 'api', then restart uvicorn." ) # Original update logic kept below for reference but no longer reachable. async def _legacy_update_api_key(payload: ApiKeyPayload): """Update the Gemini API key — persists to data/motions/config.json and hot-swaps the in-memory value so the next Gemini connect uses it. Also disconnects any currently-connected Gemini session so that the next reconnect picks up the new key cleanly. Returns the NEW masked key + a flag telling the dashboard to trigger a reconnect. """ key = payload.api_key.strip() if not key: raise HTTPException(400, "API key cannot be empty.") if len(key) < 20: raise HTTPException(400, "API key looks too short.") if not key.startswith("AIza"): raise HTTPException( 400, "Gemini API keys normally start with 'AIza'. " "Double-check you're pasting a Google AI Studio key.", ) # Persist to data/motions/config.json (atomic temp-then-replace) try: from Project.Sanad.config import load_config, save_config cfg = load_config() or {} gemini_cfg = cfg.get("gemini") if isinstance(cfg.get("gemini"), dict) else {} gemini_cfg["api_key"] = key cfg["gemini"] = gemini_cfg save_config(cfg) except Exception as exc: log.exception("Failed to persist API key to config.json") raise HTTPException(500, f"Could not save config: {exc}") # Hot-swap the in-memory module globals. # Both Project.Sanad.config AND Project.Sanad.gemini.client # have their OWN reference to GEMINI_API_KEY (the latter was created # at `from Project.Sanad.config import GEMINI_API_KEY` at import time). # Python's `from X import Y` binds a local name — updating config.Y # alone does NOT propagate to the importer, so we must patch both. try: import Project.Sanad.config as _cfg_mod _cfg_mod.GEMINI_API_KEY = key except Exception: log.exception("could not patch config.GEMINI_API_KEY") try: import Project.Sanad.gemini.client as _gc _gc.GEMINI_API_KEY = key except Exception: log.exception("could not patch gemini.client.GEMINI_API_KEY") # Disconnect any live session so reconnect uses the new key. from Project.Sanad.main import voice_client was_connected = False if voice_client is not None: was_connected = bool(getattr(voice_client, "connected", False)) if was_connected: try: await voice_client.disconnect() except Exception: log.exception("disconnect during api-key swap failed") log.info("Gemini API key updated (length=%d) source=config_file", len(key)) return { "ok": True, "masked": _mask_api_key(key), "length": len(key), "source": "config_file", "was_connected": was_connected, "message": ( "API key saved. Click 'Connect' to reopen the Gemini session with " "the new key. Any running Live Gemini subprocess must be restarted " "separately (Stop → Start) to pick up the new key." ), } # ─────────────────────── Gemini voice (robot) selection ─────────────────────── # Friendly robot labels mapped to Gemini prebuilt voice names. Source of truth # for the dashboard voice picker — each robot maps to one Gemini voice. # Optionally overridable from core_config.json → gemini_defaults.voice_options # (a list of {"voice","label"} dicts) without touching code. _DEFAULT_VOICE_OPTIONS = [ {"voice": "Charon", "label": "Unitree G1"}, {"voice": "Puck", "label": "Unitree R1"}, {"voice": "Kore", "label": "Agibot x2"}, ] def _load_voice_options() -> list[dict]: raw = _cfg_section("core", "gemini_defaults").get("voice_options") if isinstance(raw, list) and raw and all( isinstance(o, dict) and o.get("voice") and o.get("label") for o in raw ): return [{"voice": str(o["voice"]), "label": str(o["label"])} for o in raw] return list(_DEFAULT_VOICE_OPTIONS) VOICE_OPTIONS = _load_voice_options() _VOICE_BY_NAME = {o["voice"]: o for o in VOICE_OPTIONS} def _current_voice() -> str: """The voice the Gemini client will actually use on its next connect().""" try: import Project.Sanad.gemini.client as _gc return getattr(_gc, "GEMINI_VOICE", "") or "" except Exception: return "" class VoicePayload(BaseModel): voice: str @router.get("/voices") async def list_voices(): """List selectable Gemini voices with robot labels + which one is active.""" cur = _current_voice() cur_opt = _VOICE_BY_NAME.get(cur) return { "current": cur, "current_label": cur_opt["label"] if cur_opt else cur, "options": [ {"voice": o["voice"], "label": o["label"], "active": o["voice"] == cur} for o in VOICE_OPTIONS ], } @router.post("/voice") async def set_voice(payload: VoicePayload): """Switch the Gemini voice, persist it, and apply on the next connect. Mirrors the api-key hot-swap: persist to data/motions/config.json (gemini.voice) so it survives a restart, patch the in-memory GEMINI_VOICE in BOTH config and the gemini client module (each holds its own binding from `from ... import GEMINI_VOICE`), then disconnect any idle Gemini session so the next connect() (e.g. the next Typed Replay) opens with it. """ voice = (payload.voice or "").strip() if voice not in _VOICE_BY_NAME: raise HTTPException( 400, "Unknown voice %r. Allowed: %s" % (voice, ", ".join(o["voice"] for o in VOICE_OPTIONS)), ) # 1. Persist (atomic temp-then-replace) to data/motions/config.json. # Run the blocking file I/O off the event-loop thread. try: from Project.Sanad.config import load_config, save_config cfg = (await asyncio.to_thread(load_config)) or {} gemini_cfg = cfg.get("gemini") if isinstance(cfg.get("gemini"), dict) else {} gemini_cfg["voice"] = voice cfg["gemini"] = gemini_cfg await asyncio.to_thread(save_config, cfg) except Exception as exc: log.exception("Failed to persist voice to config.json") raise HTTPException(500, f"Could not save voice: {exc}") # 2. Hot-swap the in-memory globals in both modules that hold GEMINI_VOICE. try: import Project.Sanad.config as _cfg_mod _cfg_mod.GEMINI_VOICE = voice except Exception: log.exception("could not patch config.GEMINI_VOICE") try: import Project.Sanad.gemini.client as _gc _gc.GEMINI_VOICE = voice except Exception: log.exception("could not patch gemini.client.GEMINI_VOICE") # 3. Reopen the Gemini session so the new voice actually takes effect. The # voice is baked into connect()'s setup handshake and the persistent # session is only rebuilt when connected is False, so we must drop it — # otherwise the next Generate & Play reuses the still-open OLD-voice # session (voice never changes). Acquire the session lock first so we # WAIT for any in-flight generation to finish (it keeps the old voice, # which is fine) instead of cutting it off mid-stream; then disconnect. # Bound the wait so a stuck turn can't hang this request — the persisted # + patched voice still applies on the next reconnect either way. from Project.Sanad.main import voice_client was_connected = False if voice_client is not None: was_connected = bool(getattr(voice_client, "connected", False)) if was_connected: async def _reset_session(): async with voice_client.acquire_session("voice_switch"): await voice_client.disconnect() try: await asyncio.wait_for(_reset_session(), timeout=45) except asyncio.TimeoutError: log.warning("voice swap: session busy too long; new voice applies on next reconnect") except Exception: # The polite path can fail for reasons that have nothing to do # with the socket — notably the session lock being bound to a # different event loop than the request runs on (py3.8 binds # asyncio.Lock at construction, and voice_client is built at # import time, before uvicorn's loop exists), which raises # "got Future attached to a different loop" the moment the lock # is contended. Swallowing that left the OLD-voice socket open, # so the next Generate & Play spoke in the PREVIOUS robot's # voice. Drop the socket unconditionally instead — worst case a # concurrent generation reconnects. log.warning("voice swap: graceful session reset failed — " "forcing disconnect so the new voice applies", exc_info=True) try: await voice_client.disconnect() except Exception: log.exception("voice swap: forced disconnect failed too") label = _VOICE_BY_NAME[voice]["label"] log.info("Gemini voice switched to %s (%s)", voice, label) return { "ok": True, "voice": voice, "label": label, "was_connected": was_connected, "message": f"Voice set to {label} ({voice}) — applies on the next Generate & Play.", }