all frame processors are asynchrnous

In this commit we make all frame processors asynchronous, that is, they have an
internal queue and they push frames using a task from that queue.
This commit is contained in:
Aleix Conchillo Flaqué
2024-09-30 10:05:56 -07:00
parent 46ac76701e
commit c8995b82e5
15 changed files with 113 additions and 132 deletions

View File

@@ -48,15 +48,10 @@ async def on_connected(processor):
frames. To achieve that, each frame processor should only output frames from a frames. To achieve that, each frame processor should only output frames from a
single task. single task.
In this version we introduce synchronous and asynchronous frame In this version all the frame processors have their own task to push
processors. The synchronous processors push output frames from the same task frames. That is, when `push_frame()` is called the given frame will be put
that they receive input frames, and therefore only pushing frames from one into an internal queue (with the exception of system frames) and a frame
task. Asynchronous frame processors can have internal tasks to perform things processor task will push it out.
asynchronously (e.g. receiving data from a websocket) but they also have a
single task where they push frames from.
By default, frame processors are synchronous. To change a frame processor to
asynchronous you only need to pass `sync=False` to the base class constructor.
- Added pipeline clocks. A pipeline clock is used by the output transport to - Added pipeline clocks. A pipeline clock is used by the output transport to
know when a frame needs to be presented. For that, all frames now have an know when a frame needs to be presented. For that, all frames now have an
@@ -68,9 +63,7 @@ async def on_connected(processor):
`SystemClock`). This clock will be passed to each frame processor via the `SystemClock`). This clock will be passed to each frame processor via the
`StartFrame`. `StartFrame`.
- Added `CartesiaHttpTTSService`. This is a synchronous frame processor - Added `CartesiaHttpTTSService`.
(i.e. given an input text frame it will wait for the whole output before
returning).
- `DailyTransport` now supports setting the audio bitrate to improve audio - `DailyTransport` now supports setting the audio bitrate to improve audio
quality through the `DailyParams.audio_out_bitrate` parameter. The new quality through the `DailyParams.audio_out_bitrate` parameter. The new
@@ -110,8 +103,9 @@ async def on_connected(processor):
pipelines to be executed concurrently. The difference between a pipelines to be executed concurrently. The difference between a
`SyncParallelPipeline` and a `ParallelPipeline` is that, given an input frame, `SyncParallelPipeline` and a `ParallelPipeline` is that, given an input frame,
the `SyncParallelPipeline` will wait for all the internal pipelines to the `SyncParallelPipeline` will wait for all the internal pipelines to
complete. This is achieved by ensuring all the processors in each of the complete. This is achieved by making sure the last processor in each of the
internal pipelines are synchronous. pipelines is synchronous (e.g. an HTTP-based service that waits for the
response).
- `StartFrame` is back a system frame so we make sure it's processed immediately - `StartFrame` is back a system frame so we make sure it's processed immediately
by all processors. `EndFrame` stays a control frame since it needs to be by all processors. `EndFrame` stays a control frame since it needs to be

View File

@@ -86,13 +86,13 @@ async def main():
), ),
) )
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o")
tts = CartesiaHttpTTSService( tts = CartesiaHttpTTSService(
api_key=os.getenv("CARTESIA_API_KEY"), api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady
) )
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o")
imagegen = FalImageGenService( imagegen = FalImageGenService(
params=FalImageGenService.InputParams(image_size="square_hd"), params=FalImageGenService.InputParams(image_size="square_hd"),
aiohttp_session=session, aiohttp_session=session,
@@ -107,8 +107,10 @@ async def main():
# that, each pipeline runs concurrently and `SyncParallelPipeline` will # that, each pipeline runs concurrently and `SyncParallelPipeline` will
# wait for the input frame to be processed. # wait for the input frame to be processed.
# #
# Note that `SyncParallelPipeline` requires all processors in it to be # Note that `SyncParallelPipeline` requires the last processor in each
# synchronous (which is the default for most processors). # of the pipelines to be synchronous. In this case, we use
# `CartesiaHttpTTSService` and `FalImageGenService` which make HTTP
# requests and wait for the response.
pipeline = Pipeline( pipeline = Pipeline(
[ [
llm, # LLM llm, # LLM

View File

@@ -82,6 +82,7 @@ async def main():
self.frame = OutputAudioRawFrame( self.frame = OutputAudioRawFrame(
bytes(self.audio), frame.sample_rate, frame.num_channels bytes(self.audio), frame.sample_rate, frame.num_channels
) )
await self.push_frame(frame, direction)
class ImageGrabber(FrameProcessor): class ImageGrabber(FrameProcessor):
def __init__(self): def __init__(self):
@@ -93,6 +94,7 @@ async def main():
if isinstance(frame, URLImageRawFrame): if isinstance(frame, URLImageRawFrame):
self.frame = frame self.frame = frame
await self.push_frame(frame, direction)
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o") llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o")
@@ -121,8 +123,10 @@ async def main():
# `SyncParallelPipeline` will wait for the input frame to be # `SyncParallelPipeline` will wait for the input frame to be
# processed. # processed.
# #
# Note that `SyncParallelPipeline` requires all processors in it to # Note that `SyncParallelPipeline` requires the last processor in
# be synchronous (which is the default for most processors). # each of the pipelines to be synchronous. In this case, we use
# `CartesiaHttpTTSService` and `FalImageGenService` which make HTTP
# requests and wait for the response.
pipeline = Pipeline( pipeline = Pipeline(
[ [
llm, # LLM llm, # LLM

View File

@@ -37,7 +37,6 @@ class FrameProcessor:
*, *,
name: str | None = None, name: str | None = None,
metrics: FrameProcessorMetrics | None = None, metrics: FrameProcessorMetrics | None = None,
sync: bool = True,
loop: asyncio.AbstractEventLoop | None = None, loop: asyncio.AbstractEventLoop | None = None,
**kwargs, **kwargs,
): ):
@@ -47,7 +46,6 @@ class FrameProcessor:
self._prev: "FrameProcessor" | None = None self._prev: "FrameProcessor" | None = None
self._next: "FrameProcessor" | None = None self._next: "FrameProcessor" | None = None
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_running_loop() self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_running_loop()
self._sync = sync
self._event_handlers: dict = {} self._event_handlers: dict = {}
@@ -66,11 +64,8 @@ class FrameProcessor:
# Every processor in Pipecat should only output frames from a single # Every processor in Pipecat should only output frames from a single
# task. This avoid problems like audio overlapping. System frames are # task. This avoid problems like audio overlapping. System frames are
# the exception to this rule. # the exception to this rule. This create this task.
# self.__create_push_task()
# This create this task.
if not self._sync:
self.__create_push_task()
@property @property
def interruptions_allowed(self): def interruptions_allowed(self):
@@ -167,7 +162,7 @@ class FrameProcessor:
await self.push_frame(error, FrameDirection.UPSTREAM) await self.push_frame(error, FrameDirection.UPSTREAM)
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
if self._sync or isinstance(frame, SystemFrame): if isinstance(frame, SystemFrame):
await self.__internal_push_frame(frame, direction) await self.__internal_push_frame(frame, direction)
else: else:
await self.__push_queue.put((frame, direction)) await self.__push_queue.put((frame, direction))
@@ -194,13 +189,12 @@ class FrameProcessor:
# #
async def _start_interruption(self): async def _start_interruption(self):
if not self._sync: # Cancel the task. This will stop pushing frames downstream.
# Cancel the task. This will stop pushing frames downstream. self.__push_frame_task.cancel()
self.__push_frame_task.cancel() await self.__push_frame_task
await self.__push_frame_task
# Create a new queue and task. # Create a new queue and task.
self.__create_push_task() self.__create_push_task()
async def _stop_interruption(self): async def _stop_interruption(self):
# Nothing to do right now. # Nothing to do right now.

View File

@@ -516,7 +516,7 @@ class RTVIProcessor(FrameProcessor):
params: RTVIProcessorParams = RTVIProcessorParams(), params: RTVIProcessorParams = RTVIProcessorParams(),
**kwargs, **kwargs,
): ):
super().__init__(sync=False, **kwargs) super().__init__(**kwargs)
self._config = config self._config = config
self._params = params self._params = params

View File

@@ -44,7 +44,7 @@ class GStreamerPipelineSource(FrameProcessor):
clock_sync: bool = True clock_sync: bool = True
def __init__(self, *, pipeline: str, out_params: OutputParams = OutputParams(), **kwargs): def __init__(self, *, pipeline: str, out_params: OutputParams = OutputParams(), **kwargs):
super().__init__(sync=False, **kwargs) super().__init__(**kwargs)
self._out_params = out_params self._out_params = out_params

View File

@@ -26,7 +26,7 @@ class IdleFrameProcessor(FrameProcessor):
types: List[type] = [], types: List[type] = [],
**kwargs, **kwargs,
): ):
super().__init__(sync=False, **kwargs) super().__init__(**kwargs)
self._callback = callback self._callback = callback
self._timeout = timeout self._timeout = timeout

View File

@@ -31,7 +31,7 @@ class UserIdleProcessor(FrameProcessor):
timeout: float, timeout: float,
**kwargs, **kwargs,
): ):
super().__init__(sync=False, **kwargs) super().__init__(**kwargs)
self._callback = callback self._callback = callback
self._timeout = timeout self._timeout = timeout

View File

@@ -144,6 +144,10 @@ class TTSService(AIService):
# if True, TTSService will push TextFrames and LLMFullResponseEndFrames, # if True, TTSService will push TextFrames and LLMFullResponseEndFrames,
# otherwise subclass must do it # otherwise subclass must do it
push_text_frames: bool = True, push_text_frames: bool = True,
# if True, TTSService will push TTSStoppedFrames, otherwise subclass must do it
push_stop_frames: bool = False,
# if push_stop_frames is True, wait for this idle period before pushing TTSStoppedFrame
stop_frame_timeout_s: float = 1.0,
# TTS output sample rate # TTS output sample rate
sample_rate: int = 16000, sample_rate: int = 16000,
**kwargs, **kwargs,
@@ -151,9 +155,15 @@ class TTSService(AIService):
super().__init__(**kwargs) super().__init__(**kwargs)
self._aggregate_sentences: bool = aggregate_sentences self._aggregate_sentences: bool = aggregate_sentences
self._push_text_frames: bool = push_text_frames self._push_text_frames: bool = push_text_frames
self._current_sentence: str = "" self._push_stop_frames: bool = push_stop_frames
self._stop_frame_timeout_s: float = stop_frame_timeout_s
self._sample_rate: int = sample_rate self._sample_rate: int = sample_rate
self._stop_frame_task: Optional[asyncio.Task] = None
self._stop_frame_queue: asyncio.Queue = asyncio.Queue()
self._current_sentence: str = ""
@property @property
def sample_rate(self) -> int: def sample_rate(self) -> int:
return self._sample_rate return self._sample_rate
@@ -210,13 +220,72 @@ class TTSService(AIService):
async def set_role(self, role: str): async def set_role(self, role: str):
pass pass
@abstractmethod
async def flush_audio(self):
pass
# Converts the text to audio. # Converts the text to audio.
@abstractmethod @abstractmethod
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
pass pass
async def start(self, frame: StartFrame):
await super().start(frame)
if self._push_stop_frames:
self._stop_frame_task = self.get_event_loop().create_task(self._stop_frame_handler())
async def stop(self, frame: EndFrame):
await super().stop(frame)
if self._stop_frame_task:
self._stop_frame_task.cancel()
await self._stop_frame_task
self._stop_frame_task = None
async def cancel(self, frame: CancelFrame):
await super().cancel(frame)
if self._stop_frame_task:
self._stop_frame_task.cancel()
await self._stop_frame_task
self._stop_frame_task = None
async def say(self, text: str): async def say(self, text: str):
await self.process_frame(TextFrame(text=text), FrameDirection.DOWNSTREAM) await self.process_frame(TextFrame(text=text), FrameDirection.DOWNSTREAM)
await self.flush_audio()
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
await self._process_text_frame(frame)
elif isinstance(frame, StartInterruptionFrame):
await self._handle_interruption(frame, direction)
elif isinstance(frame, LLMFullResponseEndFrame) or isinstance(frame, EndFrame):
sentence = self._current_sentence
self._current_sentence = ""
await self._push_tts_frames(sentence)
if isinstance(frame, LLMFullResponseEndFrame):
if self._push_text_frames:
await self.push_frame(frame, direction)
else:
await self.push_frame(frame, direction)
elif isinstance(frame, TTSSpeakFrame):
await self._push_tts_frames(frame.text)
await self.flush_audio()
elif isinstance(frame, TTSUpdateSettingsFrame):
await self._update_tts_settings(frame)
else:
await self.push_frame(frame, direction)
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
await super().push_frame(frame, direction)
if self._push_stop_frames and (
isinstance(frame, StartInterruptionFrame)
or isinstance(frame, TTSStartedFrame)
or isinstance(frame, TTSAudioRawFrame)
or isinstance(frame, TTSStoppedFrame)
):
await self._stop_frame_queue.put(frame)
async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection): async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection):
self._current_sentence = "" self._current_sentence = ""
@@ -276,88 +345,6 @@ class TTSService(AIService):
if frame.role is not None: if frame.role is not None:
await self.set_role(frame.role) await self.set_role(frame.role)
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
await self._process_text_frame(frame)
elif isinstance(frame, StartInterruptionFrame):
await self._handle_interruption(frame, direction)
elif isinstance(frame, LLMFullResponseEndFrame) or isinstance(frame, EndFrame):
sentence = self._current_sentence
self._current_sentence = ""
await self._push_tts_frames(sentence)
if isinstance(frame, LLMFullResponseEndFrame):
if self._push_text_frames:
await self.push_frame(frame, direction)
else:
await self.push_frame(frame, direction)
elif isinstance(frame, TTSSpeakFrame):
await self._push_tts_frames(frame.text)
elif isinstance(frame, TTSUpdateSettingsFrame):
await self._update_tts_settings(frame)
else:
await self.push_frame(frame, direction)
class AsyncTTSService(TTSService):
def __init__(
self,
# if True, TTSService will push TTSStoppedFrames, otherwise subclass must do it
push_stop_frames: bool = False,
# if push_stop_frames is True, wait for this idle period before pushing TTSStoppedFrame
stop_frame_timeout_s: float = 1.0,
**kwargs,
):
super().__init__(sync=False, **kwargs)
self._push_stop_frames: bool = push_stop_frames
self._stop_frame_timeout_s: float = stop_frame_timeout_s
self._stop_frame_task: Optional[asyncio.Task] = None
self._stop_frame_queue: asyncio.Queue = asyncio.Queue()
@abstractmethod
async def flush_audio(self):
pass
async def say(self, text: str):
await super().say(text)
await self.flush_audio()
async def start(self, frame: StartFrame):
await super().start(frame)
if self._push_stop_frames:
self._stop_frame_task = self.get_event_loop().create_task(self._stop_frame_handler())
async def stop(self, frame: EndFrame):
await super().stop(frame)
if self._stop_frame_task:
self._stop_frame_task.cancel()
await self._stop_frame_task
self._stop_frame_task = None
async def cancel(self, frame: CancelFrame):
await super().cancel(frame)
if self._stop_frame_task:
self._stop_frame_task.cancel()
await self._stop_frame_task
self._stop_frame_task = None
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TTSSpeakFrame):
await self.flush_audio()
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
await super().push_frame(frame, direction)
if self._push_stop_frames and (
isinstance(frame, StartInterruptionFrame)
or isinstance(frame, TTSStartedFrame)
or isinstance(frame, TTSAudioRawFrame)
or isinstance(frame, TTSStoppedFrame)
):
await self._stop_frame_queue.put(frame)
async def _stop_frame_handler(self): async def _stop_frame_handler(self):
try: try:
has_started = False has_started = False
@@ -378,7 +365,7 @@ class AsyncTTSService(TTSService):
pass pass
class AsyncWordTTSService(AsyncTTSService): class WordTTSService(TTSService):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._initial_word_timestamp = -1 self._initial_word_timestamp = -1

View File

@@ -26,7 +26,7 @@ from pipecat.frames.frames import (
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
from pipecat.services.ai_services import AsyncWordTTSService, TTSService from pipecat.services.ai_services import WordTTSService, TTSService
from loguru import logger from loguru import logger
@@ -61,7 +61,7 @@ def language_to_cartesia_language(language: Language) -> str | None:
return None return None
class CartesiaTTSService(AsyncWordTTSService): class CartesiaTTSService(WordTTSService):
class InputParams(BaseModel): class InputParams(BaseModel):
encoding: Optional[str] = "pcm_s16le" encoding: Optional[str] = "pcm_s16le"
sample_rate: Optional[int] = 16000 sample_rate: Optional[int] = 16000

View File

@@ -23,7 +23,7 @@ from pipecat.frames.frames import (
TTSStoppedFrame, TTSStoppedFrame,
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_services import AsyncWordTTSService from pipecat.services.ai_services import WordTTSService
# See .env.example for ElevenLabs configuration needed # See .env.example for ElevenLabs configuration needed
try: try:
@@ -70,7 +70,7 @@ def calculate_word_times(
return word_times return word_times
class ElevenLabsTTSService(AsyncWordTTSService): class ElevenLabsTTSService(WordTTSService):
class InputParams(BaseModel): class InputParams(BaseModel):
language: Optional[str] = None language: Optional[str] = None
output_format: Literal["pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"] = "pcm_16000" output_format: Literal["pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"] = "pcm_16000"

View File

@@ -51,7 +51,7 @@ class GladiaSTTService(STTService):
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
super().__init__(sync=False, **kwargs) super().__init__(**kwargs)
self._api_key = api_key self._api_key = api_key
self._url = url self._url = url

View File

@@ -20,7 +20,7 @@ from pipecat.frames.frames import (
TTSStartedFrame, TTSStartedFrame,
TTSStoppedFrame, TTSStoppedFrame,
) )
from pipecat.services.ai_services import AsyncTTSService from pipecat.services.ai_services import TTSService
from loguru import logger from loguru import logger
@@ -35,7 +35,7 @@ except ModuleNotFoundError as e:
raise Exception(f"Missing module: {e}") raise Exception(f"Missing module: {e}")
class LmntTTSService(AsyncTTSService): class LmntTTSService(TTSService):
def __init__( def __init__(
self, self,
*, *,
@@ -47,7 +47,7 @@ class LmntTTSService(AsyncTTSService):
): ):
# Let TTSService produce TTSStoppedFrames after a short delay of # Let TTSService produce TTSStoppedFrames after a short delay of
# no activity. # no activity.
super().__init__(sync=False, push_stop_frames=True, sample_rate=sample_rate, **kwargs) super().__init__(push_stop_frames=True, sample_rate=sample_rate, **kwargs)
self._api_key = api_key self._api_key = api_key
self._voice_id = voice_id self._voice_id = voice_id

View File

@@ -31,7 +31,7 @@ from loguru import logger
class BaseInputTransport(FrameProcessor): class BaseInputTransport(FrameProcessor):
def __init__(self, params: TransportParams, **kwargs): def __init__(self, params: TransportParams, **kwargs):
super().__init__(sync=False, **kwargs) super().__init__(**kwargs)
self._params = params self._params = params

View File

@@ -43,7 +43,7 @@ from pipecat.utils.time import nanoseconds_to_seconds
class BaseOutputTransport(FrameProcessor): class BaseOutputTransport(FrameProcessor):
def __init__(self, params: TransportParams, **kwargs): def __init__(self, params: TransportParams, **kwargs):
super().__init__(sync=False, **kwargs) super().__init__(**kwargs)
self._params = params self._params = params