#5654·pipecat

TTSService with push_text_frames=False drops the LLMFullResponseEndFrame of any turn that produces no audio — tool-only turns never end downstream

Author: iamsolankiamitCreated Sep 7, 2026Updated Sep 16, 2026

Summary

With push_text_frames=False, TTSService never forwards the LLMFullResponseEndFrame of an LLM turn that produces no audio. Tool-only and empty responses are the common cases. Everything downstream of the TTS sees the turn start and never sees it end.

CartesiaTTSService hard-codes push_text_frames=False (services/cartesia/tts.py:337), so every Cartesia pipeline is affected by default.

Mechanism

process_frame holds the frame instead of pushing it (services/tts_service.py:826-838 on 1.8.1):

python
elif self._turn_context_id is not None:
    # Hold the original frame, keyed by this turn's context_id, so
    # _maybe_reset_word_timestamps can re-push it ...
    self._pending_llm_response_end_frames[self._turn_context_id] = frame

The only thing that re-pushes it is _maybe_reset_word_timestamps (:1780), which is called from _handle_audio_context — i.e. at the end of that turn's audio context.

A turn that never reaches run_tts never calls create_audio_context, so no audio context is created, _handle_audio_context never runs for it, and the held frame stays in the dict until an interruption clears it (_handle_interruption, :746). It is never delivered.

on_turn_context_completed (:722-746) is the point where the turn is known to be over and is already checking audio_context_available — but it only flushes audio and clears _turn_context_id.

Reproduction

Self-contained, no provider credentials needed — a bare TTSService subclass with push_text_frames=False is enough.

python
"""Exit 0 = the End frame arrived downstream. Exit 1 = swallowed."""

import asyncio
import sys
from collections.abc import AsyncGenerator

from pipecat.frames.frames import (
    Frame,
    LLMFullResponseEndFrame,
    LLMFullResponseStartFrame,
    TextFrame,
    TTSAudioRawFrame,
)
from pipecat.services.settings import TTSSettings
from pipecat.services.tts_service import TTSService
from pipecat.tests.utils import SleepFrame, run_test

SAMPLE_RATE = 24000


class SilentTTS(TTSService):
    def __init__(self):
        super().__init__(
            push_text_frames=False,
            sample_rate=SAMPLE_RATE,
            settings=TTSSettings(model=None, voice=None, language=None),
        )

    async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame | None, None]:
        yield TTSAudioRawFrame(b"\x00\x00" * 240, SAMPLE_RATE, 1)


async def _ends_seen(frames_to_send) -> int:
    down, _up = await run_test(
        SilentTTS(), frames_to_send=frames_to_send, expected_down_frames=None
    )
    return sum(isinstance(f, LLMFullResponseEndFrame) for f in down)


async def main() -> int:
    audible = await _ends_seen([
        LLMFullResponseStartFrame(),
        TextFrame("Here is your dashboard."),
        LLMFullResponseEndFrame(),
        SleepFrame(1.0),
    ])
    silent = await _ends_seen([
        LLMFullResponseStartFrame(), LLMFullResponseEndFrame(), SleepFrame(1.0)
    ])

    print(f"audible turn -> LLMFullResponseEndFrame downstream: {audible}  (expect 1)")
    print(f"silent  turn -> LLMFullResponseEndFrame downstream: {silent}  (expect 1)")
    return 0 if audible == 1 and silent == 1 else 1


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))

Output on 1.8.1 (and on 1.4.0):

audible turn -> LLMFullResponseEndFrame downstream: 1  (expect 1)
silent  turn -> LLMFullResponseEndFrame downstream: 0  (expect 1)

Impact

Any processor downstream of the TTS that pairs LLMFullResponseStartFrame with its End is left with an unbalanced pair for the rest of the session.

In our case a gate defers an inference until the assistant aggregator (the last processor) has committed the preceding response to the LLM context. One tool-only turn left that gate closed permanently: a production session went silent for 165 s after the user typed a reply, and the user left. Only an InterruptionFrame — i.e. the user giving up and speaking — reopened it. Sessions where the user spoke looked healthy throughout, because function-call result re-runs took a different path; only typed turns died.

LLMAssistantAggregator also uses the End frame as a turn boundary, so this is not specific to custom processors.

Suggested fix

on_turn_context_completed already knows the turn is over and already tests audio_context_available. Flushing the held frame there covers exactly the turns _maybe_reset_word_timestamps cannot reach:

python
async def on_turn_context_completed(self):
    """Handle the completion of a turn."""
    context_id = self._turn_context_id
    had_audio = bool(context_id) and self.audio_context_available(context_id)

    # ... existing body, unchanged ...

    # Reset the turn context ID
    self._turn_context_id = None

    # A turn that produced no audio has no audio context, so
    # _maybe_reset_word_timestamps will never run for it and the End frame held
    # in process_frame would be dropped. Emit it here instead.
    if had_audio or not context_id:
        return
    frame = self._pending_llm_response_end_frames.pop(context_id, None)
    if frame is None or not self._llm_response_started:
        return
    self._llm_response_started = False
    frame.pts = self._word_last_pts
    await self.push_frame(frame)

Notes on the shape:

  • context_id is captured first because the existing body clears _turn_context_id.
  • The TTSSpeakFrame path (:842-867) also calls this method, under a fresh context id that is never a key in _pending_llm_response_end_frames, so the pop returns None and it no-ops.
  • The _llm_response_started check mirrors _maybe_reset_word_timestamps and keeps the frame from being emitted twice.
  • The original frame object is re-pushed, matching the existing intent that observers dedup by frame.id.

We are running this as a subclass override against 1.4.0 in production and it resolves the stall. Happy to open a PR against main with the base-class version plus a test — let me know if you would prefer a different placement (e.g. inside process_frame's End branch, guarded on audio_context_available).

Versions

Reproduced on pipecat-ai==1.8.1 and pipecat-ai==1.4.0, Python 3.11, macOS. The relevant code is unchanged between them.