1977 lines
96 KiB
Python
1977 lines
96 KiB
Python
"""Gemini brain — live conversation loop using the google-genai SDK.
|
||
|
||
Implements the VoiceBrain contract documented in `voice/model_script.py`:
|
||
|
||
__init__(audio_io, recorder, voice_name, system_prompt)
|
||
async run()
|
||
stop()
|
||
|
||
Owns everything Gemini-specific: the `genai.Client`, `LiveConnectConfig`,
|
||
the session connect/receive loop, VAD-based barge-in, echo suppression,
|
||
reconnect backoff. Hardware I/O is delegated to `audio_io` and per-turn
|
||
WAV capture to `recorder` — both are model-agnostic.
|
||
|
||
Env overrides:
|
||
SANAD_GEMINI_MODEL — Gemini Live model id (without "models/" prefix)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import array
|
||
import asyncio
|
||
import base64
|
||
import json
|
||
import os
|
||
import sys
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
import numpy as np
|
||
|
||
from google import genai
|
||
from google.genai import types
|
||
|
||
from Project.Sanad.config import (
|
||
BASE_DIR,
|
||
CHUNK_SIZE,
|
||
GEMINI_API_KEY,
|
||
GEMINI_VOICE,
|
||
RECEIVE_SAMPLE_RATE,
|
||
SEND_SAMPLE_RATE,
|
||
)
|
||
from Project.Sanad.core.config_loader import section as _cfg_section
|
||
from Project.Sanad.core.logger import get_logger
|
||
from Project.Sanad.vision import recognition_state as _recog_state
|
||
|
||
log = get_logger("gemini_brain")
|
||
|
||
_SV = _cfg_section("voice", "sanad_voice")
|
||
_VAD = _cfg_section("voice", "vad")
|
||
_BI = _cfg_section("voice", "barge_in")
|
||
|
||
_MODEL = os.environ.get(
|
||
"SANAD_GEMINI_MODEL",
|
||
"gemini-2.5-flash-native-audio-preview-12-2025",
|
||
)
|
||
# Mic gain: amplifies mic samples before they reach Gemini — the single
|
||
# biggest lever for slow/failed speech detection at a distance (weak audio
|
||
# transcribes but often yields NO reply turn). Env override for per-robot
|
||
# tuning; watch for clipping if the speaker stands close (>3.0 rarely wise).
|
||
try:
|
||
_MIC_GAIN = float(os.environ.get("SANAD_MIC_GAIN", "") or _SV.get("mic_gain", 1.0))
|
||
except ValueError:
|
||
_MIC_GAIN = _SV.get("mic_gain", 1.0)
|
||
_SESSION_TIMEOUT = _SV.get("session_timeout_sec", 660)
|
||
_MAX_RECONNECT_DELAY = _SV.get("max_reconnect_delay_sec", 30)
|
||
_MAX_CONSECUTIVE_ERRORS = _SV.get("max_consecutive_errors", 10)
|
||
# How long with ZERO messages from Gemini before we declare the session dead.
|
||
# In a QUIET room Gemini legitimately sends nothing — 30s was killing healthy
|
||
# sessions every ~40s (reconnect churn + unsolicited greeting + a 1-3s deaf
|
||
# window each time). REAL deaths are caught fast by the send path ("mic send
|
||
# failed: 1011 ..."), so this can be patient.
|
||
_NO_MESSAGES_TIMEOUT = _SV.get("no_messages_timeout_sec", 180)
|
||
# Extra mic-gate time after the AI stops, on loud external-speaker profiles
|
||
# (JBL) — covers the speaker buffer + room reverb so it doesn't hear its tail.
|
||
_ECHO_TAIL_SEC = _SV.get("echo_tail_sec", 0.6)
|
||
# On a loud external speaker (JBL) barge-in must clear the measured speaker
|
||
# BLEED by this factor — so the user's own voice cuts the AI but the speaker's
|
||
# echo into the mic does not. Lower = easier to interrupt (more false cuts).
|
||
_JBL_BLEED_MARGIN = _SV.get("jbl_bleed_margin", 3.0)
|
||
# Don't allow barge-in for this long after the AI starts on the JBL — gives the
|
||
# bleed estimate time to ramp so an early loud bleed frame can't false-trigger.
|
||
_JBL_BARGE_GRACE = _SV.get("jbl_barge_grace_sec", 1.0)
|
||
# Sustained loud-frame count required to barge-in on the JBL (vs the shorter
|
||
# default). Brief speaker-echo peaks won't reach it; continuous speech will.
|
||
_JBL_BARGE_CHUNKS = _SV.get("jbl_barge_chunks", 9)
|
||
# Time since the AI last pushed audio after which the speaker bleed is assumed
|
||
# FADED (a gap between words/numbers). In that window barge-in drops to a low,
|
||
# sensitive bar so the user can interrupt; while audio is flowing it stays high.
|
||
_JBL_BLEED_FADE_SEC = _SV.get("jbl_bleed_fade_sec", 0.5)
|
||
# OPTION: allow voice barge-in on loud external speakers (JBL / Beats Pill).
|
||
# The mic feed to Gemini STAYS fully gated while the AI speaks (echo-safe);
|
||
# this only enables the LOCAL energy detector to cut the speaker, using the
|
||
# stricter external parameters (threshold×margin, longer grace, more sustained
|
||
# chunks) so speaker bleed can't false-trigger. Env > config; default OFF.
|
||
_EXTERNAL_BARGE_IN = (
|
||
os.environ.get("SANAD_EXTERNAL_BARGE_IN", "").strip().lower() in ("1", "true", "yes")
|
||
or bool(_BI.get("external_barge_in", False))
|
||
)
|
||
# OPTION: "direct answer" — reply as soon as the speaker pauses. Flips the
|
||
# Live-API end-of-speech sensitivity from LOW (conservative: waits to be sure
|
||
# you finished, feels slow) to HIGH (immediate turn-taking; may cut off very
|
||
# hesitant speakers mid-thought). Env > config; dashboard file has final say.
|
||
_DIRECT_ANSWER = (
|
||
os.environ.get("SANAD_DIRECT_ANSWER", "").strip().lower() in ("1", "true", "yes")
|
||
or bool(_VAD.get("direct_answer", False))
|
||
)
|
||
|
||
# OPTION: PURE VOICE — make the live session EXACTLY like standalone Sanadv1:
|
||
# no tools (no function-calling deliberation), no state injections, persona
|
||
# only (no face addendum), no lip-sync markers, no frame/state loops. The
|
||
# fastest possible conversation pipe; mask/nav/vision hooks are dormant.
|
||
_PURE_VOICE = (
|
||
os.environ.get("SANAD_PURE_VOICE", "").strip().lower() in ("1", "true", "yes")
|
||
or bool(_SV.get("pure_voice", False))
|
||
)
|
||
|
||
# OPTION: INTERNET SEARCH (Google Search grounding). When ON, the live session
|
||
# gets ONE extra tool, `search_internet(query)`, so the model can look up
|
||
# CURRENT facts (today's prices, the newest phones, live Etisalat offers, news).
|
||
# The tool description tells the model to SAY it will check first, then call the
|
||
# tool — so the robot announces "let me search the internet", the parent runs
|
||
# the grounded generateContent call in a worker thread, and the model speaks the
|
||
# result. Default OFF → pure voice stays tool-free and Sanadv1-fast; only when
|
||
# a search actually fires does the (announced) grounding delay occur.
|
||
_INTERNET_SEARCH = (
|
||
os.environ.get("SANAD_INTERNET_SEARCH", "").strip().lower() in ("1", "true", "yes")
|
||
or bool(_SV.get("internet_search", False))
|
||
)
|
||
# Model used for the grounded web-search call (generateContent, NOT the live
|
||
# native-audio model). gemini-3.5-flash grounds well and cites the source site.
|
||
_SEARCH_MODEL = os.environ.get("SANAD_SEARCH_MODEL", "gemini-3.5-flash")
|
||
|
||
# The dashboard toggles (data/.voice_options.json, written by the
|
||
# /api/live-subprocess/*-option endpoints, which then restart this child)
|
||
# have the FINAL say over env/config.
|
||
try:
|
||
import json as _vo_json
|
||
with open(BASE_DIR / "data" / ".voice_options.json", encoding="utf-8") as _vo_f:
|
||
_vo = _vo_json.load(_vo_f)
|
||
if isinstance(_vo, dict):
|
||
if "external_barge_in" in _vo:
|
||
_EXTERNAL_BARGE_IN = bool(_vo["external_barge_in"])
|
||
if "direct_answer" in _vo:
|
||
_DIRECT_ANSWER = bool(_vo["direct_answer"])
|
||
if "pure_voice" in _vo:
|
||
_PURE_VOICE = bool(_vo["pure_voice"])
|
||
if "internet_search" in _vo:
|
||
_INTERNET_SEARCH = bool(_vo["internet_search"])
|
||
except Exception:
|
||
pass
|
||
|
||
_CHUNK_BYTES = CHUNK_SIZE * 2
|
||
_SILENCE_PCM = b"\x00" * _CHUNK_BYTES
|
||
|
||
# Set by a "pause:1" stdin command (a record is playing on the dashboard).
|
||
# While set, the brain feeds Gemini silence (so it neither hears the playback
|
||
# nor keeps replying) and drops its own audio output (so the record owns the
|
||
# chest speaker). Cleared by "pause:0" when playback ends. threading.Event is
|
||
# safe to read from the asyncio loops.
|
||
_INPUT_PAUSED = threading.Event()
|
||
|
||
# Set by an "interrupt:" stdin command (the dashboard's Interrupt-now button).
|
||
# The mic loop consumes it: stops the speaker mid-sentence, back to listening.
|
||
_MANUAL_INTERRUPT = threading.Event()
|
||
|
||
# Set by an "answernow:" stdin command (the dashboard's Answer-now button).
|
||
# The mic loop feeds ~0.6s of silence so the server VAD closes the user's turn
|
||
# immediately → Gemini answers right away instead of waiting out the pause.
|
||
_FORCE_ANSWER = threading.Event()
|
||
|
||
# ── Recognition (camera + face gallery) tunables ──
|
||
_RECOG_STATE_PATH = Path(os.environ.get(
|
||
"SANAD_RECOGNITION_STATE_PATH",
|
||
str(BASE_DIR / "data" / ".recognition_state.json"),
|
||
))
|
||
_VISION_SEND_HZ = float(os.environ.get("SANAD_VISION_SEND_HZ", "2"))
|
||
_VISION_STALE_MS = int(os.environ.get("SANAD_VISION_STALE_MS", "1500"))
|
||
_RECOG_POLL_S = float(os.environ.get("SANAD_RECOGNITION_POLL_S", "1.0"))
|
||
_FACES_DIR = Path(os.environ.get(
|
||
"SANAD_FACES_DIR",
|
||
str(BASE_DIR / "data" / "faces"),
|
||
))
|
||
_FACES_MAX_SAMPLES = int(os.environ.get("SANAD_FACES_MAX_SAMPLES", "3"))
|
||
_FACES_PRIMER_RESIZE = int(os.environ.get("SANAD_FACES_PRIMER_RESIZE", "256"))
|
||
# N3 — zones gallery (zone → place → linked faces). Folded into a Gemini
|
||
# primer turn so Gemini can recognise / talk about known locations and the
|
||
# people associated with them.
|
||
_ZONES_DIR = Path(os.environ.get(
|
||
"SANAD_ZONES_DIR",
|
||
str(BASE_DIR / "data" / "zones"),
|
||
))
|
||
|
||
|
||
# ── navigation tools (Gemini Live function-calling → Nav2) ────
|
||
# Gemini can DRIVE the robot to saved places via native function-calling.
|
||
# The handlers call the DASHBOARD HTTP API (not web_nav3 directly) so the
|
||
# in-process _arbiter (nav↔loco mutex) + single nav client stay authoritative
|
||
# — the Gemini brain runs as a SEPARATE subprocess and cannot touch the
|
||
# dashboard's in-memory arbiter, so it must go through HTTP.
|
||
try:
|
||
import requests as _requests
|
||
except Exception: # pragma: no cover - requests is in the gemini_sdk env
|
||
_requests = None
|
||
|
||
_NAV_TOOLS_ENABLED = os.environ.get("SANAD_NAV_TOOLS", "1") != "0"
|
||
_DASHBOARD_URL = os.environ.get(
|
||
"SANAD_DASHBOARD_URL", "http://127.0.0.1:8001"
|
||
).rstrip("/")
|
||
|
||
|
||
def _nav_api(method: str, path: str, body: Optional[dict] = None,
|
||
timeout: float = 12.0) -> dict:
|
||
"""Blocking call to the dashboard nav API. Always returns a dict; never
|
||
raises (run it via asyncio.to_thread so the receive loop stays responsive)."""
|
||
if _requests is None:
|
||
return {"ok": False, "reason": "no_http_client"}
|
||
url = _DASHBOARD_URL + path
|
||
try:
|
||
if method == "GET":
|
||
resp = _requests.get(url, timeout=timeout)
|
||
else:
|
||
resp = _requests.post(url, json=(body or {}), timeout=timeout)
|
||
except Exception as exc:
|
||
return {"ok": False, "reason": "unreachable", "detail": str(exc)[:200]}
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
data = {"raw": (resp.text or "")[:200]}
|
||
if not resp.ok:
|
||
detail = data.get("detail") if isinstance(data, dict) else data
|
||
return {"ok": False, "reason": f"http_{resp.status_code}", "detail": detail}
|
||
return data if isinstance(data, dict) else {"ok": True, "data": data}
|
||
|
||
|
||
def _nav_function_declarations() -> list:
|
||
"""Gemini Live tool declarations for navigation (built lazily so the
|
||
google.genai types are resolved at call time)."""
|
||
S, T = types.Schema, types.Type
|
||
return [
|
||
types.FunctionDeclaration(
|
||
name="navigate_to_place",
|
||
description=(
|
||
"Drive the robot to a named saved place in the currently loaded "
|
||
"map (for example 'kitchen', 'reception', 'office'). Call this "
|
||
"ONLY when the user asks to go, move, walk, or be taken "
|
||
"somewhere. The place must exist in the active map — if you are "
|
||
"unsure of the name, call list_places first."
|
||
),
|
||
parameters=S(type=T.OBJECT, properties={
|
||
"place": S(type=T.STRING,
|
||
description="Destination place name, as the user said it."),
|
||
}, required=["place"]),
|
||
),
|
||
types.FunctionDeclaration(
|
||
name="list_places",
|
||
description=(
|
||
"List the saved places you can drive to in the currently loaded "
|
||
"map. Use it to tell the user where you can take them."
|
||
),
|
||
parameters=S(type=T.OBJECT, properties={}),
|
||
),
|
||
types.FunctionDeclaration(
|
||
name="where_am_i",
|
||
description=(
|
||
"Report navigation status: which map is loaded and whether the "
|
||
"robot is localized and ready to drive."
|
||
),
|
||
parameters=S(type=T.OBJECT, properties={}),
|
||
),
|
||
types.FunctionDeclaration(
|
||
name="stop_navigation",
|
||
description="Cancel the current navigation goal and stop the robot from driving.",
|
||
parameters=S(type=T.OBJECT, properties={}),
|
||
),
|
||
]
|
||
|
||
|
||
def _search_function_declaration() -> list:
|
||
"""Live tool: search the internet (Google Search grounding) for current facts."""
|
||
S, T = types.Schema, types.Type
|
||
return [
|
||
types.FunctionDeclaration(
|
||
name="search_internet",
|
||
description=(
|
||
"Search the LIVE internet for CURRENT, real-time facts you cannot "
|
||
"otherwise know: today's prices, the newest phone / device models, "
|
||
"current Etisalat / e& offers and promotions, product availability, "
|
||
"or recent news. IMPORTANT: BEFORE you call this, first SAY one "
|
||
"short sentence out loud telling the user you will check — e.g. "
|
||
"\"Let me search the internet for that.\" / "
|
||
"\"دعني أبحث لك على الإنترنت.\" — "
|
||
"then call this with a clear English search query. After the "
|
||
"result returns, answer the user naturally from it. Do NOT say the "
|
||
"tool name out loud. Only use this for genuinely current / "
|
||
"time-sensitive questions, not for things you already know."
|
||
),
|
||
parameters=S(type=T.OBJECT, properties={
|
||
"query": S(type=T.STRING,
|
||
description="The web-search query, phrased in English."),
|
||
}, required=["query"]),
|
||
),
|
||
]
|
||
|
||
|
||
# Appended to the persona ONLY when internet search is enabled, so the model
|
||
# knows it can (and how to) look things up. Kept short — the tool description
|
||
# carries the detail.
|
||
_SEARCH_PROMPT_ADDENDUM = (
|
||
"\n\nYou can look things up on the live internet with the search_internet "
|
||
"tool. Use it whenever the user asks about current prices, the latest phones "
|
||
"or devices, today's Etisalat/e& offers, availability, or recent news — "
|
||
"anything you can't be certain is up to date. ALWAYS say a short 'let me "
|
||
"check the internet' line first (in the user's language), then call the tool, "
|
||
"then answer from the result. Keep answers concise and, for exact prices, add "
|
||
"that a sales advisor can confirm the final figure. "
|
||
"CRITICAL: after the search result returns, CONTINUE where you left off — "
|
||
"NEVER repeat or re-say any sentence you already spoke, including the 'let me "
|
||
"check the internet' lead-in. If your reply is complete, stop and wait."
|
||
)
|
||
|
||
|
||
def _run_grounding_search(query: str, api_key: str) -> dict:
|
||
"""Blocking Google-Search-grounded generateContent call (run in a thread).
|
||
Returns {ok, answer} for the model to speak, or {ok: False, reason}."""
|
||
try:
|
||
client = genai.Client(api_key=api_key)
|
||
resp = client.models.generate_content(
|
||
model=_SEARCH_MODEL,
|
||
contents=query,
|
||
config=types.GenerateContentConfig(
|
||
tools=[types.Tool(google_search=types.GoogleSearch())],
|
||
),
|
||
)
|
||
answer = (getattr(resp, "text", "") or "").strip()
|
||
if not answer:
|
||
return {"ok": False, "reason": "empty_result"}
|
||
# Cap length so the spoken reply stays reasonable.
|
||
return {"ok": True, "answer": answer[:1500]}
|
||
except Exception as exc:
|
||
log.warning("internet search failed: %s", exc)
|
||
return {"ok": False, "reason": "search_failed", "detail": str(exc)[:200]}
|
||
|
||
|
||
# Emotions Gemini can show on the LED face (a subset of the mask's frames that
|
||
# read as feelings — the talk/blink/gaze frames are driven automatically).
|
||
_FACE_EMOTIONS = ("smile", "laugh", "heart", "love", "sad", "surprised",
|
||
"wink", "angry", "cool", "confused", "kiss", "thumbs_up",
|
||
"neutral")
|
||
|
||
# Instagram accounts the mask can show as a QR (parent maps these to the code).
|
||
_SOCIAL_ACCOUNTS = ("bu_sunaidah", "yslootahtech")
|
||
|
||
# Appended to whatever base system prompt is passed in, so the expressive-face
|
||
# behaviour is always present regardless of the user-edited persona.
|
||
_FACE_PROMPT_ADDENDUM = (
|
||
"\n\nYou have an expressive LED face you control with tools. IMPORTANT: when "
|
||
"the user asks you to SHOW or MAKE a specific face/emotion (e.g. 'show me a "
|
||
"smile', 'give me a thumbs up', 'look surprised', 'make a heart'), ALWAYS "
|
||
"call set_expression with that emotion right away. Also use set_expression "
|
||
"naturally as you talk — smile when greeting or happy, laugh at something "
|
||
"funny, heart or love for affection or a compliment, thumbs_up to agree or "
|
||
"approve, surprised when astonished, confused when you didn't understand, "
|
||
"wink when joking, sad when empathizing, cool when playful. Available "
|
||
"emotions: smile, laugh, heart, love, thumbs_up, surprised, confused, wink, "
|
||
"kiss, cool, sad, angry, neutral. Your mouth already lip-syncs on its own, "
|
||
"so this is only the emotion. When the user asks how to follow you, for your "
|
||
"Instagram, or to see/show your social media, ALWAYS call show_social with "
|
||
"'bu_sunaidah' (@bu.sunaidah) or 'yslootahtech' (@yslootahtech) to display "
|
||
"the QR on your face. These tools are silent — never say the tool name, the "
|
||
"emotion, or any bracket marker out loud. CRITICAL: after any tool returns, "
|
||
"CONTINUE exactly where you left off — NEVER repeat or re-say sentences you "
|
||
"already spoke before the tool call. If your reply was already complete, do "
|
||
"not speak again; wait silently for the user."
|
||
)
|
||
|
||
|
||
def _face_function_declarations() -> list:
|
||
"""Gemini Live tools for the expressive LED face + social QR (built lazily
|
||
so google.genai types resolve at call time)."""
|
||
S, T = types.Schema, types.Type
|
||
return [
|
||
types.FunctionDeclaration(
|
||
name="set_expression",
|
||
description=(
|
||
"Show an emotion on your LED face to react expressively while you "
|
||
"talk. Use it naturally and sparingly: smile when greeting or happy, "
|
||
"laugh at something funny, heart/love for affection or a compliment, "
|
||
"surprised when astonished, confused when you didn't understand, wink "
|
||
"when joking, sad when empathizing, cool when playful, sleepy when "
|
||
"tired, angry only rarely. Your mouth already lip-syncs on its own — "
|
||
"this is ONLY the emotion, not the mouth. After this tool returns, never "
|
||
"repeat what you already said — continue your sentence or stay silent."
|
||
),
|
||
parameters=S(type=T.OBJECT, properties={
|
||
"emotion": S(type=T.STRING, enum=list(_FACE_EMOTIONS),
|
||
description="The emotion to show on the face."),
|
||
}, required=["emotion"]),
|
||
),
|
||
types.FunctionDeclaration(
|
||
name="show_social",
|
||
description=(
|
||
"Display a social-media QR code on your LED face so a visitor can "
|
||
"scan it and follow. Call this when the user asks how to follow you, "
|
||
"for your Instagram, or to share your social media. Choose the "
|
||
"account: 'bu_sunaidah' (@bu.sunaidah) or 'yslootahtech' "
|
||
"(@yslootahtech)."
|
||
),
|
||
parameters=S(type=T.OBJECT, properties={
|
||
"account": S(type=T.STRING, enum=list(_SOCIAL_ACCOUNTS),
|
||
description="Which Instagram account to show."),
|
||
}, required=["account"]),
|
||
),
|
||
]
|
||
|
||
|
||
# ── stdin push channel (Marcus pattern) ──────────────────────
|
||
# The GeminiSubprocess supervisor writes two line types to this process's
|
||
# stdin:
|
||
# "frame:<base64-jpeg>\n" — a camera frame to relay to Gemini Live
|
||
# "state:<json>\n" — a motion-state update to inject as text
|
||
# A daemon thread parses them into the caches below; the asyncio tasks
|
||
# _send_frame_loop / _send_state_loop drain those caches.
|
||
|
||
_LATEST_FRAME_LOCK = threading.Lock()
|
||
_LATEST_FRAME: dict = {"bytes": None, "ts": 0.0}
|
||
|
||
_STATE_LOCK = threading.Lock()
|
||
_STATE_PENDING: list[str] = []
|
||
|
||
_STATE_TAGS = {
|
||
"start": "[STATE-START]",
|
||
"complete": "[STATE-DONE]",
|
||
"interrupted": "[STATE-INTERRUPTED]",
|
||
"error": "[STATE-ERROR]",
|
||
"paused": "[STATE-PAUSED]",
|
||
"resumed": "[STATE-RESUMED]",
|
||
# Navigation arrival/failure — pushed by the dashboard goal monitor so
|
||
# Gemini can truthfully tell the user it arrived (or couldn't get there)
|
||
# instead of guessing from the fire-and-forget goto.
|
||
"nav_arrived": "[NAV ARRIVED]",
|
||
"nav_failed": "[NAV FAILED]",
|
||
"nav_canceled": "[NAV CANCELED]",
|
||
# "Gemini Nav" session greeting — the operator entered a zone bound to a
|
||
# map; tell Gemini the zone + drivable places and to greet the user.
|
||
"nav_zone": "[GEMINI NAV]",
|
||
}
|
||
|
||
# Pending audio-profile swap signalled by the parent over "profile:" stdin
|
||
# lines. _audio_swap_loop drains it inside the brain's asyncio loop.
|
||
_PROFILE_LOCK = threading.Lock()
|
||
_PROFILE_PENDING: dict = {"id": None, "reason": ""}
|
||
|
||
_VALID_PROFILES = {"builtin", "anker", "anker_powerconf",
|
||
"hollyland_builtin", "jbl_builtin_mic", "beats_pill"}
|
||
|
||
|
||
def _stdin_watcher() -> None:
|
||
"""Daemon thread — parse 'frame:' / 'state:' / 'profile:' lines off stdin.
|
||
|
||
Best-effort: any malformed line is skipped. Exits when the parent
|
||
closes our stdin (subprocess teardown)."""
|
||
try:
|
||
# IMPORTANT: read with readline(), NOT `for line in sys.stdin`. The file
|
||
# iterator does aggressive read-ahead buffering, so on an idle pipe a
|
||
# small command like "pause:1\n" can sit unread for SECONDS (until more
|
||
# stdin data arrives to flush the read-ahead). That delayed the record-
|
||
# playback pause by ~2s — Gemini kept stomping the chest speaker so the
|
||
# clip was silent / late. readline() returns each line as soon as its
|
||
# newline arrives, so commands are delivered promptly.
|
||
while True:
|
||
line = sys.stdin.readline()
|
||
if line == "":
|
||
break # EOF — parent closed our stdin (subprocess teardown)
|
||
line = line.rstrip("\n")
|
||
if not line:
|
||
continue
|
||
if line.startswith("frame:"):
|
||
b64 = line[len("frame:"):]
|
||
try:
|
||
data = base64.b64decode(b64)
|
||
except Exception:
|
||
continue
|
||
if data:
|
||
with _LATEST_FRAME_LOCK:
|
||
_LATEST_FRAME["bytes"] = data
|
||
_LATEST_FRAME["ts"] = time.time()
|
||
elif line.startswith("interrupt:"):
|
||
_MANUAL_INTERRUPT.set()
|
||
elif line.startswith("answernow:"):
|
||
_FORCE_ANSWER.set()
|
||
elif line.startswith("state:"):
|
||
try:
|
||
payload = json.loads(line[len("state:"):])
|
||
except Exception:
|
||
continue
|
||
event = (payload.get("event") or "").strip().lower()
|
||
cmd = (payload.get("cmd") or "").strip()
|
||
tag = _STATE_TAGS.get(event)
|
||
if not tag or not cmd:
|
||
continue
|
||
msg = f"{tag} {cmd}"
|
||
elapsed = payload.get("elapsed_sec")
|
||
if isinstance(elapsed, (int, float)):
|
||
msg += f" ({float(elapsed):.1f}s)"
|
||
reason = payload.get("reason")
|
||
if reason and event == "error":
|
||
msg += f" — {reason}"
|
||
with _STATE_LOCK:
|
||
_STATE_PENDING.append(msg)
|
||
elif line.startswith("profile:"):
|
||
# Parent signals an audio-profile hot-swap. Stash the target;
|
||
# _audio_swap_loop (asyncio task) handles the actual swap so
|
||
# PyAudio open/close happens off the stdin thread.
|
||
try:
|
||
payload = json.loads(line[len("profile:"):])
|
||
except Exception:
|
||
continue
|
||
pid = (payload.get("id") or "").strip().lower()
|
||
if pid not in _VALID_PROFILES:
|
||
continue
|
||
with _PROFILE_LOCK:
|
||
_PROFILE_PENDING["id"] = pid
|
||
_PROFILE_PENDING["reason"] = (
|
||
payload.get("reason") or "").strip()
|
||
elif line.startswith("pause:"):
|
||
# Dashboard record playback — pause/resume the live interaction.
|
||
if line[len("pause:"):].strip() in ("1", "true", "True", "on"):
|
||
if not _INPUT_PAUSED.is_set():
|
||
_INPUT_PAUSED.set()
|
||
log.info("input PAUSED — record playback")
|
||
else:
|
||
if _INPUT_PAUSED.is_set():
|
||
_INPUT_PAUSED.clear()
|
||
log.info("input RESUMED — record playback ended")
|
||
except Exception:
|
||
return
|
||
|
||
|
||
# Start the watcher at import time — it blocks harmlessly on sys.stdin
|
||
# until the supervisor sends something. Daemon so it never blocks exit.
|
||
threading.Thread(target=_stdin_watcher, daemon=True, name="stdin-watcher").start()
|
||
|
||
|
||
def _audio_energy(pcm: bytes) -> int:
|
||
try:
|
||
samples = array.array("h", pcm)
|
||
return sum(abs(s) for s in samples) // len(samples) if samples else 0
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
class GeminiBrain:
|
||
"""Gemini Live conversation brain — reconnect-safe."""
|
||
|
||
def __init__(self, audio_io, recorder, voice_name: Optional[str] = None,
|
||
system_prompt: str = ""):
|
||
self._audio = audio_io
|
||
self._mic = audio_io.mic
|
||
self._speaker = audio_io.speaker
|
||
# Kept on the brain so swap_audio_devices() can rebuild profiles that
|
||
# need DDS (`builtin`, `hollyland_builtin`) without re-init'ing.
|
||
self._audio_client = getattr(audio_io, "_audio_client", None)
|
||
# Current profile id (driven by the parent's "profile:" stdin push).
|
||
# Defaults to whatever audio_io was constructed with — `from_profile`
|
||
# sets profile_id; if SANAD_AUDIO_PROFILE override is in env, that
|
||
# value matches.
|
||
self._current_profile_id = getattr(audio_io, "profile_id", None) \
|
||
or os.environ.get("SANAD_AUDIO_PROFILE", "builtin").strip().lower()
|
||
# Coordinates concurrent swap requests so two pending profile
|
||
# changes don't interleave mid-tear-down.
|
||
self._swap_lock: Optional[asyncio.Lock] = None # built in run()
|
||
self._recorder = recorder
|
||
self._voice = voice_name or GEMINI_VOICE
|
||
# PURE VOICE: persona only — no face/tool addendum (Sanadv1 parity).
|
||
self._system_prompt = ((system_prompt or "") if _PURE_VOICE
|
||
else (system_prompt or "") + _FACE_PROMPT_ADDENDUM)
|
||
# Internet-search addendum is independent of pure voice — the search
|
||
# tool is available in either mode when the option is enabled.
|
||
if _INTERNET_SEARCH:
|
||
self._system_prompt += _SEARCH_PROMPT_ADDENDUM
|
||
self._api_key = GEMINI_API_KEY
|
||
self._stop_flag = asyncio.Event()
|
||
# per-session state (reset in the outer reconnect loop)
|
||
self._speaking = False
|
||
self._stream_started = False
|
||
self._barge_block_until = 0.0
|
||
self._ai_speak_start = 0.0
|
||
self._last_ai_audio = 0.0
|
||
# Rolling estimate of the speaker bleed picked up by the mic while the AI
|
||
# talks (JBL profile) — the barge-in threshold floats above this.
|
||
self._bleed_ewma = 0.0
|
||
self._done: Optional[asyncio.Event] = None
|
||
# ── Recognition flags — kept in sync with the state file by
|
||
# _recognition_state_watcher. Boot defaults come from the file (or
|
||
# the SANAD_* env vars if the file is missing).
|
||
_initial = _recog_state.read(_RECOG_STATE_PATH)
|
||
self._vision_enabled = bool(
|
||
_initial.vision_enabled
|
||
or os.environ.get("SANAD_VISION_ENABLE", "0") == "1"
|
||
)
|
||
self._face_rec_enabled = bool(
|
||
_initial.face_rec_enabled
|
||
or os.environ.get("SANAD_FACE_RECOGNITION_ENABLE", "0") == "1"
|
||
)
|
||
self._gallery_version_primed = -1 # bumped after first successful primer
|
||
# N3 — zones knowledge toggle + primer version tracking.
|
||
self._zone_rec_enabled = bool(
|
||
_initial.zone_rec_enabled
|
||
or os.environ.get("SANAD_ZONE_RECOGNITION_ENABLE", "0") == "1"
|
||
)
|
||
self._zones_version_primed = -1
|
||
# "Go here" destination already announced this session (zone_id, place_id).
|
||
self._nav_target = (
|
||
int(_initial.nav_target_zone_id), int(_initial.nav_target_place_id),
|
||
)
|
||
# N2 — Gemini-driven locomotion enable gate (announce only; the
|
||
# actual dispatch loop lives in the parent and is wired separately).
|
||
self._movement_enabled = bool(
|
||
_initial.movement_enabled
|
||
or os.environ.get("SANAD_MOVEMENT_ENABLE", "0") == "1"
|
||
)
|
||
# Auto-record toggle — recognition_state is the live source of truth.
|
||
# Sync the recorder to it now; the watcher keeps it in sync at runtime.
|
||
self._record_enabled = bool(_initial.record_enabled)
|
||
try:
|
||
self._recorder.enabled = self._record_enabled
|
||
except Exception:
|
||
pass
|
||
|
||
def stop(self) -> None:
|
||
"""Signal the run loop to exit at the next opportunity."""
|
||
try:
|
||
self._stop_flag.set()
|
||
except Exception:
|
||
pass
|
||
|
||
# ─── public entry point ───────────────────────────────
|
||
|
||
async def run(self) -> None:
|
||
client = genai.Client(api_key=self._api_key)
|
||
config = self._build_config()
|
||
session_num = 0
|
||
start_time = time.time()
|
||
consecutive_errors = 0
|
||
|
||
while not self._stop_flag.is_set():
|
||
session_num += 1
|
||
self._reset_turn_state()
|
||
# On a reconnect (not the first session), suppress the unprompted
|
||
# re-greeting until the user speaks — keeps the chest speaker free
|
||
# for record playback and stops the "robot greets every 30s" loop.
|
||
self._suppress_greeting = session_num > 1
|
||
uptime_min = (time.time() - start_time) / 60
|
||
|
||
try:
|
||
log.info("connecting to Gemini (session #%d, uptime %.0fm)...",
|
||
session_num, uptime_min)
|
||
async with client.aio.live.connect(model=_MODEL, config=config) as session:
|
||
log.info("connected — speak anytime!")
|
||
consecutive_errors = 0
|
||
self._mic.flush()
|
||
self._done = asyncio.Event()
|
||
# Reset per-session primer state so re-priming on reconnect
|
||
# actually happens. The state watcher will re-prime as soon
|
||
# as it sees vision+face-rec (and place-rec) enabled.
|
||
self._gallery_version_primed = -1
|
||
self._zones_version_primed = -1
|
||
# Re-announce the active destination on reconnect.
|
||
self._nav_target = (-1, -1)
|
||
# Lazy-build the swap lock on the active asyncio loop.
|
||
if self._swap_lock is None:
|
||
self._swap_lock = asyncio.Lock()
|
||
|
||
# PURE VOICE is TOGGLE-AWARE: all loops run (cheap when
|
||
# their feature is off) — pure-ness is enforced per-feature
|
||
# (announce guards + tools) so enabling Camera/Movement in
|
||
# the dashboard works live, and disabling returns to pure.
|
||
try:
|
||
await asyncio.wait_for(
|
||
asyncio.gather(
|
||
self._send_mic_loop(session),
|
||
self._receive_loop(session),
|
||
self._send_frame_loop(session),
|
||
self._send_state_loop(session),
|
||
self._recognition_state_watcher(session),
|
||
self._audio_swap_loop(session),
|
||
),
|
||
timeout=_SESSION_TIMEOUT,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
log.warning("session timed out after %ds", _SESSION_TIMEOUT)
|
||
except asyncio.CancelledError:
|
||
log.warning("session cancelled")
|
||
|
||
log.info("session #%d ended — reconnecting in 1s", session_num)
|
||
self._speaker.stop()
|
||
self._mic.flush()
|
||
await asyncio.sleep(1)
|
||
|
||
except asyncio.CancelledError:
|
||
log.info("cancelled — stopping")
|
||
break
|
||
except KeyboardInterrupt:
|
||
log.info("keyboard interrupt — stopping")
|
||
break
|
||
except Exception as exc:
|
||
consecutive_errors += 1
|
||
delay = min(_MAX_RECONNECT_DELAY, 2 ** consecutive_errors)
|
||
log.error("session error (#%d): %s — reconnecting in %ds",
|
||
consecutive_errors, exc, delay)
|
||
await asyncio.sleep(delay)
|
||
if consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
||
log.warning("%d consecutive errors — recreating client",
|
||
consecutive_errors)
|
||
try:
|
||
client = genai.Client(api_key=self._api_key)
|
||
consecutive_errors = 0
|
||
except Exception as ce:
|
||
log.error("client recreation failed: %s", ce)
|
||
|
||
# ─── Gemini config ────────────────────────────────────
|
||
|
||
def _build_config(self) -> types.LiveConnectConfig:
|
||
return types.LiveConnectConfig(
|
||
response_modalities=["AUDIO"],
|
||
speech_config=types.SpeechConfig(
|
||
voice_config=types.VoiceConfig(
|
||
prebuilt_voice_config=types.PrebuiltVoiceConfig(
|
||
voice_name=self._voice,
|
||
),
|
||
),
|
||
),
|
||
realtime_input_config=types.RealtimeInputConfig(
|
||
automatic_activity_detection=types.AutomaticActivityDetection(
|
||
disabled=False,
|
||
start_of_speech_sensitivity=getattr(
|
||
types.StartSensitivity,
|
||
_VAD.get("start_sensitivity", "START_SENSITIVITY_HIGH"),
|
||
),
|
||
# "Direct answer" option: HIGH = reply the moment the
|
||
# speaker pauses; otherwise the configured (LOW) default
|
||
# waits longer to be sure the speaker finished.
|
||
end_of_speech_sensitivity=(
|
||
types.EndSensitivity.END_SENSITIVITY_HIGH
|
||
if _DIRECT_ANSWER else getattr(
|
||
types.EndSensitivity,
|
||
_VAD.get("end_sensitivity", "END_SENSITIVITY_LOW"),
|
||
)),
|
||
prefix_padding_ms=_VAD.get("prefix_padding_ms", 20),
|
||
silence_duration_ms=_VAD.get("silence_duration_ms", 200),
|
||
),
|
||
),
|
||
input_audio_transcription=types.AudioTranscriptionConfig(),
|
||
output_audio_transcription=types.AudioTranscriptionConfig(),
|
||
system_instruction=types.Content(
|
||
parts=[types.Part(text=self._system_prompt)],
|
||
),
|
||
# Native function-calling: Gemini can drive the robot to saved
|
||
# places (navigate_to_place / list_places / where_am_i /
|
||
# stop_navigation). Disable with SANAD_NAV_TOOLS=0.
|
||
# Native function-calling: nav tools (if enabled) + the always-on
|
||
# expressive-face / social-QR tools (set_expression / show_social).
|
||
# PURE VOICE: no tools (the reply-latency tax) — EXCEPT nav tools
|
||
# when movement is enabled (walking commands need them). Face
|
||
# tools stay off in pure.
|
||
tools=self._build_tools(),
|
||
)
|
||
|
||
def _build_tools(self) -> Any:
|
||
"""Assemble the live-session function tools. PURE VOICE keeps this None
|
||
(the reply-latency tax) UNLESS movement is on (nav tools) or internet
|
||
search is on (the search tool). Face tools stay off in pure voice."""
|
||
decls: list = []
|
||
# Nav tools: whenever NOT (pure and not moving) and nav is enabled.
|
||
if _NAV_TOOLS_ENABLED and (self._movement_enabled or not _PURE_VOICE):
|
||
decls += _nav_function_declarations()
|
||
# Face/social tools: only outside pure voice.
|
||
if not _PURE_VOICE:
|
||
decls += _face_function_declarations()
|
||
# Internet-search tool: whenever the option is enabled (either mode).
|
||
if _INTERNET_SEARCH:
|
||
decls += _search_function_declaration()
|
||
return [types.Tool(function_declarations=decls)] if decls else None
|
||
|
||
# ─── state helpers ────────────────────────────────────
|
||
|
||
def _reset_turn_state(self) -> None:
|
||
self._speaking = False
|
||
self._stream_started = False
|
||
self._barge_block_until = 0.0
|
||
self._ai_speak_start = 0.0
|
||
self._last_ai_audio = 0.0
|
||
# Rolling estimate of the speaker bleed picked up by the mic while the AI
|
||
# talks (JBL profile) — the barge-in threshold floats above this.
|
||
self._bleed_ewma = 0.0
|
||
# Suppress the unprompted greeting on a RECONNECT (set per-session in
|
||
# run() for session_num>1). The idle watchdog reconnects every ~30s when
|
||
# no one talks, and a fresh session greets each time ("مرحبابك…") which
|
||
# floods the shared chest speaker and stomps record playback. We drop
|
||
# that greeting's audio until the user actually speaks. Default False so
|
||
# the FIRST session (startup) greets normally.
|
||
self._suppress_greeting = False
|
||
|
||
def _interrupt(self, source: str = "local") -> None:
|
||
self._speaking = False
|
||
self._stream_started = False
|
||
self._speaker.stop()
|
||
self._mic.flush()
|
||
self._recorder.finish_turn()
|
||
log.info("interrupt (%s)", source)
|
||
|
||
# ─── mic send loop ────────────────────────────────────
|
||
|
||
async def _send_mic_loop(self, session: Any) -> None:
|
||
threshold = _BI.get("threshold", 500)
|
||
chunks_needed = _BI.get("loud_chunks_needed", 3)
|
||
cooldown = _BI.get("cooldown_sec", 0.3)
|
||
echo_suppress_below = _BI.get("echo_suppress_below", 500)
|
||
grace = _BI.get("ai_speak_grace_sec", 0.15)
|
||
|
||
loop = asyncio.get_event_loop()
|
||
loud_count = 0
|
||
last_activity = time.time()
|
||
force_silence_until = 0.0 # Answer-now button: silence burst window
|
||
|
||
while not self._done.is_set() and not self._stop_flag.is_set():
|
||
try:
|
||
raw = await loop.run_in_executor(
|
||
None, self._mic.read_chunk, _CHUNK_BYTES,
|
||
)
|
||
except Exception:
|
||
break
|
||
|
||
samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32)
|
||
samples = np.clip(samples * _MIC_GAIN, -32768, 32767).astype(np.int16)
|
||
data = samples.tobytes()
|
||
energy = _audio_energy(data)
|
||
now = time.time()
|
||
|
||
# Dashboard Answer-now button: feed the server VAD a silence burst
|
||
# so it closes the user's turn NOW → immediate reply.
|
||
if _FORCE_ANSWER.is_set():
|
||
_FORCE_ANSWER.clear()
|
||
if not self._speaking:
|
||
force_silence_until = now + 0.6
|
||
log.info("ANSWER NOW (dashboard) — forcing turn end")
|
||
|
||
# Dashboard Interrupt-now button: cut speech instantly AND stay
|
||
# SILENT until the user speaks — arming the greeting suppression
|
||
# also swallows any in-flight or reconnect-greeting audio, so a
|
||
# press while "listening" can never be followed by Sanad talking
|
||
# on his own.
|
||
if _MANUAL_INTERRUPT.is_set():
|
||
_MANUAL_INTERRUPT.clear()
|
||
log.info("MANUAL INTERRUPT (dashboard)%s",
|
||
"" if self._speaking else " — idle; muted until user speaks")
|
||
self._interrupt("manual")
|
||
self._barge_block_until = now + cooldown
|
||
self._suppress_greeting = True
|
||
# On the JBL (loud external speaker) the head mic hears the robot's
|
||
# OWN voice as loud as the user. We FULLY gate the mic to Gemini while
|
||
# it speaks (+ a short echo tail) so it NEVER hears itself, and we
|
||
# DISABLE voice barge-in there — the bleed is as loud as your voice, so
|
||
# energy can't separate them and any attempt leaks the echo back to the
|
||
# model. (Reliable JBL interrupt needs AEC; the only PulseAudio mic is
|
||
# dead, so that's separate work.) The chest speaker (builtin) keeps
|
||
# light quiet-frame suppression + working barge-in (firmware AEC).
|
||
# The Beats Pill (USB) is the same regime as the JBL: loud external
|
||
# speaker with a raw AEC-less mic inches away — the mic feed to
|
||
# Gemini is ALWAYS fully gated while speaking (echo-safe). Voice
|
||
# barge-in on external speakers is an OPTION (_EXTERNAL_BARGE_IN):
|
||
# the LOCAL detector cuts the speaker using stricter parameters.
|
||
external = any(t in (self._current_profile_id or "")
|
||
for t in ("jbl", "beats"))
|
||
full_gate = external
|
||
allow_barge = (not external) or _EXTERNAL_BARGE_IN
|
||
|
||
# Barge-in: sustained user energy cuts the AI.
|
||
if self._speaking and allow_barge and now >= self._barge_block_until:
|
||
eff_grace = _JBL_BARGE_GRACE if external else grace
|
||
eff_threshold = threshold * _JBL_BLEED_MARGIN if external else threshold
|
||
eff_chunks = _JBL_BARGE_CHUNKS if external else chunks_needed
|
||
if (now - self._ai_speak_start) >= eff_grace:
|
||
if energy > eff_threshold:
|
||
loud_count += 1
|
||
else:
|
||
loud_count = max(0, loud_count - 1)
|
||
if loud_count > eff_chunks:
|
||
log.info("BARGE-IN (e=%d, external=%s)", energy, external)
|
||
self._interrupt("barge-in")
|
||
loud_count = 0
|
||
self._barge_block_until = now + cooldown
|
||
|
||
# Echo suppression: mask the mic so the model doesn't hear its own bleed.
|
||
send_data = data
|
||
if now < force_silence_until:
|
||
# Answer-now: mask the mic so the server VAD sees pure silence.
|
||
send_data = _SILENCE_PCM
|
||
elif _INPUT_PAUSED.is_set():
|
||
# Paused for a record playback — feed silence so Gemini neither
|
||
# hears the record nor keeps talking over it.
|
||
send_data = _SILENCE_PCM
|
||
elif full_gate and (self._speaking
|
||
or (now - self._last_ai_audio) < _ECHO_TAIL_SEC):
|
||
# Loud external speaker: gate ALL frames while speaking + tail —
|
||
# this is what guarantees it never hears itself.
|
||
send_data = _SILENCE_PCM
|
||
elif self._speaking and energy < echo_suppress_below:
|
||
send_data = _SILENCE_PCM
|
||
|
||
# Record user audio when clearly speaking and AI isn't.
|
||
if energy > 250 and not self._speaking:
|
||
self._recorder.capture_user(data)
|
||
|
||
# Keep-alive watchdog
|
||
if energy > 250:
|
||
last_activity = now
|
||
elif now - last_activity > 10:
|
||
log.info("alive (no speech %.0fs, e=%d)",
|
||
now - last_activity, energy)
|
||
last_activity = now
|
||
|
||
try:
|
||
await session.send_realtime_input(
|
||
audio=types.Blob(
|
||
data=send_data,
|
||
mime_type=f"audio/pcm;rate={SEND_SAMPLE_RATE}",
|
||
),
|
||
)
|
||
except asyncio.CancelledError:
|
||
return
|
||
except Exception as exc:
|
||
log.warning("mic send failed: %s — ending session", exc)
|
||
self._done.set()
|
||
return
|
||
|
||
await asyncio.sleep(CHUNK_SIZE / SEND_SAMPLE_RATE)
|
||
|
||
log.info("send_mic task ended")
|
||
|
||
# ─── receive loop ─────────────────────────────────────
|
||
|
||
async def _receive_loop(self, session: Any) -> None:
|
||
loop = asyncio.get_event_loop()
|
||
try:
|
||
while not self._done.is_set() and not self._stop_flag.is_set():
|
||
# Iterate session.receive() with a PER-MESSAGE timeout. A plain
|
||
# `async for` parks inside the generator on a silent/half-open
|
||
# stall (server stops sending but never closes the socket), so
|
||
# the no-message watchdog below — which only ran after the
|
||
# async-for ended a cycle — could not fire, and recovery waited
|
||
# out the 660s outer session cap. Driving __anext__ under
|
||
# wait_for(_NO_MESSAGES_TIMEOUT) detects a stall in ~Ns.
|
||
_recv_agen = session.receive()
|
||
_recv_it = _recv_agen.__aiter__()
|
||
_stalled = False
|
||
try:
|
||
while True:
|
||
try:
|
||
response = await asyncio.wait_for(
|
||
_recv_it.__anext__(),
|
||
timeout=_NO_MESSAGES_TIMEOUT,
|
||
)
|
||
except StopAsyncIteration:
|
||
break # generator exhausted — same as async-for end
|
||
except asyncio.TimeoutError:
|
||
_stalled = True
|
||
break
|
||
if self._done.is_set():
|
||
break
|
||
|
||
if hasattr(response, "go_away") and response.go_away is not None:
|
||
log.info("server going away — will reconnect")
|
||
self._done.set()
|
||
return
|
||
|
||
# Native function-calling: Gemini asks us to run a tool
|
||
# (navigation). Handle it + reply, then continue — a
|
||
# tool_call message carries no server_content to process.
|
||
tc = getattr(response, "tool_call", None)
|
||
if tc is not None and getattr(tc, "function_calls", None):
|
||
await self._handle_tool_calls(session, tc.function_calls)
|
||
continue
|
||
|
||
sc = response.server_content
|
||
if sc is None:
|
||
continue
|
||
|
||
if sc.interrupted is True:
|
||
if self._speaking:
|
||
log.info("Gemini interrupted")
|
||
self._interrupt("gemini")
|
||
continue
|
||
|
||
if sc.input_transcription:
|
||
text = (sc.input_transcription.text or "").strip()
|
||
if text and not self._speaking:
|
||
log.info("USER: %s", text)
|
||
self._recorder.add_user_text(text)
|
||
# The user actually said something (real
|
||
# transcription, not mic noise) → stop suppressing
|
||
# the reconnect greeting so Gemini's reply is heard.
|
||
if self._suppress_greeting:
|
||
self._suppress_greeting = False
|
||
log.info("reconnect greeting suppression "
|
||
"lifted — user spoke")
|
||
|
||
if sc.output_transcription:
|
||
text = (sc.output_transcription.text or "").strip()
|
||
if text:
|
||
# Emit as "BOT:" (no space before colon) so the
|
||
# supervisor's _track_line can parse it the same
|
||
# way it parses "USER:" — this is the channel the
|
||
# movement dispatcher (N2) reads Gemini's own
|
||
# spoken phrases from. Keep in lock-step with
|
||
# GeminiSubprocess._track_line.
|
||
log.info("BOT: %s", text)
|
||
self._recorder.add_robot_text(text)
|
||
|
||
if sc.model_turn:
|
||
for part in sc.model_turn.parts:
|
||
if part.inline_data and part.inline_data.data:
|
||
if _INPUT_PAUSED.is_set() or self._suppress_greeting:
|
||
# Drop Gemini's audio AND halt any in-flight
|
||
# stream at the source. Two cases: (1) a record
|
||
# is playing (_INPUT_PAUSED) — Gemini's per-chunk
|
||
# PlayStream("sanad") must not stomp the record on
|
||
# the shared chest speaker; (2) this is a reconnect
|
||
# and the user hasn't spoken — drop the unprompted
|
||
# re-greeting. Gated on _stream_started so STOP
|
||
# fires once (not per chunk); the next turn's
|
||
# begin_stream() clears the stop-flag and resumes.
|
||
if self._stream_started:
|
||
await loop.run_in_executor(
|
||
None, self._speaker.stop)
|
||
self._stream_started = False
|
||
self._speaking = False
|
||
continue
|
||
now = time.time()
|
||
if not self._speaking:
|
||
self._ai_speak_start = now
|
||
self._speaking = True
|
||
self._last_ai_audio = now
|
||
raw_audio = part.inline_data.data
|
||
self._recorder.capture_robot(raw_audio)
|
||
audio = np.frombuffer(raw_audio, dtype=np.int16)
|
||
if not self._stream_started:
|
||
await loop.run_in_executor(
|
||
None, self._speaker.begin_stream,
|
||
)
|
||
self._stream_started = True
|
||
await loop.run_in_executor(
|
||
None, self._speaker.send_chunk,
|
||
audio, RECEIVE_SAMPLE_RATE,
|
||
)
|
||
# Lip-sync marker for the LED face mask: emit the
|
||
# mouth-open level (0..3) from this chunk's RMS,
|
||
# throttled. Parsed by GeminiSubprocess._reader_loop.
|
||
_mnow = time.time()
|
||
if not _PURE_VOICE and \
|
||
_mnow - getattr(self, "_mouth_t", 0.0) >= 0.08:
|
||
_rms = (float(np.sqrt(np.mean(
|
||
audio.astype(np.float32) ** 2))) if audio.size else 0.0)
|
||
# Lower thresholds bias the mouth more open
|
||
# so lip-sync reads strongly (vs. barely moving).
|
||
_lvl = (0 if _rms < 140 else 1 if _rms < 650
|
||
else 2 if _rms < 1700 else 3)
|
||
if (_lvl != getattr(self, "_mouth_lvl", -1)
|
||
or _mnow - getattr(self, "_mouth_t", 0.0) >= 0.2):
|
||
self._mouth_t = _mnow
|
||
self._mouth_lvl = _lvl
|
||
log.info("[[MOUTH:%d]]", _lvl)
|
||
|
||
if sc.turn_complete:
|
||
if (self._speaking and self._stream_started
|
||
and not self._speaker.interrupted):
|
||
log.info("speaker %.1fs", self._speaker.total_sent_sec)
|
||
await loop.run_in_executor(
|
||
None, self._speaker.wait_finish,
|
||
)
|
||
elif self._speaking and self._speaker.interrupted:
|
||
log.info("speaker interrupted")
|
||
self._speaking = False
|
||
self._stream_started = False
|
||
if getattr(self, "_mouth_lvl", 0) != 0:
|
||
self._mouth_lvl = 0
|
||
log.info("[[MOUTH:0]]") # close the LED-mask mouth
|
||
self._mic.flush()
|
||
self._recorder.finish_turn()
|
||
log.info("listening")
|
||
finally:
|
||
# Close the per-cycle receive generator so a stall/break
|
||
# doesn't leak it (the old `async for` closed it for us).
|
||
try:
|
||
await _recv_agen.aclose()
|
||
except Exception:
|
||
pass
|
||
|
||
if _stalled:
|
||
log.warning("no messages from Gemini for %ds — session dead",
|
||
_NO_MESSAGES_TIMEOUT)
|
||
break
|
||
await asyncio.sleep(0.1)
|
||
|
||
except Exception as exc:
|
||
log.warning("receive ended: %s", exc)
|
||
finally:
|
||
self._done.set()
|
||
|
||
# ─── vision-state announcer ───────────────────────────
|
||
# Injects the camera state into the live session as text context.
|
||
# On a live toggle Gemini is told to say so out loud ("I can see you
|
||
# now" / "I can't see you anymore"); at session start it's silent
|
||
# standing context so "can you see me?" is answered honestly.
|
||
|
||
async def _announce_vision_state(self, session: Any, enabled: bool,
|
||
is_toggle: bool) -> None:
|
||
if _PURE_VOICE and not enabled and not is_toggle:
|
||
# PURE: skip the "disabled" standing-context injection — only
|
||
# ENABLED features get announced/used (toggles always announce).
|
||
return
|
||
if is_toggle and enabled:
|
||
text = (
|
||
"[VISION ON] Your camera was just enabled — you can now see "
|
||
"the user through it. Briefly tell them you can see them now, "
|
||
"in your normal Khaleeji style (for example: "
|
||
"'هلا، الحين أشوفك زين')."
|
||
)
|
||
elif is_toggle and not enabled:
|
||
text = (
|
||
"[VISION OFF] Your camera was just disabled — you can no "
|
||
"longer see anything. Briefly tell the user you can't see "
|
||
"them anymore. If they later ask whether you can see them, "
|
||
"tell them to enable the camera from the dashboard."
|
||
)
|
||
elif enabled: # session start, camera already on
|
||
text = (
|
||
"[VISION STATUS] Your camera is ON — you can see the user "
|
||
"through it. Do not announce this unprompted; just answer "
|
||
"naturally if they ask what you see."
|
||
)
|
||
else: # session start, camera off
|
||
text = (
|
||
"[VISION STATUS] Your camera is OFF — you cannot see anything "
|
||
"right now. If the user asks whether you can see them, tell "
|
||
"them to enable the camera from the dashboard. Do not announce "
|
||
"this unprompted."
|
||
)
|
||
try:
|
||
await session.send_realtime_input(text=text)
|
||
log.info("vision-state injected (enabled=%s, toggle=%s)",
|
||
enabled, is_toggle)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
log.warning("vision-state inject failed: %s", exc)
|
||
|
||
# ─── face-recognition-state announcer ─────────────────
|
||
# Same idea as _announce_vision_state, for the face-recognition toggle.
|
||
# On a live OFF toggle it also tells Gemini to disregard the gallery —
|
||
# so OFF takes effect immediately instead of lingering until reconnect.
|
||
|
||
async def _announce_facerec_state(self, session: Any, enabled: bool,
|
||
is_toggle: bool) -> None:
|
||
if _PURE_VOICE and not enabled and not is_toggle:
|
||
# PURE: skip the "disabled" standing-context injection — only
|
||
# ENABLED features get announced/used (toggles always announce).
|
||
return
|
||
if is_toggle and enabled:
|
||
text = (
|
||
"[FACE RECOGNITION ON] Face recognition was just enabled — "
|
||
"you'll be shown the people you know in a moment. Briefly "
|
||
"tell the user you can now recognise the people you know, in "
|
||
"your normal Khaleeji style."
|
||
)
|
||
elif is_toggle and not enabled:
|
||
text = (
|
||
"[FACE RECOGNITION OFF] Face recognition was just disabled. "
|
||
"Disregard the face gallery you were given earlier — stop "
|
||
"greeting people by name and do not identify anyone. Briefly "
|
||
"tell the user you'll no longer recognise faces."
|
||
)
|
||
elif enabled: # session start, face rec already on
|
||
text = (
|
||
"[FACE RECOGNITION STATUS] Face recognition is ON — when you "
|
||
"see someone you've been shown in the gallery, greet them by "
|
||
"name. Do not announce this unprompted."
|
||
)
|
||
else: # session start, face rec off
|
||
text = (
|
||
"[FACE RECOGNITION STATUS] Face recognition is OFF — you "
|
||
"cannot identify people. If the user asks who someone is or "
|
||
"whether you recognise them, tell them to enable face "
|
||
"recognition from the dashboard. Do not announce this "
|
||
"unprompted."
|
||
)
|
||
try:
|
||
await session.send_realtime_input(text=text)
|
||
log.info("face-rec-state injected (enabled=%s, toggle=%s)",
|
||
enabled, is_toggle)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
log.warning("face-rec-state inject failed: %s", exc)
|
||
|
||
# ─── place-recognition-state announcer (N3) ───────────
|
||
# Same idea as _announce_facerec_state, for the places-gallery toggle.
|
||
# On a live OFF toggle it also tells Gemini to disregard the places it
|
||
# was given so OFF takes effect immediately instead of lingering.
|
||
|
||
async def _announce_zonerec_state(self, session: Any, enabled: bool,
|
||
is_toggle: bool) -> None:
|
||
if _PURE_VOICE and not enabled and not is_toggle:
|
||
# PURE: skip the "disabled" standing-context injection — only
|
||
# ENABLED features get announced/used (toggles always announce).
|
||
return
|
||
if is_toggle and enabled:
|
||
text = (
|
||
"[ZONE RECOGNITION ON] You were just given the zones and places "
|
||
"you know (and the people associated with them). Briefly tell "
|
||
"the user you now know your way around, in your normal Khaleeji "
|
||
"style."
|
||
)
|
||
elif is_toggle and not enabled:
|
||
text = (
|
||
"[ZONE RECOGNITION OFF] Zone recognition was just disabled. "
|
||
"Disregard the zones and places you were given earlier — stop "
|
||
"naming rooms or locations. Briefly tell the user you'll no "
|
||
"longer recognise places."
|
||
)
|
||
elif enabled: # session start, zone rec already on
|
||
text = (
|
||
"[ZONE RECOGNITION STATUS] Zone recognition is ON — when you see "
|
||
"or are asked about a zone/place you've been told about, you may "
|
||
"name it and use its description. Do not announce this "
|
||
"unprompted."
|
||
)
|
||
else: # session start, zone rec off
|
||
text = (
|
||
"[ZONE RECOGNITION STATUS] Zone recognition is OFF — you do not "
|
||
"know any specific zones or places. If the user asks where they "
|
||
"are or to go somewhere by name, tell them to enable zone "
|
||
"recognition from the dashboard. Do not announce this "
|
||
"unprompted."
|
||
)
|
||
try:
|
||
await session.send_realtime_input(text=text)
|
||
log.info("zone-rec-state injected (enabled=%s, toggle=%s)",
|
||
enabled, is_toggle)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
log.warning("zone-rec-state inject failed: %s", exc)
|
||
|
||
# ─── navigation-target announcer (N3 "go here") ───────
|
||
# When the operator sets a destination, tell Gemini which place to go to
|
||
# and show it the reference photo(s). Actual robot motion is wired by N2;
|
||
# this establishes the goal + visual reference.
|
||
|
||
async def _announce_nav_target(self, session: Any,
|
||
zone_id: int, place_id: int) -> None:
|
||
if not zone_id or not place_id:
|
||
try:
|
||
await session.send_realtime_input(text=(
|
||
"[DESTINATION CLEARED] You have no specific destination right "
|
||
"now. Do not announce this unprompted."
|
||
))
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
log.warning("nav-clear inject failed: %s", exc)
|
||
return
|
||
try:
|
||
from Project.Sanad.vision.zone_gallery import ZoneGallery
|
||
gallery = ZoneGallery(_ZONES_DIR)
|
||
place = gallery.get_place(zone_id, place_id)
|
||
zone = gallery.get_zone(zone_id)
|
||
except Exception as exc:
|
||
log.warning("nav-target resolve failed: %s", exc)
|
||
return
|
||
if place is None:
|
||
log.info("nav-target zone_%d/place_%d not found — skipping", zone_id, place_id)
|
||
return
|
||
place_name = place.name or f"place {place_id}"
|
||
zone_name = (zone.name if zone else None) or f"zone {zone_id}"
|
||
instr = (
|
||
f"[GO HERE] The user has set your destination to '{place_name}' in "
|
||
f"'{zone_name}'."
|
||
)
|
||
if place.description:
|
||
instr += f" Notes: {place.description}."
|
||
instr += (
|
||
" The image(s) below show what it looks like so you can recognise it. "
|
||
"If walking is enabled you will head there; if it is off, tell the "
|
||
"user to enable movement from the dashboard. Briefly acknowledge the "
|
||
"destination in your normal Khaleeji style."
|
||
)
|
||
parts: list[dict[str, Any]] = [{"text": instr}]
|
||
for p in place.sample_paths[:_FACES_MAX_SAMPLES]:
|
||
try:
|
||
raw = p.read_bytes()
|
||
except OSError:
|
||
continue
|
||
jpeg = gallery._resize_for_primer(raw, _FACES_PRIMER_RESIZE) or raw
|
||
parts.append({"inline_data": {"mime_type": "image/jpeg", "data": jpeg}})
|
||
try:
|
||
await session.send_client_content(
|
||
turns=[{"role": "user", "parts": parts}], turn_complete=True,
|
||
)
|
||
log.info("nav-target injected → zone_%d/place_%d (%s)",
|
||
zone_id, place_id, place_name)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
log.warning("nav-target inject failed: %s", exc)
|
||
|
||
# ─── navigation tool-call handler (Gemini function-calling) ───
|
||
# Gemini issues tool_calls (navigate_to_place / list_places / where_am_i /
|
||
# stop_navigation); we execute them against the dashboard nav API and reply
|
||
# with a FunctionResponse so the model can speak from the real result.
|
||
|
||
async def _handle_tool_calls(self, session: Any, function_calls: Any) -> None:
|
||
responses = []
|
||
for fc in function_calls:
|
||
name = getattr(fc, "name", "") or ""
|
||
try:
|
||
args = dict(getattr(fc, "args", None) or {})
|
||
except Exception:
|
||
args = {}
|
||
log.info("TOOL CALL: %s(%s)", name, args)
|
||
result = await self._dispatch_tool(name, args)
|
||
log.info("TOOL RESULT: %s → %s", name, result)
|
||
responses.append(types.FunctionResponse(
|
||
id=getattr(fc, "id", None), name=name, response=result,
|
||
))
|
||
if not responses:
|
||
return
|
||
try:
|
||
await session.send_tool_response(function_responses=responses)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
log.warning("send_tool_response failed: %s", exc)
|
||
|
||
async def _dispatch_tool(self, name: str, args: dict) -> dict:
|
||
try:
|
||
if name == "navigate_to_place":
|
||
# Respect the movement gate (kept fresh by the state watcher) so
|
||
# a nav call can't drive while walking is disabled.
|
||
if not self._movement_enabled:
|
||
return {"ok": False, "reason": "movement_off",
|
||
"say": "Movement is off — ask the user to enable it from the dashboard."}
|
||
place = str(args.get("place") or "").strip()
|
||
if not place:
|
||
return {"ok": False, "reason": "no_place"}
|
||
return await asyncio.to_thread(
|
||
_nav_api, "POST", "/api/nav/voice_goto", {"place": place})
|
||
if name == "list_places":
|
||
r = await asyncio.to_thread(_nav_api, "GET", "/api/nav/active", None)
|
||
if not isinstance(r, dict):
|
||
return {"ok": False, "reason": "bad_response"}
|
||
if r.get("reason"): # an error envelope from _nav_api
|
||
return r
|
||
return {"ok": True, "map": r.get("map"), "places": r.get("places", [])}
|
||
if name == "where_am_i":
|
||
r = await asyncio.to_thread(_nav_api, "GET", "/api/nav/active", None)
|
||
if not isinstance(r, dict):
|
||
return {"ok": False, "reason": "bad_response"}
|
||
if r.get("reason"):
|
||
return r
|
||
return {"ok": True, "map": r.get("map"),
|
||
"mode": r.get("mode_label"),
|
||
"ready": bool(r.get("bringup_alive")),
|
||
"localized": bool(r.get("localizing")),
|
||
"places": r.get("places", [])}
|
||
if name == "stop_navigation":
|
||
return await asyncio.to_thread(_nav_api, "POST", "/api/nav/cancel", None)
|
||
if name == "search_internet":
|
||
query = str(args.get("query") or "").strip()
|
||
if not query:
|
||
return {"ok": False, "reason": "no_query"}
|
||
log.info("[[SEARCH:%s]]", query) # breadcrumb for the dashboard
|
||
return await asyncio.to_thread(
|
||
_run_grounding_search, query, self._api_key)
|
||
if name == "set_expression":
|
||
emotion = str(args.get("emotion") or "").strip().lower()
|
||
if emotion not in _FACE_EMOTIONS:
|
||
return {"ok": False, "reason": "unknown_emotion"}
|
||
# The parent (GeminiSubprocess) relays [[FACE:…]] to the LED mask.
|
||
log.info("[[FACE:%s]]", emotion)
|
||
return {"ok": True, "shown": emotion}
|
||
if name == "show_social":
|
||
account = str(args.get("account") or "").strip().lower()
|
||
if account not in _SOCIAL_ACCOUNTS:
|
||
return {"ok": False, "reason": "unknown_account"}
|
||
log.info("[[SHOW:%s]]", account)
|
||
return {"ok": True, "showing": account}
|
||
return {"ok": False, "reason": "unknown_tool"}
|
||
except Exception as exc:
|
||
log.warning("tool %s error: %s", name, exc)
|
||
return {"ok": False, "reason": "error", "detail": str(exc)[:200]}
|
||
|
||
# ─── movement-state announcer (N2) ────────────────────
|
||
# Spoken confirmation when the operator enables / disables Gemini-driven
|
||
# locomotion from the dashboard. The actual movement dispatch loop lives
|
||
# in the parent; this only gives the user audible feedback on the toggle.
|
||
|
||
async def _announce_movement_state(self, session: Any, enabled: bool,
|
||
is_toggle: bool) -> None:
|
||
if _PURE_VOICE and not enabled and not is_toggle:
|
||
# PURE: skip the "disabled" standing-context injection — only
|
||
# ENABLED features get announced/used (toggles always announce).
|
||
return
|
||
if is_toggle and enabled:
|
||
text = (
|
||
"[MOVEMENT ON] Walking is now enabled — you can move when the "
|
||
"user asks. Briefly tell the user movement is enabled and they "
|
||
"can ask you to walk, in your normal Khaleeji style."
|
||
)
|
||
elif is_toggle and not enabled:
|
||
text = (
|
||
"[MOVEMENT OFF] Walking was just disabled — you must not move. "
|
||
"Briefly tell the user movement is now off. If they ask you to "
|
||
"walk, tell them to enable movement from the dashboard first."
|
||
)
|
||
elif enabled: # session start, movement already on
|
||
text = (
|
||
"[MOVEMENT STATUS] Walking is ON — you may move when asked. Do "
|
||
"not announce this unprompted."
|
||
)
|
||
else: # session start, movement off
|
||
text = (
|
||
"[MOVEMENT STATUS] Walking is OFF — you cannot move right now. "
|
||
"If the user asks you to walk, tell them to enable movement "
|
||
"from the dashboard. Do not announce this unprompted."
|
||
)
|
||
try:
|
||
await session.send_realtime_input(text=text)
|
||
log.info("movement-state injected (enabled=%s, toggle=%s)",
|
||
enabled, is_toggle)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
log.warning("movement-state inject failed: %s", exc)
|
||
|
||
# ─── audio profile hot-swap ───────────────────────────
|
||
# The parent (GeminiSubprocess) polls pactl for the Anker USB device
|
||
# and writes "profile:<json>" lines to our stdin. _stdin_watcher parses
|
||
# them into _PROFILE_PENDING; this loop drains the flag on the asyncio
|
||
# loop and performs the actual swap. The brain's read/write sites
|
||
# (_send_mic_loop / _receive_loop) keep using self._mic / self._speaker —
|
||
# an atomic ref reassignment is enough because nothing caches them in
|
||
# a loop-local variable (verified in exploration).
|
||
|
||
async def _audio_swap_loop(self, session: Any) -> None:
|
||
while not self._done.is_set() and not self._stop_flag.is_set():
|
||
await asyncio.sleep(0.25)
|
||
with _PROFILE_LOCK:
|
||
target = _PROFILE_PENDING.get("id")
|
||
reason = _PROFILE_PENDING.get("reason", "")
|
||
_PROFILE_PENDING["id"] = None
|
||
_PROFILE_PENDING["reason"] = ""
|
||
if not target or target == self._current_profile_id:
|
||
continue
|
||
try:
|
||
await self.swap_audio_devices(session, target, reason=reason)
|
||
except asyncio.CancelledError:
|
||
return
|
||
except Exception as exc:
|
||
log.warning("audio swap failed: %s", exc)
|
||
|
||
async def swap_audio_devices(self, session: Any, profile_id: str,
|
||
reason: str = "") -> None:
|
||
"""Hot-swap mic+speaker to `profile_id` without dropping the live
|
||
Gemini session. Idempotent (no-op if already on `profile_id`).
|
||
|
||
Order matters: start the new mic BEFORE we tear the old one down,
|
||
so a transient PyAudio failure (e.g. udev hasn't exposed Anker yet)
|
||
leaves the old backend in place. After 3 retries with backoff we
|
||
give up and log WARN — the watcher will retry on its next tick.
|
||
"""
|
||
if self._swap_lock is None:
|
||
log.warning("swap requested before session loop started — skipping")
|
||
return
|
||
async with self._swap_lock:
|
||
if profile_id == self._current_profile_id:
|
||
return
|
||
prev = self._current_profile_id
|
||
log.info("audio swap: %s → %s (reason=%s)", prev, profile_id, reason or "—")
|
||
|
||
# Build + start the new mic. Retry: pactl can see the device
|
||
# before PyAudio's get_device_count refreshes.
|
||
try:
|
||
from Project.Sanad.voice.audio_io import AudioIO as _AudioIO
|
||
except Exception as exc:
|
||
log.error("audio swap: AudioIO import failed: %s", exc)
|
||
return
|
||
new_mic = new_spk = None
|
||
last_exc: Optional[BaseException] = None
|
||
for attempt in range(3):
|
||
try:
|
||
new_mic, new_spk = _AudioIO.build_backends(
|
||
profile_id, audio_client=self._audio_client,
|
||
)
|
||
# mic.start() opens PyAudio + spawns reader thread.
|
||
# speaker is lazy (opens on first send_chunk).
|
||
await asyncio.to_thread(new_mic.start)
|
||
break
|
||
except Exception as exc:
|
||
last_exc = exc
|
||
# Tear down a partially-built backend so the next attempt
|
||
# gets a clean slate; don't leak PyAudio handles.
|
||
if new_mic is not None:
|
||
try:
|
||
await asyncio.to_thread(new_mic.stop)
|
||
except Exception:
|
||
pass
|
||
new_mic = new_spk = None
|
||
log.info("audio swap attempt %d failed: %s — retry in 0.4s",
|
||
attempt + 1, exc)
|
||
await asyncio.sleep(0.4)
|
||
if new_mic is None or new_spk is None:
|
||
log.warning("audio swap %s → %s: all 3 attempts failed (%s); "
|
||
"keeping current profile",
|
||
prev, profile_id, last_exc)
|
||
return
|
||
|
||
# Drain the old playback so any in-flight AI utterance stops
|
||
# (interrupts mid-word — acceptable per spec, <1s gap).
|
||
# MUST be awaited via to_thread: _PyAudioSpeaker.stop now
|
||
# takes a per-instance RLock and an in-flight send_chunk on
|
||
# the executor may be holding it across a back-pressured
|
||
# PortAudio write. Calling stop() synchronously on the
|
||
# event-loop thread would wedge the entire loop (mic,
|
||
# vision, session.receive) until the pulse buffer drains.
|
||
try:
|
||
await asyncio.to_thread(self._speaker.stop)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self._mic.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
# Atomic ref swap — next read_chunk / send_chunk hits new.
|
||
old_mic, old_spk = self._mic, self._speaker
|
||
self._mic = new_mic
|
||
self._speaker = new_spk
|
||
self._current_profile_id = profile_id
|
||
self._reset_turn_state()
|
||
|
||
# Tear down old AFTER the ref swap so any executor call still
|
||
# in flight finishes against the old handle and the next loop
|
||
# iteration picks up the new one cleanly.
|
||
try:
|
||
await asyncio.to_thread(old_mic.stop)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
await asyncio.to_thread(old_spk.stop)
|
||
except Exception:
|
||
pass
|
||
|
||
# Silent context to Gemini — so it knows the input chain changed
|
||
# if asked (matches the _announce_vision_state pattern).
|
||
try:
|
||
await session.send_realtime_input(text=(
|
||
f"[AUDIO SWITCH] Mic + speaker are now on the {profile_id!s} "
|
||
f"audio profile. Do not announce this unprompted; just keep "
|
||
f"replying normally — the user's voice may sound clearer or "
|
||
f"different on the new device."
|
||
))
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
log.warning("audio-swap announce failed: %s", exc)
|
||
log.info("audio swap complete: %s → %s", prev, profile_id)
|
||
|
||
# ─── recognition state watcher ────────────────────────
|
||
# Polls data/.recognition_state.json at SANAD_RECOGNITION_POLL_S Hz and
|
||
# mirrors vision_enabled / face_rec_enabled into in-memory flags so the
|
||
# rest of the session can react WITHOUT a Gemini reconnect.
|
||
|
||
async def _recognition_state_watcher(self, session: Any) -> None:
|
||
last_mtime_ns = -1
|
||
last_state = _recog_state.RecognitionState(
|
||
vision_enabled=self._vision_enabled,
|
||
face_rec_enabled=self._face_rec_enabled,
|
||
gallery_version=self._gallery_version_primed,
|
||
zone_rec_enabled=self._zone_rec_enabled,
|
||
zones_version=self._zones_version_primed,
|
||
movement_enabled=self._movement_enabled,
|
||
record_enabled=self._record_enabled,
|
||
)
|
||
# Best-effort initial primer if face_rec is already on at session start.
|
||
if self._face_rec_enabled and self._vision_enabled:
|
||
try:
|
||
cur = _recog_state.read(_RECOG_STATE_PATH)
|
||
await self._send_gallery_primer(session, cur.gallery_version)
|
||
except Exception as exc:
|
||
log.warning("initial gallery primer failed: %s", exc)
|
||
|
||
# N3 — initial zones primer if zone recognition is already on. Unlike
|
||
# faces this does NOT require vision: name+description-only places still
|
||
# give Gemini useful knowledge to talk about.
|
||
if self._zone_rec_enabled:
|
||
try:
|
||
cur = _recog_state.read(_RECOG_STATE_PATH)
|
||
await self._send_zone_primer(session, cur.zones_version)
|
||
except Exception as exc:
|
||
log.warning("initial zone primer failed: %s", exc)
|
||
|
||
# Tell Gemini the current camera + recognition + movement state at
|
||
# session start — silent standing context so "can you see me?" / "do
|
||
# you know who I am?" are answered honestly even if nothing is toggled.
|
||
await self._announce_vision_state(
|
||
session, self._vision_enabled, is_toggle=False,
|
||
)
|
||
await self._announce_facerec_state(
|
||
session, self._face_rec_enabled, is_toggle=False,
|
||
)
|
||
await self._announce_zonerec_state(
|
||
session, self._zone_rec_enabled, is_toggle=False,
|
||
)
|
||
await self._announce_movement_state(
|
||
session, self._movement_enabled, is_toggle=False,
|
||
)
|
||
# N3 — announce the active "go here" destination (if any). _nav_target
|
||
# was reset to (-1,-1) per session so this fires on every reconnect.
|
||
try:
|
||
cur = _recog_state.read(_RECOG_STATE_PATH)
|
||
nav = (cur.nav_target_zone_id, cur.nav_target_place_id)
|
||
if nav != self._nav_target:
|
||
await self._announce_nav_target(session, nav[0], nav[1])
|
||
self._nav_target = nav
|
||
except Exception as exc:
|
||
log.warning("initial nav-target announce failed: %s", exc)
|
||
|
||
while not self._done.is_set() and not self._stop_flag.is_set():
|
||
await asyncio.sleep(_RECOG_POLL_S)
|
||
try:
|
||
st = _RECOG_STATE_PATH.stat()
|
||
except FileNotFoundError:
|
||
continue
|
||
except Exception:
|
||
continue
|
||
# Use nanosecond mtime: write() does os.replace of a fresh
|
||
# tempfile, so two CRUD ops within one coarse mtime tick would
|
||
# share an identical whole-second st_mtime and the second change
|
||
# would be skipped on this tick. st_mtime_ns has far finer
|
||
# resolution, so a rapid second write is observed. (The
|
||
# version-diff logic below is still the ultimate safety net.)
|
||
if st.st_mtime_ns == last_mtime_ns:
|
||
continue
|
||
last_mtime_ns = st.st_mtime_ns
|
||
new_state = _recog_state.read(_RECOG_STATE_PATH)
|
||
|
||
# Vision toggle — instant. Announce it out loud so Gemini reacts
|
||
# ("I can see you now" / "I can't see you anymore").
|
||
if new_state.vision_enabled != last_state.vision_enabled:
|
||
self._vision_enabled = new_state.vision_enabled
|
||
log.info("vision toggled → %s", self._vision_enabled)
|
||
await self._announce_vision_state(
|
||
session, self._vision_enabled, is_toggle=True,
|
||
)
|
||
|
||
# Face-rec toggle — announce it out loud. The OFF announcement
|
||
# also tells Gemini to disregard the gallery, so OFF takes effect
|
||
# immediately instead of lingering until the next reconnect.
|
||
if new_state.face_rec_enabled != last_state.face_rec_enabled:
|
||
self._face_rec_enabled = new_state.face_rec_enabled
|
||
if self._face_rec_enabled:
|
||
log.info("face rec enabled — announcing + sending primer")
|
||
else:
|
||
log.info("face rec disabled — telling Gemini to "
|
||
"disregard the gallery")
|
||
await self._announce_facerec_state(
|
||
session, self._face_rec_enabled, is_toggle=True,
|
||
)
|
||
|
||
# Conditions for re-priming:
|
||
# - face_rec just turned ON (no_face_rec_before)
|
||
# - gallery version bumped since the last primer
|
||
face_rec_just_on = (
|
||
new_state.face_rec_enabled and not last_state.face_rec_enabled
|
||
)
|
||
gallery_changed = (
|
||
new_state.gallery_version != self._gallery_version_primed
|
||
)
|
||
if (self._face_rec_enabled
|
||
and (face_rec_just_on or gallery_changed)
|
||
and self._vision_enabled):
|
||
try:
|
||
await self._send_gallery_primer(
|
||
session, new_state.gallery_version,
|
||
)
|
||
except Exception as exc:
|
||
log.warning("gallery primer failed: %s", exc)
|
||
|
||
# N3 — zone-recognition toggle (announce out loud, like face-rec).
|
||
if new_state.zone_rec_enabled != last_state.zone_rec_enabled:
|
||
self._zone_rec_enabled = new_state.zone_rec_enabled
|
||
log.info("zone rec toggled → %s", self._zone_rec_enabled)
|
||
await self._announce_zonerec_state(
|
||
session, self._zone_rec_enabled, is_toggle=True,
|
||
)
|
||
|
||
# Re-prime zones when zone-rec just turned ON or the zones version
|
||
# bumped (any zone/place/face-link/photo CRUD). No vision needed.
|
||
zone_rec_just_on = (
|
||
new_state.zone_rec_enabled and not last_state.zone_rec_enabled
|
||
)
|
||
zones_changed = (
|
||
new_state.zones_version != self._zones_version_primed
|
||
)
|
||
if self._zone_rec_enabled and (zone_rec_just_on or zones_changed):
|
||
try:
|
||
await self._send_zone_primer(
|
||
session, new_state.zones_version,
|
||
)
|
||
except Exception as exc:
|
||
log.warning("zone primer failed: %s", exc)
|
||
|
||
# N3 — "go here" destination changed (set or cleared). Announce +
|
||
# show the reference photo. Diffed against the announced tuple so a
|
||
# CRUD-only version bump above doesn't double-fire this.
|
||
nav = (new_state.nav_target_zone_id, new_state.nav_target_place_id)
|
||
if nav != self._nav_target:
|
||
self._nav_target = nav
|
||
await self._announce_nav_target(session, nav[0], nav[1])
|
||
|
||
# N2 — movement enable/disable toggle (spoken confirmation only).
|
||
if new_state.movement_enabled != last_state.movement_enabled:
|
||
self._movement_enabled = new_state.movement_enabled
|
||
log.info("movement toggled → %s", self._movement_enabled)
|
||
await self._announce_movement_state(
|
||
session, self._movement_enabled, is_toggle=True,
|
||
)
|
||
if _PURE_VOICE:
|
||
# PURE: tools are baked per-session (nav tools only while
|
||
# movement is on) — cycle the session so the toggle takes
|
||
# effect on the function-calling side too.
|
||
log.info("pure-voice: cycling session to rebake nav tools")
|
||
self._done.set()
|
||
|
||
# Auto-record toggle — flip the recorder live (no session restart).
|
||
if new_state.record_enabled != last_state.record_enabled:
|
||
self._record_enabled = new_state.record_enabled
|
||
try:
|
||
self._recorder.enabled = self._record_enabled
|
||
except Exception:
|
||
pass
|
||
log.info("auto-record toggled → %s", self._record_enabled)
|
||
|
||
last_state = new_state
|
||
|
||
# ─── camera frame send loop ───────────────────────────
|
||
# Reads the latest JPEG from the _LATEST_FRAME cache (fed by the
|
||
# _stdin_watcher thread, which the GeminiSubprocess supervisor pushes
|
||
# 'frame:<b64>' lines into) and relays it to Gemini Live at
|
||
# _VISION_SEND_HZ. Only active when self._vision_enabled. Skips frames
|
||
# older than _VISION_STALE_MS so a stopped/unplugged camera doesn't
|
||
# waste tokens on a frozen scene.
|
||
|
||
async def _send_frame_loop(self, session: Any) -> None:
|
||
period = 1.0 / max(0.5, _VISION_SEND_HZ)
|
||
stale_s = _VISION_STALE_MS / 1000.0
|
||
backoff = 0.0
|
||
last_sent_ts = 0.0
|
||
|
||
while not self._done.is_set() and not self._stop_flag.is_set():
|
||
await asyncio.sleep(max(period, backoff))
|
||
if not self._vision_enabled:
|
||
continue
|
||
# Never compete with the INCOMING audio stream while the AI is
|
||
# speaking — a frame upload on the shared websocket delays audio
|
||
# chunks, which breaks the chest speaker (tiny firmware buffer;
|
||
# the pulse/Pill path hides it behind its own buffering).
|
||
if self._speaking or (time.time() - self._last_ai_audio) < 0.5:
|
||
continue
|
||
with _LATEST_FRAME_LOCK:
|
||
data = _LATEST_FRAME.get("bytes")
|
||
ts = _LATEST_FRAME.get("ts", 0.0)
|
||
if not data:
|
||
continue
|
||
# Stale — supervisor stopped pushing (camera off / unplugged).
|
||
if (time.time() - ts) > stale_s:
|
||
continue
|
||
# De-dup — don't re-send a frame we already relayed.
|
||
if ts == last_sent_ts:
|
||
continue
|
||
try:
|
||
await session.send_realtime_input(
|
||
video=types.Blob(data=data, mime_type="image/jpeg"),
|
||
)
|
||
last_sent_ts = ts
|
||
backoff = 0.0
|
||
except asyncio.CancelledError:
|
||
return
|
||
except Exception as exc:
|
||
log.warning("frame send failed: %s", exc)
|
||
backoff = min(backoff * 2 + 0.5, 5.0)
|
||
|
||
# ─── motion-state inject loop ─────────────────────────
|
||
# Drains _STATE_PENDING (fed by the _stdin_watcher from 'state:' lines
|
||
# the supervisor pushes when the arm starts/finishes/errors a motion)
|
||
# and injects each as silent text context into the live session, so
|
||
# Gemini can answer "what are you doing?" honestly. Per persona, Gemini
|
||
# reads these for context but does not narrate them unprompted.
|
||
|
||
async def _send_state_loop(self, session: Any) -> None:
|
||
while not self._done.is_set() and not self._stop_flag.is_set():
|
||
await asyncio.sleep(0.1)
|
||
with _STATE_LOCK:
|
||
if not _STATE_PENDING:
|
||
continue
|
||
pending = list(_STATE_PENDING)
|
||
_STATE_PENDING.clear()
|
||
for msg in pending:
|
||
try:
|
||
await session.send_realtime_input(text=msg)
|
||
log.info("STATE injected: %s", msg)
|
||
except asyncio.CancelledError:
|
||
return
|
||
except Exception as exc:
|
||
# Some SDK versions may not accept text on
|
||
# send_realtime_input — log once-ish and keep going;
|
||
# motion still works, only this context channel is lost.
|
||
log.warning("state inject failed: %s", exc)
|
||
|
||
# ─── face gallery primer ──────────────────────────────
|
||
# Builds one multimodal turn carrying the entire face gallery + a Khaleeji
|
||
# greeting instruction, and sends it via send_client_content. Gemini keeps
|
||
# this in session context until reconnect. Re-sent on gallery_version bumps.
|
||
|
||
async def _send_gallery_primer(self, session: Any, version: int) -> None:
|
||
try:
|
||
from Project.Sanad.vision.face_gallery import FaceGallery
|
||
except Exception as exc:
|
||
log.info("face gallery module unavailable: %s", exc)
|
||
return
|
||
|
||
gallery = FaceGallery(_FACES_DIR)
|
||
try:
|
||
entries = gallery.load_for_primer(
|
||
max_samples_per_face=_FACES_MAX_SAMPLES,
|
||
resize_long_side=_FACES_PRIMER_RESIZE,
|
||
)
|
||
except Exception as exc:
|
||
log.warning("face gallery load failed: %s", exc)
|
||
return
|
||
|
||
if not entries:
|
||
log.info("face gallery empty — primer skipped (v.%d)", version)
|
||
self._gallery_version_primed = version
|
||
return
|
||
|
||
parts: list[dict[str, Any]] = [{
|
||
"text": (
|
||
"GALLERY PRIMER (do not reply to this turn). "
|
||
"Below are people you know. When the live camera shows one of "
|
||
"them, greet them warmly by name in UAE Khaleeji dialect "
|
||
"(for example: 'هلا والله يا كسام، شحالك؟'), and you may use "
|
||
"the notes about them to make the conversation personal. "
|
||
"For faces NOT in this gallery, welcome them as a guest "
|
||
"without inventing a name. Greet each person only once per "
|
||
"minute to avoid repetition."
|
||
),
|
||
}]
|
||
for entry, jpegs in entries:
|
||
label = (
|
||
f"This person is named {entry.name}."
|
||
if entry.name
|
||
else "This person's name is unknown — greet as guest."
|
||
)
|
||
if entry.description:
|
||
label += f" Notes about them: {entry.description}"
|
||
parts.append({"text": f"\n— {label}"})
|
||
for jpeg in jpegs:
|
||
parts.append({
|
||
"inline_data": {"mime_type": "image/jpeg", "data": jpeg},
|
||
})
|
||
|
||
try:
|
||
await session.send_client_content(
|
||
turns=[{"role": "user", "parts": parts}],
|
||
turn_complete=True,
|
||
)
|
||
except Exception as exc:
|
||
log.warning("primer send failed: %s", exc)
|
||
return
|
||
self._gallery_version_primed = version
|
||
log.info("face gallery primed: %d person(s), v.%d", len(entries), version)
|
||
|
||
# ─── zones primer (N3) ────────────────────────────────
|
||
# One multimodal turn carrying every zone, its places (name + description +
|
||
# reference photos), and the people linked to each place. A place may have
|
||
# NO photos (name + description only), so empty image lists are tolerated.
|
||
|
||
async def _send_zone_primer(self, session: Any, version: int) -> None:
|
||
try:
|
||
from Project.Sanad.vision.zone_gallery import ZoneGallery
|
||
except Exception as exc:
|
||
log.info("zone gallery module unavailable: %s", exc)
|
||
return
|
||
|
||
gallery = ZoneGallery(_ZONES_DIR)
|
||
try:
|
||
entries = gallery.load_for_primer(
|
||
max_samples_per_place=_FACES_MAX_SAMPLES,
|
||
resize_long_side=_FACES_PRIMER_RESIZE,
|
||
)
|
||
except Exception as exc:
|
||
log.warning("zone gallery load failed: %s", exc)
|
||
return
|
||
|
||
if not entries:
|
||
log.info("zone gallery empty — primer skipped (v.%d)", version)
|
||
self._zones_version_primed = version
|
||
return
|
||
|
||
# Resolve linked face ids → names once (cheap, small galleries).
|
||
face_names: dict[int, str] = {}
|
||
try:
|
||
from Project.Sanad.vision.face_gallery import FaceGallery
|
||
for fe in FaceGallery(_FACES_DIR).list():
|
||
if fe.name:
|
||
face_names[fe.id] = fe.name
|
||
except Exception:
|
||
pass
|
||
|
||
parts: list[dict[str, Any]] = [{
|
||
"text": (
|
||
"ZONES PRIMER (do not reply to this turn). Below are the zones "
|
||
"and places you know, with the people often found at each place. "
|
||
"Use them to answer where things are, to name a place when the "
|
||
"live camera shows one, and to make directions personal. Do not "
|
||
"invent zones or places that are not listed here."
|
||
),
|
||
}]
|
||
n_zones = n_places = 0
|
||
for zone, places in entries:
|
||
n_zones += 1
|
||
zhdr = f"\n# Zone: {zone.name or '(unnamed)'}"
|
||
if zone.description:
|
||
zhdr += f" — {zone.description}"
|
||
parts.append({"text": zhdr})
|
||
if not places:
|
||
parts.append({"text": " (no places yet)"})
|
||
for place, jpegs in places:
|
||
n_places += 1
|
||
label = f"\n - Place: {place.name or '(unnamed)'}"
|
||
if place.description:
|
||
label += f" — {place.description}"
|
||
people = [face_names[f] for f in place.face_ids if f in face_names]
|
||
if people:
|
||
label += f" | People often here: {', '.join(people)}"
|
||
parts.append({"text": label})
|
||
for jpeg in jpegs:
|
||
parts.append({
|
||
"inline_data": {"mime_type": "image/jpeg", "data": jpeg},
|
||
})
|
||
|
||
try:
|
||
await session.send_client_content(
|
||
turns=[{"role": "user", "parts": parts}],
|
||
turn_complete=True,
|
||
)
|
||
except Exception as exc:
|
||
log.warning("zone primer send failed: %s", exc)
|
||
return
|
||
self._zones_version_primed = version
|
||
log.info("zones primed: %d zone(s), %d place(s), v.%d",
|
||
n_zones, n_places, version)
|