Files
le-mystere-professeur-zacus/tools/dev/mascarade_voice_bridge.py
T
L'électron rare dcf94b3ed6 feat: Zacus system prompt + game state endpoint
Add static personality prompt file for Professor Zacus with moods,
communication rules and in-game limits. Voice bridge now accepts
POST /game_state from ESP32 to inject dynamic scene context into the
LLM system prompt (scene, elapsed time, hints, mood, objective).
2026-04-02 09:27:34 +02:00

780 lines
29 KiB
Python

#!/usr/bin/env python3
"""
Voice Bridge — ESP32 <-> LLM <-> TTS
Standalone FastAPI server bridging Zacus ESP32 voice pipeline to LLM + TTS.
Can run independently or be integrated into mascarade as a router.
Usage:
python3 mascarade_voice_bridge.py [--port 8200] [--tts-url http://VM:8000]
Protocol (XiaoZhi-inspired):
1. ESP32 connects via WebSocket /voice/ws
2. JSON "hello" handshake
3. Device sends text_query or binary OPUS audio
4. Server: ASR -> LLM -> TTS -> stream audio back
Listen protocol (OPUS audio path):
- Client -> {"type":"listen","state":"detect","text":"Hi ESP"} (wake word)
- Server -> {"type":"listen_ack","state":"ready"} (ready to receive)
- Client -> binary OPUS frames (20ms each, 16kHz mono)
- Client -> {"type":"listen","state":"stop"} (end of speech)
- Server -> {"type":"stt","text":"transcribed text"} (STT result)
- Server -> {"type":"tts","state":"start","text":"response"}
- Server -> binary OPUS frames (TTS audio)
- Server -> {"type":"tts","state":"stop"}
Requires: fastapi, uvicorn, httpx, websockets, opuslib, webrtcvad
Install: pip install fastapi uvicorn httpx websockets opuslib webrtcvad
"""
import argparse
import asyncio
import io
import json
import logging
import os
import re
import struct
import sys
import threading
import time
import wave
from typing import Optional
try:
import httpx
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from starlette.websockets import WebSocketState
except ImportError:
print("Missing core dependencies. Install with:")
print(" pip install fastapi uvicorn httpx websockets")
sys.exit(1)
# OPUS codec (optional — needed for audio path)
try:
import opuslib
import opuslib.api.encoder
import opuslib.api.decoder
HAS_OPUS = True
except ImportError:
HAS_OPUS = False
# VAD (optional — needed for audio path)
try:
import webrtcvad
HAS_VAD = True
except ImportError:
HAS_VAD = False
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
TTS_URL = os.environ.get("ZACUS_TTS_URL", "http://192.168.0.120:8001/v1/audio/speech")
TTS_VOICE = os.environ.get("ZACUS_TTS_VOICE", "alloy")
LLM_URL = os.environ.get("ZACUS_LLM_URL", "http://localhost:8100/v1/chat/completions")
LLM_MODEL = os.environ.get("ZACUS_LLM_MODEL", "default")
STT_URL = os.environ.get("ZACUS_STT_URL", "http://192.168.0.120:8901/v1/audio/transcriptions")
AUTH_TOKEN = os.environ.get("ZACUS_VOICE_TOKEN", "")
LOG_LEVEL = os.environ.get("ZACUS_VOICE_LOG", "INFO")
# Audio constants
OPUS_SAMPLE_RATE = 16000 # 16kHz mono
OPUS_CHANNELS = 1
OPUS_FRAME_MS = 20 # 20ms frames from ESP32
OPUS_FRAME_SAMPLES = OPUS_SAMPLE_RATE * OPUS_FRAME_MS // 1000 # 320 samples
OPUS_APPLICATION = "voip" # opuslib application type
TTS_OPUS_BITRATE = 24000 # 24kbps for TTS back to ESP32
VAD_AGGRESSIVENESS = 2 # webrtcvad 0-3 (2 = balanced)
VAD_SILENCE_FRAMES = 30 # ~600ms of silence to trigger end-of-speech
MAX_HISTORY_TURNS = int(os.environ.get("ZACUS_MAX_HISTORY", "10")) # conversation turns per session
PROFESSOR_ZACUS_PROMPT = (
"Tu es le Professeur Zacus, un scientifique excentrique et bienveillant. "
"Tu parles en francais. Tu donnes des indices cryptiques pour aider les "
"joueurs a resoudre les enigmes, sans jamais reveler les solutions directement. "
"Tes reponses sont courtes (1-2 phrases), mysterieuses et encourageantes. "
"Tu fais parfois reference a tes experiences de laboratoire."
)
# ---------------------------------------------------------------------------
# Game State Store (thread-safe)
# ---------------------------------------------------------------------------
class GameStateStore:
"""Thread-safe store for per-device game state, updated by the ESP32."""
def __init__(self):
self._state: dict[str, dict] = {}
self._lock = threading.Lock()
def update(self, device_id: str, state: dict):
with self._lock:
self._state[device_id] = state
def get(self, device_id: str = "default") -> dict:
with self._lock:
return self._state.get(device_id, {})
game_state_store = GameStateStore()
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("voice_bridge")
def build_system_prompt(device_id: str = "default") -> str:
"""Build the full system prompt: static personality + dynamic game state."""
# Read static personality from file
prompt_path = os.path.join(
os.path.dirname(__file__), '../../game/prompts/zacus_system_prompt.txt'
)
try:
with open(prompt_path) as f:
static = f.read()
except FileNotFoundError:
logger.warning("System prompt file not found at %s, using fallback", prompt_path)
static = PROFESSOR_ZACUS_PROMPT
# Append dynamic state if available
state = game_state_store.get(device_id)
if state:
dynamic = (
"\n--- ETAT DU JEU ---\n"
f"Scene active: {state.get('scene_id', 'unknown')}\n"
f"Temps ecoule: {state.get('elapsed_min', 0)} min\n"
f"Indices donnes: {state.get('hints_given', 0)}\n"
f"Humeur: {state.get('mood', 'neutral')}\n"
f"Tentatives echouees: {state.get('failed_attempts', 0)}\n"
f"Objectif: {state.get('objective', '')}\n"
"--- FIN ETAT ---\n"
)
return static + "\n" + dynamic
return static
# ---------------------------------------------------------------------------
# Audio Session Manager
# ---------------------------------------------------------------------------
class AudioSessionManager:
"""Manages per-device audio sessions for OPUS decode, VAD, and STT.
Lifecycle:
1. start() — called on listen/detect, creates decoder, resets buffers
2. feed_opus(frame) — decode OPUS frame, run VAD, buffer PCM
3. stop() -> str — finalize, send accumulated PCM to Whisper STT
"""
def __init__(self, device_id: str):
self.device_id = device_id
self._pcm_buffer = bytearray()
self._active = False
self._decoder = None
self._vad = None
self._silence_count = 0
self._speech_detected = False
self.conversation_history: list[dict[str, str]] = []
def start(self):
"""Start a new recording session."""
self._pcm_buffer = bytearray()
self._active = True
self._silence_count = 0
self._speech_detected = False
if HAS_OPUS:
self._decoder = opuslib.Decoder(OPUS_SAMPLE_RATE, OPUS_CHANNELS)
else:
self._decoder = None
logger.warning("[%s] opuslib not available — raw PCM passthrough", self.device_id)
if HAS_VAD:
self._vad = webrtcvad.Vad(VAD_AGGRESSIVENESS)
else:
self._vad = None
logger.info("[%s] Audio session started (opus=%s, vad=%s)",
self.device_id, HAS_OPUS, HAS_VAD)
def feed_opus(self, frame: bytes) -> bool:
"""Decode one OPUS frame and buffer the PCM.
Returns True if VAD detected end-of-speech (enough trailing silence).
"""
if not self._active:
return False
# Decode OPUS -> 16-bit PCM
if self._decoder and HAS_OPUS:
try:
pcm = self._decoder.decode(frame, OPUS_FRAME_SAMPLES)
except Exception as exc:
logger.debug("[%s] OPUS decode error: %s", self.device_id, exc)
return False
else:
# Fallback: treat as raw 16-bit PCM
pcm = frame
self._pcm_buffer.extend(pcm)
# VAD check
if self._vad:
try:
# webrtcvad needs exactly 10/20/30ms of 16-bit PCM at supported rates
is_speech = self._vad.is_speech(pcm, OPUS_SAMPLE_RATE)
except Exception:
is_speech = True # assume speech on VAD error
if is_speech:
self._speech_detected = True
self._silence_count = 0
else:
self._silence_count += 1
if self._speech_detected and self._silence_count >= VAD_SILENCE_FRAMES:
logger.info("[%s] VAD: end-of-speech detected", self.device_id)
return True
else:
# No VAD: simple energy-based detection
self._speech_detected = True
if len(pcm) >= 2:
energy = _rms_energy(pcm)
if energy < 200: # silence threshold
self._silence_count += 1
if self._silence_count >= VAD_SILENCE_FRAMES:
logger.info("[%s] Energy VAD: end-of-speech", self.device_id)
return True
else:
self._silence_count = 0
return False
async def stop_and_transcribe(self) -> str:
"""Stop session and transcribe accumulated PCM via Whisper."""
self._active = False
pcm_bytes = bytes(self._pcm_buffer)
if not pcm_bytes:
logger.warning("[%s] Empty audio buffer, nothing to transcribe", self.device_id)
return ""
duration_s = len(pcm_bytes) / (OPUS_SAMPLE_RATE * 2) # 16-bit = 2 bytes/sample
logger.info("[%s] Transcribing %.1fs of audio (%d bytes PCM)",
self.device_id, duration_s, len(pcm_bytes))
# Wrap PCM in WAV container for Whisper
wav_bytes = _pcm_to_wav(pcm_bytes, OPUS_SAMPLE_RATE, OPUS_CHANNELS)
# POST to Whisper STT endpoint (OpenAI-compatible)
text = await _transcribe_audio(wav_bytes, self.device_id)
return text
@property
def active(self) -> bool:
return self._active
def _rms_energy(pcm: bytes) -> float:
"""Compute RMS energy of 16-bit PCM samples."""
n_samples = len(pcm) // 2
if n_samples == 0:
return 0.0
samples = struct.unpack(f"<{n_samples}h", pcm[:n_samples * 2])
return (sum(s * s for s in samples) / n_samples) ** 0.5
def _pcm_to_wav(pcm: bytes, sample_rate: int, channels: int) -> bytes:
"""Wrap raw 16-bit PCM in a WAV container."""
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(channels)
wf.setsampwidth(2) # 16-bit
wf.setframerate(sample_rate)
wf.writeframes(pcm)
return buf.getvalue()
def _wav_to_pcm(wav_data: bytes) -> tuple[bytes, int, int]:
"""Extract raw PCM, sample_rate, channels from WAV bytes."""
buf = io.BytesIO(wav_data)
with wave.open(buf, "rb") as wf:
pcm = wf.readframes(wf.getnframes())
return pcm, wf.getframerate(), wf.getnchannels()
def _encode_pcm_to_opus_frames(pcm: bytes, sample_rate: int, channels: int) -> list[bytes]:
"""Encode raw PCM to a list of OPUS frames suitable for streaming.
Resamples to 16kHz mono if needed (simple nearest-neighbor).
"""
if not HAS_OPUS:
logger.warning("opuslib not available — cannot encode OPUS")
return []
# Resample to target rate if needed (simple decimation)
if sample_rate != OPUS_SAMPLE_RATE or channels != OPUS_CHANNELS:
pcm = _resample_pcm(pcm, sample_rate, channels, OPUS_SAMPLE_RATE, OPUS_CHANNELS)
encoder = opuslib.Encoder(OPUS_SAMPLE_RATE, OPUS_CHANNELS, OPUS_APPLICATION)
frame_size_bytes = OPUS_FRAME_SAMPLES * 2 # 16-bit mono
frames = []
offset = 0
while offset + frame_size_bytes <= len(pcm):
chunk = pcm[offset:offset + frame_size_bytes]
try:
opus_frame = encoder.encode(chunk, OPUS_FRAME_SAMPLES)
frames.append(opus_frame)
except Exception as exc:
logger.debug("OPUS encode error at offset %d: %s", offset, exc)
offset += frame_size_bytes
logger.debug("Encoded %d OPUS frames from %d bytes PCM", len(frames), len(pcm))
return frames
def _resample_pcm(pcm: bytes, src_rate: int, src_ch: int,
dst_rate: int, dst_ch: int) -> bytes:
"""Simple nearest-neighbor resample. Good enough for voice."""
src_sample_size = 2 * src_ch # 16-bit per channel
n_src_samples = len(pcm) // src_sample_size
ratio = dst_rate / src_rate
n_dst_samples = int(n_src_samples * ratio)
result = bytearray()
for i in range(n_dst_samples):
src_idx = int(i / ratio)
if src_idx >= n_src_samples:
src_idx = n_src_samples - 1
offset = src_idx * src_sample_size
if dst_ch == 1 and src_ch == 2:
# Stereo to mono: average
l = struct.unpack_from("<h", pcm, offset)[0]
r = struct.unpack_from("<h", pcm, offset + 2)[0]
result.extend(struct.pack("<h", (l + r) // 2))
elif dst_ch == 1 and src_ch == 1:
result.extend(pcm[offset:offset + 2])
else:
# Just take first channel
result.extend(pcm[offset:offset + 2])
return bytes(result)
# Per-device session registry
_audio_sessions: dict[str, AudioSessionManager] = {}
def _get_session(device_id: str) -> AudioSessionManager:
"""Get or create an AudioSessionManager for a device."""
if device_id not in _audio_sessions:
_audio_sessions[device_id] = AudioSessionManager(device_id)
return _audio_sessions[device_id]
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(title="Zacus Voice Bridge", version="1.0.0")
@app.get("/health")
async def health():
"""Health check."""
return {
"status": "ok",
"service": "voice_bridge",
"tts_url": TTS_URL,
"stt_url": STT_URL,
"opus": HAS_OPUS,
"vad": HAS_VAD,
"active_sessions": len(_audio_sessions),
}
@app.post("/game_state")
async def update_game_state(request_body: dict):
"""Receive game state updates from ESP32 or web client.
Expected JSON fields:
device_id (str): device identifier (default: "default")
scene_id (str): current scene
elapsed_min (int): minutes elapsed
hints_given (int): hints already delivered
mood (str): neutral | impressed | worried | amused
failed_attempts (int): wrong answers count
objective (str): current puzzle objective text
"""
device_id = request_body.get("device_id", "default")
game_state_store.update(device_id, request_body)
logger.info("[%s] Game state updated: scene=%s mood=%s",
device_id,
request_body.get("scene_id", "?"),
request_body.get("mood", "?"))
return {"ok": True}
@app.websocket("/voice/ws")
async def voice_websocket(websocket: WebSocket, token: str = ""):
"""WebSocket endpoint for ESP32 voice pipeline."""
# Auth check
if AUTH_TOKEN and token != AUTH_TOKEN:
await websocket.close(code=4003, reason="unauthorized")
return
await websocket.accept()
client = f"{websocket.client.host}:{websocket.client.port}" if websocket.client else "unknown"
logger.info("Client connected: %s", client)
device_id = None
try:
# --- Handshake ---
raw = await asyncio.wait_for(websocket.receive_json(), timeout=5.0)
if raw.get("type") != "hello":
await websocket.close(code=4001, reason="expected hello")
return
device_id = raw.get("device_id", "unknown")
logger.info("Handshake from device: %s", device_id)
capabilities = ["tts", "llm", "text_query"]
if HAS_OPUS:
capabilities.append("opus_audio")
if HAS_VAD:
capabilities.append("vad")
await websocket.send_json({
"type": "hello_ack",
"version": 2,
"capabilities": capabilities,
})
# --- Main loop ---
while True:
data = await websocket.receive()
if "text" in data:
msg = json.loads(data["text"])
await _handle_message(websocket, msg, device_id)
elif "bytes" in data:
await _handle_audio(websocket, data["bytes"], device_id)
except WebSocketDisconnect:
logger.info("Client disconnected: %s", client)
if device_id and device_id in _audio_sessions:
del _audio_sessions[device_id]
except asyncio.TimeoutError:
logger.warning("Handshake timeout: %s", client)
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.close(code=4002, reason="timeout")
except Exception as exc:
logger.error("Error with %s: %s", client, exc)
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.close(code=4000, reason="internal_error")
# ---------------------------------------------------------------------------
# Message handlers
# ---------------------------------------------------------------------------
async def _handle_message(ws: WebSocket, msg: dict, device_id: str):
"""Handle JSON control message."""
msg_type = msg.get("type", "")
if msg_type == "listen":
state = msg.get("state", "")
if state == "detect":
wake_text = msg.get("text", "")
logger.info("[%s] Wake word detected: %s", device_id, wake_text)
# Start audio session for this device
session = _get_session(device_id)
session.start()
await ws.send_json({"type": "listen_ack", "state": "ready"})
elif state == "stop":
logger.info("[%s] Listen stop received — transcribing", device_id)
session = _get_session(device_id)
t0 = time.monotonic()
transcription = await session.stop_and_transcribe()
t_stt = time.monotonic() - t0
if transcription:
logger.info("[%s] STT (%.1fs): %s", device_id, t_stt, transcription[:80])
await ws.send_json({"type": "stt", "text": transcription})
# Feed transcription into the LLM pipeline
# Detect hint routing: [HINT:puzzle_id:level] prefix
hint_match = re.match(r'^\[HINT:(\w+):(\d)\]\s*(.*)', transcription)
if hint_match:
puzzle_id = hint_match.group(1)
hint_level = int(hint_match.group(2))
question = hint_match.group(3) or "Give me a hint"
response = await _query_hints(puzzle_id, question, hint_level, device_id)
else:
response = await _query_llm(transcription, device_id)
# TTS response — encode to OPUS if available
await _send_tts_response(ws, response, device_id)
else:
logger.warning("[%s] STT returned empty text", device_id)
await ws.send_json({"type": "stt", "text": ""})
elif msg_type == "text_query":
query = msg.get("text", "").strip()
if not query:
await ws.send_json({"type": "error", "message": "empty query"})
return
logger.info("[%s] Query: %s", device_id, query[:80])
t0 = time.monotonic()
# Detect hint routing: [HINT:puzzle_id:level] prefix
hint_match = re.match(r'^\[HINT:(\w+):(\d)\]\s*(.*)', query)
if hint_match:
puzzle_id = hint_match.group(1)
hint_level = int(hint_match.group(2))
question = hint_match.group(3) or "Give me a hint"
response = await _query_hints(puzzle_id, question, hint_level, device_id)
else:
response = await _query_llm(query, device_id)
# TTS response (OPUS-encoded if available)
await _send_tts_response(ws, response, device_id)
elif msg_type == "abort":
logger.info("[%s] Playback aborted", device_id)
else:
logger.debug("[%s] Unknown message type: %s", device_id, msg_type)
async def _handle_audio(ws: WebSocket, frame: bytes, device_id: str):
"""Handle binary OPUS audio frame from ESP32.
Decodes OPUS, buffers PCM, runs VAD. On end-of-speech detection,
automatically triggers transcription and LLM response.
"""
session = _get_session(device_id)
if not session.active:
logger.debug("[%s] Audio frame received but no active session (%d bytes)",
device_id, len(frame))
return
end_of_speech = session.feed_opus(frame)
if end_of_speech:
# VAD triggered end-of-speech — transcribe automatically
logger.info("[%s] VAD end-of-speech — auto-transcribing", device_id)
t0 = time.monotonic()
transcription = await session.stop_and_transcribe()
t_stt = time.monotonic() - t0
if transcription:
logger.info("[%s] Auto-STT (%.1fs): %s", device_id, t_stt, transcription[:80])
await ws.send_json({"type": "stt", "text": transcription})
# Feed into LLM pipeline — detect hint routing
hint_match = re.match(r'^\[HINT:(\w+):(\d)\]\s*(.*)', transcription)
if hint_match:
puzzle_id = hint_match.group(1)
hint_level = int(hint_match.group(2))
question = hint_match.group(3) or "Give me a hint"
response = await _query_hints(puzzle_id, question, hint_level, device_id)
else:
response = await _query_llm(transcription, device_id)
await _send_tts_response(ws, response, device_id)
else:
logger.warning("[%s] Auto-STT returned empty", device_id)
await ws.send_json({"type": "stt", "text": ""})
# ---------------------------------------------------------------------------
# LLM + TTS clients
# ---------------------------------------------------------------------------
async def _query_hints(puzzle_id: str, question: str, hint_level: int, session_id: str = "default") -> str:
"""Route to mascarade hints engine."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
"http://localhost:8100/hints/ask",
json={
"puzzle_id": puzzle_id,
"question": question,
"hint_level": hint_level,
"session_id": session_id,
},
)
if resp.status_code == 200:
data = resp.json()
return data.get("hint", "Hmm...")
logger.error("Hints HTTP %d: %s", resp.status_code, resp.text[:200])
except Exception as exc:
logger.error("Hints error: %s", exc)
# Fallback to generic LLM
return await _query_llm(question, session_id)
async def _query_llm(text: str, device_id: str = "unknown") -> str:
"""Query LLM via OpenAI-compatible API with per-session conversation history."""
session = _get_session(device_id)
# Build messages: system + last N turns from history + current user message
system_prompt = build_system_prompt(device_id)
messages: list[dict[str, str]] = [{"role": "system", "content": system_prompt}]
history_slice = session.conversation_history[-(MAX_HISTORY_TURNS * 2):]
messages.extend(history_slice)
messages.append({"role": "user", "content": text})
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
LLM_URL,
json={
"model": LLM_MODEL,
"messages": messages,
"max_tokens": 150,
"temperature": 0.8,
},
)
if resp.status_code == 200:
data = resp.json()
choices = data.get("choices", [])
if choices:
reply = choices[0].get("message", {}).get("content", "Hmm...")
# Store turn in history
session.conversation_history.append({"role": "user", "content": text})
session.conversation_history.append({"role": "assistant", "content": reply})
return reply
logger.error("LLM HTTP %d: %s", resp.status_code, resp.text[:200])
except Exception as exc:
logger.error("LLM error: %s", exc)
return "Mon laboratoire semble avoir un probleme technique... Reessaie."
async def _text_to_speech(text: str) -> Optional[bytes]:
"""Convert text to speech via Piper/OpenedAI-Speech API. Returns WAV bytes."""
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
TTS_URL,
json={
"model": "tts-1",
"input": text,
"voice": TTS_VOICE,
"response_format": "wav",
},
)
if resp.status_code == 200:
return resp.content
logger.error("TTS HTTP %d: %s", resp.status_code, resp.text[:200])
except Exception as exc:
logger.error("TTS error: %s", exc)
return None
async def _transcribe_audio(wav_bytes: bytes, device_id: str) -> str:
"""Send WAV audio to Whisper STT (OpenAI-compatible) and return text."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
STT_URL,
files={"file": ("audio.wav", wav_bytes, "audio/wav")},
data={"model": "whisper-1", "language": "fr"},
)
if resp.status_code == 200:
data = resp.json()
return data.get("text", "").strip()
logger.error("[%s] STT HTTP %d: %s", device_id, resp.status_code, resp.text[:200])
except Exception as exc:
logger.error("[%s] STT error: %s", device_id, exc)
return ""
async def _send_tts_response(ws: WebSocket, text: str, device_id: str):
"""Get TTS audio, optionally encode to OPUS, and stream to client.
Protocol:
1. {"type":"tts","state":"start","text":"..."}
2. Binary OPUS frames (or single WAV blob if OPUS unavailable)
3. {"type":"tts","state":"stop"}
"""
t0 = time.monotonic()
await ws.send_json({"type": "tts", "state": "start", "text": text})
wav_audio = await _text_to_speech(text)
t_tts = time.monotonic() - t0
if wav_audio:
if HAS_OPUS:
# Encode WAV -> OPUS frames and stream
try:
pcm, rate, channels = _wav_to_pcm(wav_audio)
opus_frames = _encode_pcm_to_opus_frames(pcm, rate, channels)
for frame in opus_frames:
await ws.send_bytes(frame)
logger.info("[%s] TTS OPUS sent: %.1fs, %d frames",
device_id, t_tts, len(opus_frames))
except Exception as exc:
logger.error("[%s] OPUS encode failed, falling back to WAV: %s",
device_id, exc)
await ws.send_bytes(wav_audio)
else:
# No OPUS encoder — send raw WAV blob
await ws.send_bytes(wav_audio)
logger.info("[%s] TTS WAV sent: %.1fs, %dKB",
device_id, t_tts, len(wav_audio) // 1024)
else:
logger.warning("[%s] TTS failed, text-only response", device_id)
await ws.send_json({"type": "tts", "state": "stop"})
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Zacus Voice Bridge")
parser.add_argument("--port", type=int, default=8200, help="Listen port")
parser.add_argument("--host", default="0.0.0.0", help="Bind address")
parser.add_argument("--tts-url", default=None, help="TTS API URL")
parser.add_argument("--llm-url", default=None, help="LLM API URL")
parser.add_argument("--stt-url", default=None, help="Whisper STT API URL")
args = parser.parse_args()
global TTS_URL, LLM_URL, STT_URL
if args.tts_url:
TTS_URL = args.tts_url
if args.llm_url:
LLM_URL = args.llm_url
if args.stt_url:
STT_URL = args.stt_url
logger.info("Starting Voice Bridge on %s:%d", args.host, args.port)
logger.info(" TTS: %s", TTS_URL)
logger.info(" LLM: %s", LLM_URL)
logger.info(" STT: %s", STT_URL)
logger.info(" OPUS: %s | VAD: %s", HAS_OPUS, HAS_VAD)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
if __name__ == "__main__":
main()