The engine-agnostic text-normalization pre-pass now runs at the three remaining text→engine choke points, applied exactly once per request: /v1/audio/speech (req.language), /ws/tts (whole text, before the sentence chunker fans it out), and the batch queue's per-segment _gen (target language) — matching the /generate, dub, and audiobook wiring. Route-level tests pin exactly-once (spy) + toggle-off-raw for each path. Also fixes a pre-existing /ws/tts bug the new test exposed: any request omitting emo_alpha hit a KeyError and got an error frame instead of audio. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
601 lines
23 KiB
Python
601 lines
23 KiB
Python
"""
|
||
OpenAI-compatible TTS & STT API — Phase 3.2 (ROADMAP.md P0).
|
||
|
||
Drop-in replacement for OpenAI's audio endpoints so that any tool speaking the
|
||
OpenAI protocol (Claude, Cursor, LangChain, litellm, etc.) can use OmniVoice
|
||
as a local backend with zero code changes.
|
||
|
||
Endpoints
|
||
─────────
|
||
POST /v1/audio/speech → TTS (text → wav/mp3/opus/flac)
|
||
POST /v1/audio/transcriptions → STT (audio file → text/json)
|
||
GET /v1/audio/voices → list available voices (OmniVoice extension)
|
||
|
||
The router delegates to the active TTS/ASR backends via the same adapter
|
||
protocol used by the rest of OmniVoice, so engine selection, GPU offloading,
|
||
and model loading all work identically.
|
||
|
||
Reference: https://platform.openai.com/docs/api-reference/audio
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
from typing import Literal, Optional
|
||
|
||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
|
||
|
||
logger = logging.getLogger("omnivoice.openai_compat")
|
||
|
||
router = APIRouter(prefix="/v1/audio", tags=["OpenAI-Compatible Audio API"])
|
||
|
||
|
||
# ── Schemas ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class SpeechRequest(BaseModel):
|
||
"""POST /v1/audio/speech — mirrors OpenAI's CreateSpeechRequest."""
|
||
|
||
model: str = Field(
|
||
default="omnivoice",
|
||
description=(
|
||
"TTS model to use. Maps to OmniVoice engine IDs: "
|
||
"'omnivoice', 'voxcpm2', 'cosyvoice', 'mlx-audio', 'kittentts', 'moss-tts-nano'. "
|
||
"Also accepts 'tts-1' and 'tts-1-hd' as aliases for the active engine."
|
||
),
|
||
)
|
||
input: str = Field(
|
||
...,
|
||
max_length=4096,
|
||
description="The text to synthesize. Max 4096 characters.",
|
||
)
|
||
voice: str = Field(
|
||
default="default",
|
||
description=(
|
||
"Voice to use. For OmniVoice: pass a voice profile ID, 'default', "
|
||
"or a KittenTTS preset name. OpenAI voice names (alloy, echo, fable, "
|
||
"onyx, nova, shimmer) are accepted but mapped to defaults."
|
||
),
|
||
)
|
||
response_format: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = Field(
|
||
default="mp3",
|
||
description="Audio output format.",
|
||
)
|
||
speed: float = Field(
|
||
default=1.0,
|
||
ge=0.25,
|
||
le=4.0,
|
||
description="Speed of the generated audio (0.25 to 4.0).",
|
||
)
|
||
# OmniVoice extensions (not part of OpenAI spec, but accepted if sent)
|
||
language: Optional[str] = Field(default=None, description="Language code (ISO 639-1)")
|
||
description: Optional[str] = Field(
|
||
default=None,
|
||
description="Voice description for voice design (VoxCPM2 only). "
|
||
"E.g. 'young female, warm tone, slight British accent'.",
|
||
)
|
||
instruct: Optional[str] = Field(default=None, description="Style instruction for the TTS engine.")
|
||
duration: Optional[float] = Field(
|
||
default=None,
|
||
gt=0,
|
||
description="OmniVoice extension: target output duration in seconds.",
|
||
)
|
||
seed: Optional[int] = Field(
|
||
default=None,
|
||
description="OmniVoice extension: deterministic sampling seed.",
|
||
)
|
||
denoise: bool = Field(
|
||
default=True,
|
||
description="OmniVoice extension: prepend denoise control when supported.",
|
||
)
|
||
preprocess_prompt: bool = Field(
|
||
default=True,
|
||
description="OmniVoice extension: trim/preprocess reference prompt when supported.",
|
||
)
|
||
chunk_duration: Optional[float] = Field(
|
||
default=None,
|
||
ge=0,
|
||
description="OmniVoice GGUF extension: long-form internal chunk duration.",
|
||
)
|
||
chunk_threshold: Optional[float] = Field(
|
||
default=None,
|
||
ge=0,
|
||
description="OmniVoice GGUF extension: long-form internal chunk threshold.",
|
||
)
|
||
# #1014: these two were silently DISCARDED before (pydantic ignores
|
||
# undeclared fields) — a 200 OK that quietly dropped the caller's quality
|
||
# knobs. Declared now and passed through, matching the native /generate
|
||
# form fields (defaults there: num_step=16, guidance_scale=2.0; the
|
||
# model's documented "quality" preset is num_step=32).
|
||
num_step: Optional[int] = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=128,
|
||
description="OmniVoice extension: iterative unmasking steps (app default 16; 32 = the model's documented quality preset).",
|
||
)
|
||
guidance_scale: Optional[float] = Field(
|
||
default=None,
|
||
gt=0,
|
||
le=20,
|
||
description="OmniVoice extension: classifier-free guidance scale (app default 2.0).",
|
||
)
|
||
|
||
|
||
class TranscriptionResponse(BaseModel):
|
||
"""Mirrors OpenAI's CreateTranscriptionResponse."""
|
||
|
||
text: str
|
||
|
||
|
||
class VerboseTranscriptionResponse(BaseModel):
|
||
"""Mirrors OpenAI's verbose_json transcription response."""
|
||
|
||
task: str = "transcribe"
|
||
language: str = ""
|
||
duration: float = 0.0
|
||
text: str = ""
|
||
segments: list[dict] = Field(default_factory=list)
|
||
|
||
|
||
# ── OpenAI voice name mapping ──────────────────────────────────────────────
|
||
|
||
# OpenAI's 6 named voices aren't real voices in OmniVoice. Map them to
|
||
# sensible defaults so callers that hardcode "alloy" don't get a 400.
|
||
_OPENAI_VOICE_ALIASES = {
|
||
"alloy", "echo", "fable", "onyx", "nova", "shimmer",
|
||
}
|
||
|
||
|
||
# ── TTS: POST /v1/audio/speech ──────────────────────────────────────────────
|
||
|
||
|
||
def _resolve_engine(model_id: str):
|
||
"""Map an OpenAI model name to an OmniVoice backend."""
|
||
from services.tts_backend import get_backend_class, get_active_tts_backend
|
||
|
||
# Accept OpenAI model names as pass-through to the active engine.
|
||
if model_id in ("tts-1", "tts-1-hd"):
|
||
return get_active_tts_backend()
|
||
|
||
# Direct engine ID match
|
||
try:
|
||
cls = get_backend_class(model_id)
|
||
ok, msg = cls.is_available()
|
||
if not ok:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Engine '{model_id}' is not available: {msg}",
|
||
)
|
||
from services.tts_backend import OmniVoiceBackend
|
||
if cls is OmniVoiceBackend:
|
||
return get_active_tts_backend()
|
||
return cls()
|
||
except ValueError:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"Unknown model '{model_id}'. Use one of: "
|
||
"omnivoice, voxcpm2, cosyvoice, mlx-audio, kittentts, "
|
||
"moss-tts-nano, indextts2, gpt-sovits, sherpa-onnx, tts-1, tts-1-hd."
|
||
),
|
||
)
|
||
|
||
|
||
def _encode_audio(wav_tensor, sample_rate: int, fmt: str) -> tuple[bytes, str, str]:
|
||
"""Encode a torch tensor to the requested audio format. Returns (bytes, mime_type, file_ext)."""
|
||
from services.audio_io import _safe_torchaudio_save
|
||
|
||
if fmt == "wav":
|
||
buf = io.BytesIO()
|
||
_safe_torchaudio_save(buf, wav_tensor, sample_rate, format="wav")
|
||
return buf.getvalue(), "audio/wav", "wav"
|
||
|
||
if fmt == "flac":
|
||
buf = io.BytesIO()
|
||
_safe_torchaudio_save(buf, wav_tensor, sample_rate, format="flac")
|
||
return buf.getvalue(), "audio/flac", "flac"
|
||
|
||
if fmt == "mp3":
|
||
# torchaudio can write mp3 if ffmpeg backend is available.
|
||
# Fall back to wav if it can't.
|
||
buf = io.BytesIO()
|
||
try:
|
||
_safe_torchaudio_save(buf, wav_tensor, sample_rate, format="mp3")
|
||
return buf.getvalue(), "audio/mpeg", "mp3"
|
||
except Exception:
|
||
# ffmpeg not available — fall back to wav. Reset the buffer
|
||
# in case the failed mp3 attempt wrote partial bytes.
|
||
buf.seek(0)
|
||
buf.truncate(0)
|
||
_safe_torchaudio_save(buf, wav_tensor, sample_rate, format="wav")
|
||
return buf.getvalue(), "audio/wav", "wav"
|
||
|
||
if fmt == "opus":
|
||
buf = io.BytesIO()
|
||
try:
|
||
_safe_torchaudio_save(buf, wav_tensor, sample_rate, format="ogg")
|
||
return buf.getvalue(), "audio/ogg", "opus"
|
||
except Exception:
|
||
buf2 = io.BytesIO()
|
||
_safe_torchaudio_save(buf2, wav_tensor, sample_rate, format="wav")
|
||
return buf2.getvalue(), "audio/wav", "wav"
|
||
|
||
if fmt == "pcm":
|
||
# Raw 16-bit little-endian PCM, no header. Still apply the same
|
||
# clamp + dtype + contig invariants the helper enforces; we just
|
||
# can't go through it because this branch produces raw samples,
|
||
# not a container.
|
||
import torch
|
||
t = wav_tensor
|
||
if t.device.type != "cpu":
|
||
t = t.cpu()
|
||
if t.dtype != torch.float32:
|
||
t = t.to(torch.float32)
|
||
t = t.clamp(-1.0, 1.0).contiguous()
|
||
pcm = (t * 32767).clamp(-32768, 32767).to(torch.int16)
|
||
return pcm.numpy().tobytes(), "audio/pcm", "pcm"
|
||
|
||
# AAC — not widely supported by torchaudio, fall back to wav
|
||
buf = io.BytesIO()
|
||
_safe_torchaudio_save(buf, wav_tensor, sample_rate, format="wav")
|
||
return buf.getvalue(), "audio/wav", "wav"
|
||
|
||
|
||
def _run_tts(backend, text: str, kw: dict):
|
||
"""Run TTS inference in the GPU thread pool."""
|
||
from services.audio_dsp import apply_mastering, normalize_audio
|
||
wav = backend.generate(text, **kw)
|
||
sr = backend.sample_rate
|
||
# Engines that already emit mastered, studio-grade audio (e.g. VoxCPM2's
|
||
# native 48 kHz) opt out of apply_mastering via `applies_own_mastering`.
|
||
# That chain's highpass + Compressor is tuned for OmniVoice's 24 kHz clone
|
||
# output; applied to a studio engine it adds an audible level pump that
|
||
# degrades the very output we want clean. Loudness normalisation still
|
||
# runs — it's a benign peak scale, not dynamics.
|
||
if not getattr(backend, "applies_own_mastering", False):
|
||
wav = apply_mastering(wav, sample_rate=sr)
|
||
wav = normalize_audio(wav, target_dBFS=-2.0)
|
||
return wav, sr
|
||
|
||
|
||
@router.post("/speech")
|
||
async def create_speech(req: SpeechRequest):
|
||
"""Generate audio from text. Compatible with OpenAI's POST /v1/audio/speech."""
|
||
backend = _resolve_engine(req.model)
|
||
|
||
# Routing gate (#21 — no silent CPU fallback), identical to REST /generate.
|
||
from core.device_caps import detect_host_caps
|
||
from services.engine_routing import resolve_routing, routing_notice
|
||
_routing = resolve_routing(getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps())
|
||
if _routing["routing_status"] == "unavailable":
|
||
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
|
||
_routing_notice = routing_notice(_routing) # (status, reason) or None
|
||
|
||
# Build kwargs for the backend's generate() method
|
||
kw: dict = {
|
||
"speed": req.speed,
|
||
"denoise": req.denoise,
|
||
"preprocess_prompt": req.preprocess_prompt,
|
||
}
|
||
if req.duration is not None:
|
||
kw["duration"] = req.duration
|
||
if req.seed is not None:
|
||
kw["seed"] = req.seed
|
||
if req.chunk_duration is not None:
|
||
kw["chunk_duration"] = req.chunk_duration
|
||
if req.chunk_threshold is not None:
|
||
kw["chunk_threshold"] = req.chunk_threshold
|
||
if req.num_step is not None:
|
||
kw["num_step"] = req.num_step
|
||
if req.guidance_scale is not None:
|
||
kw["guidance_scale"] = req.guidance_scale
|
||
if req.language:
|
||
kw["language"] = req.language
|
||
if req.instruct:
|
||
kw["instruct"] = req.instruct
|
||
if req.description:
|
||
kw["description"] = req.description
|
||
|
||
# Voice handling: if it's a known OpenAI alias, use defaults.
|
||
# If it's a UUID-like string, treat it as a profile_id and resolve ref_audio.
|
||
voice = req.voice
|
||
if voice not in _OPENAI_VOICE_ALIASES and voice != "default":
|
||
# Try to resolve as a voice profile ID
|
||
try:
|
||
from core.db import db_conn
|
||
from core.config import VOICES_DIR
|
||
with db_conn() as conn:
|
||
row = conn.execute(
|
||
"SELECT * FROM voice_profiles WHERE id=?", (voice,)
|
||
).fetchone()
|
||
if row:
|
||
if row["is_locked"] and row["locked_audio_path"]:
|
||
kw["ref_audio"] = os.path.join(VOICES_DIR, row["locked_audio_path"])
|
||
elif row["ref_audio_path"]:
|
||
kw["ref_audio"] = os.path.join(VOICES_DIR, row["ref_audio_path"])
|
||
if row["ref_text"]:
|
||
kw["ref_text"] = row["ref_text"]
|
||
if row["instruct"] and not req.instruct:
|
||
kw["instruct"] = row["instruct"]
|
||
if req.seed is None and row["seed"] is not None:
|
||
kw["seed"] = row["seed"]
|
||
else:
|
||
# Not a profile ID — forward as engine preset name
|
||
kw["voice"] = voice
|
||
except Exception:
|
||
# Not a profile ID — might be a KittenTTS preset or similar
|
||
kw["voice"] = voice
|
||
|
||
# Engine-agnostic text normalization (junk strip, numbers→words,
|
||
# abbreviations) at this route's text→engine choke point — the same
|
||
# pre-pass as /generate, applied exactly once per request. `req.language`
|
||
# is everything this route knows about the language (None → universal
|
||
# safety filters only). Pref-gated (default ON), idempotent, never raises.
|
||
from services.text_normalization import normalize_for_tts
|
||
text = normalize_for_tts(req.input, req.language)
|
||
|
||
# ── #1033/#1037/#1014: warm the engine under the LOAD budget before the
|
||
# generate clock starts. The T4 verification (#1014) measured a fresh
|
||
# install's first /v1/audio/speech burning its whole 300s generate budget
|
||
# on the multi-GB checkpoint download (0% GPU util throughout) and dying
|
||
# with a misleading "too heavy for the available compute" error. Model
|
||
# loading gets OMNIVOICE_MODEL_LOAD_TIMEOUT (default 1200s); once warm
|
||
# this is a per-request no-op.
|
||
from services.model_manager import _model_load_timeout
|
||
try:
|
||
await run_on_gpu_pool_guarded(
|
||
backend.ensure_ready,
|
||
what=f"TTS engine '{backend.id}' model load",
|
||
timeout=_model_load_timeout(),
|
||
)
|
||
# Catch the BUILTIN TimeoutError base, not GpuJobTimeoutError by name:
|
||
# several tests reload services.model_manager mid-suite, so a class
|
||
# imported at call time can differ in identity from the one the guard
|
||
# (bound at this module's import) actually raises — the except would
|
||
# silently miss. The builtin base has one identity forever. (Caught by
|
||
# this exact test failing CI-only, in full-suite order.)
|
||
except TimeoutError as e:
|
||
logger.warning("engine load exceeded the model-load budget: %s", e)
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=(
|
||
f"TTS engine '{backend.id}' did not finish loading within its "
|
||
f"model-load budget — on a first run this usually means the weight "
|
||
f"download is slow or stalled (check Settings → Models for "
|
||
f"progress), not that generation failed. Retry once the model "
|
||
f"shows as installed."
|
||
),
|
||
) from e
|
||
|
||
try:
|
||
# Bounded + pool-reset on hang so a wedged TTS request can't starve the
|
||
# GPU pool and brick the backend (#730 class).
|
||
wav, sr = await run_on_gpu_pool_guarded(
|
||
lambda: _run_tts(backend, text, kw), what="OpenAI TTS generate")
|
||
except Exception as e:
|
||
logger.exception("OpenAI TTS failed: %s", e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
audio_bytes, mime_type, ext = _encode_audio(wav, sr, req.response_format)
|
||
|
||
_headers = {
|
||
"Content-Length": str(len(audio_bytes)),
|
||
"Content-Disposition": f'inline; filename="speech.{ext}"',
|
||
}
|
||
if _routing_notice:
|
||
from services.engine_routing import header_safe_reason
|
||
_headers["X-OmniVoice-Routing"] = _routing_notice[0]
|
||
_hr = header_safe_reason(_routing_notice[1])
|
||
if _hr:
|
||
_headers["X-OmniVoice-Routing-Reason"] = _hr
|
||
return StreamingResponse(
|
||
io.BytesIO(audio_bytes),
|
||
media_type=mime_type,
|
||
headers=_headers,
|
||
)
|
||
|
||
|
||
# ── STT: POST /v1/audio/transcriptions ──────────────────────────────────────
|
||
|
||
|
||
@router.post("/transcriptions")
|
||
async def create_transcription(
|
||
file: UploadFile = File(..., description="Audio file to transcribe"),
|
||
model: str = Form(
|
||
default="whisper-1",
|
||
description=(
|
||
"ASR model. Accepts 'whisper-1' (maps to active engine), or an "
|
||
"OmniVoice engine ID: whisperx, faster-whisper, mlx-whisper, pytorch-whisper."
|
||
),
|
||
),
|
||
language: Optional[str] = Form(
|
||
default=None,
|
||
description="Language of the input audio (ISO 639-1). Optional.",
|
||
),
|
||
prompt: Optional[str] = Form(
|
||
default=None,
|
||
description="Optional text to guide the model's style or continue a previous segment.",
|
||
),
|
||
response_format: str = Form(
|
||
default="json",
|
||
description="Output format: json, text, verbose_json, srt, vtt.",
|
||
),
|
||
temperature: Optional[float] = Form(
|
||
default=None,
|
||
description="Sampling temperature (0–1). Not used by all backends.",
|
||
),
|
||
):
|
||
"""Transcribe audio to text. Compatible with OpenAI's POST /v1/audio/transcriptions."""
|
||
from services.asr_backend import get_active_asr_backend
|
||
|
||
# Write uploaded file to a temp location
|
||
suffix = os.path.splitext(file.filename or "audio.wav")[1] or ".wav"
|
||
try:
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||
content = await file.read()
|
||
tmp.write(content)
|
||
tmp_path = tmp.name
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"Could not read audio file: {e}")
|
||
|
||
try:
|
||
backend = get_active_asr_backend()
|
||
|
||
# Run transcription in the thread pool to avoid blocking the event loop,
|
||
# bounded so a stuck/starved ASR returns a 504 with guidance instead of
|
||
# hanging the request forever (see run_transcribe_guarded).
|
||
from services.asr_backend import run_transcribe_guarded
|
||
word_ts = response_format == "verbose_json"
|
||
result = await run_transcribe_guarded(
|
||
_gpu_pool,
|
||
lambda: backend.transcribe(tmp_path, word_timestamps=word_ts),
|
||
what="OpenAI",
|
||
)
|
||
|
||
# Extract the full text from segments
|
||
segments = result.get("segments", [])
|
||
chunks = result.get("chunks", [])
|
||
full_text = " ".join(
|
||
seg.get("text", "").strip()
|
||
for seg in (segments if segments else chunks)
|
||
).strip()
|
||
detected_lang = result.get("language", language or "en")
|
||
|
||
# Format response based on requested format
|
||
if response_format == "text":
|
||
from fastapi.responses import PlainTextResponse
|
||
return PlainTextResponse(full_text)
|
||
|
||
if response_format == "verbose_json":
|
||
duration = result.get("duration", 0.0)
|
||
if not duration and segments:
|
||
last = segments[-1]
|
||
duration = last.get("end", 0.0)
|
||
return VerboseTranscriptionResponse(
|
||
task="transcribe",
|
||
language=detected_lang,
|
||
duration=duration,
|
||
text=full_text,
|
||
segments=[
|
||
{
|
||
"id": i,
|
||
"text": seg.get("text", ""),
|
||
"start": seg.get("start", 0.0),
|
||
"end": seg.get("end", 0.0),
|
||
}
|
||
for i, seg in enumerate(segments)
|
||
],
|
||
)
|
||
|
||
if response_format == "srt":
|
||
from fastapi.responses import PlainTextResponse
|
||
srt_lines = []
|
||
for i, seg in enumerate(segments, 1):
|
||
start = seg.get("start", 0.0)
|
||
end = seg.get("end", 0.0)
|
||
text = seg.get("text", "").strip()
|
||
srt_lines.append(
|
||
f"{i}\n"
|
||
f"{_format_ts_srt(start)} --> {_format_ts_srt(end)}\n"
|
||
f"{text}\n"
|
||
)
|
||
return PlainTextResponse("\n".join(srt_lines), media_type="text/plain")
|
||
|
||
if response_format == "vtt":
|
||
from fastapi.responses import PlainTextResponse
|
||
vtt_lines = ["WEBVTT\n"]
|
||
for seg in segments:
|
||
start = seg.get("start", 0.0)
|
||
end = seg.get("end", 0.0)
|
||
text = seg.get("text", "").strip()
|
||
vtt_lines.append(
|
||
f"{_format_ts_vtt(start)} --> {_format_ts_vtt(end)}\n{text}\n"
|
||
)
|
||
return PlainTextResponse("\n".join(vtt_lines), media_type="text/vtt")
|
||
|
||
# Default: json
|
||
return TranscriptionResponse(text=full_text)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except TimeoutError as e:
|
||
# ASRTimeoutError (subclass): backend alive, ASR too heavy for compute.
|
||
logger.warning("OpenAI transcription timed out: %s", e)
|
||
raise HTTPException(status_code=504, detail=str(e))
|
||
except Exception as e:
|
||
logger.exception("OpenAI transcription failed: %s", e)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
finally:
|
||
# Clean up temp file
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
# ── Voices: GET /v1/audio/voices (OmniVoice extension) ─────────────────────
|
||
|
||
|
||
@router.get("/voices")
|
||
def list_voices():
|
||
"""List available voices. OmniVoice extension to the OpenAI API."""
|
||
from services.tts_backend import list_backends
|
||
|
||
backends = list_backends()
|
||
voices = []
|
||
|
||
# Always include the OpenAI standard voice names as aliases
|
||
for name in sorted(_OPENAI_VOICE_ALIASES):
|
||
voices.append({
|
||
"voice_id": name,
|
||
"name": name.capitalize(),
|
||
"type": "openai_alias",
|
||
"description": f"OpenAI '{name}' voice — maps to the active OmniVoice engine's default voice.",
|
||
})
|
||
|
||
# Include voice profiles from the database
|
||
try:
|
||
from core.db import db_conn
|
||
with db_conn() as conn:
|
||
rows = conn.execute(
|
||
"SELECT id, name, language FROM voice_profiles ORDER BY name"
|
||
).fetchall()
|
||
for row in rows:
|
||
voices.append({
|
||
"voice_id": row["id"],
|
||
"name": row["name"],
|
||
"type": "profile",
|
||
"language": row["language"],
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
return {"voices": voices, "engines": backends}
|
||
|
||
|
||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _format_ts_srt(seconds: float) -> str:
|
||
"""Format seconds as SRT timestamp: HH:MM:SS,mmm"""
|
||
h = int(seconds // 3600)
|
||
m = int((seconds % 3600) // 60)
|
||
s = int(seconds % 60)
|
||
ms = int((seconds % 1) * 1000)
|
||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||
|
||
|
||
def _format_ts_vtt(seconds: float) -> str:
|
||
"""Format seconds as VTT timestamp: HH:MM:SS.mmm"""
|
||
h = int(seconds // 3600)
|
||
m = int((seconds % 3600) // 60)
|
||
s = int(seconds % 60)
|
||
ms = int((seconds % 1) * 1000)
|
||
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|