290 lines
11 KiB
Python
290 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 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)
|