transports: reorganize module

This commit is contained in:
Aleix Conchillo Flaqué
2025-09-02 15:03:21 -07:00
parent 7f1100bd4c
commit aeb9f1ffca
184 changed files with 7997 additions and 7767 deletions

View File

@@ -43,7 +43,7 @@ import aiohttp
from loguru import logger
from pydantic import BaseModel
from pipecat.transports.services.helpers.daily_rest import (
from pipecat.transports.daily.utils import (
DailyRESTHelper,
DailyRoomParams,
DailyRoomProperties,

View File

@@ -182,7 +182,7 @@ def _setup_webrtc_routes(app: FastAPI, esp32_mode: bool = False, host: str = "lo
try:
from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI
from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
except ImportError as e:
logger.error(f"WebRTC transport dependencies not installed: {e}")
return

View File

@@ -203,7 +203,7 @@ def get_transport_client_id(transport: BaseTransport, client: Any) -> str:
"""
# Import conditionally to avoid dependency issues
try:
from pipecat.transports.network.small_webrtc import SmallWebRTCTransport
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
if isinstance(transport, SmallWebRTCTransport):
return client.pc_id
@@ -211,7 +211,7 @@ def get_transport_client_id(transport: BaseTransport, client: Any) -> str:
pass
try:
from pipecat.transports.services.daily import DailyTransport
from pipecat.transports.daily.transport import DailyTransport
if isinstance(transport, DailyTransport):
return client["id"]
@@ -233,7 +233,7 @@ async def maybe_capture_participant_camera(
framerate: Video capture framerate. Defaults to 0 (auto).
"""
try:
from pipecat.transports.services.daily import DailyTransport
from pipecat.transports.daily.transport import DailyTransport
if isinstance(transport, DailyTransport):
await transport.capture_participant_video(
@@ -254,7 +254,7 @@ async def maybe_capture_participant_screen(
framerate: Video capture framerate. Defaults to 0 (auto).
"""
try:
from pipecat.transports.services.daily import DailyTransport
from pipecat.transports.daily.transport import DailyTransport
if isinstance(transport, DailyTransport):
await transport.capture_participant_video(
@@ -359,7 +359,7 @@ async def _create_telephony_transport(
Returns:
Configured FastAPIWebsocketTransport ready for telephony use.
"""
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketTransport
from pipecat.transports.websocket.fastapi import FastAPIWebsocketTransport
if params is None:
raise ValueError(
@@ -482,7 +482,7 @@ async def create_transport(
if isinstance(runner_args, DailyRunnerArguments):
params = _get_transport_params("daily", transport_params)
from pipecat.transports.services.daily import DailyTransport
from pipecat.transports.daily.transport import DailyTransport
return DailyTransport(
runner_args.room_url,
@@ -494,7 +494,7 @@ async def create_transport(
elif isinstance(runner_args, SmallWebRTCRunnerArguments):
params = _get_transport_params("webrtc", transport_params)
from pipecat.transports.network.small_webrtc import SmallWebRTCTransport
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
return SmallWebRTCTransport(
params=params,

View File

@@ -34,7 +34,7 @@ from pipecat.frames.frames import (
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup
from pipecat.services.ai_service import AIService
from pipecat.transports.services.tavus import TavusCallbacks, TavusParams, TavusTransportClient
from pipecat.transports.tavus.transport import TavusCallbacks, TavusParams, TavusTransportClient
class TavusVideoService(AIService):

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,410 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Daily REST Helpers.
Methods that wrap the Daily API to create rooms, check room URLs, and get meeting tokens.
"""
import time
from typing import Dict, List, Literal, Optional
from urllib.parse import urlparse
import aiohttp
from pydantic import BaseModel, Field, ValidationError
class DailyRoomSipParams(BaseModel):
"""SIP configuration parameters for Daily rooms.
Parameters:
display_name: Name shown for the SIP endpoint.
video: Whether video is enabled for SIP.
sip_mode: SIP connection mode, typically 'dial-in'.
num_endpoints: Number of allowed SIP endpoints.
codecs: Codecs to support for audio and video. If None, uses Daily defaults.
Example: {"audio": ["OPUS"], "video": ["H264"]}
"""
display_name: str = "sw-sip-dialin"
video: bool = False
sip_mode: str = "dial-in"
num_endpoints: int = 1
codecs: Optional[Dict[str, List[str]]] = None
class RecordingsBucketConfig(BaseModel):
"""Configuration for storing Daily recordings in a custom S3 bucket.
Refer to the Daily API documentation for more information:
https://docs.daily.co/guides/products/live-streaming-recording/storing-recordings-in-a-custom-s3-bucket
Parameters:
bucket_name: Name of the S3 bucket for storing recordings.
bucket_region: AWS region where the S3 bucket is located.
assume_role_arn: ARN of the IAM role to assume for S3 access.
allow_api_access: Whether to allow API access to the recordings.
"""
bucket_name: str
bucket_region: str
assume_role_arn: str
allow_api_access: bool = False
class TranscriptionBucketConfig(BaseModel):
"""Configuration for storing Daily transcription in a custom S3 bucket.
Refer to the Daily API documentation for more information:
https://docs.daily.co/guides/products/live-streaming-recording/storing-recordings-in-a-custom-s3-bucket
Parameters:
bucket_name: Name of the S3 bucket for storing transcription.
bucket_region: AWS region where the S3 bucket is located.
assume_role_arn: ARN of the IAM role to assume for S3 access.
allow_api_access: Whether to allow API access to the transcription.
"""
bucket_name: str
bucket_region: str
assume_role_arn: str
allow_api_access: bool = False
class DailyRoomProperties(BaseModel, extra="allow"):
"""Properties for configuring a Daily room.
Reference: https://docs.daily.co/reference/rest-api/rooms/create-room#properties
Parameters:
exp: Optional Unix epoch timestamp for room expiration (e.g., time.time() + 300 for 5 minutes).
enable_chat: Whether chat is enabled in the room.
enable_prejoin_ui: Whether the pre-join UI is enabled.
enable_emoji_reactions: Whether emoji reactions are enabled.
eject_at_room_exp: Whether to remove participants when room expires.
enable_dialout: Whether SIP dial-out is enabled.
enable_recording: Recording settings ('cloud', 'local', 'raw-tracks').
enable_transcription_storage: Whether transcription storage is enabled.
geo: Geographic region for room.
max_participants: Maximum number of participants allowed in the room.
recordings_bucket: Configuration for custom S3 bucket recordings.
transcription_bucket: Configuration for custom S3 bucket transcription.
sip: SIP configuration parameters.
sip_uri: SIP URI information returned by Daily.
start_video_off: Whether video is off by default.
"""
exp: Optional[float] = None
enable_chat: bool = False
enable_prejoin_ui: bool = False
enable_emoji_reactions: bool = False
eject_at_room_exp: bool = False
enable_dialout: Optional[bool] = None
enable_recording: Optional[Literal["cloud", "local", "raw-tracks"]] = None
enable_transcription_storage: Optional[bool] = None
geo: Optional[str] = None
max_participants: Optional[int] = None
recordings_bucket: Optional[RecordingsBucketConfig] = None
transcription_bucket: Optional[TranscriptionBucketConfig] = None
sip: Optional[DailyRoomSipParams] = None
sip_uri: Optional[dict] = None
start_video_off: bool = False
@property
def sip_endpoint(self) -> str:
"""Get the SIP endpoint URI if available.
Returns:
SIP endpoint URI or empty string if not available.
"""
if not self.sip_uri:
return ""
else:
return "sip:%s" % self.sip_uri["endpoint"]
class DailyRoomParams(BaseModel):
"""Parameters for creating a Daily room.
Parameters:
name: Optional custom name for the room.
privacy: Room privacy setting ('private' or 'public').
properties: Room configuration properties.
"""
name: Optional[str] = None
privacy: Literal["private", "public"] = "public"
properties: DailyRoomProperties = Field(default_factory=DailyRoomProperties)
class DailyRoomObject(BaseModel):
"""Represents a Daily room returned by the API.
Parameters:
id: Unique room identifier.
name: Room name.
api_created: Whether room was created via API.
privacy: Room privacy setting ('private' or 'public').
url: Full URL for joining the room.
created_at: Timestamp of room creation in ISO 8601 format (e.g., "2019-01-26T09:01:22.000Z").
config: Room configuration properties.
"""
id: str
name: str
api_created: bool
privacy: str
url: str
created_at: str
config: DailyRoomProperties
class DailyMeetingTokenProperties(BaseModel):
"""Properties for configuring a Daily meeting token.
Refer to the Daily API documentation for more information:
https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token#properties
Parameters:
room_name: The room for which this token is valid. If not set, the token is valid for all rooms in your domain.
eject_at_token_exp: If True, the user will be ejected from the room when the token expires.
eject_after_elapsed: The number of seconds after which the user will be ejected from the room.
nbf: Not before timestamp - users cannot join with this token before this time.
exp: Expiration time (unix timestamp in seconds). Strongly recommended for security.
is_owner: If True, the token will grant owner privileges in the room.
user_name: The name of the user. This will be added to the token payload.
user_id: A unique identifier for the user. This will be added to the token payload.
enable_screenshare: If True, the user will be able to share their screen.
start_video_off: If True, the user's video will be turned off when they join the room.
start_audio_off: If True, the user's audio will be turned off when they join the room.
enable_recording: Recording settings for the token. Must be one of 'cloud', 'local' or 'raw-tracks'.
enable_prejoin_ui: If True, the user will see the prejoin UI before joining the room.
start_cloud_recording: Start cloud recording when the user joins the room.
permissions: Specifies the initial default permissions for a non-meeting-owner participant.
"""
room_name: Optional[str] = None
eject_at_token_exp: Optional[bool] = None
eject_after_elapsed: Optional[int] = None
nbf: Optional[int] = None
exp: Optional[int] = None
is_owner: Optional[bool] = None
user_name: Optional[str] = None
user_id: Optional[str] = None
enable_screenshare: Optional[bool] = None
start_video_off: Optional[bool] = None
start_audio_off: Optional[bool] = None
enable_recording: Optional[Literal["cloud", "local", "raw-tracks"]] = None
enable_prejoin_ui: Optional[bool] = None
start_cloud_recording: Optional[bool] = None
permissions: Optional[dict] = None
class DailyMeetingTokenParams(BaseModel):
"""Parameters for creating a Daily meeting token.
Refer to the Daily API documentation for more information:
https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token#body-params
Parameters:
properties: Meeting token configuration properties.
"""
properties: DailyMeetingTokenProperties = Field(default_factory=DailyMeetingTokenProperties)
class DailyRESTHelper:
"""Helper class for interacting with Daily's REST API.
Provides methods for creating, managing, and accessing Daily rooms.
"""
def __init__(
self,
*,
daily_api_key: str,
daily_api_url: str = "https://api.daily.co/v1",
aiohttp_session: aiohttp.ClientSession,
):
"""Initialize the Daily REST helper.
Args:
daily_api_key: Your Daily API key.
daily_api_url: Daily API base URL (e.g. "https://api.daily.co/v1").
aiohttp_session: Async HTTP session for making requests.
"""
self.daily_api_key = daily_api_key
self.daily_api_url = daily_api_url
self.aiohttp_session = aiohttp_session
def get_name_from_url(self, room_url: str) -> str:
"""Extract room name from a Daily room URL.
Args:
room_url: Full Daily room URL.
Returns:
Room name portion of the URL.
"""
return urlparse(room_url).path[1:]
async def get_room_from_url(self, room_url: str) -> DailyRoomObject:
"""Get room details from a Daily room URL.
Args:
room_url: Full Daily room URL.
Returns:
DailyRoomObject instance for the room.
"""
room_name = self.get_name_from_url(room_url)
return await self._get_room_from_name(room_name)
async def create_room(self, params: DailyRoomParams) -> DailyRoomObject:
"""Create a new Daily room.
Args:
params: Room configuration parameters.
Returns:
DailyRoomObject instance for the created room.
Raises:
Exception: If room creation fails or response is invalid.
"""
headers = {"Authorization": f"Bearer {self.daily_api_key}"}
json = params.model_dump(exclude_none=True)
async with self.aiohttp_session.post(
f"{self.daily_api_url}/rooms", headers=headers, json=json
) as r:
if r.status != 200:
text = await r.text()
raise Exception(f"Unable to create room (status: {r.status}): {text}")
data = await r.json()
try:
room = DailyRoomObject(**data)
except ValidationError as e:
raise Exception(f"Invalid response: {e}")
return room
async def get_token(
self,
room_url: str,
expiry_time: float = 60 * 60,
eject_at_token_exp: bool = False,
owner: bool = True,
params: Optional[DailyMeetingTokenParams] = None,
) -> str:
"""Generate a meeting token for user to join a Daily room.
Args:
room_url: Daily room URL.
expiry_time: Token validity duration in seconds (default: 1 hour).
eject_at_token_exp: Whether to eject user when token expires.
owner: Whether token has owner privileges.
params: Optional additional token properties. Note that room_name,
exp, and is_owner will be set based on the other function
parameters regardless of values in params.
Returns:
Meeting token.
Raises:
Exception: If token generation fails or room URL is missing.
"""
if not room_url:
raise Exception(
"No Daily room specified. You must specify a Daily room in order a token to be generated."
)
expiration: int = int(time.time() + expiry_time)
room_name = self.get_name_from_url(room_url)
headers = {"Authorization": f"Bearer {self.daily_api_key}"}
if params is None:
params = DailyMeetingTokenParams(
properties=DailyMeetingTokenProperties(
room_name=room_name,
is_owner=owner,
exp=expiration,
eject_at_token_exp=eject_at_token_exp,
)
)
else:
params.properties.room_name = room_name
params.properties.exp = expiration
params.properties.eject_at_token_exp = eject_at_token_exp
params.properties.is_owner = owner
json = params.model_dump(exclude_none=True)
async with self.aiohttp_session.post(
f"{self.daily_api_url}/meeting-tokens", headers=headers, json=json
) as r:
if r.status != 200:
text = await r.text()
raise Exception(f"Failed to create meeting token (status: {r.status}): {text}")
data = await r.json()
return data["token"]
async def delete_room_by_url(self, room_url: str) -> bool:
"""Delete a room using its URL.
Args:
room_url: Daily room URL.
Returns:
True if deletion was successful.
"""
room_name = self.get_name_from_url(room_url)
return await self.delete_room_by_name(room_name)
async def delete_room_by_name(self, room_name: str) -> bool:
"""Delete a room using its name.
Args:
room_name: Name of the room to delete.
Returns:
True if deletion was successful.
Raises:
Exception: If deletion fails (excluding 404 Not Found).
"""
headers = {"Authorization": f"Bearer {self.daily_api_key}"}
async with self.aiohttp_session.delete(
f"{self.daily_api_url}/rooms/{room_name}", headers=headers
) as r:
if r.status != 200 and r.status != 404:
text = await r.text()
raise Exception(f"Failed to delete room [{room_name}] (status: {r.status}): {text}")
return True
async def _get_room_from_name(self, room_name: str) -> DailyRoomObject:
"""Internal method to get room details by name."""
headers = {"Authorization": f"Bearer {self.daily_api_key}"}
async with self.aiohttp_session.get(
f"{self.daily_api_url}/rooms/{room_name}", headers=headers
) as r:
if r.status != 200:
raise Exception(f"Room not found: {room_name}")
data = await r.json()
try:
room = DailyRoomObject(**data)
except ValidationError as e:
raise Exception(f"Invalid response: {e}")
return room

View File

@@ -0,0 +1,988 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""LiveKit transport implementation for Pipecat.
This module provides comprehensive LiveKit real-time communication integration
including audio streaming, data messaging, participant management, and room
event handling for conversational AI applications.
"""
import asyncio
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, List, Optional
from loguru import logger
from pydantic import BaseModel
from pipecat.audio.utils import create_stream_resampler
from pipecat.audio.vad.vad_analyzer import VADAnalyzer
from pipecat.frames.frames import (
AudioRawFrame,
CancelFrame,
EndFrame,
OutputAudioRawFrame,
StartFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
UserAudioRawFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup
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.task_manager import BaseTaskManager
try:
from livekit import rtc
from tenacity import retry, stop_after_attempt, wait_exponential
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use LiveKit, you need to `pip install pipecat-ai[livekit]`.")
raise Exception(f"Missing module: {e}")
@dataclass
class LiveKitTransportMessageFrame(TransportMessageFrame):
"""Frame for transport messages in LiveKit rooms.
Parameters:
participant_id: Optional ID of the participant this message is for/from.
"""
participant_id: Optional[str] = None
@dataclass
class LiveKitTransportMessageUrgentFrame(TransportMessageUrgentFrame):
"""Frame for urgent transport messages in LiveKit rooms.
Parameters:
participant_id: Optional ID of the participant this message is for/from.
"""
participant_id: Optional[str] = None
class LiveKitParams(TransportParams):
"""Configuration parameters for LiveKit transport.
Inherits all parameters from TransportParams without additional configuration.
"""
pass
class LiveKitCallbacks(BaseModel):
"""Callback handlers for LiveKit events.
Parameters:
on_connected: Called when connected to the LiveKit room.
on_disconnected: Called when disconnected from the LiveKit room.
on_participant_connected: Called when a participant joins the room.
on_participant_disconnected: Called when a participant leaves the room.
on_audio_track_subscribed: Called when an audio track is subscribed.
on_audio_track_unsubscribed: Called when an audio track is unsubscribed.
on_data_received: Called when data is received from a participant.
on_first_participant_joined: Called when the first participant joins.
"""
on_connected: Callable[[], Awaitable[None]]
on_disconnected: Callable[[], Awaitable[None]]
on_participant_connected: Callable[[str], Awaitable[None]]
on_participant_disconnected: Callable[[str], Awaitable[None]]
on_audio_track_subscribed: Callable[[str], Awaitable[None]]
on_audio_track_unsubscribed: Callable[[str], Awaitable[None]]
on_data_received: Callable[[bytes, str], Awaitable[None]]
on_first_participant_joined: Callable[[str], Awaitable[None]]
class LiveKitTransportClient:
"""Core client for interacting with LiveKit rooms.
Manages the connection to LiveKit rooms and handles all low-level API interactions
including room management, audio streaming, data messaging, and event handling.
"""
def __init__(
self,
url: str,
token: str,
room_name: str,
params: LiveKitParams,
callbacks: LiveKitCallbacks,
transport_name: str,
):
"""Initialize the LiveKit transport client.
Args:
url: LiveKit server URL to connect to.
token: Authentication token for the room.
room_name: Name of the LiveKit room to join.
params: Configuration parameters for the transport.
callbacks: Event callback handlers.
transport_name: Name identifier for the transport.
"""
self._url = url
self._token = token
self._room_name = room_name
self._params = params
self._callbacks = callbacks
self._transport_name = transport_name
self._room: Optional[rtc.Room] = None
self._participant_id: str = ""
self._connected = False
self._disconnect_counter = 0
self._audio_source: Optional[rtc.AudioSource] = None
self._audio_track: Optional[rtc.LocalAudioTrack] = None
self._audio_tracks = {}
self._audio_queue = asyncio.Queue()
self._other_participant_has_joined = False
self._task_manager: Optional[BaseTaskManager] = None
@property
def participant_id(self) -> str:
"""Get the participant ID for this client.
Returns:
The participant ID assigned by LiveKit.
"""
return self._participant_id
@property
def room(self) -> rtc.Room:
"""Get the LiveKit room instance.
Returns:
The LiveKit room object.
Raises:
Exception: If room object is not available.
"""
if not self._room:
raise Exception(f"{self}: missing room object (pipeline not started?)")
return self._room
async def setup(self, setup: FrameProcessorSetup):
"""Setup the client with task manager and room initialization.
Args:
setup: The frame processor setup configuration.
"""
if self._task_manager:
return
self._task_manager = setup.task_manager
self._room = rtc.Room(loop=self._task_manager.get_event_loop())
# Set up room event handlers
self.room.on("participant_connected")(self._on_participant_connected_wrapper)
self.room.on("participant_disconnected")(self._on_participant_disconnected_wrapper)
self.room.on("track_subscribed")(self._on_track_subscribed_wrapper)
self.room.on("track_unsubscribed")(self._on_track_unsubscribed_wrapper)
self.room.on("data_received")(self._on_data_received_wrapper)
self.room.on("connected")(self._on_connected_wrapper)
self.room.on("disconnected")(self._on_disconnected_wrapper)
async def cleanup(self):
"""Cleanup client resources."""
await self.disconnect()
async def start(self, frame: StartFrame):
"""Start the client and initialize audio components.
Args:
frame: The start frame containing initialization parameters.
"""
self._out_sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
async def connect(self):
"""Connect to the LiveKit room with retry logic."""
if self._connected:
# Increment disconnect counter if already connected.
self._disconnect_counter += 1
return
logger.info(f"Connecting to {self._room_name}")
try:
await self.room.connect(
self._url,
self._token,
options=rtc.RoomOptions(auto_subscribe=True),
)
self._connected = True
# Increment disconnect counter if we successfully connected.
self._disconnect_counter += 1
self._participant_id = self.room.local_participant.sid
logger.info(f"Connected to {self._room_name}")
# Set up audio source and track
self._audio_source = rtc.AudioSource(
self._out_sample_rate, self._params.audio_out_channels
)
self._audio_track = rtc.LocalAudioTrack.create_audio_track(
"pipecat-audio", self._audio_source
)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await self.room.local_participant.publish_track(self._audio_track, options)
await self._callbacks.on_connected()
# Check if there are already participants in the room
participants = self.get_participants()
if participants and not self._other_participant_has_joined:
self._other_participant_has_joined = True
await self._callbacks.on_first_participant_joined(participants[0])
except Exception as e:
logger.error(f"Error connecting to {self._room_name}: {e}")
raise
async def disconnect(self):
"""Disconnect from the LiveKit room."""
# Decrement leave counter when leaving.
self._disconnect_counter -= 1
if not self._connected or self._disconnect_counter > 0:
return
logger.info(f"Disconnecting from {self._room_name}")
await self.room.disconnect()
self._connected = False
logger.info(f"Disconnected from {self._room_name}")
await self._callbacks.on_disconnected()
async def send_data(self, data: bytes, participant_id: Optional[str] = None):
"""Send data to participants in the room.
Args:
data: The data bytes to send.
participant_id: Optional specific participant to send to.
"""
if not self._connected:
return
try:
if participant_id:
await self.room.local_participant.publish_data(
data, reliable=True, destination_identities=[participant_id]
)
else:
await self.room.local_participant.publish_data(data, reliable=True)
except Exception as e:
logger.error(f"Error sending data: {e}")
async def publish_audio(self, audio_frame: rtc.AudioFrame):
"""Publish an audio frame to the room.
Args:
audio_frame: The LiveKit audio frame to publish.
"""
if not self._connected or not self._audio_source:
return
try:
await self._audio_source.capture_frame(audio_frame)
except Exception as e:
logger.error(f"Error publishing audio: {e}")
def get_participants(self) -> List[str]:
"""Get list of participant IDs in the room.
Returns:
List of participant IDs.
"""
return [p.sid for p in self.room.remote_participants.values()]
async def get_participant_metadata(self, participant_id: str) -> dict:
"""Get metadata for a specific participant.
Args:
participant_id: ID of the participant to get metadata for.
Returns:
Dictionary containing participant metadata.
"""
participant = self.room.remote_participants.get(participant_id)
if participant:
return {
"id": participant.sid,
"name": participant.name,
"metadata": participant.metadata,
"is_speaking": participant.is_speaking,
}
return {}
async def set_participant_metadata(self, metadata: str):
"""Set metadata for the local participant.
Args:
metadata: Metadata string to set.
"""
await self.room.local_participant.set_metadata(metadata)
async def mute_participant(self, participant_id: str):
"""Mute a specific participant's audio tracks.
Args:
participant_id: ID of the participant to mute.
"""
participant = self.room.remote_participants.get(participant_id)
if participant:
for track in participant.tracks.values():
if track.kind == "audio":
await track.set_enabled(False)
async def unmute_participant(self, participant_id: str):
"""Unmute a specific participant's audio tracks.
Args:
participant_id: ID of the participant to unmute.
"""
participant = self.room.remote_participants.get(participant_id)
if participant:
for track in participant.tracks.values():
if track.kind == "audio":
await track.set_enabled(True)
# Wrapper methods for event handlers
def _on_participant_connected_wrapper(self, participant: rtc.RemoteParticipant):
"""Wrapper for participant connected events."""
self._task_manager.create_task(
self._async_on_participant_connected(participant),
f"{self}::_async_on_participant_connected",
)
def _on_participant_disconnected_wrapper(self, participant: rtc.RemoteParticipant):
"""Wrapper for participant disconnected events."""
self._task_manager.create_task(
self._async_on_participant_disconnected(participant),
f"{self}::_async_on_participant_disconnected",
)
def _on_track_subscribed_wrapper(
self,
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
"""Wrapper for track subscribed events."""
self._task_manager.create_task(
self._async_on_track_subscribed(track, publication, participant),
f"{self}::_async_on_track_subscribed",
)
def _on_track_unsubscribed_wrapper(
self,
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
"""Wrapper for track unsubscribed events."""
self._task_manager.create_task(
self._async_on_track_unsubscribed(track, publication, participant),
f"{self}::_async_on_track_unsubscribed",
)
def _on_data_received_wrapper(self, data: rtc.DataPacket):
"""Wrapper for data received events."""
self._task_manager.create_task(
self._async_on_data_received(data),
f"{self}::_async_on_data_received",
)
def _on_connected_wrapper(self):
"""Wrapper for connected events."""
self._task_manager.create_task(self._async_on_connected(), f"{self}::_async_on_connected")
def _on_disconnected_wrapper(self):
"""Wrapper for disconnected events."""
self._task_manager.create_task(
self._async_on_disconnected(), f"{self}::_async_on_disconnected"
)
# Async methods for event handling
async def _async_on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle participant connected events."""
logger.info(f"Participant connected: {participant.identity}")
await self._callbacks.on_participant_connected(participant.sid)
if not self._other_participant_has_joined:
self._other_participant_has_joined = True
await self._callbacks.on_first_participant_joined(participant.sid)
async def _async_on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnected events."""
logger.info(f"Participant disconnected: {participant.identity}")
await self._callbacks.on_participant_disconnected(participant.sid)
if len(self.get_participants()) == 0:
self._other_participant_has_joined = False
async def _async_on_track_subscribed(
self,
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
"""Handle track subscribed events."""
if track.kind == rtc.TrackKind.KIND_AUDIO:
logger.info(f"Audio track subscribed: {track.sid} from participant {participant.sid}")
self._audio_tracks[participant.sid] = track
audio_stream = rtc.AudioStream(track)
self._task_manager.create_task(
self._process_audio_stream(audio_stream, participant.sid),
f"{self}::_process_audio_stream",
)
await self._callbacks.on_audio_track_subscribed(participant.sid)
async def _async_on_track_unsubscribed(
self,
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
"""Handle track unsubscribed events."""
logger.info(f"Track unsubscribed: {publication.sid} from {participant.identity}")
if track.kind == rtc.TrackKind.KIND_AUDIO:
await self._callbacks.on_audio_track_unsubscribed(participant.sid)
async def _async_on_data_received(self, data: rtc.DataPacket):
"""Handle data received events."""
await self._callbacks.on_data_received(data.data, data.participant.sid)
async def _async_on_connected(self):
"""Handle connected events."""
await self._callbacks.on_connected()
async def _async_on_disconnected(self, reason=None):
"""Handle disconnected events."""
self._connected = False
logger.info(f"Disconnected from {self._room_name}. Reason: {reason}")
await self._callbacks.on_disconnected()
async def _process_audio_stream(self, audio_stream: rtc.AudioStream, participant_id: str):
"""Process incoming audio stream from a participant."""
logger.info(f"Started processing audio stream for participant {participant_id}")
async for event in audio_stream:
if isinstance(event, rtc.AudioFrameEvent):
await self._audio_queue.put((event, participant_id))
else:
logger.warning(f"Received unexpected event type: {type(event)}")
async def get_next_audio_frame(self):
"""Get the next audio frame from the queue."""
while True:
frame, participant_id = await self._audio_queue.get()
yield frame, participant_id
def __str__(self):
"""String representation of the LiveKit transport client."""
return f"{self._transport_name}::LiveKitTransportClient"
class LiveKitInputTransport(BaseInputTransport):
"""Handles incoming media streams and events from LiveKit rooms.
Processes incoming audio streams from room participants and forwards them
as Pipecat frames, including audio resampling and VAD integration.
"""
def __init__(
self,
transport: BaseTransport,
client: LiveKitTransportClient,
params: LiveKitParams,
**kwargs,
):
"""Initialize the LiveKit input transport.
Args:
transport: The parent transport instance.
client: LiveKitTransportClient instance.
params: Configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._client = client
self._audio_in_task = None
self._vad_analyzer: Optional[VADAnalyzer] = params.vad_analyzer
self._resampler = create_stream_resampler()
# Whether we have seen a StartFrame already.
self._initialized = False
@property
def vad_analyzer(self) -> Optional[VADAnalyzer]:
"""Get the Voice Activity Detection analyzer.
Returns:
The VAD analyzer instance if configured.
"""
return self._vad_analyzer
async def start(self, frame: StartFrame):
"""Start the input transport and connect to LiveKit room.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.start(frame)
await self._client.connect()
if not self._audio_in_task and self._params.audio_in_enabled:
self._audio_in_task = self.create_task(self._audio_in_task_handler())
await self.set_transport_ready(frame)
logger.info("LiveKitInputTransport started")
async def stop(self, frame: EndFrame):
"""Stop the input transport and disconnect from LiveKit room.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.disconnect()
if self._audio_in_task:
await self.cancel_task(self._audio_in_task)
logger.info("LiveKitInputTransport stopped")
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport and disconnect from LiveKit room.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.disconnect()
if self._audio_in_task and self._params.audio_in_enabled:
await self.cancel_task(self._audio_in_task)
async def setup(self, setup: FrameProcessorSetup):
"""Setup the input transport with shared client setup.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._client.setup(setup)
async def cleanup(self):
"""Cleanup input transport and shared resources."""
await super().cleanup()
await self._transport.cleanup()
async def push_app_message(self, message: Any, sender: str):
"""Push an application message as an urgent transport frame.
Args:
message: The message data to send.
sender: ID of the message sender.
"""
frame = LiveKitTransportMessageUrgentFrame(message=message, participant_id=sender)
await self.push_frame(frame)
async def _audio_in_task_handler(self):
"""Handle incoming audio frames from participants."""
logger.info("Audio input task started")
audio_iterator = self._client.get_next_audio_frame()
async for audio_data in audio_iterator:
if audio_data:
audio_frame_event, participant_id = audio_data
pipecat_audio_frame = await self._convert_livekit_audio_to_pipecat(
audio_frame_event
)
# Skip frames with no audio data
if len(pipecat_audio_frame.audio) == 0:
continue
input_audio_frame = UserAudioRawFrame(
user_id=participant_id,
audio=pipecat_audio_frame.audio,
sample_rate=pipecat_audio_frame.sample_rate,
num_channels=pipecat_audio_frame.num_channels,
)
await self.push_audio_frame(input_audio_frame)
async def _convert_livekit_audio_to_pipecat(
self, audio_frame_event: rtc.AudioFrameEvent
) -> AudioRawFrame:
"""Convert LiveKit audio frame to Pipecat audio frame."""
audio_frame = audio_frame_event.frame
audio_data = await self._resampler.resample(
audio_frame.data.tobytes(), audio_frame.sample_rate, self.sample_rate
)
return AudioRawFrame(
audio=audio_data,
sample_rate=self.sample_rate,
num_channels=audio_frame.num_channels,
)
class LiveKitOutputTransport(BaseOutputTransport):
"""Handles outgoing media streams and events to LiveKit rooms.
Manages sending audio frames and data messages to LiveKit room participants,
including audio format conversion for LiveKit compatibility.
"""
def __init__(
self,
transport: BaseTransport,
client: LiveKitTransportClient,
params: LiveKitParams,
**kwargs,
):
"""Initialize the LiveKit output transport.
Args:
transport: The parent transport instance.
client: LiveKitTransportClient instance.
params: Configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._client = client
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the output transport and connect to LiveKit room.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.start(frame)
await self._client.connect()
await self.set_transport_ready(frame)
logger.info("LiveKitOutputTransport started")
async def stop(self, frame: EndFrame):
"""Stop the output transport and disconnect from LiveKit room.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.disconnect()
logger.info("LiveKitOutputTransport stopped")
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and disconnect from LiveKit room.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.disconnect()
async def setup(self, setup: FrameProcessorSetup):
"""Setup the output transport with shared client setup.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._client.setup(setup)
async def cleanup(self):
"""Cleanup output transport and shared resources."""
await super().cleanup()
await self._transport.cleanup()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message to participants.
Args:
frame: The transport message frame to send.
"""
if isinstance(frame, (LiveKitTransportMessageFrame, LiveKitTransportMessageUrgentFrame)):
await self._client.send_data(frame.message.encode(), frame.participant_id)
else:
await self._client.send_data(frame.message.encode())
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the LiveKit room.
Args:
frame: The audio frame to write.
"""
livekit_audio = self._convert_pipecat_audio_to_livekit(frame.audio)
await self._client.publish_audio(livekit_audio)
def _convert_pipecat_audio_to_livekit(self, pipecat_audio: bytes) -> rtc.AudioFrame:
"""Convert Pipecat audio data to LiveKit audio frame."""
bytes_per_sample = 2 # Assuming 16-bit audio
total_samples = len(pipecat_audio) // bytes_per_sample
samples_per_channel = total_samples // self._params.audio_out_channels
return rtc.AudioFrame(
data=pipecat_audio,
sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels,
samples_per_channel=samples_per_channel,
)
class LiveKitTransport(BaseTransport):
"""Transport implementation for LiveKit real-time communication.
Provides comprehensive LiveKit integration including audio streaming, data
messaging, participant management, and room event handling for conversational
AI applications.
"""
def __init__(
self,
url: str,
token: str,
room_name: str,
params: Optional[LiveKitParams] = None,
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the LiveKit transport.
Args:
url: LiveKit server URL to connect to.
token: Authentication token for the room.
room_name: Name of the LiveKit room to join.
params: Configuration parameters for the transport.
input_name: Optional name for the input transport.
output_name: Optional name for the output transport.
"""
super().__init__(input_name=input_name, output_name=output_name)
callbacks = LiveKitCallbacks(
on_connected=self._on_connected,
on_disconnected=self._on_disconnected,
on_participant_connected=self._on_participant_connected,
on_participant_disconnected=self._on_participant_disconnected,
on_audio_track_subscribed=self._on_audio_track_subscribed,
on_audio_track_unsubscribed=self._on_audio_track_unsubscribed,
on_data_received=self._on_data_received,
on_first_participant_joined=self._on_first_participant_joined,
)
self._params = params or LiveKitParams()
self._client = LiveKitTransportClient(
url, token, room_name, self._params, callbacks, self.name
)
self._input: Optional[LiveKitInputTransport] = None
self._output: Optional[LiveKitOutputTransport] = None
self._register_event_handler("on_connected")
self._register_event_handler("on_disconnected")
self._register_event_handler("on_participant_connected")
self._register_event_handler("on_participant_disconnected")
self._register_event_handler("on_audio_track_subscribed")
self._register_event_handler("on_audio_track_unsubscribed")
self._register_event_handler("on_data_received")
self._register_event_handler("on_first_participant_joined")
self._register_event_handler("on_participant_left")
self._register_event_handler("on_call_state_updated")
def input(self) -> LiveKitInputTransport:
"""Get the input transport for receiving media and events.
Returns:
The LiveKit input transport instance.
"""
if not self._input:
self._input = LiveKitInputTransport(
self, self._client, self._params, name=self._input_name
)
return self._input
def output(self) -> LiveKitOutputTransport:
"""Get the output transport for sending media and events.
Returns:
The LiveKit output transport instance.
"""
if not self._output:
self._output = LiveKitOutputTransport(
self, self._client, self._params, name=self._output_name
)
return self._output
@property
def participant_id(self) -> str:
"""Get the participant ID for this transport.
Returns:
The participant ID assigned by LiveKit.
"""
return self._client.participant_id
async def send_audio(self, frame: OutputAudioRawFrame):
"""Send an audio frame to the LiveKit room.
Args:
frame: The audio frame to send.
"""
if self._output:
await self._output.queue_frame(frame, FrameDirection.DOWNSTREAM)
def get_participants(self) -> List[str]:
"""Get list of participant IDs in the room.
Returns:
List of participant IDs.
"""
return self._client.get_participants()
async def get_participant_metadata(self, participant_id: str) -> dict:
"""Get metadata for a specific participant.
Args:
participant_id: ID of the participant to get metadata for.
Returns:
Dictionary containing participant metadata.
"""
return await self._client.get_participant_metadata(participant_id)
async def set_metadata(self, metadata: str):
"""Set metadata for the local participant.
Args:
metadata: Metadata string to set.
"""
await self._client.set_participant_metadata(metadata)
async def mute_participant(self, participant_id: str):
"""Mute a specific participant's audio tracks.
Args:
participant_id: ID of the participant to mute.
"""
await self._client.mute_participant(participant_id)
async def unmute_participant(self, participant_id: str):
"""Unmute a specific participant's audio tracks.
Args:
participant_id: ID of the participant to unmute.
"""
await self._client.unmute_participant(participant_id)
async def _on_connected(self):
"""Handle room connected events."""
await self._call_event_handler("on_connected")
async def _on_disconnected(self):
"""Handle room disconnected events."""
await self._call_event_handler("on_disconnected")
async def _on_participant_connected(self, participant_id: str):
"""Handle participant connected events."""
await self._call_event_handler("on_participant_connected", participant_id)
async def _on_participant_disconnected(self, participant_id: str):
"""Handle participant disconnected events."""
await self._call_event_handler("on_participant_disconnected", participant_id)
await self._call_event_handler("on_participant_left", participant_id, "disconnected")
async def _on_audio_track_subscribed(self, participant_id: str):
"""Handle audio track subscribed events."""
await self._call_event_handler("on_audio_track_subscribed", participant_id)
participant = self._client.room.remote_participants.get(participant_id)
if participant:
for publication in participant.audio_tracks.values():
self._client._on_track_subscribed_wrapper(
publication.track, publication, participant
)
async def _on_audio_track_unsubscribed(self, participant_id: str):
"""Handle audio track unsubscribed events."""
await self._call_event_handler("on_audio_track_unsubscribed", participant_id)
async def _on_data_received(self, data: bytes, participant_id: str):
"""Handle data received events."""
if self._input:
await self._input.push_app_message(data.decode(), participant_id)
await self._call_event_handler("on_data_received", data, participant_id)
async def send_message(self, message: str, participant_id: Optional[str] = None):
"""Send a message to participants in the room.
Args:
message: The message string to send.
participant_id: Optional specific participant to send to.
"""
if self._output:
frame = LiveKitTransportMessageFrame(message=message, participant_id=participant_id)
await self._output.send_message(frame)
async def send_message_urgent(self, message: str, participant_id: Optional[str] = None):
"""Send an urgent message to participants in the room.
Args:
message: The urgent message string to send.
participant_id: Optional specific participant to send to.
"""
if self._output:
frame = LiveKitTransportMessageUrgentFrame(
message=message, participant_id=participant_id
)
await self._output.send_message(frame)
async def on_room_event(self, event):
"""Handle room events.
Args:
event: The room event to handle.
"""
# Handle room events
pass
async def on_participant_event(self, event):
"""Handle participant events.
Args:
event: The participant event to handle.
"""
# Handle participant events
pass
async def on_track_event(self, event):
"""Handle track events.
Args:
event: The track event to handle.
"""
# Handle track events
pass
async def _on_call_state_updated(self, state: str):
"""Handle call state update events."""
await self._call_event_handler("on_call_state_updated", self, state)
async def _on_first_participant_joined(self, participant_id: str):
"""Handle first participant joined events."""
await self._call_event_handler("on_first_participant_joined", participant_id)

View File

@@ -11,537 +11,15 @@ using FastAPI and WebSocket connections. Supports binary and text serialization
with configurable session timeouts and WAV header generation.
"""
import asyncio
import io
import time
import typing
import wave
from typing import Awaitable, Callable, Optional
import warnings
from loguru import logger
from pydantic import BaseModel
from pipecat.transports.websocket.fastapi import *
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
StartFrame,
StartInterruptionFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializerType
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
try:
from fastapi import WebSocket
from starlette.websockets import WebSocketState
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error(
"In order to use FastAPI websockets, you need to `pip install pipecat-ai[websocket]`."
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Module `pipecat.transports.network.fastapi_websocket` is deprecated, "
"use `pipecat.transports.websocket.fastapi` instead.",
DeprecationWarning,
stacklevel=2,
)
raise Exception(f"Missing module: {e}")
class FastAPIWebsocketParams(TransportParams):
"""Configuration parameters for FastAPI WebSocket transport.
Parameters:
add_wav_header: Whether to add WAV headers to audio frames.
serializer: Frame serializer for encoding/decoding messages.
session_timeout: Session timeout in seconds, None for no timeout.
"""
add_wav_header: bool = False
serializer: Optional[FrameSerializer] = None
session_timeout: Optional[int] = None
class FastAPIWebsocketCallbacks(BaseModel):
"""Callback functions for WebSocket events.
Parameters:
on_client_connected: Called when a client connects to the WebSocket.
on_client_disconnected: Called when a client disconnects from the WebSocket.
on_session_timeout: Called when a session timeout occurs.
"""
on_client_connected: Callable[[WebSocket], Awaitable[None]]
on_client_disconnected: Callable[[WebSocket], Awaitable[None]]
on_session_timeout: Callable[[WebSocket], Awaitable[None]]
class FastAPIWebsocketClient:
"""WebSocket client wrapper for handling connections and message passing.
Manages WebSocket state, message sending/receiving, and connection lifecycle
with support for both binary and text message types.
"""
def __init__(self, websocket: WebSocket, is_binary: bool, callbacks: FastAPIWebsocketCallbacks):
"""Initialize the WebSocket client.
Args:
websocket: The FastAPI WebSocket connection.
is_binary: Whether to use binary message format.
callbacks: Event callback functions.
"""
self._websocket = websocket
self._closing = False
self._is_binary = is_binary
self._callbacks = callbacks
self._leave_counter = 0
async def setup(self, _: StartFrame):
"""Set up the WebSocket client.
Args:
_: The start frame (unused).
"""
self._leave_counter += 1
def receive(self) -> typing.AsyncIterator[bytes | str]:
"""Get an async iterator for receiving WebSocket messages.
Returns:
An async iterator yielding bytes or strings based on message type.
"""
return self._websocket.iter_bytes() if self._is_binary else self._websocket.iter_text()
async def send(self, data: str | bytes):
"""Send data through the WebSocket connection.
Args:
data: The data to send (string or bytes).
"""
try:
if self._can_send():
if self._is_binary:
await self._websocket.send_bytes(data)
else:
await self._websocket.send_text(data)
except Exception as e:
logger.error(
f"{self} exception sending data: {e.__class__.__name__} ({e}), application_state: {self._websocket.application_state}"
)
# For some reason the websocket is disconnected, and we are not able to send data
# So let's properly handle it and disconnect the transport if it is not already disconnecting
if (
self._websocket.application_state == WebSocketState.DISCONNECTED
and not self.is_closing
):
logger.warning("Closing already disconnected websocket!")
self._closing = True
await self.trigger_client_disconnected()
async def disconnect(self):
"""Disconnect the WebSocket client."""
self._leave_counter -= 1
if self._leave_counter > 0:
return
if self.is_connected and not self.is_closing:
self._closing = True
try:
await self._websocket.close()
except Exception as e:
logger.error(f"{self} exception while closing the websocket: {e}")
finally:
await self.trigger_client_disconnected()
async def trigger_client_disconnected(self):
"""Trigger the client disconnected callback."""
await self._callbacks.on_client_disconnected(self._websocket)
async def trigger_client_connected(self):
"""Trigger the client connected callback."""
await self._callbacks.on_client_connected(self._websocket)
async def trigger_client_timeout(self):
"""Trigger the client timeout callback."""
await self._callbacks.on_session_timeout(self._websocket)
def _can_send(self):
"""Check if data can be sent through the WebSocket."""
return self.is_connected and not self.is_closing
@property
def is_connected(self) -> bool:
"""Check if the WebSocket is currently connected.
Returns:
True if the WebSocket is in connected state.
"""
return self._websocket.client_state == WebSocketState.CONNECTED
@property
def is_closing(self) -> bool:
"""Check if the WebSocket is currently closing.
Returns:
True if the WebSocket is in the process of closing.
"""
return self._closing
class FastAPIWebsocketInputTransport(BaseInputTransport):
"""Input transport for FastAPI WebSocket connections.
Handles incoming WebSocket messages, deserializes frames, and manages
connection monitoring with optional session timeouts.
"""
def __init__(
self,
transport: BaseTransport,
client: FastAPIWebsocketClient,
params: FastAPIWebsocketParams,
**kwargs,
):
"""Initialize the WebSocket input transport.
Args:
transport: The parent transport instance.
client: The WebSocket client wrapper.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._client = client
self._params = params
self._receive_task = None
self._monitor_websocket_task = None
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the input transport and begin message processing.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.setup(frame)
if self._params.serializer:
await self._params.serializer.setup(frame)
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()
if not self._receive_task:
self._receive_task = self.create_task(self._receive_messages())
await self.set_transport_ready(frame)
async def _stop_tasks(self):
"""Stop all running tasks."""
if self._monitor_websocket_task:
await self.cancel_task(self._monitor_websocket_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):
"""Stop the input transport and cleanup resources.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._stop_tasks()
await self._client.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport and stop all processing.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._stop_tasks()
await self._client.disconnect()
async def cleanup(self):
"""Clean up transport resources."""
await super().cleanup()
await self._transport.cleanup()
async def _receive_messages(self):
"""Main message receiving loop for WebSocket messages."""
try:
async for message in self._client.receive():
if not self._params.serializer:
continue
frame = await self._params.serializer.deserialize(message)
if not frame:
continue
if isinstance(frame, InputAudioRawFrame):
await self.push_audio_frame(frame)
else:
await self.push_frame(frame)
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
await self._client.trigger_client_disconnected()
async def _monitor_websocket(self):
"""Wait for self._params.session_timeout seconds, if the websocket is still open, trigger timeout event."""
await asyncio.sleep(self._params.session_timeout)
await self._client.trigger_client_timeout()
class FastAPIWebsocketOutputTransport(BaseOutputTransport):
"""Output transport for FastAPI WebSocket connections.
Handles outgoing frame serialization, audio streaming with timing simulation,
and WebSocket message transmission with optional WAV header generation.
"""
def __init__(
self,
transport: BaseTransport,
client: FastAPIWebsocketClient,
params: FastAPIWebsocketParams,
**kwargs,
):
"""Initialize the WebSocket output transport.
Args:
transport: The parent transport instance.
client: The WebSocket client wrapper.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._client = client
self._params = params
# write_audio_frame() is called quickly, as soon as we get audio
# (e.g. from the TTS), and since this is just a network connection we
# would be sending it to quickly. Instead, we want to block to emulate
# an audio device, this is what the send interval is. It will be
# computed on StartFrame.
self._send_interval = 0
self._next_send_time = 0
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the output transport and initialize timing.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.setup(frame)
if self._params.serializer:
await self._params.serializer.setup(frame)
self._send_interval = (self.audio_chunk_size / self.sample_rate) / 2
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport and cleanup resources.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._write_frame(frame)
await self._client.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and stop all processing.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._write_frame(frame)
await self._client.disconnect()
async def cleanup(self):
"""Clean up transport resources."""
await super().cleanup()
await self._transport.cleanup()
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process outgoing frames with special handling for interruptions.
Args:
frame: The frame to process.
direction: The direction of frame flow in the pipeline.
"""
await super().process_frame(frame, direction)
if isinstance(frame, StartInterruptionFrame):
await self._write_frame(frame)
self._next_send_time = 0
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message frame.
Args:
frame: The transport message frame to send.
"""
await self._write_frame(frame)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebSocket with timing simulation.
Args:
frame: The output audio frame to write.
"""
if self._client.is_closing or not self._client.is_connected:
return
frame = OutputAudioRawFrame(
audio=frame.audio,
sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels,
)
if self._params.add_wav_header:
with io.BytesIO() as buffer:
with wave.open(buffer, "wb") as wf:
wf.setsampwidth(2)
wf.setnchannels(frame.num_channels)
wf.setframerate(frame.sample_rate)
wf.writeframes(frame.audio)
wav_frame = OutputAudioRawFrame(
buffer.getvalue(),
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
)
frame = wav_frame
await self._write_frame(frame)
# Simulate audio playback with a sleep.
await self._write_audio_sleep()
async def _write_frame(self, frame: Frame):
"""Serialize and send a frame through the WebSocket."""
if not self._params.serializer:
return
try:
payload = await self._params.serializer.serialize(frame)
if payload:
await self._client.send(payload)
except Exception as e:
logger.error(f"{self} exception sending data: {e.__class__.__name__} ({e})")
async def _write_audio_sleep(self):
"""Simulate audio playback timing with appropriate delays."""
# Simulate a clock.
current_time = time.monotonic()
sleep_duration = max(0, self._next_send_time - current_time)
await asyncio.sleep(sleep_duration)
if sleep_duration == 0:
self._next_send_time = time.monotonic() + self._send_interval
else:
self._next_send_time += self._send_interval
class FastAPIWebsocketTransport(BaseTransport):
"""FastAPI WebSocket transport for real-time audio/video streaming.
Provides bidirectional WebSocket communication with frame serialization,
session management, and event handling for client connections and timeouts.
"""
def __init__(
self,
websocket: WebSocket,
params: FastAPIWebsocketParams,
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the FastAPI WebSocket transport.
Args:
websocket: The FastAPI WebSocket connection.
params: Transport configuration parameters.
input_name: Optional name for the input processor.
output_name: Optional name for the output processor.
"""
super().__init__(input_name=input_name, output_name=output_name)
self._params = params
self._callbacks = FastAPIWebsocketCallbacks(
on_client_connected=self._on_client_connected,
on_client_disconnected=self._on_client_disconnected,
on_session_timeout=self._on_session_timeout,
)
is_binary = False
if self._params.serializer:
is_binary = self._params.serializer.type == FrameSerializerType.BINARY
self._client = FastAPIWebsocketClient(websocket, is_binary, self._callbacks)
self._input = FastAPIWebsocketInputTransport(
self, self._client, self._params, name=self._input_name
)
self._output = FastAPIWebsocketOutputTransport(
self, self._client, self._params, name=self._output_name
)
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected")
self._register_event_handler("on_session_timeout")
def input(self) -> FastAPIWebsocketInputTransport:
"""Get the input transport processor.
Returns:
The WebSocket input transport instance.
"""
return self._input
def output(self) -> FastAPIWebsocketOutputTransport:
"""Get the output transport processor.
Returns:
The WebSocket output transport instance.
"""
return self._output
async def _on_client_connected(self, websocket):
"""Handle client connected event."""
await self._call_event_handler("on_client_connected", websocket)
async def _on_client_disconnected(self, websocket):
"""Handle client disconnected event."""
await self._call_event_handler("on_client_disconnected", websocket)
async def _on_session_timeout(self, websocket):
"""Handle session timeout event."""
await self._call_event_handler("on_session_timeout", websocket)

View File

@@ -11,925 +11,15 @@ real-time audio and video communication. It supports bidirectional media
streaming, application messaging, and client connection management.
"""
import asyncio
import fractions
import time
from collections import deque
from typing import Any, Awaitable, Callable, Optional
import numpy as np
from loguru import logger
from pydantic import BaseModel
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
OutputImageRawFrame,
SpriteFrame,
StartFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
UserImageRawFrame,
UserImageRequestFrame,
)
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.transports.network.webrtc_connection import SmallWebRTCConnection
try:
import cv2
from aiortc import VideoStreamTrack
from aiortc.mediastreams import AudioStreamTrack, MediaStreamError
from av import AudioFrame, AudioResampler, VideoFrame
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use the SmallWebRTC, you need to `pip install pipecat-ai[webrtc]`.")
raise Exception(f"Missing module: {e}")
CAM_VIDEO_SOURCE = "camera"
SCREEN_VIDEO_SOURCE = "screenVideo"
MIC_AUDIO_SOURCE = "microphone"
class SmallWebRTCCallbacks(BaseModel):
"""Callback handlers for SmallWebRTC events.
Parameters:
on_app_message: Called when an application message is received.
on_client_connected: Called when a client establishes connection.
on_client_disconnected: Called when a client disconnects.
"""
on_app_message: Callable[[Any], Awaitable[None]]
on_client_connected: Callable[[SmallWebRTCConnection], Awaitable[None]]
on_client_disconnected: Callable[[SmallWebRTCConnection], Awaitable[None]]
class RawAudioTrack(AudioStreamTrack):
"""Custom audio stream track for WebRTC output.
Handles audio frame generation and timing for WebRTC transmission,
supporting queued audio data with proper synchronization.
"""
def __init__(self, sample_rate):
"""Initialize the raw audio track.
Args:
sample_rate: The audio sample rate in Hz.
"""
super().__init__()
self._sample_rate = sample_rate
self._samples_per_10ms = sample_rate * 10 // 1000
self._bytes_per_10ms = self._samples_per_10ms * 2 # 16-bit (2 bytes per sample)
self._timestamp = 0
self._start = time.time()
# Queue of (bytes, future), broken into 10ms sub chunks as needed
self._chunk_queue = deque()
def add_audio_bytes(self, audio_bytes: bytes):
"""Add audio bytes to the buffer for transmission.
Args:
audio_bytes: Raw audio data to queue for transmission.
Returns:
A Future that completes when the data is processed.
Raises:
ValueError: If audio bytes are not a multiple of 10ms size.
"""
if len(audio_bytes) % self._bytes_per_10ms != 0:
raise ValueError("Audio bytes must be a multiple of 10ms size.")
future = asyncio.get_running_loop().create_future()
# Break input into 10ms chunks
for i in range(0, len(audio_bytes), self._bytes_per_10ms):
chunk = audio_bytes[i : i + self._bytes_per_10ms]
# Only the last chunk carries the future to be resolved once fully consumed
fut = future if i + self._bytes_per_10ms >= len(audio_bytes) else None
self._chunk_queue.append((chunk, fut))
return future
async def recv(self):
"""Return the next audio frame for WebRTC transmission.
Returns:
An AudioFrame containing the next audio data or silence.
"""
# Compute required wait time for synchronization
if self._timestamp > 0:
wait = self._start + (self._timestamp / self._sample_rate) - time.time()
if wait > 0:
await asyncio.sleep(wait)
if self._chunk_queue:
chunk, future = self._chunk_queue.popleft()
if future and not future.done():
future.set_result(True)
else:
chunk = bytes(self._bytes_per_10ms) # silence
# Convert the byte data to an ndarray of int16 samples
samples = np.frombuffer(chunk, dtype=np.int16)
# Create AudioFrame
frame = AudioFrame.from_ndarray(samples[None, :], layout="mono")
frame.sample_rate = self._sample_rate
frame.pts = self._timestamp
frame.time_base = fractions.Fraction(1, self._sample_rate)
self._timestamp += self._samples_per_10ms
return frame
class RawVideoTrack(VideoStreamTrack):
"""Custom video stream track for WebRTC output.
Handles video frame queuing and conversion for WebRTC transmission.
"""
def __init__(self, width, height):
"""Initialize the raw video track.
Args:
width: Video frame width in pixels.
height: Video frame height in pixels.
"""
super().__init__()
self._width = width
self._height = height
self._video_buffer = asyncio.Queue()
def add_video_frame(self, frame):
"""Add a video frame to the transmission buffer.
Args:
frame: The video frame to queue for transmission.
"""
self._video_buffer.put_nowait(frame)
async def recv(self):
"""Return the next video frame for WebRTC transmission.
Returns:
A VideoFrame ready for WebRTC transmission.
"""
raw_frame = await self._video_buffer.get()
# Convert bytes to NumPy array
frame_data = np.frombuffer(raw_frame.image, dtype=np.uint8).reshape(
(self._height, self._width, 3)
)
frame = VideoFrame.from_ndarray(frame_data, format="rgb24")
# Assign timestamp
frame.pts, frame.time_base = await self.next_timestamp()
return frame
class SmallWebRTCClient:
"""WebRTC client implementation for handling connections and media streams.
Manages WebRTC peer connections, audio/video streaming, and application
messaging through the SmallWebRTCConnection interface.
"""
FORMAT_CONVERSIONS = {
"yuv420p": cv2.COLOR_YUV2RGB_I420,
"yuvj420p": cv2.COLOR_YUV2RGB_I420, # OpenCV treats both the same
"nv12": cv2.COLOR_YUV2RGB_NV12,
"gray": cv2.COLOR_GRAY2RGB,
}
def __init__(self, webrtc_connection: SmallWebRTCConnection, callbacks: SmallWebRTCCallbacks):
"""Initialize the WebRTC client.
Args:
webrtc_connection: The underlying WebRTC connection handler.
callbacks: Event callbacks for connection and message handling.
"""
self._webrtc_connection = webrtc_connection
self._closing = False
self._callbacks = callbacks
self._audio_output_track = None
self._video_output_track = None
self._audio_input_track: Optional[AudioStreamTrack] = None
self._video_input_track: Optional[VideoStreamTrack] = None
self._screen_video_track: Optional[VideoStreamTrack] = None
self._params = None
self._audio_in_channels = None
self._in_sample_rate = None
self._out_sample_rate = None
self._leave_counter = 0
# We are always resampling it for 16000 if the sample_rate that we receive is bigger than that.
# otherwise we face issues with Silero VAD
self._pipecat_resampler = AudioResampler("s16", "mono", 16000)
@self._webrtc_connection.event_handler("connected")
async def on_connected(connection: SmallWebRTCConnection):
logger.debug("Peer connection established.")
await self._handle_client_connected()
@self._webrtc_connection.event_handler("disconnected")
async def on_disconnected(connection: SmallWebRTCConnection):
logger.debug("Peer connection lost.")
await self._handle_peer_disconnected()
@self._webrtc_connection.event_handler("closed")
async def on_closed(connection: SmallWebRTCConnection):
logger.debug("Client connection closed.")
await self._handle_client_closed()
@self._webrtc_connection.event_handler("app-message")
async def on_app_message(connection: SmallWebRTCConnection, message: Any):
await self._handle_app_message(message)
def _convert_frame(self, frame_array: np.ndarray, format_name: str) -> np.ndarray:
"""Convert a video frame to RGB format based on the input format.
Args:
frame_array: The input frame as a NumPy array.
format_name: The format of the input frame.
Returns:
The converted RGB frame as a NumPy array.
Raises:
ValueError: If the format is unsupported.
"""
if format_name.startswith("rgb"): # Already in RGB, no conversion needed
return frame_array
conversion_code = SmallWebRTCClient.FORMAT_CONVERSIONS.get(format_name)
if conversion_code is None:
raise ValueError(f"Unsupported format: {format_name}")
return cv2.cvtColor(frame_array, conversion_code)
async def read_video_frame(self, video_source: str):
"""Read video frames from the WebRTC connection.
Reads a video frame from the given MediaStreamTrack, converts it to RGB,
and creates an InputImageRawFrame.
Args:
video_source: Video source to capture ("camera" or "screenVideo").
Yields:
UserImageRawFrame objects containing video data from the peer.
"""
while True:
video_track = (
self._video_input_track
if video_source == CAM_VIDEO_SOURCE
else self._screen_video_track
)
if video_track is None:
await asyncio.sleep(0.01)
continue
try:
frame = await asyncio.wait_for(video_track.recv(), timeout=2.0)
except asyncio.TimeoutError:
if self._webrtc_connection.is_connected():
logger.warning("Timeout: No video frame received within the specified time.")
# self._webrtc_connection.ask_to_renegotiate()
frame = None
except MediaStreamError:
logger.warning("Received an unexpected media stream error while reading the audio.")
frame = None
if frame is None or not isinstance(frame, VideoFrame):
# If no valid frame, sleep for a bit
await asyncio.sleep(0.01)
continue
format_name = frame.format.name
# Convert frame to NumPy array in its native format
frame_array = frame.to_ndarray(format=format_name)
frame_rgb = self._convert_frame(frame_array, format_name)
image_frame = UserImageRawFrame(
user_id=self._webrtc_connection.pc_id,
image=frame_rgb.tobytes(),
size=(frame.width, frame.height),
format="RGB",
)
image_frame.transport_source = video_source
yield image_frame
async def read_audio_frame(self):
"""Read audio frames from the WebRTC connection.
Reads 20ms of audio from the given MediaStreamTrack and creates an InputAudioRawFrame.
Yields:
InputAudioRawFrame objects containing audio data from the peer.
"""
while True:
if self._audio_input_track is None:
await asyncio.sleep(0.01)
continue
try:
frame = await asyncio.wait_for(self._audio_input_track.recv(), timeout=2.0)
except asyncio.TimeoutError:
if self._webrtc_connection.is_connected():
logger.warning("Timeout: No audio frame received within the specified time.")
frame = None
except MediaStreamError:
logger.warning("Received an unexpected media stream error while reading the audio.")
frame = None
if frame is None or not isinstance(frame, AudioFrame):
# If we don't read any audio let's sleep for a little bit (i.e. busy wait).
await asyncio.sleep(0.01)
continue
if frame.sample_rate > self._in_sample_rate:
resampled_frames = self._pipecat_resampler.resample(frame)
for resampled_frame in resampled_frames:
# 16-bit PCM bytes
pcm_bytes = resampled_frame.to_ndarray().astype(np.int16).tobytes()
audio_frame = InputAudioRawFrame(
audio=pcm_bytes,
sample_rate=resampled_frame.sample_rate,
num_channels=self._audio_in_channels,
)
yield audio_frame
else:
# 16-bit PCM bytes
pcm_bytes = frame.to_ndarray().astype(np.int16).tobytes()
audio_frame = InputAudioRawFrame(
audio=pcm_bytes,
sample_rate=frame.sample_rate,
num_channels=self._audio_in_channels,
)
yield audio_frame
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebRTC connection.
Args:
frame: The audio frame to transmit.
"""
if self._can_send() and self._audio_output_track:
await self._audio_output_track.add_audio_bytes(frame.audio)
async def write_video_frame(self, frame: OutputImageRawFrame):
"""Write a video frame to the WebRTC connection.
Args:
frame: The video frame to transmit.
"""
if self._can_send() and self._video_output_track:
self._video_output_track.add_video_frame(frame)
async def setup(self, _params: TransportParams, frame):
"""Set up the client with transport parameters.
Args:
_params: Transport configuration parameters.
frame: The initialization frame containing setup data.
"""
self._audio_in_channels = _params.audio_in_channels
self._in_sample_rate = _params.audio_in_sample_rate or frame.audio_in_sample_rate
self._out_sample_rate = _params.audio_out_sample_rate or frame.audio_out_sample_rate
self._params = _params
self._leave_counter += 1
async def connect(self):
"""Establish the WebRTC connection."""
if self._webrtc_connection.is_connected():
# already initialized
return
logger.info(f"Connecting to Small WebRTC")
await self._webrtc_connection.connect()
async def disconnect(self):
"""Disconnect from the WebRTC peer."""
self._leave_counter -= 1
if self._leave_counter > 0:
return
if self.is_connected and not self.is_closing:
logger.info(f"Disconnecting to Small WebRTC")
self._closing = True
await self._webrtc_connection.disconnect()
await self._handle_peer_disconnected()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send an application message through the WebRTC connection.
Args:
frame: The message frame to send.
"""
if self._can_send():
self._webrtc_connection.send_app_message(frame.message)
async def _handle_client_connected(self):
"""Handle client connection establishment."""
# There is nothing to do here yet, the pipeline is still not ready
if not self._params:
return
self._audio_input_track = self._webrtc_connection.audio_input_track()
self._video_input_track = self._webrtc_connection.video_input_track()
self._screen_video_track = self._webrtc_connection.screen_video_input_track()
if self._params.audio_out_enabled:
self._audio_output_track = RawAudioTrack(sample_rate=self._out_sample_rate)
self._webrtc_connection.replace_audio_track(self._audio_output_track)
if self._params.video_out_enabled:
self._video_output_track = RawVideoTrack(
width=self._params.video_out_width, height=self._params.video_out_height
)
self._webrtc_connection.replace_video_track(self._video_output_track)
await self._callbacks.on_client_connected(self._webrtc_connection)
async def _handle_peer_disconnected(self):
"""Handle peer disconnection cleanup."""
self._audio_input_track = None
self._video_input_track = None
self._screen_video_track = None
self._audio_output_track = None
self._video_output_track = None
async def _handle_client_closed(self):
"""Handle client connection closure."""
self._audio_input_track = None
self._video_input_track = None
self._screen_video_track = None
self._audio_output_track = None
self._video_output_track = None
await self._callbacks.on_client_disconnected(self._webrtc_connection)
async def _handle_app_message(self, message: Any):
"""Handle incoming application messages."""
await self._callbacks.on_app_message(message)
def _can_send(self):
"""Check if the connection is ready for sending data."""
return self.is_connected and not self.is_closing
@property
def is_connected(self) -> bool:
"""Check if the WebRTC connection is established.
Returns:
True if connected to the peer.
"""
return self._webrtc_connection.is_connected()
@property
def is_closing(self) -> bool:
"""Check if the connection is in the process of closing.
Returns:
True if the connection is closing.
"""
return self._closing
class SmallWebRTCInputTransport(BaseInputTransport):
"""Input transport implementation for SmallWebRTC.
Handles incoming audio and video streams from WebRTC peers,
including user image requests and application message handling.
"""
def __init__(
self,
client: SmallWebRTCClient,
params: TransportParams,
**kwargs,
):
"""Initialize the WebRTC input transport.
Args:
client: The WebRTC client instance.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._client = client
self._params = params
self._receive_audio_task = None
self._receive_video_task = None
self._receive_screen_video_task = None
self._image_requests = {}
# Whether we have seen a StartFrame already.
self._initialized = False
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process incoming frames including user image requests.
Args:
frame: The frame to process.
direction: The direction of frame flow in the pipeline.
"""
await super().process_frame(frame, direction)
if isinstance(frame, UserImageRequestFrame):
await self.request_participant_image(frame)
async def start(self, frame: StartFrame):
"""Start the input transport and establish WebRTC connection.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.setup(self._params, frame)
await self._client.connect()
await self.set_transport_ready(frame)
if not self._receive_audio_task and self._params.audio_in_enabled:
self._receive_audio_task = self.create_task(self._receive_audio())
if not self._receive_video_task and self._params.video_in_enabled:
self._receive_video_task = self.create_task(self._receive_video(CAM_VIDEO_SOURCE))
async def _stop_tasks(self):
"""Stop all background tasks."""
if self._receive_audio_task:
await self.cancel_task(self._receive_audio_task)
self._receive_audio_task = None
if self._receive_video_task:
await self.cancel_task(self._receive_video_task)
self._receive_video_task = None
async def stop(self, frame: EndFrame):
"""Stop the input transport and disconnect from WebRTC.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._stop_tasks()
await self._client.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport and disconnect immediately.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._stop_tasks()
await self._client.disconnect()
async def _receive_audio(self):
"""Background task for receiving audio frames from WebRTC."""
try:
audio_iterator = self._client.read_audio_frame()
async for audio_frame in audio_iterator:
if audio_frame:
await self.push_audio_frame(audio_frame)
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
async def _receive_video(self, video_source: str):
"""Background task for receiving video frames from WebRTC.
Args:
video_source: Video source to capture ("camera" or "screenVideo").
"""
try:
video_iterator = self._client.read_video_frame(video_source)
async for video_frame in video_iterator:
if video_frame:
await self.push_video_frame(video_frame)
# Check if there are any pending image requests and create UserImageRawFrame
if self._image_requests:
for req_id, request_frame in list(self._image_requests.items()):
if request_frame.video_source == video_source:
# Create UserImageRawFrame using the current video frame
image_frame = UserImageRawFrame(
user_id=request_frame.user_id,
request=request_frame,
image=video_frame.image,
size=video_frame.size,
format=video_frame.format,
)
image_frame.transport_source = video_source
# Push the frame to the pipeline
await self.push_video_frame(image_frame)
# Remove from pending requests
del self._image_requests[req_id]
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
async def push_app_message(self, message: Any):
"""Push an application message into the pipeline.
Args:
message: The application message to process.
"""
logger.debug(f"Received app message inside SmallWebRTCInputTransport {message}")
frame = TransportMessageUrgentFrame(message=message)
await self.push_frame(frame)
# Add this method similar to DailyInputTransport.request_participant_image
async def request_participant_image(self, frame: UserImageRequestFrame):
"""Request an image frame from the participant's video stream.
When a UserImageRequestFrame is received, this method will store the request
and the next video frame received will be converted to a UserImageRawFrame.
Args:
frame: The user image request frame.
"""
logger.debug(f"Requesting image from participant: {frame.user_id}")
# Store the request
request_id = f"{frame.function_name}:{frame.tool_call_id}"
self._image_requests[request_id] = frame
# Default to camera if no source specified
if frame.video_source is None:
frame.video_source = CAM_VIDEO_SOURCE
# If we're not already receiving video, try to get a frame now
if (
frame.video_source == CAM_VIDEO_SOURCE
and not self._receive_video_task
and self._params.video_in_enabled
):
# Start video reception if it's not already running
self._receive_video_task = self.create_task(self._receive_video(CAM_VIDEO_SOURCE))
elif (
frame.video_source == SCREEN_VIDEO_SOURCE
and not self._receive_screen_video_task
and self._params.video_in_enabled
):
# Start screen video reception if it's not already running
self._receive_screen_video_task = self.create_task(
self._receive_video(SCREEN_VIDEO_SOURCE)
)
async def capture_participant_media(
self,
source: str = CAM_VIDEO_SOURCE,
):
"""Capture media from a specific participant.
Args:
source: Media source to capture from. ("camera", "microphone", or "screenVideo")
"""
# If we're not already receiving video, try to get a frame now
if (
source == MIC_AUDIO_SOURCE
and not self._receive_audio_task
and self._params.audio_in_enabled
):
# Start audio reception if it's not already running
self._receive_audio_task = self.create_task(self._receive_audio())
elif (
source == CAM_VIDEO_SOURCE
and not self._receive_video_task
and self._params.video_in_enabled
):
# Start video reception if it's not already running
self._receive_video_task = self.create_task(self._receive_video(CAM_VIDEO_SOURCE))
elif (
source == SCREEN_VIDEO_SOURCE
and not self._receive_screen_video_task
and self._params.video_in_enabled
):
# Start screen video reception if it's not already running
self._receive_screen_video_task = self.create_task(
self._receive_video(SCREEN_VIDEO_SOURCE)
)
class SmallWebRTCOutputTransport(BaseOutputTransport):
"""Output transport implementation for SmallWebRTC.
Handles outgoing audio and video streams to WebRTC peers,
including transport message sending.
"""
def __init__(
self,
client: SmallWebRTCClient,
params: TransportParams,
**kwargs,
):
"""Initialize the WebRTC output transport.
Args:
client: The WebRTC client instance.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._client = client
self._params = params
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the output transport and establish WebRTC connection.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.setup(self._params, frame)
await self._client.connect()
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport and disconnect from WebRTC.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and disconnect immediately.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.disconnect()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message through the WebRTC connection.
Args:
frame: The transport message frame to send.
"""
await self._client.send_message(frame)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebRTC connection.
Args:
frame: The output audio frame to transmit.
"""
await self._client.write_audio_frame(frame)
async def write_video_frame(self, frame: OutputImageRawFrame):
"""Write a video frame to the WebRTC connection.
Args:
frame: The output video frame to transmit.
"""
await self._client.write_video_frame(frame)
class SmallWebRTCTransport(BaseTransport):
"""WebRTC transport implementation for real-time communication.
Provides bidirectional audio and video streaming over WebRTC connections
with support for application messaging and connection event handling.
"""
def __init__(
self,
webrtc_connection: SmallWebRTCConnection,
params: TransportParams,
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the WebRTC transport.
Args:
webrtc_connection: The underlying WebRTC connection handler.
params: Transport configuration parameters.
input_name: Optional name for the input processor.
output_name: Optional name for the output processor.
"""
super().__init__(input_name=input_name, output_name=output_name)
self._params = params
self._callbacks = SmallWebRTCCallbacks(
on_app_message=self._on_app_message,
on_client_connected=self._on_client_connected,
on_client_disconnected=self._on_client_disconnected,
)
self._client = SmallWebRTCClient(webrtc_connection, self._callbacks)
self._input: Optional[SmallWebRTCInputTransport] = None
self._output: Optional[SmallWebRTCOutputTransport] = None
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_app_message")
self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected")
def input(self) -> SmallWebRTCInputTransport:
"""Get the input transport processor.
Returns:
The input transport for handling incoming media streams.
"""
if not self._input:
self._input = SmallWebRTCInputTransport(
self._client, self._params, name=self._input_name
)
return self._input
def output(self) -> SmallWebRTCOutputTransport:
"""Get the output transport processor.
Returns:
The output transport for handling outgoing media streams.
"""
if not self._output:
self._output = SmallWebRTCOutputTransport(
self._client, self._params, name=self._input_name
)
return self._output
async def send_image(self, frame: OutputImageRawFrame | SpriteFrame):
"""Send an image frame through the transport.
Args:
frame: The image frame to send.
"""
if self._output:
await self._output.queue_frame(frame, FrameDirection.DOWNSTREAM)
async def send_audio(self, frame: OutputAudioRawFrame):
"""Send an audio frame through the transport.
Args:
frame: The audio frame to send.
"""
if self._output:
await self._output.queue_frame(frame, FrameDirection.DOWNSTREAM)
async def _on_app_message(self, message: Any):
"""Handle incoming application messages."""
if self._input:
await self._input.push_app_message(message)
await self._call_event_handler("on_app_message", message)
async def _on_client_connected(self, webrtc_connection):
"""Handle client connection events."""
await self._call_event_handler("on_client_connected", webrtc_connection)
async def _on_client_disconnected(self, webrtc_connection):
"""Handle client disconnection events."""
await self._call_event_handler("on_client_disconnected", webrtc_connection)
async def capture_participant_video(
self,
video_source: str = CAM_VIDEO_SOURCE,
):
"""Capture video from a specific participant.
Args:
video_source: Video source to capture from ("camera" or "screenVideo").
"""
if self._input:
await self._input.capture_participant_media(source=video_source)
async def capture_participant_audio(
self,
audio_source: str = MIC_AUDIO_SOURCE,
):
"""Capture audio from a specific participant.
Args:
audio_source: Audio source to capture from. (currently, "microphone" is the only supported option)
"""
if self._input:
await self._input.capture_participant_media(source=audio_source)
import warnings
from pipecat.transports.smallwebrtc.transport import *
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Module `pipecat.transports.network.small_webrtc` is deprecated, "
"use `pipecat.transports.smallwebrtc.transport` instead.",
DeprecationWarning,
stacklevel=2,
)

View File

@@ -11,602 +11,15 @@ with support for audio/video tracks, data channels, and signaling
for real-time communication applications.
"""
import asyncio
import json
import time
from typing import Any, List, Literal, Optional, Union
import warnings
from loguru import logger
from pydantic import BaseModel, TypeAdapter
from pipecat.transports.smallwebrtc.connection import *
from pipecat.utils.base_object import BaseObject
try:
from aiortc import (
MediaStreamTrack,
RTCConfiguration,
RTCIceServer,
RTCPeerConnection,
RTCSessionDescription,
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Module `pipecat.transports.network.webrtc_connection` is deprecated, "
"use `pipecat.transports.smallwebrtc.connection` instead.",
DeprecationWarning,
stacklevel=2,
)
from aiortc.rtcrtpreceiver import RemoteStreamTrack
from av.frame import Frame
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use the SmallWebRTC, you need to `pip install pipecat-ai[webrtc]`.")
raise Exception(f"Missing module: {e}")
SIGNALLING_TYPE = "signalling"
AUDIO_TRANSCEIVER_INDEX = 0
VIDEO_TRANSCEIVER_INDEX = 1
SCREEN_VIDEO_TRANSCEIVER_INDEX = 2
class TrackStatusMessage(BaseModel):
"""Message for updating track enabled/disabled status.
Parameters:
type: Message type identifier.
receiver_index: Index of the track receiver to update.
enabled: Whether the track should be enabled or disabled.
"""
type: Literal["trackStatus"]
receiver_index: int
enabled: bool
class RenegotiateMessage(BaseModel):
"""Message requesting WebRTC renegotiation.
Parameters:
type: Message type identifier for renegotiation requests.
"""
type: Literal["renegotiate"] = "renegotiate"
class PeerLeftMessage(BaseModel):
"""Message indicating a peer has left the connection.
Parameters:
type: Message type identifier for peer departure.
"""
type: Literal["peerLeft"] = "peerLeft"
class SignallingMessage:
"""Union types for signaling message handling.
Parameters:
Inbound: Types of messages that can be received from peers.
outbound: Types of messages that can be sent to peers.
"""
Inbound = Union[TrackStatusMessage] # in case we need to add new messages in the future
outbound = Union[RenegotiateMessage]
class SmallWebRTCTrack:
"""Wrapper for WebRTC media tracks with enabled/disabled state management.
Provides additional functionality on top of aiortc MediaStreamTrack including
enable/disable control and frame discarding for audio and video streams.
"""
def __init__(self, track: MediaStreamTrack):
"""Initialize the WebRTC track wrapper.
Args:
track: The underlying MediaStreamTrack to wrap.
index: The index of the track in the transceiver (0 for mic, 1 for cam, 2 for screen)
"""
self._track = track
self._enabled = True
def set_enabled(self, enabled: bool) -> None:
"""Enable or disable the track.
Args:
enabled: Whether the track should be enabled for receiving frames.
"""
self._enabled = enabled
def is_enabled(self) -> bool:
"""Check if the track is currently enabled.
Returns:
True if the track is enabled for receiving frames.
"""
return self._enabled
async def discard_old_frames(self):
"""Discard old frames from the track queue to reduce latency."""
remote_track = self._track
if isinstance(remote_track, RemoteStreamTrack):
if not hasattr(remote_track, "_queue") or not isinstance(
remote_track._queue, asyncio.Queue
):
print("Warning: _queue does not exist or has changed in aiortc.")
return
logger.debug("Discarding old frames")
while not remote_track._queue.empty():
remote_track._queue.get_nowait() # Remove the oldest frame
remote_track._queue.task_done()
async def recv(self) -> Optional[Frame]:
"""Receive the next frame from the track.
Returns:
The next frame, except for video tracks, where it returns the frame only if the track is enabled, otherwise, returns None.
"""
if not self._enabled and self._track.kind == "video":
return None
return await self._track.recv()
def __getattr__(self, name):
"""Forward attribute access to the underlying track.
Args:
name: The attribute name to access.
Returns:
The attribute value from the underlying track.
"""
# Forward other attribute/method calls to the underlying track
return getattr(self._track, name)
# Alias so we don't need to expose RTCIceServer
IceServer = RTCIceServer
class SmallWebRTCConnection(BaseObject):
"""WebRTC connection implementation using aiortc.
Provides WebRTC peer connection functionality including ICE server configuration,
track management, data channel communication, and connection state handling
for real-time audio/video communication.
"""
def __init__(self, ice_servers: Optional[Union[List[str], List[IceServer]]] = None):
"""Initialize the WebRTC connection.
Args:
ice_servers: List of ICE servers as URLs or IceServer objects.
Raises:
TypeError: If ice_servers contains mixed types or unsupported types.
"""
super().__init__()
if not ice_servers:
self.ice_servers: List[IceServer] = []
elif all(isinstance(s, IceServer) for s in ice_servers):
self.ice_servers = ice_servers
elif all(isinstance(s, str) for s in ice_servers):
self.ice_servers = [IceServer(urls=s) for s in ice_servers]
else:
raise TypeError("ice_servers must be either List[str] or List[RTCIceServer]")
self._connect_invoked = False
self._track_map = {}
self._track_getters = {
AUDIO_TRANSCEIVER_INDEX: self.audio_input_track,
VIDEO_TRANSCEIVER_INDEX: self.video_input_track,
SCREEN_VIDEO_TRANSCEIVER_INDEX: self.screen_video_input_track,
}
self._initialize()
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("app-message")
self._register_event_handler("track-started")
self._register_event_handler("track-ended")
# connection states
self._register_event_handler("connecting")
self._register_event_handler("connected")
self._register_event_handler("disconnected")
self._register_event_handler("closed")
self._register_event_handler("failed")
self._register_event_handler("new")
@property
def pc(self) -> RTCPeerConnection:
"""Get the underlying RTCPeerConnection.
Returns:
The aiortc RTCPeerConnection instance.
"""
return self._pc
@property
def pc_id(self) -> str:
"""Get the peer connection identifier.
Returns:
The unique identifier for this peer connection.
"""
return self._pc_id
def _initialize(self):
"""Initialize the peer connection and associated components."""
logger.debug("Initializing new peer connection")
rtc_config = RTCConfiguration(iceServers=self.ice_servers)
self._answer: Optional[RTCSessionDescription] = None
self._pc = RTCPeerConnection(rtc_config)
self._pc_id = self.name
self._setup_listeners()
self._data_channel = None
self._renegotiation_in_progress = False
self._last_received_time = None
self._message_queue = []
self._pending_app_messages = []
def _setup_listeners(self):
"""Set up event listeners for the peer connection."""
@self._pc.on("datachannel")
def on_datachannel(channel):
self._data_channel = channel
# Flush queued messages once the data channel is open
@channel.on("open")
async def on_open():
logger.debug("Data channel is open, flushing queued messages")
while self._message_queue:
message = self._message_queue.pop(0)
self._data_channel.send(message)
@channel.on("message")
async def on_message(message):
try:
# aiortc does not provide any way so we can be aware when we are disconnected,
# so we are using this keep alive message as a way to implement that
if isinstance(message, str) and message.startswith("ping"):
self._last_received_time = time.time()
else:
json_message = json.loads(message)
if json_message["type"] == SIGNALLING_TYPE and json_message.get("message"):
self._handle_signalling_message(json_message["message"])
else:
if self.is_connected():
await self._call_event_handler("app-message", json_message)
else:
logger.debug("Client not connected. Queuing app-message.")
self._pending_app_messages.append(json_message)
except Exception as e:
logger.exception(f"Error parsing JSON message {message}, {e}")
# Despite the fact that aiortc provides this listener, they don't have a status for "disconnected"
# So, in case we loose connection, this event will not be triggered
@self._pc.on("connectionstatechange")
async def on_connectionstatechange():
await self._handle_new_connection_state()
# Despite the fact that aiortc provides this listener, they don't have a status for "disconnected"
# So, in case we loose connection, this event will not be triggered
@self._pc.on("iceconnectionstatechange")
async def on_iceconnectionstatechange():
logger.debug(
f"ICE connection state is {self._pc.iceConnectionState}, connection is {self._pc.connectionState}"
)
@self._pc.on("icegatheringstatechange")
async def on_icegatheringstatechange():
logger.debug(f"ICE gathering state is {self._pc.iceGatheringState}")
@self._pc.on("track")
async def on_track(track):
logger.debug(f"Track {track.kind} received")
await self._call_event_handler("track-started", track)
@track.on("ended")
async def on_ended():
logger.debug(f"Track {track.kind} ended")
await self._call_event_handler("track-ended", track)
async def _create_answer(self, sdp: str, type: str):
"""Create an SDP answer for the given offer."""
offer = RTCSessionDescription(sdp=sdp, type=type)
await self._pc.setRemoteDescription(offer)
# For some reason, aiortc is not respecting the SDP for the transceivers to be sendrcv
# so we are basically forcing it to act this way
self.force_transceivers_to_send_recv()
# this answer does not contain the ice candidates, which will be gathered later, after the setLocalDescription
logger.debug(f"Creating answer")
local_answer = await self._pc.createAnswer()
await self._pc.setLocalDescription(local_answer)
logger.debug(f"Setting the answer after the local description is created")
self._answer = self._pc.localDescription
async def initialize(self, sdp: str, type: str):
"""Initialize the connection with an SDP offer.
Args:
sdp: The SDP offer string.
type: The SDP type (usually "offer").
"""
await self._create_answer(sdp, type)
async def connect(self):
"""Connect the WebRTC peer connection and handle initial setup."""
self._connect_invoked = True
# If we already connected, trigger again the connected event
if self.is_connected():
await self._call_event_handler("connected")
logger.debug("Flushing pending app-messages")
for message in self._pending_app_messages:
await self._call_event_handler("app-message", message)
# We are renegotiating here, because likely we have loose the first video frames
# and aiortc does not handle that pretty well.
video_input_track = self.video_input_track()
if video_input_track:
await self.video_input_track().discard_old_frames()
screen_video_input_track = self.screen_video_input_track()
if screen_video_input_track:
await self.screen_video_input_track().discard_old_frames()
if video_input_track or screen_video_input_track:
# This prevents an issue where sometimes the WebRTC connection can be established
# before the bot is ready to receive video. When that happens, we can lose a couple
# of seconds of video before we received a key frame to finally start displaying it.
self.ask_to_renegotiate()
async def renegotiate(self, sdp: str, type: str, restart_pc: bool = False):
"""Renegotiate the WebRTC connection with new parameters.
Args:
sdp: The new SDP offer string.
type: The SDP type (usually "offer").
restart_pc: Whether to restart the peer connection entirely.
"""
logger.debug(f"Renegotiating {self._pc_id}")
if restart_pc:
await self._call_event_handler("disconnected")
logger.debug("Closing old peer connection")
# removing the listeners to prevent the bot from closing
self._pc.remove_all_listeners()
await self._close()
# we are initializing a new peer connection in this case.
self._initialize()
await self._create_answer(sdp, type)
# Maybe we should refactor to receive a message from the client side when the renegotiation is completed.
# or look at the peer connection listeners
# but this is good enough for now for testing.
async def delayed_task():
await asyncio.sleep(2)
self._renegotiation_in_progress = False
asyncio.create_task(delayed_task())
def force_transceivers_to_send_recv(self):
"""Force all transceivers to bidirectional send/receive mode."""
transceivers = self._pc.getTransceivers()
# For now, we only support sendrecv for camera audio and video (the first two transceivers)
for i, transceiver in enumerate(transceivers):
if i < 2: # First two transceivers (camera audio and video)
transceiver.direction = "sendrecv"
else:
transceiver.direction = "recvonly"
# logger.debug(
# f"Transceiver: {transceiver}, Mid: {transceiver.mid}, Direction: {transceiver.direction}"
# )
# logger.debug(f"Sender track: {transceiver.sender.track}")
def replace_audio_track(self, track):
"""Replace the audio track in the first transceiver.
Args:
track: The new audio track to use for sending.
"""
logger.debug(f"Replacing audio track {track.kind}")
# Transceivers always appear in creation-order for both peers
# For now we are only considering that we are going to have 02 transceivers,
# one for audio and one for video
transceivers = self._pc.getTransceivers()
if len(transceivers) > 0 and transceivers[0].sender:
transceivers[0].sender.replaceTrack(track)
else:
logger.warning("Audio transceiver not found. Cannot replace audio track.")
def replace_video_track(self, track):
"""Replace the video track in the second transceiver.
Args:
track: The new video track to use for sending.
"""
logger.debug(f"Replacing video track {track.kind}")
# Transceivers always appear in creation-order for both peers
# For now we are only considering that we are going to have 02 transceivers,
# one for audio and one for video
transceivers = self._pc.getTransceivers()
if len(transceivers) > 1 and transceivers[1].sender:
transceivers[1].sender.replaceTrack(track)
else:
logger.warning("Video transceiver not found. Cannot replace video track.")
def replace_screen_video_track(self, track):
"""Replace the screen video track in the second transceiver.
Args:
track: The new screen video track to use for sending.
"""
logger.debug(f"Replacing screen video track {track.kind}")
# Transceivers always appear in creation-order for both peers
# For now we are only considering that we are going to have 02 transceivers,
# one for audio and one for video
transceivers = self._pc.getTransceivers()
if len(transceivers) > 2 and transceivers[2].sender:
transceivers[2].sender.replaceTrack(track)
else:
logger.warning("Screen video transceiver not found. Cannot replace screen video track.")
async def disconnect(self):
"""Disconnect from the WebRTC peer connection."""
self.send_app_message({"type": SIGNALLING_TYPE, "message": PeerLeftMessage().model_dump()})
await self._close()
async def _close(self):
"""Close the peer connection and cleanup resources."""
if self._pc:
await self._pc.close()
self._message_queue.clear()
self._pending_app_messages.clear()
self._track_map = {}
def get_answer(self):
"""Get the SDP answer for the current connection.
Returns:
Dictionary containing SDP answer, type, and peer connection ID,
or None if no answer is available.
"""
if not self._answer:
return None
return {
"sdp": self._answer.sdp,
"type": self._answer.type,
"pc_id": self._pc_id,
}
async def _handle_new_connection_state(self):
"""Handle changes in the peer connection state."""
state = self._pc.connectionState
if state == "connected" and not self._connect_invoked:
# We are going to wait until the pipeline is ready before triggering the event
return
logger.debug(f"Connection state changed to: {state}")
await self._call_event_handler(state)
if state == "failed":
logger.warning("Connection failed, closing peer connection.")
await self._close()
# Despite the fact that aiortc provides this listener, they don't have a status for "disconnected"
# So, there is no advantage in looking at self._pc.connectionState
# That is why we are trying to keep our own state
def is_connected(self) -> bool:
"""Check if the WebRTC connection is currently active.
Returns:
True if the connection is active and receiving data.
"""
# If the small webrtc transport has never invoked to connect
# we are acting like if we are not connected
if not self._connect_invoked:
return False
if self._last_received_time is None:
# if we have never received a message, it is probably because the client has not created a data channel
# so we are going to trust aiortc in this case
return self._pc.connectionState == "connected"
# Checks if the last received ping was within the last 3 seconds.
return (time.time() - self._last_received_time) < 3
def audio_input_track(self):
"""Get the audio input track wrapper.
Returns:
SmallWebRTCTrack wrapper for the audio track, or None if unavailable.
"""
if self._track_map.get(AUDIO_TRANSCEIVER_INDEX):
return self._track_map[AUDIO_TRANSCEIVER_INDEX]
# Transceivers always appear in creation-order for both peers
# For support 3 receivers in the following order:
# audio, video, screenVideo
transceivers = self._pc.getTransceivers()
if len(transceivers) == 0 or not transceivers[AUDIO_TRANSCEIVER_INDEX].receiver:
logger.warning("No audio transceiver is available")
return None
track = transceivers[AUDIO_TRANSCEIVER_INDEX].receiver.track
audio_track = SmallWebRTCTrack(track) if track else None
self._track_map[AUDIO_TRANSCEIVER_INDEX] = audio_track
return audio_track
def video_input_track(self):
"""Get the video input track wrapper.
Returns:
SmallWebRTCTrack wrapper for the video track, or None if unavailable.
"""
if self._track_map.get(VIDEO_TRANSCEIVER_INDEX):
return self._track_map[VIDEO_TRANSCEIVER_INDEX]
# Transceivers always appear in creation-order for both peers
# For support 3 receivers in the following order:
# audio, video, screenVideo
transceivers = self._pc.getTransceivers()
if len(transceivers) <= 1 or not transceivers[VIDEO_TRANSCEIVER_INDEX].receiver:
logger.warning("No video transceiver is available")
return None
track = transceivers[VIDEO_TRANSCEIVER_INDEX].receiver.track
video_track = SmallWebRTCTrack(track) if track else None
self._track_map[VIDEO_TRANSCEIVER_INDEX] = video_track
return video_track
def screen_video_input_track(self):
"""Get the screen video input track wrapper.
Returns:
SmallWebRTCTrack wrapper for the screen video track, or None if unavailable.
"""
if self._track_map.get(SCREEN_VIDEO_TRANSCEIVER_INDEX):
return self._track_map[SCREEN_VIDEO_TRANSCEIVER_INDEX]
# Transceivers always appear in creation-order for both peers
# For support 3 receivers in the following order:
# audio, video, screenVideo
transceivers = self._pc.getTransceivers()
if len(transceivers) <= 2 or not transceivers[SCREEN_VIDEO_TRANSCEIVER_INDEX].receiver:
logger.warning("No screen video transceiver is available")
return None
track = transceivers[SCREEN_VIDEO_TRANSCEIVER_INDEX].receiver.track
video_track = SmallWebRTCTrack(track) if track else None
self._track_map[SCREEN_VIDEO_TRANSCEIVER_INDEX] = video_track
return video_track
def send_app_message(self, message: Any):
"""Send an application message through the data channel.
Args:
message: The message to send (will be JSON serialized).
"""
json_message = json.dumps(message)
if self._data_channel and self._data_channel.readyState == "open":
self._data_channel.send(json_message)
else:
logger.debug("Data channel not ready, queuing message")
self._message_queue.append(json_message)
def ask_to_renegotiate(self):
"""Request renegotiation of the WebRTC connection."""
if self._renegotiation_in_progress:
return
self._renegotiation_in_progress = True
self.send_app_message(
{"type": SIGNALLING_TYPE, "message": RenegotiateMessage().model_dump()}
)
def _handle_signalling_message(self, message):
"""Handle incoming signaling messages."""
logger.debug(f"Signalling message received: {message}")
inbound_adapter = TypeAdapter(SignallingMessage.Inbound)
signalling_message = inbound_adapter.validate_python(message)
match signalling_message:
case TrackStatusMessage():
track = (
self._track_getters.get(signalling_message.receiver_index) or (lambda: None)
)()
if track:
track.set_enabled(signalling_message.enabled)

View File

@@ -11,484 +11,15 @@ communication over WebSocket connections, with support for audio streaming,
frame serialization, and connection management.
"""
import asyncio
import io
import time
import wave
from typing import Awaitable, Callable, Optional
import websockets
from loguru import logger
from pydantic.main import BaseModel
from websockets.asyncio.client import connect as websocket_connect
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
StartFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameProcessorSetup
from pipecat.serializers.base_serializer import FrameSerializer
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.task_manager import BaseTaskManager
class WebsocketClientParams(TransportParams):
"""Configuration parameters for WebSocket client transport.
Parameters:
add_wav_header: Whether to add WAV headers to audio frames.
serializer: Frame serializer for encoding/decoding messages.
"""
add_wav_header: bool = True
serializer: Optional[FrameSerializer] = None
class WebsocketClientCallbacks(BaseModel):
"""Callback functions for WebSocket client events.
Parameters:
on_connected: Called when WebSocket connection is established.
on_disconnected: Called when WebSocket connection is closed.
on_message: Called when a message is received from the WebSocket.
"""
on_connected: Callable[[websockets.WebSocketClientProtocol], Awaitable[None]]
on_disconnected: Callable[[websockets.WebSocketClientProtocol], Awaitable[None]]
on_message: Callable[[websockets.WebSocketClientProtocol, websockets.Data], Awaitable[None]]
class WebsocketClientSession:
"""Manages a WebSocket client connection session.
Handles connection lifecycle, message sending/receiving, and provides
callback mechanisms for connection events.
"""
def __init__(
self,
uri: str,
params: WebsocketClientParams,
callbacks: WebsocketClientCallbacks,
transport_name: str,
):
"""Initialize the WebSocket client session.
Args:
uri: The WebSocket URI to connect to.
params: Configuration parameters for the session.
callbacks: Callback functions for session events.
transport_name: Name of the parent transport for logging.
"""
self._uri = uri
self._params = params
self._callbacks = callbacks
self._transport_name = transport_name
self._leave_counter = 0
self._task_manager: Optional[BaseTaskManager] = None
self._websocket: Optional[websockets.WebSocketClientProtocol] = None
@property
def task_manager(self) -> BaseTaskManager:
"""Get the task manager for this session.
Returns:
The task manager instance.
Raises:
Exception: If task manager is not initialized.
"""
if not self._task_manager:
raise Exception(
f"{self._transport_name}::WebsocketClientSession: TaskManager not initialized (pipeline not started?)"
)
return self._task_manager
async def setup(self, task_manager: BaseTaskManager):
"""Set up the session with a task manager.
Args:
task_manager: The task manager to use for session tasks.
"""
self._leave_counter += 1
if not self._task_manager:
self._task_manager = task_manager
async def connect(self):
"""Connect to the WebSocket server."""
if self._websocket:
return
try:
self._websocket = await websocket_connect(uri=self._uri, open_timeout=10)
self._client_task = self.task_manager.create_task(
self._client_task_handler(),
f"{self._transport_name}::WebsocketClientSession::_client_task_handler",
)
await self._callbacks.on_connected(self._websocket)
except TimeoutError:
logger.error(f"Timeout connecting to {self._uri}")
async def disconnect(self):
"""Disconnect from the WebSocket server."""
self._leave_counter -= 1
if not self._websocket or self._leave_counter > 0:
return
await self.task_manager.cancel_task(self._client_task)
await self._websocket.close()
self._websocket = None
async def send(self, message: websockets.Data):
"""Send a message through the WebSocket connection.
Args:
message: The message data to send.
"""
try:
if self._websocket:
await self._websocket.send(message)
except Exception as e:
logger.error(f"{self} exception sending data: {e.__class__.__name__} ({e})")
async def _client_task_handler(self):
"""Handle incoming messages from the WebSocket connection."""
try:
# Handle incoming messages
async for message in self._websocket:
await self._callbacks.on_message(self._websocket, message)
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
await self._callbacks.on_disconnected(self._websocket)
def __str__(self):
"""String representation of the WebSocket client session."""
return f"{self._transport_name}::WebsocketClientSession"
class WebsocketClientInputTransport(BaseInputTransport):
"""WebSocket client input transport for receiving frames.
Handles incoming WebSocket messages, deserializes them to frames,
and pushes them downstream in the processing pipeline.
"""
def __init__(
self,
transport: BaseTransport,
session: WebsocketClientSession,
params: WebsocketClientParams,
):
"""Initialize the WebSocket client input transport.
Args:
transport: The parent transport instance.
session: The WebSocket session to use for communication.
params: Configuration parameters for the transport.
"""
super().__init__(params)
self._transport = transport
self._session = session
self._params = params
# Whether we have seen a StartFrame already.
self._initialized = False
async def setup(self, setup: FrameProcessorSetup):
"""Set up the input transport with the frame processor setup.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._session.setup(setup.task_manager)
async def start(self, frame: StartFrame):
"""Start the input transport and initialize the WebSocket connection.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
if self._params.serializer:
await self._params.serializer.setup(frame)
await self._session.connect()
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the input transport and disconnect from WebSocket.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._session.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport and disconnect from WebSocket.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._session.disconnect()
async def cleanup(self):
"""Clean up the input transport resources."""
await super().cleanup()
await self._transport.cleanup()
async def on_message(self, websocket, message):
"""Handle incoming WebSocket messages.
Args:
websocket: The WebSocket connection that received the message.
message: The received message data.
"""
if not self._params.serializer:
return
frame = await self._params.serializer.deserialize(message)
if not frame:
return
if isinstance(frame, InputAudioRawFrame) and self._params.audio_in_enabled:
await self.push_audio_frame(frame)
else:
await self.push_frame(frame)
class WebsocketClientOutputTransport(BaseOutputTransport):
"""WebSocket client output transport for sending frames.
Handles outgoing frames, serializes them for WebSocket transmission,
and manages audio streaming with proper timing simulation.
"""
def __init__(
self,
transport: BaseTransport,
session: WebsocketClientSession,
params: WebsocketClientParams,
):
"""Initialize the WebSocket client output transport.
Args:
transport: The parent transport instance.
session: The WebSocket session to use for communication.
params: Configuration parameters for the transport.
"""
super().__init__(params)
self._transport = transport
self._session = session
self._params = params
# write_audio_frame() is called quickly, as soon as we get audio
# (e.g. from the TTS), and since this is just a network connection we
# would be sending it to quickly. Instead, we want to block to emulate
# an audio device, this is what the send interval is. It will be
# computed on StartFrame.
self._send_interval = 0
self._next_send_time = 0
# Whether we have seen a StartFrame already.
self._initialized = False
async def setup(self, setup: FrameProcessorSetup):
"""Set up the output transport with the frame processor setup.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._session.setup(setup.task_manager)
async def start(self, frame: StartFrame):
"""Start the output transport and initialize the WebSocket connection.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
self._send_interval = (self.audio_chunk_size / self.sample_rate) / 2
if self._params.serializer:
await self._params.serializer.setup(frame)
await self._session.connect()
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport and disconnect from WebSocket.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._session.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and disconnect from WebSocket.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._session.disconnect()
async def cleanup(self):
"""Clean up the output transport resources."""
await super().cleanup()
await self._transport.cleanup()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message through the WebSocket.
Args:
frame: The transport message frame to send.
"""
await self._write_frame(frame)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebSocket with optional WAV header.
Args:
frame: The output audio frame to write.
"""
frame = OutputAudioRawFrame(
audio=frame.audio,
sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels,
)
if self._params.add_wav_header:
with io.BytesIO() as buffer:
with wave.open(buffer, "wb") as wf:
wf.setsampwidth(2)
wf.setnchannels(frame.num_channels)
wf.setframerate(frame.sample_rate)
wf.writeframes(frame.audio)
wav_frame = OutputAudioRawFrame(
buffer.getvalue(),
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
)
frame = wav_frame
await self._write_frame(frame)
# Simulate audio playback with a sleep.
await self._write_audio_sleep()
async def _write_frame(self, frame: Frame):
"""Write a frame to the WebSocket after serialization."""
if not self._params.serializer:
return
payload = await self._params.serializer.serialize(frame)
if payload:
await self._session.send(payload)
async def _write_audio_sleep(self):
"""Simulate audio playback timing with sleep delays."""
# Simulate a clock.
current_time = time.monotonic()
sleep_duration = max(0, self._next_send_time - current_time)
await asyncio.sleep(sleep_duration)
if sleep_duration == 0:
self._next_send_time = time.monotonic() + self._send_interval
else:
self._next_send_time += self._send_interval
class WebsocketClientTransport(BaseTransport):
"""WebSocket client transport for bidirectional communication.
Provides a complete WebSocket client transport implementation with
input and output capabilities, connection management, and event handling.
"""
def __init__(
self,
uri: str,
params: Optional[WebsocketClientParams] = None,
):
"""Initialize the WebSocket client transport.
Args:
uri: The WebSocket URI to connect to.
params: Optional configuration parameters for the transport.
"""
super().__init__()
self._params = params or WebsocketClientParams()
self._params.serializer = self._params.serializer or ProtobufFrameSerializer()
callbacks = WebsocketClientCallbacks(
on_connected=self._on_connected,
on_disconnected=self._on_disconnected,
on_message=self._on_message,
)
self._session = WebsocketClientSession(uri, self._params, callbacks, self.name)
self._input: Optional[WebsocketClientInputTransport] = None
self._output: Optional[WebsocketClientOutputTransport] = None
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_connected")
self._register_event_handler("on_disconnected")
def input(self) -> WebsocketClientInputTransport:
"""Get the input transport for receiving frames.
Returns:
The WebSocket client input transport instance.
"""
if not self._input:
self._input = WebsocketClientInputTransport(self, self._session, self._params)
return self._input
def output(self) -> WebsocketClientOutputTransport:
"""Get the output transport for sending frames.
Returns:
The WebSocket client output transport instance.
"""
if not self._output:
self._output = WebsocketClientOutputTransport(self, self._session, self._params)
return self._output
async def _on_connected(self, websocket):
"""Handle WebSocket connection established event."""
await self._call_event_handler("on_connected", websocket)
async def _on_disconnected(self, websocket):
"""Handle WebSocket connection closed event."""
await self._call_event_handler("on_disconnected", websocket)
async def _on_message(self, websocket, message):
"""Handle incoming WebSocket message."""
if self._input:
await self._input.on_message(websocket, message)
import warnings
from pipecat.transports.websocket.client import *
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Module `pipecat.transports.network.websocket_client` is deprecated, "
"use `pipecat.transports.websocket.client` instead.",
DeprecationWarning,
stacklevel=2,
)

View File

@@ -11,490 +11,15 @@ audio and data streaming, including client connection management, session
handling, and frame serialization.
"""
import asyncio
import io
import time
import wave
from typing import Awaitable, Callable, Optional
from loguru import logger
from pydantic import BaseModel
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
StartFrame,
StartInterruptionFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.serializers.base_serializer import FrameSerializer
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
try:
import websockets
from websockets.asyncio.server import serve as websocket_serve
from websockets.protocol import State
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use websockets, you need to `pip install pipecat-ai[websocket]`.")
raise Exception(f"Missing module: {e}")
class WebsocketServerParams(TransportParams):
"""Configuration parameters for WebSocket server transport.
Parameters:
add_wav_header: Whether to add WAV headers to audio frames.
serializer: Frame serializer for message encoding/decoding.
session_timeout: Timeout in seconds for client sessions.
"""
add_wav_header: bool = False
serializer: Optional[FrameSerializer] = None
session_timeout: Optional[int] = None
class WebsocketServerCallbacks(BaseModel):
"""Callback functions for WebSocket server events.
Parameters:
on_client_connected: Called when a client connects to the server.
on_client_disconnected: Called when a client disconnects from the server.
on_session_timeout: Called when a client session times out.
on_websocket_ready: Called when the WebSocket server is ready to accept connections.
"""
on_client_connected: Callable[[websockets.WebSocketServerProtocol], Awaitable[None]]
on_client_disconnected: Callable[[websockets.WebSocketServerProtocol], Awaitable[None]]
on_session_timeout: Callable[[websockets.WebSocketServerProtocol], Awaitable[None]]
on_websocket_ready: Callable[[], Awaitable[None]]
class WebsocketServerInputTransport(BaseInputTransport):
"""WebSocket server input transport for receiving client data.
Handles incoming WebSocket connections, message processing, and client
session management including timeout monitoring and connection lifecycle.
"""
def __init__(
self,
transport: BaseTransport,
host: str,
port: int,
params: WebsocketServerParams,
callbacks: WebsocketServerCallbacks,
**kwargs,
):
"""Initialize the WebSocket server input transport.
Args:
transport: The parent transport instance.
host: Host address to bind the WebSocket server to.
port: Port number to bind the WebSocket server to.
params: WebSocket server configuration parameters.
callbacks: Callback functions for WebSocket events.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._host = host
self._port = port
self._params = params
self._callbacks = callbacks
self._websocket: Optional[websockets.WebSocketServerProtocol] = None
self._server_task = None
# This task will monitor the websocket connection periodically.
self._monitor_task = None
self._stop_server_event = asyncio.Event()
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the WebSocket server and initialize components.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
if self._params.serializer:
await self._params.serializer.setup(frame)
if not self._server_task:
self._server_task = self.create_task(self._server_task_handler())
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the WebSocket server and cleanup resources.
Args:
frame: The end frame signaling transport shutdown.
"""
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._server_task
self._server_task = None
async def cancel(self, frame: CancelFrame):
"""Cancel the WebSocket server and stop all processing.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
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 cleanup(self):
"""Cleanup resources and parent transport."""
await super().cleanup()
await self._transport.cleanup()
async def _server_task_handler(self):
"""Handle WebSocket server startup and client connections."""
logger.info(f"Starting websocket server on {self._host}:{self._port}")
async with websocket_serve(self._client_handler, self._host, self._port) as server:
await self._callbacks.on_websocket_ready()
await self._stop_server_event.wait()
async def _client_handler(self, websocket: websockets.WebSocketServerProtocol):
"""Handle individual client connections and message processing."""
logger.info(f"New client connection from {websocket.remote_address}")
if self._websocket:
await self._websocket.close()
logger.warning("Only one client connected, using new connection")
self._websocket = websocket
# Notify
await self._callbacks.on_client_connected(websocket)
# Create a task to monitor the websocket connection
if not self._monitor_task and self._params.session_timeout:
self._monitor_task = self.create_task(
self._monitor_websocket(websocket, self._params.session_timeout)
)
# Handle incoming messages
try:
async for message in websocket:
if not self._params.serializer:
continue
frame = await self._params.serializer.deserialize(message)
if not frame:
continue
if isinstance(frame, InputAudioRawFrame):
await self.push_audio_frame(frame)
else:
await self.push_frame(frame)
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
# Notify disconnection
await self._callbacks.on_client_disconnected(websocket)
await self._websocket.close()
self._websocket = None
logger.info(f"Client {websocket.remote_address} disconnected")
async def _monitor_websocket(
self, websocket: websockets.WebSocketServerProtocol, session_timeout: int
):
"""Monitor WebSocket connection for session timeout."""
try:
await asyncio.sleep(session_timeout)
if websocket.state is not State.CLOSED:
await self._callbacks.on_session_timeout(websocket)
except asyncio.CancelledError:
logger.info(f"Monitoring task cancelled for: {websocket.remote_address}")
raise
class WebsocketServerOutputTransport(BaseOutputTransport):
"""WebSocket server output transport for sending data to clients.
Handles outgoing frame serialization, audio streaming with timing control,
and client connection management for WebSocket communication.
"""
def __init__(self, transport: BaseTransport, params: WebsocketServerParams, **kwargs):
"""Initialize the WebSocket server output transport.
Args:
transport: The parent transport instance.
params: WebSocket server configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._params = params
self._websocket: Optional[websockets.WebSocketServerProtocol] = None
# write_audio_frame() is called quickly, as soon as we get audio
# (e.g. from the TTS), and since this is just a network connection we
# would be sending it to quickly. Instead, we want to block to emulate
# an audio device, this is what the send interval is. It will be
# computed on StartFrame.
self._send_interval = 0
self._next_send_time = 0
# Whether we have seen a StartFrame already.
self._initialized = False
async def set_client_connection(self, websocket: Optional[websockets.WebSocketServerProtocol]):
"""Set the active client WebSocket connection.
Args:
websocket: The WebSocket connection to set as active, or None to clear.
"""
if self._websocket:
await self._websocket.close()
logger.warning("Only one client allowed, using new connection")
self._websocket = websocket
async def start(self, frame: StartFrame):
"""Start the output transport and initialize components.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
if self._params.serializer:
await self._params.serializer.setup(frame)
self._send_interval = (self.audio_chunk_size / self.sample_rate) / 2
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport and send final frame.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._write_frame(frame)
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and send cancellation frame.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._write_frame(frame)
async def cleanup(self):
"""Cleanup resources and parent transport."""
await super().cleanup()
await self._transport.cleanup()
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process frames and handle interruption timing.
Args:
frame: The frame to process.
direction: The direction of frame flow in the pipeline.
"""
await super().process_frame(frame, direction)
if isinstance(frame, StartInterruptionFrame):
await self._write_frame(frame)
self._next_send_time = 0
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message frame to the client.
Args:
frame: The transport message frame to send.
"""
await self._write_frame(frame)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebSocket client with timing control.
Args:
frame: The output audio frame to write.
"""
if not self._websocket:
return
frame = OutputAudioRawFrame(
audio=frame.audio,
sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels,
)
if self._params.add_wav_header:
with io.BytesIO() as buffer:
with wave.open(buffer, "wb") as wf:
wf.setsampwidth(2)
wf.setnchannels(frame.num_channels)
wf.setframerate(frame.sample_rate)
wf.writeframes(frame.audio)
wav_frame = OutputAudioRawFrame(
buffer.getvalue(),
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
)
frame = wav_frame
await self._write_frame(frame)
# Simulate audio playback with a sleep.
await self._write_audio_sleep()
async def _write_frame(self, frame: Frame):
"""Serialize and send a frame to the WebSocket client."""
if not self._params.serializer:
return
try:
payload = await self._params.serializer.serialize(frame)
if payload and self._websocket:
await self._websocket.send(payload)
except Exception as e:
logger.error(f"{self} exception sending data: {e.__class__.__name__} ({e})")
async def _write_audio_sleep(self):
"""Simulate audio device timing by sleeping between audio chunks."""
# Simulate a clock.
current_time = time.monotonic()
sleep_duration = max(0, self._next_send_time - current_time)
await asyncio.sleep(sleep_duration)
if sleep_duration == 0:
self._next_send_time = time.monotonic() + self._send_interval
else:
self._next_send_time += self._send_interval
class WebsocketServerTransport(BaseTransport):
"""WebSocket server transport for bidirectional real-time communication.
Provides a complete WebSocket server implementation with separate input and
output transports, client connection management, and event handling for
real-time audio and data streaming applications.
"""
def __init__(
self,
params: WebsocketServerParams,
host: str = "localhost",
port: int = 8765,
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the WebSocket server transport.
Args:
params: WebSocket server configuration parameters.
host: Host address to bind the server to. Defaults to "localhost".
port: Port number to bind the server to. Defaults to 8765.
input_name: Optional name for the input processor.
output_name: Optional name for the output processor.
"""
super().__init__(input_name=input_name, output_name=output_name)
self._host = host
self._port = port
self._params = params
self._callbacks = WebsocketServerCallbacks(
on_client_connected=self._on_client_connected,
on_client_disconnected=self._on_client_disconnected,
on_session_timeout=self._on_session_timeout,
on_websocket_ready=self._on_websocket_ready,
)
self._input: Optional[WebsocketServerInputTransport] = None
self._output: Optional[WebsocketServerOutputTransport] = None
self._websocket: Optional[websockets.WebSocketServerProtocol] = None
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected")
self._register_event_handler("on_session_timeout")
self._register_event_handler("on_websocket_ready")
def input(self) -> WebsocketServerInputTransport:
"""Get the input transport for receiving client data.
Returns:
The WebSocket server input transport instance.
"""
if not self._input:
self._input = WebsocketServerInputTransport(
self, self._host, self._port, self._params, self._callbacks, name=self._input_name
)
return self._input
def output(self) -> WebsocketServerOutputTransport:
"""Get the output transport for sending data to clients.
Returns:
The WebSocket server output transport instance.
"""
if not self._output:
self._output = WebsocketServerOutputTransport(
self, self._params, name=self._output_name
)
return self._output
async def _on_client_connected(self, websocket):
"""Handle client connection events."""
if self._output:
await self._output.set_client_connection(websocket)
await self._call_event_handler("on_client_connected", websocket)
else:
logger.error("A WebsocketServerTransport output is missing in the pipeline")
async def _on_client_disconnected(self, websocket):
"""Handle client disconnection events."""
if self._output:
await self._output.set_client_connection(None)
await self._call_event_handler("on_client_disconnected", websocket)
else:
logger.error("A WebsocketServerTransport output is missing in the pipeline")
async def _on_session_timeout(self, websocket):
"""Handle client session timeout events."""
await self._call_event_handler("on_session_timeout", websocket)
async def _on_websocket_ready(self):
"""Handle WebSocket server ready events."""
await self._call_event_handler("on_websocket_ready")
import warnings
from pipecat.transports.websocket.server import *
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Module `pipecat.transports.network.websocket_server` is deprecated, "
"use `pipecat.transports.websocket.server` instead.",
DeprecationWarning,
stacklevel=2,
)

File diff suppressed because it is too large Load Diff

View File

@@ -9,402 +9,15 @@
Methods that wrap the Daily API to create rooms, check room URLs, and get meeting tokens.
"""
import time
from typing import Dict, List, Literal, Optional
from urllib.parse import urlparse
import aiohttp
from pydantic import BaseModel, Field, ValidationError
class DailyRoomSipParams(BaseModel):
"""SIP configuration parameters for Daily rooms.
Parameters:
display_name: Name shown for the SIP endpoint.
video: Whether video is enabled for SIP.
sip_mode: SIP connection mode, typically 'dial-in'.
num_endpoints: Number of allowed SIP endpoints.
codecs: Codecs to support for audio and video. If None, uses Daily defaults.
Example: {"audio": ["OPUS"], "video": ["H264"]}
"""
display_name: str = "sw-sip-dialin"
video: bool = False
sip_mode: str = "dial-in"
num_endpoints: int = 1
codecs: Optional[Dict[str, List[str]]] = None
class RecordingsBucketConfig(BaseModel):
"""Configuration for storing Daily recordings in a custom S3 bucket.
Refer to the Daily API documentation for more information:
https://docs.daily.co/guides/products/live-streaming-recording/storing-recordings-in-a-custom-s3-bucket
Parameters:
bucket_name: Name of the S3 bucket for storing recordings.
bucket_region: AWS region where the S3 bucket is located.
assume_role_arn: ARN of the IAM role to assume for S3 access.
allow_api_access: Whether to allow API access to the recordings.
"""
bucket_name: str
bucket_region: str
assume_role_arn: str
allow_api_access: bool = False
class TranscriptionBucketConfig(BaseModel):
"""Configuration for storing Daily transcription in a custom S3 bucket.
Refer to the Daily API documentation for more information:
https://docs.daily.co/guides/products/live-streaming-recording/storing-recordings-in-a-custom-s3-bucket
Parameters:
bucket_name: Name of the S3 bucket for storing transcription.
bucket_region: AWS region where the S3 bucket is located.
assume_role_arn: ARN of the IAM role to assume for S3 access.
allow_api_access: Whether to allow API access to the transcription.
"""
bucket_name: str
bucket_region: str
assume_role_arn: str
allow_api_access: bool = False
class DailyRoomProperties(BaseModel, extra="allow"):
"""Properties for configuring a Daily room.
Reference: https://docs.daily.co/reference/rest-api/rooms/create-room#properties
Parameters:
exp: Optional Unix epoch timestamp for room expiration (e.g., time.time() + 300 for 5 minutes).
enable_chat: Whether chat is enabled in the room.
enable_prejoin_ui: Whether the pre-join UI is enabled.
enable_emoji_reactions: Whether emoji reactions are enabled.
eject_at_room_exp: Whether to remove participants when room expires.
enable_dialout: Whether SIP dial-out is enabled.
enable_recording: Recording settings ('cloud', 'local', 'raw-tracks').
enable_transcription_storage: Whether transcription storage is enabled.
geo: Geographic region for room.
max_participants: Maximum number of participants allowed in the room.
recordings_bucket: Configuration for custom S3 bucket recordings.
transcription_bucket: Configuration for custom S3 bucket transcription.
sip: SIP configuration parameters.
sip_uri: SIP URI information returned by Daily.
start_video_off: Whether video is off by default.
"""
exp: Optional[float] = None
enable_chat: bool = False
enable_prejoin_ui: bool = False
enable_emoji_reactions: bool = False
eject_at_room_exp: bool = False
enable_dialout: Optional[bool] = None
enable_recording: Optional[Literal["cloud", "local", "raw-tracks"]] = None
enable_transcription_storage: Optional[bool] = None
geo: Optional[str] = None
max_participants: Optional[int] = None
recordings_bucket: Optional[RecordingsBucketConfig] = None
transcription_bucket: Optional[TranscriptionBucketConfig] = None
sip: Optional[DailyRoomSipParams] = None
sip_uri: Optional[dict] = None
start_video_off: bool = False
@property
def sip_endpoint(self) -> str:
"""Get the SIP endpoint URI if available.
Returns:
SIP endpoint URI or empty string if not available.
"""
if not self.sip_uri:
return ""
else:
return "sip:%s" % self.sip_uri["endpoint"]
class DailyRoomParams(BaseModel):
"""Parameters for creating a Daily room.
Parameters:
name: Optional custom name for the room.
privacy: Room privacy setting ('private' or 'public').
properties: Room configuration properties.
"""
name: Optional[str] = None
privacy: Literal["private", "public"] = "public"
properties: DailyRoomProperties = Field(default_factory=DailyRoomProperties)
class DailyRoomObject(BaseModel):
"""Represents a Daily room returned by the API.
Parameters:
id: Unique room identifier.
name: Room name.
api_created: Whether room was created via API.
privacy: Room privacy setting ('private' or 'public').
url: Full URL for joining the room.
created_at: Timestamp of room creation in ISO 8601 format (e.g., "2019-01-26T09:01:22.000Z").
config: Room configuration properties.
"""
id: str
name: str
api_created: bool
privacy: str
url: str
created_at: str
config: DailyRoomProperties
class DailyMeetingTokenProperties(BaseModel):
"""Properties for configuring a Daily meeting token.
Refer to the Daily API documentation for more information:
https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token#properties
Parameters:
room_name: The room for which this token is valid. If not set, the token is valid for all rooms in your domain.
eject_at_token_exp: If True, the user will be ejected from the room when the token expires.
eject_after_elapsed: The number of seconds after which the user will be ejected from the room.
nbf: Not before timestamp - users cannot join with this token before this time.
exp: Expiration time (unix timestamp in seconds). Strongly recommended for security.
is_owner: If True, the token will grant owner privileges in the room.
user_name: The name of the user. This will be added to the token payload.
user_id: A unique identifier for the user. This will be added to the token payload.
enable_screenshare: If True, the user will be able to share their screen.
start_video_off: If True, the user's video will be turned off when they join the room.
start_audio_off: If True, the user's audio will be turned off when they join the room.
enable_recording: Recording settings for the token. Must be one of 'cloud', 'local' or 'raw-tracks'.
enable_prejoin_ui: If True, the user will see the prejoin UI before joining the room.
start_cloud_recording: Start cloud recording when the user joins the room.
permissions: Specifies the initial default permissions for a non-meeting-owner participant.
"""
room_name: Optional[str] = None
eject_at_token_exp: Optional[bool] = None
eject_after_elapsed: Optional[int] = None
nbf: Optional[int] = None
exp: Optional[int] = None
is_owner: Optional[bool] = None
user_name: Optional[str] = None
user_id: Optional[str] = None
enable_screenshare: Optional[bool] = None
start_video_off: Optional[bool] = None
start_audio_off: Optional[bool] = None
enable_recording: Optional[Literal["cloud", "local", "raw-tracks"]] = None
enable_prejoin_ui: Optional[bool] = None
start_cloud_recording: Optional[bool] = None
permissions: Optional[dict] = None
class DailyMeetingTokenParams(BaseModel):
"""Parameters for creating a Daily meeting token.
Refer to the Daily API documentation for more information:
https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token#body-params
Parameters:
properties: Meeting token configuration properties.
"""
properties: DailyMeetingTokenProperties = Field(default_factory=DailyMeetingTokenProperties)
class DailyRESTHelper:
"""Helper class for interacting with Daily's REST API.
Provides methods for creating, managing, and accessing Daily rooms.
"""
def __init__(
self,
*,
daily_api_key: str,
daily_api_url: str = "https://api.daily.co/v1",
aiohttp_session: aiohttp.ClientSession,
):
"""Initialize the Daily REST helper.
Args:
daily_api_key: Your Daily API key.
daily_api_url: Daily API base URL (e.g. "https://api.daily.co/v1").
aiohttp_session: Async HTTP session for making requests.
"""
self.daily_api_key = daily_api_key
self.daily_api_url = daily_api_url
self.aiohttp_session = aiohttp_session
def get_name_from_url(self, room_url: str) -> str:
"""Extract room name from a Daily room URL.
Args:
room_url: Full Daily room URL.
Returns:
Room name portion of the URL.
"""
return urlparse(room_url).path[1:]
async def get_room_from_url(self, room_url: str) -> DailyRoomObject:
"""Get room details from a Daily room URL.
Args:
room_url: Full Daily room URL.
Returns:
DailyRoomObject instance for the room.
"""
room_name = self.get_name_from_url(room_url)
return await self._get_room_from_name(room_name)
async def create_room(self, params: DailyRoomParams) -> DailyRoomObject:
"""Create a new Daily room.
Args:
params: Room configuration parameters.
Returns:
DailyRoomObject instance for the created room.
Raises:
Exception: If room creation fails or response is invalid.
"""
headers = {"Authorization": f"Bearer {self.daily_api_key}"}
json = params.model_dump(exclude_none=True)
async with self.aiohttp_session.post(
f"{self.daily_api_url}/rooms", headers=headers, json=json
) as r:
if r.status != 200:
text = await r.text()
raise Exception(f"Unable to create room (status: {r.status}): {text}")
data = await r.json()
try:
room = DailyRoomObject(**data)
except ValidationError as e:
raise Exception(f"Invalid response: {e}")
return room
async def get_token(
self,
room_url: str,
expiry_time: float = 60 * 60,
eject_at_token_exp: bool = False,
owner: bool = True,
params: Optional[DailyMeetingTokenParams] = None,
) -> str:
"""Generate a meeting token for user to join a Daily room.
Args:
room_url: Daily room URL.
expiry_time: Token validity duration in seconds (default: 1 hour).
eject_at_token_exp: Whether to eject user when token expires.
owner: Whether token has owner privileges.
params: Optional additional token properties. Note that room_name,
exp, and is_owner will be set based on the other function
parameters regardless of values in params.
Returns:
Meeting token.
Raises:
Exception: If token generation fails or room URL is missing.
"""
if not room_url:
raise Exception(
"No Daily room specified. You must specify a Daily room in order a token to be generated."
)
expiration: int = int(time.time() + expiry_time)
room_name = self.get_name_from_url(room_url)
headers = {"Authorization": f"Bearer {self.daily_api_key}"}
if params is None:
params = DailyMeetingTokenParams(
properties=DailyMeetingTokenProperties(
room_name=room_name,
is_owner=owner,
exp=expiration,
eject_at_token_exp=eject_at_token_exp,
)
)
else:
params.properties.room_name = room_name
params.properties.exp = expiration
params.properties.eject_at_token_exp = eject_at_token_exp
params.properties.is_owner = owner
json = params.model_dump(exclude_none=True)
async with self.aiohttp_session.post(
f"{self.daily_api_url}/meeting-tokens", headers=headers, json=json
) as r:
if r.status != 200:
text = await r.text()
raise Exception(f"Failed to create meeting token (status: {r.status}): {text}")
data = await r.json()
return data["token"]
async def delete_room_by_url(self, room_url: str) -> bool:
"""Delete a room using its URL.
Args:
room_url: Daily room URL.
Returns:
True if deletion was successful.
"""
room_name = self.get_name_from_url(room_url)
return await self.delete_room_by_name(room_name)
async def delete_room_by_name(self, room_name: str) -> bool:
"""Delete a room using its name.
Args:
room_name: Name of the room to delete.
Returns:
True if deletion was successful.
Raises:
Exception: If deletion fails (excluding 404 Not Found).
"""
headers = {"Authorization": f"Bearer {self.daily_api_key}"}
async with self.aiohttp_session.delete(
f"{self.daily_api_url}/rooms/{room_name}", headers=headers
) as r:
if r.status != 200 and r.status != 404:
text = await r.text()
raise Exception(f"Failed to delete room [{room_name}] (status: {r.status}): {text}")
return True
async def _get_room_from_name(self, room_name: str) -> DailyRoomObject:
"""Internal method to get room details by name."""
headers = {"Authorization": f"Bearer {self.daily_api_key}"}
async with self.aiohttp_session.get(
f"{self.daily_api_url}/rooms/{room_name}", headers=headers
) as r:
if r.status != 200:
raise Exception(f"Room not found: {room_name}")
data = await r.json()
try:
room = DailyRoomObject(**data)
except ValidationError as e:
raise Exception(f"Invalid response: {e}")
return room
import warnings
from pipecat.transports.daily.utils import *
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Module `pipecat.transports.services.helpers.daily_rest` is deprecated, "
"use `pipecat.transports.daily.utils` instead.",
DeprecationWarning,
stacklevel=2,
)

View File

@@ -11,978 +11,15 @@ including audio streaming, data messaging, participant management, and room
event handling for conversational AI applications.
"""
import asyncio
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, List, Optional
from loguru import logger
from pydantic import BaseModel
from pipecat.audio.utils import create_stream_resampler
from pipecat.audio.vad.vad_analyzer import VADAnalyzer
from pipecat.frames.frames import (
AudioRawFrame,
CancelFrame,
EndFrame,
OutputAudioRawFrame,
StartFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
UserAudioRawFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup
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.task_manager import BaseTaskManager
try:
from livekit import rtc
from tenacity import retry, stop_after_attempt, wait_exponential
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use LiveKit, you need to `pip install pipecat-ai[livekit]`.")
raise Exception(f"Missing module: {e}")
@dataclass
class LiveKitTransportMessageFrame(TransportMessageFrame):
"""Frame for transport messages in LiveKit rooms.
Parameters:
participant_id: Optional ID of the participant this message is for/from.
"""
participant_id: Optional[str] = None
@dataclass
class LiveKitTransportMessageUrgentFrame(TransportMessageUrgentFrame):
"""Frame for urgent transport messages in LiveKit rooms.
Parameters:
participant_id: Optional ID of the participant this message is for/from.
"""
participant_id: Optional[str] = None
class LiveKitParams(TransportParams):
"""Configuration parameters for LiveKit transport.
Inherits all parameters from TransportParams without additional configuration.
"""
pass
class LiveKitCallbacks(BaseModel):
"""Callback handlers for LiveKit events.
Parameters:
on_connected: Called when connected to the LiveKit room.
on_disconnected: Called when disconnected from the LiveKit room.
on_participant_connected: Called when a participant joins the room.
on_participant_disconnected: Called when a participant leaves the room.
on_audio_track_subscribed: Called when an audio track is subscribed.
on_audio_track_unsubscribed: Called when an audio track is unsubscribed.
on_data_received: Called when data is received from a participant.
on_first_participant_joined: Called when the first participant joins.
"""
on_connected: Callable[[], Awaitable[None]]
on_disconnected: Callable[[], Awaitable[None]]
on_participant_connected: Callable[[str], Awaitable[None]]
on_participant_disconnected: Callable[[str], Awaitable[None]]
on_audio_track_subscribed: Callable[[str], Awaitable[None]]
on_audio_track_unsubscribed: Callable[[str], Awaitable[None]]
on_data_received: Callable[[bytes, str], Awaitable[None]]
on_first_participant_joined: Callable[[str], Awaitable[None]]
class LiveKitTransportClient:
"""Core client for interacting with LiveKit rooms.
Manages the connection to LiveKit rooms and handles all low-level API interactions
including room management, audio streaming, data messaging, and event handling.
"""
def __init__(
self,
url: str,
token: str,
room_name: str,
params: LiveKitParams,
callbacks: LiveKitCallbacks,
transport_name: str,
):
"""Initialize the LiveKit transport client.
Args:
url: LiveKit server URL to connect to.
token: Authentication token for the room.
room_name: Name of the LiveKit room to join.
params: Configuration parameters for the transport.
callbacks: Event callback handlers.
transport_name: Name identifier for the transport.
"""
self._url = url
self._token = token
self._room_name = room_name
self._params = params
self._callbacks = callbacks
self._transport_name = transport_name
self._room: Optional[rtc.Room] = None
self._participant_id: str = ""
self._connected = False
self._disconnect_counter = 0
self._audio_source: Optional[rtc.AudioSource] = None
self._audio_track: Optional[rtc.LocalAudioTrack] = None
self._audio_tracks = {}
self._audio_queue = asyncio.Queue()
self._other_participant_has_joined = False
self._task_manager: Optional[BaseTaskManager] = None
@property
def participant_id(self) -> str:
"""Get the participant ID for this client.
Returns:
The participant ID assigned by LiveKit.
"""
return self._participant_id
@property
def room(self) -> rtc.Room:
"""Get the LiveKit room instance.
Returns:
The LiveKit room object.
Raises:
Exception: If room object is not available.
"""
if not self._room:
raise Exception(f"{self}: missing room object (pipeline not started?)")
return self._room
async def setup(self, setup: FrameProcessorSetup):
"""Setup the client with task manager and room initialization.
Args:
setup: The frame processor setup configuration.
"""
if self._task_manager:
return
self._task_manager = setup.task_manager
self._room = rtc.Room(loop=self._task_manager.get_event_loop())
# Set up room event handlers
self.room.on("participant_connected")(self._on_participant_connected_wrapper)
self.room.on("participant_disconnected")(self._on_participant_disconnected_wrapper)
self.room.on("track_subscribed")(self._on_track_subscribed_wrapper)
self.room.on("track_unsubscribed")(self._on_track_unsubscribed_wrapper)
self.room.on("data_received")(self._on_data_received_wrapper)
self.room.on("connected")(self._on_connected_wrapper)
self.room.on("disconnected")(self._on_disconnected_wrapper)
async def cleanup(self):
"""Cleanup client resources."""
await self.disconnect()
async def start(self, frame: StartFrame):
"""Start the client and initialize audio components.
Args:
frame: The start frame containing initialization parameters.
"""
self._out_sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
async def connect(self):
"""Connect to the LiveKit room with retry logic."""
if self._connected:
# Increment disconnect counter if already connected.
self._disconnect_counter += 1
return
logger.info(f"Connecting to {self._room_name}")
try:
await self.room.connect(
self._url,
self._token,
options=rtc.RoomOptions(auto_subscribe=True),
)
self._connected = True
# Increment disconnect counter if we successfully connected.
self._disconnect_counter += 1
self._participant_id = self.room.local_participant.sid
logger.info(f"Connected to {self._room_name}")
# Set up audio source and track
self._audio_source = rtc.AudioSource(
self._out_sample_rate, self._params.audio_out_channels
)
self._audio_track = rtc.LocalAudioTrack.create_audio_track(
"pipecat-audio", self._audio_source
)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await self.room.local_participant.publish_track(self._audio_track, options)
await self._callbacks.on_connected()
# Check if there are already participants in the room
participants = self.get_participants()
if participants and not self._other_participant_has_joined:
self._other_participant_has_joined = True
await self._callbacks.on_first_participant_joined(participants[0])
except Exception as e:
logger.error(f"Error connecting to {self._room_name}: {e}")
raise
async def disconnect(self):
"""Disconnect from the LiveKit room."""
# Decrement leave counter when leaving.
self._disconnect_counter -= 1
if not self._connected or self._disconnect_counter > 0:
return
logger.info(f"Disconnecting from {self._room_name}")
await self.room.disconnect()
self._connected = False
logger.info(f"Disconnected from {self._room_name}")
await self._callbacks.on_disconnected()
async def send_data(self, data: bytes, participant_id: Optional[str] = None):
"""Send data to participants in the room.
Args:
data: The data bytes to send.
participant_id: Optional specific participant to send to.
"""
if not self._connected:
return
try:
if participant_id:
await self.room.local_participant.publish_data(
data, reliable=True, destination_identities=[participant_id]
)
else:
await self.room.local_participant.publish_data(data, reliable=True)
except Exception as e:
logger.error(f"Error sending data: {e}")
async def publish_audio(self, audio_frame: rtc.AudioFrame):
"""Publish an audio frame to the room.
Args:
audio_frame: The LiveKit audio frame to publish.
"""
if not self._connected or not self._audio_source:
return
try:
await self._audio_source.capture_frame(audio_frame)
except Exception as e:
logger.error(f"Error publishing audio: {e}")
def get_participants(self) -> List[str]:
"""Get list of participant IDs in the room.
Returns:
List of participant IDs.
"""
return [p.sid for p in self.room.remote_participants.values()]
async def get_participant_metadata(self, participant_id: str) -> dict:
"""Get metadata for a specific participant.
Args:
participant_id: ID of the participant to get metadata for.
Returns:
Dictionary containing participant metadata.
"""
participant = self.room.remote_participants.get(participant_id)
if participant:
return {
"id": participant.sid,
"name": participant.name,
"metadata": participant.metadata,
"is_speaking": participant.is_speaking,
}
return {}
async def set_participant_metadata(self, metadata: str):
"""Set metadata for the local participant.
Args:
metadata: Metadata string to set.
"""
await self.room.local_participant.set_metadata(metadata)
async def mute_participant(self, participant_id: str):
"""Mute a specific participant's audio tracks.
Args:
participant_id: ID of the participant to mute.
"""
participant = self.room.remote_participants.get(participant_id)
if participant:
for track in participant.tracks.values():
if track.kind == "audio":
await track.set_enabled(False)
async def unmute_participant(self, participant_id: str):
"""Unmute a specific participant's audio tracks.
Args:
participant_id: ID of the participant to unmute.
"""
participant = self.room.remote_participants.get(participant_id)
if participant:
for track in participant.tracks.values():
if track.kind == "audio":
await track.set_enabled(True)
# Wrapper methods for event handlers
def _on_participant_connected_wrapper(self, participant: rtc.RemoteParticipant):
"""Wrapper for participant connected events."""
self._task_manager.create_task(
self._async_on_participant_connected(participant),
f"{self}::_async_on_participant_connected",
)
def _on_participant_disconnected_wrapper(self, participant: rtc.RemoteParticipant):
"""Wrapper for participant disconnected events."""
self._task_manager.create_task(
self._async_on_participant_disconnected(participant),
f"{self}::_async_on_participant_disconnected",
)
def _on_track_subscribed_wrapper(
self,
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
"""Wrapper for track subscribed events."""
self._task_manager.create_task(
self._async_on_track_subscribed(track, publication, participant),
f"{self}::_async_on_track_subscribed",
)
def _on_track_unsubscribed_wrapper(
self,
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
"""Wrapper for track unsubscribed events."""
self._task_manager.create_task(
self._async_on_track_unsubscribed(track, publication, participant),
f"{self}::_async_on_track_unsubscribed",
)
def _on_data_received_wrapper(self, data: rtc.DataPacket):
"""Wrapper for data received events."""
self._task_manager.create_task(
self._async_on_data_received(data),
f"{self}::_async_on_data_received",
)
def _on_connected_wrapper(self):
"""Wrapper for connected events."""
self._task_manager.create_task(self._async_on_connected(), f"{self}::_async_on_connected")
def _on_disconnected_wrapper(self):
"""Wrapper for disconnected events."""
self._task_manager.create_task(
self._async_on_disconnected(), f"{self}::_async_on_disconnected"
)
# Async methods for event handling
async def _async_on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle participant connected events."""
logger.info(f"Participant connected: {participant.identity}")
await self._callbacks.on_participant_connected(participant.sid)
if not self._other_participant_has_joined:
self._other_participant_has_joined = True
await self._callbacks.on_first_participant_joined(participant.sid)
async def _async_on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnected events."""
logger.info(f"Participant disconnected: {participant.identity}")
await self._callbacks.on_participant_disconnected(participant.sid)
if len(self.get_participants()) == 0:
self._other_participant_has_joined = False
async def _async_on_track_subscribed(
self,
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
"""Handle track subscribed events."""
if track.kind == rtc.TrackKind.KIND_AUDIO:
logger.info(f"Audio track subscribed: {track.sid} from participant {participant.sid}")
self._audio_tracks[participant.sid] = track
audio_stream = rtc.AudioStream(track)
self._task_manager.create_task(
self._process_audio_stream(audio_stream, participant.sid),
f"{self}::_process_audio_stream",
)
await self._callbacks.on_audio_track_subscribed(participant.sid)
async def _async_on_track_unsubscribed(
self,
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
"""Handle track unsubscribed events."""
logger.info(f"Track unsubscribed: {publication.sid} from {participant.identity}")
if track.kind == rtc.TrackKind.KIND_AUDIO:
await self._callbacks.on_audio_track_unsubscribed(participant.sid)
async def _async_on_data_received(self, data: rtc.DataPacket):
"""Handle data received events."""
await self._callbacks.on_data_received(data.data, data.participant.sid)
async def _async_on_connected(self):
"""Handle connected events."""
await self._callbacks.on_connected()
async def _async_on_disconnected(self, reason=None):
"""Handle disconnected events."""
self._connected = False
logger.info(f"Disconnected from {self._room_name}. Reason: {reason}")
await self._callbacks.on_disconnected()
async def _process_audio_stream(self, audio_stream: rtc.AudioStream, participant_id: str):
"""Process incoming audio stream from a participant."""
logger.info(f"Started processing audio stream for participant {participant_id}")
async for event in audio_stream:
if isinstance(event, rtc.AudioFrameEvent):
await self._audio_queue.put((event, participant_id))
else:
logger.warning(f"Received unexpected event type: {type(event)}")
async def get_next_audio_frame(self):
"""Get the next audio frame from the queue."""
while True:
frame, participant_id = await self._audio_queue.get()
yield frame, participant_id
def __str__(self):
"""String representation of the LiveKit transport client."""
return f"{self._transport_name}::LiveKitTransportClient"
class LiveKitInputTransport(BaseInputTransport):
"""Handles incoming media streams and events from LiveKit rooms.
Processes incoming audio streams from room participants and forwards them
as Pipecat frames, including audio resampling and VAD integration.
"""
def __init__(
self,
transport: BaseTransport,
client: LiveKitTransportClient,
params: LiveKitParams,
**kwargs,
):
"""Initialize the LiveKit input transport.
Args:
transport: The parent transport instance.
client: LiveKitTransportClient instance.
params: Configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._client = client
self._audio_in_task = None
self._vad_analyzer: Optional[VADAnalyzer] = params.vad_analyzer
self._resampler = create_stream_resampler()
# Whether we have seen a StartFrame already.
self._initialized = False
@property
def vad_analyzer(self) -> Optional[VADAnalyzer]:
"""Get the Voice Activity Detection analyzer.
Returns:
The VAD analyzer instance if configured.
"""
return self._vad_analyzer
async def start(self, frame: StartFrame):
"""Start the input transport and connect to LiveKit room.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.start(frame)
await self._client.connect()
if not self._audio_in_task and self._params.audio_in_enabled:
self._audio_in_task = self.create_task(self._audio_in_task_handler())
await self.set_transport_ready(frame)
logger.info("LiveKitInputTransport started")
async def stop(self, frame: EndFrame):
"""Stop the input transport and disconnect from LiveKit room.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.disconnect()
if self._audio_in_task:
await self.cancel_task(self._audio_in_task)
logger.info("LiveKitInputTransport stopped")
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport and disconnect from LiveKit room.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.disconnect()
if self._audio_in_task and self._params.audio_in_enabled:
await self.cancel_task(self._audio_in_task)
async def setup(self, setup: FrameProcessorSetup):
"""Setup the input transport with shared client setup.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._client.setup(setup)
async def cleanup(self):
"""Cleanup input transport and shared resources."""
await super().cleanup()
await self._transport.cleanup()
async def push_app_message(self, message: Any, sender: str):
"""Push an application message as an urgent transport frame.
Args:
message: The message data to send.
sender: ID of the message sender.
"""
frame = LiveKitTransportMessageUrgentFrame(message=message, participant_id=sender)
await self.push_frame(frame)
async def _audio_in_task_handler(self):
"""Handle incoming audio frames from participants."""
logger.info("Audio input task started")
audio_iterator = self._client.get_next_audio_frame()
async for audio_data in audio_iterator:
if audio_data:
audio_frame_event, participant_id = audio_data
pipecat_audio_frame = await self._convert_livekit_audio_to_pipecat(
audio_frame_event
)
# Skip frames with no audio data
if len(pipecat_audio_frame.audio) == 0:
continue
input_audio_frame = UserAudioRawFrame(
user_id=participant_id,
audio=pipecat_audio_frame.audio,
sample_rate=pipecat_audio_frame.sample_rate,
num_channels=pipecat_audio_frame.num_channels,
)
await self.push_audio_frame(input_audio_frame)
async def _convert_livekit_audio_to_pipecat(
self, audio_frame_event: rtc.AudioFrameEvent
) -> AudioRawFrame:
"""Convert LiveKit audio frame to Pipecat audio frame."""
audio_frame = audio_frame_event.frame
audio_data = await self._resampler.resample(
audio_frame.data.tobytes(), audio_frame.sample_rate, self.sample_rate
)
return AudioRawFrame(
audio=audio_data,
sample_rate=self.sample_rate,
num_channels=audio_frame.num_channels,
)
class LiveKitOutputTransport(BaseOutputTransport):
"""Handles outgoing media streams and events to LiveKit rooms.
Manages sending audio frames and data messages to LiveKit room participants,
including audio format conversion for LiveKit compatibility.
"""
def __init__(
self,
transport: BaseTransport,
client: LiveKitTransportClient,
params: LiveKitParams,
**kwargs,
):
"""Initialize the LiveKit output transport.
Args:
transport: The parent transport instance.
client: LiveKitTransportClient instance.
params: Configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._client = client
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the output transport and connect to LiveKit room.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.start(frame)
await self._client.connect()
await self.set_transport_ready(frame)
logger.info("LiveKitOutputTransport started")
async def stop(self, frame: EndFrame):
"""Stop the output transport and disconnect from LiveKit room.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.disconnect()
logger.info("LiveKitOutputTransport stopped")
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and disconnect from LiveKit room.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.disconnect()
async def setup(self, setup: FrameProcessorSetup):
"""Setup the output transport with shared client setup.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._client.setup(setup)
async def cleanup(self):
"""Cleanup output transport and shared resources."""
await super().cleanup()
await self._transport.cleanup()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message to participants.
Args:
frame: The transport message frame to send.
"""
if isinstance(frame, (LiveKitTransportMessageFrame, LiveKitTransportMessageUrgentFrame)):
await self._client.send_data(frame.message.encode(), frame.participant_id)
else:
await self._client.send_data(frame.message.encode())
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the LiveKit room.
Args:
frame: The audio frame to write.
"""
livekit_audio = self._convert_pipecat_audio_to_livekit(frame.audio)
await self._client.publish_audio(livekit_audio)
def _convert_pipecat_audio_to_livekit(self, pipecat_audio: bytes) -> rtc.AudioFrame:
"""Convert Pipecat audio data to LiveKit audio frame."""
bytes_per_sample = 2 # Assuming 16-bit audio
total_samples = len(pipecat_audio) // bytes_per_sample
samples_per_channel = total_samples // self._params.audio_out_channels
return rtc.AudioFrame(
data=pipecat_audio,
sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels,
samples_per_channel=samples_per_channel,
)
class LiveKitTransport(BaseTransport):
"""Transport implementation for LiveKit real-time communication.
Provides comprehensive LiveKit integration including audio streaming, data
messaging, participant management, and room event handling for conversational
AI applications.
"""
def __init__(
self,
url: str,
token: str,
room_name: str,
params: Optional[LiveKitParams] = None,
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the LiveKit transport.
Args:
url: LiveKit server URL to connect to.
token: Authentication token for the room.
room_name: Name of the LiveKit room to join.
params: Configuration parameters for the transport.
input_name: Optional name for the input transport.
output_name: Optional name for the output transport.
"""
super().__init__(input_name=input_name, output_name=output_name)
callbacks = LiveKitCallbacks(
on_connected=self._on_connected,
on_disconnected=self._on_disconnected,
on_participant_connected=self._on_participant_connected,
on_participant_disconnected=self._on_participant_disconnected,
on_audio_track_subscribed=self._on_audio_track_subscribed,
on_audio_track_unsubscribed=self._on_audio_track_unsubscribed,
on_data_received=self._on_data_received,
on_first_participant_joined=self._on_first_participant_joined,
)
self._params = params or LiveKitParams()
self._client = LiveKitTransportClient(
url, token, room_name, self._params, callbacks, self.name
)
self._input: Optional[LiveKitInputTransport] = None
self._output: Optional[LiveKitOutputTransport] = None
self._register_event_handler("on_connected")
self._register_event_handler("on_disconnected")
self._register_event_handler("on_participant_connected")
self._register_event_handler("on_participant_disconnected")
self._register_event_handler("on_audio_track_subscribed")
self._register_event_handler("on_audio_track_unsubscribed")
self._register_event_handler("on_data_received")
self._register_event_handler("on_first_participant_joined")
self._register_event_handler("on_participant_left")
self._register_event_handler("on_call_state_updated")
def input(self) -> LiveKitInputTransport:
"""Get the input transport for receiving media and events.
Returns:
The LiveKit input transport instance.
"""
if not self._input:
self._input = LiveKitInputTransport(
self, self._client, self._params, name=self._input_name
)
return self._input
def output(self) -> LiveKitOutputTransport:
"""Get the output transport for sending media and events.
Returns:
The LiveKit output transport instance.
"""
if not self._output:
self._output = LiveKitOutputTransport(
self, self._client, self._params, name=self._output_name
)
return self._output
@property
def participant_id(self) -> str:
"""Get the participant ID for this transport.
Returns:
The participant ID assigned by LiveKit.
"""
return self._client.participant_id
async def send_audio(self, frame: OutputAudioRawFrame):
"""Send an audio frame to the LiveKit room.
Args:
frame: The audio frame to send.
"""
if self._output:
await self._output.queue_frame(frame, FrameDirection.DOWNSTREAM)
def get_participants(self) -> List[str]:
"""Get list of participant IDs in the room.
Returns:
List of participant IDs.
"""
return self._client.get_participants()
async def get_participant_metadata(self, participant_id: str) -> dict:
"""Get metadata for a specific participant.
Args:
participant_id: ID of the participant to get metadata for.
Returns:
Dictionary containing participant metadata.
"""
return await self._client.get_participant_metadata(participant_id)
async def set_metadata(self, metadata: str):
"""Set metadata for the local participant.
Args:
metadata: Metadata string to set.
"""
await self._client.set_participant_metadata(metadata)
async def mute_participant(self, participant_id: str):
"""Mute a specific participant's audio tracks.
Args:
participant_id: ID of the participant to mute.
"""
await self._client.mute_participant(participant_id)
async def unmute_participant(self, participant_id: str):
"""Unmute a specific participant's audio tracks.
Args:
participant_id: ID of the participant to unmute.
"""
await self._client.unmute_participant(participant_id)
async def _on_connected(self):
"""Handle room connected events."""
await self._call_event_handler("on_connected")
async def _on_disconnected(self):
"""Handle room disconnected events."""
await self._call_event_handler("on_disconnected")
async def _on_participant_connected(self, participant_id: str):
"""Handle participant connected events."""
await self._call_event_handler("on_participant_connected", participant_id)
async def _on_participant_disconnected(self, participant_id: str):
"""Handle participant disconnected events."""
await self._call_event_handler("on_participant_disconnected", participant_id)
await self._call_event_handler("on_participant_left", participant_id, "disconnected")
async def _on_audio_track_subscribed(self, participant_id: str):
"""Handle audio track subscribed events."""
await self._call_event_handler("on_audio_track_subscribed", participant_id)
participant = self._client.room.remote_participants.get(participant_id)
if participant:
for publication in participant.audio_tracks.values():
self._client._on_track_subscribed_wrapper(
publication.track, publication, participant
)
async def _on_audio_track_unsubscribed(self, participant_id: str):
"""Handle audio track unsubscribed events."""
await self._call_event_handler("on_audio_track_unsubscribed", participant_id)
async def _on_data_received(self, data: bytes, participant_id: str):
"""Handle data received events."""
if self._input:
await self._input.push_app_message(data.decode(), participant_id)
await self._call_event_handler("on_data_received", data, participant_id)
async def send_message(self, message: str, participant_id: Optional[str] = None):
"""Send a message to participants in the room.
Args:
message: The message string to send.
participant_id: Optional specific participant to send to.
"""
if self._output:
frame = LiveKitTransportMessageFrame(message=message, participant_id=participant_id)
await self._output.send_message(frame)
async def send_message_urgent(self, message: str, participant_id: Optional[str] = None):
"""Send an urgent message to participants in the room.
Args:
message: The urgent message string to send.
participant_id: Optional specific participant to send to.
"""
if self._output:
frame = LiveKitTransportMessageUrgentFrame(
message=message, participant_id=participant_id
)
await self._output.send_message(frame)
async def on_room_event(self, event):
"""Handle room events.
Args:
event: The room event to handle.
"""
# Handle room events
pass
async def on_participant_event(self, event):
"""Handle participant events.
Args:
event: The participant event to handle.
"""
# Handle participant events
pass
async def on_track_event(self, event):
"""Handle track events.
Args:
event: The track event to handle.
"""
# Handle track events
pass
async def _on_call_state_updated(self, state: str):
"""Handle call state update events."""
await self._call_event_handler("on_call_state_updated", self, state)
async def _on_first_participant_joined(self, participant_id: str):
"""Handle first participant joined events."""
await self._call_event_handler("on_first_participant_joined", participant_id)
import warnings
from pipecat.transports.livekit.transport import *
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Module `pipecat.transports.services.livekit` is deprecated, "
"use `pipecat.transports.livekit.transport` instead.",
DeprecationWarning,
stacklevel=2,
)

View File

@@ -11,760 +11,15 @@ AI applications with avatars. It manages conversation sessions and provides real
audio/video streaming capabilities through the Tavus API.
"""
import os
from functools import partial
from typing import Any, Awaitable, Callable, Mapping, Optional
import aiohttp
from daily.daily import AudioData
from loguru import logger
from pydantic import BaseModel
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
StartFrame,
StartInterruptionFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.services.daily import (
DailyCallbacks,
DailyParams,
DailyTransportClient,
)
class TavusApi:
"""Helper class for interacting with the Tavus API (v2).
Provides methods for creating and managing conversations with Tavus avatars,
including conversation lifecycle management and persona information retrieval.
"""
BASE_URL = "https://tavusapi.com/v2"
MOCK_CONVERSATION_ID = "dev-conversation"
MOCK_PERSONA_NAME = "TestTavusTransport"
def __init__(self, api_key: str, session: aiohttp.ClientSession):
"""Initialize the TavusApi client.
Args:
api_key: Tavus API key for authentication.
session: An aiohttp session for making HTTP requests.
"""
self._api_key = api_key
self._session = session
self._headers = {"Content-Type": "application/json", "x-api-key": self._api_key}
# Only for development
self._dev_room_url = os.getenv("TAVUS_SAMPLE_ROOM_URL")
async def create_conversation(self, replica_id: str, persona_id: str) -> dict:
"""Create a new conversation with the specified replica and persona.
Args:
replica_id: ID of the replica to use in the conversation.
persona_id: ID of the persona to use in the conversation.
Returns:
Dictionary containing conversation_id and conversation_url.
"""
if self._dev_room_url:
return {
"conversation_id": self.MOCK_CONVERSATION_ID,
"conversation_url": self._dev_room_url,
}
logger.debug(f"Creating Tavus conversation: replica={replica_id}, persona={persona_id}")
url = f"{self.BASE_URL}/conversations"
payload = {
"replica_id": replica_id,
"persona_id": persona_id,
}
async with self._session.post(url, headers=self._headers, json=payload) as r:
r.raise_for_status()
response = await r.json()
logger.debug(f"Created Tavus conversation: {response}")
return response
async def end_conversation(self, conversation_id: str):
"""End an existing conversation.
Args:
conversation_id: ID of the conversation to end.
"""
if conversation_id is None or conversation_id == self.MOCK_CONVERSATION_ID:
return
url = f"{self.BASE_URL}/conversations/{conversation_id}/end"
async with self._session.post(url, headers=self._headers) as r:
r.raise_for_status()
logger.debug(f"Ended Tavus conversation {conversation_id}")
async def get_persona_name(self, persona_id: str) -> str:
"""Get the name of a persona by ID.
Args:
persona_id: ID of the persona to retrieve.
Returns:
The name of the persona.
"""
if self._dev_room_url is not None:
return self.MOCK_PERSONA_NAME
url = f"{self.BASE_URL}/personas/{persona_id}"
async with self._session.get(url, headers=self._headers) as r:
r.raise_for_status()
response = await r.json()
logger.debug(f"Fetched Tavus persona: {response}")
return response["persona_name"]
class TavusCallbacks(BaseModel):
"""Callback handlers for Tavus events.
Parameters:
on_participant_joined: Called when a participant joins the conversation.
on_participant_left: Called when a participant leaves the conversation.
"""
on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]]
on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]]
class TavusParams(DailyParams):
"""Configuration parameters for the Tavus transport.
Parameters:
audio_in_enabled: Whether to enable audio input from participants.
audio_out_enabled: Whether to enable audio output to participants.
microphone_out_enabled: Whether to enable microphone output track.
"""
audio_in_enabled: bool = True
audio_out_enabled: bool = True
microphone_out_enabled: bool = False
class TavusTransportClient:
"""Transport client that integrates Pipecat with the Tavus platform.
A transport client that integrates a Pipecat Bot with the Tavus platform by managing
conversation sessions using the Tavus API.
This client uses `TavusApi` to interact with the Tavus backend services. When a conversation
is started via `TavusApi`, Tavus provides a `roomURL` that can be used to connect the Pipecat Bot
into the same virtual room where the TavusBot is operating.
"""
def __init__(
self,
*,
bot_name: str,
params: TavusParams = TavusParams(),
callbacks: TavusCallbacks,
api_key: str,
replica_id: str,
persona_id: str = "pipecat-stream",
session: aiohttp.ClientSession,
) -> None:
"""Initialize the Tavus transport client.
Args:
bot_name: The name of the Pipecat bot instance.
params: Optional parameters for Tavus operation.
callbacks: Callback handlers for Tavus-related events.
api_key: API key for authenticating with Tavus API.
replica_id: ID of the replica to use in the Tavus conversation.
persona_id: ID of the Tavus persona. Defaults to "pipecat-stream",
which signals Tavus to use the TTS voice of the Pipecat bot
instead of a Tavus persona voice.
session: The aiohttp session for making async HTTP requests.
"""
self._bot_name = bot_name
self._api = TavusApi(api_key, session)
self._replica_id = replica_id
self._persona_id = persona_id
self._conversation_id: Optional[str] = None
self._client: Optional[DailyTransportClient] = None
self._callbacks = callbacks
self._params = params
async def _initialize(self) -> str:
"""Initialize the conversation and return the room URL."""
response = await self._api.create_conversation(self._replica_id, self._persona_id)
self._conversation_id = response["conversation_id"]
return response["conversation_url"]
async def setup(self, setup: FrameProcessorSetup):
"""Setup the client and initialize the conversation.
Args:
setup: The frame processor setup configuration.
"""
if self._conversation_id is not None:
logger.debug(f"Conversation ID already defined: {self._conversation_id}")
return
try:
room_url = await self._initialize()
daily_callbacks = DailyCallbacks(
on_active_speaker_changed=partial(
self._on_handle_callback, "on_active_speaker_changed"
),
on_joined=self._on_joined,
on_left=self._on_left,
on_error=partial(self._on_handle_callback, "on_error"),
on_app_message=partial(self._on_handle_callback, "on_app_message"),
on_call_state_updated=partial(self._on_handle_callback, "on_call_state_updated"),
on_client_connected=partial(self._on_handle_callback, "on_client_connected"),
on_client_disconnected=partial(self._on_handle_callback, "on_client_disconnected"),
on_dialin_connected=partial(self._on_handle_callback, "on_dialin_connected"),
on_dialin_ready=partial(self._on_handle_callback, "on_dialin_ready"),
on_dialin_stopped=partial(self._on_handle_callback, "on_dialin_stopped"),
on_dialin_error=partial(self._on_handle_callback, "on_dialin_error"),
on_dialin_warning=partial(self._on_handle_callback, "on_dialin_warning"),
on_dialout_answered=partial(self._on_handle_callback, "on_dialout_answered"),
on_dialout_connected=partial(self._on_handle_callback, "on_dialout_connected"),
on_dialout_stopped=partial(self._on_handle_callback, "on_dialout_stopped"),
on_dialout_error=partial(self._on_handle_callback, "on_dialout_error"),
on_dialout_warning=partial(self._on_handle_callback, "on_dialout_warning"),
on_participant_joined=self._callbacks.on_participant_joined,
on_participant_left=self._callbacks.on_participant_left,
on_participant_updated=partial(self._on_handle_callback, "on_participant_updated"),
on_transcription_message=partial(
self._on_handle_callback, "on_transcription_message"
),
on_recording_started=partial(self._on_handle_callback, "on_recording_started"),
on_recording_stopped=partial(self._on_handle_callback, "on_recording_stopped"),
on_recording_error=partial(self._on_handle_callback, "on_recording_error"),
on_transcription_stopped=partial(
self._on_handle_callback, "on_transcription_stopped"
),
on_transcription_error=partial(self._on_handle_callback, "on_transcription_error"),
)
self._client = DailyTransportClient(
room_url, None, "Pipecat", self._params, daily_callbacks, self._bot_name
)
await self._client.setup(setup)
except Exception as e:
logger.error(f"Failed to setup TavusTransportClient: {e}")
await self._api.end_conversation(self._conversation_id)
self._conversation_id = None
async def cleanup(self):
"""Cleanup client resources."""
try:
await self._client.cleanup()
except Exception as e:
logger.exception(f"Exception during cleanup: {e}")
async def _on_joined(self, data):
"""Handle joined event."""
logger.debug("TavusTransportClient joined!")
async def _on_left(self):
"""Handle left event."""
logger.debug("TavusTransportClient left!")
async def _on_handle_callback(self, event_name, *args, **kwargs):
"""Handle generic callback events."""
logger.trace(f"[Callback] {event_name} called with args={args}, kwargs={kwargs}")
async def get_persona_name(self) -> str:
"""Get the persona name from the API.
Returns:
The name of the current persona.
"""
return await self._api.get_persona_name(self._persona_id)
async def start(self, frame: StartFrame):
"""Start the client and join the room.
Args:
frame: The start frame containing initialization parameters.
"""
logger.debug("TavusTransportClient start invoked!")
await self._client.start(frame)
await self._client.join()
async def stop(self):
"""Stop the client and end the conversation."""
await self._client.leave()
await self._api.end_conversation(self._conversation_id)
self._conversation_id = None
async def capture_participant_video(
self,
participant_id: str,
callback: Callable,
framerate: int = 30,
video_source: str = "camera",
color_format: str = "RGB",
):
"""Capture video from a participant.
Args:
participant_id: ID of the participant to capture video from.
callback: Callback function to handle video frames.
framerate: Desired framerate for video capture.
video_source: Video source to capture from.
color_format: Color format for video frames.
"""
await self._client.capture_participant_video(
participant_id, callback, framerate, video_source, color_format
)
async def capture_participant_audio(
self,
participant_id: str,
callback: Callable,
audio_source: str = "microphone",
sample_rate: int = 16000,
callback_interval_ms: int = 20,
):
"""Capture audio from a participant.
Args:
participant_id: ID of the participant to capture audio from.
callback: Callback function to handle audio data.
audio_source: Audio source to capture from.
sample_rate: Desired sample rate for audio capture.
callback_interval_ms: Interval between audio callbacks in milliseconds.
"""
await self._client.capture_participant_audio(
participant_id, callback, audio_source, sample_rate, callback_interval_ms
)
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a message to participants.
Args:
frame: The message frame to send.
"""
await self._client.send_message(frame)
@property
def out_sample_rate(self) -> int:
"""Get the output sample rate.
Returns:
The output sample rate in Hz.
"""
return self._client.out_sample_rate
@property
def in_sample_rate(self) -> int:
"""Get the input sample rate.
Returns:
The input sample rate in Hz.
"""
return self._client.in_sample_rate
async def send_interrupt_message(self) -> None:
"""Send an interrupt message to the conversation."""
transport_frame = TransportMessageUrgentFrame(
message={
"message_type": "conversation",
"event_type": "conversation.interrupt",
"conversation_id": self._conversation_id,
}
)
await self.send_message(transport_frame)
async def update_subscriptions(self, participant_settings=None, profile_settings=None):
"""Update subscription settings for participants.
Args:
participant_settings: Per-participant subscription settings.
profile_settings: Global subscription profile settings.
"""
if not self._client:
return
await self._client.update_subscriptions(
participant_settings=participant_settings, profile_settings=profile_settings
)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the transport.
Args:
frame: The audio frame to write.
"""
if not self._client:
return
await self._client.write_audio_frame(frame)
async def register_audio_destination(self, destination: str):
"""Register an audio destination for output.
Args:
destination: The destination identifier to register.
"""
if not self._client:
return
await self._client.register_audio_destination(destination)
class TavusInputTransport(BaseInputTransport):
"""Input transport for receiving audio and events from Tavus conversations.
Handles incoming audio streams from participants and manages audio capture
from the Daily room connected to the Tavus conversation.
"""
def __init__(
self,
client: TavusTransportClient,
params: TransportParams,
**kwargs,
):
"""Initialize the Tavus input transport.
Args:
client: The Tavus transport client instance.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._client = client
self._params = params
# Whether we have seen a StartFrame already.
self._initialized = False
async def setup(self, setup: FrameProcessorSetup):
"""Setup the input transport.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._client.setup(setup)
async def cleanup(self):
"""Cleanup input transport resources."""
await super().cleanup()
await self._client.cleanup()
async def start(self, frame: StartFrame):
"""Start the input transport.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.start(frame)
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the input transport.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.stop()
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.stop()
async def start_capturing_audio(self, participant):
"""Start capturing audio from a participant.
Args:
participant: The participant to capture audio from.
"""
if self._params.audio_in_enabled:
logger.info(
f"TavusTransportClient start capturing audio for participant {participant['id']}"
)
await self._client.capture_participant_audio(
participant_id=participant["id"],
callback=self._on_participant_audio_data,
sample_rate=self._client.in_sample_rate,
)
async def _on_participant_audio_data(
self, participant_id: str, audio: AudioData, audio_source: str
):
"""Handle received participant audio data."""
frame = InputAudioRawFrame(
audio=audio.audio_frames,
sample_rate=audio.audio_frames,
num_channels=audio.num_channels,
)
frame.transport_source = audio_source
await self.push_audio_frame(frame)
class TavusOutputTransport(BaseOutputTransport):
"""Output transport for sending audio and events to Tavus conversations.
Handles outgoing audio streams to participants and manages the custom
audio track expected by the Tavus platform.
"""
def __init__(
self,
client: TavusTransportClient,
params: TransportParams,
**kwargs,
):
"""Initialize the Tavus output transport.
Args:
client: The Tavus transport client instance.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._client = client
self._params = params
# Whether we have seen a StartFrame already.
self._initialized = False
# This is the custom track destination expected by Tavus
self._transport_destination: Optional[str] = "stream"
async def setup(self, setup: FrameProcessorSetup):
"""Setup the output transport.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._client.setup(setup)
async def cleanup(self):
"""Cleanup output transport resources."""
await super().cleanup()
await self._client.cleanup()
async def start(self, frame: StartFrame):
"""Start the output transport.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.start(frame)
if self._transport_destination:
await self._client.register_audio_destination(self._transport_destination)
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.stop()
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.stop()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a message to participants.
Args:
frame: The message frame to send.
"""
logger.info(f"TavusOutputTransport sending message {frame}")
await self._client.send_message(frame)
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process frames and handle interruptions.
Args:
frame: The frame to process.
direction: The direction of frame flow in the pipeline.
"""
await super().process_frame(frame, direction)
if isinstance(frame, StartInterruptionFrame):
await self._handle_interruptions()
async def _handle_interruptions(self):
"""Handle interruption events by sending interrupt message."""
await self._client.send_interrupt_message()
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the Tavus transport.
Args:
frame: The audio frame to write.
"""
# This is the custom track destination expected by Tavus
frame.transport_destination = self._transport_destination
await self._client.write_audio_frame(frame)
async def register_audio_destination(self, destination: str):
"""Register an audio destination.
Args:
destination: The destination identifier to register.
"""
await self._client.register_audio_destination(destination)
class TavusTransport(BaseTransport):
"""Transport implementation for Tavus video calls.
When used, the Pipecat bot joins the same virtual room as the Tavus Avatar and the user.
This is achieved by using `TavusTransportClient`, which initiates the conversation via
`TavusApi` and obtains a room URL that all participants connect to.
"""
def __init__(
self,
bot_name: str,
session: aiohttp.ClientSession,
api_key: str,
replica_id: str,
persona_id: str = "pipecat-stream",
params: TavusParams = TavusParams(),
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the Tavus transport.
Args:
bot_name: The name of the Pipecat bot.
session: aiohttp session used for async HTTP requests.
api_key: Tavus API key for authentication.
replica_id: ID of the replica model used for voice generation.
persona_id: ID of the Tavus persona. Defaults to "pipecat-stream"
to use the Pipecat TTS voice.
params: Optional Tavus-specific configuration parameters.
input_name: Optional name for the input transport.
output_name: Optional name for the output transport.
"""
super().__init__(input_name=input_name, output_name=output_name)
self._params = params
callbacks = TavusCallbacks(
on_participant_joined=self._on_participant_joined,
on_participant_left=self._on_participant_left,
)
self._client = TavusTransportClient(
bot_name="Pipecat",
callbacks=callbacks,
api_key=api_key,
replica_id=replica_id,
persona_id=persona_id,
session=session,
params=params,
)
self._input: Optional[TavusInputTransport] = None
self._output: Optional[TavusOutputTransport] = None
self._tavus_participant_id = None
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected")
async def _on_participant_left(self, participant, reason):
"""Handle participant left events."""
persona_name = await self._client.get_persona_name()
if participant.get("info", {}).get("userName", "") != persona_name:
await self._on_client_disconnected(participant)
async def _on_participant_joined(self, participant):
"""Handle participant joined events."""
# get persona, look up persona_name, set this as the bot name to ignore
persona_name = await self._client.get_persona_name()
# Ignore the Tavus replica's microphone
if participant.get("info", {}).get("userName", "") == persona_name:
self._tavus_participant_id = participant["id"]
else:
await self._on_client_connected(participant)
if self._tavus_participant_id:
logger.debug(f"Ignoring {self._tavus_participant_id}'s microphone")
await self.update_subscriptions(
participant_settings={
self._tavus_participant_id: {
"media": {"microphone": "unsubscribed"},
}
}
)
if self._input:
await self._input.start_capturing_audio(participant)
async def update_subscriptions(self, participant_settings=None, profile_settings=None):
"""Update subscription settings for participants.
Args:
participant_settings: Per-participant subscription settings.
profile_settings: Global subscription profile settings.
"""
await self._client.update_subscriptions(
participant_settings=participant_settings,
profile_settings=profile_settings,
)
def input(self) -> FrameProcessor:
"""Get the input transport for receiving media and events.
Returns:
The Tavus input transport instance.
"""
if not self._input:
self._input = TavusInputTransport(client=self._client, params=self._params)
return self._input
def output(self) -> FrameProcessor:
"""Get the output transport for sending media and events.
Returns:
The Tavus output transport instance.
"""
if not self._output:
self._output = TavusOutputTransport(client=self._client, params=self._params)
return self._output
async def _on_client_connected(self, participant: Any):
"""Handle client connected events."""
await self._call_event_handler("on_client_connected", participant)
async def _on_client_disconnected(self, participant: Any):
"""Handle client disconnected events."""
await self._call_event_handler("on_client_disconnected", participant)
import warnings
from pipecat.transports.tavus.transport import *
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Module `pipecat.transports.services.tavus` is deprecated, "
"use `pipecat.transports.tavus.transport` instead.",
DeprecationWarning,
stacklevel=2,
)

View File

@@ -0,0 +1,612 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Small WebRTC connection implementation for Pipecat.
This module provides a WebRTC connection implementation using aiortc,
with support for audio/video tracks, data channels, and signaling
for real-time communication applications.
"""
import asyncio
import json
import time
from typing import Any, List, Literal, Optional, Union
from loguru import logger
from pydantic import BaseModel, TypeAdapter
from pipecat.utils.base_object import BaseObject
try:
from aiortc import (
MediaStreamTrack,
RTCConfiguration,
RTCIceServer,
RTCPeerConnection,
RTCSessionDescription,
)
from aiortc.rtcrtpreceiver import RemoteStreamTrack
from av.frame import Frame
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use the SmallWebRTC, you need to `pip install pipecat-ai[webrtc]`.")
raise Exception(f"Missing module: {e}")
SIGNALLING_TYPE = "signalling"
AUDIO_TRANSCEIVER_INDEX = 0
VIDEO_TRANSCEIVER_INDEX = 1
SCREEN_VIDEO_TRANSCEIVER_INDEX = 2
class TrackStatusMessage(BaseModel):
"""Message for updating track enabled/disabled status.
Parameters:
type: Message type identifier.
receiver_index: Index of the track receiver to update.
enabled: Whether the track should be enabled or disabled.
"""
type: Literal["trackStatus"]
receiver_index: int
enabled: bool
class RenegotiateMessage(BaseModel):
"""Message requesting WebRTC renegotiation.
Parameters:
type: Message type identifier for renegotiation requests.
"""
type: Literal["renegotiate"] = "renegotiate"
class PeerLeftMessage(BaseModel):
"""Message indicating a peer has left the connection.
Parameters:
type: Message type identifier for peer departure.
"""
type: Literal["peerLeft"] = "peerLeft"
class SignallingMessage:
"""Union types for signaling message handling.
Parameters:
Inbound: Types of messages that can be received from peers.
outbound: Types of messages that can be sent to peers.
"""
Inbound = Union[TrackStatusMessage] # in case we need to add new messages in the future
outbound = Union[RenegotiateMessage]
class SmallWebRTCTrack:
"""Wrapper for WebRTC media tracks with enabled/disabled state management.
Provides additional functionality on top of aiortc MediaStreamTrack including
enable/disable control and frame discarding for audio and video streams.
"""
def __init__(self, track: MediaStreamTrack):
"""Initialize the WebRTC track wrapper.
Args:
track: The underlying MediaStreamTrack to wrap.
index: The index of the track in the transceiver (0 for mic, 1 for cam, 2 for screen)
"""
self._track = track
self._enabled = True
def set_enabled(self, enabled: bool) -> None:
"""Enable or disable the track.
Args:
enabled: Whether the track should be enabled for receiving frames.
"""
self._enabled = enabled
def is_enabled(self) -> bool:
"""Check if the track is currently enabled.
Returns:
True if the track is enabled for receiving frames.
"""
return self._enabled
async def discard_old_frames(self):
"""Discard old frames from the track queue to reduce latency."""
remote_track = self._track
if isinstance(remote_track, RemoteStreamTrack):
if not hasattr(remote_track, "_queue") or not isinstance(
remote_track._queue, asyncio.Queue
):
print("Warning: _queue does not exist or has changed in aiortc.")
return
logger.debug("Discarding old frames")
while not remote_track._queue.empty():
remote_track._queue.get_nowait() # Remove the oldest frame
remote_track._queue.task_done()
async def recv(self) -> Optional[Frame]:
"""Receive the next frame from the track.
Returns:
The next frame, except for video tracks, where it returns the frame only if the track is enabled, otherwise, returns None.
"""
if not self._enabled and self._track.kind == "video":
return None
return await self._track.recv()
def __getattr__(self, name):
"""Forward attribute access to the underlying track.
Args:
name: The attribute name to access.
Returns:
The attribute value from the underlying track.
"""
# Forward other attribute/method calls to the underlying track
return getattr(self._track, name)
# Alias so we don't need to expose RTCIceServer
IceServer = RTCIceServer
class SmallWebRTCConnection(BaseObject):
"""WebRTC connection implementation using aiortc.
Provides WebRTC peer connection functionality including ICE server configuration,
track management, data channel communication, and connection state handling
for real-time audio/video communication.
"""
def __init__(self, ice_servers: Optional[Union[List[str], List[IceServer]]] = None):
"""Initialize the WebRTC connection.
Args:
ice_servers: List of ICE servers as URLs or IceServer objects.
Raises:
TypeError: If ice_servers contains mixed types or unsupported types.
"""
super().__init__()
if not ice_servers:
self.ice_servers: List[IceServer] = []
elif all(isinstance(s, IceServer) for s in ice_servers):
self.ice_servers = ice_servers
elif all(isinstance(s, str) for s in ice_servers):
self.ice_servers = [IceServer(urls=s) for s in ice_servers]
else:
raise TypeError("ice_servers must be either List[str] or List[RTCIceServer]")
self._connect_invoked = False
self._track_map = {}
self._track_getters = {
AUDIO_TRANSCEIVER_INDEX: self.audio_input_track,
VIDEO_TRANSCEIVER_INDEX: self.video_input_track,
SCREEN_VIDEO_TRANSCEIVER_INDEX: self.screen_video_input_track,
}
self._initialize()
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("app-message")
self._register_event_handler("track-started")
self._register_event_handler("track-ended")
# connection states
self._register_event_handler("connecting")
self._register_event_handler("connected")
self._register_event_handler("disconnected")
self._register_event_handler("closed")
self._register_event_handler("failed")
self._register_event_handler("new")
@property
def pc(self) -> RTCPeerConnection:
"""Get the underlying RTCPeerConnection.
Returns:
The aiortc RTCPeerConnection instance.
"""
return self._pc
@property
def pc_id(self) -> str:
"""Get the peer connection identifier.
Returns:
The unique identifier for this peer connection.
"""
return self._pc_id
def _initialize(self):
"""Initialize the peer connection and associated components."""
logger.debug("Initializing new peer connection")
rtc_config = RTCConfiguration(iceServers=self.ice_servers)
self._answer: Optional[RTCSessionDescription] = None
self._pc = RTCPeerConnection(rtc_config)
self._pc_id = self.name
self._setup_listeners()
self._data_channel = None
self._renegotiation_in_progress = False
self._last_received_time = None
self._message_queue = []
self._pending_app_messages = []
def _setup_listeners(self):
"""Set up event listeners for the peer connection."""
@self._pc.on("datachannel")
def on_datachannel(channel):
self._data_channel = channel
# Flush queued messages once the data channel is open
@channel.on("open")
async def on_open():
logger.debug("Data channel is open, flushing queued messages")
while self._message_queue:
message = self._message_queue.pop(0)
self._data_channel.send(message)
@channel.on("message")
async def on_message(message):
try:
# aiortc does not provide any way so we can be aware when we are disconnected,
# so we are using this keep alive message as a way to implement that
if isinstance(message, str) and message.startswith("ping"):
self._last_received_time = time.time()
else:
json_message = json.loads(message)
if json_message["type"] == SIGNALLING_TYPE and json_message.get("message"):
self._handle_signalling_message(json_message["message"])
else:
if self.is_connected():
await self._call_event_handler("app-message", json_message)
else:
logger.debug("Client not connected. Queuing app-message.")
self._pending_app_messages.append(json_message)
except Exception as e:
logger.exception(f"Error parsing JSON message {message}, {e}")
# Despite the fact that aiortc provides this listener, they don't have a status for "disconnected"
# So, in case we loose connection, this event will not be triggered
@self._pc.on("connectionstatechange")
async def on_connectionstatechange():
await self._handle_new_connection_state()
# Despite the fact that aiortc provides this listener, they don't have a status for "disconnected"
# So, in case we loose connection, this event will not be triggered
@self._pc.on("iceconnectionstatechange")
async def on_iceconnectionstatechange():
logger.debug(
f"ICE connection state is {self._pc.iceConnectionState}, connection is {self._pc.connectionState}"
)
@self._pc.on("icegatheringstatechange")
async def on_icegatheringstatechange():
logger.debug(f"ICE gathering state is {self._pc.iceGatheringState}")
@self._pc.on("track")
async def on_track(track):
logger.debug(f"Track {track.kind} received")
await self._call_event_handler("track-started", track)
@track.on("ended")
async def on_ended():
logger.debug(f"Track {track.kind} ended")
await self._call_event_handler("track-ended", track)
async def _create_answer(self, sdp: str, type: str):
"""Create an SDP answer for the given offer."""
offer = RTCSessionDescription(sdp=sdp, type=type)
await self._pc.setRemoteDescription(offer)
# For some reason, aiortc is not respecting the SDP for the transceivers to be sendrcv
# so we are basically forcing it to act this way
self.force_transceivers_to_send_recv()
# this answer does not contain the ice candidates, which will be gathered later, after the setLocalDescription
logger.debug(f"Creating answer")
local_answer = await self._pc.createAnswer()
await self._pc.setLocalDescription(local_answer)
logger.debug(f"Setting the answer after the local description is created")
self._answer = self._pc.localDescription
async def initialize(self, sdp: str, type: str):
"""Initialize the connection with an SDP offer.
Args:
sdp: The SDP offer string.
type: The SDP type (usually "offer").
"""
await self._create_answer(sdp, type)
async def connect(self):
"""Connect the WebRTC peer connection and handle initial setup."""
self._connect_invoked = True
# If we already connected, trigger again the connected event
if self.is_connected():
await self._call_event_handler("connected")
logger.debug("Flushing pending app-messages")
for message in self._pending_app_messages:
await self._call_event_handler("app-message", message)
# We are renegotiating here, because likely we have loose the first video frames
# and aiortc does not handle that pretty well.
video_input_track = self.video_input_track()
if video_input_track:
await self.video_input_track().discard_old_frames()
screen_video_input_track = self.screen_video_input_track()
if screen_video_input_track:
await self.screen_video_input_track().discard_old_frames()
if video_input_track or screen_video_input_track:
# This prevents an issue where sometimes the WebRTC connection can be established
# before the bot is ready to receive video. When that happens, we can lose a couple
# of seconds of video before we received a key frame to finally start displaying it.
self.ask_to_renegotiate()
async def renegotiate(self, sdp: str, type: str, restart_pc: bool = False):
"""Renegotiate the WebRTC connection with new parameters.
Args:
sdp: The new SDP offer string.
type: The SDP type (usually "offer").
restart_pc: Whether to restart the peer connection entirely.
"""
logger.debug(f"Renegotiating {self._pc_id}")
if restart_pc:
await self._call_event_handler("disconnected")
logger.debug("Closing old peer connection")
# removing the listeners to prevent the bot from closing
self._pc.remove_all_listeners()
await self._close()
# we are initializing a new peer connection in this case.
self._initialize()
await self._create_answer(sdp, type)
# Maybe we should refactor to receive a message from the client side when the renegotiation is completed.
# or look at the peer connection listeners
# but this is good enough for now for testing.
async def delayed_task():
await asyncio.sleep(2)
self._renegotiation_in_progress = False
asyncio.create_task(delayed_task())
def force_transceivers_to_send_recv(self):
"""Force all transceivers to bidirectional send/receive mode."""
transceivers = self._pc.getTransceivers()
# For now, we only support sendrecv for camera audio and video (the first two transceivers)
for i, transceiver in enumerate(transceivers):
if i < 2: # First two transceivers (camera audio and video)
transceiver.direction = "sendrecv"
else:
transceiver.direction = "recvonly"
# logger.debug(
# f"Transceiver: {transceiver}, Mid: {transceiver.mid}, Direction: {transceiver.direction}"
# )
# logger.debug(f"Sender track: {transceiver.sender.track}")
def replace_audio_track(self, track):
"""Replace the audio track in the first transceiver.
Args:
track: The new audio track to use for sending.
"""
logger.debug(f"Replacing audio track {track.kind}")
# Transceivers always appear in creation-order for both peers
# For now we are only considering that we are going to have 02 transceivers,
# one for audio and one for video
transceivers = self._pc.getTransceivers()
if len(transceivers) > 0 and transceivers[0].sender:
transceivers[0].sender.replaceTrack(track)
else:
logger.warning("Audio transceiver not found. Cannot replace audio track.")
def replace_video_track(self, track):
"""Replace the video track in the second transceiver.
Args:
track: The new video track to use for sending.
"""
logger.debug(f"Replacing video track {track.kind}")
# Transceivers always appear in creation-order for both peers
# For now we are only considering that we are going to have 02 transceivers,
# one for audio and one for video
transceivers = self._pc.getTransceivers()
if len(transceivers) > 1 and transceivers[1].sender:
transceivers[1].sender.replaceTrack(track)
else:
logger.warning("Video transceiver not found. Cannot replace video track.")
def replace_screen_video_track(self, track):
"""Replace the screen video track in the second transceiver.
Args:
track: The new screen video track to use for sending.
"""
logger.debug(f"Replacing screen video track {track.kind}")
# Transceivers always appear in creation-order for both peers
# For now we are only considering that we are going to have 02 transceivers,
# one for audio and one for video
transceivers = self._pc.getTransceivers()
if len(transceivers) > 2 and transceivers[2].sender:
transceivers[2].sender.replaceTrack(track)
else:
logger.warning("Screen video transceiver not found. Cannot replace screen video track.")
async def disconnect(self):
"""Disconnect from the WebRTC peer connection."""
self.send_app_message({"type": SIGNALLING_TYPE, "message": PeerLeftMessage().model_dump()})
await self._close()
async def _close(self):
"""Close the peer connection and cleanup resources."""
if self._pc:
await self._pc.close()
self._message_queue.clear()
self._pending_app_messages.clear()
self._track_map = {}
def get_answer(self):
"""Get the SDP answer for the current connection.
Returns:
Dictionary containing SDP answer, type, and peer connection ID,
or None if no answer is available.
"""
if not self._answer:
return None
return {
"sdp": self._answer.sdp,
"type": self._answer.type,
"pc_id": self._pc_id,
}
async def _handle_new_connection_state(self):
"""Handle changes in the peer connection state."""
state = self._pc.connectionState
if state == "connected" and not self._connect_invoked:
# We are going to wait until the pipeline is ready before triggering the event
return
logger.debug(f"Connection state changed to: {state}")
await self._call_event_handler(state)
if state == "failed":
logger.warning("Connection failed, closing peer connection.")
await self._close()
# Despite the fact that aiortc provides this listener, they don't have a status for "disconnected"
# So, there is no advantage in looking at self._pc.connectionState
# That is why we are trying to keep our own state
def is_connected(self) -> bool:
"""Check if the WebRTC connection is currently active.
Returns:
True if the connection is active and receiving data.
"""
# If the small webrtc transport has never invoked to connect
# we are acting like if we are not connected
if not self._connect_invoked:
return False
if self._last_received_time is None:
# if we have never received a message, it is probably because the client has not created a data channel
# so we are going to trust aiortc in this case
return self._pc.connectionState == "connected"
# Checks if the last received ping was within the last 3 seconds.
return (time.time() - self._last_received_time) < 3
def audio_input_track(self):
"""Get the audio input track wrapper.
Returns:
SmallWebRTCTrack wrapper for the audio track, or None if unavailable.
"""
if self._track_map.get(AUDIO_TRANSCEIVER_INDEX):
return self._track_map[AUDIO_TRANSCEIVER_INDEX]
# Transceivers always appear in creation-order for both peers
# For support 3 receivers in the following order:
# audio, video, screenVideo
transceivers = self._pc.getTransceivers()
if len(transceivers) == 0 or not transceivers[AUDIO_TRANSCEIVER_INDEX].receiver:
logger.warning("No audio transceiver is available")
return None
track = transceivers[AUDIO_TRANSCEIVER_INDEX].receiver.track
audio_track = SmallWebRTCTrack(track) if track else None
self._track_map[AUDIO_TRANSCEIVER_INDEX] = audio_track
return audio_track
def video_input_track(self):
"""Get the video input track wrapper.
Returns:
SmallWebRTCTrack wrapper for the video track, or None if unavailable.
"""
if self._track_map.get(VIDEO_TRANSCEIVER_INDEX):
return self._track_map[VIDEO_TRANSCEIVER_INDEX]
# Transceivers always appear in creation-order for both peers
# For support 3 receivers in the following order:
# audio, video, screenVideo
transceivers = self._pc.getTransceivers()
if len(transceivers) <= 1 or not transceivers[VIDEO_TRANSCEIVER_INDEX].receiver:
logger.warning("No video transceiver is available")
return None
track = transceivers[VIDEO_TRANSCEIVER_INDEX].receiver.track
video_track = SmallWebRTCTrack(track) if track else None
self._track_map[VIDEO_TRANSCEIVER_INDEX] = video_track
return video_track
def screen_video_input_track(self):
"""Get the screen video input track wrapper.
Returns:
SmallWebRTCTrack wrapper for the screen video track, or None if unavailable.
"""
if self._track_map.get(SCREEN_VIDEO_TRANSCEIVER_INDEX):
return self._track_map[SCREEN_VIDEO_TRANSCEIVER_INDEX]
# Transceivers always appear in creation-order for both peers
# For support 3 receivers in the following order:
# audio, video, screenVideo
transceivers = self._pc.getTransceivers()
if len(transceivers) <= 2 or not transceivers[SCREEN_VIDEO_TRANSCEIVER_INDEX].receiver:
logger.warning("No screen video transceiver is available")
return None
track = transceivers[SCREEN_VIDEO_TRANSCEIVER_INDEX].receiver.track
video_track = SmallWebRTCTrack(track) if track else None
self._track_map[SCREEN_VIDEO_TRANSCEIVER_INDEX] = video_track
return video_track
def send_app_message(self, message: Any):
"""Send an application message through the data channel.
Args:
message: The message to send (will be JSON serialized).
"""
json_message = json.dumps(message)
if self._data_channel and self._data_channel.readyState == "open":
self._data_channel.send(json_message)
else:
logger.debug("Data channel not ready, queuing message")
self._message_queue.append(json_message)
def ask_to_renegotiate(self):
"""Request renegotiation of the WebRTC connection."""
if self._renegotiation_in_progress:
return
self._renegotiation_in_progress = True
self.send_app_message(
{"type": SIGNALLING_TYPE, "message": RenegotiateMessage().model_dump()}
)
def _handle_signalling_message(self, message):
"""Handle incoming signaling messages."""
logger.debug(f"Signalling message received: {message}")
inbound_adapter = TypeAdapter(SignallingMessage.Inbound)
signalling_message = inbound_adapter.validate_python(message)
match signalling_message:
case TrackStatusMessage():
track = (
self._track_getters.get(signalling_message.receiver_index) or (lambda: None)
)()
if track:
track.set_enabled(signalling_message.enabled)

View File

@@ -0,0 +1,935 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Small WebRTC transport implementation for Pipecat.
This module provides a WebRTC transport implementation using aiortc for
real-time audio and video communication. It supports bidirectional media
streaming, application messaging, and client connection management.
"""
import asyncio
import fractions
import time
from collections import deque
from typing import Any, Awaitable, Callable, Optional
import numpy as np
from loguru import logger
from pydantic import BaseModel
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
OutputImageRawFrame,
SpriteFrame,
StartFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
UserImageRawFrame,
UserImageRequestFrame,
)
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.transports.smallwebrtc.connection import SmallWebRTCConnection
try:
import cv2
from aiortc import VideoStreamTrack
from aiortc.mediastreams import AudioStreamTrack, MediaStreamError
from av import AudioFrame, AudioResampler, VideoFrame
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use the SmallWebRTC, you need to `pip install pipecat-ai[webrtc]`.")
raise Exception(f"Missing module: {e}")
CAM_VIDEO_SOURCE = "camera"
SCREEN_VIDEO_SOURCE = "screenVideo"
MIC_AUDIO_SOURCE = "microphone"
class SmallWebRTCCallbacks(BaseModel):
"""Callback handlers for SmallWebRTC events.
Parameters:
on_app_message: Called when an application message is received.
on_client_connected: Called when a client establishes connection.
on_client_disconnected: Called when a client disconnects.
"""
on_app_message: Callable[[Any], Awaitable[None]]
on_client_connected: Callable[[SmallWebRTCConnection], Awaitable[None]]
on_client_disconnected: Callable[[SmallWebRTCConnection], Awaitable[None]]
class RawAudioTrack(AudioStreamTrack):
"""Custom audio stream track for WebRTC output.
Handles audio frame generation and timing for WebRTC transmission,
supporting queued audio data with proper synchronization.
"""
def __init__(self, sample_rate):
"""Initialize the raw audio track.
Args:
sample_rate: The audio sample rate in Hz.
"""
super().__init__()
self._sample_rate = sample_rate
self._samples_per_10ms = sample_rate * 10 // 1000
self._bytes_per_10ms = self._samples_per_10ms * 2 # 16-bit (2 bytes per sample)
self._timestamp = 0
self._start = time.time()
# Queue of (bytes, future), broken into 10ms sub chunks as needed
self._chunk_queue = deque()
def add_audio_bytes(self, audio_bytes: bytes):
"""Add audio bytes to the buffer for transmission.
Args:
audio_bytes: Raw audio data to queue for transmission.
Returns:
A Future that completes when the data is processed.
Raises:
ValueError: If audio bytes are not a multiple of 10ms size.
"""
if len(audio_bytes) % self._bytes_per_10ms != 0:
raise ValueError("Audio bytes must be a multiple of 10ms size.")
future = asyncio.get_running_loop().create_future()
# Break input into 10ms chunks
for i in range(0, len(audio_bytes), self._bytes_per_10ms):
chunk = audio_bytes[i : i + self._bytes_per_10ms]
# Only the last chunk carries the future to be resolved once fully consumed
fut = future if i + self._bytes_per_10ms >= len(audio_bytes) else None
self._chunk_queue.append((chunk, fut))
return future
async def recv(self):
"""Return the next audio frame for WebRTC transmission.
Returns:
An AudioFrame containing the next audio data or silence.
"""
# Compute required wait time for synchronization
if self._timestamp > 0:
wait = self._start + (self._timestamp / self._sample_rate) - time.time()
if wait > 0:
await asyncio.sleep(wait)
if self._chunk_queue:
chunk, future = self._chunk_queue.popleft()
if future and not future.done():
future.set_result(True)
else:
chunk = bytes(self._bytes_per_10ms) # silence
# Convert the byte data to an ndarray of int16 samples
samples = np.frombuffer(chunk, dtype=np.int16)
# Create AudioFrame
frame = AudioFrame.from_ndarray(samples[None, :], layout="mono")
frame.sample_rate = self._sample_rate
frame.pts = self._timestamp
frame.time_base = fractions.Fraction(1, self._sample_rate)
self._timestamp += self._samples_per_10ms
return frame
class RawVideoTrack(VideoStreamTrack):
"""Custom video stream track for WebRTC output.
Handles video frame queuing and conversion for WebRTC transmission.
"""
def __init__(self, width, height):
"""Initialize the raw video track.
Args:
width: Video frame width in pixels.
height: Video frame height in pixels.
"""
super().__init__()
self._width = width
self._height = height
self._video_buffer = asyncio.Queue()
def add_video_frame(self, frame):
"""Add a video frame to the transmission buffer.
Args:
frame: The video frame to queue for transmission.
"""
self._video_buffer.put_nowait(frame)
async def recv(self):
"""Return the next video frame for WebRTC transmission.
Returns:
A VideoFrame ready for WebRTC transmission.
"""
raw_frame = await self._video_buffer.get()
# Convert bytes to NumPy array
frame_data = np.frombuffer(raw_frame.image, dtype=np.uint8).reshape(
(self._height, self._width, 3)
)
frame = VideoFrame.from_ndarray(frame_data, format="rgb24")
# Assign timestamp
frame.pts, frame.time_base = await self.next_timestamp()
return frame
class SmallWebRTCClient:
"""WebRTC client implementation for handling connections and media streams.
Manages WebRTC peer connections, audio/video streaming, and application
messaging through the SmallWebRTCConnection interface.
"""
FORMAT_CONVERSIONS = {
"yuv420p": cv2.COLOR_YUV2RGB_I420,
"yuvj420p": cv2.COLOR_YUV2RGB_I420, # OpenCV treats both the same
"nv12": cv2.COLOR_YUV2RGB_NV12,
"gray": cv2.COLOR_GRAY2RGB,
}
def __init__(self, webrtc_connection: SmallWebRTCConnection, callbacks: SmallWebRTCCallbacks):
"""Initialize the WebRTC client.
Args:
webrtc_connection: The underlying WebRTC connection handler.
callbacks: Event callbacks for connection and message handling.
"""
self._webrtc_connection = webrtc_connection
self._closing = False
self._callbacks = callbacks
self._audio_output_track = None
self._video_output_track = None
self._audio_input_track: Optional[AudioStreamTrack] = None
self._video_input_track: Optional[VideoStreamTrack] = None
self._screen_video_track: Optional[VideoStreamTrack] = None
self._params = None
self._audio_in_channels = None
self._in_sample_rate = None
self._out_sample_rate = None
self._leave_counter = 0
# We are always resampling it for 16000 if the sample_rate that we receive is bigger than that.
# otherwise we face issues with Silero VAD
self._pipecat_resampler = AudioResampler("s16", "mono", 16000)
@self._webrtc_connection.event_handler("connected")
async def on_connected(connection: SmallWebRTCConnection):
logger.debug("Peer connection established.")
await self._handle_client_connected()
@self._webrtc_connection.event_handler("disconnected")
async def on_disconnected(connection: SmallWebRTCConnection):
logger.debug("Peer connection lost.")
await self._handle_peer_disconnected()
@self._webrtc_connection.event_handler("closed")
async def on_closed(connection: SmallWebRTCConnection):
logger.debug("Client connection closed.")
await self._handle_client_closed()
@self._webrtc_connection.event_handler("app-message")
async def on_app_message(connection: SmallWebRTCConnection, message: Any):
await self._handle_app_message(message)
def _convert_frame(self, frame_array: np.ndarray, format_name: str) -> np.ndarray:
"""Convert a video frame to RGB format based on the input format.
Args:
frame_array: The input frame as a NumPy array.
format_name: The format of the input frame.
Returns:
The converted RGB frame as a NumPy array.
Raises:
ValueError: If the format is unsupported.
"""
if format_name.startswith("rgb"): # Already in RGB, no conversion needed
return frame_array
conversion_code = SmallWebRTCClient.FORMAT_CONVERSIONS.get(format_name)
if conversion_code is None:
raise ValueError(f"Unsupported format: {format_name}")
return cv2.cvtColor(frame_array, conversion_code)
async def read_video_frame(self, video_source: str):
"""Read video frames from the WebRTC connection.
Reads a video frame from the given MediaStreamTrack, converts it to RGB,
and creates an InputImageRawFrame.
Args:
video_source: Video source to capture ("camera" or "screenVideo").
Yields:
UserImageRawFrame objects containing video data from the peer.
"""
while True:
video_track = (
self._video_input_track
if video_source == CAM_VIDEO_SOURCE
else self._screen_video_track
)
if video_track is None:
await asyncio.sleep(0.01)
continue
try:
frame = await asyncio.wait_for(video_track.recv(), timeout=2.0)
except asyncio.TimeoutError:
if self._webrtc_connection.is_connected():
logger.warning("Timeout: No video frame received within the specified time.")
# self._webrtc_connection.ask_to_renegotiate()
frame = None
except MediaStreamError:
logger.warning("Received an unexpected media stream error while reading the audio.")
frame = None
if frame is None or not isinstance(frame, VideoFrame):
# If no valid frame, sleep for a bit
await asyncio.sleep(0.01)
continue
format_name = frame.format.name
# Convert frame to NumPy array in its native format
frame_array = frame.to_ndarray(format=format_name)
frame_rgb = self._convert_frame(frame_array, format_name)
image_frame = UserImageRawFrame(
user_id=self._webrtc_connection.pc_id,
image=frame_rgb.tobytes(),
size=(frame.width, frame.height),
format="RGB",
)
image_frame.transport_source = video_source
yield image_frame
async def read_audio_frame(self):
"""Read audio frames from the WebRTC connection.
Reads 20ms of audio from the given MediaStreamTrack and creates an InputAudioRawFrame.
Yields:
InputAudioRawFrame objects containing audio data from the peer.
"""
while True:
if self._audio_input_track is None:
await asyncio.sleep(0.01)
continue
try:
frame = await asyncio.wait_for(self._audio_input_track.recv(), timeout=2.0)
except asyncio.TimeoutError:
if self._webrtc_connection.is_connected():
logger.warning("Timeout: No audio frame received within the specified time.")
frame = None
except MediaStreamError:
logger.warning("Received an unexpected media stream error while reading the audio.")
frame = None
if frame is None or not isinstance(frame, AudioFrame):
# If we don't read any audio let's sleep for a little bit (i.e. busy wait).
await asyncio.sleep(0.01)
continue
if frame.sample_rate > self._in_sample_rate:
resampled_frames = self._pipecat_resampler.resample(frame)
for resampled_frame in resampled_frames:
# 16-bit PCM bytes
pcm_bytes = resampled_frame.to_ndarray().astype(np.int16).tobytes()
audio_frame = InputAudioRawFrame(
audio=pcm_bytes,
sample_rate=resampled_frame.sample_rate,
num_channels=self._audio_in_channels,
)
yield audio_frame
else:
# 16-bit PCM bytes
pcm_bytes = frame.to_ndarray().astype(np.int16).tobytes()
audio_frame = InputAudioRawFrame(
audio=pcm_bytes,
sample_rate=frame.sample_rate,
num_channels=self._audio_in_channels,
)
yield audio_frame
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebRTC connection.
Args:
frame: The audio frame to transmit.
"""
if self._can_send() and self._audio_output_track:
await self._audio_output_track.add_audio_bytes(frame.audio)
async def write_video_frame(self, frame: OutputImageRawFrame):
"""Write a video frame to the WebRTC connection.
Args:
frame: The video frame to transmit.
"""
if self._can_send() and self._video_output_track:
self._video_output_track.add_video_frame(frame)
async def setup(self, _params: TransportParams, frame):
"""Set up the client with transport parameters.
Args:
_params: Transport configuration parameters.
frame: The initialization frame containing setup data.
"""
self._audio_in_channels = _params.audio_in_channels
self._in_sample_rate = _params.audio_in_sample_rate or frame.audio_in_sample_rate
self._out_sample_rate = _params.audio_out_sample_rate or frame.audio_out_sample_rate
self._params = _params
self._leave_counter += 1
async def connect(self):
"""Establish the WebRTC connection."""
if self._webrtc_connection.is_connected():
# already initialized
return
logger.info(f"Connecting to Small WebRTC")
await self._webrtc_connection.connect()
async def disconnect(self):
"""Disconnect from the WebRTC peer."""
self._leave_counter -= 1
if self._leave_counter > 0:
return
if self.is_connected and not self.is_closing:
logger.info(f"Disconnecting to Small WebRTC")
self._closing = True
await self._webrtc_connection.disconnect()
await self._handle_peer_disconnected()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send an application message through the WebRTC connection.
Args:
frame: The message frame to send.
"""
if self._can_send():
self._webrtc_connection.send_app_message(frame.message)
async def _handle_client_connected(self):
"""Handle client connection establishment."""
# There is nothing to do here yet, the pipeline is still not ready
if not self._params:
return
self._audio_input_track = self._webrtc_connection.audio_input_track()
self._video_input_track = self._webrtc_connection.video_input_track()
self._screen_video_track = self._webrtc_connection.screen_video_input_track()
if self._params.audio_out_enabled:
self._audio_output_track = RawAudioTrack(sample_rate=self._out_sample_rate)
self._webrtc_connection.replace_audio_track(self._audio_output_track)
if self._params.video_out_enabled:
self._video_output_track = RawVideoTrack(
width=self._params.video_out_width, height=self._params.video_out_height
)
self._webrtc_connection.replace_video_track(self._video_output_track)
await self._callbacks.on_client_connected(self._webrtc_connection)
async def _handle_peer_disconnected(self):
"""Handle peer disconnection cleanup."""
self._audio_input_track = None
self._video_input_track = None
self._screen_video_track = None
self._audio_output_track = None
self._video_output_track = None
async def _handle_client_closed(self):
"""Handle client connection closure."""
self._audio_input_track = None
self._video_input_track = None
self._screen_video_track = None
self._audio_output_track = None
self._video_output_track = None
await self._callbacks.on_client_disconnected(self._webrtc_connection)
async def _handle_app_message(self, message: Any):
"""Handle incoming application messages."""
await self._callbacks.on_app_message(message)
def _can_send(self):
"""Check if the connection is ready for sending data."""
return self.is_connected and not self.is_closing
@property
def is_connected(self) -> bool:
"""Check if the WebRTC connection is established.
Returns:
True if connected to the peer.
"""
return self._webrtc_connection.is_connected()
@property
def is_closing(self) -> bool:
"""Check if the connection is in the process of closing.
Returns:
True if the connection is closing.
"""
return self._closing
class SmallWebRTCInputTransport(BaseInputTransport):
"""Input transport implementation for SmallWebRTC.
Handles incoming audio and video streams from WebRTC peers,
including user image requests and application message handling.
"""
def __init__(
self,
client: SmallWebRTCClient,
params: TransportParams,
**kwargs,
):
"""Initialize the WebRTC input transport.
Args:
client: The WebRTC client instance.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._client = client
self._params = params
self._receive_audio_task = None
self._receive_video_task = None
self._receive_screen_video_task = None
self._image_requests = {}
# Whether we have seen a StartFrame already.
self._initialized = False
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process incoming frames including user image requests.
Args:
frame: The frame to process.
direction: The direction of frame flow in the pipeline.
"""
await super().process_frame(frame, direction)
if isinstance(frame, UserImageRequestFrame):
await self.request_participant_image(frame)
async def start(self, frame: StartFrame):
"""Start the input transport and establish WebRTC connection.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.setup(self._params, frame)
await self._client.connect()
await self.set_transport_ready(frame)
if not self._receive_audio_task and self._params.audio_in_enabled:
self._receive_audio_task = self.create_task(self._receive_audio())
if not self._receive_video_task and self._params.video_in_enabled:
self._receive_video_task = self.create_task(self._receive_video(CAM_VIDEO_SOURCE))
async def _stop_tasks(self):
"""Stop all background tasks."""
if self._receive_audio_task:
await self.cancel_task(self._receive_audio_task)
self._receive_audio_task = None
if self._receive_video_task:
await self.cancel_task(self._receive_video_task)
self._receive_video_task = None
async def stop(self, frame: EndFrame):
"""Stop the input transport and disconnect from WebRTC.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._stop_tasks()
await self._client.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport and disconnect immediately.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._stop_tasks()
await self._client.disconnect()
async def _receive_audio(self):
"""Background task for receiving audio frames from WebRTC."""
try:
audio_iterator = self._client.read_audio_frame()
async for audio_frame in audio_iterator:
if audio_frame:
await self.push_audio_frame(audio_frame)
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
async def _receive_video(self, video_source: str):
"""Background task for receiving video frames from WebRTC.
Args:
video_source: Video source to capture ("camera" or "screenVideo").
"""
try:
video_iterator = self._client.read_video_frame(video_source)
async for video_frame in video_iterator:
if video_frame:
await self.push_video_frame(video_frame)
# Check if there are any pending image requests and create UserImageRawFrame
if self._image_requests:
for req_id, request_frame in list(self._image_requests.items()):
if request_frame.video_source == video_source:
# Create UserImageRawFrame using the current video frame
image_frame = UserImageRawFrame(
user_id=request_frame.user_id,
request=request_frame,
image=video_frame.image,
size=video_frame.size,
format=video_frame.format,
)
image_frame.transport_source = video_source
# Push the frame to the pipeline
await self.push_video_frame(image_frame)
# Remove from pending requests
del self._image_requests[req_id]
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
async def push_app_message(self, message: Any):
"""Push an application message into the pipeline.
Args:
message: The application message to process.
"""
logger.debug(f"Received app message inside SmallWebRTCInputTransport {message}")
frame = TransportMessageUrgentFrame(message=message)
await self.push_frame(frame)
# Add this method similar to DailyInputTransport.request_participant_image
async def request_participant_image(self, frame: UserImageRequestFrame):
"""Request an image frame from the participant's video stream.
When a UserImageRequestFrame is received, this method will store the request
and the next video frame received will be converted to a UserImageRawFrame.
Args:
frame: The user image request frame.
"""
logger.debug(f"Requesting image from participant: {frame.user_id}")
# Store the request
request_id = f"{frame.function_name}:{frame.tool_call_id}"
self._image_requests[request_id] = frame
# Default to camera if no source specified
if frame.video_source is None:
frame.video_source = CAM_VIDEO_SOURCE
# If we're not already receiving video, try to get a frame now
if (
frame.video_source == CAM_VIDEO_SOURCE
and not self._receive_video_task
and self._params.video_in_enabled
):
# Start video reception if it's not already running
self._receive_video_task = self.create_task(self._receive_video(CAM_VIDEO_SOURCE))
elif (
frame.video_source == SCREEN_VIDEO_SOURCE
and not self._receive_screen_video_task
and self._params.video_in_enabled
):
# Start screen video reception if it's not already running
self._receive_screen_video_task = self.create_task(
self._receive_video(SCREEN_VIDEO_SOURCE)
)
async def capture_participant_media(
self,
source: str = CAM_VIDEO_SOURCE,
):
"""Capture media from a specific participant.
Args:
source: Media source to capture from. ("camera", "microphone", or "screenVideo")
"""
# If we're not already receiving video, try to get a frame now
if (
source == MIC_AUDIO_SOURCE
and not self._receive_audio_task
and self._params.audio_in_enabled
):
# Start audio reception if it's not already running
self._receive_audio_task = self.create_task(self._receive_audio())
elif (
source == CAM_VIDEO_SOURCE
and not self._receive_video_task
and self._params.video_in_enabled
):
# Start video reception if it's not already running
self._receive_video_task = self.create_task(self._receive_video(CAM_VIDEO_SOURCE))
elif (
source == SCREEN_VIDEO_SOURCE
and not self._receive_screen_video_task
and self._params.video_in_enabled
):
# Start screen video reception if it's not already running
self._receive_screen_video_task = self.create_task(
self._receive_video(SCREEN_VIDEO_SOURCE)
)
class SmallWebRTCOutputTransport(BaseOutputTransport):
"""Output transport implementation for SmallWebRTC.
Handles outgoing audio and video streams to WebRTC peers,
including transport message sending.
"""
def __init__(
self,
client: SmallWebRTCClient,
params: TransportParams,
**kwargs,
):
"""Initialize the WebRTC output transport.
Args:
client: The WebRTC client instance.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._client = client
self._params = params
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the output transport and establish WebRTC connection.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.setup(self._params, frame)
await self._client.connect()
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport and disconnect from WebRTC.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and disconnect immediately.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.disconnect()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message through the WebRTC connection.
Args:
frame: The transport message frame to send.
"""
await self._client.send_message(frame)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebRTC connection.
Args:
frame: The output audio frame to transmit.
"""
await self._client.write_audio_frame(frame)
async def write_video_frame(self, frame: OutputImageRawFrame):
"""Write a video frame to the WebRTC connection.
Args:
frame: The output video frame to transmit.
"""
await self._client.write_video_frame(frame)
class SmallWebRTCTransport(BaseTransport):
"""WebRTC transport implementation for real-time communication.
Provides bidirectional audio and video streaming over WebRTC connections
with support for application messaging and connection event handling.
"""
def __init__(
self,
webrtc_connection: SmallWebRTCConnection,
params: TransportParams,
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the WebRTC transport.
Args:
webrtc_connection: The underlying WebRTC connection handler.
params: Transport configuration parameters.
input_name: Optional name for the input processor.
output_name: Optional name for the output processor.
"""
super().__init__(input_name=input_name, output_name=output_name)
self._params = params
self._callbacks = SmallWebRTCCallbacks(
on_app_message=self._on_app_message,
on_client_connected=self._on_client_connected,
on_client_disconnected=self._on_client_disconnected,
)
self._client = SmallWebRTCClient(webrtc_connection, self._callbacks)
self._input: Optional[SmallWebRTCInputTransport] = None
self._output: Optional[SmallWebRTCOutputTransport] = None
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_app_message")
self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected")
def input(self) -> SmallWebRTCInputTransport:
"""Get the input transport processor.
Returns:
The input transport for handling incoming media streams.
"""
if not self._input:
self._input = SmallWebRTCInputTransport(
self._client, self._params, name=self._input_name
)
return self._input
def output(self) -> SmallWebRTCOutputTransport:
"""Get the output transport processor.
Returns:
The output transport for handling outgoing media streams.
"""
if not self._output:
self._output = SmallWebRTCOutputTransport(
self._client, self._params, name=self._input_name
)
return self._output
async def send_image(self, frame: OutputImageRawFrame | SpriteFrame):
"""Send an image frame through the transport.
Args:
frame: The image frame to send.
"""
if self._output:
await self._output.queue_frame(frame, FrameDirection.DOWNSTREAM)
async def send_audio(self, frame: OutputAudioRawFrame):
"""Send an audio frame through the transport.
Args:
frame: The audio frame to send.
"""
if self._output:
await self._output.queue_frame(frame, FrameDirection.DOWNSTREAM)
async def _on_app_message(self, message: Any):
"""Handle incoming application messages."""
if self._input:
await self._input.push_app_message(message)
await self._call_event_handler("on_app_message", message)
async def _on_client_connected(self, webrtc_connection):
"""Handle client connection events."""
await self._call_event_handler("on_client_connected", webrtc_connection)
async def _on_client_disconnected(self, webrtc_connection):
"""Handle client disconnection events."""
await self._call_event_handler("on_client_disconnected", webrtc_connection)
async def capture_participant_video(
self,
video_source: str = CAM_VIDEO_SOURCE,
):
"""Capture video from a specific participant.
Args:
video_source: Video source to capture from ("camera" or "screenVideo").
"""
if self._input:
await self._input.capture_participant_media(source=video_source)
async def capture_participant_audio(
self,
audio_source: str = MIC_AUDIO_SOURCE,
):
"""Capture audio from a specific participant.
Args:
audio_source: Audio source to capture from. (currently, "microphone" is the only supported option)
"""
if self._input:
await self._input.capture_participant_media(source=audio_source)

View File

View File

@@ -0,0 +1,770 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tavus transport implementation for Pipecat.
This module provides integration with the Tavus platform for creating conversational
AI applications with avatars. It manages conversation sessions and provides real-time
audio/video streaming capabilities through the Tavus API.
"""
import os
from functools import partial
from typing import Any, Awaitable, Callable, Mapping, Optional
import aiohttp
from daily.daily import AudioData
from loguru import logger
from pydantic import BaseModel
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
StartFrame,
StartInterruptionFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import (
DailyCallbacks,
DailyParams,
DailyTransportClient,
)
class TavusApi:
"""Helper class for interacting with the Tavus API (v2).
Provides methods for creating and managing conversations with Tavus avatars,
including conversation lifecycle management and persona information retrieval.
"""
BASE_URL = "https://tavusapi.com/v2"
MOCK_CONVERSATION_ID = "dev-conversation"
MOCK_PERSONA_NAME = "TestTavusTransport"
def __init__(self, api_key: str, session: aiohttp.ClientSession):
"""Initialize the TavusApi client.
Args:
api_key: Tavus API key for authentication.
session: An aiohttp session for making HTTP requests.
"""
self._api_key = api_key
self._session = session
self._headers = {"Content-Type": "application/json", "x-api-key": self._api_key}
# Only for development
self._dev_room_url = os.getenv("TAVUS_SAMPLE_ROOM_URL")
async def create_conversation(self, replica_id: str, persona_id: str) -> dict:
"""Create a new conversation with the specified replica and persona.
Args:
replica_id: ID of the replica to use in the conversation.
persona_id: ID of the persona to use in the conversation.
Returns:
Dictionary containing conversation_id and conversation_url.
"""
if self._dev_room_url:
return {
"conversation_id": self.MOCK_CONVERSATION_ID,
"conversation_url": self._dev_room_url,
}
logger.debug(f"Creating Tavus conversation: replica={replica_id}, persona={persona_id}")
url = f"{self.BASE_URL}/conversations"
payload = {
"replica_id": replica_id,
"persona_id": persona_id,
}
async with self._session.post(url, headers=self._headers, json=payload) as r:
r.raise_for_status()
response = await r.json()
logger.debug(f"Created Tavus conversation: {response}")
return response
async def end_conversation(self, conversation_id: str):
"""End an existing conversation.
Args:
conversation_id: ID of the conversation to end.
"""
if conversation_id is None or conversation_id == self.MOCK_CONVERSATION_ID:
return
url = f"{self.BASE_URL}/conversations/{conversation_id}/end"
async with self._session.post(url, headers=self._headers) as r:
r.raise_for_status()
logger.debug(f"Ended Tavus conversation {conversation_id}")
async def get_persona_name(self, persona_id: str) -> str:
"""Get the name of a persona by ID.
Args:
persona_id: ID of the persona to retrieve.
Returns:
The name of the persona.
"""
if self._dev_room_url is not None:
return self.MOCK_PERSONA_NAME
url = f"{self.BASE_URL}/personas/{persona_id}"
async with self._session.get(url, headers=self._headers) as r:
r.raise_for_status()
response = await r.json()
logger.debug(f"Fetched Tavus persona: {response}")
return response["persona_name"]
class TavusCallbacks(BaseModel):
"""Callback handlers for Tavus events.
Parameters:
on_participant_joined: Called when a participant joins the conversation.
on_participant_left: Called when a participant leaves the conversation.
"""
on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]]
on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]]
class TavusParams(DailyParams):
"""Configuration parameters for the Tavus transport.
Parameters:
audio_in_enabled: Whether to enable audio input from participants.
audio_out_enabled: Whether to enable audio output to participants.
microphone_out_enabled: Whether to enable microphone output track.
"""
audio_in_enabled: bool = True
audio_out_enabled: bool = True
microphone_out_enabled: bool = False
class TavusTransportClient:
"""Transport client that integrates Pipecat with the Tavus platform.
A transport client that integrates a Pipecat Bot with the Tavus platform by managing
conversation sessions using the Tavus API.
This client uses `TavusApi` to interact with the Tavus backend services. When a conversation
is started via `TavusApi`, Tavus provides a `roomURL` that can be used to connect the Pipecat Bot
into the same virtual room where the TavusBot is operating.
"""
def __init__(
self,
*,
bot_name: str,
params: TavusParams = TavusParams(),
callbacks: TavusCallbacks,
api_key: str,
replica_id: str,
persona_id: str = "pipecat-stream",
session: aiohttp.ClientSession,
) -> None:
"""Initialize the Tavus transport client.
Args:
bot_name: The name of the Pipecat bot instance.
params: Optional parameters for Tavus operation.
callbacks: Callback handlers for Tavus-related events.
api_key: API key for authenticating with Tavus API.
replica_id: ID of the replica to use in the Tavus conversation.
persona_id: ID of the Tavus persona. Defaults to "pipecat-stream",
which signals Tavus to use the TTS voice of the Pipecat bot
instead of a Tavus persona voice.
session: The aiohttp session for making async HTTP requests.
"""
self._bot_name = bot_name
self._api = TavusApi(api_key, session)
self._replica_id = replica_id
self._persona_id = persona_id
self._conversation_id: Optional[str] = None
self._client: Optional[DailyTransportClient] = None
self._callbacks = callbacks
self._params = params
async def _initialize(self) -> str:
"""Initialize the conversation and return the room URL."""
response = await self._api.create_conversation(self._replica_id, self._persona_id)
self._conversation_id = response["conversation_id"]
return response["conversation_url"]
async def setup(self, setup: FrameProcessorSetup):
"""Setup the client and initialize the conversation.
Args:
setup: The frame processor setup configuration.
"""
if self._conversation_id is not None:
logger.debug(f"Conversation ID already defined: {self._conversation_id}")
return
try:
room_url = await self._initialize()
daily_callbacks = DailyCallbacks(
on_active_speaker_changed=partial(
self._on_handle_callback, "on_active_speaker_changed"
),
on_joined=self._on_joined,
on_left=self._on_left,
on_error=partial(self._on_handle_callback, "on_error"),
on_app_message=partial(self._on_handle_callback, "on_app_message"),
on_call_state_updated=partial(self._on_handle_callback, "on_call_state_updated"),
on_client_connected=partial(self._on_handle_callback, "on_client_connected"),
on_client_disconnected=partial(self._on_handle_callback, "on_client_disconnected"),
on_dialin_connected=partial(self._on_handle_callback, "on_dialin_connected"),
on_dialin_ready=partial(self._on_handle_callback, "on_dialin_ready"),
on_dialin_stopped=partial(self._on_handle_callback, "on_dialin_stopped"),
on_dialin_error=partial(self._on_handle_callback, "on_dialin_error"),
on_dialin_warning=partial(self._on_handle_callback, "on_dialin_warning"),
on_dialout_answered=partial(self._on_handle_callback, "on_dialout_answered"),
on_dialout_connected=partial(self._on_handle_callback, "on_dialout_connected"),
on_dialout_stopped=partial(self._on_handle_callback, "on_dialout_stopped"),
on_dialout_error=partial(self._on_handle_callback, "on_dialout_error"),
on_dialout_warning=partial(self._on_handle_callback, "on_dialout_warning"),
on_participant_joined=self._callbacks.on_participant_joined,
on_participant_left=self._callbacks.on_participant_left,
on_participant_updated=partial(self._on_handle_callback, "on_participant_updated"),
on_transcription_message=partial(
self._on_handle_callback, "on_transcription_message"
),
on_recording_started=partial(self._on_handle_callback, "on_recording_started"),
on_recording_stopped=partial(self._on_handle_callback, "on_recording_stopped"),
on_recording_error=partial(self._on_handle_callback, "on_recording_error"),
on_transcription_stopped=partial(
self._on_handle_callback, "on_transcription_stopped"
),
on_transcription_error=partial(self._on_handle_callback, "on_transcription_error"),
)
self._client = DailyTransportClient(
room_url, None, "Pipecat", self._params, daily_callbacks, self._bot_name
)
await self._client.setup(setup)
except Exception as e:
logger.error(f"Failed to setup TavusTransportClient: {e}")
await self._api.end_conversation(self._conversation_id)
self._conversation_id = None
async def cleanup(self):
"""Cleanup client resources."""
try:
await self._client.cleanup()
except Exception as e:
logger.exception(f"Exception during cleanup: {e}")
async def _on_joined(self, data):
"""Handle joined event."""
logger.debug("TavusTransportClient joined!")
async def _on_left(self):
"""Handle left event."""
logger.debug("TavusTransportClient left!")
async def _on_handle_callback(self, event_name, *args, **kwargs):
"""Handle generic callback events."""
logger.trace(f"[Callback] {event_name} called with args={args}, kwargs={kwargs}")
async def get_persona_name(self) -> str:
"""Get the persona name from the API.
Returns:
The name of the current persona.
"""
return await self._api.get_persona_name(self._persona_id)
async def start(self, frame: StartFrame):
"""Start the client and join the room.
Args:
frame: The start frame containing initialization parameters.
"""
logger.debug("TavusTransportClient start invoked!")
await self._client.start(frame)
await self._client.join()
async def stop(self):
"""Stop the client and end the conversation."""
await self._client.leave()
await self._api.end_conversation(self._conversation_id)
self._conversation_id = None
async def capture_participant_video(
self,
participant_id: str,
callback: Callable,
framerate: int = 30,
video_source: str = "camera",
color_format: str = "RGB",
):
"""Capture video from a participant.
Args:
participant_id: ID of the participant to capture video from.
callback: Callback function to handle video frames.
framerate: Desired framerate for video capture.
video_source: Video source to capture from.
color_format: Color format for video frames.
"""
await self._client.capture_participant_video(
participant_id, callback, framerate, video_source, color_format
)
async def capture_participant_audio(
self,
participant_id: str,
callback: Callable,
audio_source: str = "microphone",
sample_rate: int = 16000,
callback_interval_ms: int = 20,
):
"""Capture audio from a participant.
Args:
participant_id: ID of the participant to capture audio from.
callback: Callback function to handle audio data.
audio_source: Audio source to capture from.
sample_rate: Desired sample rate for audio capture.
callback_interval_ms: Interval between audio callbacks in milliseconds.
"""
await self._client.capture_participant_audio(
participant_id, callback, audio_source, sample_rate, callback_interval_ms
)
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a message to participants.
Args:
frame: The message frame to send.
"""
await self._client.send_message(frame)
@property
def out_sample_rate(self) -> int:
"""Get the output sample rate.
Returns:
The output sample rate in Hz.
"""
return self._client.out_sample_rate
@property
def in_sample_rate(self) -> int:
"""Get the input sample rate.
Returns:
The input sample rate in Hz.
"""
return self._client.in_sample_rate
async def send_interrupt_message(self) -> None:
"""Send an interrupt message to the conversation."""
transport_frame = TransportMessageUrgentFrame(
message={
"message_type": "conversation",
"event_type": "conversation.interrupt",
"conversation_id": self._conversation_id,
}
)
await self.send_message(transport_frame)
async def update_subscriptions(self, participant_settings=None, profile_settings=None):
"""Update subscription settings for participants.
Args:
participant_settings: Per-participant subscription settings.
profile_settings: Global subscription profile settings.
"""
if not self._client:
return
await self._client.update_subscriptions(
participant_settings=participant_settings, profile_settings=profile_settings
)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the transport.
Args:
frame: The audio frame to write.
"""
if not self._client:
return
await self._client.write_audio_frame(frame)
async def register_audio_destination(self, destination: str):
"""Register an audio destination for output.
Args:
destination: The destination identifier to register.
"""
if not self._client:
return
await self._client.register_audio_destination(destination)
class TavusInputTransport(BaseInputTransport):
"""Input transport for receiving audio and events from Tavus conversations.
Handles incoming audio streams from participants and manages audio capture
from the Daily room connected to the Tavus conversation.
"""
def __init__(
self,
client: TavusTransportClient,
params: TransportParams,
**kwargs,
):
"""Initialize the Tavus input transport.
Args:
client: The Tavus transport client instance.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._client = client
self._params = params
# Whether we have seen a StartFrame already.
self._initialized = False
async def setup(self, setup: FrameProcessorSetup):
"""Setup the input transport.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._client.setup(setup)
async def cleanup(self):
"""Cleanup input transport resources."""
await super().cleanup()
await self._client.cleanup()
async def start(self, frame: StartFrame):
"""Start the input transport.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.start(frame)
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the input transport.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.stop()
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.stop()
async def start_capturing_audio(self, participant):
"""Start capturing audio from a participant.
Args:
participant: The participant to capture audio from.
"""
if self._params.audio_in_enabled:
logger.info(
f"TavusTransportClient start capturing audio for participant {participant['id']}"
)
await self._client.capture_participant_audio(
participant_id=participant["id"],
callback=self._on_participant_audio_data,
sample_rate=self._client.in_sample_rate,
)
async def _on_participant_audio_data(
self, participant_id: str, audio: AudioData, audio_source: str
):
"""Handle received participant audio data."""
frame = InputAudioRawFrame(
audio=audio.audio_frames,
sample_rate=audio.audio_frames,
num_channels=audio.num_channels,
)
frame.transport_source = audio_source
await self.push_audio_frame(frame)
class TavusOutputTransport(BaseOutputTransport):
"""Output transport for sending audio and events to Tavus conversations.
Handles outgoing audio streams to participants and manages the custom
audio track expected by the Tavus platform.
"""
def __init__(
self,
client: TavusTransportClient,
params: TransportParams,
**kwargs,
):
"""Initialize the Tavus output transport.
Args:
client: The Tavus transport client instance.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._client = client
self._params = params
# Whether we have seen a StartFrame already.
self._initialized = False
# This is the custom track destination expected by Tavus
self._transport_destination: Optional[str] = "stream"
async def setup(self, setup: FrameProcessorSetup):
"""Setup the output transport.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._client.setup(setup)
async def cleanup(self):
"""Cleanup output transport resources."""
await super().cleanup()
await self._client.cleanup()
async def start(self, frame: StartFrame):
"""Start the output transport.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.start(frame)
if self._transport_destination:
await self._client.register_audio_destination(self._transport_destination)
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._client.stop()
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._client.stop()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a message to participants.
Args:
frame: The message frame to send.
"""
logger.info(f"TavusOutputTransport sending message {frame}")
await self._client.send_message(frame)
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process frames and handle interruptions.
Args:
frame: The frame to process.
direction: The direction of frame flow in the pipeline.
"""
await super().process_frame(frame, direction)
if isinstance(frame, StartInterruptionFrame):
await self._handle_interruptions()
async def _handle_interruptions(self):
"""Handle interruption events by sending interrupt message."""
await self._client.send_interrupt_message()
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the Tavus transport.
Args:
frame: The audio frame to write.
"""
# This is the custom track destination expected by Tavus
frame.transport_destination = self._transport_destination
await self._client.write_audio_frame(frame)
async def register_audio_destination(self, destination: str):
"""Register an audio destination.
Args:
destination: The destination identifier to register.
"""
await self._client.register_audio_destination(destination)
class TavusTransport(BaseTransport):
"""Transport implementation for Tavus video calls.
When used, the Pipecat bot joins the same virtual room as the Tavus Avatar and the user.
This is achieved by using `TavusTransportClient`, which initiates the conversation via
`TavusApi` and obtains a room URL that all participants connect to.
"""
def __init__(
self,
bot_name: str,
session: aiohttp.ClientSession,
api_key: str,
replica_id: str,
persona_id: str = "pipecat-stream",
params: TavusParams = TavusParams(),
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the Tavus transport.
Args:
bot_name: The name of the Pipecat bot.
session: aiohttp session used for async HTTP requests.
api_key: Tavus API key for authentication.
replica_id: ID of the replica model used for voice generation.
persona_id: ID of the Tavus persona. Defaults to "pipecat-stream"
to use the Pipecat TTS voice.
params: Optional Tavus-specific configuration parameters.
input_name: Optional name for the input transport.
output_name: Optional name for the output transport.
"""
super().__init__(input_name=input_name, output_name=output_name)
self._params = params
callbacks = TavusCallbacks(
on_participant_joined=self._on_participant_joined,
on_participant_left=self._on_participant_left,
)
self._client = TavusTransportClient(
bot_name="Pipecat",
callbacks=callbacks,
api_key=api_key,
replica_id=replica_id,
persona_id=persona_id,
session=session,
params=params,
)
self._input: Optional[TavusInputTransport] = None
self._output: Optional[TavusOutputTransport] = None
self._tavus_participant_id = None
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected")
async def _on_participant_left(self, participant, reason):
"""Handle participant left events."""
persona_name = await self._client.get_persona_name()
if participant.get("info", {}).get("userName", "") != persona_name:
await self._on_client_disconnected(participant)
async def _on_participant_joined(self, participant):
"""Handle participant joined events."""
# get persona, look up persona_name, set this as the bot name to ignore
persona_name = await self._client.get_persona_name()
# Ignore the Tavus replica's microphone
if participant.get("info", {}).get("userName", "") == persona_name:
self._tavus_participant_id = participant["id"]
else:
await self._on_client_connected(participant)
if self._tavus_participant_id:
logger.debug(f"Ignoring {self._tavus_participant_id}'s microphone")
await self.update_subscriptions(
participant_settings={
self._tavus_participant_id: {
"media": {"microphone": "unsubscribed"},
}
}
)
if self._input:
await self._input.start_capturing_audio(participant)
async def update_subscriptions(self, participant_settings=None, profile_settings=None):
"""Update subscription settings for participants.
Args:
participant_settings: Per-participant subscription settings.
profile_settings: Global subscription profile settings.
"""
await self._client.update_subscriptions(
participant_settings=participant_settings,
profile_settings=profile_settings,
)
def input(self) -> FrameProcessor:
"""Get the input transport for receiving media and events.
Returns:
The Tavus input transport instance.
"""
if not self._input:
self._input = TavusInputTransport(client=self._client, params=self._params)
return self._input
def output(self) -> FrameProcessor:
"""Get the output transport for sending media and events.
Returns:
The Tavus output transport instance.
"""
if not self._output:
self._output = TavusOutputTransport(client=self._client, params=self._params)
return self._output
async def _on_client_connected(self, participant: Any):
"""Handle client connected events."""
await self._call_event_handler("on_client_connected", participant)
async def _on_client_disconnected(self, participant: Any):
"""Handle client disconnected events."""
await self._call_event_handler("on_client_disconnected", participant)

View File

@@ -0,0 +1,494 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""WebSocket client transport implementation for Pipecat.
This module provides a WebSocket client transport that enables bidirectional
communication over WebSocket connections, with support for audio streaming,
frame serialization, and connection management.
"""
import asyncio
import io
import time
import wave
from typing import Awaitable, Callable, Optional
import websockets
from loguru import logger
from pydantic.main import BaseModel
from websockets.asyncio.client import connect as websocket_connect
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
StartFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameProcessorSetup
from pipecat.serializers.base_serializer import FrameSerializer
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.task_manager import BaseTaskManager
class WebsocketClientParams(TransportParams):
"""Configuration parameters for WebSocket client transport.
Parameters:
add_wav_header: Whether to add WAV headers to audio frames.
serializer: Frame serializer for encoding/decoding messages.
"""
add_wav_header: bool = True
serializer: Optional[FrameSerializer] = None
class WebsocketClientCallbacks(BaseModel):
"""Callback functions for WebSocket client events.
Parameters:
on_connected: Called when WebSocket connection is established.
on_disconnected: Called when WebSocket connection is closed.
on_message: Called when a message is received from the WebSocket.
"""
on_connected: Callable[[websockets.WebSocketClientProtocol], Awaitable[None]]
on_disconnected: Callable[[websockets.WebSocketClientProtocol], Awaitable[None]]
on_message: Callable[[websockets.WebSocketClientProtocol, websockets.Data], Awaitable[None]]
class WebsocketClientSession:
"""Manages a WebSocket client connection session.
Handles connection lifecycle, message sending/receiving, and provides
callback mechanisms for connection events.
"""
def __init__(
self,
uri: str,
params: WebsocketClientParams,
callbacks: WebsocketClientCallbacks,
transport_name: str,
):
"""Initialize the WebSocket client session.
Args:
uri: The WebSocket URI to connect to.
params: Configuration parameters for the session.
callbacks: Callback functions for session events.
transport_name: Name of the parent transport for logging.
"""
self._uri = uri
self._params = params
self._callbacks = callbacks
self._transport_name = transport_name
self._leave_counter = 0
self._task_manager: Optional[BaseTaskManager] = None
self._websocket: Optional[websockets.WebSocketClientProtocol] = None
@property
def task_manager(self) -> BaseTaskManager:
"""Get the task manager for this session.
Returns:
The task manager instance.
Raises:
Exception: If task manager is not initialized.
"""
if not self._task_manager:
raise Exception(
f"{self._transport_name}::WebsocketClientSession: TaskManager not initialized (pipeline not started?)"
)
return self._task_manager
async def setup(self, task_manager: BaseTaskManager):
"""Set up the session with a task manager.
Args:
task_manager: The task manager to use for session tasks.
"""
self._leave_counter += 1
if not self._task_manager:
self._task_manager = task_manager
async def connect(self):
"""Connect to the WebSocket server."""
if self._websocket:
return
try:
self._websocket = await websocket_connect(uri=self._uri, open_timeout=10)
self._client_task = self.task_manager.create_task(
self._client_task_handler(),
f"{self._transport_name}::WebsocketClientSession::_client_task_handler",
)
await self._callbacks.on_connected(self._websocket)
except TimeoutError:
logger.error(f"Timeout connecting to {self._uri}")
async def disconnect(self):
"""Disconnect from the WebSocket server."""
self._leave_counter -= 1
if not self._websocket or self._leave_counter > 0:
return
await self.task_manager.cancel_task(self._client_task)
await self._websocket.close()
self._websocket = None
async def send(self, message: websockets.Data):
"""Send a message through the WebSocket connection.
Args:
message: The message data to send.
"""
try:
if self._websocket:
await self._websocket.send(message)
except Exception as e:
logger.error(f"{self} exception sending data: {e.__class__.__name__} ({e})")
async def _client_task_handler(self):
"""Handle incoming messages from the WebSocket connection."""
try:
# Handle incoming messages
async for message in self._websocket:
await self._callbacks.on_message(self._websocket, message)
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
await self._callbacks.on_disconnected(self._websocket)
def __str__(self):
"""String representation of the WebSocket client session."""
return f"{self._transport_name}::WebsocketClientSession"
class WebsocketClientInputTransport(BaseInputTransport):
"""WebSocket client input transport for receiving frames.
Handles incoming WebSocket messages, deserializes them to frames,
and pushes them downstream in the processing pipeline.
"""
def __init__(
self,
transport: BaseTransport,
session: WebsocketClientSession,
params: WebsocketClientParams,
):
"""Initialize the WebSocket client input transport.
Args:
transport: The parent transport instance.
session: The WebSocket session to use for communication.
params: Configuration parameters for the transport.
"""
super().__init__(params)
self._transport = transport
self._session = session
self._params = params
# Whether we have seen a StartFrame already.
self._initialized = False
async def setup(self, setup: FrameProcessorSetup):
"""Set up the input transport with the frame processor setup.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._session.setup(setup.task_manager)
async def start(self, frame: StartFrame):
"""Start the input transport and initialize the WebSocket connection.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
if self._params.serializer:
await self._params.serializer.setup(frame)
await self._session.connect()
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the input transport and disconnect from WebSocket.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._session.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport and disconnect from WebSocket.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._session.disconnect()
async def cleanup(self):
"""Clean up the input transport resources."""
await super().cleanup()
await self._transport.cleanup()
async def on_message(self, websocket, message):
"""Handle incoming WebSocket messages.
Args:
websocket: The WebSocket connection that received the message.
message: The received message data.
"""
if not self._params.serializer:
return
frame = await self._params.serializer.deserialize(message)
if not frame:
return
if isinstance(frame, InputAudioRawFrame) and self._params.audio_in_enabled:
await self.push_audio_frame(frame)
else:
await self.push_frame(frame)
class WebsocketClientOutputTransport(BaseOutputTransport):
"""WebSocket client output transport for sending frames.
Handles outgoing frames, serializes them for WebSocket transmission,
and manages audio streaming with proper timing simulation.
"""
def __init__(
self,
transport: BaseTransport,
session: WebsocketClientSession,
params: WebsocketClientParams,
):
"""Initialize the WebSocket client output transport.
Args:
transport: The parent transport instance.
session: The WebSocket session to use for communication.
params: Configuration parameters for the transport.
"""
super().__init__(params)
self._transport = transport
self._session = session
self._params = params
# write_audio_frame() is called quickly, as soon as we get audio
# (e.g. from the TTS), and since this is just a network connection we
# would be sending it to quickly. Instead, we want to block to emulate
# an audio device, this is what the send interval is. It will be
# computed on StartFrame.
self._send_interval = 0
self._next_send_time = 0
# Whether we have seen a StartFrame already.
self._initialized = False
async def setup(self, setup: FrameProcessorSetup):
"""Set up the output transport with the frame processor setup.
Args:
setup: The frame processor setup configuration.
"""
await super().setup(setup)
await self._session.setup(setup.task_manager)
async def start(self, frame: StartFrame):
"""Start the output transport and initialize the WebSocket connection.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
self._send_interval = (self.audio_chunk_size / self.sample_rate) / 2
if self._params.serializer:
await self._params.serializer.setup(frame)
await self._session.connect()
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport and disconnect from WebSocket.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._session.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and disconnect from WebSocket.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._session.disconnect()
async def cleanup(self):
"""Clean up the output transport resources."""
await super().cleanup()
await self._transport.cleanup()
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message through the WebSocket.
Args:
frame: The transport message frame to send.
"""
await self._write_frame(frame)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebSocket with optional WAV header.
Args:
frame: The output audio frame to write.
"""
frame = OutputAudioRawFrame(
audio=frame.audio,
sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels,
)
if self._params.add_wav_header:
with io.BytesIO() as buffer:
with wave.open(buffer, "wb") as wf:
wf.setsampwidth(2)
wf.setnchannels(frame.num_channels)
wf.setframerate(frame.sample_rate)
wf.writeframes(frame.audio)
wav_frame = OutputAudioRawFrame(
buffer.getvalue(),
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
)
frame = wav_frame
await self._write_frame(frame)
# Simulate audio playback with a sleep.
await self._write_audio_sleep()
async def _write_frame(self, frame: Frame):
"""Write a frame to the WebSocket after serialization."""
if not self._params.serializer:
return
payload = await self._params.serializer.serialize(frame)
if payload:
await self._session.send(payload)
async def _write_audio_sleep(self):
"""Simulate audio playback timing with sleep delays."""
# Simulate a clock.
current_time = time.monotonic()
sleep_duration = max(0, self._next_send_time - current_time)
await asyncio.sleep(sleep_duration)
if sleep_duration == 0:
self._next_send_time = time.monotonic() + self._send_interval
else:
self._next_send_time += self._send_interval
class WebsocketClientTransport(BaseTransport):
"""WebSocket client transport for bidirectional communication.
Provides a complete WebSocket client transport implementation with
input and output capabilities, connection management, and event handling.
"""
def __init__(
self,
uri: str,
params: Optional[WebsocketClientParams] = None,
):
"""Initialize the WebSocket client transport.
Args:
uri: The WebSocket URI to connect to.
params: Optional configuration parameters for the transport.
"""
super().__init__()
self._params = params or WebsocketClientParams()
self._params.serializer = self._params.serializer or ProtobufFrameSerializer()
callbacks = WebsocketClientCallbacks(
on_connected=self._on_connected,
on_disconnected=self._on_disconnected,
on_message=self._on_message,
)
self._session = WebsocketClientSession(uri, self._params, callbacks, self.name)
self._input: Optional[WebsocketClientInputTransport] = None
self._output: Optional[WebsocketClientOutputTransport] = None
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_connected")
self._register_event_handler("on_disconnected")
def input(self) -> WebsocketClientInputTransport:
"""Get the input transport for receiving frames.
Returns:
The WebSocket client input transport instance.
"""
if not self._input:
self._input = WebsocketClientInputTransport(self, self._session, self._params)
return self._input
def output(self) -> WebsocketClientOutputTransport:
"""Get the output transport for sending frames.
Returns:
The WebSocket client output transport instance.
"""
if not self._output:
self._output = WebsocketClientOutputTransport(self, self._session, self._params)
return self._output
async def _on_connected(self, websocket):
"""Handle WebSocket connection established event."""
await self._call_event_handler("on_connected", websocket)
async def _on_disconnected(self, websocket):
"""Handle WebSocket connection closed event."""
await self._call_event_handler("on_disconnected", websocket)
async def _on_message(self, websocket, message):
"""Handle incoming WebSocket message."""
if self._input:
await self._input.on_message(websocket, message)

View File

@@ -0,0 +1,547 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""FastAPI WebSocket transport implementation for Pipecat.
This module provides WebSocket-based transport for real-time audio/video streaming
using FastAPI and WebSocket connections. Supports binary and text serialization
with configurable session timeouts and WAV header generation.
"""
import asyncio
import io
import time
import typing
import wave
from typing import Awaitable, Callable, Optional
from loguru import logger
from pydantic import BaseModel
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
StartFrame,
StartInterruptionFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializerType
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
try:
from fastapi import WebSocket
from starlette.websockets import WebSocketState
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error(
"In order to use FastAPI websockets, you need to `pip install pipecat-ai[websocket]`."
)
raise Exception(f"Missing module: {e}")
class FastAPIWebsocketParams(TransportParams):
"""Configuration parameters for FastAPI WebSocket transport.
Parameters:
add_wav_header: Whether to add WAV headers to audio frames.
serializer: Frame serializer for encoding/decoding messages.
session_timeout: Session timeout in seconds, None for no timeout.
"""
add_wav_header: bool = False
serializer: Optional[FrameSerializer] = None
session_timeout: Optional[int] = None
class FastAPIWebsocketCallbacks(BaseModel):
"""Callback functions for WebSocket events.
Parameters:
on_client_connected: Called when a client connects to the WebSocket.
on_client_disconnected: Called when a client disconnects from the WebSocket.
on_session_timeout: Called when a session timeout occurs.
"""
on_client_connected: Callable[[WebSocket], Awaitable[None]]
on_client_disconnected: Callable[[WebSocket], Awaitable[None]]
on_session_timeout: Callable[[WebSocket], Awaitable[None]]
class FastAPIWebsocketClient:
"""WebSocket client wrapper for handling connections and message passing.
Manages WebSocket state, message sending/receiving, and connection lifecycle
with support for both binary and text message types.
"""
def __init__(self, websocket: WebSocket, is_binary: bool, callbacks: FastAPIWebsocketCallbacks):
"""Initialize the WebSocket client.
Args:
websocket: The FastAPI WebSocket connection.
is_binary: Whether to use binary message format.
callbacks: Event callback functions.
"""
self._websocket = websocket
self._closing = False
self._is_binary = is_binary
self._callbacks = callbacks
self._leave_counter = 0
async def setup(self, _: StartFrame):
"""Set up the WebSocket client.
Args:
_: The start frame (unused).
"""
self._leave_counter += 1
def receive(self) -> typing.AsyncIterator[bytes | str]:
"""Get an async iterator for receiving WebSocket messages.
Returns:
An async iterator yielding bytes or strings based on message type.
"""
return self._websocket.iter_bytes() if self._is_binary else self._websocket.iter_text()
async def send(self, data: str | bytes):
"""Send data through the WebSocket connection.
Args:
data: The data to send (string or bytes).
"""
try:
if self._can_send():
if self._is_binary:
await self._websocket.send_bytes(data)
else:
await self._websocket.send_text(data)
except Exception as e:
logger.error(
f"{self} exception sending data: {e.__class__.__name__} ({e}), application_state: {self._websocket.application_state}"
)
# For some reason the websocket is disconnected, and we are not able to send data
# So let's properly handle it and disconnect the transport if it is not already disconnecting
if (
self._websocket.application_state == WebSocketState.DISCONNECTED
and not self.is_closing
):
logger.warning("Closing already disconnected websocket!")
self._closing = True
await self.trigger_client_disconnected()
async def disconnect(self):
"""Disconnect the WebSocket client."""
self._leave_counter -= 1
if self._leave_counter > 0:
return
if self.is_connected and not self.is_closing:
self._closing = True
try:
await self._websocket.close()
except Exception as e:
logger.error(f"{self} exception while closing the websocket: {e}")
finally:
await self.trigger_client_disconnected()
async def trigger_client_disconnected(self):
"""Trigger the client disconnected callback."""
await self._callbacks.on_client_disconnected(self._websocket)
async def trigger_client_connected(self):
"""Trigger the client connected callback."""
await self._callbacks.on_client_connected(self._websocket)
async def trigger_client_timeout(self):
"""Trigger the client timeout callback."""
await self._callbacks.on_session_timeout(self._websocket)
def _can_send(self):
"""Check if data can be sent through the WebSocket."""
return self.is_connected and not self.is_closing
@property
def is_connected(self) -> bool:
"""Check if the WebSocket is currently connected.
Returns:
True if the WebSocket is in connected state.
"""
return self._websocket.client_state == WebSocketState.CONNECTED
@property
def is_closing(self) -> bool:
"""Check if the WebSocket is currently closing.
Returns:
True if the WebSocket is in the process of closing.
"""
return self._closing
class FastAPIWebsocketInputTransport(BaseInputTransport):
"""Input transport for FastAPI WebSocket connections.
Handles incoming WebSocket messages, deserializes frames, and manages
connection monitoring with optional session timeouts.
"""
def __init__(
self,
transport: BaseTransport,
client: FastAPIWebsocketClient,
params: FastAPIWebsocketParams,
**kwargs,
):
"""Initialize the WebSocket input transport.
Args:
transport: The parent transport instance.
client: The WebSocket client wrapper.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._client = client
self._params = params
self._receive_task = None
self._monitor_websocket_task = None
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the input transport and begin message processing.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.setup(frame)
if self._params.serializer:
await self._params.serializer.setup(frame)
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()
if not self._receive_task:
self._receive_task = self.create_task(self._receive_messages())
await self.set_transport_ready(frame)
async def _stop_tasks(self):
"""Stop all running tasks."""
if self._monitor_websocket_task:
await self.cancel_task(self._monitor_websocket_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):
"""Stop the input transport and cleanup resources.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._stop_tasks()
await self._client.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the input transport and stop all processing.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._stop_tasks()
await self._client.disconnect()
async def cleanup(self):
"""Clean up transport resources."""
await super().cleanup()
await self._transport.cleanup()
async def _receive_messages(self):
"""Main message receiving loop for WebSocket messages."""
try:
async for message in self._client.receive():
if not self._params.serializer:
continue
frame = await self._params.serializer.deserialize(message)
if not frame:
continue
if isinstance(frame, InputAudioRawFrame):
await self.push_audio_frame(frame)
else:
await self.push_frame(frame)
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
await self._client.trigger_client_disconnected()
async def _monitor_websocket(self):
"""Wait for self._params.session_timeout seconds, if the websocket is still open, trigger timeout event."""
await asyncio.sleep(self._params.session_timeout)
await self._client.trigger_client_timeout()
class FastAPIWebsocketOutputTransport(BaseOutputTransport):
"""Output transport for FastAPI WebSocket connections.
Handles outgoing frame serialization, audio streaming with timing simulation,
and WebSocket message transmission with optional WAV header generation.
"""
def __init__(
self,
transport: BaseTransport,
client: FastAPIWebsocketClient,
params: FastAPIWebsocketParams,
**kwargs,
):
"""Initialize the WebSocket output transport.
Args:
transport: The parent transport instance.
client: The WebSocket client wrapper.
params: Transport configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._client = client
self._params = params
# write_audio_frame() is called quickly, as soon as we get audio
# (e.g. from the TTS), and since this is just a network connection we
# would be sending it to quickly. Instead, we want to block to emulate
# an audio device, this is what the send interval is. It will be
# computed on StartFrame.
self._send_interval = 0
self._next_send_time = 0
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the output transport and initialize timing.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
await self._client.setup(frame)
if self._params.serializer:
await self._params.serializer.setup(frame)
self._send_interval = (self.audio_chunk_size / self.sample_rate) / 2
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport and cleanup resources.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._write_frame(frame)
await self._client.disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and stop all processing.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._write_frame(frame)
await self._client.disconnect()
async def cleanup(self):
"""Clean up transport resources."""
await super().cleanup()
await self._transport.cleanup()
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process outgoing frames with special handling for interruptions.
Args:
frame: The frame to process.
direction: The direction of frame flow in the pipeline.
"""
await super().process_frame(frame, direction)
if isinstance(frame, StartInterruptionFrame):
await self._write_frame(frame)
self._next_send_time = 0
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message frame.
Args:
frame: The transport message frame to send.
"""
await self._write_frame(frame)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebSocket with timing simulation.
Args:
frame: The output audio frame to write.
"""
if self._client.is_closing or not self._client.is_connected:
return
frame = OutputAudioRawFrame(
audio=frame.audio,
sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels,
)
if self._params.add_wav_header:
with io.BytesIO() as buffer:
with wave.open(buffer, "wb") as wf:
wf.setsampwidth(2)
wf.setnchannels(frame.num_channels)
wf.setframerate(frame.sample_rate)
wf.writeframes(frame.audio)
wav_frame = OutputAudioRawFrame(
buffer.getvalue(),
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
)
frame = wav_frame
await self._write_frame(frame)
# Simulate audio playback with a sleep.
await self._write_audio_sleep()
async def _write_frame(self, frame: Frame):
"""Serialize and send a frame through the WebSocket."""
if not self._params.serializer:
return
try:
payload = await self._params.serializer.serialize(frame)
if payload:
await self._client.send(payload)
except Exception as e:
logger.error(f"{self} exception sending data: {e.__class__.__name__} ({e})")
async def _write_audio_sleep(self):
"""Simulate audio playback timing with appropriate delays."""
# Simulate a clock.
current_time = time.monotonic()
sleep_duration = max(0, self._next_send_time - current_time)
await asyncio.sleep(sleep_duration)
if sleep_duration == 0:
self._next_send_time = time.monotonic() + self._send_interval
else:
self._next_send_time += self._send_interval
class FastAPIWebsocketTransport(BaseTransport):
"""FastAPI WebSocket transport for real-time audio/video streaming.
Provides bidirectional WebSocket communication with frame serialization,
session management, and event handling for client connections and timeouts.
"""
def __init__(
self,
websocket: WebSocket,
params: FastAPIWebsocketParams,
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the FastAPI WebSocket transport.
Args:
websocket: The FastAPI WebSocket connection.
params: Transport configuration parameters.
input_name: Optional name for the input processor.
output_name: Optional name for the output processor.
"""
super().__init__(input_name=input_name, output_name=output_name)
self._params = params
self._callbacks = FastAPIWebsocketCallbacks(
on_client_connected=self._on_client_connected,
on_client_disconnected=self._on_client_disconnected,
on_session_timeout=self._on_session_timeout,
)
is_binary = False
if self._params.serializer:
is_binary = self._params.serializer.type == FrameSerializerType.BINARY
self._client = FastAPIWebsocketClient(websocket, is_binary, self._callbacks)
self._input = FastAPIWebsocketInputTransport(
self, self._client, self._params, name=self._input_name
)
self._output = FastAPIWebsocketOutputTransport(
self, self._client, self._params, name=self._output_name
)
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected")
self._register_event_handler("on_session_timeout")
def input(self) -> FastAPIWebsocketInputTransport:
"""Get the input transport processor.
Returns:
The WebSocket input transport instance.
"""
return self._input
def output(self) -> FastAPIWebsocketOutputTransport:
"""Get the output transport processor.
Returns:
The WebSocket output transport instance.
"""
return self._output
async def _on_client_connected(self, websocket):
"""Handle client connected event."""
await self._call_event_handler("on_client_connected", websocket)
async def _on_client_disconnected(self, websocket):
"""Handle client disconnected event."""
await self._call_event_handler("on_client_disconnected", websocket)
async def _on_session_timeout(self, websocket):
"""Handle session timeout event."""
await self._call_event_handler("on_session_timeout", websocket)

View File

@@ -0,0 +1,500 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""WebSocket server transport implementation for Pipecat.
This module provides WebSocket server transport functionality for real-time
audio and data streaming, including client connection management, session
handling, and frame serialization.
"""
import asyncio
import io
import time
import wave
from typing import Awaitable, Callable, Optional
from loguru import logger
from pydantic import BaseModel
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
StartFrame,
StartInterruptionFrame,
TransportMessageFrame,
TransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.serializers.base_serializer import FrameSerializer
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
try:
import websockets
from websockets.asyncio.server import serve as websocket_serve
from websockets.protocol import State
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use websockets, you need to `pip install pipecat-ai[websocket]`.")
raise Exception(f"Missing module: {e}")
class WebsocketServerParams(TransportParams):
"""Configuration parameters for WebSocket server transport.
Parameters:
add_wav_header: Whether to add WAV headers to audio frames.
serializer: Frame serializer for message encoding/decoding.
session_timeout: Timeout in seconds for client sessions.
"""
add_wav_header: bool = False
serializer: Optional[FrameSerializer] = None
session_timeout: Optional[int] = None
class WebsocketServerCallbacks(BaseModel):
"""Callback functions for WebSocket server events.
Parameters:
on_client_connected: Called when a client connects to the server.
on_client_disconnected: Called when a client disconnects from the server.
on_session_timeout: Called when a client session times out.
on_websocket_ready: Called when the WebSocket server is ready to accept connections.
"""
on_client_connected: Callable[[websockets.WebSocketServerProtocol], Awaitable[None]]
on_client_disconnected: Callable[[websockets.WebSocketServerProtocol], Awaitable[None]]
on_session_timeout: Callable[[websockets.WebSocketServerProtocol], Awaitable[None]]
on_websocket_ready: Callable[[], Awaitable[None]]
class WebsocketServerInputTransport(BaseInputTransport):
"""WebSocket server input transport for receiving client data.
Handles incoming WebSocket connections, message processing, and client
session management including timeout monitoring and connection lifecycle.
"""
def __init__(
self,
transport: BaseTransport,
host: str,
port: int,
params: WebsocketServerParams,
callbacks: WebsocketServerCallbacks,
**kwargs,
):
"""Initialize the WebSocket server input transport.
Args:
transport: The parent transport instance.
host: Host address to bind the WebSocket server to.
port: Port number to bind the WebSocket server to.
params: WebSocket server configuration parameters.
callbacks: Callback functions for WebSocket events.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._host = host
self._port = port
self._params = params
self._callbacks = callbacks
self._websocket: Optional[websockets.WebSocketServerProtocol] = None
self._server_task = None
# This task will monitor the websocket connection periodically.
self._monitor_task = None
self._stop_server_event = asyncio.Event()
# Whether we have seen a StartFrame already.
self._initialized = False
async def start(self, frame: StartFrame):
"""Start the WebSocket server and initialize components.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
if self._params.serializer:
await self._params.serializer.setup(frame)
if not self._server_task:
self._server_task = self.create_task(self._server_task_handler())
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the WebSocket server and cleanup resources.
Args:
frame: The end frame signaling transport shutdown.
"""
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._server_task
self._server_task = None
async def cancel(self, frame: CancelFrame):
"""Cancel the WebSocket server and stop all processing.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
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 cleanup(self):
"""Cleanup resources and parent transport."""
await super().cleanup()
await self._transport.cleanup()
async def _server_task_handler(self):
"""Handle WebSocket server startup and client connections."""
logger.info(f"Starting websocket server on {self._host}:{self._port}")
async with websocket_serve(self._client_handler, self._host, self._port) as server:
await self._callbacks.on_websocket_ready()
await self._stop_server_event.wait()
async def _client_handler(self, websocket: websockets.WebSocketServerProtocol):
"""Handle individual client connections and message processing."""
logger.info(f"New client connection from {websocket.remote_address}")
if self._websocket:
await self._websocket.close()
logger.warning("Only one client connected, using new connection")
self._websocket = websocket
# Notify
await self._callbacks.on_client_connected(websocket)
# Create a task to monitor the websocket connection
if not self._monitor_task and self._params.session_timeout:
self._monitor_task = self.create_task(
self._monitor_websocket(websocket, self._params.session_timeout)
)
# Handle incoming messages
try:
async for message in websocket:
if not self._params.serializer:
continue
frame = await self._params.serializer.deserialize(message)
if not frame:
continue
if isinstance(frame, InputAudioRawFrame):
await self.push_audio_frame(frame)
else:
await self.push_frame(frame)
except Exception as e:
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
# Notify disconnection
await self._callbacks.on_client_disconnected(websocket)
await self._websocket.close()
self._websocket = None
logger.info(f"Client {websocket.remote_address} disconnected")
async def _monitor_websocket(
self, websocket: websockets.WebSocketServerProtocol, session_timeout: int
):
"""Monitor WebSocket connection for session timeout."""
try:
await asyncio.sleep(session_timeout)
if websocket.state is not State.CLOSED:
await self._callbacks.on_session_timeout(websocket)
except asyncio.CancelledError:
logger.info(f"Monitoring task cancelled for: {websocket.remote_address}")
raise
class WebsocketServerOutputTransport(BaseOutputTransport):
"""WebSocket server output transport for sending data to clients.
Handles outgoing frame serialization, audio streaming with timing control,
and client connection management for WebSocket communication.
"""
def __init__(self, transport: BaseTransport, params: WebsocketServerParams, **kwargs):
"""Initialize the WebSocket server output transport.
Args:
transport: The parent transport instance.
params: WebSocket server configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(params, **kwargs)
self._transport = transport
self._params = params
self._websocket: Optional[websockets.WebSocketServerProtocol] = None
# write_audio_frame() is called quickly, as soon as we get audio
# (e.g. from the TTS), and since this is just a network connection we
# would be sending it to quickly. Instead, we want to block to emulate
# an audio device, this is what the send interval is. It will be
# computed on StartFrame.
self._send_interval = 0
self._next_send_time = 0
# Whether we have seen a StartFrame already.
self._initialized = False
async def set_client_connection(self, websocket: Optional[websockets.WebSocketServerProtocol]):
"""Set the active client WebSocket connection.
Args:
websocket: The WebSocket connection to set as active, or None to clear.
"""
if self._websocket:
await self._websocket.close()
logger.warning("Only one client allowed, using new connection")
self._websocket = websocket
async def start(self, frame: StartFrame):
"""Start the output transport and initialize components.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._initialized:
return
self._initialized = True
if self._params.serializer:
await self._params.serializer.setup(frame)
self._send_interval = (self.audio_chunk_size / self.sample_rate) / 2
await self.set_transport_ready(frame)
async def stop(self, frame: EndFrame):
"""Stop the output transport and send final frame.
Args:
frame: The end frame signaling transport shutdown.
"""
await super().stop(frame)
await self._write_frame(frame)
async def cancel(self, frame: CancelFrame):
"""Cancel the output transport and send cancellation frame.
Args:
frame: The cancel frame signaling immediate cancellation.
"""
await super().cancel(frame)
await self._write_frame(frame)
async def cleanup(self):
"""Cleanup resources and parent transport."""
await super().cleanup()
await self._transport.cleanup()
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process frames and handle interruption timing.
Args:
frame: The frame to process.
direction: The direction of frame flow in the pipeline.
"""
await super().process_frame(frame, direction)
if isinstance(frame, StartInterruptionFrame):
await self._write_frame(frame)
self._next_send_time = 0
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
"""Send a transport message frame to the client.
Args:
frame: The transport message frame to send.
"""
await self._write_frame(frame)
async def write_audio_frame(self, frame: OutputAudioRawFrame):
"""Write an audio frame to the WebSocket client with timing control.
Args:
frame: The output audio frame to write.
"""
if not self._websocket:
return
frame = OutputAudioRawFrame(
audio=frame.audio,
sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels,
)
if self._params.add_wav_header:
with io.BytesIO() as buffer:
with wave.open(buffer, "wb") as wf:
wf.setsampwidth(2)
wf.setnchannels(frame.num_channels)
wf.setframerate(frame.sample_rate)
wf.writeframes(frame.audio)
wav_frame = OutputAudioRawFrame(
buffer.getvalue(),
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
)
frame = wav_frame
await self._write_frame(frame)
# Simulate audio playback with a sleep.
await self._write_audio_sleep()
async def _write_frame(self, frame: Frame):
"""Serialize and send a frame to the WebSocket client."""
if not self._params.serializer:
return
try:
payload = await self._params.serializer.serialize(frame)
if payload and self._websocket:
await self._websocket.send(payload)
except Exception as e:
logger.error(f"{self} exception sending data: {e.__class__.__name__} ({e})")
async def _write_audio_sleep(self):
"""Simulate audio device timing by sleeping between audio chunks."""
# Simulate a clock.
current_time = time.monotonic()
sleep_duration = max(0, self._next_send_time - current_time)
await asyncio.sleep(sleep_duration)
if sleep_duration == 0:
self._next_send_time = time.monotonic() + self._send_interval
else:
self._next_send_time += self._send_interval
class WebsocketServerTransport(BaseTransport):
"""WebSocket server transport for bidirectional real-time communication.
Provides a complete WebSocket server implementation with separate input and
output transports, client connection management, and event handling for
real-time audio and data streaming applications.
"""
def __init__(
self,
params: WebsocketServerParams,
host: str = "localhost",
port: int = 8765,
input_name: Optional[str] = None,
output_name: Optional[str] = None,
):
"""Initialize the WebSocket server transport.
Args:
params: WebSocket server configuration parameters.
host: Host address to bind the server to. Defaults to "localhost".
port: Port number to bind the server to. Defaults to 8765.
input_name: Optional name for the input processor.
output_name: Optional name for the output processor.
"""
super().__init__(input_name=input_name, output_name=output_name)
self._host = host
self._port = port
self._params = params
self._callbacks = WebsocketServerCallbacks(
on_client_connected=self._on_client_connected,
on_client_disconnected=self._on_client_disconnected,
on_session_timeout=self._on_session_timeout,
on_websocket_ready=self._on_websocket_ready,
)
self._input: Optional[WebsocketServerInputTransport] = None
self._output: Optional[WebsocketServerOutputTransport] = None
self._websocket: Optional[websockets.WebSocketServerProtocol] = None
# Register supported handlers. The user will only be able to register
# these handlers.
self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected")
self._register_event_handler("on_session_timeout")
self._register_event_handler("on_websocket_ready")
def input(self) -> WebsocketServerInputTransport:
"""Get the input transport for receiving client data.
Returns:
The WebSocket server input transport instance.
"""
if not self._input:
self._input = WebsocketServerInputTransport(
self, self._host, self._port, self._params, self._callbacks, name=self._input_name
)
return self._input
def output(self) -> WebsocketServerOutputTransport:
"""Get the output transport for sending data to clients.
Returns:
The WebSocket server output transport instance.
"""
if not self._output:
self._output = WebsocketServerOutputTransport(
self, self._params, name=self._output_name
)
return self._output
async def _on_client_connected(self, websocket):
"""Handle client connection events."""
if self._output:
await self._output.set_client_connection(websocket)
await self._call_event_handler("on_client_connected", websocket)
else:
logger.error("A WebsocketServerTransport output is missing in the pipeline")
async def _on_client_disconnected(self, websocket):
"""Handle client disconnection events."""
if self._output:
await self._output.set_client_connection(None)
await self._call_event_handler("on_client_disconnected", websocket)
else:
logger.error("A WebsocketServerTransport output is missing in the pipeline")
async def _on_session_timeout(self, websocket):
"""Handle client session timeout events."""
await self._call_event_handler("on_session_timeout", websocket)
async def _on_websocket_ready(self):
"""Handle WebSocket server ready events."""
await self._call_event_handler("on_websocket_ready")

View File

@@ -1,5 +0,0 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#

View File

@@ -12,12 +12,12 @@ WhatsApp call events.
"""
import asyncio
from typing import Awaitable, Callable, Dict, List, Optional, Union
from typing import Awaitable, Callable, Dict, List, Optional
import aiohttp
from loguru import logger
from pipecat.transports.network.webrtc_connection import IceServer, SmallWebRTCConnection
from pipecat.transports.smallwebrtc.connection import IceServer, SmallWebRTCConnection
from pipecat.transports.whatsapp.api import (
WhatsAppApi,
WhatsAppConnectCall,