"""Audio administration: the transcription and speech endpoints.""" from __future__ import annotations import logging from fastapi import APIRouter, Form, Request, Response, status from fastapi.responses import RedirectResponse from lembas.api.deps import AdminUser, Db from lembas.services import audio as audio_service from lembas.services import settings_store from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask from lembas.services.llm.openai_client import LLMError from lembas.web.templating import render log = logging.getLogger(__name__) router = APIRouter(prefix="/admin/audio", tags=["admin-audio"]) # Read out by the speech test. Short, and the one line this project would pick. TEST_PHRASE = "Speak, friend, and enter." def _page_context(db: Db) -> dict: config = settings_store.audio(db) return { "values": config, "formats": audio_service.FORMATS, "masked": { "stt": mask(decrypt(config.get("stt_api_key_encrypted") or "")), "tts": mask(decrypt(config.get("tts_api_key_encrypted") or "")), }, "unchanged": UNCHANGED_SENTINEL, } @router.get("") async def audio_page(request: Request, db: Db, user: AdminUser, saved: bool = False): from lembas.api.audio import available_voices context = _page_context(db) voices, error = await available_voices(context["values"]) return render( request, "admin/audio.html", {**context, "voices": voices, "voice_error": error, "saved": saved}, ) @router.post("") async def save_audio( db: Db, user: AdminUser, stt_enabled: bool = Form(False), stt_base_url: str = Form(""), stt_api_key: str = Form(""), stt_model: str = Form(""), stt_language: str = Form(""), tts_enabled: bool = Form(False), tts_base_url: str = Form(""), tts_api_key: str = Form(""), tts_model: str = Form(""), tts_voice: str = Form(""), tts_format: str = Form("mp3"), tts_speed: float = Form(1.0), tts_autoplay: bool = Form(False), ) -> Response: """Save both endpoints. Unchecked checkboxes are absent from a form post, which is why every toggle defaults to False here -- that absence *is* the "off" signal. """ current = settings_store.audio(db) settings_store.update( db, { "stt_enabled": stt_enabled, "stt_base_url": stt_base_url.strip().rstrip("/"), "stt_api_key_encrypted": keep_or_replace( stt_api_key, current.get("stt_api_key_encrypted") or "" ), "stt_model": stt_model.strip() or "whisper-1", "stt_language": stt_language.strip()[:16], "tts_enabled": tts_enabled, "tts_base_url": tts_base_url.strip().rstrip("/"), "tts_api_key_encrypted": keep_or_replace( tts_api_key, current.get("tts_api_key_encrypted") or "" ), "tts_model": tts_model.strip() or "tts-1", "tts_voice": tts_voice.strip()[:120], "tts_format": tts_format if tts_format in audio_service.FORMATS else "mp3", "tts_speed": min(max(tts_speed, 0.25), 4.0), "tts_autoplay": tts_autoplay, }, key=settings_store.AUDIO, ) # The voice list belongs to whatever URL was configured before; keeping it # would show the previous server's voices against the new one. audio_service.forget_voices() log.info("audio settings saved by %s", user.email) return RedirectResponse("/admin/audio?saved=1", status_code=status.HTTP_303_SEE_OTHER) @router.post("/test/{side}") async def test_audio(request: Request, db: Db, user: AdminUser, side: str): """Contact one of the two endpoints and report what happened. Speech is tested by synthesising a phrase and measuring the bytes back; transcription by sending a short generated tone, which is *expected* to come back as no words at all. That still proves what matters -- the URL resolves, the key is accepted and the response parses. """ context = _page_context(db) config = context["values"] message, kind = "", "success" try: if side == "tts": _, stream = await audio_service.speak( audio_service.endpoint_for(config, "tts"), TEST_PHRASE, model=config.get("tts_model") or "tts-1", voice=config.get("tts_voice") or "", fmt=config.get("tts_format") or "mp3", speed=float(config.get("tts_speed") or 1.0), ) size = 0 async for chunk in stream: size += len(chunk) message = f"Spoke the test phrase: {size:,} bytes of audio." elif side == "stt": text = await audio_service.transcribe( audio_service.endpoint_for(config, "stt"), data=_silent_wav(), filename="test.wav", content_type="audio/wav", model=config.get("stt_model") or "whisper-1", language=config.get("stt_language") or "", ) heard = f'Heard "{text}".' if text else "Heard nothing, as expected." message = f"The endpoint answered. {heard}" else: message, kind = "Unknown endpoint.", "error" except LLMError as exc: message, kind = exc.message, "error" voices, voice_error = [], "" if side == "tts": from lembas.api.audio import available_voices voices, voice_error = await available_voices(config, refresh=True) return render( request, "admin/_audio_result.html", { "side": side, "message": message, "message_kind": kind, "voices": voices, "voice_error": voice_error, "values": config, }, ) def _silent_wav(seconds: float = 0.5, rate: int = 16000) -> bytes: """A valid, silent WAV. Generated rather than committed: half a second of silence is fourteen lines of header arithmetic, and a binary fixture in the repository would be one more thing nobody can review. """ import struct frames = int(rate * seconds) data = b"\x00\x00" * frames header = struct.pack( "<4sI4s4sIHHIIHH4sI", b"RIFF", 36 + len(data), b"WAVE", b"fmt ", 16, 1, 1, rate, rate * 2, 2, 16, b"data", len(data), ) return header + data