Merge pull request #1285 from pipecat-ai/aleix/handle-stop-task-gracefully

handle stop task gracefully
This commit is contained in:
Aleix Conchillo Flaqué
2025-02-25 11:25:38 -08:00
committed by GitHub
41 changed files with 396 additions and 128 deletions

View File

@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added a new `StopFrame` which can be used to stop a pipeline task while
keeping the frame processors running. The frame processors could then be used
in a different pipeline. The difference between a `StopFrame` and a
`StopTaskFrame` is that, as with `EndFrame` and `EndTaskFrame`, the
`StopFrame` is pushed from the task and the `StopTaskFrame` is pushed upstream
inside the pipeline by any processor.
- Added a new `PipelineTask` parameter `observers` that replaces the previous
`PipelineParams.observers`.

View File

@@ -18,8 +18,7 @@ from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask
from pipecat.services.fal import FalImageGenService
from pipecat.transports.base_transport import TransportParams
from pipecat.transports.local.tk import TkLocalTransport
from pipecat.transports.local.tk import TkLocalTransport, TkTransportParams
load_dotenv(override=True)
@@ -34,7 +33,9 @@ async def main():
transport = TkLocalTransport(
tk_root,
TransportParams(camera_out_enabled=True, camera_out_width=1024, camera_out_height=1024),
TkTransportParams(
camera_out_enabled=True, camera_out_width=1024, camera_out_height=1024
),
)
imagegen = FalImageGenService(

View File

@@ -30,8 +30,7 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.cartesia import CartesiaHttpTTSService
from pipecat.services.fal import FalImageGenService
from pipecat.services.openai import OpenAILLMService
from pipecat.transports.base_transport import TransportParams
from pipecat.transports.local.tk import TkLocalTransport, TkOutputTransport
from pipecat.transports.local.tk import TkLocalTransport, TkTransportParams
load_dotenv(override=True)
@@ -152,7 +151,7 @@ async def main():
transport = TkLocalTransport(
tk_root,
TransportParams(
TkTransportParams(
audio_out_enabled=True,
camera_out_enabled=True,
camera_out_width=1024,

View File

@@ -24,8 +24,7 @@ from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.transports.base_transport import TransportParams
from pipecat.transports.local.tk import TkLocalTransport
from pipecat.transports.local.tk import TkLocalTransport, TkTransportParams
from pipecat.transports.services.daily import DailyParams, DailyTransport
load_dotenv(override=True)
@@ -67,7 +66,7 @@ async def main():
tk_transport = TkLocalTransport(
tk_root,
TransportParams(
TkTransportParams(
audio_out_enabled=True,
camera_out_enabled=True,
camera_out_is_live=True,

View File

@@ -138,6 +138,7 @@ class OutputGate(FrameProcessor):
self._gate_open = start_open
self._frames_buffer = []
self._notifier = notifier
self._gate_task = None
def close_gate(self):
self._gate_open = False
@@ -178,10 +179,13 @@ class OutputGate(FrameProcessor):
async def _start(self):
self._frames_buffer = []
self._gate_task = self.create_task(self._gate_task_handler())
if not self._gate_task:
self._gate_task = self.create_task(self._gate_task_handler())
async def _stop(self):
await self.cancel_task(self._gate_task)
if self._gate_task:
await self.cancel_task(self._gate_task)
self._gate_task = None
async def _gate_task_handler(self):
while True:

View File

@@ -342,6 +342,7 @@ class OutputGate(FrameProcessor):
self._gate_open = start_open
self._frames_buffer = []
self._notifier = notifier
self._gate_task = None
def close_gate(self):
self._gate_open = False
@@ -382,10 +383,13 @@ class OutputGate(FrameProcessor):
async def _start(self):
self._frames_buffer = []
self._gate_task = self.create_task(self._gate_task_handler())
if not self._gate_task:
self._gate_task = self.create_task(self._gate_task_handler())
async def _stop(self):
await self.cancel_task(self._gate_task)
if self._gate_task:
await self.cancel_task(self._gate_task)
self._gate_task = None
async def _gate_task_handler(self):
while True:

View File

@@ -25,10 +25,8 @@ from pipecat.frames.frames import (
InputAudioRawFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMMessagesFrame,
StartFrame,
StartInterruptionFrame,
StopInterruptionFrame,
SystemFrame,
TextFrame,
TranscriptionFrame,
@@ -555,6 +553,7 @@ class OutputGate(FrameProcessor):
self._notifier = notifier
self._context = context
self._transcription_buffer = user_transcription_buffer
self._gate_task = None
def close_gate(self):
self._gate_open = False
@@ -602,10 +601,13 @@ class OutputGate(FrameProcessor):
async def _start(self):
self._frames_buffer = []
self._gate_task = self.create_task(self._gate_task_handler())
if not self._gate_task:
self._gate_task = self.create_task(self._gate_task_handler())
async def _stop(self):
await self.cancel_task(self._gate_task)
if self._gate_task:
await self.cancel_task(self._gate_task)
self._gate_task = None
async def _gate_task_handler(self):
while True:

View File

@@ -23,7 +23,7 @@ from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.clocks.base_clock import BaseClock
from pipecat.metrics.metrics import MetricsData
from pipecat.transcriptions.language import Language
from pipecat.utils.asyncio import TaskManager
from pipecat.utils.asyncio import BaseTaskManager
from pipecat.utils.time import nanoseconds_to_str
from pipecat.utils.utils import obj_count, obj_id
@@ -438,7 +438,7 @@ class StartFrame(SystemFrame):
"""This is the first frame that should be pushed down a pipeline."""
clock: BaseClock
task_manager: TaskManager
task_manager: BaseTaskManager
audio_in_sample_rate: int = 16000
audio_out_sample_rate: int = 24000
allow_interruptions: bool = False
@@ -513,9 +513,9 @@ class CancelTaskFrame(SystemFrame):
@dataclass
class StopTaskFrame(SystemFrame):
"""Indicates that a pipeline task should be stopped but that the pipeline
processors should be kept in a running state. This is normally queued from
the pipeline task.
"""This is used to notify the pipeline task that it should be stopped as
soon as possible (flushing all the queued frames) but that the pipeline
processors should be kept in a running state.
"""
@@ -722,6 +722,17 @@ class EndFrame(ControlFrame):
pass
@dataclass
class StopFrame(ControlFrame):
"""Indicates that a pipeline should be stopped but that the pipeline
processors should be kept in a running state. This is normally queued from
the pipeline task.
"""
pass
@dataclass
class LLMFullResponseStartFrame(ControlFrame):
"""Used to indicate the beginning of an LLM response. Following by one or

View File

@@ -81,6 +81,8 @@ class ParallelPipeline(BasePipeline):
self._seen_ids = set()
self._endframe_counter: Dict[int, int] = {}
self._up_task = None
self._down_task = None
self._up_queue = asyncio.Queue()
self._down_queue = asyncio.Queue()
@@ -150,19 +152,30 @@ class ParallelPipeline(BasePipeline):
await self._create_tasks()
async def _stop(self):
# The up task doesn't receive an EndFrame, so we just cancel it.
await self.cancel_task(self._up_task)
# The down tasks waits for the last EndFrame sent by the internal
# pipelines.
await self._down_task
if self._up_task:
# The up task doesn't receive an EndFrame, so we just cancel it.
await self.cancel_task(self._up_task)
self._up_task = None
if self._down_task:
# The down tasks waits for the last EndFrame sent by the internal
# pipelines.
await self._down_task
self._down_task = None
async def _cancel(self):
await self.cancel_task(self._up_task)
await self.cancel_task(self._down_task)
if self._up_task:
await self.cancel_task(self._up_task)
self._up_task = None
if self._down_task:
await self.cancel_task(self._down_task)
self._down_task = None
async def _create_tasks(self):
self._up_task = self.create_task(self._process_up_queue())
self._down_task = self.create_task(self._process_down_queue())
if not self._up_task:
self._up_task = self.create_task(self._process_up_queue())
if not self._down_task:
self._down_task = self.create_task(self._process_down_queue())
async def _drain_queues(self):
while not self._up_queue.empty:

View File

@@ -5,7 +5,7 @@
#
import asyncio
from typing import Any, AsyncIterable, Dict, Iterable, List
from typing import Any, AsyncIterable, Dict, Iterable, List, Optional
from loguru import logger
from pydantic import BaseModel, ConfigDict
@@ -22,6 +22,7 @@ from pipecat.frames.frames import (
HeartbeatFrame,
MetricsFrame,
StartFrame,
StopFrame,
StopTaskFrame,
)
from pipecat.metrics.metrics import ProcessingMetricsData, TTFBMetricsData
@@ -30,7 +31,7 @@ from pipecat.pipeline.base_pipeline import BasePipeline
from pipecat.pipeline.base_task import BaseTask
from pipecat.pipeline.task_observer import TaskObserver
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.utils.asyncio import TaskManager
from pipecat.utils.asyncio import BaseTaskManager, TaskManager
from pipecat.utils.utils import obj_count, obj_id
HEARTBEAT_SECONDS = 1.0
@@ -133,6 +134,7 @@ class PipelineTask(BaseTask):
params: PipelineParams = PipelineParams(),
observers: List[BaseObserver] = [],
clock: BaseClock = SystemClock(),
task_manager: Optional[BaseTaskManager] = None,
check_dangling_tasks: bool = True,
):
self._id: int = obj_id()
@@ -163,9 +165,9 @@ class PipelineTask(BaseTask):
# This is the heartbeat queue. When a heartbeat frame is received in the
# down queue we add it to the heartbeat queue for processing.
self._heartbeat_queue = asyncio.Queue()
# This event is used to indicate an EndFrame has been received in the
# down queue.
self._endframe_event = asyncio.Event()
# This event is used to indicate a finalize frame (e.g. EndFrame,
# StopFrame) has been received in the down queue.
self._pipeline_end_event = asyncio.Event()
self._source = PipelineTaskSource(self._up_queue)
self._source.link(pipeline)
@@ -173,7 +175,7 @@ class PipelineTask(BaseTask):
self._sink = PipelineTaskSink(self._down_queue)
pipeline.link(self._sink)
self._task_manager = TaskManager()
self._task_manager = task_manager or TaskManager()
self._observer = TaskObserver(observers=observers, task_manager=self._task_manager)
@@ -224,9 +226,12 @@ class PipelineTask(BaseTask):
"""Starts and manages the pipeline execution until completion or cancellation."""
if self.has_finished():
return
cleanup_pipeline = True
try:
push_task = await self._create_tasks()
await self._task_manager.wait_for_task(push_task)
# We have already cleaned up the pipeline inside the task.
cleanup_pipeline = False
except asyncio.CancelledError:
# We are awaiting on the push task and it might be cancelled
# (e.g. Ctrl-C). This means we will get a CancelledError here as
@@ -234,7 +239,7 @@ class PipelineTask(BaseTask):
# awaiting a task.
pass
await self._cancel_tasks()
await self._cleanup()
await self._cleanup(cleanup_pipeline)
if self._check_dangling_tasks:
self._print_dangling_tasks()
self._finished = True
@@ -305,13 +310,14 @@ class PipelineTask(BaseTask):
data.append(ProcessingMetricsData(processor=p.name, value=0.0))
return MetricsFrame(data=data)
async def _wait_for_endframe(self):
await self._endframe_event.wait()
self._endframe_event.clear()
async def _wait_for_pipeline_end(self):
await self._pipeline_end_event.wait()
self._pipeline_end_event.clear()
async def _cleanup(self):
async def _cleanup(self, cleanup_pipeline: bool):
await self._source.cleanup()
await self._pipeline.cleanup()
if cleanup_pipeline:
await self._pipeline.cleanup()
await self._sink.cleanup()
async def _process_push_queue(self):
@@ -342,18 +348,16 @@ class PipelineTask(BaseTask):
await self._source.queue_frame(self._initial_metrics_frame(), FrameDirection.DOWNSTREAM)
running = True
should_cleanup = True
cleanup_pipeline = True
while running:
frame = await self._push_queue.get()
await self._source.queue_frame(frame, FrameDirection.DOWNSTREAM)
if isinstance(frame, EndFrame):
await self._wait_for_endframe()
running = not isinstance(frame, (CancelFrame, EndFrame, StopTaskFrame))
should_cleanup = not isinstance(frame, StopTaskFrame)
if isinstance(frame, (EndFrame, StopFrame)):
await self._wait_for_pipeline_end()
running = not isinstance(frame, (CancelFrame, EndFrame, StopFrame))
cleanup_pipeline = not isinstance(frame, StopFrame)
self._push_queue.task_done()
# Cleanup only if we need to.
if should_cleanup:
await self._cleanup()
await self._cleanup(cleanup_pipeline)
async def _process_up_queue(self):
"""This is the task that processes frames coming upstream from the
@@ -371,7 +375,8 @@ class PipelineTask(BaseTask):
# Tell the task we should end right away.
await self.queue_frame(CancelFrame())
elif isinstance(frame, StopTaskFrame):
await self.queue_frame(StopTaskFrame())
# Tell the task we should stop nicely.
await self.queue_frame(StopFrame())
elif isinstance(frame, ErrorFrame):
logger.error(f"Error running app: {frame}")
if frame.fatal:
@@ -390,8 +395,8 @@ class PipelineTask(BaseTask):
"""
while True:
frame = await self._down_queue.get()
if isinstance(frame, EndFrame):
self._endframe_event.set()
if isinstance(frame, (EndFrame, StopFrame)):
self._pipeline_end_event.set()
elif isinstance(frame, HeartbeatFrame):
await self._heartbeat_queue.put(frame)
self._down_queue.task_done()

View File

@@ -12,7 +12,7 @@ from attr import dataclass
from pipecat.frames.frames import Frame
from pipecat.observers.base_observer import BaseObserver
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.utils.asyncio import TaskManager
from pipecat.utils.asyncio import BaseTaskManager
from pipecat.utils.utils import obj_count, obj_id
@@ -55,7 +55,7 @@ class TaskObserver(BaseObserver):
"""
def __init__(self, *, observers: List[BaseObserver] = [], task_manager: TaskManager):
def __init__(self, *, observers: List[BaseObserver] = [], task_manager: BaseTaskManager):
self._id: int = obj_id()
self._name: str = f"{self.__class__.__name__}#{obj_count(self)}"
self._observers = observers

View File

@@ -21,6 +21,7 @@ class GatedOpenAILLMContextAggregator(FrameProcessor):
self._notifier = notifier
self._start_open = start_open
self._last_context_frame = None
self._gate_task = None
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
@@ -41,10 +42,13 @@ class GatedOpenAILLMContextAggregator(FrameProcessor):
await self.push_frame(frame, direction)
async def _start(self):
self._gate_task = self.create_task(self._gate_task_handler())
if not self._gate_task:
self._gate_task = self.create_task(self._gate_task_handler())
async def _stop(self):
await self.cancel_task(self._gate_task)
if self._gate_task:
await self.cancel_task(self._gate_task)
self._gate_task = None
async def _gate_task_handler(self):
while True:

View File

@@ -246,11 +246,15 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
await super().process_frame(frame, direction)
if isinstance(frame, StartFrame):
# Push StartFrame before start(), because we want StartFrame to be
# processed by every processor before any other frame is processed.
await self.push_frame(frame, direction)
await self._start(frame)
await self.push_frame(frame, direction)
elif isinstance(frame, EndFrame):
await self._stop(frame)
# Push EndFrame before stop(), because stop() waits on the task to
# finish and the task finishes when EndFrame is processed.
await self.push_frame(frame, direction)
await self._stop(frame)
elif isinstance(frame, CancelFrame):
await self._cancel(frame)
await self.push_frame(frame, direction)
@@ -309,7 +313,8 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
self._seen_interim_results = True
def _create_aggregation_task(self):
self._aggregation_task = self.create_task(self._aggregation_task_handler())
if not self._aggregation_task:
self._aggregation_task = self.create_task(self._aggregation_task_handler())
async def _cancel_aggregation_task(self):
if self._aggregation_task:

View File

@@ -23,7 +23,7 @@ from pipecat.frames.frames import (
)
from pipecat.metrics.metrics import LLMTokenUsage, MetricsData
from pipecat.processors.metrics.frame_processor_metrics import FrameProcessorMetrics
from pipecat.utils.asyncio import TaskManager
from pipecat.utils.asyncio import BaseTaskManager
from pipecat.utils.utils import obj_count, obj_id
@@ -52,7 +52,7 @@ class FrameProcessor:
self._clock: Optional[BaseClock] = None
# Task Manager
self._task_manager: Optional[TaskManager] = None
self._task_manager: Optional[BaseTaskManager] = None
# Other properties
self._allow_interruptions = False
@@ -192,7 +192,7 @@ class FrameProcessor:
raise Exception(f"{self} Clock is still not initialized.")
return self._clock
def get_task_manager(self) -> TaskManager:
def get_task_manager(self) -> BaseTaskManager:
if not self._task_manager:
raise Exception(f"{self} TaskManager is still not initialized.")
return self._task_manager
@@ -240,7 +240,7 @@ class FrameProcessor:
elif isinstance(frame, StopInterruptionFrame):
self._should_report_ttfb = True
elif isinstance(frame, CancelFrame):
self._cancelling = True
await self.__cancel(frame)
async def push_error(self, error: ErrorFrame):
await self.push_frame(error, FrameDirection.UPSTREAM)
@@ -275,6 +275,11 @@ class FrameProcessor:
self.__create_input_task()
self.__create_push_task()
async def __cancel(self, frame: CancelFrame):
self._cancelling = True
await self.__cancel_input_task()
await self.__cancel_push_task()
#
# Handle interruptions
#

View File

@@ -956,8 +956,10 @@ class RTVIProcessor(FrameProcessor):
await self._pipeline.cleanup()
async def _start(self, frame: StartFrame):
self._action_task = self.create_task(self._action_task_handler())
self._message_task = self.create_task(self._message_task_handler())
if not self._action_task:
self._action_task = self.create_task(self._action_task_handler())
if not self._message_task:
self._message_task = self.create_task(self._message_task_handler())
await self._call_event_handler("on_bot_started")
async def _stop(self, frame: EndFrame):

View File

@@ -30,6 +30,7 @@ class IdleFrameProcessor(FrameProcessor):
self._callback = callback
self._timeout = timeout
self._types = types
self._idle_task = None
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
@@ -49,11 +50,13 @@ class IdleFrameProcessor(FrameProcessor):
self._idle_event.set()
async def cleanup(self):
await self.cancel_task(self._idle_task)
if self._idle_task:
await self.cancel_task(self._idle_task)
def _create_idle_task(self):
self._idle_event = asyncio.Event()
self._idle_task = self.create_task(self._idle_task_handler())
if not self._idle_task:
self._idle_event = asyncio.Event()
self._idle_task = self.create_task(self._idle_task_handler())
async def _idle_task_handler(self):
while True:

View File

@@ -102,7 +102,7 @@ class UserIdleProcessor(FrameProcessor):
def _create_idle_task(self) -> None:
"""Creates the idle task if it hasn't been created yet."""
if self._idle_task is None:
if not self._idle_task:
self._idle_task = self.create_task(self._idle_task_handler())
@property
@@ -112,7 +112,7 @@ class UserIdleProcessor(FrameProcessor):
async def _stop(self) -> None:
"""Stops and cleans up the idle monitoring task."""
if self._idle_task is not None:
if self._idle_task:
await self.cancel_task(self._idle_task)
self._idle_task = None

View File

@@ -273,7 +273,7 @@ class TTSService(AIService):
async def start(self, frame: StartFrame):
await super().start(frame)
self._sample_rate = self._init_sample_rate or frame.audio_out_sample_rate
if self._push_stop_frames:
if self._push_stop_frames and not self._stop_frame_task:
self._stop_frame_task = self.create_task(self._stop_frame_handler())
async def stop(self, frame: EndFrame):
@@ -487,7 +487,8 @@ class WordTTSService(TTSService):
self.reset_word_timestamps()
def _create_words_task(self):
self._words_task = self.create_task(self._words_task_handler())
if not self._words_task:
self._words_task = self.create_task(self._words_task_handler())
async def _stop_words_task(self):
if self._words_task:
@@ -663,9 +664,10 @@ class AudioContextWordTTSService(WebsocketWordTTSService):
self._create_audio_context_task()
def _create_audio_context_task(self):
self._contexts_queue = asyncio.Queue()
self._contexts: Dict[str, asyncio.Queue] = {}
self._audio_context_task = self.create_task(self._audio_context_task_handler())
if not self._audio_context_task:
self._contexts_queue = asyncio.Queue()
self._contexts: Dict[str, asyncio.Queue] = {}
self._audio_context_task = self.create_task(self._audio_context_task_handler())
async def _stop_audio_context_task(self):
if self._audio_context_task:
@@ -841,7 +843,8 @@ class SegmentedSTTService(STTService):
async def start(self, frame: StartFrame):
await super().start(frame)
(self._content, self._wave) = self._new_wave()
if not self._wave:
(self._content, self._wave) = self._new_wave()
async def stop(self, frame: EndFrame):
await super().stop(frame)

View File

@@ -91,6 +91,9 @@ class AssemblyAISTTService(STTService):
AssemblyAI transcriber.
"""
if self._transcriber:
return
def on_open(session_opened: aai.RealtimeSessionOpened):
"""Callback for when the connection to AssemblyAI is opened."""
logger.info(f"{self}: Connected to AssemblyAI")

View File

@@ -535,6 +535,10 @@ class AzureTTSService(AzureBaseTTSService):
async def start(self, frame: StartFrame):
await super().start(frame)
if self._speech_config:
return
# Now self.sample_rate is properly initialized
self._speech_config = SpeechConfig(
subscription=self._api_key,
@@ -624,6 +628,10 @@ class AzureHttpTTSService(AzureBaseTTSService):
async def start(self, frame: StartFrame):
await super().start(frame)
if self._speech_config:
return
self._speech_config = SpeechConfig(
subscription=self._api_key,
region=self._region,
@@ -678,15 +686,22 @@ class AzureSTTService(STTService):
self._speech_config = SpeechConfig(subscription=api_key, region=region)
self._speech_config.speech_recognition_language = language
self._audio_stream = None
self._speech_recognizer = None
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
await self.start_processing_metrics()
self._audio_stream.write(audio)
if self._audio_stream:
self._audio_stream.write(audio)
await self.stop_processing_metrics()
yield None
async def start(self, frame: StartFrame):
await super().start(frame)
if self._audio_stream:
return
stream_format = AudioStreamFormat(samples_per_second=self.sample_rate, channels=1)
self._audio_stream = PushAudioInputStream(stream_format)
@@ -700,13 +715,21 @@ class AzureSTTService(STTService):
async def stop(self, frame: EndFrame):
await super().stop(frame)
self._speech_recognizer.stop_continuous_recognition_async()
self._audio_stream.close()
if self._speech_recognizer:
self._speech_recognizer.stop_continuous_recognition_async()
if self._audio_stream:
self._audio_stream.close()
async def cancel(self, frame: CancelFrame):
await super().cancel(frame)
self._speech_recognizer.stop_continuous_recognition_async()
self._audio_stream.close()
if self._speech_recognizer:
self._speech_recognizer.stop_continuous_recognition_async()
if self._audio_stream:
self._audio_stream.close()
def _on_handle_recognized(self, event):
if event.result.reason == ResultReason.RecognizedSpeech and len(event.result.text) > 0:

View File

@@ -182,8 +182,8 @@ class CartesiaTTSService(AudioContextWordTTSService):
async def _connect(self):
await self._connect_websocket()
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
if not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
async def _disconnect(self):
if self._receive_task:
@@ -194,6 +194,8 @@ class CartesiaTTSService(AudioContextWordTTSService):
async def _connect_websocket(self):
try:
if self._websocket:
return
logger.debug("Connecting to Cartesia")
self._websocket = await websockets.connect(
f"{self._url}?api_key={self._api_key}&cartesia_version={self._cartesia_version}"

View File

@@ -214,6 +214,9 @@ class ElevenLabsTTSService(InterruptibleWordTTSService):
self._started = False
self._cumulative_time = 0
self._receive_task = None
self._keepalive_task = None
def can_generate_metrics(self) -> bool:
return True
@@ -286,8 +289,11 @@ class ElevenLabsTTSService(InterruptibleWordTTSService):
async def _connect(self):
await self._connect_websocket()
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
self._keepalive_task = self.create_task(self._keepalive_task_handler())
if not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
if not self._keepalive_task:
self._keepalive_task = self.create_task(self._keepalive_task_handler())
async def _disconnect(self):
if self._receive_task:
@@ -302,6 +308,9 @@ class ElevenLabsTTSService(InterruptibleWordTTSService):
async def _connect_websocket(self):
try:
if self._websocket:
return
logger.debug("Connecting to ElevenLabs")
voice_id = self._voice_id

View File

@@ -106,7 +106,8 @@ class FishAudioTTSService(InterruptibleTTSService):
async def _connect(self):
await self._connect_websocket()
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
if not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
async def _disconnect(self):
if self._receive_task:
@@ -117,6 +118,9 @@ class FishAudioTTSService(InterruptibleTTSService):
async def _connect_websocket(self):
try:
if self._websocket:
return
logger.debug("Connecting to Fish Audio")
headers = {"Authorization": f"Bearer {self._api_key}"}
self._websocket = await websockets.connect(self._base_url, extra_headers=headers)

View File

@@ -172,27 +172,38 @@ class GladiaSTTService(STTService):
},
}
self._confidence = confidence
self._websocket = None
self._receive_task = None
def language_to_service_language(self, language: Language) -> Optional[str]:
return language_to_gladia_language(language)
async def start(self, frame: StartFrame):
await super().start(frame)
if self._websocket:
return
self._settings["sample_rate"] = self.sample_rate
response = await self._setup_gladia()
self._websocket = await websockets.connect(response["url"])
self._receive_task = self.create_task(self._receive_task_handler())
if not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler())
async def stop(self, frame: EndFrame):
await super().stop(frame)
await self._send_stop_recording()
await self._websocket.close()
await self.wait_for_task(self._receive_task)
if self._websocket:
await self._websocket.close()
self._websocket = None
if self._receive_task:
await self.wait_for_task(self._receive_task)
self._receive_task = None
async def cancel(self, frame: CancelFrame):
await super().cancel(frame)
await self._websocket.close()
await self.cancel_task(self._receive_task)
if self._receive_task:
await self.cancel_task(self._receive_task)
self._receive_task = None
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
await self.start_processing_metrics()

View File

@@ -83,6 +83,7 @@ class LmntTTSService(InterruptibleTTSService):
"format": "raw", # Use raw format for direct PCM data
}
self._started = False
self._receive_task = None
def can_generate_metrics(self) -> bool:
return True
@@ -110,7 +111,8 @@ class LmntTTSService(InterruptibleTTSService):
async def _connect(self):
await self._connect_websocket()
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
if not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
async def _disconnect(self):
if self._receive_task:
@@ -122,6 +124,9 @@ class LmntTTSService(InterruptibleTTSService):
async def _connect_websocket(self):
"""Connect to LMNT websocket."""
try:
if self._websocket:
return
logger.debug("Connecting to LMNT")
# Build initial connection message

View File

@@ -159,7 +159,8 @@ class PlayHTTTSService(InterruptibleTTSService):
async def _connect(self):
await self._connect_websocket()
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
if not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
async def _disconnect(self):
if self._receive_task:
@@ -170,6 +171,9 @@ class PlayHTTTSService(InterruptibleTTSService):
async def _connect_websocket(self):
try:
if self._websocket:
return
logger.debug("Connecting to PlayHT")
if not self._websocket_url:

View File

@@ -165,7 +165,9 @@ class RimeTTSService(AudioContextWordTTSService):
async def _connect(self):
"""Establish websocket connection and start receive task."""
await self._connect_websocket()
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
if not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
async def _disconnect(self):
"""Close websocket connection and clean up tasks."""
@@ -178,6 +180,9 @@ class RimeTTSService(AudioContextWordTTSService):
async def _connect_websocket(self):
"""Connect to Rime websocket API with configured settings."""
try:
if self._websocket:
return
params = "&".join(f"{k}={v}" for k, v in self._settings.items())
url = f"{self._url}?{params}"
headers = {"Authorization": f"Bearer {self._api_key}"}

View File

@@ -166,6 +166,9 @@ class ParakeetSTTService(STTService):
self._asr_service = riva.client.ASRService(auth)
self._queue = asyncio.Queue()
self._config = None
self._thread_task = None
self._response_task = None
def can_generate_metrics(self) -> bool:
return False
@@ -173,6 +176,9 @@ class ParakeetSTTService(STTService):
async def start(self, frame: StartFrame):
await super().start(frame)
if self._config:
return
config = riva.client.StreamingRecognitionConfig(
config=riva.client.RecognitionConfig(
encoding=riva.client.AudioEncoding.LINEAR_PCM,
@@ -205,9 +211,12 @@ class ParakeetSTTService(STTService):
self._config = config
self._thread_task = self.create_task(self._thread_task_handler())
self._response_task = self.create_task(self._response_task_handler())
self._response_queue = asyncio.Queue()
if not self._thread_task:
self._thread_task = self.create_task(self._thread_task_handler())
if not self._response_task:
self._response_queue = asyncio.Queue()
self._response_task = self.create_task(self._response_task_handler())
async def stop(self, frame: EndFrame):
await super().stop(frame)
@@ -218,8 +227,13 @@ class ParakeetSTTService(STTService):
await self._stop_tasks()
async def _stop_tasks(self):
await self.cancel_task(self._thread_task)
await self.cancel_task(self._response_task)
if self._thread_task:
await self.cancel_task(self._thread_task)
self._thread_task = None
if self._response_task:
await self.cancel_task(self._response_task)
self._response_task = None
def _response_handler(self):
responses = self._asr_service.streaming_response_generator(

View File

@@ -43,14 +43,21 @@ class SimliVideoService(FrameProcessor):
self._pipecat_resampler: AudioResampler = None
self._simli_resampler = AudioResampler("s16", "mono", 16000)
self._initialized = False
self._audio_task: asyncio.Task = None
self._video_task: asyncio.Task = None
async def _start_connection(self):
await self._simli_client.Initialize()
if not self._initialized:
await self._simli_client.Initialize()
self._initialized = True
# Create task to consume and process audio and video
self._audio_task = self.create_task(self._consume_and_process_audio())
self._video_task = self.create_task(self._consume_and_process_video())
if not self._audio_task:
self._audio_task = self.create_task(self._consume_and_process_audio())
if not self._video_task:
self._video_task = self.create_task(self._consume_and_process_video())
async def _consume_and_process_audio(self):
await self._pipecat_resampler_event.wait()
@@ -117,5 +124,7 @@ class SimliVideoService(FrameProcessor):
await self._simli_client.stop()
if self._audio_task:
await self.cancel_task(self._audio_task)
self._audio_task = None
if self._video_task:
await self.cancel_task(self._video_task)
self._video_task = None

View File

@@ -99,6 +99,10 @@ class XTTSService(TTSService):
async def start(self, frame: StartFrame):
await super().start(frame)
if self._studio_speakers:
return
async with self._aiohttp_session.get(self._settings["base_url"] + "/studio_speakers") as r:
if r.status != 200:
text = await r.text()

View File

@@ -74,7 +74,7 @@ class BaseInputTransport(FrameProcessor):
if self._params.audio_in_filter:
await self._params.audio_in_filter.start(self._sample_rate)
# Create audio input queue and task if needed.
if self._params.audio_in_enabled or self._params.vad_enabled:
if not self._audio_task and (self._params.audio_in_enabled or self._params.vad_enabled):
self._audio_in_queue = asyncio.Queue()
self._audio_task = self.create_task(self._audio_task_handler())

View File

@@ -238,10 +238,12 @@ class BaseOutputTransport(FrameProcessor):
#
def _create_sink_tasks(self):
self._sink_queue = asyncio.Queue()
self._sink_clock_queue = asyncio.PriorityQueue()
self._sink_task = self.create_task(self._sink_task_handler())
self._sink_clock_task = self.create_task(self._sink_clock_task_handler())
if not self._sink_task:
self._sink_queue = asyncio.Queue()
self._sink_task = self.create_task(self._sink_task_handler())
if not self._sink_clock_task:
self._sink_clock_queue = asyncio.PriorityQueue()
self._sink_clock_task = self.create_task(self._sink_clock_task_handler())
async def _cancel_sink_tasks(self):
# Stop sink tasks.
@@ -358,7 +360,7 @@ class BaseOutputTransport(FrameProcessor):
def _create_camera_task(self):
# Create camera output queue and task if needed.
if self._params.camera_out_enabled:
if not self._camera_out_task and self._params.camera_out_enabled:
self._camera_out_queue = asyncio.Queue()
self._camera_out_task = self.create_task(self._camera_out_task_handler())

View File

@@ -44,6 +44,9 @@ class LocalAudioInputTransport(BaseInputTransport):
async def start(self, frame: StartFrame):
await super().start(frame)
if self._in_stream:
return
self._sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate
num_frames = int(self._sample_rate / 100) * 2 # 20ms of audio
@@ -94,6 +97,9 @@ class LocalAudioOutputTransport(BaseOutputTransport):
async def start(self, frame: StartFrame):
await super().start(frame)
if self._out_stream:
return
self._sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
self._out_stream = self._py_audio.open(
@@ -110,6 +116,7 @@ class LocalAudioOutputTransport(BaseOutputTransport):
if self._out_stream:
self._out_stream.stop_stream()
self._out_stream.close()
self._out_stream = None
async def write_raw_audio_frames(self, frames: bytes):
if self._out_stream:

View File

@@ -51,6 +51,9 @@ class TkInputTransport(BaseInputTransport):
async def start(self, frame: StartFrame):
await super().start(frame)
if self._in_stream:
return
self._sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate
num_frames = int(self._sample_rate / 100) * 2 # 20ms of audio
@@ -70,6 +73,7 @@ class TkInputTransport(BaseInputTransport):
if self._in_stream:
self._in_stream.stop_stream()
self._in_stream.close()
self._in_stream = None
def _audio_in_callback(self, in_data, frame_count, time_info, status):
frame = InputAudioRawFrame(
@@ -86,7 +90,7 @@ class TkInputTransport(BaseInputTransport):
class TkOutputTransport(BaseOutputTransport):
_params: TkTransportParams
def __init__(self, tk_root: tk.Tk, py_audio: pyaudio.PyAudio, params: TransportParams):
def __init__(self, tk_root: tk.Tk, py_audio: pyaudio.PyAudio, params: TkTransportParams):
super().__init__(params)
self._py_audio = py_audio
self._out_stream = None
@@ -106,6 +110,9 @@ class TkOutputTransport(BaseOutputTransport):
async def start(self, frame: StartFrame):
await super().start(frame)
if self._out_stream:
return
self._sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
self._out_stream = self._py_audio.open(
@@ -122,6 +129,7 @@ class TkOutputTransport(BaseOutputTransport):
if self._out_stream:
self._out_stream.stop_stream()
self._out_stream.close()
self._out_stream = None
async def write_raw_audio_frames(self, frames: bytes):
if self._out_stream:
@@ -145,7 +153,7 @@ class TkOutputTransport(BaseOutputTransport):
class TkLocalTransport(BaseTransport):
def __init__(self, tk_root: tk.Tk, params: TransportParams):
def __init__(self, tk_root: tk.Tk, params: TkTransportParams):
super().__init__()
self._tk_root = tk_root
self._params = params

View File

@@ -115,15 +115,19 @@ class FastAPIWebsocketInputTransport(BaseInputTransport):
async def start(self, frame: StartFrame):
await super().start(frame)
await self._params.serializer.setup(frame)
if self._params.session_timeout:
if not self._monitor_websocket_task and self._params.session_timeout:
self._monitor_websocket_task = self.create_task(self._monitor_websocket())
await self._client.trigger_client_connected()
self._receive_task = self.create_task(self._receive_messages())
if not self._receive_task:
self._receive_task = self.create_task(self._receive_messages())
async def _stop_tasks(self):
if self._monitor_websocket_task:
await self.cancel_task(self._monitor_websocket_task)
await self.cancel_task(self._receive_task)
self._monitor_websocket_task = None
if self._receive_task:
await self.cancel_task(self._receive_task)
self._receive_task = None
async def stop(self, frame: EndFrame):
await super().stop(frame)

View File

@@ -30,7 +30,7 @@ from pipecat.serializers.protobuf import ProtobufFrameSerializer
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.utils.asyncio import TaskManager
from pipecat.utils.asyncio import BaseTaskManager
class WebsocketClientParams(TransportParams):
@@ -57,12 +57,12 @@ class WebsocketClientSession:
self._callbacks = callbacks
self._transport_name = transport_name
self._task_manager: Optional[TaskManager] = None
self._task_manager: Optional[BaseTaskManager] = None
self._websocket: Optional[websockets.WebSocketClientProtocol] = None
@property
def task_manager(self) -> TaskManager:
def task_manager(self) -> BaseTaskManager:
if not self._task_manager:
raise Exception(
f"{self._transport_name}::WebsocketClientSession: TaskManager not initialized (pipeline not started?)"

View File

@@ -22,7 +22,6 @@ from pipecat.frames.frames import (
OutputAudioRawFrame,
StartFrame,
StartInterruptionFrame,
TextFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
)
@@ -81,22 +80,27 @@ class WebsocketServerInputTransport(BaseInputTransport):
async def start(self, frame: StartFrame):
await super().start(frame)
await self._params.serializer.setup(frame)
self._server_task = self.create_task(self._server_task_handler())
if not self._server_task:
self._server_task = self.create_task(self._server_task_handler())
async def stop(self, frame: EndFrame):
await super().stop(frame)
self._stop_server_event.set()
if self._monitor_task:
await self.cancel_task(self._monitor_task)
self._monitor_task = None
if self._server_task:
await self.wait_for_task(self._server_task)
self._server_task = None
async def cancel(self, frame: CancelFrame):
await super().cancel(frame)
if self._monitor_task:
await self.cancel_task(self._monitor_task)
self._monitor_task = None
if self._server_task:
await self.cancel_task(self._server_task)
self._server_task = None
async def _server_task_handler(self):
logger.info(f"Starting websocket server on {self._host}:{self._port}")
@@ -116,7 +120,7 @@ class WebsocketServerInputTransport(BaseInputTransport):
await self._callbacks.on_client_connected(websocket)
# Create a task to monitor the websocket connection
if self._params.session_timeout:
if not self._monitor_task and self._params.session_timeout:
self._monitor_task = self.create_task(
self._monitor_websocket(websocket, self._params.session_timeout)
)

View File

@@ -43,7 +43,7 @@ from pipecat.transcriptions.language import Language
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.utils.asyncio import TaskManager
from pipecat.utils.asyncio import BaseTaskManager
try:
from daily import CallClient, Daily, EventHandler
@@ -293,7 +293,7 @@ class DailyTransportClient(EventHandler):
self._joined_event = asyncio.Event()
self._leave_counter = 0
self._task_manager: Optional[TaskManager] = None
self._task_manager: Optional[BaseTaskManager] = None
# We use the executor to cleanup the client. We just do it from one
# place, so only one thread is really needed.
@@ -832,6 +832,9 @@ class DailyInputTransport(BaseInputTransport):
self._video_renderers = {}
# Whether we have seen a StartFrame already.
self._initialized = False
# Task that gets audio data from a device or the network and queues it
# internally to be processed.
self._audio_in_task = None
@@ -845,13 +848,19 @@ class DailyInputTransport(BaseInputTransport):
def start_audio_in_streaming(self):
# Create audio task. It reads audio frames from Daily and push them
# internally for VAD processing.
if self._params.audio_in_enabled or self._params.vad_enabled:
if not self._audio_in_task and (self._params.audio_in_enabled or self._params.vad_enabled):
logger.debug(f"Start receiving audio")
self._audio_in_task = self.create_task(self._audio_in_task_handler())
async def start(self, frame: StartFrame):
# Parent start.
await super().start(frame)
if self._initialized:
return
self._initialized = True
# Setup client.
await self._client.setup(frame)
# Join the room.
@@ -980,9 +989,18 @@ class DailyOutputTransport(BaseOutputTransport):
self._client = client
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
# Parent start.
await super().start(frame)
if self._initialized:
return
self._initialized = True
# Setup client.
await self._client.setup(frame)
# Join the room.

View File

@@ -27,7 +27,7 @@ from pipecat.processors.frame_processor import FrameDirection
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.utils.asyncio import TaskManager
from pipecat.utils.asyncio import BaseTaskManager
try:
from livekit import rtc
@@ -88,7 +88,7 @@ class LiveKitTransportClient:
self._audio_tracks = {}
self._audio_queue = asyncio.Queue()
self._other_participant_has_joined = False
self._task_manager: Optional[TaskManager] = None
self._task_manager: Optional[BaseTaskManager] = None
@property
def participant_id(self) -> str:
@@ -360,7 +360,7 @@ class LiveKitInputTransport(BaseInputTransport):
await super().start(frame)
await self._client.setup(frame)
await self._client.connect()
if self._params.audio_in_enabled or self._params.vad_enabled:
if not self._audio_in_task and (self._params.audio_in_enabled or self._params.vad_enabled):
self._audio_in_task = self.create_task(self._audio_in_task_handler())
logger.info("LiveKitInputTransport started")

View File

@@ -5,12 +5,76 @@
#
import asyncio
from abc import ABC, abstractmethod
from typing import Coroutine, Optional, Set
from loguru import logger
class TaskManager:
class BaseTaskManager(ABC):
@abstractmethod
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
pass
@abstractmethod
def get_event_loop(self) -> asyncio.AbstractEventLoop:
pass
@abstractmethod
def create_task(self, coroutine: Coroutine, name: str) -> asyncio.Task:
"""
Creates and schedules a new asyncio Task that runs the given coroutine.
The task is added to a global set of created tasks.
Args:
loop (asyncio.AbstractEventLoop): The event loop to use for creating the task.
coroutine (Coroutine): The coroutine to be executed within the task.
name (str): The name to assign to the task for identification.
Returns:
asyncio.Task: The created task object.
"""
pass
@abstractmethod
async def wait_for_task(self, task: asyncio.Task, timeout: Optional[float] = None):
"""Wait for an asyncio.Task to complete with optional timeout handling.
This function awaits the specified asyncio.Task and handles scenarios for
timeouts, cancellations, and other exceptions. It also ensures that the task
is removed from the set of registered tasks upon completion or failure.
Args:
task (asyncio.Task): The asyncio Task to wait for.
timeout (Optional[float], optional): The maximum number of seconds
to wait for the task to complete. If None, waits indefinitely.
Defaults to None.
"""
pass
@abstractmethod
async def cancel_task(self, task: asyncio.Task, timeout: Optional[float] = None):
"""Cancels the given asyncio Task and awaits its completion with an
optional timeout.
This function removes the task from the set of registered tasks upon
completion or failure.
Args:
task (asyncio.Task): The task to be cancelled.
timeout (Optional[float]): The optional timeout in seconds to wait for the task to cancel.
"""
pass
@abstractmethod
def current_tasks(self) -> Set[asyncio.Task]:
"""Returns the list of currently created/registered tasks."""
pass
class TaskManager(BaseTaskManager):
def __init__(self) -> None:
self._tasks: Set[asyncio.Task] = set()
self._loop: Optional[asyncio.AbstractEventLoop] = None
@@ -80,6 +144,7 @@ class TaskManager:
logger.warning(f"{name}: timed out waiting for task to finish")
except asyncio.CancelledError:
logger.trace(f"{name}: unexpected task cancellation (maybe Ctrl-C?)")
raise
except Exception as e:
logger.exception(f"{name}: unexpected exception while stopping task: {e}")
finally:

View File

@@ -10,7 +10,7 @@ from openai.types.chat import (
)
from pipecat.frames.frames import LLMFullResponseEndFrame, LLMFullResponseStartFrame, TextFrame
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.openai import OpenAILLMContext, OpenAILLMContextFrame, OpenAILLMService
from pipecat.utils.test_frame_processor import TestFrameProcessor