268 lines
10 KiB
Python
268 lines
10 KiB
Python
#!/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()
|