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.

elevenlabs-api · verified Jul 8, 2026

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