Files
Shaman 82c306ff69 init
2026-10-01 07:38:01 +03:00

119 lines
3.5 KiB
Python

import base64
import time
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
from app.audio import encode, _SUPPORTED_FORMATS
from app.config import AppConfig
from app.errors import invalid_request_error
from app.segmentation import detect_language, prepare_segments
from app.ssml import PITCH_VALUES, RATE_VALUES, strip_sentinels
from app.synthesis import SynthesisEngine
router = APIRouter()
_HTML_PATH = Path(__file__).parent / "webui.html"
_MODEL_NAME = "UniosTTS"
def _standard_voices(config: AppConfig) -> list[str]:
ids: list[str] = []
for voices_map in config.model_voices.values():
for v in voices_map:
if v not in ids:
ids.append(v)
return sorted(ids)
def _resolve_speaker(config: AppConfig, model_id: str, voice_name: str) -> str | None:
voices = config.model_voices.get(model_id, {})
return voices.get(voice_name) or voices.get("default")
@router.get("/webui")
async def serve_ui():
return FileResponse(_HTML_PATH, media_type="text/html")
@router.get("/webui/models")
async def models():
return {"models": [_MODEL_NAME]}
@router.get("/webui/voices")
async def voices(request: Request):
config: AppConfig = request.app.state.config
return {"voices": _standard_voices(config)}
@router.post("/webui/speech")
async def speech(request: Request):
body = await request.json()
text = body.get("input", "")
if not text.strip():
return invalid_request_error("input must be a non-empty string")
voice = body.get("voice", "default")
fmt = body.get("response_format", "mp3")
rate = body.get("rate") or None
pitch = body.get("pitch") or None
if fmt not in _SUPPORTED_FORMATS:
return invalid_request_error(f"Unsupported response_format: '{fmt}'")
if rate is not None and rate not in RATE_VALUES:
return invalid_request_error(
f"Unsupported rate: '{rate}' (expected one of {', '.join(RATE_VALUES)})"
)
if pitch is not None and pitch not in PITCH_VALUES:
return invalid_request_error(
f"Unsupported pitch: '{pitch}' (expected one of {', '.join(PITCH_VALUES)})"
)
config: AppConfig = request.app.state.config
engine: SynthesisEngine = request.app.state.engine
sr = config.runtime.sample_rate
ff = config.runtime.ffmpeg_path
lang = detect_language(text)
model = engine.find_model(lang)
if model is None:
return invalid_request_error(f"no model supports language '{lang}'")
speaker = _resolve_speaker(config, model.model_id, voice)
if speaker is None:
return invalid_request_error(
f"voice '{voice}' not found for model '{model.model_id}'"
)
segments = prepare_segments(text)
if not segments:
return invalid_request_error("no speakable text found after processing")
normalized_text = strip_sentinels(" ".join(seg.text for seg in segments), " · ")
t0 = time.perf_counter()
pcm = engine.synthesize_segments_model(
segments, model.model_id, speaker, rate=rate, pitch=pitch
)
audio_bytes, _ = encode(pcm, fmt, sr, ff)
elapsed_ms = int((time.perf_counter() - t0) * 1000)
return JSONResponse({
"audio": base64.b64encode(audio_bytes).decode(),
"format": fmt,
"language": lang,
"model": _MODEL_NAME,
"voice": voice,
"speaker": speaker,
"normalized_text": normalized_text,
"segments": len(segments),
"synthesis_time_ms": elapsed_ms,
})