Skip to content

feat(smallestai): use Waves continuous WebSocket streaming protocol for TTS#6363

Merged
tinalenguyen merged 2 commits into
livekit:mainfrom
harshitajain165:feat/smallest-tts-streaming-protocol
Jul 17, 2026
Merged

feat(smallestai): use Waves continuous WebSocket streaming protocol for TTS#6363
tinalenguyen merged 2 commits into
livekit:mainfrom
harshitajain165:feat/smallest-tts-streaming-protocol

Conversation

@harshitajain165

@harshitajain165 harshitajain165 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

The SmallestAI SynthesizeStream currently buffers all incoming tokens locally and sends one isolated request per segment, without using the Waves continuous-streaming protocol (continue / flush / max_buffer_flush_ms) that comparable LiveKit TTS plugins (ElevenLabs, Cartesia, Rime, Neuphonic, Inworld, …) use.

This PR switches the plugin to the continuous protocol so it speaks the Waves streaming API the way the official SDK and other providers' plugins do:

  • Forwards tokens to /waves/v1/tts/live as they arrive with "continue": true, instead of buffering the whole segment locally.
  • Closes each segment with an explicit "flush": true frame.
  • Exposes the server-side max_buffer_flush_ms buffer bound as a TTS(...) constructor arg and update_options(...) option (default 0).
  • Runs send/receive as concurrent tasks over the pooled connection. The connection is only returned to the pool after a clean complete; the pool discards it on error/interruption, so reuse stays safe.
  • Collapses to one SynthesizeStream instance = one segment, matching the current base-class contract (multi-segment instances are deprecated upstream).

Response-frame handling (chunk / word_timestamp / complete / error) is unchanged, and the HTTP ChunkedStream path is untouched.

Scope / note

This change is about protocol parity — using the continuous-streaming fields the Waves WS API exposes, matching the official SDK and other plugins. It is not a latency change: with current server behavior the endpoint synthesizes on the explicit flush, so output timing is unchanged from the previous buffer-until-flush implementation. The client is now ready to benefit from any future server-side incremental emission with no further plugin changes.

Testing

Verified against the live Waves /tts/live endpoint:

  • Continuous streaming: tokens streamed with continue/flush/max_buffer_flush_ms are accepted and return audio (lightning_v3.1 / magnus).
  • Word timestamps over streaming: with the correct voice/model pairing (lightning_v3.1_pro / meher, word_timestamps=True), the endpoint returns word_timestamp frames — all words surfaced as TimedString transcript entries with sensible timings alongside audio. The word_timestamps flag on the streamed frames is accepted (no "Invalid input data").
  • Interruption / reuse: cancelling a stream mid-synthesis leaves the pool clean — a subsequent stream on the same TTS instance completes normally.
  • HTTP path: synthesize() (ChunkedStream) is unchanged and still returns audio.
  • Hermetic tests (local mock WS server) cover the wire protocol and the word_timestamps flag — kept local for now, not included in this PR.
  • ruff format, ruff check, mypy all clean.

…or TTS

The SynthesizeStream previously buffered all tokens locally and sent one
isolated request per segment, so synthesis could not begin until the full
segment text was assembled — adding TTFB latency and ignoring the Waves
continuous-streaming protocol that every comparable LiveKit TTS plugin uses.

Stream tokens to /waves/v1/tts/live as they arrive with `continue: true`,
close each segment with an explicit `flush: true`, and expose the server-side
`max_buffer_flush_ms` buffer bound as a constructor/update_options option.
Send and receive run as concurrent tasks over the pooled connection; the
connection is only returned to the pool after a clean `complete` (the pool
discards it on error/interruption), keeping reuse safe.

One SynthesizeStream instance now maps to one segment, matching the current
base-class contract (extra segments per instance are deprecated upstream).

Verified against the live Waves endpoint: it accepts the continue/flush
protocol and returns audio.
_send_task sent a flush frame unconditionally and returned None, so the
`sent_any` guard in _run was always falsy and _recv_task was cancelled
before receiving audio. Return whether any non-whitespace text was sent
and only flush when it was: an empty segment now finishes promptly instead
of waiting on a `complete` the server never emits.
@harshitajain165
harshitajain165 marked this pull request as ready for review July 13, 2026 12:31
@harshitajain165
harshitajain165 requested a review from a team as a code owner July 13, 2026 12:31

@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: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@harshitajain165

harshitajain165 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Problem

The Waves WS API supports incremental streaming — continue / flush / max_buffer_flush_ms — but livekit-plugins-smallestai (as of 1.6.4) doesn't use any of it. Its SynthesizeStream buffers every token locally until a flush, then sends one isolated request per segment with none of the streaming fields.

Change

  • Stream tokens to /waves/v1/tts/live as they arrive with continue: true instead of buffering the whole segment, so the server can start synthesizing before the text is complete.
  • Close each segment with an explicit flush: true.
  • Expose the server-side max_buffer_flush_ms buffer bound as a constructor / update_options option.
  • Send and receive run as concurrent tasks over a pooled WS connection; the connection is only returned to the pool after a clean complete (discarded on error/interruption), keeping reuse safe.
  • Empty segments send no flush and don't wait on a complete the server never emits — so they finish promptly instead of hanging until the connection timeout.

@tinalenguyen Requesting a review whenever you get a chance

Uses the Waves continuous-streaming protocol: text tokens are forwarded to the
server as they arrive (``continue: true``) rather than buffered locally, and an
explicit ``flush: true`` message closes each segment. This lets synthesis begin
before the full segment text is known, lowering time-to-first-byte.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The docstring says this lowers time-to-first-byte, but the scope note under PR description says output timing is unchanged since the server synthesizes on the explicit flush — and the only flush is at end-of-segment, so TTFB should be the same as before.

Would sentence-level flushing be in scope? other providers e.g elevenlabs,cartesia run a tokenize.blingfire.SentenceTokenizer inside SynthesizeStream and flush per sentence instead of per segment. Since Waves already synthesizes on flush, that would let audio start after sentence one rather than after the full LLM response — the TTFB win the docstring describes, with no server-side change needed.

@tinalenguyen tinalenguyen 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.

thanks for the PR!

@tinalenguyen
tinalenguyen merged commit 31ddda6 into livekit:main Jul 17, 2026
18 checks passed
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.

3 participants