initial: silero-tts-service + README

This commit is contained in:
Никита Тарасов committed 2026-08-24 21:28:44 +05:00
1 parent 8c60c25ad2
commit 984b1fec6d
18 files changed
+2080

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
Silero TTS CLI — синтез речи через HTTP API для пайплайнов и скриптов.
Использование:
# Проверка здоровья
python3 tts.py health
# Список голосов
python3 tts.py voices
# Базовый синтез
python3 tts.py speak "Привет, мир!" --voice xenia --output hello.wav
# Синтез в MP3
python3 tts.py speak "Тестовый текст" --voice baya --format mp3 --output test.mp3
# Пайплайн: конвейер (stdin → stdout)
echo "Текст для озвучки" | python3 tts.py pipe --voice aidar > output.wav
# Пайплайн: JSON-результат (base64-аудио)
python3 tts.py speak "Текст" --json
# HyperFrames: генерация аудио для видео-композиции
python3 tts.py speak "Добро пожаловать в HyperFrames" --voice xenia --output narration.wav
Требования: сервис Silero TTS на http://127.0.0.1:8001 (или через env SILERO_TTS_URL)
"""
import argparse
import base64
import json
import os
import sys
from urllib.request import Request, urlopen
from urllib.error import URLError
API_URL = os.environ.get("SILERO_TTS_URL", "http://127.0.0.1:8001")
# track whether URL was overridden from CLI
_url_from_args = None
def get_api_url() -> str:
"""Получить текущий URL API (CLI-аргумент или env)."""
return _url_from_args or API_URL
SPEAKERS = {
"aidar": "Мужской, нейтральный (русский)",
"baya": "Женский, мягкий (русский)",
"kseniya": "Женский, энергичный (русский)",
"xenia": "Женский, молодой (русский)",
"eugene": "Мужской, спокойный (русский)",
}
def api_get(path: str) -> dict:
"""GET-запрос к API, возвращает JSON."""
url = f"{get_api_url()}{path}"
req = Request(url)
req.add_header("Accept", "application/json")
try:
with urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except URLError as e:
print(f"Ошибка подключения к {url}: {e}", file=sys.stderr)
print("Сервис запущен? Проверь: curl http://127.0.0.1:8001/health", file=sys.stderr)
sys.exit(1)
def api_post(path: str, data: dict, return_raw: bool = False) -> bytes | dict:
"""POST-запрос к API. Если return_raw — вернуть сырые байты."""
url = f"{get_api_url()}{path}"
body = json.dumps(data).encode()
req = Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
if not return_raw:
req.add_header("Accept", "application/json")
try:
with urlopen(req, timeout=120) as resp:
content = resp.read()
if return_raw:
return content
return json.loads(content.decode())
except URLError as e:
print(f"Ошибка подключения к {url}: {e}", file=sys.stderr)
sys.exit(1)
def cmd_health():
"""Проверка здоровья сервиса."""
result = api_get("/v1/health")
status = "✅ Здоров" if result.get("status") == "healthy" else "❌ Проблемы"
print(f"Silero TTS API: {status}")
print(f" Модель загружена: {result.get('model_loaded', False)}")
if "device" in result:
print(f" Устройство: {result['device']}")
def cmd_voices():
"""Список доступных голосов."""
result = api_get("/v1/voices")
speakers = result.get("speakers", {})
print("Доступные голоса:")
for name, desc in speakers.items():
marker = " ← default" if name == "xenia" else ""
print(f" {name:12s} — {desc}{marker}")
def cmd_speak(text: str, voice: str, fmt: str, output: str | None, as_json: bool):
"""Синтез речи и сохранение/вывод результата."""
if voice not in SPEAKERS:
print(f"Неизвестный голос: {voice}", file=sys.stderr)
print(f"Доступные: {', '.join(SPEAKERS.keys())}", file=sys.stderr)
sys.exit(1)
# Определяем endpoint и формат
if fmt == "wav":
path = "/v1/tts"
payload = {"text": text, "speaker": voice}
audio = api_post(path, payload, return_raw=True)
elif fmt == "mp3":
path = "/v1/audio/speech"
payload = {
"model": "tts-1",
"input": text,
"voice": voice,
"response_format": "mp3",
}
audio = api_post(path, payload, return_raw=True)
else:
print(f"Неизвестный формат: {fmt}", file=sys.stderr)
sys.exit(1)
if as_json:
# Вывести JSON с base64-аудио
result = {
"format": fmt,
"voice": voice,
"text": text,
"audio_base64": base64.b64encode(audio).decode(),
"size_bytes": len(audio),
}
print(json.dumps(result, ensure_ascii=False))
elif output:
# Сохранить в файл
with open(output, "wb") as f:
f.write(audio)
size_kb = len(audio) / 1024
print(f"✅ Сохранено: {output} ({size_kb:.1f} KB, {fmt.upper()})")
else:
# Вывести в stdout (для пайпов)
sys.stdout.buffer.write(audio)
def cmd_pipe(voice: str):
"""Чтение текста из stdin и вывод WAV в stdout."""
text = sys.stdin.read().strip()
if not text:
print("Пустой ввод", file=sys.stderr)
sys.exit(1)
cmd_speak(text, voice, "wav", None, False)
def cmd_hyperframes(text: str, voice: str, output: str, duration_hint: float | None):
"""
Сгенерировать аудио для HyperFrames-композиции.
Выводит HTML-сниппет с data-атрибутами для вставки в композицию.
"""
# Синтез
audio = api_post("/v1/tts", {"text": text, "speaker": voice}, return_raw=True)
# Сохранить аудио
if not output:
output = f"narration_{voice}.wav"
with open(output, "wb") as f:
f.write(audio)
# Определить длительность из WAV-заголовка
import struct
wav_size = len(audio)
# WAV: sample_rate at bytes 24-27, data_size at bytes 40-43
if wav_size >= 44:
sample_rate = struct.unpack_from("<I", audio, 24)[0]
data_size = struct.unpack_from("<I", audio, 40)[0]
duration = data_size / (sample_rate * 2) # 16-bit mono = 2 bytes/sample
else:
duration = duration_hint or 3.0
size_kb = wav_size / 1024
print(f"✅ Аудио: {output} ({size_kb:.1f} KB, {duration:.2f} сек)")
# Генерировать HTML-сниппет для HyperFrames композиции
print()
print("─── HTML-сниппет для HyperFrames (вставить в <div data-composition-id=\"...\">) ───")
print()
print(f' <audio id="narration"')
print(f' data-start="0"')
print(f' data-duration="{duration:.1f}"')
print(f' data-track-index="0"')
print(f' data-volume="1.0"')
print(f' src="{output}">')
print(f' </audio>')
def main():
parser = argparse.ArgumentParser(
description="Silero TTS CLI — синтез речи для пайплайнов и HyperFrames",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--url",
default=API_URL,
help=f"URL Silero TTS API (по умолчанию: {API_URL})",
)
sub = parser.add_subparsers(dest="command", help="Команда")
# health
sub.add_parser("health", help="Проверка здоровья сервиса")
# voices
sub.add_parser("voices", help="Список доступных голосов")
# speak
sp = sub.add_parser("speak", help="Синтез речи в файл")
sp.add_argument("text", help="Текст для синтеза")
sp.add_argument("--voice", "-v", default="xenia", choices=list(SPEAKERS.keys()), help="Голос")
sp.add_argument("--format", "-f", default="wav", choices=["wav", "mp3"], help="Формат аудио")
sp.add_argument("--output", "-o", help="Путь для сохранения (по умолчанию stdout)")
sp.add_argument("--json", action="store_true", help="Вывести JSON с base64-аудио вместо файла")
# pipe
pp = sub.add_parser("pipe", help="Чтение текста из stdin, вывод WAV в stdout")
pp.add_argument("--voice", "-v", default="xenia", choices=list(SPEAKERS.keys()), help="Голос")
# hyperframes
hf = sub.add_parser("hyperframes", help="Генерация аудио для HyperFrames + HTML-сниппет")
hf.add_argument("text", help="Текст для озвучки")
hf.add_argument("--voice", "-v", default="xenia", choices=list(SPEAKERS.keys()), help="Голос")
hf.add_argument("--output", "-o", default="narration.wav", help="Имя выходного файла")
hf.add_argument("--duration", type=float, help="Длительность в секундах (если не удалось определить из WAV)")
args = parser.parse_args()
# URL из аргументов
global _url_from_args
if args.url:
_url_from_args = args.url
if args.command == "health":
cmd_health()
elif args.command == "voices":
cmd_voices()
elif args.command == "speak":
cmd_speak(args.text, args.voice, args.format, args.output, args.json)
elif args.command == "pipe":
cmd_pipe(args.voice)
elif args.command == "hyperframes":
cmd_hyperframes(args.text, args.voice, args.output, args.duration)
else:
# По умолчанию: если есть аргумент — speak, иначе help
parser.print_help()
if __name__ == "__main__":
main()