#!/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(") ───") print() print(f' ') 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()