Fixing TTS frame order.

This commit is contained in:
filipi87
2026-03-19 09:43:40 -03:00
committed by Mark Backman
parent ed160fd2e0
commit d32a8a9ee2
2 changed files with 359 additions and 12 deletions

View File

@@ -43,6 +43,7 @@ from pipecat.frames.frames import (
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
StartFrame,
SystemFrame,
TextFrame,
TranscriptionFrame,
TTSAudioRawFrame,
@@ -557,9 +558,9 @@ class TTSService(AIService):
"""
await super().stop(frame)
if self._audio_context_task:
# Indicate no more audio contexts are available; this will end the
# task cleanly after all contexts have been processed.
await self._contexts_queue.put(None)
# Sentinel None shuts down the serialization queue once all
# pending contexts and frames have been processed.
await self._serialization_queue.put(None)
await self._audio_context_task
self._audio_context_task = None
if self._stop_frame_task:
@@ -791,7 +792,15 @@ class TTSService(AIService):
await self._maybe_resume_frame_processing()
await self.push_frame(frame, direction)
else:
await self.push_frame(frame, direction)
if direction == FrameDirection.DOWNSTREAM and not isinstance(frame, SystemFrame):
# Route non-system downstream frames through the serialization queue so they
# are emitted in the same order they arrive relative to any audio contexts that
# are already queued (e.g. a FooFrame sent right after a TTSSpeakFrame must
# not overtake the TTSStartedFrame / TTSAudioRawFrame / TTSStoppedFrame
# sequence from that speak frame).
await self._serialization_queue.put(frame)
else:
await self.push_frame(frame, direction)
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
"""Push a frame downstream with TTS-specific handling.
@@ -994,7 +1003,10 @@ class TTSService(AIService):
# is spoken, so we set append_to_context to False.
src_frame.append_to_context = False
src_frame.context_id = context_id
await self.push_frame(src_frame)
# Route AggregatedTextFrame through the serialization queue so it is emitted
# immediately before the TTSStartedFrame of the audio context it describes,
# rather than racing ahead of audio frames from a previous context.
await self._serialization_queue.put(src_frame)
# Note: Text transformations are meant to only affect the text sent to the TTS for
# TTS-specific purposes. This allows for explicit TTS modifications (e.g., inserting
@@ -1203,7 +1215,7 @@ class TTSService(AIService):
Args:
context_id: Unique identifier for the audio context.
"""
await self._contexts_queue.put(context_id)
await self._serialization_queue.put(context_id)
self._audio_contexts[context_id] = asyncio.Queue()
logger.trace(f"{self} created audio context {context_id}")
@@ -1295,7 +1307,14 @@ class TTSService(AIService):
def _create_audio_context_task(self):
if not self._audio_context_task:
self._contexts_queue: asyncio.Queue = asyncio.Queue()
# Single FIFO queue that serializes everything the TTS service emits downstream.
# Items can be:
# str an audio context ID: process the per-context audio queue in full before
# moving on (see _handle_audio_context).
# Frame a non-system downstream frame (e.g. AggregatedTextFrame, FooFrame) that
# must be emitted in-order relative to surrounding audio contexts.
# None shutdown sentinel (sent by stop()).
self._serialization_queue: asyncio.Queue = asyncio.Queue()
self._audio_contexts: Dict[str, asyncio.Queue] = {}
self._audio_context_task = self.create_task(self._audio_context_task_handler())
@@ -1305,13 +1324,26 @@ class TTSService(AIService):
self._audio_context_task = None
async def _audio_context_task_handler(self):
"""In this task we process audio contexts in order."""
"""Drain the serialization queue, preserving downstream frame order.
The queue carries three kinds of items (see _create_audio_context_task):
* str audio context ID: block until all audio for that context has been
pushed downstream, then call on_audio_context_completed().
* Frame a non-system downstream frame that must be emitted at this exact
position in the output stream (e.g. AggregatedTextFrame preceding
its audio, or an arbitrary frame that arrived between two speak frames).
* None shutdown sentinel; exit the loop once reached.
"""
running = True
while running:
context_id = await self._contexts_queue.get()
self._playing_context_id = context_id
context_value = await self._serialization_queue.get()
if isinstance(context_value, Frame):
await self.push_frame(context_value)
elif isinstance(context_value, str):
context_id = context_value
self._playing_context_id = context_id
if context_id:
# Process the audio context until the context doesn't have more
# audio available (i.e. we find None).
await self._handle_audio_context(context_id)
@@ -1323,7 +1355,7 @@ class TTSService(AIService):
else:
running = False
self._contexts_queue.task_done()
self._serialization_queue.task_done()
async def _handle_audio_context(self, context_id: str):
"""Process items from an audio context queue until it is exhausted."""