"""Live Gemini Subprocess control endpoints.""" from __future__ import annotations import asyncio from fastapi import APIRouter, HTTPException, Query from Project.Sanad.config import BASE_DIR from Project.Sanad.vision import recognition_state router = APIRouter() _STATE_PATH = BASE_DIR / "data" / ".recognition_state.json" def _sub_or_503(): from Project.Sanad.main import live_sub if live_sub is None: raise HTTPException(503, "Live subprocess not available") return live_sub @router.get("/status") async def subprocess_status(): from Project.Sanad.main import live_sub # record_enabled is a live flag (recognition_state) the panel toggle drives; # surface it so the UI shows the current state even before a session starts. rec = bool(recognition_state.read(_STATE_PATH).record_enabled) if live_sub is None: return {"available": False, "state": "unavailable", "record_enabled": rec} return {**live_sub.status(), "record_enabled": rec} @router.post("/record") async def set_record(on: bool = Query(...)): """Toggle auto-recording of conversation turns to data/recordings/. Takes effect live (the voice child syncs its recorder) — no session restart.""" st = await asyncio.to_thread( recognition_state.mutate, _STATE_PATH, record_enabled=bool(on)) return {"ok": True, "record_enabled": st.record_enabled} _VOICE_OPTS_PATH = BASE_DIR / "data" / ".voice_options.json" def _read_voice_opts() -> dict: import json try: with open(_VOICE_OPTS_PATH, encoding="utf-8") as f: d = json.load(f) return d if isinstance(d, dict) else {} except Exception: return {} @router.get("/interrupt-option") async def get_interrupt_option(): """Voice interrupt (barge-in) — GLOBAL, all speakers. OFF: Sanad always finishes his sentence on every profile (chest/Anker/JBL/Beats Pill — mic fully gated while he speaks). ON: loud sustained speech cuts him. Resolution: dashboard toggle (data/.voice_options.json) > SANAD_EXTERNAL_BARGE_IN env > voice_config.json barge_in.external_barge_in > off.""" import os opts = _read_voice_opts() if "external_barge_in" in opts: return {"enabled": bool(opts["external_barge_in"]), "source": "dashboard"} env = os.environ.get("SANAD_EXTERNAL_BARGE_IN", "").strip().lower() if env in ("1", "true", "yes"): return {"enabled": True, "source": "env"} try: from Project.Sanad.core.config_loader import section if bool((section("voice", "barge_in") or {}).get("external_barge_in", False)): return {"enabled": True, "source": "config"} except Exception: pass return {"enabled": False, "source": "default"} @router.post("/interrupt-option") async def set_interrupt_option(on: bool = Query(...)): """Persist the interrupt option (survives rebuilds — data/ is mounted) and restart the live session so the voice child picks it up.""" import json import tempfile import os as _os opts = _read_voice_opts() opts["external_barge_in"] = bool(on) _VOICE_OPTS_PATH.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=str(_VOICE_OPTS_PATH.parent), prefix=".voice_options.", suffix=".tmp") with _os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(opts, f, indent=2) _os.replace(tmp, _VOICE_OPTS_PATH) restarted = False from Project.Sanad.main import live_sub if live_sub is not None: try: if (live_sub.status() or {}).get("running"): await asyncio.to_thread(live_sub.stop) await asyncio.to_thread(live_sub.start) restarted = True except Exception: pass return {"ok": True, "enabled": bool(on), "restarted": restarted} @router.get("/direct-answer-option") async def get_direct_answer_option(): """'Direct answer' = Gemini replies as soon as the speaker pauses (Live-API end-of-speech sensitivity HIGH). Resolution: dashboard > env > config.""" import os opts = _read_voice_opts() if "direct_answer" in opts: return {"enabled": bool(opts["direct_answer"]), "source": "dashboard"} if os.environ.get("SANAD_DIRECT_ANSWER", "").strip().lower() in ("1", "true", "yes"): return {"enabled": True, "source": "env"} try: from Project.Sanad.core.config_loader import section if bool((section("voice", "vad") or {}).get("direct_answer", False)): return {"enabled": True, "source": "config"} except Exception: pass return {"enabled": False, "source": "default"} @router.post("/direct-answer-option") async def set_direct_answer_option(on: bool = Query(...)): """Persist the direct-answer option and restart the live session.""" import json import tempfile import os as _os opts = _read_voice_opts() opts["direct_answer"] = bool(on) _VOICE_OPTS_PATH.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=str(_VOICE_OPTS_PATH.parent), prefix=".voice_options.", suffix=".tmp") with _os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(opts, f, indent=2) _os.replace(tmp, _VOICE_OPTS_PATH) restarted = False from Project.Sanad.main import live_sub if live_sub is not None: try: if (live_sub.status() or {}).get("running"): await asyncio.to_thread(live_sub.stop) await asyncio.to_thread(live_sub.start) restarted = True except Exception: pass return {"ok": True, "enabled": bool(on), "restarted": restarted} @router.get("/pure-voice-option") async def get_pure_voice_option(): """PURE VOICE = the live session built exactly like standalone Sanadv1: no tools, no state injections, persona only, no lip-sync markers — the fastest conversation pipe. Resolution: dashboard > env > config.""" import os opts = _read_voice_opts() if "pure_voice" in opts: return {"enabled": bool(opts["pure_voice"]), "source": "dashboard"} if os.environ.get("SANAD_PURE_VOICE", "").strip().lower() in ("1", "true", "yes"): return {"enabled": True, "source": "env"} try: from Project.Sanad.core.config_loader import section if bool((section("voice", "sanad_voice") or {}).get("pure_voice", False)): return {"enabled": True, "source": "config"} except Exception: pass return {"enabled": False, "source": "default"} @router.post("/pure-voice-option") async def set_pure_voice_option(on: bool = Query(...)): """Persist the pure-voice option and restart the live session.""" import json import tempfile import os as _os opts = _read_voice_opts() opts["pure_voice"] = bool(on) _VOICE_OPTS_PATH.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=str(_VOICE_OPTS_PATH.parent), prefix=".voice_options.", suffix=".tmp") with _os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(opts, f, indent=2) _os.replace(tmp, _VOICE_OPTS_PATH) restarted = False from Project.Sanad.main import live_sub if live_sub is not None: try: if (live_sub.status() or {}).get("running"): await asyncio.to_thread(live_sub.stop) await asyncio.to_thread(live_sub.start) restarted = True except Exception: pass return {"ok": True, "enabled": bool(on), "restarted": restarted} @router.get("/internet-search-option") async def get_internet_search_option(): """INTERNET SEARCH = the live session gets the search_internet tool so Gemini can look up current facts (prices, latest phones, live offers, news) via Google Search grounding, announcing the search first. Resolution: dashboard > env > config.""" import os opts = _read_voice_opts() if "internet_search" in opts: return {"enabled": bool(opts["internet_search"]), "source": "dashboard"} if os.environ.get("SANAD_INTERNET_SEARCH", "").strip().lower() in ("1", "true", "yes"): return {"enabled": True, "source": "env"} try: from Project.Sanad.core.config_loader import section if bool((section("voice", "sanad_voice") or {}).get("internet_search", False)): return {"enabled": True, "source": "config"} except Exception: pass return {"enabled": False, "source": "default"} @router.post("/internet-search-option") async def set_internet_search_option(on: bool = Query(...)): """Persist the internet-search option and restart the live session so the tool set (and persona addendum) is rebaked.""" import json import tempfile import os as _os opts = _read_voice_opts() opts["internet_search"] = bool(on) _VOICE_OPTS_PATH.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=str(_VOICE_OPTS_PATH.parent), prefix=".voice_options.", suffix=".tmp") with _os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(opts, f, indent=2) _os.replace(tmp, _VOICE_OPTS_PATH) restarted = False from Project.Sanad.main import live_sub if live_sub is not None: try: if (live_sub.status() or {}).get("running"): await asyncio.to_thread(live_sub.stop) await asyncio.to_thread(live_sub.start) restarted = True except Exception: pass return {"ok": True, "enabled": bool(on), "restarted": restarted} @router.post("/answer-now") async def answer_now(): """Force Gemini to reply immediately instead of waiting out the pause detection (no-op while it is already speaking).""" sub = _sub_or_503() if not (sub.status() or {}).get("running"): raise HTTPException(409, "Live session not running") if not hasattr(sub, "send_answer_now"): raise HTTPException(501, "engine build lacks send_answer_now") await asyncio.to_thread(sub.send_answer_now) return {"ok": True} @router.post("/interrupt") async def interrupt_speech(): """Instantly stop the AI mid-sentence and go back to listening (works regardless of the barge-in option; no-op if it isn't speaking).""" sub = _sub_or_503() if not (sub.status() or {}).get("running"): raise HTTPException(409, "Live session not running") if not hasattr(sub, "send_interrupt"): raise HTTPException(501, "engine build lacks send_interrupt") await asyncio.to_thread(sub.send_interrupt) return {"ok": True} @router.post("/start") async def start_subprocess(): live_sub = _sub_or_503() try: return await asyncio.to_thread(live_sub.start) except RuntimeError as exc: raise HTTPException(404, str(exc)) @router.post("/stop") async def stop_subprocess(): return await asyncio.to_thread(_sub_or_503().stop)