2026-08-13 16:23:18 +04:00

307 lines
11 KiB
Python

"""Voice-session control — the on/off switch behind the Interaction page.
The conversation loop is a systemd *user* unit (sanad_agibot.service) that the
dashboard already has the rights to drive, because both run as `agi` in the same
user session. So "turn speaking on" is: persist the operator's selection, then
start/restart that unit; "off" is stopping it.
The (gender, language, model) -> (persona, voice) mapping deliberately lives in
Sanad's own voice/session_profile.py and is imported from there by path rather
than copied here. A copy would drift, and a drifted copy means the dashboard
shows one character while the robot speaks as another.
Everything in here degrades to a readable status instead of raising: if Sanad
is not deployed, or systemd is unavailable, the card still renders and explains
what is missing.
"""
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any
SERVICE = os.environ.get("SANAD_SERVICE_NAME", "sanad_agibot.service")
SANAD_DIR = Path(os.environ.get(
"SANAD_DIR",
Path.home() / "sanad_deploy" / "sanad_package_1" / "vendor" / "Sanad",
))
_PROFILE_MODULE_PATH = SANAD_DIR / "voice" / "session_profile.py"
# Choices the UI renders. Kept server-side so the page and the robot can never
# disagree about what is selectable.
OPTIONS: dict[str, list[dict[str, str]]] = {
"gender": [
{"value": "female", "label": "Female — Muza / موزة"},
{"value": "male", "label": "Male — Lumi / لومي"},
],
"language": [
{"value": "arabic", "label": "Arabic only (Emirati)"},
{"value": "multi", "label": "Multi-language"},
],
# The second value is still keyed "linksoul" so an already-saved selection
# keeps working; the label describes what it actually does now.
"model": [
{"value": "gemini", "label": "Gemini Live"},
{"value": "linksoul", "label": "Pipeline — Fatima / Hamdan"},
],
}
_profile_mod = None
_profile_err: str | None = None
def _profile():
"""Sanad's session_profile module, loaded from disk on first use."""
global _profile_mod, _profile_err
if _profile_mod is not None or _profile_err is not None:
return _profile_mod
try:
spec = importlib.util.spec_from_file_location(
"sanad_session_profile", _PROFILE_MODULE_PATH)
if spec is None or spec.loader is None:
raise ImportError(f"cannot load {_PROFILE_MODULE_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
_profile_mod = module
except Exception as exc: # noqa: BLE001 - reported, never fatal
_profile_err = f"{type(exc).__name__}: {exc}"
return _profile_mod
def _systemctl(*args: str, timeout: float = 20.0) -> tuple[int, str]:
if not shutil.which("systemctl"):
return 127, "systemctl not available on this host"
try:
proc = subprocess.run(
["systemctl", "--user", *args],
capture_output=True, text=True, timeout=timeout,
)
return proc.returncode, (proc.stdout + proc.stderr).strip()
except subprocess.TimeoutExpired:
return 124, f"systemctl --user {' '.join(args)} timed out"
except OSError as exc:
return 1, str(exc)
_VOICE_PATTERNS = (
r"vendor/Sanad/voice/sanad_voice\.py",
r"vendor/Sanad/pipeline/runner\.py",
)
def _kill_voice_processes(grace: float = 6.0) -> int:
"""Stop the running voice process whoever started it.
Needed because the process is launched by keepalive_daemon.sh rather than
systemd, so stopping the unit is not enough to silence the robot. SIGINT
first so it releases ROS audio focus and closes the Gemini session cleanly.
"""
import signal
import subprocess as sp
pids: list[str] = []
for pat in _VOICE_PATTERNS:
try:
out = sp.run(["pgrep", "-f", pat], capture_output=True, text=True, timeout=5)
pids += [p for p in out.stdout.split() if p.strip()]
except Exception: # noqa: BLE001
pass
if not pids:
return 0
for p in pids:
try:
os.kill(int(p), signal.SIGINT)
except Exception: # noqa: BLE001
pass
time.sleep(grace)
for p in pids:
try:
os.kill(int(p), signal.SIGKILL)
except Exception: # noqa: BLE001
pass
return len(pids)
def service_status() -> dict[str, Any]:
code, out = _systemctl("is-active", SERVICE, timeout=10.0)
active = out.strip() or ("unknown" if code else "inactive")
return {
"unit": SERVICE,
"state": active,
"running": active == "active",
}
ENV_FILE = Path(os.environ.get("SANAD_ENV_FILE", Path.home() / ".sanad_agibot_env"))
_CRED_KEYS = ("LINKSOUL_APP_ID", "LINKSOUL_APP_KEY", "LINKSOUL_APP_SECRET")
def _env_file_keys() -> set[str]:
"""Names assigned in ~/.sanad_agibot_env.
Checked here rather than in os.environ because that file is sourced by the
launcher, not by the dashboard — the credentials are never visible in this
process's own environment, so reading os.environ would always report them
missing. Only key names are collected; the values are never read.
"""
found: set[str] = set()
try:
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("export "):
line = line[len("export "):].lstrip()
name, sep, value = line.partition("=")
if sep and value.strip().strip("\"'"):
found.add(name.strip())
except OSError:
pass
return found
def linksoul_ready() -> dict[str, Any]:
"""Whether the pipeline brain can actually run right now.
It needs edge-tts for the Emirati voice, miniaudio to decode that MP3 (this
robot has no ffmpeg and the `agi` account has no sudo), requests for the STT
call, and a Gemini key. It does NOT need the LinkSoul cloud SDK or its app
credentials - that was the earlier cloud design, which required AgiBot to
bind this robot to an application. The pipeline talks to the mic and speaker
directly, so it works with no platform registration at all.
"""
missing_pkgs = [p for p in ("edge_tts", "miniaudio", "requests")
if importlib.util.find_spec(p) is None]
has_key = ("SANAD_GEMINI_API_KEY" in _env_file_keys()
or bool(os.environ.get("SANAD_GEMINI_API_KEY"))
or bool(os.environ.get("GEMINI_API_KEY")))
reasons = []
if missing_pkgs:
reasons.append("missing Python packages: " + ", ".join(missing_pkgs)
+ " (pip3 install --user " + " ".join(missing_pkgs) + ")")
if not has_key:
reasons.append(f"SANAD_GEMINI_API_KEY is not set in {ENV_FILE}")
return {"ready": not reasons, "reasons": reasons}
def snapshot() -> dict[str, Any]:
"""Everything the Interaction card needs to render itself."""
mod = _profile()
if mod is None:
return {
"available": False,
"error": _profile_err or "session_profile.py not found",
"sanad_dir": str(SANAD_DIR),
"options": OPTIONS,
"service": service_status(),
"linksoul": linksoul_ready(),
}
resolved = mod.resolve()
return {
"available": True,
"error": "",
"options": OPTIONS,
**mod.catalog(),
"profile": {k: resolved[k] for k in ("enabled", "gender", "language", "model")},
"persona": {
"file": resolved["persona_file"],
"exists": resolved["persona_exists"],
"name_en": resolved["name_en"],
"name_ar": resolved["name_ar"],
},
"voice": {
"gemini": resolved["gemini_voice"],
"edge": resolved["edge_voice"],
},
"greeting": resolved["greeting"],
"service": service_status(),
"linksoul": linksoul_ready(),
}
def apply(enabled: bool, gender: str | None, language: str | None,
model: str | None) -> dict[str, Any]:
"""Persist the selection and bring the conversation loop to that state.
Returns the new snapshot plus a `greeting` the caller should speak when the
session has just been switched on. The greeting is spoken by the dashboard
rather than by the persona so it fires exactly once, at the moment Apply is
pressed, on whichever model is selected — the LinkSoul path is a passive
callback and cannot start talking on its own.
"""
mod = _profile()
if mod is None:
return {"ok": False, "error": _profile_err or "session_profile.py not found",
"snapshot": snapshot()}
# Validate a CANDIDATE before persisting anything. Saving first and
# rejecting afterwards left an unlaunchable selection on disk: the dashboard
# refused to start it, but start_agibot.sh would happily replay it at the
# next boot, exec a runner that exits immediately, and Restart=on-failure
# would turn that into a crash loop with the robot mute.
current = mod.load()
candidate = mod.normalize({
"enabled": enabled,
"gender": gender or current["gender"],
"language": language or current["language"],
"model": model or current["model"],
})
resolved = mod.resolve(candidate)
if not resolved["persona_exists"]:
return {
"ok": False,
"error": f"Persona file missing: {resolved['persona_file']}",
"snapshot": snapshot(),
}
if enabled and resolved["model"] == "linksoul":
ready = linksoul_ready()
if not ready["ready"]:
return {"ok": False,
"error": "LinkSoul is not usable yet — " + "; ".join(ready["reasons"]),
"snapshot": snapshot()}
saved = mod.save(**{k: candidate[k] for k in ("enabled", "gender", "language", "model")})
resolved = mod.resolve(saved)
# The voice process is owned by keepalive_daemon.sh, not by systemd - the
# user manager dies with the last login session (no linger, needs root), so
# the daemon had to take over. That means `systemctl stop` no longer stops
# anything: pressing "Speaking off" left the robot still listening. So act
# on the processes directly here, and let the daemon keep enforcing it.
_kill_voice_processes()
if enabled:
_systemctl("reset-failed", SERVICE, timeout=10.0)
code, out = _systemctl("restart", SERVICE, timeout=45.0)
action = "restarted"
else:
code, out = _systemctl("stop", SERVICE, timeout=30.0)
action = "stopped"
# The voice process does not exit on SIGINT within TimeoutStopSec, so
# systemd SIGKILLs it and books the unit as failed. That is an artifact
# of an operator-requested stop, not a fault, and showing "failed" on
# the card after pressing Off would be plainly wrong.
if code == 0:
_systemctl("reset-failed", SERVICE, timeout=10.0)
if code != 0:
return {"ok": False, "error": f"systemctl {action} failed: {out}",
"snapshot": snapshot()}
return {
"ok": True,
"action": action,
"greeting": resolved["greeting"] if enabled else "",
"snapshot": snapshot(),
}