291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""LiveVoiceLoop — voice-to-arm phrase trigger dispatcher.
|
|
|
|
Listens to user transcriptions from the GeminiSubprocess and, when a
|
|
configured wake phrase is matched, fires the corresponding arm action via
|
|
`motion.sanad_arm_controller.ARM`.
|
|
|
|
Mode toggle ("DEFERRED TRIGGER"):
|
|
- fire_on_wake_match=True fires the arm instantly on phrase match
|
|
(fast, no coordination with AI speech)
|
|
- fire_on_wake_match=False marks a pending action that fires when the
|
|
AI starts/finishes its reply (visually nicer
|
|
— robot answers, then moves)
|
|
|
|
This is Option-D integration: parallel to skill_registry, uses the full
|
|
gemini_interact phrase dictionary (sanad_arm.txt, 29 arm IDs, hundreds of
|
|
Arabic phrase variants).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
from Project.Sanad.config import SCRIPTS_DIR, BASE_DIR
|
|
from Project.Sanad.core.config_loader import section as _cfg_section
|
|
from Project.Sanad.core.logger import get_logger
|
|
from Project.Sanad.voice.text_utils import (
|
|
load_arm_phrase_dispatch,
|
|
maybe_trigger_arm,
|
|
)
|
|
|
|
log = get_logger("live_voice_loop")
|
|
|
|
_LV_CFG = _cfg_section("voice", "live_voice_loop")
|
|
# Filename from core.script_files (single source) — resolved under SCRIPTS_DIR
|
|
_SCRIPTS = _cfg_section("core", "script_files")
|
|
_ARM_TXT_NAME = _SCRIPTS.get("arm_phrases", "sanad_arm.txt")
|
|
SANAD_ARM_TXT = SCRIPTS_DIR / _ARM_TXT_NAME
|
|
TRIGGER_LOG_SIZE = _LV_CFG.get("trigger_log_size", 100)
|
|
POLL_INTERVAL_SEC = _LV_CFG.get("poll_interval_sec", 0.1)
|
|
DEFERRED_DEFAULT = _LV_CFG.get("deferred_default", False)
|
|
TRIGGER_ENABLED_DEFAULT = bool(_LV_CFG.get("trigger_enabled_default", False))
|
|
|
|
|
|
class LiveVoiceLoop:
|
|
"""Polls GeminiSubprocess transcripts → fires arm actions."""
|
|
|
|
def __init__(self, voice_client, arm, wake_mgr, audio_mgr):
|
|
self.voice_client = voice_client
|
|
self.arm = arm # Sanad's motion/arm_controller (not used for trigger)
|
|
self.wake_mgr = wake_mgr
|
|
self.audio_mgr = audio_mgr
|
|
|
|
self._running = False
|
|
self._poll_thread: threading.Thread | None = None
|
|
self._stop_event = threading.Event()
|
|
|
|
# Deferred-trigger toggle (fire on phrase match vs fire after AI responds)
|
|
self.deferred_mode = DEFERRED_DEFAULT
|
|
# Master arm-trigger gate — when False, transcripts are still
|
|
# captured (you can watch them on the dashboard) but NO arm
|
|
# actions fire. Defaults to OFF so the robot doesn't move
|
|
# unexpectedly until the operator opts in.
|
|
self.trigger_enabled = TRIGGER_ENABLED_DEFAULT
|
|
|
|
# Trigger history (dashboard log)
|
|
self.triggers: deque[dict[str, Any]] = deque(maxlen=TRIGGER_LOG_SIZE)
|
|
self.last_heard: str = ""
|
|
self.last_action: str = ""
|
|
|
|
# ASR dispatch state (SimpleNamespace — maybe_trigger_arm mutates attrs)
|
|
self.state = SimpleNamespace()
|
|
|
|
# Load sanad_arm.txt on first construction
|
|
self.wake_dispatch: dict[int, set[str]] = {}
|
|
self.option_by_id: dict[int, Any] = {}
|
|
self.sanad_arm: Any = None
|
|
self._load_dispatch()
|
|
|
|
# Snapshot of already-processed transcript lines so we don't re-fire
|
|
self._seen_transcripts: set[str] = set()
|
|
|
|
# ── phrase dispatch loader ────────────────────────────────────
|
|
def _load_dispatch(self):
|
|
try:
|
|
from Project.Sanad.motion.sanad_arm_controller import ARM, OPTION_LIST, OPTION_BY_ID
|
|
self.sanad_arm = ARM
|
|
self.option_by_id = OPTION_BY_ID
|
|
if SANAD_ARM_TXT.exists():
|
|
self.wake_dispatch = load_arm_phrase_dispatch(SANAD_ARM_TXT, OPTION_LIST)
|
|
log.info("loaded %d arm-action phrase sets from %s",
|
|
len(self.wake_dispatch), SANAD_ARM_TXT.name)
|
|
else:
|
|
log.warning("sanad_arm.txt missing at %s — arm trigger disabled",
|
|
SANAD_ARM_TXT)
|
|
except Exception as exc:
|
|
log.warning("arm dispatch unavailable: %s", exc)
|
|
self.sanad_arm = None
|
|
self.wake_dispatch = {}
|
|
|
|
# ── lifecycle ────────────────────────────────────────────────
|
|
async def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
self._stop_event.clear()
|
|
self._running = True
|
|
self._poll_thread = threading.Thread(
|
|
target=self._poll_loop, daemon=True, name="live_voice_loop")
|
|
self._poll_thread.start()
|
|
log.info("LiveVoiceLoop started (deferred=%s, dispatch=%d)",
|
|
self.deferred_mode, len(self.wake_dispatch))
|
|
|
|
async def stop(self) -> None:
|
|
self._stop_event.set()
|
|
self._running = False
|
|
log.info("LiveVoiceLoop stopped")
|
|
|
|
def set_deferred(self, enabled: bool) -> None:
|
|
self.deferred_mode = bool(enabled)
|
|
|
|
def set_trigger_enabled(self, enabled: bool) -> None:
|
|
"""Master arm-trigger gate. When False, phrase matches are ignored.
|
|
|
|
Toggle semantics (no queue memory across the gate):
|
|
- Always clears any in-flight pending trigger so a late
|
|
fallback fire can't happen after disable/enable.
|
|
- On enable: snapshots every transcript currently in the
|
|
live_sub deque as already-seen. Only NEW speech after this
|
|
moment will dispatch — phrases said while the gate was off
|
|
don't suddenly fire when you turn it back on.
|
|
"""
|
|
self.trigger_enabled = bool(enabled)
|
|
|
|
# Drop pending fallback timer — a queued "fire in 0.6s" from
|
|
# before the toggle must not leak across.
|
|
self.state._pending_arm_wave = False
|
|
self.state._pending_arm_wave_fired = False
|
|
self.state._pending_arm_trigger_fn = None
|
|
self.state._pending_arm_fallback_time = 0.0
|
|
|
|
snapshotted = 0
|
|
if self.trigger_enabled:
|
|
try:
|
|
from Project.Sanad.main import live_sub
|
|
if live_sub is not None:
|
|
for txt in list(live_sub.user_transcript):
|
|
if txt and txt not in self._seen_transcripts:
|
|
self._seen_transcripts.add(txt)
|
|
snapshotted += 1
|
|
except Exception as exc:
|
|
log.warning("set_trigger_enabled: snapshot failed: %s", exc)
|
|
|
|
log.info("trigger_enabled=%s (pending cleared, %d transcripts marked seen)",
|
|
self.trigger_enabled, snapshotted)
|
|
|
|
# ── poll loop ────────────────────────────────────────────────
|
|
def _poll_loop(self):
|
|
"""Poll GeminiSubprocess.user_transcript for new user texts AND
|
|
fire any deferred-mode arm trigger whose fallback timer elapsed.
|
|
|
|
Without the pending-check, a deferred trigger (`fire_on_wake_match=
|
|
False`) would only fire when the NEXT transcript arrives — so if
|
|
the user says one sentence and stops, the arm never moves.
|
|
"""
|
|
while not self._stop_event.is_set():
|
|
self._check_transcripts()
|
|
self._check_pending_trigger()
|
|
self._stop_event.wait(POLL_INTERVAL_SEC)
|
|
|
|
def _check_pending_trigger(self):
|
|
"""Fire a queued deferred trigger if its fallback time has passed."""
|
|
# Master gate — same check as _dispatch
|
|
if not self.trigger_enabled:
|
|
return
|
|
if not getattr(self.state, "_pending_arm_wave", False):
|
|
return
|
|
if getattr(self.state, "_pending_arm_wave_fired", False):
|
|
return
|
|
fn = getattr(self.state, "_pending_arm_trigger_fn", None)
|
|
if fn is None:
|
|
return
|
|
fallback_at = float(getattr(self.state, "_pending_arm_fallback_time", 0.0) or 0.0)
|
|
if fallback_at <= 0.0 or time.time() < fallback_at:
|
|
return
|
|
# Gate on arm idle — skip fire if a motion is already running
|
|
if self.sanad_arm is not None and getattr(self.sanad_arm, "_is_busy", False):
|
|
return
|
|
try:
|
|
fn()
|
|
except Exception as exc:
|
|
log.warning("deferred arm trigger failed: %s", exc)
|
|
finally:
|
|
self.state._pending_arm_wave_fired = True
|
|
self.state._pending_arm_wave = False
|
|
self.state._pending_arm_trigger_fn = None
|
|
|
|
def _check_transcripts(self):
|
|
try:
|
|
from Project.Sanad.main import live_sub
|
|
except Exception:
|
|
return
|
|
if live_sub is None:
|
|
return
|
|
# Pull recent transcripts
|
|
for text in list(live_sub.user_transcript):
|
|
if text in self._seen_transcripts:
|
|
continue
|
|
self._seen_transcripts.add(text)
|
|
self.last_heard = text
|
|
self._dispatch(text)
|
|
|
|
# Prune seen set when subprocess stops to free memory
|
|
if not live_sub.is_running() and len(self._seen_transcripts) > 500:
|
|
self._seen_transcripts.clear()
|
|
|
|
def _dispatch(self, transcript_text: str) -> None:
|
|
if not self.wake_dispatch or self.sanad_arm is None:
|
|
return
|
|
# Master gate — skip arm triggering entirely when disabled
|
|
if not self.trigger_enabled:
|
|
return
|
|
# Gate trigger on arm idle
|
|
if getattr(self.sanad_arm, "_is_busy", False):
|
|
return
|
|
|
|
fire_now = not self.deferred_mode
|
|
|
|
for action_id, phrases in self.wake_dispatch.items():
|
|
fn = self._make_trigger_fn(action_id)
|
|
fired = maybe_trigger_arm(
|
|
self.state, transcript_text, phrases,
|
|
fire_on_wake_match=fire_now,
|
|
arm_trigger_fn=fn,
|
|
)
|
|
if fired:
|
|
self._record_trigger(action_id, transcript_text, fire_now)
|
|
break
|
|
|
|
def _make_trigger_fn(self, action_id: int):
|
|
def _fire():
|
|
try:
|
|
self.sanad_arm.trigger_action_by_id(action_id)
|
|
except Exception as exc:
|
|
log.warning("arm trigger failed (id=%d): %s", action_id, exc)
|
|
return _fire
|
|
|
|
def _record_trigger(self, action_id: int, user_text: str, fired_now: bool):
|
|
opt = self.option_by_id.get(action_id)
|
|
action_name = opt.name if opt else f"id={action_id}"
|
|
self.last_action = action_name
|
|
self.triggers.append({
|
|
"time": datetime.now().strftime("%H:%M:%S"),
|
|
"user_text": user_text,
|
|
"action_id": action_id,
|
|
"action_name": action_name,
|
|
"mode": "instant" if fired_now else "deferred",
|
|
})
|
|
log.info("arm trigger %s (id=%d) for: %r [%s]",
|
|
action_name, action_id, user_text,
|
|
"instant" if fired_now else "deferred")
|
|
|
|
# ── status (dashboard) ───────────────────────────────────────
|
|
def status(self) -> dict[str, Any]:
|
|
pending = ""
|
|
pend_name = ""
|
|
if getattr(self.state, "_pending_arm_wave", False):
|
|
if self.state._pending_arm_trigger_fn is not None:
|
|
# We can't introspect the action id from fn (closure), but
|
|
# the last triggered line in self.triggers is likely the one.
|
|
pend_name = self.triggers[-1].get("action_name", "") if self.triggers else ""
|
|
pending = f"pending: {pend_name}"
|
|
|
|
return {
|
|
"running": self._running,
|
|
"deferred_mode": self.deferred_mode,
|
|
"trigger_enabled": self.trigger_enabled,
|
|
"last_heard": self.last_heard,
|
|
"pending_action": pending,
|
|
"last_action": self.last_action,
|
|
"audio_attached": self.audio_mgr is not None,
|
|
"arm_attached": self.sanad_arm is not None,
|
|
"gemini_connected": bool(
|
|
self.voice_client and self.voice_client.connected),
|
|
"dispatch_actions": len(self.wake_dispatch),
|
|
"triggers": list(self.triggers)[-30:],
|
|
}
|