initial: silero-tts-service + README
This commit is contained in:
1 parent
8c60c25ad2
commit
984b1fec6d
18 files changed
+2080
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user