feat: VoiceCoach v2 — livekit-agents 1.5 personal voice assistant
Replaces pipecat 0.0.48 (6 manual patches, DuplicateIdentity reconnect storms) with livekit-agents 1.5 SDK. New features: - SvelteKit UI with live transcript, VAD ring, mode bar, vocal meter, history view - SQLite session store with WAL mode (cross-session memory) - Async SpeechBrain vocal/emotion analysis injected per turn - Homelab tools: SSH/docker, Prometheus alerts, Home Assistant, SearXNG search - OpenClaw LLM gateway preserved (qwen2.5:14b via Ollama) - 4 modes: coaching, vent, practice, briefing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl libsndfile1 ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s \
|
||||
CMD curl -sf http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["python", "main.py", "start"]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""VoiceCoach v2 — livekit-agents 1.5."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from livekit import rtc
|
||||
from livekit.agents import (
|
||||
Agent,
|
||||
AgentSession,
|
||||
AutoSubscribe,
|
||||
JobContext,
|
||||
RoomInputOptions,
|
||||
llm as lk_llm,
|
||||
)
|
||||
from livekit.plugins import openai as lk_openai
|
||||
from livekit.plugins import silero
|
||||
|
||||
import store
|
||||
from modes import DEFAULT_MODE, MODES, ModeConfig
|
||||
from tools import ALL_TOOLS
|
||||
from vocal import VocalAnalyzer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SPEACHES_URL = os.getenv("SPEACHES_URL", "http://192.168.86.27:8000")
|
||||
OPENCLAW_URL = os.getenv("OPENCLAW_URL", "http://voicecoach-openclaw:3100")
|
||||
OPENCLAW_TOKEN = os.getenv("OPENCLAW_TOKEN", "")
|
||||
WHISPER_MODEL = "deepdml/faster-whisper-large-v3-turbo-ct2"
|
||||
KOKORO_MODEL = "speaches-ai/Kokoro-82M-v1.0-ONNX-fp16"
|
||||
KOKORO_VOICE = os.getenv("KOKORO_VOICE", "af_nova")
|
||||
|
||||
|
||||
def _build_instructions(mode: ModeConfig, context: str = "") -> str:
|
||||
if context:
|
||||
return f"{mode.instructions}\n\n{context}"
|
||||
return mode.instructions
|
||||
|
||||
|
||||
class VoiceCoachAgent(Agent):
|
||||
def __init__(self, mode_name: str, context: str, session_id: str, room: rtc.Room):
|
||||
mode = MODES[mode_name]
|
||||
super().__init__(
|
||||
instructions=_build_instructions(mode, context),
|
||||
tools=ALL_TOOLS,
|
||||
)
|
||||
self._mode_name = mode_name
|
||||
self._session_id = session_id
|
||||
self._room = room
|
||||
self._vocal = VocalAnalyzer()
|
||||
|
||||
async def on_enter(self):
|
||||
await store.create_session(self._session_id, self._mode_name)
|
||||
self.session.generate_reply(
|
||||
instructions="Greet Jeremy warmly in one sentence. Be natural, not formal."
|
||||
)
|
||||
|
||||
async def on_exit(self):
|
||||
await store.end_session(self._session_id)
|
||||
|
||||
async def on_user_turn_completed(
|
||||
self,
|
||||
turn_ctx: lk_llm.ChatContext,
|
||||
new_message: lk_llm.ChatMessage,
|
||||
):
|
||||
content = new_message.text_content or ""
|
||||
mode = MODES[self._mode_name]
|
||||
|
||||
# Inject vocal state tag into message if mode uses it
|
||||
if mode.vocal_feedback and content:
|
||||
vocal = self._vocal.current
|
||||
if vocal.energy > 0.02:
|
||||
tag = vocal.to_tag()
|
||||
# Update message content to include vocal context
|
||||
new_message.content = [f"{tag}\n{content}"]
|
||||
|
||||
asyncio.create_task(
|
||||
store.add_message(
|
||||
self._session_id,
|
||||
"user",
|
||||
content,
|
||||
vocal_energy=self._vocal.current.energy,
|
||||
vocal_emotion=self._vocal.current.emotion,
|
||||
)
|
||||
)
|
||||
|
||||
await self._push({
|
||||
"type": "final_transcript",
|
||||
"text": content,
|
||||
"speaker": "user",
|
||||
})
|
||||
|
||||
def switch_mode(self, mode_name: str):
|
||||
if mode_name not in MODES:
|
||||
logger.warning("Unknown mode: %s", mode_name)
|
||||
return
|
||||
self._mode_name = mode_name
|
||||
self.update_instructions(_build_instructions(MODES[mode_name]))
|
||||
asyncio.create_task(self._push({"type": "mode_ack", "mode": mode_name}))
|
||||
logger.info("Mode → %s", mode_name)
|
||||
|
||||
async def _push(self, payload: dict):
|
||||
try:
|
||||
data = json.dumps(payload).encode()
|
||||
await self._room.local_participant.publish_data(data, reliable=True)
|
||||
except Exception as e:
|
||||
logger.debug("Data push failed: %s", e)
|
||||
|
||||
|
||||
async def entrypoint(ctx: JobContext):
|
||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||
|
||||
session_id = uuid.uuid4().hex[:8]
|
||||
context = await store.get_recent_context(limit=3)
|
||||
|
||||
agent = VoiceCoachAgent(
|
||||
mode_name=DEFAULT_MODE,
|
||||
context=context,
|
||||
session_id=session_id,
|
||||
room=ctx.room,
|
||||
)
|
||||
|
||||
session = AgentSession(
|
||||
stt=lk_openai.STT(
|
||||
base_url=f"{SPEACHES_URL}/v1",
|
||||
model=WHISPER_MODEL,
|
||||
api_key="speaches",
|
||||
),
|
||||
llm=lk_openai.LLM(
|
||||
base_url=f"{OPENCLAW_URL}/v1",
|
||||
model="openclaw/assistant",
|
||||
api_key=OPENCLAW_TOKEN,
|
||||
),
|
||||
tts=lk_openai.TTS(
|
||||
base_url=f"{SPEACHES_URL}/v1",
|
||||
model=KOKORO_MODEL,
|
||||
voice=KOKORO_VOICE,
|
||||
api_key="speaches",
|
||||
),
|
||||
vad=silero.VAD.load(),
|
||||
allow_interruptions=True,
|
||||
min_endpointing_delay=0.4,
|
||||
max_endpointing_delay=2.0,
|
||||
)
|
||||
|
||||
# Data channel → mode switching
|
||||
@ctx.room.on("data_received")
|
||||
def on_data(packet: rtc.DataPacket):
|
||||
try:
|
||||
msg = json.loads(packet.data.decode())
|
||||
if msg.get("type") == "mode_change":
|
||||
agent.switch_mode(msg.get("mode", DEFAULT_MODE))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Session events → browser
|
||||
@session.on("user_input_transcribed")
|
||||
def on_transcribed(ev):
|
||||
asyncio.create_task(agent._push({
|
||||
"type": "partial_transcript" if not ev.is_final else "final_transcript",
|
||||
"text": ev.transcript,
|
||||
"speaker": "user",
|
||||
}))
|
||||
|
||||
@session.on("conversation_item_added")
|
||||
def on_item(ev):
|
||||
item = ev.item
|
||||
if hasattr(item, "role") and item.role == "assistant":
|
||||
text = item.text_content or ""
|
||||
if text:
|
||||
asyncio.create_task(agent._push({
|
||||
"type": "agent_response",
|
||||
"text": text,
|
||||
"speaker": "assistant",
|
||||
}))
|
||||
asyncio.create_task(
|
||||
store.add_message(session_id, "assistant", text)
|
||||
)
|
||||
|
||||
await session.start(
|
||||
agent,
|
||||
room=ctx.room,
|
||||
room_input_options=RoomInputOptions(),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""VoiceCoach v2 — entry point."""
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from livekit.agents import WorkerOptions, cli
|
||||
|
||||
import server
|
||||
from agent import entrypoint
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def run_api_server():
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api_thread = threading.Thread(target=run_api_server, daemon=True)
|
||||
api_thread.start()
|
||||
|
||||
cli.run_app(
|
||||
WorkerOptions(
|
||||
entrypoint_fnc=entrypoint,
|
||||
ws_url=os.getenv("LIVEKIT_URL", "ws://voicecoach-livekit:7880"),
|
||||
api_key=os.getenv("LIVEKIT_API_KEY"),
|
||||
api_secret=os.getenv("LIVEKIT_API_SECRET"),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModeConfig:
|
||||
name: str
|
||||
instructions: str
|
||||
max_tokens: int
|
||||
vocal_feedback: bool
|
||||
|
||||
|
||||
MODES: dict[str, ModeConfig] = {
|
||||
"coaching": ModeConfig(
|
||||
name="coaching",
|
||||
instructions="""You are a personal voice coach and communication advisor. You are speaking with Jeremy.
|
||||
|
||||
Keep responses conversational and concise (2-4 sentences unless asked for more). You receive a [VOCAL] tag before each message with paralinguistic analysis. Use it to provide feedback on BOTH what Jeremy says AND how he sounds.
|
||||
|
||||
Never quote raw numbers from the vocal analysis. Translate them naturally: "I notice some tension in your voice" not "energy=0.8".
|
||||
|
||||
Available modes (Jeremy switches by saying the phrase or clicking the UI):
|
||||
- "coaching mode" (default) — active feedback on content and delivery
|
||||
- "vent mode" — listen and reflect, minimal advice
|
||||
- "practice mode" — roleplay a difficult conversation partner
|
||||
- "briefing mode" — summarize tasks and context from memory
|
||||
|
||||
You have tools to interact with Jeremy's homelab infrastructure. Use them when asked about system status, container health, alerts, or home automation. Be concise about results: "hf-pdocker-01 looks healthy, 38 containers running" not a full list.""",
|
||||
max_tokens=512,
|
||||
vocal_feedback=True,
|
||||
),
|
||||
"vent": ModeConfig(
|
||||
name="vent",
|
||||
instructions="""You are a warm, non-judgmental listener. Jeremy wants to vent or think something through.
|
||||
|
||||
Listen, reflect back what you hear, and ask one gentle follow-up question. Keep responses short (1-3 sentences). No coaching, no advice unless explicitly asked.
|
||||
|
||||
Ignore the [VOCAL] tag data — just be present.""",
|
||||
max_tokens=256,
|
||||
vocal_feedback=False,
|
||||
),
|
||||
"practice": ModeConfig(
|
||||
name="practice",
|
||||
instructions="""You are playing a difficult conversation partner for Jeremy to practice with.
|
||||
|
||||
Stay in character as: a skeptical stakeholder, a demanding manager, or whoever Jeremy describes. Be realistic — push back, ask hard questions, don't cave too easily. Only break character if Jeremy says "stop practice".
|
||||
|
||||
Keep your responses to 1-3 sentences to keep the conversation moving.""",
|
||||
max_tokens=256,
|
||||
vocal_feedback=False,
|
||||
),
|
||||
"briefing": ModeConfig(
|
||||
name="briefing",
|
||||
instructions="""You are giving Jeremy a morning briefing. Check infrastructure status, pull any alerts, and summarize what needs attention.
|
||||
|
||||
Be concise and actionable. Lead with anything that needs immediate attention, then routine status. Use tools to get current data — don't guess.""",
|
||||
max_tokens=768,
|
||||
vocal_feedback=False,
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_MODE = "coaching"
|
||||
@@ -0,0 +1,7 @@
|
||||
livekit-agents[openai,silero]==1.5.15
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
aiosqlite==0.20.0
|
||||
httpx==0.27.0
|
||||
structlog==24.4.0
|
||||
python-dotenv==1.0.1
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Token server + sessions REST API — FastAPI."""
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from livekit import api as livekit_api
|
||||
from pydantic import BaseModel
|
||||
|
||||
import store
|
||||
|
||||
LIVEKIT_API_KEY = os.environ["LIVEKIT_API_KEY"]
|
||||
LIVEKIT_API_SECRET = os.environ["LIVEKIT_API_SECRET"]
|
||||
LIVEKIT_PUBLIC_URL = os.getenv("LIVEKIT_PUBLIC_URL", "wss://voice.hobbs.farm")
|
||||
|
||||
app = FastAPI(title="VoiceCoach API")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
identity: str = "jeremy"
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await store.init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "2.0"}
|
||||
|
||||
|
||||
@app.post("/api/token")
|
||||
async def get_token(req: TokenRequest):
|
||||
token = livekit_api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
||||
token.with_grants(livekit_api.VideoGrants(
|
||||
room_join=True,
|
||||
room="voicecoach",
|
||||
can_publish=True,
|
||||
can_subscribe=True,
|
||||
))
|
||||
token.identity = req.identity
|
||||
token.ttl = timedelta(hours=8)
|
||||
return {"token": token.to_jwt(), "url": LIVEKIT_PUBLIC_URL}
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def list_sessions(limit: int = 20):
|
||||
return await store.get_sessions(limit=limit)
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/messages")
|
||||
async def get_messages(session_id: str):
|
||||
msgs = await store.get_session_messages(session_id)
|
||||
if not msgs:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return msgs
|
||||
|
||||
|
||||
@app.get("/api/profile/{key}")
|
||||
async def get_profile(key: str):
|
||||
value = await store.get_profile(key)
|
||||
if value is None:
|
||||
raise HTTPException(status_code=404, detail="Key not found")
|
||||
return {"key": key, "value": value}
|
||||
|
||||
|
||||
@app.put("/api/profile/{key}")
|
||||
async def set_profile_key(key: str, body: dict):
|
||||
await store.set_profile(key, str(body.get("value", "")))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def run():
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080, log_level="info")
|
||||
@@ -0,0 +1,140 @@
|
||||
"""SQLite session store — WAL mode, aiosqlite."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
DB_PATH = os.getenv("DB_PATH", "/data/sessions.db")
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
mode TEXT DEFAULT 'coaching',
|
||||
summary TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
vocal_energy REAL,
|
||||
vocal_emotion TEXT,
|
||||
ts REAL NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_session(session_id: str, mode: str = "coaching"):
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO sessions (id, started_at, mode) VALUES (?, ?, ?)",
|
||||
(session_id, time.time(), mode),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def end_session(session_id: str, summary: Optional[str] = None):
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"UPDATE sessions SET ended_at=?, summary=? WHERE id=?",
|
||||
(time.time(), summary, session_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def add_message(
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
vocal_energy: Optional[float] = None,
|
||||
vocal_emotion: Optional[str] = None,
|
||||
):
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"INSERT INTO messages (session_id, role, content, vocal_energy, vocal_emotion, ts) VALUES (?,?,?,?,?,?)",
|
||||
(session_id, role, content, vocal_energy, vocal_emotion, time.time()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_recent_context(limit: int = 3) -> str:
|
||||
"""Return a brief context string from the last N sessions for injection into system prompt."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute(
|
||||
"SELECT id, started_at, mode, summary FROM sessions WHERE ended_at IS NOT NULL ORDER BY ended_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
parts = ["Recent session context:"]
|
||||
for session_id, started_at, mode, summary in rows:
|
||||
import datetime
|
||||
dt = datetime.datetime.fromtimestamp(started_at).strftime("%Y-%m-%d")
|
||||
if summary:
|
||||
parts.append(f"- {dt} ({mode}): {summary}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def get_sessions(limit: int = 20) -> list[dict]:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute(
|
||||
"SELECT id, started_at, ended_at, mode, summary FROM sessions ORDER BY started_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"started_at": r[1],
|
||||
"ended_at": r[2],
|
||||
"mode": r[3],
|
||||
"summary": r[4],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_session_messages(session_id: str) -> list[dict]:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute(
|
||||
"SELECT role, content, vocal_emotion, ts FROM messages WHERE session_id=? ORDER BY ts",
|
||||
(session_id,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [{"role": r[0], "content": r[1], "vocal_emotion": r[2], "ts": r[3]} for r in rows]
|
||||
|
||||
|
||||
async def get_profile(key: str) -> Optional[str]:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute("SELECT value FROM profile WHERE key=?", (key,)) as cur:
|
||||
row = await cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def set_profile(key: str, value: str):
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO profile (key, value, updated_at) VALUES (?,?,?)",
|
||||
(key, value, time.time()),
|
||||
)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Homelab tools wired into the agent via @function_tool."""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from livekit.agents import llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Homelab node config
|
||||
NODES = {
|
||||
"hf-pdocker-01": "192.168.86.192",
|
||||
"hf-pdocker-02": "192.168.86.100",
|
||||
"bart": "192.168.86.167",
|
||||
"gpu": "192.168.86.27",
|
||||
}
|
||||
SSH_USER = "glyptic"
|
||||
SSH_PASS = os.getenv("SSH_PASS", "&Rip8a115")
|
||||
FARM_MANAGER_URL = "http://192.168.86.192:8888"
|
||||
HA_URL = os.getenv("HA_URL", "http://192.168.86.192:8123")
|
||||
HA_TOKEN = os.getenv("HA_TOKEN", "")
|
||||
ALERTMANAGER_URL = "http://192.168.86.192:9093"
|
||||
SEARXNG_URL = "http://192.168.86.100:8888"
|
||||
|
||||
|
||||
async def _ssh_exec(host: str, cmd: str, timeout: float = 15.0) -> str:
|
||||
"""Run a command on a remote host via sshpass."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sshpass", "-p", SSH_PASS,
|
||||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
|
||||
f"{SSH_USER}@{host}", cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return f"ERROR: command timed out after {timeout}s"
|
||||
if proc.returncode != 0:
|
||||
return f"ERROR: {stderr.decode().strip()}"
|
||||
return stdout.decode().strip()
|
||||
|
||||
|
||||
@llm.function_tool
|
||||
async def infra_status(
|
||||
node: Annotated[str, "Node name: hf-pdocker-01, hf-pdocker-02, bart, gpu, or 'all'"],
|
||||
) -> str:
|
||||
"""Check container health on a homelab node. Returns running container count and any unhealthy containers."""
|
||||
nodes = list(NODES.keys()) if node == "all" else [node]
|
||||
results = []
|
||||
for n in nodes:
|
||||
ip = NODES.get(n)
|
||||
if not ip:
|
||||
results.append(f"{n}: unknown node")
|
||||
continue
|
||||
out = await _ssh_exec(ip, "docker ps --format '{{.Names}}\\t{{.Status}}' | head -50")
|
||||
lines = [l for l in out.splitlines() if l.strip()]
|
||||
unhealthy = [l for l in lines if "unhealthy" in l.lower() or "exited" in l.lower()]
|
||||
results.append(
|
||||
f"{n}: {len(lines)} containers running"
|
||||
+ (f", ISSUES: {'; '.join(l.split(chr(9))[0] for l in unhealthy)}" if unhealthy else ", all healthy")
|
||||
)
|
||||
return "\n".join(results)
|
||||
|
||||
|
||||
@llm.function_tool
|
||||
async def container_action(
|
||||
node: Annotated[str, "Node name: hf-pdocker-01, hf-pdocker-02, bart, gpu"],
|
||||
container: Annotated[str, "Container name"],
|
||||
action: Annotated[str, "Action: restart, stop, or start"],
|
||||
) -> str:
|
||||
"""Restart, stop, or start a Docker container on a homelab node."""
|
||||
ip = NODES.get(node)
|
||||
if not ip:
|
||||
return f"Unknown node: {node}"
|
||||
if action not in ("restart", "stop", "start"):
|
||||
return f"Invalid action: {action}. Use restart, stop, or start."
|
||||
result = await _ssh_exec(ip, f"docker {action} {container}")
|
||||
return f"{action} {container} on {node}: {'done' if container in result or result == '' else result}"
|
||||
|
||||
|
||||
@llm.function_tool
|
||||
async def check_logs(
|
||||
node: Annotated[str, "Node name"],
|
||||
container: Annotated[str, "Container name"],
|
||||
lines: Annotated[int, "Number of log lines to return (max 50)"] = 20,
|
||||
) -> str:
|
||||
"""Get recent logs from a container."""
|
||||
ip = NODES.get(node)
|
||||
if not ip:
|
||||
return f"Unknown node: {node}"
|
||||
lines = min(lines, 50)
|
||||
return await _ssh_exec(ip, f"docker logs --tail {lines} {container} 2>&1")
|
||||
|
||||
|
||||
@llm.function_tool
|
||||
async def read_alerts() -> str:
|
||||
"""Check Prometheus Alertmanager for active alerts."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(f"{ALERTMANAGER_URL}/api/v2/alerts?active=true&silenced=false")
|
||||
r.raise_for_status()
|
||||
alerts = r.json()
|
||||
if not alerts:
|
||||
return "No active alerts."
|
||||
parts = []
|
||||
for a in alerts[:10]:
|
||||
name = a.get("labels", {}).get("alertname", "unknown")
|
||||
severity = a.get("labels", {}).get("severity", "")
|
||||
summary = a.get("annotations", {}).get("summary", "")
|
||||
parts.append(f"[{severity}] {name}: {summary}" if summary else f"[{severity}] {name}")
|
||||
return "\n".join(parts)
|
||||
except Exception as e:
|
||||
return f"Could not reach Alertmanager: {e}"
|
||||
|
||||
|
||||
@llm.function_tool
|
||||
async def home_assistant(
|
||||
action: Annotated[str, "get_states, turn_on, turn_off, or toggle"],
|
||||
entity_id: Annotated[str, "HA entity ID, e.g. light.living_room or 'all' for get_states"] = "all",
|
||||
) -> str:
|
||||
"""Interact with Home Assistant — check sensor states or control devices."""
|
||||
if not HA_TOKEN:
|
||||
return "HA_TOKEN not configured."
|
||||
headers = {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
if action == "get_states":
|
||||
if entity_id == "all":
|
||||
r = await client.get(f"{HA_URL}/api/states", headers=headers)
|
||||
states = r.json()
|
||||
# Summarize lights/switches/sensors
|
||||
lights = [s for s in states if s["entity_id"].startswith("light.")]
|
||||
sensors = [s for s in states if s["entity_id"].startswith("sensor.")]
|
||||
on_lights = [s["entity_id"].split(".")[1] for s in lights if s["state"] == "on"]
|
||||
return (
|
||||
f"{len(lights)} lights ({len(on_lights)} on: {', '.join(on_lights[:5])}), "
|
||||
f"{len(sensors)} sensors"
|
||||
)
|
||||
else:
|
||||
r = await client.get(f"{HA_URL}/api/states/{entity_id}", headers=headers)
|
||||
s = r.json()
|
||||
return f"{entity_id}: {s.get('state')} (attrs: {list(s.get('attributes', {}).keys())[:3]})"
|
||||
else:
|
||||
service = action.replace("turn_", "").replace("toggle", "toggle")
|
||||
domain = entity_id.split(".")[0]
|
||||
r = await client.post(
|
||||
f"{HA_URL}/api/services/{domain}/{action}",
|
||||
headers=headers,
|
||||
json={"entity_id": entity_id},
|
||||
)
|
||||
return f"{action} {entity_id}: {'done' if r.status_code in (200, 201) else r.text}"
|
||||
except Exception as e:
|
||||
return f"HA error: {e}"
|
||||
|
||||
|
||||
@llm.function_tool
|
||||
async def search_web(
|
||||
query: Annotated[str, "Search query"],
|
||||
) -> str:
|
||||
"""Search the web using the local SearXNG instance."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.get(
|
||||
f"{SEARXNG_URL}/search",
|
||||
params={"q": query, "format": "json", "categories": "general"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
results = data.get("results", [])[:4]
|
||||
if not results:
|
||||
return "No results found."
|
||||
parts = []
|
||||
for res in results:
|
||||
title = res.get("title", "")
|
||||
content = res.get("content", "")[:150]
|
||||
parts.append(f"• {title}: {content}")
|
||||
return "\n".join(parts)
|
||||
except Exception as e:
|
||||
return f"Search failed: {e}"
|
||||
|
||||
|
||||
ALL_TOOLS = [infra_status, container_action, check_logs, read_alerts, home_assistant, search_web]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Async vocal analysis — runs background, never blocks TTS path."""
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
import struct
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SPEECHBRAIN_URL = "http://speechbrain-service:8001"
|
||||
|
||||
|
||||
@dataclass
|
||||
class VocalState:
|
||||
emotion: str = "neutral"
|
||||
confidence: float = 0.0
|
||||
energy: float = 0.0
|
||||
pitch_hz: Optional[float] = None
|
||||
rate: float = 0.0
|
||||
|
||||
def to_tag(self) -> str:
|
||||
parts = [f"emotion={self.emotion}", f"energy={self.energy:.2f}"]
|
||||
if self.pitch_hz:
|
||||
parts.append(f"pitch={self.pitch_hz:.0f}hz")
|
||||
if self.rate:
|
||||
parts.append(f"rate={'fast' if self.rate > 3 else 'slow' if self.rate < 1.5 else 'normal'}")
|
||||
return f"[VOCAL {' '.join(parts)}]"
|
||||
|
||||
|
||||
def compute_energy(audio_bytes: bytes) -> float:
|
||||
if len(audio_bytes) < 2:
|
||||
return 0.0
|
||||
samples = struct.unpack_from("<" + "h" * (len(audio_bytes) // 2), audio_bytes)
|
||||
rms = math.sqrt(sum(x * x for x in samples) / len(samples)) / 32768.0
|
||||
return round(min(rms * 10, 1.0), 3)
|
||||
|
||||
|
||||
async def analyze_audio(audio_bytes: bytes) -> VocalState:
|
||||
"""Call SpeechBrain for emotion/pitch. Falls back to energy-only if unavailable."""
|
||||
energy = compute_energy(audio_bytes)
|
||||
state = VocalState(energy=energy)
|
||||
|
||||
try:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(16000)
|
||||
wf.writeframes(audio_bytes)
|
||||
wav_bytes = buf.getvalue()
|
||||
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
r = await client.post(
|
||||
f"{SPEECHBRAIN_URL}/analyze",
|
||||
files={"audio": ("u.wav", wav_bytes, "audio/wav")},
|
||||
)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
state.emotion = d.get("emotion", "neutral")
|
||||
state.confidence = d.get("emotion_confidence", 0.0)
|
||||
state.pitch_hz = d.get("pitch_mean_hz")
|
||||
state.rate = d.get("speaking_rate_approx", 0.0)
|
||||
except Exception as e:
|
||||
logger.debug("SpeechBrain unavailable, using energy-only: %s", e)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
class VocalAnalyzer:
|
||||
"""Holds the last completed vocal state; updates async after each utterance."""
|
||||
|
||||
def __init__(self, on_update: Optional[Callable[[VocalState], None]] = None):
|
||||
self._state = VocalState()
|
||||
self._on_update = on_update
|
||||
self._audio_buffer: list[bytes] = []
|
||||
self._collecting = False
|
||||
|
||||
@property
|
||||
def current(self) -> VocalState:
|
||||
return self._state
|
||||
|
||||
def start_collecting(self):
|
||||
self._collecting = True
|
||||
self._audio_buffer = []
|
||||
|
||||
def feed(self, audio_chunk: bytes):
|
||||
if self._collecting:
|
||||
self._audio_buffer.append(audio_chunk)
|
||||
|
||||
def stop_collecting_and_analyze(self) -> asyncio.Task:
|
||||
self._collecting = False
|
||||
audio = b"".join(self._audio_buffer)
|
||||
self._audio_buffer = []
|
||||
return asyncio.create_task(self._analyze_bg(audio))
|
||||
|
||||
async def _analyze_bg(self, audio_bytes: bytes):
|
||||
if not audio_bytes:
|
||||
return
|
||||
try:
|
||||
state = await analyze_audio(audio_bytes)
|
||||
self._state = state
|
||||
if self._on_update:
|
||||
self._on_update(state)
|
||||
except Exception as e:
|
||||
logger.warning("Vocal analysis failed: %s", e)
|
||||
Reference in New Issue
Block a user