Skip to content

feat(tts): streaming playback for edge and openai providers - #54668

Closed
a1398394385 wants to merge 4 commits into
NousResearch:mainfrom
a1398394385:feat/tts-generic-streaming
Closed

feat(tts): streaming playback for edge and openai providers#54668
a1398394385 wants to merge 4 commits into
NousResearch:mainfrom
a1398394385:feat/tts-generic-streaming

Conversation

@a1398394385

Copy link
Copy Markdown

Summary

Adds streaming playback for two providers that previously took the slow
file-then-play path in CLI voice mode, and generalizes the streaming
dispatch surface
so future built-in TTS providers plug in via one
elif branch instead of touching cli.py or stream_tts_to_speaker.

What this PR actually delivers per provider

Provider Streaming nature User-visible win
Edge (free, no key) True streaming — edge_tts.Communicate + miniaudio on-demand decode First-audio latency: full-reply-wait → ~first sentence
OpenAI (paid) HTTP-body chunked only — server generates full clip before first byte Skips temp-file round-trip; no latency win
ElevenLabs Unchanged Hard-coded branch removed; now goes through generic dispatch

Why the dispatch surface matters

Before this PR, cli.py only activated streaming for ElevenLabs and
stream_tts_to_speaker was hard-coded around the ElevenLabs SDK. The
three built-in providers (edge/openai/elevenlabs) now route
through a single generic dispatch surface (_iter_pcm_chunks), so a
future built-in plugs in via one elif branch instead of touching
cli.py or stream_tts_to_speaker.

Plugin providers (registered via the TTSProvider ABC) are not
dispatched to stream() by this PR
_dispatch_to_plugin_provider
still routes to synthesize() only. Wiring it to stream() is a
follow-up that needs its own format-negotiation contract (sample rate
/ channel / format advertisement).

Cross-references

What changed

  • agent/tts_provider.py — minor docstring tweak on
    TTSProvider.stream() ("the dispatcher falls back" → "the caller
    falls back") to reflect that the fallback decision lives in the
    caller, not the registry.
  • tools/tts_tool.py
    • _stream_edge(text, *, voice, speed, tts_config) — sync bridge
      over edge_tts.Communicate().stream(); yields raw MP3 chunks.
    • _stream_openai(text, *, voice, model, speed, format, tts_config)
      — yields bytes from
      client.audio.speech.create(...).iter_bytes(chunk_size=4096);
      supports format="pcm" for direct sounddevice write. Note:
      the OpenAI endpoint is HTTP-body chunked only — see "What this PR
      actually delivers" above.
    • _decode_edge_mp3_to_pcm(mp3_iter)
      miniaudio.StreamableSource wrapping a chunked MP3 iterator so
      PCM is decoded on demand (no full-file buffering).
    • _iter_pcm_chunks(text, provider, tts_config) — single dispatch
      by provider; all branches return Iterator[bytes] yielding the
      same PCM shape (24 kHz int16 mono LE).
    • _probe_streaming_deps(provider) — startup dep + cred check so
      the user gets a clean warning before the first sentence, not a
      per-call exception inside the speak loop.
    • stream_tts_to_speaker(..., provider="elevenlabs") — gains a
      provider arg and routes through _iter_pcm_chunks instead of
      building an ElevenLabs client inline.
    • suppress_token_streaming_for_voice(agent, use_streaming_tts)
      clears agent.stream_delta_callback while voice streaming is
      rendering sentence-by-sentence, so the user doesn't see two
      ⚕ Hermes boxes with the same content (same root cause as
      [Bug]: Signal replies are duplicated when gateway streaming is enabled #4647's double delivery class).
  • cli.py_voice_tts activation no longer hard-codes ElevenLabs;
    reads tts.<provider>.streaming config and calls
    _probe_streaming_deps(provider). Restores
    stream_delta_callback in a finally block.
  • hermes_cli/config.py — adds streaming: bool to tts.edge,
    tts.elevenlabs, tts.openai sub-dicts. Defaults:
    elevenlabs=true, edge=false, openai=false (edge needs
    miniaudio; opt-in keeps pip install minimal). Schema version
    31 → 32.
  • pyproject.toml — declares miniaudio>=1.71,<2 in the voice
    extra. Lazy-imported at call time so non-streaming installs don't
    pay the import cost.

New tests

  • tests/hermes_cli/test_tts_streaming_config.py
  • tests/tools/test_stream_tts_to_speaker.py — multi-provider
    dispatch; pytest.importorskip("numpy") at module top so the file
    is skipped on lean CI.
  • tests/tools/test_tts_streaming.py
  • tests/tools/test_tts_streaming_playback.py
  • tests/tools/test_voice_cli_integration.py::TestStreamingTTSActivation
    rewritten to cover all three providers instead of only ElevenLabs.

Out of scope / follow-ups

Verification

scripts/run_tests.sh \
  tests/tools/test_tts_streaming.py \
  tests/tools/test_stream_tts_to_speaker.py \
  tests/tools/test_tts_streaming_playback.py \
  tests/hermes_cli/test_tts_streaming_config.py \
  tests/tools/test_voice_cli_integration.py

Result on this branch: 112 tests passed, 0 failed in ~1.7s.

Manual smoke (CLI, Edge TTS, free, no key):

# config.yaml
tts:
  provider: edge
  edge:
    streaming: true

hermes/voice on → expect first-audio latency < 1.5 s on a
short reply; no second ⚕ Hermes box.

Manual smoke (CLI, OpenAI TTS, paid):

# config.yaml
tts:
  provider: openai
  openai:
    streaming: true
    model: gpt-4o-mini-tts
    voice: alloy

Expect no temp-file write/read on the playback path; first-audio
latency unchanged from non-streaming because the OpenAI endpoint
synthesizes the full clip server-side (see "What this PR actually
delivers" above).

Risks

  • miniaudio becomes a runtime dep when tts.edge.streaming=true.
    Documented in the config default (streaming: false for edge).
  • _config_version bump 31 → 32 is additive — streaming keys
    merge in via the existing deep-merge path for users on 31 with no
    schema migration required.

Commits

feat(tts): streaming playback for edge and openai providers
build(pyproject): add miniaudio to voice extra for edge streaming decode

@alt-glitch alt-glitch added type/feature New feature or request tool/tts Text-to-speech and transcription comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have labels Jun 29, 2026
@a1398394385
a1398394385 force-pushed the feat/tts-generic-streaming branch from 57433ce to 19e29df Compare June 29, 2026 06:42
@a1398394385 a1398394385 changed the title feat(tts): streaming playback for edge and openai feat(tts): streaming playback for edge and openai providers Jun 29, 2026
@a1398394385
a1398394385 marked this pull request as ready for review June 29, 2026 06:43
@a1398394385
a1398394385 requested a review from a team June 29, 2026 06:43
@a1398394385
a1398394385 force-pushed the feat/tts-generic-streaming branch 2 times, most recently from d56631a to fed8da0 Compare July 11, 2026 14:19

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for generalizing the CLI streaming path. The premise remains valid on current main: cli.py:12248 only activates it for ElevenLabs.

Problems

  • tools/tts_tool.py:1157 unpacks two values from _resolve_openai_audio_client_config(), but current main defines that helper as a three-tuple at tools/tts_tool.py:2525-2553; the existing synchronous caller correctly unpacks all three at tools/tts_tool.py:1027. OpenAI streaming will raise before playback. The added test masks this by mocking a two-tuple at tests/tools/test_tts_streaming_playback.py:33-34.
  • tests/hermes_cli/test_tts_streaming_config.py:27 asserts version 32, while this PR sets the default to 34 in hermes_cli/config.py:3309. This is both self-failing and a config-version snapshot rather than a behavioral test.

Suggested changes

  • Preserve the three-value OpenAI resolver contract, including managed-gateway model handling, and test direct plus managed resolution.
  • Replace the fixed-version assertion with a migration/deep-merge invariant, and document the new opt-in Edge/OpenAI flags in website/docs/user-guide/features/tts.md:43-58.

Automated hermes-sweeper review.

Comment thread tools/tts_tool.py Outdated
Mirrors the synthesize() config surface: ``tts.openai.model`` /
``voice`` / ``speed`` / ``base_url`` and ``tts.use_gateway``.
"""
api_key, base_url = _resolve_openai_audio_client_config()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_resolve_openai_audio_client_config() returns (api_key, base_url, is_managed) on current main (tools/tts_tool.py:2525-2553), so this two-value unpack raises ValueError for every OpenAI streaming call. Preserve the third value and the managed-gateway handling used by _generate_openai_tts.


def test_config_version_bumped_to_32():
from hermes_cli.config import DEFAULT_CONFIG
assert DEFAULT_CONFIG["_config_version"] == 32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion cannot pass with this PR: hermes_cli/config.py:3309 sets the default schema version to 34. Please remove the literal version snapshot and assert migration behavior against DEFAULT_CONFIG instead.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 15, 2026
@a1398394385
a1398394385 force-pushed the feat/tts-generic-streaming branch from 1dc5ca8 to a4c646a Compare July 17, 2026 16:06
@a1398394385

Copy link
Copy Markdown
Author

Sweeper feedback addressed in a4c646a0d:

  • OpenAI streaming crash_stream_openai now unpacks the 3-tuple from _resolve_openai_audio_client_config() (api_key, base_url, is_managed) and mirrors the sync _generate_openai_tts path's managed-gateway model coercion — when is_managed and no custom base_url, an unsupported model (e.g. tts-1-hd) is coerced to a MANAGED_OPENAI_TTS_MODELS entry so the gateway doesn't 400. The original test masked the bug by mocking a 2-tuple; both test mocks corrected to 3-tuples, and a new test_iter_pcm_chunks_openai_managed_coerces_unsupported_model covers the managed path. Direct + managed resolution are now both exercised.
  • Brittle version snapshot — dropped test_config_version_bumped_to_32. The two migration tests (test_v31_config_migrates_streaming_fields, test_v31_config_preserves_user_set_streaming) already cover the deep-merge invariant that matters.
  • Docs — added tts.<provider>.streaming (with the edge/miniaudio opt-in note) to website/docs/user-guide/features/tts.md.

Also rebased onto latest main; 112 PR-scoped tests pass.

@a1398394385
a1398394385 requested a review from teknium1 July 17, 2026 16:14
Generalize stream_tts_to_speaker so CLI voice mode can stream audio
from any provider implementing a PCM-style Iterator[bytes], not just
ElevenLabs. Adds a small PCM contract (int16 LE, 24 kHz, mono) so the
three providers can plug into the existing sounddevice path uniformly.

Edge TTS (free, no key) gains real streaming via edge_tts.Communicate
chunks decoded on demand by miniaudio. OpenAI TTS gains chunked write
via audio.speech.create(...).iter_bytes with response_format='pcm' for
direct sounddevice output; note this is HTTP-body chunking only — the
OpenAI endpoint generates the full clip server-side before the first
byte arrives, so it doesn't reduce first-audio latency the way edge
streaming does. ElevenLabs (already streaming) is moved onto the same
dispatch surface — no behavior change for existing users on that
provider, but the hard-coded ElevenLabs client construction inside
stream_tts_to_speaker and the provider=='elevenlabs' check in cli.py
are both removed.

CLI use_streaming_tts activation now reads tts.<provider>.streaming
config and probes per-provider deps at startup so the user sees a
clean warning before the first sentence instead of a per-call
exception inside the speak loop.

suppress_token_streaming_for_voice clears agent.stream_delta_callback
while voice streaming is rendering sentence-by-sentence, avoiding the
double Hermes response box (same root cause as NousResearch#4647's double
delivery class).

Related to NousResearch#47896 (plugin TTS providers define stream() but CLI never
consumes it generically) — this PR delivers the built-in half: edge,
openai, and elevenlabs all dispatch through _iter_pcm_chunks. Plugin
providers (registered via the TTSProvider ABC) still route through
synthesize() only; wiring _dispatch_to_plugin_provider to call
stream() is a follow-up that needs its own format-negotiation
contract. Partial progress on NousResearch#6926 (native speed params now wired
on edge + openai streaming paths).

Tests:
- tests/tools/test_tts_streaming.py — _stream_edge / _stream_openai
- tests/tools/test_stream_tts_to_speaker.py — multi-provider dispatch
  (skipped on lean CI via pytest.importorskip('numpy'))
- tests/tools/test_tts_streaming_playback.py — miniaudio decode path
- tests/hermes_cli/test_tts_streaming_config.py — config defaults
- tests/tools/test_voice_cli_integration.py::TestStreamingTTSActivation
  rewritten to cover all three providers instead of only ElevenLabs
The new _decode_edge_mp3_to_pcm helper imports miniaudio to stream-
decode MP3 chunks from edge_tts into 24kHz int16 mono PCM. miniaudio
was undeclared in pyproject.toml; pull it into the 'voice' extra
alongside numpy and sounddevice (already there for the local STT +
playback paths). Lazy-imported at call time so non-streaming installs
don't pay the import cost.
Mirror main's defensive (x or {}) pattern in the three streaming
helpers introduced by this PR (_stream_edge, _stream_openai,
_iter_pcm_chunks). Without the guard, an explicit `tts.edge: null`
(or openai/elevenlabs) in config.yaml — which YAML round-trips as
None — propagates into .get() on the next line and crashes with
AttributeError. Same root cause as main's NousResearch#47318 fix, applied to
the new generic dispatch sites.

See: 3a39421, 8937121
Review (hermes-sweeper, NousResearch#54668) flagged two issues; both confirmed:

1. tools/tts_tool.py:1217 (now _stream_openai) unpacked two values
   from _resolve_openai_audio_client_config(), but main defines it
   as a three-tuple ``(api_key, base_url, is_managed)`` (line ~2816).
   OpenAI streaming would raise ValueError before playback; the test
   masked it by mocking a two-tuple.

   Fix: unpack three values and mirror the sync _generate_openai_tts
   path — when ``is_managed`` and the user hasn't redirected base_url,
   coerce an unsupported model (e.g. tts-1-hd) to a
   MANAGED_OPENAI_TTS_MODELS entry so the managed gateway doesn't 400.

   Tests: existing 2-tuple mocks corrected to 3-tuples; new
   test_iter_pcm_chunks_openai_managed_coerces_unsupported_model
   covers the managed coercion path. Both direct and managed
   resolution are now exercised.

2. tests/hermes_cli/test_tts_streaming_config.py asserted
   ``_config_version == 32``, but the PR sets the default to 34 (main
   bumped to 33 in NousResearch#56955). Self-failing and a brittle snapshot.

   Fix: drop the version snapshot. The migration tests
   (test_v31_config_migrates_streaming_fields,
   test_v31_config_preserves_user_set_streaming) already cover the
   deep-merge invariant that matters — streaming fields are visible
   after migration and user-set values survive.

Also document the new opt-in edge/openai streaming flags and the
elevenlabs default in website/docs/user-guide/features/tts.md.
@a1398394385
a1398394385 force-pushed the feat/tts-generic-streaming branch from a4c646a to e883efc Compare July 19, 2026 06:19
@teknium1 teknium1 added the area/streaming Streaming responses: gateway delivery, provider wire label Jul 19, 2026
@a1398394385

Copy link
Copy Markdown
Author

Closing — superseded by #69511 (merged), which shipped a more complete provider-agnostic streaming core (StreamingTTSProvider ABC + registry), barge-in, and CLI/TUI/Desktop coverage.

Most of what this PR delivered overlaps with #69511:

  • ElevenLabs + OpenAI streaming PCM paths
  • stream_tts_to_speaker refactored into a generic dispatcher
  • CLI streaming gate broadened beyond ElevenLabs-only

The one piece #69511 deliberately left out is Edge TTS streaming (Edge only outputs MP3, so true streaming needs miniaudio decode, and per-sentence sync fallback is what their dispatch does today). I will open a smaller follow-up that registers Edge as a StreamingTTSProvider instead of re-implementing the whole dispatch surface.

Thanks for the hermes-sweeper review — the _resolve_openai_audio_client_config 3-tuple catch was a real bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/streaming Streaming responses: gateway delivery, provider wire comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows tool/tts Text-to-speech and transcription type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants