238 lines
8.2 KiB
Python
238 lines
8.2 KiB
Python
"""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.
|
|
|
|
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.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."
|
|
),
|
|
}
|