Skip to content

soniox TTS: SynthesizeStream drops FlushSentinel β€” flush() never sends text_end, leaving the stream open for per-stream 408sΒ #6425

Description

@pauldabrowski85

Bug Description

SynthesizeStream._input_task in livekit-plugins-soniox silently discards FlushSentinel:

# livekit/plugins/soniox/tts.py (1.6.5)
async def _input_task() -> None:
    async for data in self._input_ch:
        if self._cancelled.is_set():
            break
        if isinstance(data, self._FlushSentinel):
            continue          # <-- dropped, nothing sent to Soniox
        self._mark_started()
        connection.send_text(self._stream_id, data, text_end=False)

    if not self._cancelled.is_set():
        connection.send_text(self._stream_id, "", text_end=True)

text_end is only sent when the input channel is exhausted, so stream.flush() is a wire-level no-op for this plugin: no text_end, no segment completion, and Soniox's per-stream request timer keeps running.

Why it matters

Soniox's per-stream 408 (request_timeout) fires when a stream "stopped sending intermediate text chunks and never sent text_end" (errors reference). The connection-level keepalive the plugin already sends does not reset it β€” Soniox is explicit that keepalive "applies to the whole WebSocket connection, not to a specific stream_id" (connection keepalive).

That makes flush() the natural tool for ending a segment before a known pause (FlushSentinel from llm_node is the documented segment-boundary mechanism in Pipeline nodes). The AgentSession llm_node path happens to work because AgentActivity splits segments above the plugin and closes the per-segment input channel β€” but anyone using tts.stream() + flush() directly (custom tts_node, standalone synthesis per the plugin guide) silently gets one unbounded stream. That unbounded stream is the substrate of the 408s reported in #6225.

Expected Behavior

On FlushSentinel: if the current stream_id has sent text, send {"stream_id": ..., "text_end": true}, complete the current segment, and lazily start a new stream_id for the next text chunk. The connection already multiplexes stream_ids, and Soniox documents exactly this model for gaps: keep the connection open between streams "so you can start a new stream_id without reconnecting."

Package Versions

livekit-agents==1.6.5
livekit-plugins-soniox==1.6.5

Additional Context

Production evidence of the resulting 408 mid-call (with keepalive flowing) is in #6225 β€” this issue is the plugin-level building block that would let users and the framework mitigate it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions