Unitree_G1_Sanad/dashboard/routes/live_subprocess.py

287 lines
11 KiB
Python

"""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 barge-in on external speakers (JBL / Beats Pill). 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)