Two-agent voice assistant (Cosmo + Люся) via OpenClaw Gateway. Streaming STT (Groq) + LLM + TTS (ElevenLabs) pipeline with keep-alive sessions, barge-in, and daily conversation sessions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
5.8 KiB
Python
171 lines
5.8 KiB
Python
import os
|
||
import sys
|
||
|
||
from .config import GATEWAY_URL, AGENT, FOLLOWUP_TIMEOUT, log
|
||
from .audio import record, record_with_timeout
|
||
from .tts import play_activation_sound, speak, stop_speaking
|
||
from .llm import ask_agent_stream, Conversation, is_reset_command
|
||
|
||
# Персистентные сессии — одна на день для каждого агента
|
||
_sessions: dict[str, Conversation] = {}
|
||
|
||
|
||
def _get_session(agent_id: str) -> Conversation:
|
||
"""Возвращает текущую сессию, создаёт новую если день сменился"""
|
||
conv = _sessions.get(agent_id)
|
||
if conv is None or conv.is_expired():
|
||
conv = Conversation(agent_id=agent_id)
|
||
_sessions[agent_id] = conv
|
||
print(f"🆕 Новая сессия для {agent_id}")
|
||
return conv
|
||
|
||
|
||
def _handle_reset(text: str, agent_id: str) -> bool:
|
||
"""Проверяет команду сброса. Возвращает True если сброс произошёл."""
|
||
if is_reset_command(text):
|
||
_sessions[agent_id] = Conversation(agent_id=agent_id)
|
||
msg = "Начинаю новую сессию."
|
||
print(f"🔄 {msg}")
|
||
speak(msg, agent_id)
|
||
return True
|
||
return False
|
||
|
||
|
||
def run_with_enter():
|
||
print("\n🦞 Cosmo Satellite запущен (режим: Enter для активации)")
|
||
print(f" Gateway : {GATEWAY_URL}")
|
||
print(f" Агент : {AGENT}")
|
||
print("\nНажми Enter → говори → получи ответ. Ctrl+C для выхода.\n")
|
||
|
||
while True:
|
||
try:
|
||
input("⏎ Нажми Enter и говори...")
|
||
stop_speaking() # barge-in: прервать если ещё говорит
|
||
play_activation_sound()
|
||
|
||
conv = _get_session("cosmo")
|
||
|
||
while True:
|
||
text = record()
|
||
if not text:
|
||
print("⚠️ Ничего не распознано")
|
||
break
|
||
|
||
print(f"📝 Ты: {text}")
|
||
|
||
if _handle_reset(text, "cosmo"):
|
||
conv = _get_session("cosmo")
|
||
break
|
||
|
||
response = ask_agent_stream(text, conv=conv)
|
||
print(f"🤖 Cosmo: {response}\n")
|
||
|
||
print(f"👂 Слушаю продолжение ({int(FOLLOWUP_TIMEOUT)} сек)...")
|
||
followup = record_with_timeout(timeout=FOLLOWUP_TIMEOUT)
|
||
|
||
if not followup:
|
||
print("😴 Нет продолжения, жду активации...\n")
|
||
break
|
||
|
||
text = followup
|
||
|
||
except KeyboardInterrupt:
|
||
print("\n👋 Выход")
|
||
break
|
||
except Exception as e:
|
||
log.exception("Непредвиденная ошибка в цикле Enter")
|
||
print(f"⚠️ Ошибка: {e} — продолжаю работу...\n")
|
||
|
||
|
||
def run_with_porcupine():
|
||
"""Режим продакшн — два wake word через Porcupine (для Pi)"""
|
||
import pvporcupine
|
||
import struct
|
||
|
||
from .config import AGENTS
|
||
|
||
porcupine_key = os.getenv("PORCUPINE_KEY")
|
||
wake_word_cosmo = os.getenv("WAKE_WORD_COSMO")
|
||
wake_word_lusya = os.getenv("WAKE_WORD_LUSYA")
|
||
|
||
if not porcupine_key:
|
||
print("❌ PORCUPINE_KEY не задан в .env")
|
||
sys.exit(1)
|
||
|
||
keyword_paths = []
|
||
wake_word_map = []
|
||
|
||
if wake_word_cosmo:
|
||
keyword_paths.append(wake_word_cosmo)
|
||
wake_word_map.append("cosmo")
|
||
if wake_word_lusya:
|
||
keyword_paths.append(wake_word_lusya)
|
||
wake_word_map.append("lusya")
|
||
|
||
if not keyword_paths:
|
||
print("❌ WAKE_WORD_COSMO или WAKE_WORD_LUSYA не заданы в .env")
|
||
sys.exit(1)
|
||
|
||
import pyaudio
|
||
|
||
porcupine = pvporcupine.create(
|
||
access_key=porcupine_key,
|
||
keyword_paths=keyword_paths,
|
||
)
|
||
|
||
audio = pyaudio.PyAudio()
|
||
stream = audio.open(
|
||
rate=porcupine.sample_rate,
|
||
channels=1,
|
||
format=pyaudio.paInt16,
|
||
input=True,
|
||
frames_per_buffer=porcupine.frame_length,
|
||
)
|
||
|
||
print("\n🦞 Cosmo Satellite запущен (режим: wake word)")
|
||
for agent_id in wake_word_map:
|
||
cfg = AGENTS[agent_id]
|
||
print(f" {cfg['name']:6s} : {cfg['gateway_url']} → {cfg['agent']}")
|
||
print(f"\nСкажи 'Космо' или 'Люся'...\n")
|
||
|
||
try:
|
||
while True:
|
||
try:
|
||
pcm = stream.read(porcupine.frame_length)
|
||
pcm = struct.unpack_from("h" * porcupine.frame_length, pcm)
|
||
|
||
keyword_index = porcupine.process(pcm)
|
||
if keyword_index >= 0:
|
||
agent_id = wake_word_map[keyword_index]
|
||
agent_name = AGENTS[agent_id]["name"]
|
||
stop_speaking() # barge-in: прервать если ещё говорит
|
||
print(f"✅ Услышал '{agent_name}'!")
|
||
play_activation_sound()
|
||
|
||
conv = _get_session(agent_id)
|
||
|
||
text = record()
|
||
if not text:
|
||
continue
|
||
|
||
print(f"📝 Ты → {agent_name}: {text}")
|
||
|
||
if _handle_reset(text, agent_id):
|
||
continue
|
||
|
||
response = ask_agent_stream(text, conv=conv, agent_id=agent_id)
|
||
print(f"🤖 {agent_name}: {response}\n")
|
||
|
||
except KeyboardInterrupt:
|
||
raise
|
||
except Exception as e:
|
||
log.exception("Непредвиденная ошибка в цикле Porcupine")
|
||
print(f"⚠️ Ошибка: {e} — продолжаю слушать...\n")
|
||
|
||
except KeyboardInterrupt:
|
||
print("\n👋 Выход")
|
||
finally:
|
||
stream.stop_stream()
|
||
audio.terminate()
|
||
porcupine.delete()
|