feat(tts): streaming playback for edge and openai providers - #54668
feat(tts): streaming playback for edge and openai providers#54668a1398394385 wants to merge 4 commits into
Conversation
57433ce to
19e29df
Compare
d56631a to
fed8da0
Compare
teknium1
left a comment
There was a problem hiding this comment.
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:1157unpacks two values from_resolve_openai_audio_client_config(), but current main defines that helper as a three-tuple attools/tts_tool.py:2525-2553; the existing synchronous caller correctly unpacks all three attools/tts_tool.py:1027. OpenAI streaming will raise before playback. The added test masks this by mocking a two-tuple attests/tools/test_tts_streaming_playback.py:33-34.tests/hermes_cli/test_tts_streaming_config.py:27asserts version32, while this PR sets the default to34inhermes_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.
| 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() |
There was a problem hiding this comment.
_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 |
There was a problem hiding this comment.
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.
1dc5ca8 to
a4c646a
Compare
|
Sweeper feedback addressed in
Also rebased onto latest |
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.
a4c646a to
e883efc
Compare
|
Closing — superseded by #69511 (merged), which shipped a more complete provider-agnostic streaming core ( Most of what this PR delivered overlaps with #69511:
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 Thanks for the hermes-sweeper review — the |
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
elifbranch instead of touchingcli.pyorstream_tts_to_speaker.What this PR actually delivers per provider
edge_tts.Communicate+ miniaudio on-demand decodeWhy the dispatch surface matters
Before this PR,
cli.pyonly activated streaming for ElevenLabs andstream_tts_to_speakerwas hard-coded around the ElevenLabs SDK. Thethree built-in providers (
edge/openai/elevenlabs) now routethrough a single generic dispatch surface (
_iter_pcm_chunks), so afuture built-in plugs in via one
elifbranch instead of touchingcli.pyorstream_tts_to_speaker.Plugin providers (registered via the
TTSProviderABC) are notdispatched to
stream()by this PR —_dispatch_to_plugin_providerstill routes to
synthesize()only. Wiring it tostream()is afollow-up that needs its own format-negotiation contract (sample rate
/ channel / format advertisement).
Cross-references
stream()but CLI never consumes it generically. This PR deliversthe built-in half: generic dispatch path for edge/openai/elevenlabs,
CLI no longer hard-coded, refactored
stream_tts_to_speaker. Theplugin dispatcher still routes to
synthesize()only — wiring it tostream()is a follow-up.speedparams now wired onthe edge and openai streaming paths (Tier 1 of that issue).
suppress_token_streaming_for_voicehelper eliminates the duplicate⚕ Hermesresponse box that voice streaming produced when bothdisplay_callback(sentence-level) andstream_delta_callback(token-level) ran simultaneously.
What changed
agent/tts_provider.py— minor docstring tweak onTTSProvider.stream()("the dispatcher falls back" → "the callerfalls 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 bridgeover
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.StreamableSourcewrapping a chunked MP3 iterator soPCM is decoded on demand (no full-file buffering).
_iter_pcm_chunks(text, provider, tts_config)— single dispatchby provider; all branches return
Iterator[bytes]yielding thesame PCM shape (24 kHz int16 mono LE).
_probe_streaming_deps(provider)— startup dep + cred check sothe 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 aproviderarg and routes through_iter_pcm_chunksinstead ofbuilding an ElevenLabs client inline.
suppress_token_streaming_for_voice(agent, use_streaming_tts)—clears
agent.stream_delta_callbackwhile voice streaming isrendering sentence-by-sentence, so the user doesn't see two
⚕ Hermesboxes 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_ttsactivation no longer hard-codes ElevenLabs;reads
tts.<provider>.streamingconfig and calls_probe_streaming_deps(provider). Restoresstream_delta_callbackin afinallyblock.hermes_cli/config.py— addsstreaming: booltotts.edge,tts.elevenlabs,tts.openaisub-dicts. Defaults:elevenlabs=true,edge=false,openai=false(edge needsminiaudio; opt-in keepspip installminimal). Schema version31 → 32.
pyproject.toml— declaresminiaudio>=1.71,<2in thevoiceextra. Lazy-imported at call time so non-streaming installs don't
pay the import cost.
New tests
tests/hermes_cli/test_tts_streaming_config.pytests/tools/test_stream_tts_to_speaker.py— multi-providerdispatch;
pytest.importorskip("numpy")at module top so the fileis skipped on lean CI.
tests/tools/test_tts_streaming.pytests/tools/test_tts_streaming_playback.pytests/tools/test_voice_cli_integration.py::TestStreamingTTSActivationrewritten to cover all three providers instead of only ElevenLabs.
Out of scope / follow-ups
tts.xai.*config knobs fromupstream commit
e00b96540work for the file path; this PR doesnot wire
_iter_pcm_chunks(xai=)because the current xAI RESTendpoint advertises a streaming URL but is non-chunked per docs.
Tracked separately.
sampleratechange) is a follow-up — this PR only sends nativeprovider speed params on the streaming path.
providers may yield raw PCM with varying sample rate / channel
counts. The 24 kHz int16 mono LE contract is documented in
_iter_pcm_chunksdocstring but not yet passed as explicit formatmetadata. PR keeps the existing implicit contract to avoid
breaking callers; a follow-up can lift it to a
StreamFormatdataclass when the second non-PCM-shape provider lands.
fourth provider in
_iter_pcm_chunks. The dispatch table here isone
elifbranch away from supporting that — MiMo'sOpenAI-compatible request shape means the
_stream_openaibranchis the reference implementation.
Verification
Result on this branch: 112 tests passed, 0 failed in ~1.7s.
Manual smoke (CLI, Edge TTS, free, no key):
hermes→/voice on→ expect first-audio latency < 1.5 s on ashort reply; no second
⚕ Hermesbox.Manual smoke (CLI, OpenAI TTS, paid):
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
miniaudiobecomes a runtime dep whentts.edge.streaming=true.Documented in the config default (
streaming: falsefor edge)._config_versionbump 31 → 32 is additive —streamingkeysmerge in via the existing deep-merge path for users on 31 with no
schema migration required.
Commits