voice-server/app/queue_manager.py
Cal Corum 5f7dd68bf6 Add urgent flag for higher volume playback
Added optional 'urgent' boolean field to POST /notify requests.
When urgent=true, audio is played at 1.5x volume with clipping
protection for critical messages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:10:26 -06:00

245 lines
7.1 KiB
Python

"""
TTS Queue Manager for voice-server.
Manages an async queue of TTS requests and processes them sequentially.
"""
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any
import numpy as np
logger = logging.getLogger(__name__)
class QueueFullError(Exception):
"""Raised when the TTS queue is full."""
pass
@dataclass
class TTSRequest:
"""A TTS request in the queue."""
message: str
voice: str
rate: int
voice_enabled: bool
urgent: bool = False
timestamp: float = field(default_factory=time.time)
request_id: str | None = None
@dataclass
class QueueStats:
"""Statistics about queue processing."""
processed: int = 0
errors: int = 0
total_audio_seconds: float = 0.0
class TTSQueueManager:
"""
Manages the TTS request queue and processes requests sequentially.
Ensures audio doesn't overlap by processing one request at a time.
"""
def __init__(
self,
tts_engine: Any,
audio_player: Any,
max_size: int = 50,
request_timeout: float = 60.0,
):
"""
Initialize the queue manager.
Args:
tts_engine: TTS engine instance for synthesis
audio_player: Audio player instance for playback
max_size: Maximum queue size
request_timeout: Timeout for processing each request (seconds)
"""
self.tts_engine = tts_engine
self.audio_player = audio_player
self.max_size = max_size
self.request_timeout = request_timeout
self._queue: asyncio.Queue[TTSRequest] = asyncio.Queue(maxsize=max_size)
self._stats = QueueStats()
self._running = False
self._processor_task: asyncio.Task | None = None
async def start(self) -> None:
"""Start the queue processor background task."""
if self._running:
return
self._running = True
self._processor_task = asyncio.create_task(self._process_queue())
logger.info("TTS queue processor started")
async def stop(self) -> None:
"""Stop the queue processor and wait for current item to complete."""
self._running = False
if self._processor_task:
# Cancel the task
self._processor_task.cancel()
try:
await self._processor_task
except asyncio.CancelledError:
pass
self._processor_task = None
# Stop any playing audio
self.audio_player.stop()
logger.info("TTS queue processor stopped")
async def enqueue(self, request: TTSRequest) -> int:
"""
Add a TTS request to the queue.
Args:
request: The TTS request to queue
Returns:
Queue position (1-indexed)
Raises:
QueueFullError: If the queue is full
"""
try:
# Use a short timeout to avoid blocking
await asyncio.wait_for(
self._queue.put(request),
timeout=1.0,
)
position = self._queue.qsize()
logger.debug(f"Enqueued request: {request.message[:50]}... (position={position})")
return position
except asyncio.TimeoutError:
raise QueueFullError(f"TTS queue is full (max_size={self.max_size})")
async def _process_queue(self) -> None:
"""Background task that processes queued requests."""
while self._running:
try:
# Wait for a request (with timeout to allow checking _running)
try:
request = await asyncio.wait_for(
self._queue.get(),
timeout=1.0,
)
except asyncio.TimeoutError:
continue
await self._process_request(request)
self._queue.task_done()
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"Error in queue processor: {e}")
self._stats.errors += 1
async def _process_request(self, request: TTSRequest) -> None:
"""
Process a single TTS request.
Args:
request: The TTS request to process
"""
start_time = time.time()
try:
logger.debug(f"Processing TTS request: {request.message[:50]}...")
if not request.voice_enabled:
logger.debug("Voice disabled, skipping TTS")
self._stats.processed += 1
return
# Synthesize audio (run in thread pool to avoid blocking)
loop = asyncio.get_event_loop()
audio_data = await asyncio.wait_for(
loop.run_in_executor(
None,
self.tts_engine.synthesize_to_float32,
request.message,
request.voice,
),
timeout=self.request_timeout,
)
if len(audio_data) == 0:
logger.warning("TTS generated empty audio")
self._stats.processed += 1
return
# Apply volume boost for urgent messages
if request.urgent:
audio_data = np.clip(audio_data * 1.5, -1.0, 1.0).astype(np.float32)
logger.debug("Applied urgent volume boost (1.5x)")
# Play audio
self.audio_player.play(audio_data, self.tts_engine.get_sample_rate())
# Wait for playback to complete
await self.audio_player.wait_async()
# Update stats
duration = len(audio_data) / self.tts_engine.get_sample_rate()
self._stats.processed += 1
self._stats.total_audio_seconds += duration
elapsed = time.time() - start_time
logger.debug(f"Request processed: {duration:.2f}s audio in {elapsed:.2f}s")
except asyncio.TimeoutError:
logger.error(f"Request timed out after {self.request_timeout}s")
self._stats.errors += 1
except Exception as e:
logger.error(f"Error processing request: {e}")
self._stats.errors += 1
@property
def size(self) -> int:
"""Get current queue size."""
return self._queue.qsize()
@property
def capacity(self) -> int:
"""Get queue capacity."""
return self.max_size
@property
def utilization(self) -> float:
"""Get queue utilization percentage."""
if self.max_size == 0:
return 0.0
return (self.size / self.max_size) * 100.0
@property
def stats(self) -> QueueStats:
"""Get queue statistics."""
return self._stats
def get_status(self) -> dict:
"""Get queue status for health checks."""
return {
"size": self.size,
"capacity": self.capacity,
"utilization": round(self.utilization, 1),
"processed": self._stats.processed,
"errors": self._stats.errors,
"total_audio_seconds": round(self._stats.total_audio_seconds, 1),
"running": self._running,
}