TTSService with push_text_frames=False drops the LLMFullResponseEndFrame of any turn that produces no audio — tool-only turns never end downstream
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):
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] = frameThe 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.
"""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:
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_idis captured first because the existing body clears_turn_context_id.- The
TTSSpeakFramepath (:842-867) also calls this method, under a fresh context id that is never a key in_pending_llm_response_end_frames, so thepopreturnsNoneand it no-ops. - The
_llm_response_startedcheck mirrors_maybe_reset_word_timestampsand 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.
Source: pipecat-ai/pipecat