Implemented experimental real production ready voice chat, relegated old flow to voice debug mode. New Web UI panel for Voice Chat.

This commit is contained in:
2026-01-20 23:06:17 +02:00
parent 362108f4b0
commit 2934efba22
31 changed files with 5408 additions and 357 deletions

View File

@@ -0,0 +1,275 @@
"""
STT Client for Discord Bot
WebSocket client that connects to the STT server and handles:
- Audio streaming to STT
- Receiving VAD events
- Receiving partial/final transcripts
- Interruption detection
"""
import aiohttp
import asyncio
import logging
from typing import Optional, Callable
import json
logger = logging.getLogger('stt_client')
class STTClient:
"""
WebSocket client for STT server communication.
Handles audio streaming and receives transcription events.
"""
def __init__(
self,
user_id: str,
stt_url: str = "ws://miku-stt:8766/ws/stt",
on_vad_event: Optional[Callable] = None,
on_partial_transcript: Optional[Callable] = None,
on_final_transcript: Optional[Callable] = None,
on_interruption: Optional[Callable] = None
):
"""
Initialize STT client.
Args:
user_id: Discord user ID
stt_url: Base WebSocket URL for STT server
on_vad_event: Callback for VAD events (event_dict)
on_partial_transcript: Callback for partial transcripts (text, timestamp)
on_final_transcript: Callback for final transcripts (text, timestamp)
on_interruption: Callback for interruption detection (probability)
"""
self.user_id = user_id
self.stt_url = f"{stt_url}/{user_id}"
# Callbacks
self.on_vad_event = on_vad_event
self.on_partial_transcript = on_partial_transcript
self.on_final_transcript = on_final_transcript
self.on_interruption = on_interruption
# Connection state
self.websocket: Optional[aiohttp.ClientWebSocket] = None
self.session: Optional[aiohttp.ClientSession] = None
self.connected = False
self.running = False
# Receive task
self._receive_task: Optional[asyncio.Task] = None
logger.info(f"STT client initialized for user {user_id}")
async def connect(self):
"""Connect to STT WebSocket server."""
if self.connected:
logger.warning(f"Already connected for user {self.user_id}")
return
try:
self.session = aiohttp.ClientSession()
self.websocket = await self.session.ws_connect(
self.stt_url,
heartbeat=30
)
# Wait for ready message
ready_msg = await self.websocket.receive_json()
logger.info(f"STT connected for user {self.user_id}: {ready_msg}")
self.connected = True
self.running = True
# Start receive task
self._receive_task = asyncio.create_task(self._receive_events())
logger.info(f"✓ STT WebSocket connected for user {self.user_id}")
except Exception as e:
logger.error(f"Failed to connect STT for user {self.user_id}: {e}", exc_info=True)
await self.disconnect()
raise
async def disconnect(self):
"""Disconnect from STT WebSocket."""
logger.info(f"Disconnecting STT for user {self.user_id}")
self.running = False
self.connected = False
# Cancel receive task
if self._receive_task and not self._receive_task.done():
self._receive_task.cancel()
try:
await self._receive_task
except asyncio.CancelledError:
pass
# Close WebSocket
if self.websocket:
await self.websocket.close()
self.websocket = None
# Close session
if self.session:
await self.session.close()
self.session = None
logger.info(f"✓ STT disconnected for user {self.user_id}")
async def send_audio(self, audio_data: bytes):
"""
Send audio chunk to STT server.
Args:
audio_data: PCM audio (int16, 16kHz mono)
"""
if not self.connected or not self.websocket:
logger.warning(f"Cannot send audio, not connected for user {self.user_id}")
return
try:
await self.websocket.send_bytes(audio_data)
logger.debug(f"Sent {len(audio_data)} bytes to STT")
except Exception as e:
logger.error(f"Failed to send audio to STT: {e}")
self.connected = False
async def send_final(self):
"""
Request final transcription from STT server.
Call this when the user stops speaking to get the final transcript.
"""
if not self.connected or not self.websocket:
logger.warning(f"Cannot send final command, not connected for user {self.user_id}")
return
try:
command = json.dumps({"type": "final"})
await self.websocket.send_str(command)
logger.debug(f"Sent final command to STT")
except Exception as e:
logger.error(f"Failed to send final command to STT: {e}")
self.connected = False
async def send_reset(self):
"""
Reset the STT server's audio buffer.
Call this to clear any buffered audio.
"""
if not self.connected or not self.websocket:
logger.warning(f"Cannot send reset command, not connected for user {self.user_id}")
return
try:
command = json.dumps({"type": "reset"})
await self.websocket.send_str(command)
logger.debug(f"Sent reset command to STT")
except Exception as e:
logger.error(f"Failed to send reset command to STT: {e}")
self.connected = False
async def _receive_events(self):
"""Background task to receive events from STT server."""
try:
while self.running and self.websocket:
try:
msg = await self.websocket.receive()
if msg.type == aiohttp.WSMsgType.TEXT:
event = json.loads(msg.data)
await self._handle_event(event)
elif msg.type == aiohttp.WSMsgType.CLOSED:
logger.info(f"STT WebSocket closed for user {self.user_id}")
break
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.error(f"STT WebSocket error for user {self.user_id}")
break
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error receiving STT event: {e}", exc_info=True)
finally:
self.connected = False
logger.info(f"STT receive task ended for user {self.user_id}")
async def _handle_event(self, event: dict):
"""
Handle incoming STT event.
Args:
event: Event dictionary from STT server
"""
event_type = event.get('type')
if event_type == 'transcript':
# New ONNX server protocol: single transcript type with is_final flag
text = event.get('text', '')
is_final = event.get('is_final', False)
timestamp = event.get('timestamp', 0)
if is_final:
logger.info(f"Final transcript [{self.user_id}]: {text}")
if self.on_final_transcript:
await self.on_final_transcript(text, timestamp)
else:
logger.info(f"Partial transcript [{self.user_id}]: {text}")
if self.on_partial_transcript:
await self.on_partial_transcript(text, timestamp)
elif event_type == 'vad':
# VAD event: speech detection (legacy support)
logger.debug(f"VAD event: {event}")
if self.on_vad_event:
await self.on_vad_event(event)
elif event_type == 'partial':
# Legacy protocol support: partial transcript
text = event.get('text', '')
timestamp = event.get('timestamp', 0)
logger.info(f"Partial transcript [{self.user_id}]: {text}")
if self.on_partial_transcript:
await self.on_partial_transcript(text, timestamp)
elif event_type == 'final':
# Legacy protocol support: final transcript
text = event.get('text', '')
timestamp = event.get('timestamp', 0)
logger.info(f"Final transcript [{self.user_id}]: {text}")
if self.on_final_transcript:
await self.on_final_transcript(text, timestamp)
elif event_type == 'interruption':
# Interruption detected (legacy support)
probability = event.get('probability', 0)
logger.info(f"Interruption detected from user {self.user_id} (prob={probability:.3f})")
if self.on_interruption:
await self.on_interruption(probability)
elif event_type == 'info':
# Info message
logger.info(f"STT info: {event.get('message', '')}")
elif event_type == 'error':
# Error message
logger.error(f"STT error: {event.get('message', '')}")
else:
logger.warning(f"Unknown STT event type: {event_type}")
def is_connected(self) -> bool:
"""Check if STT client is connected."""
return self.connected