This commit is contained in:
Shaman
2026-10-01 07:38:01 +03:00
commit 82c306ff69
33 changed files with 2629 additions and 0 deletions
View File
+104
View File
@@ -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]
+54
View File
@@ -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),
)
+15
View File
@@ -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
View File
@@ -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)
+77
View File
@@ -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")
+60
View File
@@ -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
View File
@@ -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
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
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]
+112
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
})