Skip to content

Add support for Deepgram's Flux TTS API (v2/speak) integration#6511

Open
dg-edcharbeneau wants to merge 3 commits into
livekit:mainfrom
dg-edcharbeneau:flux-tts-v2
Open

Add support for Deepgram's Flux TTS API (v2/speak) integration#6511
dg-edcharbeneau wants to merge 3 commits into
livekit:mainfrom
dg-edcharbeneau:flux-tts-v2

Conversation

@dg-edcharbeneau

Copy link
Copy Markdown

Summary

Adds support for Deepgram's new Flux TTS API (the /v2/speak endpoint) to the Deepgram plugin, alongside the existing Aura TTS (/v1/speak). This is purely additive β€” no breaking changes.

The implementation mirrors the non-breaking extension pattern the plugin already uses for Flux STT (stt_v2.py / STTv2): a new parallel module and class rather than modifying the existing TTS.

What's new

  • deepgram.TTSv2 β€” a new class targeting wss://api.deepgram.com/v2/speak (streaming) and POST https://api.deepgram.com/v2/speak (batch). Named TTSv2 for consistency with the existing STTv2.
  • deepgram.FluxTTSModels β€” a model-name literal (seeded with flux-alexis-en), typed as FluxTTSModels | str so any flux-{voice}-{language} string works without a code change, matching how v1 handles TTSModels | str.

Implementation notes

  • New tts_v2.py with TTSv2, ChunkedStreamv2 (batch/HTTP), and SynthesizeStreamv2 (streaming/WebSocket). It reuses the existing infrastructure (ConnectionPool, _to_deepgram_url, AudioEmitter, word tokenizer) β€” no new dependencies.
  • The main protocol difference from v1 is the Flux server-message set: the streaming receive loop ends each turn's segment on SpeechMetadata (the documented "all audio sent" marker) and treats Connected / SpeechStarted / Flushed / SessionMetadata as lifecycle/telemetry messages.
  • tts.py (v1 TTS) is untouched; __init__.py exports the new symbols additively.

Testing

  • ruff and mypy (strict) pass on the new module.
  • Verified end-to-end against the live /v2/speak API:
    • Batch (TTSv2().synthesize(...)): ~104 KB of PCM audio returned.
    • Streaming (TTSv2().stream(...)): ~84 KB of audio across one cleanly-closed segment, confirming end-of-turn detection on SpeechMetadata.
  • Confirmed existing deepgram.TTS() continues to resolve and stream unchanged.

Usage

from livekit.plugins import deepgram

# streaming or batch, opt-in to Flux TTS
tts = deepgram.TTSv2(model="flux-alexis-en")

…m.com/docs/flux-tts/overview) API (the `/v2/speak` endpoint) to the Deepgram plugin, alongside the existing Aura TTS (`/v1/speak`). This is purely additive β€” **no breaking changes**.

The implementation mirrors the non-breaking extension pattern the plugin already uses for Flux STT (`stt_v2.py` / `STTv2`): a new parallel module and class rather than modifying the existing `TTS`.

- **`deepgram.TTSv2`** β€” a new class targeting `wss://api.deepgram.com/v2/speak` (streaming) and `POST https://api.deepgram.com/v2/speak` (batch). Named `TTSv2` for consistency with the existing `STTv2`.
- **`deepgram.FluxTTSModels`** β€” a model-name literal (seeded with `flux-alexis-en`), typed as `FluxTTSModels | str` so any `flux-{voice}-{language}` string works without a code change, matching how v1 handles `TTSModels | str`.

- New `tts_v2.py` with `TTSv2`, `ChunkedStreamv2` (batch/HTTP), and `SynthesizeStreamv2` (streaming/WebSocket). It reuses the existing infrastructure (`ConnectionPool`, `_to_deepgram_url`, `AudioEmitter`, word tokenizer) β€” no new dependencies.
- The main protocol difference from v1 is the Flux server-message set: the streaming receive loop ends each turn's segment on **`SpeechMetadata`** (the documented "all audio sent" marker) and treats `Connected` / `SpeechStarted` / `Flushed` / `SessionMetadata` as lifecycle/telemetry messages.
- `tts.py` (v1 `TTS`) is untouched; `__init__.py` exports the new symbols additively.

- `ruff` and `mypy` (strict) pass on the new module.
- Verified end-to-end against the live `/v2/speak` API:
  - **Batch** (`TTSv2().synthesize(...)`): ~104 KB of PCM audio returned.
  - **Streaming** (`TTSv2().stream(...)`): ~84 KB of audio across one cleanly-closed segment, confirming end-of-turn detection on `SpeechMetadata`.
- Confirmed existing `deepgram.TTS()` continues to resolve and stream unchanged.

```python
from livekit.plugins import deepgram

tts = deepgram.TTSv2(model="flux-alexis-en")
Follow-up to the Flux TTS (v2/speak) plugin, resolving review findings.
No breaking changes.

- mip_opt_out: fix docstring that described the flag backwards β€” it's an
  opt-out, and default False means requests may be used to improve models
  (the code already sent the value correctly).
- encoding/bit_rate: document that only linear16 works end-to-end on the
  streaming path; compressed encodings (mp3/opus/flac/aac) and bit_rate
  apply to the batch synthesize() path only. Validate encoding on stream()
  and fail fast with a clear error; drop the no-op bit_rate from the WS
  connect params.
- batch mime type: derive the mime type from encoding via
  _encoding_to_mimetype (sibling pattern) instead of hardcoding audio/pcm,
  so compressed batch output is decoded correctly. Scoped to the encodings
  the framework can decode; mulaw/alaw stay blocked.
- Warning log now includes the Flux error code (e.g. NO_ACTIVE_SPEECH).
- Comment the intentional no-op absorption of the trailing Flushed frame
  on pooled connection reuse.
- Add a hermetic, mocked-WebSocket test suite (no credentials required)
  and register TTSv2 in the cross-provider tts test list.
@dg-edcharbeneau
dg-edcharbeneau requested a review from a team as a code owner July 22, 2026 16:13
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +418 to +427
if mtype == "SpeechMetadata":
# Authoritative end-of-turn marker: all audio for the turn has
# been sent between SpeechStarted and this message. The server
# emits a trailing Flushed frame after this; on a pooled reuse it
# is harmlessly absorbed as a no-op at the top of the next turn.
output_emitter.end_segment()
break
elif mtype in ("Connected", "SpeechStarted", "Flushed", "SessionMetadata"):
# lifecycle / telemetry messages, no audio action needed
pass

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.

🟑 A spoken segment that produces no audio stalls until it times out and errors

A synthesis segment that yields no audio waits for an end-of-turn marker (SpeechMetadata at livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/tts_v2.py:418) that never arrives, so instead of finishing quietly it keeps listening until the connection times out.
Impact: A turn containing only whitespace/punctuation (or otherwise producing no speech) hangs for the full connection timeout and then fails the synthesis with a timeout error instead of ending cleanly.

Divergence from v1 end-of-turn detection

In the v1 receive loop (tts.py:378-380) the segment ends on the Flushed message, which the server always sends in response to the Flush that send_task emits after each segment. This means even a segment that generates no audio terminates cleanly.

In v2, Flushed is treated as a no-op (tts_v2.py:425-427) and the segment only ends on SpeechMetadata (tts_v2.py:418-424). SpeechMetadata is documented as the marker that all audio for a turn has been sent; the tested "No active speech detected" warning path (tests/test_plugin_deepgram_tts.py:196-216) suggests the server can respond to a flush with a Warning (+Flushed) and no SpeechMetadata. In that case recv_task never breaks and blocks on ws.receive(timeout=self._conn_options.timeout) (tts_v2.py:399) until it raises asyncio.TimeoutError, which propagates as APITimeoutError.

Segments with no words can occur: _tokenize_input creates a word_stream as soon as any string is pushed (tts_v2.py:344-347), so whitespace-only input still produces a segment whose tokenizer may yield no words, sending only a Flush.

Prompt for agents
The v2 streaming receive loop in SynthesizeStreamv2._run_ws only ends a segment on the SpeechMetadata message and treats Flushed as a no-op. Unlike v1 (tts.py), which ends on Flushed, this means a segment that generates no audio (e.g. whitespace/punctuation-only input, or a flush that triggers a 'No active speech detected' warning) will never receive SpeechMetadata and recv_task will block on ws.receive until conn_options.timeout elapses, raising APITimeoutError and failing the turn. Consider also ending the segment (or otherwise breaking the loop cleanly) when a Flushed is observed after the flush has been sent and no SpeechMetadata is expected, or when a Warning indicates no speech will be produced, so empty/no-audio segments terminate cleanly like v1 does. Be careful to preserve the intended 'trailing Flushed absorbed as no-op on pooled reuse' behavior for the normal case where SpeechMetadata does arrive.
Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants