Voice agent (ElevenLabs streaming-input TTS over WebSocket) silently drops the trailing/mid portion of an LLM response — text keeps arriving as `message_chunk` events on the client but never becomes audio, with no error logged.
Fix: Root cause: `elevenlabs.tts.js` opened the ElevenLabs stream-input WebSocket once and had a single one-shot `done` Promise resolved by either the normal `isFinal` completion signal OR any socket `error`/`close` event. If the socket closed unexpectedly mid-turn (network blip, ElevenLabs-side idle/rate-limit close), `done` resolved immediately and the caller (`audio.stream.js`, `await done`) returned as if TTS had finished successfully — while the LLM stream kept running and every subsequent `add(token)` call just pushed into a local `queue` array attached to a dead, unreachable socket, with no reconnect and no error surfaced. Also, the app only sent one keep-alive message at connection open despite setting `inactivity_timeout=180`, so a >180s silent gap (e.g. a long tool call) could trigger an ElevenLabs-side idle close with the same symptom. Fix: refactored `openTtsSocket` to support reconnecting the underlying WebSocket transparently. Distinguish an intentional close (caller called `session.close()`/`interrupt()`, or the turn already finished via `isFinal`/`doneCalled`) from an unexpected mid-turn close. On an unexpected close, reconnect (up to 2 retries) and reuse the same closure state — the not-yet-sent `queue` array is preserved (only `interrupt()` clears it) and gets flushed once the new socket opens, so already-queued/incoming LLM tokens for this turn are no longer silently lost. Only give up (and resolve `done`) after retries are exhausted, with an error log. Also added a 15s periodic keep-alive (`{text:' '}`) per ElevenLabs' documented keep-alive pattern to avoid ever hitting the inactivity timeout during normal generation gaps.
elevenlabsttswebsocketvoice-agentstreaming-inputreconnectnode.js
References
- https://elevenlabs.io/docs/api-reference/text-to-speech/v-1-text-to-speech-voice-id-stream-input — stream-input WebSocket default inactivity timeout is 20s, configurable up to 180s via inactivity_timeout query param; to keep the connection open you must send an additional single space character text message periodically (per timeout interval), which is the documented keep-alive pattern.
- https://help.elevenlabs.io/hc/en-us/articles/28084868106513-How-can-I-keep-the-WebSocket-open — Confirms the keep-alive-via-space-character mechanism and that it must be repeated periodically, not sent once at connection open.
- https://github.com/livekit/agents/issues/4676 — Confirms ElevenLabs TTS streaming WebSocket closing unexpectedly mid-session (no HTTP status, low-level connection failure) is a known real-world failure mode in production voice agents, motivating app-level reconnect handling rather than assuming the socket stays up for the duration of a turn.