"""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: 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. Never returns the full key. Response: { "has_key": true, "masked": "AIza***...kqf8", "length": 39, "source": "config_file" | "default" } """ import Project.Sanad.config as cfg_mod key = getattr(cfg_mod, "GEMINI_API_KEY", "") or "" # Detect where the value came from (persisted override vs module default) try: from Project.Sanad.config import load_config stored = load_config().get("gemini", {}) or {} source = "config_file" if stored.get("api_key") else "default" except Exception: source = "default" return { "has_key": bool(key), "masked": _mask_api_key(key), "length": len(key), "source": source, } @router.post("/api-key") async def 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.voice.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.voice.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." ), }