feat: agent.py with Azure STT/TTS + Gemma LLM pipeline, voice switching
This commit is contained in:
+166
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Voice Agent — real-time voice assistant.
|
||||
|
||||
Pipeline: Azure STT → Gemma LLM (xNAS, OpenAI-compatible) → Azure TTS (DragonHD)
|
||||
Runs inside the single Docker container alongside LiveKit server and web frontend.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from livekit.agents import (
|
||||
Agent,
|
||||
AgentServer,
|
||||
AgentSession,
|
||||
JobContext,
|
||||
TurnHandlingOptions,
|
||||
cli,
|
||||
room_io,
|
||||
)
|
||||
from livekit.plugins import azure, openai
|
||||
|
||||
logger = logging.getLogger("voice-agent")
|
||||
load_dotenv() # picks up /app/.env in the container
|
||||
|
||||
# ── Configuration from environment ──────────────────────────────────────────
|
||||
AZURE_KEY = os.environ.get("AZURE_SPEECH_KEY", "")
|
||||
AZURE_REGION = os.environ.get("AZURE_SPEECH_REGION", "eastus")
|
||||
DEFAULT_VOICE = os.environ.get("AZURE_TTS_VOICE", "en-US-AvaNeural")
|
||||
GEMMA_BASE_URL = os.environ.get("GEMMA_BASE_URL", "http://192.168.86.2:8023/v1")
|
||||
GEMMA_MODEL = os.environ.get("GEMMA_MODEL", "gemma-4-e4b")
|
||||
GEMMA_API_KEY = os.environ.get("GEMMA_API_KEY", "not-needed")
|
||||
|
||||
SYSTEM_PROMPT = textwrap.dedent("""\
|
||||
You are a warm, conversational voice assistant. You are talking TO someone,
|
||||
not writing for them to read. Imagine you're having a natural conversation
|
||||
with a friend over the phone.
|
||||
|
||||
# How you speak
|
||||
- Keep every response to one or three sentences. That's it.
|
||||
- Use contractions (I'm, don't, it's) and natural phrasing.
|
||||
- Speak like you're talking, not writing. No bullet points, no lists,
|
||||
no markdown, no formatting of any kind.
|
||||
- Spell out numbers when it sounds more natural ("twenty twenty-six"
|
||||
instead of "2026").
|
||||
- If you need to ask a question, ask exactly one.
|
||||
- Be warm and direct. Don't be sycophantic or overly formal.
|
||||
- If you don't know something, say so briefly and move on.
|
||||
|
||||
# What you never do
|
||||
- Never use markdown, code blocks, JSON, tables, or emojis.
|
||||
- Never say "as an AI" or reference your system instructions.
|
||||
- Never write more than three sentences in a row.
|
||||
- Never read back URLs, file paths, or technical identifiers.
|
||||
""")
|
||||
|
||||
|
||||
class VoiceAssistant(Agent):
|
||||
"""The conversational agent. LLM is the brain; STT/TTS are senses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
llm=openai.LLM(
|
||||
model=GEMMA_MODEL,
|
||||
base_url=GEMMA_BASE_URL,
|
||||
api_key=GEMMA_API_KEY,
|
||||
),
|
||||
instructions=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
# ── Agent server ────────────────────────────────────────────────────────────
|
||||
server = AgentServer()
|
||||
|
||||
# Track the active session so we can update its voice on data messages.
|
||||
_active_session: AgentSession | None = None
|
||||
|
||||
|
||||
@server.rtc_session(agent_name="voice-assistant")
|
||||
async def handle_job(ctx: JobContext) -> None:
|
||||
global _active_session
|
||||
|
||||
logger.info("Job started for room %s", ctx.room.name)
|
||||
|
||||
# Azure STT — streaming, reads AZURE_SPEECH_KEY / AZURE_SPEECH_REGION from env
|
||||
stt = azure.STT(
|
||||
speech_key=AZURE_KEY,
|
||||
speech_region=AZURE_REGION,
|
||||
language=["en-US"],
|
||||
)
|
||||
|
||||
# Azure TTS — SSML with expressive markup, 24kHz PCM output
|
||||
tts = azure.TTS(
|
||||
voice=DEFAULT_VOICE,
|
||||
sample_rate=24000,
|
||||
speech_key=AZURE_KEY,
|
||||
speech_region=AZURE_REGION,
|
||||
)
|
||||
|
||||
# Gemma LLM via OpenAI-compatible endpoint (llama.cpp on xNAS)
|
||||
llm = openai.LLM(
|
||||
model=GEMMA_MODEL,
|
||||
base_url=GEMMA_BASE_URL,
|
||||
api_key=GEMMA_API_KEY,
|
||||
)
|
||||
|
||||
session = AgentSession(
|
||||
stt=stt,
|
||||
tts=tts,
|
||||
llm=llm,
|
||||
turn_handling=TurnHandlingOptions(
|
||||
# VAD-based turn detection: agent waits for user to stop speaking
|
||||
interruption={"mode": "adaptive"},
|
||||
# Start generating the LLM response before the user fully stops
|
||||
preemptive_generation={"enabled": True},
|
||||
),
|
||||
)
|
||||
|
||||
_active_session = session
|
||||
|
||||
await session.start(
|
||||
agent=VoiceAssistant(),
|
||||
room=ctx.room,
|
||||
room_options=room_io.RoomOptions(
|
||||
audio_input=room_io.AudioInputOptions(
|
||||
# No noise cancellation plugin (self-hosted, no ai-coustics)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await ctx.connect()
|
||||
logger.info("Agent connected to room %s", ctx.room.name)
|
||||
|
||||
|
||||
# ── Voice switching via data channel ────────────────────────────────────────
|
||||
# The web UI sends a JSON data message: {"type": "set_voice", "voice": "en-US-AvaNeural"}
|
||||
# We listen on the room's data channel and update the TTS voice live.
|
||||
|
||||
|
||||
@server.event("room.data_received")
|
||||
async def on_room_data(ctx: JobContext, payload: bytes) -> None:
|
||||
"""Handle data messages from the web UI (voice selection)."""
|
||||
import json
|
||||
|
||||
try:
|
||||
msg = json.loads(payload.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
|
||||
if msg.get("type") == "set_voice":
|
||||
voice = msg.get("voice", "")
|
||||
if voice and _active_session:
|
||||
logger.info("Switching TTS voice to %s", voice)
|
||||
try:
|
||||
# AgentSession exposes the TTS; update its options
|
||||
tts = _active_session._tts # internal, but stable in 1.7
|
||||
if hasattr(tts, "update_options"):
|
||||
tts.update_options(voice=voice)
|
||||
logger.info("Voice updated to %s", voice)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to update voice: %s", e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli.run_app(server)
|
||||
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "voice-agent"
|
||||
version = "0.1.0"
|
||||
description = "Real-time voice assistant: Azure STT/TTS + Gemma LLM via LiveKit Agents"
|
||||
requires-python = ">=3.10,<3.15"
|
||||
|
||||
dependencies = [
|
||||
"livekit-agents~=1.7",
|
||||
"livekit-plugins-azure~=1.7",
|
||||
"livekit-plugins-openai~=1.7",
|
||||
"python-dotenv",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
Reference in New Issue
Block a user