Merge pull request #1105 from pipecat-ai/aleix/websocket-client
added new websocket client transport
This commit is contained in:
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Added a new transport: `WebsocketClientTransport`.
|
||||||
|
|
||||||
- Added a `metadata` field to `Frame` which makes it possible to pass custom
|
- Added a `metadata` field to `Frame` which makes it possible to pass custom
|
||||||
data to all frames.
|
data to all frames.
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class TaskObserver(BaseObserver):
|
|||||||
task = create_task(
|
task = create_task(
|
||||||
loop,
|
loop,
|
||||||
self._proxy_task_handler(queue, observer),
|
self._proxy_task_handler(queue, observer),
|
||||||
f"{self}::{observer.__class__.__name__}",
|
f"{self}::{observer.__class__.__name__}::_proxy_task_handler",
|
||||||
)
|
)
|
||||||
proxy = Proxy(queue=queue, task=task, observer=observer)
|
proxy = Proxy(queue=queue, task=task, observer=observer)
|
||||||
proxies.append(proxy)
|
proxies.append(proxy)
|
||||||
|
|||||||
240
src/pipecat/transports/network/websocket_client.py
Normal file
240
src/pipecat/transports/network/websocket_client.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic.main import BaseModel
|
||||||
|
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
CancelFrame,
|
||||||
|
EndFrame,
|
||||||
|
Frame,
|
||||||
|
InputAudioRawFrame,
|
||||||
|
OutputAudioRawFrame,
|
||||||
|
StartFrame,
|
||||||
|
TransportMessageFrame,
|
||||||
|
TransportMessageUrgentFrame,
|
||||||
|
)
|
||||||
|
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 import cancel_task, create_task
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketClientParams(TransportParams):
|
||||||
|
add_wav_header: bool = True
|
||||||
|
serializer: FrameSerializer = ProtobufFrameSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketClientCallbacks(BaseModel):
|
||||||
|
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:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
params: WebsocketClientParams,
|
||||||
|
callbacks: WebsocketClientCallbacks,
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
transport_name: str,
|
||||||
|
):
|
||||||
|
self._uri = uri
|
||||||
|
self._params = params
|
||||||
|
self._callbacks = callbacks
|
||||||
|
self._loop = loop
|
||||||
|
self._transport_name = transport_name
|
||||||
|
|
||||||
|
self._websocket: websockets.WebSocketClientProtocol | None = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
if self._websocket:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._websocket = await websockets.connect(uri=self._uri, open_timeout=10)
|
||||||
|
self._client_task = create_task(
|
||||||
|
self._loop,
|
||||||
|
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):
|
||||||
|
if not self._websocket:
|
||||||
|
return
|
||||||
|
|
||||||
|
await cancel_task(self._client_task)
|
||||||
|
|
||||||
|
await self._websocket.close()
|
||||||
|
self._websocket = None
|
||||||
|
|
||||||
|
async def send(self, message: websockets.Data):
|
||||||
|
if self._websocket:
|
||||||
|
await self._websocket.send(message)
|
||||||
|
|
||||||
|
async def _client_task_handler(self):
|
||||||
|
# Handle incoming messages
|
||||||
|
async for message in self._websocket:
|
||||||
|
await self._callbacks.on_message(self._websocket, message)
|
||||||
|
|
||||||
|
await self._callbacks.on_disconnected(self._websocket)
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketClientInputTransport(BaseInputTransport):
|
||||||
|
def __init__(self, session: WebsocketClientSession, params: WebsocketClientParams):
|
||||||
|
super().__init__(params)
|
||||||
|
|
||||||
|
self._session = session
|
||||||
|
self._params = params
|
||||||
|
|
||||||
|
async def start(self, frame: StartFrame):
|
||||||
|
await super().start(frame)
|
||||||
|
await self._session.connect()
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame):
|
||||||
|
await super().stop(frame)
|
||||||
|
await self._session.disconnect()
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame):
|
||||||
|
await super().cancel(frame)
|
||||||
|
await self._session.disconnect()
|
||||||
|
|
||||||
|
async def on_message(self, websocket, message):
|
||||||
|
frame = 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):
|
||||||
|
def __init__(self, session: WebsocketClientSession, params: WebsocketClientParams):
|
||||||
|
super().__init__(params)
|
||||||
|
|
||||||
|
self._session = session
|
||||||
|
self._params = params
|
||||||
|
|
||||||
|
self._send_interval = (self._audio_chunk_size / self._params.audio_out_sample_rate) / 2
|
||||||
|
self._next_send_time = 0
|
||||||
|
|
||||||
|
async def start(self, frame: StartFrame):
|
||||||
|
await super().start(frame)
|
||||||
|
await self._session.connect()
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame):
|
||||||
|
await super().stop(frame)
|
||||||
|
await self._session.disconnect()
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame):
|
||||||
|
await super().cancel(frame)
|
||||||
|
await self._session.disconnect()
|
||||||
|
|
||||||
|
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
|
||||||
|
await self._write_frame(frame)
|
||||||
|
|
||||||
|
async def write_raw_audio_frames(self, frames: bytes):
|
||||||
|
frame = OutputAudioRawFrame(
|
||||||
|
audio=frames,
|
||||||
|
sample_rate=self._params.audio_out_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):
|
||||||
|
payload = self._params.serializer.serialize(frame)
|
||||||
|
if payload:
|
||||||
|
await self._session.send(payload)
|
||||||
|
|
||||||
|
async def _write_audio_sleep(self):
|
||||||
|
# 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):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
params: WebsocketClientParams = WebsocketClientParams(),
|
||||||
|
loop: asyncio.AbstractEventLoop | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(loop=loop)
|
||||||
|
|
||||||
|
self._params = params
|
||||||
|
|
||||||
|
callbacks = WebsocketClientCallbacks(
|
||||||
|
on_connected=self._on_connected,
|
||||||
|
on_disconnected=self._on_disconnected,
|
||||||
|
on_message=self._on_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._session = WebsocketClientSession(uri, params, callbacks, self._loop, self.name)
|
||||||
|
self._input: WebsocketClientInputTransport | None = None
|
||||||
|
self._output: WebsocketClientOutputTransport | None = 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:
|
||||||
|
if not self._input:
|
||||||
|
self._input = WebsocketClientInputTransport(self._session, self._params)
|
||||||
|
return self._input
|
||||||
|
|
||||||
|
def output(self) -> WebsocketClientOutputTransport:
|
||||||
|
if not self._output:
|
||||||
|
self._output = WebsocketClientOutputTransport(self._session, self._params)
|
||||||
|
return self._output
|
||||||
|
|
||||||
|
async def _on_connected(self, websocket):
|
||||||
|
await self._call_event_handler("on_connected", websocket)
|
||||||
|
|
||||||
|
async def _on_disconnected(self, websocket):
|
||||||
|
await self._call_event_handler("on_disconnected", websocket)
|
||||||
|
|
||||||
|
async def _on_message(self, websocket, message):
|
||||||
|
if self._input:
|
||||||
|
await self._input.on_message(websocket, message)
|
||||||
@@ -141,8 +141,6 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
|
|||||||
|
|
||||||
self._websocket: websockets.WebSocketServerProtocol | None = None
|
self._websocket: websockets.WebSocketServerProtocol | None = None
|
||||||
|
|
||||||
self._websocket_audio_buffer = bytes()
|
|
||||||
|
|
||||||
self._send_interval = (self._audio_chunk_size / self._params.audio_out_sample_rate) / 2
|
self._send_interval = (self._audio_chunk_size / self._params.audio_out_sample_rate) / 2
|
||||||
self._next_send_time = 0
|
self._next_send_time = 0
|
||||||
|
|
||||||
@@ -187,8 +185,6 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
|
|||||||
|
|
||||||
await self._write_frame(frame)
|
await self._write_frame(frame)
|
||||||
|
|
||||||
self._websocket_audio_buffer = bytes()
|
|
||||||
|
|
||||||
# Simulate audio playback with a sleep.
|
# Simulate audio playback with a sleep.
|
||||||
await self._write_audio_sleep()
|
await self._write_audio_sleep()
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ class DailyTransportClient(EventHandler):
|
|||||||
self._callback_task = create_task(
|
self._callback_task = create_task(
|
||||||
self._loop,
|
self._loop,
|
||||||
self._callback_task_handler(),
|
self._callback_task_handler(),
|
||||||
f"{self._transport_name}::DailyTransportClient::callback_task",
|
f"{self._transport_name}::DailyTransportClient::_callback_task_handler",
|
||||||
)
|
)
|
||||||
|
|
||||||
self._camera: VirtualCameraDevice | None = None
|
self._camera: VirtualCameraDevice | None = None
|
||||||
|
|||||||
Reference in New Issue
Block a user