introduce UninterruptibleFrame frames
This commit is contained in:
6
changelog/3189.added.md
Normal file
6
changelog/3189.added.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
- Data and control frames can now be marked as non-interruptible by using the
|
||||||
|
`UninterruptibleFrame` mixin. Frames marked as `UninterruptibleFrame` will not
|
||||||
|
be interrupted during processing, and any queued frames of this type will be
|
||||||
|
retained in the internal queues. This is useful when you need ordered frames
|
||||||
|
(data or control) that should not be discarded or cancelled due to
|
||||||
|
interruptions.
|
||||||
@@ -186,6 +186,20 @@ class ControlFrame(Frame):
|
|||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UninterruptibleFrame:
|
||||||
|
"""A marker for data or control frames that must not be interrupted.
|
||||||
|
|
||||||
|
Frames with this mixin are still ordered normally, but unlike other frames,
|
||||||
|
they are preserved during interruptions: they remain in internal queues and
|
||||||
|
any task processing them will not be cancelled. This ensures the frame is
|
||||||
|
always delivered and processed to completion.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AudioRawFrame:
|
class AudioRawFrame:
|
||||||
"""A frame containing a chunk of raw audio.
|
"""A frame containing a chunk of raw audio.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from pipecat.frames.frames import (
|
|||||||
InterruptionTaskFrame,
|
InterruptionTaskFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
SystemFrame,
|
SystemFrame,
|
||||||
|
UninterruptibleFrame,
|
||||||
)
|
)
|
||||||
from pipecat.metrics.metrics import LLMTokenUsage, MetricsData
|
from pipecat.metrics.metrics import LLMTokenUsage, MetricsData
|
||||||
from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed
|
from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed
|
||||||
@@ -211,6 +212,7 @@ class FrameProcessor(BaseObject):
|
|||||||
# The input task that handles all types of frames. It processes system
|
# The input task that handles all types of frames. It processes system
|
||||||
# frames right away and queues non-system frames for later processing.
|
# frames right away and queues non-system frames for later processing.
|
||||||
self.__should_block_system_frames = False
|
self.__should_block_system_frames = False
|
||||||
|
self.__input_queue = FrameProcessorQueue()
|
||||||
self.__input_event: Optional[asyncio.Event] = None
|
self.__input_event: Optional[asyncio.Event] = None
|
||||||
self.__input_frame_task: Optional[asyncio.Task] = None
|
self.__input_frame_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
@@ -220,8 +222,10 @@ class FrameProcessor(BaseObject):
|
|||||||
# called. To resume processing frames we need to call
|
# called. To resume processing frames we need to call
|
||||||
# `resume_processing_frames()` which will wake up the event.
|
# `resume_processing_frames()` which will wake up the event.
|
||||||
self.__should_block_frames = False
|
self.__should_block_frames = False
|
||||||
|
self.__process_queue = asyncio.Queue()
|
||||||
self.__process_event: Optional[asyncio.Event] = None
|
self.__process_event: Optional[asyncio.Event] = None
|
||||||
self.__process_frame_task: Optional[asyncio.Task] = None
|
self.__process_frame_task: Optional[asyncio.Task] = None
|
||||||
|
self.__process_current_frame: Optional[Frame] = None
|
||||||
|
|
||||||
# To interrupt a pipeline, we push an `InterruptionTaskFrame` upstream.
|
# To interrupt a pipeline, we push an `InterruptionTaskFrame` upstream.
|
||||||
# Then we wait for the corresponding `InterruptionFrame` to travel from
|
# Then we wait for the corresponding `InterruptionFrame` to travel from
|
||||||
@@ -805,8 +809,12 @@ class FrameProcessor(BaseObject):
|
|||||||
# interruption). Instead we just drain the queue because this is
|
# interruption). Instead we just drain the queue because this is
|
||||||
# an interruption.
|
# an interruption.
|
||||||
self.__reset_process_task()
|
self.__reset_process_task()
|
||||||
|
elif isinstance(self.__process_current_frame, UninterruptibleFrame):
|
||||||
|
# We don't want to cancel UninterruptibleFrame, so we simply
|
||||||
|
# cleanup the queue.
|
||||||
|
self.__reset_process_queue()
|
||||||
else:
|
else:
|
||||||
# Cancel and re-create the process task including the queue.
|
# Cancel and re-create the process task.
|
||||||
await self.__cancel_process_task()
|
await self.__cancel_process_task()
|
||||||
self.__create_process_task()
|
self.__create_process_task()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -872,7 +880,6 @@ class FrameProcessor(BaseObject):
|
|||||||
|
|
||||||
if not self.__input_frame_task:
|
if not self.__input_frame_task:
|
||||||
self.__input_event = asyncio.Event()
|
self.__input_event = asyncio.Event()
|
||||||
self.__input_queue = FrameProcessorQueue()
|
|
||||||
self.__input_frame_task = self.create_task(self.__input_frame_task_handler())
|
self.__input_frame_task = self.create_task(self.__input_frame_task_handler())
|
||||||
|
|
||||||
async def __cancel_input_task(self):
|
async def __cancel_input_task(self):
|
||||||
@@ -890,9 +897,7 @@ class FrameProcessor(BaseObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self.__process_frame_task:
|
if not self.__process_frame_task:
|
||||||
self.__should_block_frames = False
|
self.__reset_process_task()
|
||||||
self.__process_event = asyncio.Event()
|
|
||||||
self.__process_queue = asyncio.Queue()
|
|
||||||
self.__process_frame_task = self.create_task(self.__process_frame_task_handler())
|
self.__process_frame_task = self.create_task(self.__process_frame_task_handler())
|
||||||
|
|
||||||
def __reset_process_task(self):
|
def __reset_process_task(self):
|
||||||
@@ -902,10 +907,26 @@ class FrameProcessor(BaseObject):
|
|||||||
|
|
||||||
self.__should_block_frames = False
|
self.__should_block_frames = False
|
||||||
self.__process_event = asyncio.Event()
|
self.__process_event = asyncio.Event()
|
||||||
|
self.__reset_process_queue()
|
||||||
|
|
||||||
|
def __reset_process_queue(self):
|
||||||
|
"""Reset non-system frame processing queue."""
|
||||||
|
# Create a new queue to insert UninterruptibleFrame frames.
|
||||||
|
new_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
# Process current queue and keep UninterruptibleFrame frames.
|
||||||
while not self.__process_queue.empty():
|
while not self.__process_queue.empty():
|
||||||
self.__process_queue.get_nowait()
|
item = self.__process_queue.get_nowait()
|
||||||
|
if isinstance(item, UninterruptibleFrame):
|
||||||
|
new_queue.put_nowait(item)
|
||||||
self.__process_queue.task_done()
|
self.__process_queue.task_done()
|
||||||
|
|
||||||
|
# Put back UninterruptibleFrame frames into our process queue.
|
||||||
|
while not new_queue.empty():
|
||||||
|
item = new_queue.get_nowait()
|
||||||
|
self.__process_queue.put_nowait(item)
|
||||||
|
new_queue.task_done()
|
||||||
|
|
||||||
async def __cancel_process_task(self):
|
async def __cancel_process_task(self):
|
||||||
"""Cancel the non-system frame processing task."""
|
"""Cancel the non-system frame processing task."""
|
||||||
if self.__process_frame_task:
|
if self.__process_frame_task:
|
||||||
@@ -959,8 +980,12 @@ class FrameProcessor(BaseObject):
|
|||||||
async def __process_frame_task_handler(self):
|
async def __process_frame_task_handler(self):
|
||||||
"""Handle non-system frames from the process queue."""
|
"""Handle non-system frames from the process queue."""
|
||||||
while True:
|
while True:
|
||||||
|
self.__process_current_frame = None
|
||||||
|
|
||||||
(frame, direction, callback) = await self.__process_queue.get()
|
(frame, direction, callback) = await self.__process_queue.get()
|
||||||
|
|
||||||
|
self.__process_current_frame = frame
|
||||||
|
|
||||||
if self.__should_block_frames and self.__process_event:
|
if self.__should_block_frames and self.__process_event:
|
||||||
logger.trace(f"{self}: frame processing paused")
|
logger.trace(f"{self}: frame processing paused")
|
||||||
await self.__process_event.wait()
|
await self.__process_event.wait()
|
||||||
|
|||||||
@@ -6,13 +6,17 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
|
DataFrame,
|
||||||
EndFrame,
|
EndFrame,
|
||||||
Frame,
|
Frame,
|
||||||
InterruptionFrame,
|
InterruptionFrame,
|
||||||
OutputTransportMessageUrgentFrame,
|
OutputTransportMessageUrgentFrame,
|
||||||
|
SystemFrame,
|
||||||
TextFrame,
|
TextFrame,
|
||||||
|
UninterruptibleFrame,
|
||||||
)
|
)
|
||||||
from pipecat.pipeline.pipeline import Pipeline
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
from pipecat.processors.filters.identity_filter import IdentityFilter
|
from pipecat.processors.filters.identity_filter import IdentityFilter
|
||||||
@@ -110,3 +114,75 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase):
|
|||||||
expected_down_frames=expected_down_frames,
|
expected_down_frames=expected_down_frames,
|
||||||
send_end_frame=False,
|
send_end_frame=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_interruptible_frames(self):
|
||||||
|
@dataclass
|
||||||
|
class TestInterruptibleFrame(DataFrame):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
class DelayTestFrameProcessor(FrameProcessor):
|
||||||
|
"""This processor just delays processing frames so we have time to
|
||||||
|
try to interrupt them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if not isinstance(frame, SystemFrame):
|
||||||
|
# Sleep more than SleepFrame default.
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
pipeline = Pipeline([DelayTestFrameProcessor()])
|
||||||
|
|
||||||
|
frames_to_send = [
|
||||||
|
TestInterruptibleFrame(text="Hello from Pipecat!"),
|
||||||
|
# Make sure we hit the DelayTestFrameProcessor first.
|
||||||
|
SleepFrame(),
|
||||||
|
# Just a random interruption. This should cause the interruption of
|
||||||
|
# TestInterruptibleFrame.
|
||||||
|
InterruptionFrame(),
|
||||||
|
]
|
||||||
|
expected_down_frames = [InterruptionFrame]
|
||||||
|
await run_test(
|
||||||
|
pipeline,
|
||||||
|
frames_to_send=frames_to_send,
|
||||||
|
expected_down_frames=expected_down_frames,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_uninterruptible_frames(self):
|
||||||
|
@dataclass
|
||||||
|
class TestUninterruptibleFrame(DataFrame, UninterruptibleFrame):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
class DelayTestFrameProcessor(FrameProcessor):
|
||||||
|
"""This processor just delays processing non-InterruptionFrame so we
|
||||||
|
have time to try to interrupt them.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if not isinstance(frame, SystemFrame):
|
||||||
|
# Sleep more than SleepFrame default.
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
pipeline = Pipeline([DelayTestFrameProcessor()])
|
||||||
|
|
||||||
|
frames_to_send = [
|
||||||
|
TestUninterruptibleFrame(text="Hello from Pipecat!"),
|
||||||
|
# Make sure we hit the DelayTestFrameProcessor first.
|
||||||
|
SleepFrame(),
|
||||||
|
# Just a random interruption. This should not cause the interruption
|
||||||
|
# of TestUninterruptibleFrame.
|
||||||
|
InterruptionFrame(),
|
||||||
|
]
|
||||||
|
expected_down_frames = [
|
||||||
|
InterruptionFrame,
|
||||||
|
TestUninterruptibleFrame,
|
||||||
|
]
|
||||||
|
await run_test(
|
||||||
|
pipeline,
|
||||||
|
frames_to_send=frames_to_send,
|
||||||
|
expected_down_frames=expected_down_frames,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user