A local HTTP service that accepts text via POST and speaks it through system speakers using Piper TTS neural voice synthesis. Features: - POST /notify - Queue text for TTS playback - GET /health - Health check with TTS/audio/queue status - GET /voices - List installed voice models - Async queue processing (no overlapping audio) - Non-blocking audio via sounddevice - 73 tests covering API contract Tech stack: - FastAPI + Uvicorn - Piper TTS (neural voices, offline) - sounddevice (PortAudio) - Pydantic for validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
199 lines
5.6 KiB
Python
199 lines
5.6 KiB
Python
"""
|
|
API routes for voice-server.
|
|
|
|
Defines all HTTP endpoints:
|
|
- POST /notify: Submit text for TTS playback
|
|
- GET /health: Health check endpoint
|
|
- GET /voices: List available voice models
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Response, status
|
|
|
|
from app.config import get_settings
|
|
from app.models import (
|
|
ErrorResponse,
|
|
HealthResponse,
|
|
NotifyRequest,
|
|
NotifyResponse,
|
|
QueueStatus,
|
|
VoiceInfo,
|
|
VoicesResponse,
|
|
)
|
|
from app.queue_manager import QueueFullError, TTSRequest
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _get_components():
|
|
"""Get the TTS components from main module."""
|
|
from app import main
|
|
|
|
return main.tts_engine, main.audio_player, main.queue_manager, main.get_uptime_seconds
|
|
|
|
|
|
@router.post(
|
|
"/notify",
|
|
response_model=NotifyResponse,
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
responses={
|
|
422: {"model": ErrorResponse, "description": "Validation error"},
|
|
503: {"model": ErrorResponse, "description": "Queue full"},
|
|
},
|
|
)
|
|
async def notify(request: NotifyRequest) -> NotifyResponse:
|
|
"""
|
|
Submit text for TTS playback.
|
|
|
|
Accepts a text message and queues it for text-to-speech conversion
|
|
and playback through the system speakers.
|
|
|
|
Returns immediately with queue position; audio plays asynchronously.
|
|
"""
|
|
tts_engine, audio_player, queue_manager, _ = _get_components()
|
|
settings = get_settings()
|
|
|
|
# Validate voice exists if specified
|
|
voice = request.voice or settings.default_voice
|
|
if tts_engine and not tts_engine.is_voice_available(voice):
|
|
available = [v["name"] for v in tts_engine.list_voices()]
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Voice '{voice}' not found. Available: {available}",
|
|
)
|
|
|
|
# Create TTS request
|
|
tts_request = TTSRequest(
|
|
message=request.message,
|
|
voice=voice,
|
|
rate=request.rate,
|
|
voice_enabled=request.voice_enabled and settings.voice_enabled,
|
|
)
|
|
|
|
# Enqueue request
|
|
try:
|
|
if queue_manager:
|
|
position = await queue_manager.enqueue(tts_request)
|
|
else:
|
|
position = 1 # Fallback for testing
|
|
except QueueFullError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=str(e),
|
|
)
|
|
|
|
return NotifyResponse(
|
|
status="queued",
|
|
message_length=len(request.message),
|
|
queue_position=position,
|
|
voice_model=voice,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/health",
|
|
response_model=HealthResponse,
|
|
responses={
|
|
503: {"model": HealthResponse, "description": "Service unhealthy"},
|
|
},
|
|
)
|
|
async def health(response: Response) -> HealthResponse:
|
|
"""
|
|
Health check endpoint.
|
|
|
|
Returns comprehensive health status including:
|
|
- TTS engine status
|
|
- Audio output status
|
|
- Queue status
|
|
- System metrics
|
|
"""
|
|
tts_engine, audio_player, queue_manager, get_uptime_seconds = _get_components()
|
|
settings = get_settings()
|
|
|
|
errors = []
|
|
|
|
# Check TTS engine health
|
|
tts_status = "unknown"
|
|
if tts_engine:
|
|
tts_health = tts_engine.health_check()
|
|
tts_status = tts_health.get("status", "unknown")
|
|
if tts_status != "healthy":
|
|
errors.append(f"TTS: {tts_health.get('error', 'Unknown error')}")
|
|
|
|
# Check audio health
|
|
audio_status = "unknown"
|
|
if audio_player:
|
|
audio_health = audio_player.health_check()
|
|
audio_status = audio_health.get("status", "unknown")
|
|
if audio_status != "healthy":
|
|
errors.append(f"Audio: {audio_health.get('error', 'Unknown error')}")
|
|
|
|
# Get queue status
|
|
if queue_manager:
|
|
queue_info = queue_manager.get_status()
|
|
queue_status = QueueStatus(
|
|
size=queue_info["size"],
|
|
capacity=queue_info["capacity"],
|
|
utilization=queue_info["utilization"],
|
|
)
|
|
total_requests = queue_info["processed"]
|
|
failed_requests = queue_info["errors"]
|
|
else:
|
|
queue_status = QueueStatus(
|
|
size=0,
|
|
capacity=settings.queue_max_size,
|
|
utilization=0.0,
|
|
)
|
|
total_requests = None
|
|
failed_requests = None
|
|
|
|
# Determine overall status
|
|
overall_status = "healthy" if not errors else "unhealthy"
|
|
|
|
# Set response status code for unhealthy
|
|
if overall_status == "unhealthy":
|
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
|
|
return HealthResponse(
|
|
status=overall_status,
|
|
uptime_seconds=get_uptime_seconds(),
|
|
queue=queue_status,
|
|
tts_engine="piper",
|
|
audio_output="available" if audio_status == "healthy" else "unavailable",
|
|
total_requests=total_requests,
|
|
failed_requests=failed_requests,
|
|
errors=errors if errors else None,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/voices",
|
|
response_model=VoicesResponse,
|
|
)
|
|
async def list_voices() -> VoicesResponse:
|
|
"""
|
|
List available voice models.
|
|
|
|
Returns a list of installed voice models with their metadata
|
|
and the current default voice.
|
|
"""
|
|
tts_engine, _, _, _ = _get_components()
|
|
settings = get_settings()
|
|
|
|
voices = []
|
|
if tts_engine:
|
|
for voice_data in tts_engine.list_voices():
|
|
voices.append(
|
|
VoiceInfo(
|
|
name=voice_data["name"],
|
|
language=voice_data["language"],
|
|
quality=voice_data["quality"],
|
|
size_mb=voice_data["size_mb"],
|
|
installed=voice_data["installed"],
|
|
)
|
|
)
|
|
|
|
return VoicesResponse(
|
|
voices=voices,
|
|
default_voice=settings.default_voice,
|
|
)
|