Потоковый TTS

Hermes умеет проигрывать TTS-аудио по мере его поступления от провайдера, не дожидаясь полного аудиофайла. Это используют голосовой режим (живой разговор в CLI/TUI), WebSocket speak-stream дашборда и — через StreamingTTSConsumer шлюза — любой адаптер платформы, который подключается к потоковому аудио. Голосовые ответы начинают звучать после первой фразы, а не после полной генерации и синтеза.

Архитектура

Потоковый конвейер состоит из четырёх частей:

  1. Продюсер — LLM выдаёт текстовые дельты по мере генерации ответа
  2. Разбивка на предложения — tools.tts_streaming.SentenceChunker накапливает дельты, вырезает блоки <think> (даже разорванные между дельтами) и сбрасывает законченные предложения
  3. TTS-провайдер — зарегистрированный StreamingTTSProvider превращает каждое предложение в сырые PCM-чанки (int16 моно с объявленным провайдером sample_rate)
  4. Аудиоприёмник — sounddevice.OutputStream для локального воспроизведения (tools.tts_tool_speaker.stream_tts_to_speaker) или шов write_streaming_tts адаптера платформы шлюза (gateway/streaming_tts_consumer.py)

Провайдеры без чанкового API всё равно получают воспроизведение по предложениям через проверенный синхронный путь text_to_speech_tool, так что edge (провайдер по умолчанию) тоже звучит как разговор. Весь произносимый текст очищает tools.tts_text_normalize.prepare_spoken_text (один очиститель на все пути).

Как выбирается провайдер

По умолчанию диспетчер стримит через уже настроенный вами провайдер (tts.provider), если у него есть чанковый API, — он никогда втихую не подменит ваш голос другим провайдером только ради стриминга.

Чтобы переопределить, задайте tts.streaming.provider в config.yaml:

  • имя провайдера (elevenlabs, gemini, openai, xai) закрепляет этот стример
  • auto идёт по списку приоритетов elevenlabs → gemini → openai → xai и берёт первый, чьи учётные данные удалось найти, — явное согласие на «лучший доступный чанковый голос»
tts:
  provider: gemini
  streaming:
    provider: gemini      # или "auto"
    min_len: 20           # самое короткое первое предложение (символов), которое озвучивается отдельно; для CJK ставят ~6
  gemini:
    model: gemini-2.5-flash-preview-tts
    voice: Kore

Матрица возможностей

ПровайдерТранспортЧанковый PCMУчётные данные
elevenlabsчанковый HTTP (pcm_24000)даELEVENLABS_API_KEY / tts.elevenlabs
openaiчанковый HTTP (with_streaming_response, pcm)даtts.openai.api_key → env → управляемый шлюз
geminiSSE (streamGenerateContent?alt=sse)даGEMINI_API_KEY / GOOGLE_API_KEY
xaiWebSocket (wss://api.x.ai/v1/tts)дапредпочтительно XAI_API_KEY, иначе OAuth xAI (bearer подписки получает 403 на тарифицируемом TTS)
edge, piper, kitten, neutts, mistral, minimax, deepinfra, …—нет (синхронный фолбэк по предложениям)как обычно
плагин TTSProvider с streams_pcmstream(format="pcm") плагинада, на его stream_sample_rateсобственные у плагина

Все поиски учётных данных идут через resolve_provider_secret() (конфиг > env/.env > пул учётных данных) — никаких чтений env напрямую. Потоковые тела ограничены 16 MiB на предложение, повторяя инвариант синхронных провайдеров об ограниченном теле ответа апстрима.

Добавление нового потокового провайдера

  1. Унаследуйте StreamingTTSProvider в tools/tts_streaming.py
  2. Задайте sample_rate (а также channels / sample_width, если это не int16 моно)
  3. Реализуйте available() (чистая проверка — никогда ничего не устанавливает) и stream(self, text) -> Iterator[bytes], отдающий сырые PCM-чанки
  4. Навесьте декоратор @register("yourname")
  5. Добавьте тесты в tests/tools/test_tts_streaming.py

ABC обеспечивает контракт; реестр делает провайдера обнаружимым; диспетчер (stream_tts_to_speaker) и потребитель шлюза бесплатно берут на себя буфер предложений, события остановки и аудиоприёмник.

Провайдеры из плагинов

Плагин TTSProvider (регистрируется через ctx.register_tts_provider()) подключается к этому пути без правок ядра. Он ставит streams_pcm = True и положительный stream_sample_rate, а его stream(text, format="pcm", voice=, model=, speed=) отдаёт моно PCM int16. resolve_streaming_provider оборачивает его (tools.tts_streaming._PluginPCMStreamer) по тем же правилам маршрутизации, что и синхронный диспетчер (tools.tts_tool_plugins): встроенные имена в реестр не попадают, одноимённый провайдер с type: command побеждает, а плагин получает те же tts.voice / tts.model / tts.speed, что и synthesize(). Настроенный плагин стримит первым. При tts.streaming.provider: auto его пробуют только после встроенного списка приоритетов. streams_pcm, stream_sample_rate и is_available() читаются при каждом разрешении стримера. Если частоты нет или провайдер сообщает о недоступности, Hermes остаётся на синтезе по предложениям. Как и у встроенных стримеров, конвейер динамика предзагружает до трёх предложений, поэтому stream() плагина должен переносить параллельные вызовы (синхронный путь сериализует synthesize(), этот — нет).

Стриминг в шлюзе (адаптеры платформ)

gateway/streaming_tts_consumer.py соединяет дельты агента со швом потокового аудио адаптера. Адаптеры подключаются, переопределяя в BasePlatformAdapter:

  • supports_streaming_tts(chat_id, audio_format) -> bool
  • begin_streaming_tts / write_streaming_tts / finish_streaming_tts / abort_streaming_tts

По умолчанию всё это «не поддерживается»/no-op, так что существующие адаптеры не затронуты. Когда потоковое аудио хода завершено, автоматический TTS-ответ целым файлом для этого хода подавляется (без двойного воспроизведения); если стриминг упал до того, как прозвучал хоть какой-то звук, шлюз откатывается на прежний голосовой ответ целым файлом.

перевод и редактура:

обновлено

⌕ ESC