1313
1414import asyncio
1515from dataclasses import dataclass
16+ from enum import Enum
1617from itertools import chain
1718from typing import List
1819
2425from pipecat .processors .frame_processor import FrameDirection , FrameProcessor , FrameProcessorSetup
2526
2627
28+ class FrameOrder (Enum ):
29+ """Controls the order in which synchronized frames are pushed downstream.
30+
31+ When multiple parallel pipelines produce output for the same input frame,
32+ this setting determines the order in which those output frames are pushed.
33+
34+ Attributes:
35+ ARRIVAL: Frames are pushed in the order they arrive from any pipeline.
36+ This is the default and matches the behavior of prior versions.
37+ PIPELINE: Frames are pushed in pipeline definition order — all frames
38+ from the first pipeline are pushed, then all frames from the second
39+ pipeline, and so on. Useful when the relative ordering between
40+ pipelines matters (e.g. ensuring image frames precede audio frames).
41+ """
42+
43+ ARRIVAL = "arrival"
44+ PIPELINE = "pipeline"
45+
46+
2747@dataclass
2848class SyncFrame (ControlFrame ):
2949 """Control frame used to synchronize parallel pipeline processing.
@@ -109,20 +129,30 @@ class SyncParallelPipeline(BasePipeline):
109129
110130 The pipeline uses SyncFrame control frames to coordinate between parallel paths
111131 and ensure all paths have completed processing before moving to the next frame.
132+
133+ By default, output frames are pushed in the order they arrive from any pipeline
134+ (``FrameOrder.ARRIVAL``). Set ``frame_order=FrameOrder.PIPELINE`` to push frames
135+ in pipeline definition order instead — all output from the first pipeline, then
136+ the second, and so on.
112137 """
113138
114- def __init__ (self , * args ):
139+ def __init__ (self , * args , frame_order : FrameOrder = FrameOrder . ARRIVAL ):
115140 """Initialize the synchronous parallel pipeline.
116141
117142 Args:
118- *args: Variable number of processor lists, each representing a parallel pipeline path.
119- Each argument should be a list of FrameProcessor instances.
143+ *args: Variable number of processor lists, each representing a parallel
144+ pipeline path. Each argument should be a list of FrameProcessor instances.
145+ frame_order: Controls the order in which synchronized output frames are
146+ pushed. ``FrameOrder.ARRIVAL`` (default) pushes frames in the order they arrive.
147+ ``FrameOrder.PIPELINE`` pushes all frames from the first pipeline
148+ before the second, and so on.
120149
121150 Raises:
122151 Exception: If no arguments are provided.
123152 TypeError: If any argument is not a list of processors.
124153 """
125154 super ().__init__ ()
155+ self ._frame_order = frame_order
126156
127157 if len (args ) == 0 :
128158 raise Exception (f"SyncParallelPipeline needs at least one argument" )
@@ -215,6 +245,11 @@ async def process_frame(self, frame: Frame, direction: FrameDirection):
215245 to maintain proper ordering and prevent duplicate processing. Uses SyncFrame
216246 control frames to coordinate between parallel paths.
217247
248+ When ``frame_order`` is ``FrameOrder.ARRIVAL``, output frames are pushed in
249+ the order they arrive from any pipeline (via a shared queue). When it is
250+ ``FrameOrder.PIPELINE``, each pipeline collects its output into a separate
251+ list and the lists are drained in pipeline definition order.
252+
218253 Args:
219254 frame: The frame to process.
220255 direction: The direction of frame flow.
@@ -235,60 +270,88 @@ async def process_frame(self, frame: Frame, direction: FrameDirection):
235270 await self .push_frame (frame , direction )
236271 return
237272
273+ use_pipeline_order = self ._frame_order == FrameOrder .PIPELINE
274+
238275 # The last processor of each pipeline needs to be synchronous otherwise
239- # this element won't work. Since, we know it should be synchronous we
276+ # this element won't work. Since we know it should be synchronous we
240277 # push a SyncFrame. Since frames are ordered we know this frame will be
241278 # pushed after the synchronous processor has pushed its data allowing us
242279 # to synchronize all the internal pipelines by waiting for the
243280 # SyncFrame in all of them.
281+ #
282+ # In ARRIVAL mode, output frames are put onto a shared main_queue as
283+ # they arrive. In PIPELINE mode, they are accumulated in a per-pipeline
284+ # list and returned so the caller can drain them in definition order.
244285 async def wait_for_sync (
245286 obj , main_queue : asyncio .Queue , frame : Frame , direction : FrameDirection
246- ):
287+ ) -> list [ Frame ] :
247288 processor = obj ["processor" ]
248289 queue = obj ["queue" ]
290+ output_frames : list [Frame ] = []
249291
250292 await processor .process_frame (frame , direction )
251293
252294 if isinstance (frame , EndFrame ):
253295 new_frame = await queue .get ()
254296 if isinstance (new_frame , EndFrame ):
255- await main_queue .put (new_frame )
297+ if use_pipeline_order :
298+ output_frames .append (new_frame )
299+ else :
300+ await main_queue .put (new_frame )
256301 else :
257302 while not isinstance (new_frame , EndFrame ):
258- await main_queue .put (new_frame )
303+ if use_pipeline_order :
304+ output_frames .append (new_frame )
305+ else :
306+ await main_queue .put (new_frame )
259307 queue .task_done ()
260308 new_frame = await queue .get ()
261309 else :
262310 await processor .process_frame (SyncFrame (), direction )
263311 new_frame = await queue .get ()
264312 while not isinstance (new_frame , SyncFrame ):
265- await main_queue .put (new_frame )
313+ if use_pipeline_order :
314+ output_frames .append (new_frame )
315+ else :
316+ await main_queue .put (new_frame )
266317 queue .task_done ()
267318 new_frame = await queue .get ()
268319
320+ return output_frames
321+
269322 if direction == FrameDirection .UPSTREAM :
270323 # If we get an upstream frame we process it in each sink.
271- await asyncio .gather (
324+ frames_per_pipeline = await asyncio .gather (
272325 * [wait_for_sync (s , self ._up_queue , frame , direction ) for s in self ._sinks ]
273326 )
274327 elif direction == FrameDirection .DOWNSTREAM :
275328 # If we get a downstream frame we process it in each source.
276- await asyncio .gather (
329+ frames_per_pipeline = await asyncio .gather (
277330 * [wait_for_sync (s , self ._down_queue , frame , direction ) for s in self ._sources ]
278331 )
279332
280- seen_ids = set ()
281- while not self ._up_queue .empty ():
282- frame = await self ._up_queue .get ()
283- if frame .id not in seen_ids :
284- await self .push_frame (frame , FrameDirection .UPSTREAM )
285- seen_ids .add (frame .id )
286- self ._up_queue .task_done ()
287-
288- seen_ids = set ()
289- while not self ._down_queue .empty ():
290- frame = await self ._down_queue .get ()
291- if frame .id not in seen_ids :
292- await self .push_frame (frame , FrameDirection .DOWNSTREAM )
293- seen_ids .add (frame .id )
294- self ._down_queue .task_done ()
333+ if use_pipeline_order :
334+ # Push frames in pipeline definition order, deduplicating by id.
335+ seen_ids = set ()
336+ for pipeline_frames in frames_per_pipeline :
337+ for f in pipeline_frames :
338+ if f .id not in seen_ids :
339+ await self .push_frame (f , direction )
340+ seen_ids .add (f .id )
341+ else :
342+ # ARRIVAL mode: drain the shared queues in the order frames arrived.
343+ seen_ids = set ()
344+ while not self ._up_queue .empty ():
345+ frame = await self ._up_queue .get ()
346+ if frame .id not in seen_ids :
347+ await self .push_frame (frame , FrameDirection .UPSTREAM )
348+ seen_ids .add (frame .id )
349+ self ._up_queue .task_done ()
350+
351+ seen_ids = set ()
352+ while not self ._down_queue .empty ():
353+ frame = await self ._down_queue .get ()
354+ if frame .id not in seen_ids :
355+ await self .push_frame (frame , FrameDirection .DOWNSTREAM )
356+ seen_ids .add (frame .id )
357+ self ._down_queue .task_done ()
0 commit comments