Files
home-voice-assistant/satellite/modes.py
Cosmo e4e7529063 feat(notifier): push state events to Smart Home Tablet overlay
Adds a thin HTTP bridge so the tablet at https://tablet.digital-home.site
shows a Siri-style overlay reflecting the current assistant state
(wake / command / response / idle / error). Non-fatal: if the tablet
is offline or TABLET_URL/VOICE_API_KEY are unset, events are silently
skipped and the assistant keeps working.

- satellite/notifier.py — POST /api/voice/event with bearer token,
  reused requests.Session for keep-alive, 1.5s timeout
- satellite/modes.py — emits wake on activation, command after STT,
  response after LLM, idle on timeout
- satellite/llm.py — emits error on gateway connection/timeout/HTTP
- .env.example documents TABLET_URL and VOICE_API_KEY

Tablet side (separate repo smart-home-tablet, commit 51c3d60) exposes
POST /api/voice/event + GET /api/voice/stream (SSE) and renders a
full-screen overlay in components/VoiceOverlay.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:43:01 +00:00

148 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from .config import GATEWAY_URL, AGENTS, FOLLOWUP_TIMEOUT, MAX_DURATION, log
from .audio import record
from .tts import speak, stop_speaking
from .llm import ask_agent_stream, is_reset_command, VOICE_SESSION_KEY
from . import notifier
WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5"))
def _handle_reset(text: str, agent_id: str) -> bool:
"""Команда сброса — отправляет slash-команду /new в OpenClaw (без озвучки ответа)."""
if not is_reset_command(text):
return False
cfg = AGENTS.get(agent_id, AGENTS["cosmo"])
print("🔄 Отправляю /new в OpenClaw")
try:
cfg["session"].post(
f"{cfg['gateway_url']}/v1/chat/completions",
headers={
"x-ocplatform-model": cfg["voice_model"],
"x-openclaw-session-key": cfg.get("session_key", VOICE_SESSION_KEY),
},
json={
"stream": False,
"messages": [{"role": "user", "content": "/new"}],
},
timeout=30,
)
except Exception:
log.exception("Не удалось отправить /new")
msg = "Начинаю новую сессию."
print(f"🔄 {msg}")
speak(msg, agent_id)
return True
def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
"""Основной цикл диалога.
Первая запись — с большим таймаутом (MAX_DURATION), дальше — короткий FOLLOWUP_TIMEOUT."""
first = True
while True:
timeout = MAX_DURATION if first else FOLLOWUP_TIMEOUT
first = False
text = record(initial_silence_timeout=timeout)
if not text:
print("😴 Тишина, жду активации...\n")
notifier.idle()
return
print(f"📝 Ты → {agent_name}: {text}")
notifier.command(text, agent_id)
if _handle_reset(text, agent_id):
continue
response = ask_agent_stream(text, agent_id=agent_id)
print(f"🤖 {agent_name}: {response}\n")
notifier.response(response, agent_id)
def run_with_enter():
print("\n🦞 Cosmo Satellite запущен (режим: Enter для активации)")
print(f" Gateway : {GATEWAY_URL}")
print("\nНажми Enter → говори → получи ответ. Ctrl+C для выхода.\n")
while True:
try:
input("⏎ Нажми Enter и говори...")
stop_speaking() # barge-in
notifier.wake("cosmo")
_conversation_loop("cosmo", "Cosmo")
except KeyboardInterrupt:
print("\n👋 Выход")
break
except Exception as e:
log.exception("Непредвиденная ошибка в цикле Enter")
print(f"⚠️ Ошибка: {e} — продолжаю работу...\n")
def run_with_porcupine():
import numpy as np
import pyaudio
from openwakeword.model import Model
cosmo_model = Model(
wakeword_models=[os.getenv("WAKE_WORD_COSMO")],
inference_framework="onnx",
)
# TODO: подключить Люсю — раскомментировать когда модель lusya обучена
# lusya_model = Model(
# wakeword_models=[os.getenv("WAKE_WORD_LUSYA")],
# inference_framework="onnx",
# )
audio = pyaudio.PyAudio()
# OpenWakeWord ожидает 16 kHz mono PCM 16-bit, фреймы по 1280 семплов (80 мс)
stream = audio.open(rate=16000, channels=1, format=pyaudio.paInt16,
input=True, frames_per_buffer=1280)
print("✅ Слушаю через OpenWakeWord...")
try:
while True:
try:
pcm = stream.read(1280, exception_on_overflow=False)
pcm = np.frombuffer(pcm, dtype=np.int16)
cosmo_score = cosmo_model.predict(pcm)["cosmo"]
if cosmo_score > 0.1:
print(f"PREDICTION cosmo: {cosmo_score:.3f}")
if cosmo_score > WAKE_THRESHOLD:
print("✅ Услышал 'Космо'!")
stop_speaking() # на случай если TTS ещё играет
notifier.wake("cosmo")
stream.stop_stream()
_conversation_loop("cosmo", "Cosmo")
cosmo_model.reset()
stream.start_stream()
continue
# TODO: Люся — раскомментировать когда модель готова
# lusya_score = lusya_model.predict(pcm)["lusya"]
# if lusya_score > WAKE_THRESHOLD:
# stop_speaking()
# stream.stop_stream()
# _conversation_loop("lusya", "Люся")
# lusya_model.reset()
# stream.start_stream()
# continue
except KeyboardInterrupt:
raise
except Exception as e:
log.exception("Непредвиденная ошибка в wake-word цикле")
print(f"⚠️ Ошибка: {e} — продолжаю слушать...\n")
except KeyboardInterrupt:
print("\n👋 Выход")
finally:
stream.close()
audio.terminate()