Потоковый TTS
Hermes умеет проигрывать TTS-аудио по мере его поступления от провайдера, не
дожидаясь полного аудиофайла. Это используют голосовой режим (живой разговор в
CLI/TUI), WebSocket speak-stream дашборда и — через StreamingTTSConsumer
шлюза — любой адаптер платформы, который подключается к потоковому аудио.
Голосовые ответы начинают звучать после первой фразы, а не после полной
генерации и синтеза.
Архитектура
Потоковый конвейер состоит из четырёх частей:
- Продюсер — LLM выдаёт текстовые дельты по мере генерации ответа
- Разбивка на предложения —
tools.tts_streaming.SentenceChunkerнакапливает дельты, вырезает блоки<think>(даже разорванные между дельтами) и сбрасывает законченные предложения - TTS-провайдер — зарегистрированный
StreamingTTSProviderпревращает каждое предложение в сырые PCM-чанки (int16 моно с объявленным провайдеромsample_rate) - Аудиоприёмник —
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 → управляемый шлюз |
| gemini | SSE (streamGenerateContent?alt=sse) | да | GEMINI_API_KEY / GOOGLE_API_KEY |
| xai | WebSocket (wss://api.x.ai/v1/tts) | да | предпочтительно XAI_API_KEY, иначе OAuth xAI (bearer подписки получает 403 на тарифицируемом TTS) |
| edge, piper, kitten, neutts, mistral, minimax, deepinfra, … | — | нет (синхронный фолбэк по предложениям) | как обычно |
плагин TTSProvider с streams_pcm | stream(format="pcm") плагина | да, на его stream_sample_rate | собственные у плагина |
Все поиски учётных данных идут через resolve_provider_secret()
(конфиг > env/.env > пул учётных данных) — никаких чтений env напрямую. Потоковые
тела ограничены 16 MiB на предложение, повторяя инвариант синхронных провайдеров
об ограниченном теле ответа апстрима.
Добавление нового потокового провайдера
- Унаследуйте
StreamingTTSProviderвtools/tts_streaming.py - Задайте
sample_rate(а такжеchannels/sample_width, если это не int16 моно) - Реализуйте
available()(чистая проверка — никогда ничего не устанавливает) иstream(self, text) -> Iterator[bytes], отдающий сырые PCM-чанки - Навесьте декоратор
@register("yourname") - Добавьте тесты в
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) -> boolbegin_streaming_tts / write_streaming_tts / finish_streaming_tts / abort_streaming_tts
По умолчанию всё это «не поддерживается»/no-op, так что существующие адаптеры не затронуты. Когда потоковое аудио хода завершено, автоматический TTS-ответ целым файлом для этого хода подавляется (без двойного воспроизведения); если стриминг упал до того, как прозвучал хоть какой-то звук, шлюз откатывается на прежний голосовой ответ целым файлом.