"""Voice endpoints — text-to-speech and speech-to-text. These are thin HTTP surfaces over :mod:`deeptutor.services.voice`. Config comes from the admin-managed model catalog (``services.tts`` / ``services.stt``), so voice is shared infrastructure like embedding/search — any authenticated user may call it; it is not gated by per-user LLM grants. """ from __future__ import annotations import io import logging import wave from fastapi import APIRouter, File, Form, HTTPException, Response, UploadFile, status from pydantic import BaseModel, Field from deeptutor.services.voice import ( VoiceProviderError, synthesize_speech, transcribe_audio, ) logger = logging.getLogger(__name__) router = APIRouter() # Guard against pathological uploads (the providers cap well below this anyway). _MAX_AUDIO_BYTES = 25 * 1024 * 1024 # 25 MB, matching OpenAI's limit. _DEFAULT_PCM_SAMPLE_RATE = 24_000 _DEFAULT_PCM_CHANNELS = 1 _PCM16_SAMPLE_WIDTH = 2 class TTSRequest(BaseModel): """Text-to-speech request body.""" text: str = Field(..., min_length=1) voice: str | None = None format: str | None = None def _parse_pcm_content_type(content_type: str) -> tuple[int, int] | None: """Return ``(sample_rate, channels)`` when a provider sent raw PCM audio.""" media_type, *params = (content_type or "").split(";") if media_type.strip().lower() not in {"audio/pcm", "audio/x-pcm", "audio/l16"}: return None sample_rate = _DEFAULT_PCM_SAMPLE_RATE channels = _DEFAULT_PCM_CHANNELS for item in params: key, sep, value = item.strip().partition("=") if not sep: continue key = key.strip().lower() value = value.strip().strip('"') try: parsed = int(value) except ValueError: continue if key in {"rate", "sample-rate", "samplerate"} and parsed > 0: sample_rate = parsed elif key in {"channels", "channel"} and parsed > 0: channels = parsed return sample_rate, channels def _pcm16_to_wav(audio: bytes, *, sample_rate: int, channels: int) -> bytes: """Wrap provider PCM16 bytes in a WAV container browsers can play.""" buffer = io.BytesIO() with wave.open(buffer, "wb") as wav: wav.setnchannels(channels) wav.setsampwidth(_PCM16_SAMPLE_WIDTH) wav.setframerate(sample_rate) wav.writeframes(audio) return buffer.getvalue() @router.post("/tts") async def text_to_speech(payload: TTSRequest) -> Response: """Synthesize ``text`` to audio using the active TTS provider.""" try: audio, content_type = await synthesize_speech( payload.text, voice=payload.voice, response_format=payload.format, ) except ValueError as exc: # missing/invalid configuration raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc except VoiceProviderError as exc: logger.warning("TTS provider error: %s", exc) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc pcm_info = _parse_pcm_content_type(content_type) if pcm_info: sample_rate, channels = pcm_info audio = _pcm16_to_wav(audio, sample_rate=sample_rate, channels=channels) content_type = "audio/wav" return Response( content=audio, media_type=content_type, headers={"Cache-Control": "no-store"}, ) @router.post("/stt") async def speech_to_text( file: UploadFile = File(...), language: str | None = Form(default=None), ) -> dict[str, str]: """Transcribe an uploaded audio clip using the active STT provider.""" audio = await file.read() if not audio: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty audio upload.") if len(audio) > _MAX_AUDIO_BYTES: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Audio exceeds the 25 MB limit.", ) try: text = await transcribe_audio( audio, filename=file.filename or "audio.webm", content_type=file.content_type or "application/octet-stream", language=language, ) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc except VoiceProviderError as exc: logger.warning("STT provider error: %s", exc) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc return {"text": text}