init
This commit is contained in:
+104
@@ -0,0 +1,104 @@
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def _find_ffmpeg(configured: str = "ffmpeg") -> str:
|
||||
if shutil.which(configured):
|
||||
return configured
|
||||
try:
|
||||
from imageio_ffmpeg import get_ffmpeg_exe
|
||||
return get_ffmpeg_exe()
|
||||
except Exception:
|
||||
raise RuntimeError(
|
||||
"ffmpeg not found: install it on PATH, set runtime.ffmpeg_path "
|
||||
"in config.json, or `pip install imageio-ffmpeg`"
|
||||
) from None
|
||||
|
||||
|
||||
_SUPPORTED_FORMATS = {"mp3", "wav", "pcm", "opus", "aac", "flac"}
|
||||
|
||||
_CONTENT_TYPES = {
|
||||
"mp3": "audio/mpeg",
|
||||
"wav": "audio/wav",
|
||||
"pcm": "audio/pcm",
|
||||
"opus": "audio/opus",
|
||||
"aac": "audio/aac",
|
||||
"flac": "audio/flac",
|
||||
}
|
||||
|
||||
_FFMPEG_CODECS = {
|
||||
"mp3": "libmp3lame",
|
||||
"opus": "libopus",
|
||||
"aac": "aac",
|
||||
"flac": "flac",
|
||||
}
|
||||
|
||||
|
||||
def pcm_to_wav(pcm: bytes, sample_rate: int = 24000) -> bytes:
|
||||
data_size = len(pcm)
|
||||
byte_rate = sample_rate * 2
|
||||
header = struct.pack(
|
||||
"<4sI4s4sI4sI",
|
||||
b"RIFF",
|
||||
36 + data_size,
|
||||
b"WAVE",
|
||||
b"fmt ",
|
||||
16,
|
||||
b"data",
|
||||
data_size,
|
||||
)
|
||||
fmt = struct.pack("<HHIIHH", 1, 1, sample_rate, byte_rate, 2, 16)
|
||||
return header[:20] + fmt + header[20:] + pcm
|
||||
|
||||
|
||||
def pcm_to_raw(pcm: bytes) -> bytes:
|
||||
return pcm
|
||||
|
||||
|
||||
def pcm_to_ffmpeg(
|
||||
pcm: bytes, fmt: str, sample_rate: int = 24000, ffmpeg_path: str = "ffmpeg"
|
||||
) -> bytes:
|
||||
codec = _FFMPEG_CODECS.get(fmt)
|
||||
if codec is None:
|
||||
raise ValueError(f"Unsupported ffmpeg format: {fmt}")
|
||||
|
||||
cmd = [
|
||||
ffmpeg_path, "-y",
|
||||
"-f", "s16le",
|
||||
"-ar", str(sample_rate),
|
||||
"-ac", "1",
|
||||
"-i", "pipe:0",
|
||||
"-f", _ffprobe_fmt(fmt),
|
||||
"-codec:a", codec,
|
||||
"pipe:1",
|
||||
]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=pcm,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {proc.stderr.decode(errors='replace')}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _ffprobe_fmt(fmt: str) -> str:
|
||||
return {"mp3": "mp3", "opus": "opus", "aac": "adts", "flac": "flac"}.get(fmt, fmt)
|
||||
|
||||
|
||||
def encode(pcm: bytes, fmt: str, sample_rate: int = 24000, ffmpeg_path: str = "ffmpeg") -> tuple[bytes, str]:
|
||||
if fmt not in _SUPPORTED_FORMATS:
|
||||
raise ValueError(f"Unsupported response_format: {fmt}")
|
||||
|
||||
if fmt == "wav":
|
||||
data = pcm_to_wav(pcm, sample_rate)
|
||||
elif fmt == "pcm":
|
||||
data = pcm
|
||||
else:
|
||||
data = pcm_to_ffmpeg(pcm, fmt, sample_rate, _find_ffmpeg(ffmpeg_path))
|
||||
|
||||
return data, _CONTENT_TYPES[fmt]
|
||||
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
file: str
|
||||
languages: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
sample_rate: int = 24000
|
||||
ffmpeg_path: str = "ffmpeg"
|
||||
translit_dict: str = "translit_dict.json"
|
||||
port: int = 5215
|
||||
host: str = "0.0.0.0"
|
||||
ssml_max_chars: int = 500
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
models: list[ModelConfig] = field(default_factory=list)
|
||||
model_voices: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
|
||||
webui: bool = False
|
||||
use_ssml: bool = True
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path = "config.json") -> "AppConfig":
|
||||
with open(path, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
models = [
|
||||
ModelConfig(file=m["file"], languages=m.get("languages", []))
|
||||
for m in raw.get("models", [])
|
||||
]
|
||||
model_voices = raw.get("model_voices", {})
|
||||
rt = raw.get("runtime", {})
|
||||
runtime = RuntimeConfig(
|
||||
sample_rate=rt.get("sample_rate", 24000),
|
||||
ffmpeg_path=rt.get("ffmpeg_path", "ffmpeg"),
|
||||
translit_dict=rt.get("translit_dict", "translit_dict.json"),
|
||||
port=int(rt.get("port", 5215)),
|
||||
host=rt.get("host", "0.0.0.0"),
|
||||
ssml_max_chars=int(rt.get("ssml_max_chars", 500)),
|
||||
)
|
||||
return cls(
|
||||
models=models,
|
||||
model_voices=model_voices,
|
||||
runtime=runtime,
|
||||
webui=raw.get("webui", False),
|
||||
use_ssml=raw.get("use_ssml", True),
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
|
||||
def invalid_request_error(message: str, status_code: int = 400) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.config import AppConfig
|
||||
from app.speech import router as speech_router
|
||||
from app.synthesis import SynthesisEngine
|
||||
from app.translit import set_dict_path
|
||||
|
||||
config = AppConfig.load()
|
||||
set_dict_path(config.runtime.translit_dict)
|
||||
engine = SynthesisEngine()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.config = config
|
||||
app.state.engine = engine
|
||||
engine.use_ssml = config.use_ssml
|
||||
engine.ssml_max_chars = config.runtime.ssml_max_chars
|
||||
engine.load(
|
||||
models_config=config.models,
|
||||
sample_rate=config.runtime.sample_rate,
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Silero TTS", version="0.1.0", lifespan=lifespan)
|
||||
app.include_router(speech_router)
|
||||
|
||||
if config.webui:
|
||||
from app.webui import router as webui_router
|
||||
app.include_router(webui_router)
|
||||
|
||||
_SUPPORTED_MODELS = ["UniosTTS"]
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
async def models():
|
||||
created = int(time.time())
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": m, "object": "model", "created": created, "owned_by": "silero"}
|
||||
for m in _SUPPORTED_MODELS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=config.runtime.host, port=config.runtime.port)
|
||||
@@ -0,0 +1,77 @@
|
||||
import re
|
||||
|
||||
from rutextnorm import normalize_russian
|
||||
|
||||
from app.ssml import PAUSE_DELIM
|
||||
|
||||
_TRAILING_CLOSERS = "\"'»)]`"
|
||||
|
||||
# Региональные (языкоспецифичные) нормализаторы: lang -> функция.
|
||||
# Общие шаги, применимые к любому языку, вынесены в normalize_common().
|
||||
_REGIONAL_NORMALIZERS = {"ru": normalize_russian}
|
||||
|
||||
_DELIM_CHARS = "([{«“<)]}»>\"'`*_~"
|
||||
_DELIM_RUN_RE = re.compile(r"([ \t]*)([" + re.escape(_DELIM_CHARS) + r"]+)([ \t]*)")
|
||||
|
||||
|
||||
def _prev_nonspace(text: str, pos: int) -> str:
|
||||
i = pos - 1
|
||||
while i >= 0 and text[i] in " \t":
|
||||
i -= 1
|
||||
return text[i] if i >= 0 else ""
|
||||
|
||||
|
||||
def _next_nonspace(text: str, pos: int) -> str:
|
||||
while pos < len(text) and text[pos] in " \t":
|
||||
pos += 1
|
||||
return text[pos] if pos < len(text) else ""
|
||||
|
||||
|
||||
def mark_pause_delimiters(text: str) -> str:
|
||||
"""Удаляет выделительные разделители (скобки, кавычки, markdown-разметку).
|
||||
Если по обе стороны от прогона есть буквы/цифры, ставит sentinel паузы
|
||||
(паузная граница для SSML-слоя) вместо литеральной запятой; иначе просто
|
||||
удаляет. Одинарная кавычка между буквами (апостроф) сохраняется."""
|
||||
def repl(m: re.Match) -> str:
|
||||
left, run, right = m.group(1), m.group(2), m.group(3)
|
||||
start, end = m.start(2), m.end(2)
|
||||
left_touch = text[start - 1] if start > 0 else ""
|
||||
right_touch = text[end] if end < len(text) else ""
|
||||
if set(run) == {"'"} and left_touch.isalnum() and right_touch.isalnum():
|
||||
return m.group(0)
|
||||
if _prev_nonspace(text, start).isalnum() and _next_nonspace(text, end).isalnum():
|
||||
return " " + PAUSE_DELIM + " "
|
||||
return " " if (left or right) else ""
|
||||
|
||||
return _DELIM_RUN_RE.sub(repl, text)
|
||||
|
||||
|
||||
def ensure_line_punctuation(text: str) -> str:
|
||||
result = []
|
||||
for line in text.split("\n"):
|
||||
line = line.rstrip()
|
||||
core = line.rstrip(_TRAILING_CLOSERS).rstrip()
|
||||
if core and core[-1].isalnum():
|
||||
line = line + "."
|
||||
result.append(line)
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def normalize_common(text: str) -> str:
|
||||
"""Языконезависимая нормализация. Применяется ко всему тексту целиком,
|
||||
до разбиения на предложения, для любого языка."""
|
||||
text = mark_pause_delimiters(text)
|
||||
text = ensure_line_punctuation(text)
|
||||
text = re.sub(r"[ \t]{2,}", " ", text)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_language(text: str, lang: str) -> str:
|
||||
"""Региональная нормализация для конкретного языка. Без изменений для
|
||||
языков, у которых нет собственных правил (например, en)."""
|
||||
fn = _REGIONAL_NORMALIZERS.get(lang)
|
||||
return fn(text) if fn else text
|
||||
|
||||
|
||||
def normalize_ru(text: str) -> str:
|
||||
return normalize_language(text, "ru")
|
||||
@@ -0,0 +1,60 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from razdel import sentenize
|
||||
|
||||
from app.normalize import normalize_common, normalize_language
|
||||
from app.ssml import DEFAULT_PAUSE_MS, explicit_pause_char
|
||||
from app.translit import transliterate_latin
|
||||
|
||||
_CYRILIC_RE = re.compile(r'[\u0400-\u04FF]')
|
||||
_PARAGRAPH_RE = re.compile(r"\n[ \t]*\n")
|
||||
# Явная отметка паузы во входном тексте: `|||` (пауза по умолчанию) или
|
||||
# `|||1500` (миллисекунды). Извлекается ДО нормализации, длительность
|
||||
# ограничивается здесь, чтобы цифры не попадали в rutextnorm.
|
||||
_EXPLICIT_PAUSE_RE = re.compile(r"\|\|\|(?:(\d+))?")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
text: str
|
||||
lang: str
|
||||
para: int = 0
|
||||
|
||||
|
||||
def split_sentences(text: str) -> list[str]:
|
||||
return [s.text for s in sentenize(text)]
|
||||
|
||||
|
||||
def split_paragraphs(text: str) -> list[str]:
|
||||
return [p for p in (block.strip() for block in _PARAGRAPH_RE.split(text)) if p]
|
||||
|
||||
|
||||
def extract_explicit_pauses(text: str) -> str:
|
||||
def repl(m: re.Match) -> str:
|
||||
ms = int(m.group(1)) if m.group(1) else DEFAULT_PAUSE_MS
|
||||
return explicit_pause_char(ms)
|
||||
return _EXPLICIT_PAUSE_RE.sub(repl, text)
|
||||
|
||||
|
||||
def has_cyrillic(text: str) -> bool:
|
||||
return bool(_CYRILIC_RE.search(text))
|
||||
|
||||
|
||||
def detect_language(text: str) -> str:
|
||||
return "ru" if has_cyrillic(text) else "en"
|
||||
|
||||
|
||||
def prepare_segments(text: str) -> list[Segment]:
|
||||
lang = detect_language(text)
|
||||
text = extract_explicit_pauses(text)
|
||||
text = normalize_common(text)
|
||||
all_segments: list[Segment] = []
|
||||
for para_idx, para in enumerate(split_paragraphs(text)):
|
||||
for sent in split_sentences(para):
|
||||
if lang == "ru":
|
||||
sent = transliterate_latin(sent)
|
||||
seg_text = normalize_language(sent, lang)
|
||||
if seg_text.strip():
|
||||
all_segments.append(Segment(text=seg_text, lang=lang, para=para_idx))
|
||||
return all_segments
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel, ValidationError, field_validator
|
||||
|
||||
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
|
||||
from app.synthesis import SynthesisEngine
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SpeechRequest(BaseModel):
|
||||
model: str = "UniosTTS"
|
||||
voice: str
|
||||
input: str
|
||||
response_format: str = "mp3"
|
||||
speed: float = 1.0
|
||||
stream: bool | None = None
|
||||
stream_format: str | None = None
|
||||
rate: str | None = None
|
||||
pitch: str | None = None
|
||||
|
||||
@field_validator("rate")
|
||||
@classmethod
|
||||
def _check_rate(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in RATE_VALUES:
|
||||
raise ValueError(f"rate must be one of {', '.join(RATE_VALUES)}")
|
||||
return v
|
||||
|
||||
@field_validator("pitch")
|
||||
@classmethod
|
||||
def _check_pitch(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in PITCH_VALUES:
|
||||
raise ValueError(f"pitch must be one of {', '.join(PITCH_VALUES)}")
|
||||
return v
|
||||
|
||||
|
||||
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("/v1/audio/voices")
|
||||
async def voices(request: Request):
|
||||
config: AppConfig = request.app.state.config
|
||||
ids: list[str] = []
|
||||
for voices_map in config.model_voices.values():
|
||||
for v in voices_map:
|
||||
if v not in ids:
|
||||
ids.append(v)
|
||||
ids.sort()
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [{"id": name, "object": "voice"} for name in ids],
|
||||
"voices": ids,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/v1/audio/speech")
|
||||
async def speech(request: Request):
|
||||
body = await request.json()
|
||||
|
||||
try:
|
||||
req = SpeechRequest(**body)
|
||||
except ValidationError as e:
|
||||
msg = str(e.errors()[0].get("msg", "invalid parameter")) if e.errors() else "invalid request"
|
||||
return invalid_request_error(msg, status_code=422)
|
||||
|
||||
if not req.input.strip():
|
||||
return invalid_request_error("input must be a non-empty string")
|
||||
|
||||
if req.response_format not in _SUPPORTED_FORMATS:
|
||||
return invalid_request_error(
|
||||
f"Unsupported response_format: '{req.response_format}'"
|
||||
)
|
||||
|
||||
if req.speed != 1.0:
|
||||
return invalid_request_error(
|
||||
"speech rate adjustment is not supported; speed must be 1.0"
|
||||
)
|
||||
|
||||
if req.stream_format == "sse":
|
||||
return invalid_request_error("stream_format 'sse' is not supported")
|
||||
|
||||
config: AppConfig = request.app.state.config
|
||||
engine: SynthesisEngine = request.app.state.engine
|
||||
|
||||
fmt = req.response_format
|
||||
sr = config.runtime.sample_rate
|
||||
ff = config.runtime.ffmpeg_path
|
||||
|
||||
is_stream = req.stream is True or req.stream_format == "audio"
|
||||
|
||||
lang = detect_language(req.input)
|
||||
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, req.voice)
|
||||
if speaker is None:
|
||||
return invalid_request_error(f"voice '{req.voice}' not found for model '{model.model_id}'")
|
||||
|
||||
segments = prepare_segments(req.input)
|
||||
if not segments:
|
||||
return invalid_request_error("no speakable text found after processing")
|
||||
|
||||
if is_stream:
|
||||
def _stream_gen():
|
||||
for seg in segments:
|
||||
seg_pcm = engine.synthesize_segments(
|
||||
[seg], speaker, lang, rate=req.rate, pitch=req.pitch
|
||||
)
|
||||
data, _ = encode(seg_pcm, fmt, sr, ff)
|
||||
yield data
|
||||
|
||||
_, ct = encode(b"\x00" * 4, fmt, sr, ff)
|
||||
return StreamingResponse(
|
||||
_stream_gen(), media_type=ct, headers={"Transfer-Encoding": "chunked"}
|
||||
)
|
||||
|
||||
pcm = engine.synthesize_segments(segments, speaker, lang, rate=req.rate, pitch=req.pitch)
|
||||
data, content_type = encode(pcm, fmt, sr, ff)
|
||||
return Response(content=data, media_type=content_type)
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Sentinel, который нормализатор оставляет вместо удалённого выделительного
|
||||
# разделителя (пауза фиксированной длины).
|
||||
PAUSE_DELIM = "\uE000"
|
||||
DEFAULT_PAUSE_MS = 150
|
||||
MAX_BREAK_MS = 5000
|
||||
|
||||
# Явная пользовательская пауза кодируется ОДНИМ символом из PUA:
|
||||
# код = EXPLICIT_BASE + длительность_в_мс (0..MAX_BREAK_MS). Одно кодо-point
|
||||
# переживает rutextnorm/translit (в отличие от длинных прогонов и цифр).
|
||||
_EXPLICIT_BASE = 0xE100
|
||||
|
||||
RATE_VALUES = ("x-slow", "slow", "medium", "fast", "x-fast")
|
||||
PITCH_VALUES = ("x-low", "low", "medium", "high", "x-high")
|
||||
|
||||
|
||||
def _escape(text: str) -> str:
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
def explicit_pause_char(ms: int) -> str:
|
||||
ms = max(0, min(int(ms), MAX_BREAK_MS))
|
||||
return chr(_EXPLICIT_BASE + ms)
|
||||
|
||||
|
||||
def _is_sentinel(char: str) -> bool:
|
||||
return _segment_to_break(char) is not None
|
||||
|
||||
|
||||
def strip_sentinels(text: str, replacement: str = "") -> str:
|
||||
"""Убирает sentinel-символы (для отображения нормализованного текста)."""
|
||||
out = [replacement if _is_sentinel(ch) else ch for ch in text]
|
||||
return re.sub(r" {2,}", " ", "".join(out)).strip()
|
||||
|
||||
|
||||
def _segment_to_break(char: str) -> int | None:
|
||||
"""Длительность паузы (мс) для sentinel-символа или None, если это текст."""
|
||||
code = ord(char)
|
||||
if char == PAUSE_DELIM:
|
||||
return DEFAULT_PAUSE_MS
|
||||
if _EXPLICIT_BASE <= code <= _EXPLICIT_BASE + MAX_BREAK_MS:
|
||||
return code - _EXPLICIT_BASE
|
||||
return None
|
||||
|
||||
|
||||
def _sentence_content(text: str) -> str:
|
||||
"""Текст предложения с заменой sentinel-пауз на <break .../>; сам sentinel
|
||||
вырезается, XML-спецсимволы экранируются."""
|
||||
parts: list[str] = []
|
||||
buf: list[str] = []
|
||||
for ch in text:
|
||||
ms = _segment_to_break(ch)
|
||||
if ms is None:
|
||||
buf.append(ch)
|
||||
continue
|
||||
if buf:
|
||||
parts.append(_escape("".join(buf)))
|
||||
buf = []
|
||||
ms = max(0, min(ms, MAX_BREAK_MS))
|
||||
parts.append(f'<break time="{ms}ms"/>')
|
||||
if buf:
|
||||
parts.append(_escape("".join(buf)))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _para_block(segments) -> str:
|
||||
inner = "".join(f"<s>{_sentence_content(s.text)}</s>" for s in segments)
|
||||
return f"<p>{inner}</p>"
|
||||
|
||||
|
||||
def _content_length(segments) -> int:
|
||||
total = 0
|
||||
for s in segments:
|
||||
total += sum(1 for ch in s.text if _segment_to_break(ch) is None)
|
||||
return total
|
||||
|
||||
|
||||
def build_ssml(segments, *, rate: str | None = None, pitch: str | None = None) -> str:
|
||||
"""Собирает один <speak>-документ: предложения -> <s>, группы предложений
|
||||
одного абзаца (одинаковый Segment.para) -> <p>. rate/pitch (опционально)
|
||||
оборачивают всё содержимое одним <prosody>."""
|
||||
if not segments:
|
||||
return ""
|
||||
|
||||
groups: list[list] = []
|
||||
for seg in segments:
|
||||
key = (seg.lang, getattr(seg, "para", 0))
|
||||
if groups and (
|
||||
(groups[-1][0].lang, getattr(groups[-1][0], "para", 0)) == key
|
||||
):
|
||||
groups[-1].append(seg)
|
||||
else:
|
||||
groups.append([seg])
|
||||
|
||||
body = "".join(_para_block(g) for g in groups)
|
||||
|
||||
attrs: list[str] = []
|
||||
if rate and rate != "medium":
|
||||
attrs.append(f'rate="{rate}"')
|
||||
if pitch and pitch != "medium":
|
||||
attrs.append(f'pitch="{pitch}"')
|
||||
if attrs:
|
||||
body = f"<prosody {' '.join(attrs)}>{body}</prosody>"
|
||||
|
||||
return f"<speak>{body}</speak>"
|
||||
|
||||
|
||||
def build_ssml_chunks(segments, *, rate: str | None = None, pitch: str | None = None,
|
||||
max_chars: int = 5000) -> list[str]:
|
||||
"""Режет сегменты по границам абзацев на документы <= max_chars символов
|
||||
контента (параграф = атомарный чанк; гигантский параграф режется по
|
||||
предложениям). Возвращает список <speak>-документов."""
|
||||
if not segments:
|
||||
return []
|
||||
|
||||
paragraphs: list[list] = []
|
||||
for seg in segments:
|
||||
key = (seg.lang, getattr(seg, "para", 0))
|
||||
if paragraphs and (
|
||||
(paragraphs[-1][0].lang, getattr(paragraphs[-1][0], "para", 0)) == key
|
||||
):
|
||||
paragraphs[-1].append(seg)
|
||||
else:
|
||||
paragraphs.append([seg])
|
||||
|
||||
chunks: list[list] = []
|
||||
current: list = []
|
||||
current_len = 0
|
||||
for para in paragraphs:
|
||||
para_len = _content_length(para)
|
||||
if para_len > max_chars:
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = []
|
||||
current_len = 0
|
||||
piece: list = []
|
||||
piece_len = 0
|
||||
for seg in para:
|
||||
seg_len = _content_length([seg])
|
||||
if piece and piece_len + seg_len > max_chars:
|
||||
chunks.append(piece)
|
||||
piece = []
|
||||
piece_len = 0
|
||||
piece.append(seg)
|
||||
piece_len += seg_len
|
||||
if piece:
|
||||
current = piece
|
||||
current_len = piece_len
|
||||
continue
|
||||
if current and current_len + para_len > max_chars:
|
||||
chunks.append(current)
|
||||
current = []
|
||||
current_len = 0
|
||||
current.extend(para)
|
||||
current_len += para_len
|
||||
if current:
|
||||
chunks.append(current)
|
||||
|
||||
return [build_ssml(ch, rate=rate, pitch=pitch) for ch in chunks]
|
||||
@@ -0,0 +1,112 @@
|
||||
import numpy as np
|
||||
from dataclasses import dataclass, field
|
||||
from silero_tts.silero_tts import SileroTTS
|
||||
|
||||
from app.segmentation import Segment
|
||||
from app.ssml import build_ssml_chunks
|
||||
|
||||
SSML_MAX_CHARS = 500
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelEntry:
|
||||
model_id: str
|
||||
tts: SileroTTS
|
||||
languages: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SynthesisEngine:
|
||||
models: list[ModelEntry] = field(default_factory=list)
|
||||
sample_rate: int = 24000
|
||||
use_ssml: bool = True
|
||||
ssml_max_chars: int = SSML_MAX_CHARS
|
||||
|
||||
def load(self, models_config, sample_rate: int = 24000) -> None:
|
||||
self.sample_rate = sample_rate
|
||||
self.models = []
|
||||
for m in models_config:
|
||||
tts = SileroTTS(
|
||||
model_id=m.file,
|
||||
language=m.languages[0] if m.languages else "ru",
|
||||
speaker="default",
|
||||
sample_rate=sample_rate,
|
||||
device="cpu",
|
||||
num_threads=4,
|
||||
)
|
||||
self.models.append(ModelEntry(
|
||||
model_id=m.file,
|
||||
tts=tts,
|
||||
languages=m.languages,
|
||||
))
|
||||
|
||||
def find_model(self, lang: str) -> ModelEntry | None:
|
||||
for m in self.models:
|
||||
if lang in m.languages:
|
||||
return m
|
||||
return None
|
||||
|
||||
def get_model(self, model_id: str) -> ModelEntry | None:
|
||||
for m in self.models:
|
||||
if m.model_id == model_id:
|
||||
return m
|
||||
return None
|
||||
|
||||
def available_speakers(self) -> dict[str, list[str]]:
|
||||
return {m.model_id: list(m.tts.tts_model.speakers) for m in self.models}
|
||||
|
||||
def _synthesize_text(self, model: ModelEntry, speaker: str, text: str) -> np.ndarray:
|
||||
audio = model.tts.tts_model.apply_tts(
|
||||
text=text,
|
||||
speaker=speaker,
|
||||
sample_rate=model.tts.sample_rate,
|
||||
put_accent=model.tts.put_accent,
|
||||
put_yo=model.tts.put_yo,
|
||||
)
|
||||
return (audio.numpy() * 32767).astype(np.int16)
|
||||
|
||||
def _synthesize_ssml(self, model: ModelEntry, speaker: str, ssml_text: str) -> np.ndarray:
|
||||
audio = model.tts.tts_model.apply_tts(
|
||||
ssml_text=ssml_text,
|
||||
speaker=speaker,
|
||||
sample_rate=model.tts.sample_rate,
|
||||
put_accent=True,
|
||||
put_yo=model.tts.put_yo,
|
||||
)
|
||||
return (audio.numpy() * 32767).astype(np.int16)
|
||||
|
||||
def synthesize_segments(
|
||||
self, segments: list[Segment], speaker: str, lang: str,
|
||||
*, rate: str | None = None, pitch: str | None = None, use_ssml: bool | None = None,
|
||||
) -> bytes:
|
||||
model = self.find_model(lang)
|
||||
if model is None:
|
||||
raise ValueError(f"no model supports language '{lang}'")
|
||||
return self._synthesize_multi(segments, model, speaker, rate, pitch, use_ssml)
|
||||
|
||||
def synthesize_segments_model(
|
||||
self, segments: list[Segment], model_id: str, speaker: str,
|
||||
*, rate: str | None = None, pitch: str | None = None, use_ssml: bool | None = None,
|
||||
) -> bytes:
|
||||
model = self.get_model(model_id)
|
||||
if model is None:
|
||||
raise ValueError(f"model '{model_id}' not loaded")
|
||||
return self._synthesize_multi(segments, model, speaker, rate, pitch, use_ssml)
|
||||
|
||||
def _synthesize_multi(
|
||||
self, segments: list[Segment], model: ModelEntry, speaker: str,
|
||||
rate: str | None, pitch: str | None, use_ssml: bool | None,
|
||||
) -> bytes:
|
||||
parts: list[np.ndarray] = []
|
||||
ssml = self.use_ssml if use_ssml is None else use_ssml
|
||||
if ssml:
|
||||
for chunk in build_ssml_chunks(
|
||||
segments, rate=rate, pitch=pitch, max_chars=self.ssml_max_chars
|
||||
):
|
||||
parts.append(self._synthesize_ssml(model, speaker, chunk))
|
||||
else:
|
||||
for seg in segments:
|
||||
parts.append(self._synthesize_text(model, speaker, seg.text))
|
||||
if not parts:
|
||||
return b""
|
||||
return np.concatenate(parts).tobytes()
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_LATIN_RUN_RE = re.compile(r"[A-Za-z]+")
|
||||
|
||||
# Фонетические правила en->ru. Применяются слева направо, длиннейшее
|
||||
# совпадение выигрывает (триграфы раньше диграфов, диграфы раньше букв).
|
||||
_RULES: dict[str, str] = {
|
||||
"tion": "шн",
|
||||
"sure": "шур",
|
||||
"ture": "тур",
|
||||
"ough": "оф",
|
||||
"tch": "ч",
|
||||
"sch": "ш",
|
||||
"igh": "ай",
|
||||
"ng": "нг",
|
||||
"nk": "нк",
|
||||
"qu": "кв",
|
||||
"th": "т",
|
||||
"sh": "ш",
|
||||
"ch": "ч",
|
||||
"ck": "к",
|
||||
"ph": "ф",
|
||||
"wh": "у",
|
||||
"wr": "р",
|
||||
"kn": "н",
|
||||
"ee": "и",
|
||||
"ea": "и",
|
||||
"oo": "у",
|
||||
"ou": "ау",
|
||||
"ow": "ау",
|
||||
"ai": "ай",
|
||||
"ay": "эй",
|
||||
"oi": "ой",
|
||||
"oy": "ой",
|
||||
"au": "о",
|
||||
"aw": "о",
|
||||
"ir": "ер",
|
||||
"ur": "ер",
|
||||
"er": "ер",
|
||||
"ar": "ар",
|
||||
"or": "ор",
|
||||
"a": "а",
|
||||
"b": "б",
|
||||
"c": "к",
|
||||
"d": "д",
|
||||
"e": "е",
|
||||
"f": "ф",
|
||||
"g": "г",
|
||||
"h": "х",
|
||||
"i": "и",
|
||||
"j": "дж",
|
||||
"k": "к",
|
||||
"l": "л",
|
||||
"m": "м",
|
||||
"n": "н",
|
||||
"o": "о",
|
||||
"p": "п",
|
||||
"q": "к",
|
||||
"r": "р",
|
||||
"s": "с",
|
||||
"t": "т",
|
||||
"u": "у",
|
||||
"v": "в",
|
||||
"w": "у",
|
||||
"x": "кс",
|
||||
"y": "и",
|
||||
"z": "з",
|
||||
}
|
||||
_RULE_KEYS = sorted(_RULES, key=len, reverse=True)
|
||||
|
||||
_dict_path = Path("translit_dict.json")
|
||||
_cache: tuple[dict[str, str], dict[str, str]] | None = None
|
||||
|
||||
|
||||
def set_dict_path(path: str | Path) -> None:
|
||||
global _dict_path, _cache
|
||||
_dict_path = Path(path)
|
||||
_cache = None
|
||||
|
||||
|
||||
def _load_dict() -> tuple[dict[str, str], dict[str, str]]:
|
||||
global _cache
|
||||
if _cache is None:
|
||||
data = json.loads(_dict_path.read_text(encoding="utf-8"))
|
||||
exact = {k.lower(): v for k, v in data.get("exact", {}).items()}
|
||||
abbrev = {k.upper(): v for k, v in data.get("abbreviations", {}).items()}
|
||||
_cache = (exact, abbrev)
|
||||
return _cache
|
||||
|
||||
|
||||
def _apply_rules(word: str) -> str:
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
n = len(word)
|
||||
while i < n:
|
||||
for key in _RULE_KEYS:
|
||||
if word.startswith(key, i):
|
||||
out.append(_RULES[key])
|
||||
i += len(key)
|
||||
break
|
||||
else:
|
||||
out.append(word[i])
|
||||
i += 1
|
||||
result = "".join(out)
|
||||
if word.endswith("e") and len(result) > 2 and result.endswith("е"):
|
||||
result = result[:-1]
|
||||
return result
|
||||
|
||||
|
||||
def _match_case(token: str, lower_out: str) -> str:
|
||||
if token.isupper():
|
||||
return lower_out.upper()
|
||||
if token[:1].isupper():
|
||||
return lower_out.capitalize()
|
||||
return lower_out
|
||||
|
||||
|
||||
def transliterate_token(token: str) -> str:
|
||||
exact, abbrev = _load_dict()
|
||||
if token.isupper() and token in abbrev:
|
||||
return abbrev[token]
|
||||
key = token.lower()
|
||||
if key in exact:
|
||||
return _match_case(token, exact[key])
|
||||
return _match_case(token, _apply_rules(key))
|
||||
|
||||
|
||||
def transliterate_latin(text: str) -> str:
|
||||
return _LATIN_RUN_RE.sub(lambda m: transliterate_token(m.group()), text)
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Silero TTS Test</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; padding: 2rem; }
|
||||
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; color: #a0c4ff; }
|
||||
.container { max-width: 720px; margin: 0 auto; }
|
||||
.controls { display: flex; flex-direction: column; gap: 0.8rem; margin-bottom: 1.2rem; }
|
||||
textarea { width: 100%; min-height: 100px; padding: 0.7rem; border: 1px solid #333; border-radius: 6px; background: #16213e; color: #e0e0e0; font-size: 0.95rem; resize: vertical; }
|
||||
textarea:focus { outline: none; border-color: #a0c4ff; }
|
||||
.row { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
|
||||
.row label { font-size: 0.85rem; color: #888; }
|
||||
select { padding: 0.4rem 0.6rem; border: 1px solid #333; border-radius: 4px; background: #16213e; color: #e0e0e0; font-size: 0.9rem; }
|
||||
button { padding: 0.5rem 1.2rem; border: none; border-radius: 4px; background: #a0c4ff; color: #1a1a2e; font-weight: 600; cursor: pointer; font-size: 0.9rem; }
|
||||
button:hover { background: #7db4f0; }
|
||||
button:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
.btn-download { background: #4ecca3; }
|
||||
.btn-download:hover { background: #3db892; }
|
||||
.result { margin-top: 1.2rem; padding: 1rem; background: #16213e; border-radius: 8px; border: 1px solid #333; display: none; }
|
||||
.result.visible { display: block; }
|
||||
.meta { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.8rem; font-size: 0.85rem; }
|
||||
.meta .badge { background: #0f3460; padding: 0.2rem 0.5rem; border-radius: 3px; }
|
||||
.meta .lang { background: #e94560; color: #fff; font-weight: 600; }
|
||||
.normalized { font-size: 0.85rem; color: #aaa; margin-bottom: 0.8rem; word-break: break-word; }
|
||||
.normalized strong { color: #ccc; }
|
||||
audio { width: 100%; margin: 0.5rem 0; }
|
||||
.actions { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
|
||||
.history { margin-top: 1.5rem; }
|
||||
.history h2 { font-size: 1rem; color: #888; margin-bottom: 0.6rem; }
|
||||
.history table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
||||
.history th { text-align: left; color: #666; padding: 0.3rem 0.5rem; border-bottom: 1px solid #333; }
|
||||
.history td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #222; }
|
||||
.history td.truncate { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #555; border-top-color: #a0c4ff; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 0.4rem; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.status { font-size: 0.85rem; color: #888; min-height: 1.2em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Silero TTS Test</h1>
|
||||
<div class="controls">
|
||||
<textarea id="input" placeholder="Enter text..."></textarea>
|
||||
<div class="row">
|
||||
<label>Model</label>
|
||||
<select id="model"></select>
|
||||
<label>Voice</label>
|
||||
<select id="voice"></select>
|
||||
<label>Format</label>
|
||||
<select id="format">
|
||||
<option value="mp3">mp3</option>
|
||||
<option value="wav">wav</option>
|
||||
<option value="opus">opus</option>
|
||||
<option value="aac">aac</option>
|
||||
<option value="flac">flac</option>
|
||||
</select>
|
||||
<label>Rate</label>
|
||||
<select id="rate">
|
||||
<option value="">normal</option>
|
||||
<option value="x-slow">x-slow</option>
|
||||
<option value="slow">slow</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="fast">fast</option>
|
||||
<option value="x-fast">x-fast</option>
|
||||
</select>
|
||||
<label>Pitch</label>
|
||||
<select id="pitch">
|
||||
<option value="">normal</option>
|
||||
<option value="x-low">x-low</option>
|
||||
<option value="low">low</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="high">high</option>
|
||||
<option value="x-high">x-high</option>
|
||||
</select>
|
||||
<button id="generate">Generate</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
<div class="result" id="result">
|
||||
<div class="meta">
|
||||
<span class="badge lang" id="r-lang"></span>
|
||||
<span class="badge" id="r-model"></span>
|
||||
<span class="badge" id="r-voice"></span>
|
||||
<span class="badge" id="r-speaker"></span>
|
||||
<span class="badge" id="r-segs"></span>
|
||||
<span class="badge" id="r-time"></span>
|
||||
</div>
|
||||
<div class="normalized"><strong>Normalized:</strong> <span id="r-text"></span></div>
|
||||
<audio id="r-audio" controls></audio>
|
||||
<div class="actions">
|
||||
<button class="btn-download" id="r-download">Download</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history" id="history-section" style="display:none">
|
||||
<h2>History</h2>
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Lang</th><th>Voice</th><th>Speaker</th><th>Text</th><th>Time</th></tr></thead>
|
||||
<tbody id="history-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const history = [];
|
||||
|
||||
async function loadModels() {
|
||||
const res = await fetch('/webui/models');
|
||||
const data = await res.json();
|
||||
const sel = $('model');
|
||||
sel.innerHTML = '';
|
||||
for (const m of data.models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m; opt.textContent = m;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVoices() {
|
||||
const res = await fetch('/webui/voices');
|
||||
const data = await res.json();
|
||||
const sel = $('voice');
|
||||
sel.innerHTML = '';
|
||||
for (const v of data.voices) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v; opt.textContent = v;
|
||||
if (v === 'default') opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
$('generate').disabled = loading;
|
||||
$('status').innerHTML = loading ? '<span class="spinner"></span>Generating...' : '';
|
||||
}
|
||||
|
||||
function base64ToBlob(b64, format) {
|
||||
const mimeMap = {mp3:'audio/mpeg', wav:'audio/wav', opus:'audio/opus', aac:'audio/aac', flac:'audio/flac', pcm:'audio/pcm'};
|
||||
const binary = atob(b64);
|
||||
const array = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
|
||||
return new Blob([array], { type: mimeMap[format] || 'application/octet-stream' });
|
||||
}
|
||||
|
||||
function addToHistory(entry) {
|
||||
history.unshift(entry);
|
||||
$('history-section').style.display = '';
|
||||
const tbody = $('history-body');
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${entry.time}</td><td>${entry.lang}</td><td>${entry.voice}</td><td>${entry.speaker}</td><td class="truncate">${entry.text}</td><td>${entry.duration}</td>`;
|
||||
tbody.prepend(tr);
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const text = $('input').value;
|
||||
if (!text.trim()) return;
|
||||
setLoading(true);
|
||||
$('result').classList.remove('visible');
|
||||
try {
|
||||
const res = await fetch('/webui/speech', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
input: text,
|
||||
model: $('model').value,
|
||||
voice: $('voice').value,
|
||||
response_format: $('format').value,
|
||||
rate: $('rate').value || undefined,
|
||||
pitch: $('pitch').value || undefined
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({error:{message:'request failed'}}));
|
||||
$('status').textContent = 'Error: ' + (err.error?.message || res.statusText);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const blob = base64ToBlob(data.audio, data.format);
|
||||
const url = URL.createObjectURL(blob);
|
||||
$('r-audio').src = url;
|
||||
$('r-lang').textContent = data.language;
|
||||
$('r-model').textContent = data.model;
|
||||
$('r-voice').textContent = 'voice: ' + data.voice;
|
||||
$('r-speaker').textContent = 'speaker: ' + data.speaker;
|
||||
$('r-segs').textContent = 'Segs: ' + data.segments;
|
||||
$('r-time').textContent = data.synthesis_time_ms + 'ms';
|
||||
$('r-text').textContent = data.normalized_text;
|
||||
$('r-download').onclick = () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'tts_' + Date.now() + '.' + data.format;
|
||||
a.click();
|
||||
};
|
||||
$('result').classList.add('visible');
|
||||
const now = new Date();
|
||||
addToHistory({
|
||||
time: now.toLocaleTimeString(),
|
||||
lang: data.language,
|
||||
voice: data.voice,
|
||||
speaker: data.speaker,
|
||||
text: text.length > 40 ? text.slice(0, 40) + '...' : text,
|
||||
duration: data.synthesis_time_ms + 'ms'
|
||||
});
|
||||
$('r-audio').play().catch(() => {});
|
||||
} catch (e) {
|
||||
$('status').textContent = 'Error: ' + e.message;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
$('generate').addEventListener('click', generate);
|
||||
$('input').addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); generate(); }
|
||||
});
|
||||
loadModels();
|
||||
loadVoices();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
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,
|
||||
})
|
||||
Reference in New Issue
Block a user