Skip to content

feat(gnani): update TTS voices/models and STT sample rates + streaming toggle#6468

Merged
tinalenguyen merged 9 commits into
mainfrom
tina/gnani-updates
Jul 18, 2026
Merged

feat(gnani): update TTS voices/models and STT sample rates + streaming toggle#6468
tinalenguyen merged 9 commits into
mainfrom
tina/gnani-updates

Conversation

@tinalenguyen

Copy link
Copy Markdown
Member

No description provided.

@tinalenguyen
tinalenguyen requested a review from a team as a code owner July 17, 2026 19:55
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

…s.py

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

- tts.py: _strip_wav_header now strips a fixed-size header and preserves
  the trailing PCM when no `data` sub-chunk is found, instead of dropping
  the whole chunk (the fallback branch was dead, returning b"" either way).
- tts.py: move finalize/flush/end_segment out of the SynthesizeStream
  `finally` block to the success path only. Flushing the coalescer's
  sub-frame remainder on error set pushed_duration > 0.0 and suppressed
  otherwise-retryable transient WebSocket failures.
- utils.py: correct the websockets version threshold for the header kwarg.
  The top-level `websockets.connect` only switched to the asyncio impl
  (additional_headers) in 14.0; 12.x and 13.x use the legacy client
  (extra_headers). Verified against 12.0/13.1/14.0/15.0.1.
- tests: update gnani STT/TTS tests for STREAM_SUPPORTED_SAMPLE_RATES
  (44100/48000 now accepted), the new voice/model defaults (Pranav /
  timbre-v2.0), and server-side voice validation (arbitrary voices accepted).
Comment thread livekit-plugins/livekit-plugins-gnani/livekit/plugins/gnani/tts.py Outdated
… WAV stripping

Switch the SSE, WebSocket-chunked, and WebSocket streaming paths to request
the configured container (WAV by default) and push bytes straight to the
AudioEmitter, whose built-in AudioStreamDecoder decodes and re-frames them.
Removes _strip_wav_header, the PCM aligner/coalescer, and the forced-raw
streaming payload override.

@chenghao-mou chenghao-mou left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two comments. Otherwise LGTM. Tested locally with both STT and TTS.

Comment thread livekit-plugins/livekit-plugins-gnani/livekit/plugins/gnani/models.py Outdated
(used by the STT/TTS clients) only switched to the asyncio implementation in
websockets 14.0 β€” in 12.x and 13.x it is still the legacy client. Select the
kwarg by that boundary so WebSocket STT/TTS works across ``websockets>=12``.
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Q: if we enforce >=14.0 in this plugin, we then don't need this file, right?

@tinalenguyen tinalenguyen Jul 18, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

great callout, will bump and remove

Bump websockets to >=14, which always uses the asyncio client (headers via
additional_headers). Pass it inline at the three connect() sites and delete
utils.ws_header_kwargs, the shim that only existed for the extra_headers kwarg
on websockets 12.x/13.x.

Also drop mulaw from GnaniTTSContainers (and its docstring/README mentions): it
has no libav container mapping, so it would not decode through the
AudioEmitter's AudioStreamDecoder anyway.

@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 new potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +388 to +394
if audio_b64:
output_emitter.push(base64.b64decode(audio_b64))
break

audio_b64 = payload.get("audio", "")
if audio_b64:
output_emitter.push(base64.b64decode(audio_b64))

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.

πŸ”΄ Streamed speech may come out garbled or cut short

Each streamed audio chunk is now handed straight to the audio player (output_emitter.push(...) at livekit-plugins/livekit-plugins-gnani/livekit/plugins/gnani/tts.py:389) without first removing its embedded file header, so the player treats every chunk's header bytes as sound.

Impact: Users can hear distorted noise at chunk boundaries or only the first fragment of speech whenever SSE or WebSocket TTS streaming is used.

Regression from removing per-chunk WAV header stripping

The removed helper _strip_wav_header and the deleted docstrings explicitly documented the streaming contract: "Each SSE chunk decodes to a complete WAV file" and "Each received chunk's WAV header is stripped; only raw PCM is emitted." The default container is wav (GnaniTTSOptions.container = "wav"), and the request payload keeps sending container in audio_config via _build_payload, so the server continues to return per-chunk WAV-wrapped audio.

With the change, the bytes are pushed unmodified to tts.AudioEmitter, which (for a non-audio/pcm mime type) routes them through a single codecs.AudioStreamDecoder created once with format="wav" (livekit-agents/livekit/agents/tts/tts.py:1333-1342). A single WAV/RIFF demuxer expects exactly one file: after decoding the first chunk's declared data it will either stop (truncating all later chunks) or misinterpret each subsequent chunk's RIFF header bytes as PCM samples (audible corruption).

The same removal affects all three streaming paths: SSE (tts.py:389, tts.py:394), WebSocketChunkedStream (tts.py:460, tts.py:470, tts.py:477), and SynthesizeStream (tts.py:566, tts.py:576, tts.py:583). Only the REST path (single full WAV) remains safe.

If the streaming endpoints have genuinely changed to emit one continuous WAV header followed by raw PCM (rather than a complete WAV per chunk), the change would be correct β€” but that contradicts the prior documented behavior and is not mentioned in the PR intent, so it needs verification.

Prompt for agents
The PR removed the _strip_wav_header helper and now pushes each streaming audio chunk directly into tts.AudioEmitter with the container mime type (audio/wav by default). Previously the code documented that each SSE/WebSocket chunk is a complete WAV file and stripped the per-chunk RIFF/WAV header, emitting only raw PCM with mime audio/pcm. The AudioEmitter, for a non-audio/pcm mime type, feeds all pushed bytes through a single codecs.AudioStreamDecoder created once with the WAV format; a single WAV demuxer cannot decode multiple concatenated WAV files, so later chunks are either truncated or corrupted. Verify the actual wire format of the Gnani /api/v1/tts/sse and wss /api/v1/tts endpoints for the timbre models. If each chunk is still a complete WAV file, restore per-chunk header stripping (and keep emitting mime audio/pcm), or alternatively decode each chunk independently. This affects SSEChunkedStream._run, WebSocketChunkedStream._run, and SynthesizeStream._run (all the output_emitter.push sites), while the REST path is unaffected because it returns a single WAV.
Open in Devin Review

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verified β€” not an issue. For audio/wav, AudioStreamDecoder uses _WavInlineDecoder (not PyAV), a state machine that resets on each new RIFF header, so concatenated complete-WAV chunks decode correctly (decoder.py:180-187). I drove the real AudioEmitter as the plugin does β€” 8 complete WAV files, one per push β€” and reconstruction is byte-exact in both modes. So per-chunk stripping / audio/pcm isn't needed.

@tinalenguyen
tinalenguyen merged commit ce820ce into main Jul 18, 2026
26 checks passed
@tinalenguyen
tinalenguyen deleted the tina/gnani-updates branch July 18, 2026 01:13
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