dv-pipecat-ai 0.0.82.dev815__py3-none-any.whl → 0.0.82.dev857__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of dv-pipecat-ai might be problematic. Click here for more details.
- {dv_pipecat_ai-0.0.82.dev815.dist-info → dv_pipecat_ai-0.0.82.dev857.dist-info}/METADATA +8 -3
- {dv_pipecat_ai-0.0.82.dev815.dist-info → dv_pipecat_ai-0.0.82.dev857.dist-info}/RECORD +106 -79
- pipecat/adapters/base_llm_adapter.py +44 -6
- pipecat/adapters/services/anthropic_adapter.py +302 -2
- pipecat/adapters/services/aws_nova_sonic_adapter.py +40 -2
- pipecat/adapters/services/bedrock_adapter.py +40 -2
- pipecat/adapters/services/gemini_adapter.py +276 -6
- pipecat/adapters/services/open_ai_adapter.py +88 -7
- pipecat/adapters/services/open_ai_realtime_adapter.py +39 -1
- pipecat/audio/dtmf/__init__.py +0 -0
- pipecat/audio/dtmf/types.py +47 -0
- pipecat/audio/dtmf/utils.py +70 -0
- pipecat/audio/filters/aic_filter.py +199 -0
- pipecat/audio/utils.py +9 -7
- pipecat/extensions/ivr/__init__.py +0 -0
- pipecat/extensions/ivr/ivr_navigator.py +452 -0
- pipecat/frames/frames.py +156 -43
- pipecat/pipeline/llm_switcher.py +76 -0
- pipecat/pipeline/parallel_pipeline.py +3 -3
- pipecat/pipeline/service_switcher.py +144 -0
- pipecat/pipeline/task.py +68 -28
- pipecat/pipeline/task_observer.py +10 -0
- pipecat/processors/aggregators/dtmf_aggregator.py +2 -2
- pipecat/processors/aggregators/llm_context.py +277 -0
- pipecat/processors/aggregators/llm_response.py +48 -15
- pipecat/processors/aggregators/llm_response_universal.py +840 -0
- pipecat/processors/aggregators/openai_llm_context.py +3 -3
- pipecat/processors/dtmf_aggregator.py +0 -2
- pipecat/processors/filters/stt_mute_filter.py +0 -2
- pipecat/processors/frame_processor.py +18 -11
- pipecat/processors/frameworks/rtvi.py +17 -10
- pipecat/processors/metrics/sentry.py +2 -0
- pipecat/runner/daily.py +137 -36
- pipecat/runner/run.py +1 -1
- pipecat/runner/utils.py +7 -7
- pipecat/serializers/asterisk.py +20 -4
- pipecat/serializers/exotel.py +1 -1
- pipecat/serializers/plivo.py +1 -1
- pipecat/serializers/telnyx.py +1 -1
- pipecat/serializers/twilio.py +1 -1
- pipecat/services/__init__.py +2 -2
- pipecat/services/anthropic/llm.py +113 -28
- pipecat/services/asyncai/tts.py +4 -0
- pipecat/services/aws/llm.py +82 -8
- pipecat/services/aws/tts.py +0 -10
- pipecat/services/aws_nova_sonic/aws.py +5 -0
- pipecat/services/cartesia/tts.py +28 -16
- pipecat/services/cerebras/llm.py +15 -10
- pipecat/services/deepgram/stt.py +8 -0
- pipecat/services/deepseek/llm.py +13 -8
- pipecat/services/fireworks/llm.py +13 -8
- pipecat/services/fish/tts.py +8 -6
- pipecat/services/gemini_multimodal_live/gemini.py +5 -0
- pipecat/services/gladia/config.py +7 -1
- pipecat/services/gladia/stt.py +23 -15
- pipecat/services/google/llm.py +159 -59
- pipecat/services/google/llm_openai.py +18 -3
- pipecat/services/grok/llm.py +2 -1
- pipecat/services/llm_service.py +38 -3
- pipecat/services/mem0/memory.py +2 -1
- pipecat/services/mistral/llm.py +5 -6
- pipecat/services/nim/llm.py +2 -1
- pipecat/services/openai/base_llm.py +88 -26
- pipecat/services/openai/image.py +6 -1
- pipecat/services/openai_realtime_beta/openai.py +5 -2
- pipecat/services/openpipe/llm.py +6 -8
- pipecat/services/perplexity/llm.py +13 -8
- pipecat/services/playht/tts.py +9 -6
- pipecat/services/rime/tts.py +1 -1
- pipecat/services/sambanova/llm.py +18 -13
- pipecat/services/sarvam/tts.py +415 -10
- pipecat/services/speechmatics/stt.py +2 -2
- pipecat/services/tavus/video.py +1 -1
- pipecat/services/tts_service.py +15 -5
- pipecat/services/vistaar/llm.py +2 -5
- pipecat/transports/base_input.py +32 -19
- pipecat/transports/base_output.py +39 -5
- pipecat/transports/daily/__init__.py +0 -0
- pipecat/transports/daily/transport.py +2371 -0
- pipecat/transports/daily/utils.py +410 -0
- pipecat/transports/livekit/__init__.py +0 -0
- pipecat/transports/livekit/transport.py +1042 -0
- pipecat/transports/network/fastapi_websocket.py +12 -546
- pipecat/transports/network/small_webrtc.py +12 -922
- pipecat/transports/network/webrtc_connection.py +9 -595
- pipecat/transports/network/websocket_client.py +12 -481
- pipecat/transports/network/websocket_server.py +12 -487
- pipecat/transports/services/daily.py +9 -2334
- pipecat/transports/services/helpers/daily_rest.py +12 -396
- pipecat/transports/services/livekit.py +12 -975
- pipecat/transports/services/tavus.py +12 -757
- pipecat/transports/smallwebrtc/__init__.py +0 -0
- pipecat/transports/smallwebrtc/connection.py +612 -0
- pipecat/transports/smallwebrtc/transport.py +936 -0
- pipecat/transports/tavus/__init__.py +0 -0
- pipecat/transports/tavus/transport.py +770 -0
- pipecat/transports/websocket/__init__.py +0 -0
- pipecat/transports/websocket/client.py +494 -0
- pipecat/transports/websocket/fastapi.py +559 -0
- pipecat/transports/websocket/server.py +500 -0
- pipecat/transports/whatsapp/__init__.py +0 -0
- pipecat/transports/whatsapp/api.py +345 -0
- pipecat/transports/whatsapp/client.py +364 -0
- {dv_pipecat_ai-0.0.82.dev815.dist-info → dv_pipecat_ai-0.0.82.dev857.dist-info}/WHEEL +0 -0
- {dv_pipecat_ai-0.0.82.dev815.dist-info → dv_pipecat_ai-0.0.82.dev857.dist-info}/licenses/LICENSE +0 -0
- {dv_pipecat_ai-0.0.82.dev815.dist-info → dv_pipecat_ai-0.0.82.dev857.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2024–2025, Daily
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: BSD 2-Clause License
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
"""FastAPI WebSocket transport implementation for Pipecat.
|
|
8
|
+
|
|
9
|
+
This module provides WebSocket-based transport for real-time audio/video streaming
|
|
10
|
+
using FastAPI and WebSocket connections. Supports binary and text serialization
|
|
11
|
+
with configurable session timeouts and WAV header generation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import io
|
|
16
|
+
import time
|
|
17
|
+
import typing
|
|
18
|
+
import wave
|
|
19
|
+
from typing import Awaitable, Callable, Optional
|
|
20
|
+
|
|
21
|
+
from loguru import logger
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from pipecat.frames.frames import (
|
|
25
|
+
CancelFrame,
|
|
26
|
+
EndFrame,
|
|
27
|
+
Frame,
|
|
28
|
+
InputAudioRawFrame,
|
|
29
|
+
OutputAudioRawFrame,
|
|
30
|
+
StartFrame,
|
|
31
|
+
StartInterruptionFrame,
|
|
32
|
+
TransportMessageFrame,
|
|
33
|
+
TransportMessageUrgentFrame,
|
|
34
|
+
)
|
|
35
|
+
from pipecat.processors.frame_processor import FrameDirection
|
|
36
|
+
from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializerType
|
|
37
|
+
from pipecat.transports.base_input import BaseInputTransport
|
|
38
|
+
from pipecat.transports.base_output import BaseOutputTransport
|
|
39
|
+
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from fastapi import WebSocket
|
|
43
|
+
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
|
44
|
+
except ModuleNotFoundError as e:
|
|
45
|
+
logger.error(f"Exception: {e}")
|
|
46
|
+
logger.error(
|
|
47
|
+
"In order to use FastAPI websockets, you need to `pip install pipecat-ai[websocket]`."
|
|
48
|
+
)
|
|
49
|
+
raise Exception(f"Missing module: {e}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FastAPIWebsocketParams(TransportParams):
|
|
53
|
+
"""Configuration parameters for FastAPI WebSocket transport.
|
|
54
|
+
|
|
55
|
+
Parameters:
|
|
56
|
+
add_wav_header: Whether to add WAV headers to audio frames.
|
|
57
|
+
serializer: Frame serializer for encoding/decoding messages.
|
|
58
|
+
session_timeout: Session timeout in seconds, None for no timeout.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
add_wav_header: bool = False
|
|
62
|
+
serializer: Optional[FrameSerializer] = None
|
|
63
|
+
session_timeout: Optional[int] = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FastAPIWebsocketCallbacks(BaseModel):
|
|
67
|
+
"""Callback functions for WebSocket events.
|
|
68
|
+
|
|
69
|
+
Parameters:
|
|
70
|
+
on_client_connected: Called when a client connects to the WebSocket.
|
|
71
|
+
on_client_disconnected: Called when a client disconnects from the WebSocket.
|
|
72
|
+
on_session_timeout: Called when a session timeout occurs.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
on_client_connected: Callable[[WebSocket], Awaitable[None]]
|
|
76
|
+
on_client_disconnected: Callable[[WebSocket], Awaitable[None]]
|
|
77
|
+
on_session_timeout: Callable[[WebSocket], Awaitable[None]]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FastAPIWebsocketClient:
|
|
81
|
+
"""WebSocket client wrapper for handling connections and message passing.
|
|
82
|
+
|
|
83
|
+
Manages WebSocket state, message sending/receiving, and connection lifecycle
|
|
84
|
+
with support for both binary and text message types.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, websocket: WebSocket, is_binary: bool, callbacks: FastAPIWebsocketCallbacks):
|
|
88
|
+
"""Initialize the WebSocket client.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
websocket: The FastAPI WebSocket connection.
|
|
92
|
+
is_binary: Whether to use binary message format.
|
|
93
|
+
callbacks: Event callback functions.
|
|
94
|
+
"""
|
|
95
|
+
self._websocket = websocket
|
|
96
|
+
self._closing = False
|
|
97
|
+
self._is_binary = is_binary
|
|
98
|
+
self._callbacks = callbacks
|
|
99
|
+
self._leave_counter = 0
|
|
100
|
+
self._conversation_id = None
|
|
101
|
+
|
|
102
|
+
async def setup(self, _: StartFrame):
|
|
103
|
+
"""Set up the WebSocket client.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
_: The start frame (unused).
|
|
107
|
+
"""
|
|
108
|
+
self._leave_counter += 1
|
|
109
|
+
if _.metadata and "call_id" in _.metadata:
|
|
110
|
+
self._conversation_id = _.metadata["call_id"]
|
|
111
|
+
|
|
112
|
+
def receive(self) -> typing.AsyncIterator[bytes | str]:
|
|
113
|
+
"""Get an async iterator for receiving WebSocket messages.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
An async iterator yielding bytes or strings based on message type.
|
|
117
|
+
"""
|
|
118
|
+
return self._websocket.iter_bytes() if self._is_binary else self._websocket.iter_text()
|
|
119
|
+
|
|
120
|
+
async def send(self, data: str | bytes):
|
|
121
|
+
"""Send data through the WebSocket connection.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
data: The data to send (string or bytes).
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
if self._can_send():
|
|
128
|
+
if self._is_binary:
|
|
129
|
+
await self._websocket.send_bytes(data)
|
|
130
|
+
else:
|
|
131
|
+
await self._websocket.send_text(data)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
if isinstance(e, WebSocketDisconnect):
|
|
134
|
+
logger.warning(
|
|
135
|
+
f"{self} WebSocket disconnected during send: {e}, application_state: {self._websocket.application_state}",
|
|
136
|
+
call_id=self._conversation_id,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
logger.error(
|
|
140
|
+
f"{self} exception sending data: {e.__class__.__name__} ({e}), application_state: {self._websocket.application_state}",
|
|
141
|
+
call_id=self._conversation_id,
|
|
142
|
+
)
|
|
143
|
+
# For some reason the websocket is disconnected, and we are not able to send data
|
|
144
|
+
# So let's properly handle it and disconnect the transport if it is not already disconnecting
|
|
145
|
+
if (
|
|
146
|
+
self._websocket.application_state == WebSocketState.DISCONNECTED
|
|
147
|
+
and not self.is_closing
|
|
148
|
+
):
|
|
149
|
+
logger.warning(
|
|
150
|
+
"Closing already disconnected websocket!", call_id=self._conversation_id
|
|
151
|
+
)
|
|
152
|
+
self._closing = True
|
|
153
|
+
await self.trigger_client_disconnected()
|
|
154
|
+
|
|
155
|
+
async def disconnect(self):
|
|
156
|
+
"""Disconnect the WebSocket client."""
|
|
157
|
+
self._leave_counter -= 1
|
|
158
|
+
if self._leave_counter > 0:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
if self.is_connected and not self.is_closing:
|
|
162
|
+
self._closing = True
|
|
163
|
+
try:
|
|
164
|
+
await self._websocket.close()
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.error(f"{self} exception while closing the websocket: {e}")
|
|
167
|
+
finally:
|
|
168
|
+
await self.trigger_client_disconnected()
|
|
169
|
+
|
|
170
|
+
async def trigger_client_disconnected(self):
|
|
171
|
+
"""Trigger the client disconnected callback."""
|
|
172
|
+
await self._callbacks.on_client_disconnected(self._websocket)
|
|
173
|
+
|
|
174
|
+
async def trigger_client_connected(self):
|
|
175
|
+
"""Trigger the client connected callback."""
|
|
176
|
+
await self._callbacks.on_client_connected(self._websocket)
|
|
177
|
+
|
|
178
|
+
async def trigger_client_timeout(self):
|
|
179
|
+
"""Trigger the client timeout callback."""
|
|
180
|
+
await self._callbacks.on_session_timeout(self._websocket)
|
|
181
|
+
|
|
182
|
+
def _can_send(self):
|
|
183
|
+
"""Check if data can be sent through the WebSocket."""
|
|
184
|
+
return self.is_connected and not self.is_closing
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def is_connected(self) -> bool:
|
|
188
|
+
"""Check if the WebSocket is currently connected.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if the WebSocket is in connected state.
|
|
192
|
+
"""
|
|
193
|
+
return self._websocket.client_state == WebSocketState.CONNECTED
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def is_closing(self) -> bool:
|
|
197
|
+
"""Check if the WebSocket is currently closing.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if the WebSocket is in the process of closing.
|
|
201
|
+
"""
|
|
202
|
+
return self._closing
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class FastAPIWebsocketInputTransport(BaseInputTransport):
|
|
206
|
+
"""Input transport for FastAPI WebSocket connections.
|
|
207
|
+
|
|
208
|
+
Handles incoming WebSocket messages, deserializes frames, and manages
|
|
209
|
+
connection monitoring with optional session timeouts.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(
|
|
213
|
+
self,
|
|
214
|
+
transport: BaseTransport,
|
|
215
|
+
client: FastAPIWebsocketClient,
|
|
216
|
+
params: FastAPIWebsocketParams,
|
|
217
|
+
**kwargs,
|
|
218
|
+
):
|
|
219
|
+
"""Initialize the WebSocket input transport.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
transport: The parent transport instance.
|
|
223
|
+
client: The WebSocket client wrapper.
|
|
224
|
+
params: Transport configuration parameters.
|
|
225
|
+
**kwargs: Additional arguments passed to parent class.
|
|
226
|
+
"""
|
|
227
|
+
super().__init__(params, **kwargs)
|
|
228
|
+
self._transport = transport
|
|
229
|
+
self._client = client
|
|
230
|
+
self._params = params
|
|
231
|
+
self._receive_task = None
|
|
232
|
+
self._monitor_websocket_task = None
|
|
233
|
+
|
|
234
|
+
# Whether we have seen a StartFrame already.
|
|
235
|
+
self._initialized = False
|
|
236
|
+
|
|
237
|
+
async def start(self, frame: StartFrame):
|
|
238
|
+
"""Start the input transport and begin message processing.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
frame: The start frame containing initialization parameters.
|
|
242
|
+
"""
|
|
243
|
+
await super().start(frame)
|
|
244
|
+
|
|
245
|
+
if self._initialized:
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
self._initialized = True
|
|
249
|
+
|
|
250
|
+
await self._client.setup(frame)
|
|
251
|
+
if self._params.serializer:
|
|
252
|
+
await self._params.serializer.setup(frame)
|
|
253
|
+
if not self._monitor_websocket_task and self._params.session_timeout:
|
|
254
|
+
self._monitor_websocket_task = self.create_task(self._monitor_websocket())
|
|
255
|
+
await self._client.trigger_client_connected()
|
|
256
|
+
if not self._receive_task:
|
|
257
|
+
self._receive_task = self.create_task(self._receive_messages())
|
|
258
|
+
await self.set_transport_ready(frame)
|
|
259
|
+
|
|
260
|
+
async def _stop_tasks(self):
|
|
261
|
+
"""Stop all running tasks."""
|
|
262
|
+
if self._monitor_websocket_task:
|
|
263
|
+
await self.cancel_task(self._monitor_websocket_task)
|
|
264
|
+
self._monitor_websocket_task = None
|
|
265
|
+
if self._receive_task:
|
|
266
|
+
await self.cancel_task(self._receive_task)
|
|
267
|
+
self._receive_task = None
|
|
268
|
+
|
|
269
|
+
async def stop(self, frame: EndFrame):
|
|
270
|
+
"""Stop the input transport and cleanup resources.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
frame: The end frame signaling transport shutdown.
|
|
274
|
+
"""
|
|
275
|
+
await super().stop(frame)
|
|
276
|
+
await self._stop_tasks()
|
|
277
|
+
await self._client.disconnect()
|
|
278
|
+
|
|
279
|
+
async def cancel(self, frame: CancelFrame):
|
|
280
|
+
"""Cancel the input transport and stop all processing.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
frame: The cancel frame signaling immediate cancellation.
|
|
284
|
+
"""
|
|
285
|
+
await super().cancel(frame)
|
|
286
|
+
await self._stop_tasks()
|
|
287
|
+
await self._client.disconnect()
|
|
288
|
+
|
|
289
|
+
async def cleanup(self):
|
|
290
|
+
"""Clean up transport resources."""
|
|
291
|
+
await super().cleanup()
|
|
292
|
+
await self._transport.cleanup()
|
|
293
|
+
|
|
294
|
+
async def _receive_messages(self):
|
|
295
|
+
"""Main message receiving loop for WebSocket messages."""
|
|
296
|
+
try:
|
|
297
|
+
async for message in self._client.receive():
|
|
298
|
+
if not self._params.serializer:
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
frame = await self._params.serializer.deserialize(message)
|
|
302
|
+
|
|
303
|
+
if not frame:
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
if isinstance(frame, InputAudioRawFrame):
|
|
307
|
+
await self.push_audio_frame(frame)
|
|
308
|
+
else:
|
|
309
|
+
await self.push_frame(frame)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
|
|
312
|
+
|
|
313
|
+
await self._client.trigger_client_disconnected()
|
|
314
|
+
|
|
315
|
+
async def _monitor_websocket(self):
|
|
316
|
+
"""Wait for self._params.session_timeout seconds, if the websocket is still open, trigger timeout event."""
|
|
317
|
+
await asyncio.sleep(self._params.session_timeout)
|
|
318
|
+
await self._client.trigger_client_timeout()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class FastAPIWebsocketOutputTransport(BaseOutputTransport):
|
|
322
|
+
"""Output transport for FastAPI WebSocket connections.
|
|
323
|
+
|
|
324
|
+
Handles outgoing frame serialization, audio streaming with timing simulation,
|
|
325
|
+
and WebSocket message transmission with optional WAV header generation.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
def __init__(
|
|
329
|
+
self,
|
|
330
|
+
transport: BaseTransport,
|
|
331
|
+
client: FastAPIWebsocketClient,
|
|
332
|
+
params: FastAPIWebsocketParams,
|
|
333
|
+
**kwargs,
|
|
334
|
+
):
|
|
335
|
+
"""Initialize the WebSocket output transport.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
transport: The parent transport instance.
|
|
339
|
+
client: The WebSocket client wrapper.
|
|
340
|
+
params: Transport configuration parameters.
|
|
341
|
+
**kwargs: Additional arguments passed to parent class.
|
|
342
|
+
"""
|
|
343
|
+
super().__init__(params, **kwargs)
|
|
344
|
+
|
|
345
|
+
self._transport = transport
|
|
346
|
+
self._client = client
|
|
347
|
+
self._params = params
|
|
348
|
+
|
|
349
|
+
# write_audio_frame() is called quickly, as soon as we get audio
|
|
350
|
+
# (e.g. from the TTS), and since this is just a network connection we
|
|
351
|
+
# would be sending it to quickly. Instead, we want to block to emulate
|
|
352
|
+
# an audio device, this is what the send interval is. It will be
|
|
353
|
+
# computed on StartFrame.
|
|
354
|
+
self._send_interval = 0
|
|
355
|
+
self._next_send_time = 0
|
|
356
|
+
|
|
357
|
+
# Whether we have seen a StartFrame already.
|
|
358
|
+
self._initialized = False
|
|
359
|
+
|
|
360
|
+
async def start(self, frame: StartFrame):
|
|
361
|
+
"""Start the output transport and initialize timing.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
frame: The start frame containing initialization parameters.
|
|
365
|
+
"""
|
|
366
|
+
await super().start(frame)
|
|
367
|
+
|
|
368
|
+
if self._initialized:
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
self._initialized = True
|
|
372
|
+
|
|
373
|
+
await self._client.setup(frame)
|
|
374
|
+
if self._params.serializer:
|
|
375
|
+
await self._params.serializer.setup(frame)
|
|
376
|
+
self._send_interval = (self.audio_chunk_size / self.sample_rate) / 2
|
|
377
|
+
await self.set_transport_ready(frame)
|
|
378
|
+
|
|
379
|
+
async def stop(self, frame: EndFrame):
|
|
380
|
+
"""Stop the output transport and cleanup resources.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
frame: The end frame signaling transport shutdown.
|
|
384
|
+
"""
|
|
385
|
+
await super().stop(frame)
|
|
386
|
+
await self._write_frame(frame)
|
|
387
|
+
await self._client.disconnect()
|
|
388
|
+
|
|
389
|
+
async def cancel(self, frame: CancelFrame):
|
|
390
|
+
"""Cancel the output transport and stop all processing.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
frame: The cancel frame signaling immediate cancellation.
|
|
394
|
+
"""
|
|
395
|
+
await super().cancel(frame)
|
|
396
|
+
await self._write_frame(frame)
|
|
397
|
+
await self._client.disconnect()
|
|
398
|
+
|
|
399
|
+
async def cleanup(self):
|
|
400
|
+
"""Clean up transport resources."""
|
|
401
|
+
await super().cleanup()
|
|
402
|
+
await self._transport.cleanup()
|
|
403
|
+
|
|
404
|
+
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
|
405
|
+
"""Process outgoing frames with special handling for interruptions.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
frame: The frame to process.
|
|
409
|
+
direction: The direction of frame flow in the pipeline.
|
|
410
|
+
"""
|
|
411
|
+
await super().process_frame(frame, direction)
|
|
412
|
+
|
|
413
|
+
if isinstance(frame, StartInterruptionFrame):
|
|
414
|
+
await self._write_frame(frame)
|
|
415
|
+
self._next_send_time = 0
|
|
416
|
+
|
|
417
|
+
async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
|
|
418
|
+
"""Send a transport message frame.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
frame: The transport message frame to send.
|
|
422
|
+
"""
|
|
423
|
+
await self._write_frame(frame)
|
|
424
|
+
|
|
425
|
+
async def write_audio_frame(self, frame: OutputAudioRawFrame):
|
|
426
|
+
"""Write an audio frame to the WebSocket with timing simulation.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
frame: The output audio frame to write.
|
|
430
|
+
"""
|
|
431
|
+
if self._client.is_closing or not self._client.is_connected:
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
frame = OutputAudioRawFrame(
|
|
435
|
+
audio=frame.audio,
|
|
436
|
+
sample_rate=self.sample_rate,
|
|
437
|
+
num_channels=self._params.audio_out_channels,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if self._params.add_wav_header:
|
|
441
|
+
with io.BytesIO() as buffer:
|
|
442
|
+
with wave.open(buffer, "wb") as wf:
|
|
443
|
+
wf.setsampwidth(2)
|
|
444
|
+
wf.setnchannels(frame.num_channels)
|
|
445
|
+
wf.setframerate(frame.sample_rate)
|
|
446
|
+
wf.writeframes(frame.audio)
|
|
447
|
+
wav_frame = OutputAudioRawFrame(
|
|
448
|
+
buffer.getvalue(),
|
|
449
|
+
sample_rate=frame.sample_rate,
|
|
450
|
+
num_channels=frame.num_channels,
|
|
451
|
+
)
|
|
452
|
+
frame = wav_frame
|
|
453
|
+
|
|
454
|
+
await self._write_frame(frame)
|
|
455
|
+
|
|
456
|
+
# Simulate audio playback with a sleep.
|
|
457
|
+
await self._write_audio_sleep()
|
|
458
|
+
|
|
459
|
+
async def _write_frame(self, frame: Frame):
|
|
460
|
+
"""Serialize and send a frame through the WebSocket."""
|
|
461
|
+
if not self._params.serializer:
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
payload = await self._params.serializer.serialize(frame)
|
|
466
|
+
if payload:
|
|
467
|
+
await self._client.send(payload)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
logger.error(f"{self} exception sending data: {e.__class__.__name__} ({e})")
|
|
470
|
+
|
|
471
|
+
async def _write_audio_sleep(self):
|
|
472
|
+
"""Simulate audio playback timing with appropriate delays."""
|
|
473
|
+
# Simulate a clock.
|
|
474
|
+
current_time = time.monotonic()
|
|
475
|
+
sleep_duration = max(0, self._next_send_time - current_time)
|
|
476
|
+
await asyncio.sleep(sleep_duration)
|
|
477
|
+
if sleep_duration == 0:
|
|
478
|
+
self._next_send_time = time.monotonic() + self._send_interval
|
|
479
|
+
else:
|
|
480
|
+
self._next_send_time += self._send_interval
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class FastAPIWebsocketTransport(BaseTransport):
|
|
484
|
+
"""FastAPI WebSocket transport for real-time audio/video streaming.
|
|
485
|
+
|
|
486
|
+
Provides bidirectional WebSocket communication with frame serialization,
|
|
487
|
+
session management, and event handling for client connections and timeouts.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
def __init__(
|
|
491
|
+
self,
|
|
492
|
+
websocket: WebSocket,
|
|
493
|
+
params: FastAPIWebsocketParams,
|
|
494
|
+
input_name: Optional[str] = None,
|
|
495
|
+
output_name: Optional[str] = None,
|
|
496
|
+
):
|
|
497
|
+
"""Initialize the FastAPI WebSocket transport.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
websocket: The FastAPI WebSocket connection.
|
|
501
|
+
params: Transport configuration parameters.
|
|
502
|
+
input_name: Optional name for the input processor.
|
|
503
|
+
output_name: Optional name for the output processor.
|
|
504
|
+
"""
|
|
505
|
+
super().__init__(input_name=input_name, output_name=output_name)
|
|
506
|
+
|
|
507
|
+
self._params = params
|
|
508
|
+
|
|
509
|
+
self._callbacks = FastAPIWebsocketCallbacks(
|
|
510
|
+
on_client_connected=self._on_client_connected,
|
|
511
|
+
on_client_disconnected=self._on_client_disconnected,
|
|
512
|
+
on_session_timeout=self._on_session_timeout,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
is_binary = False
|
|
516
|
+
if self._params.serializer:
|
|
517
|
+
is_binary = self._params.serializer.type == FrameSerializerType.BINARY
|
|
518
|
+
self._client = FastAPIWebsocketClient(websocket, is_binary, self._callbacks)
|
|
519
|
+
|
|
520
|
+
self._input = FastAPIWebsocketInputTransport(
|
|
521
|
+
self, self._client, self._params, name=self._input_name
|
|
522
|
+
)
|
|
523
|
+
self._output = FastAPIWebsocketOutputTransport(
|
|
524
|
+
self, self._client, self._params, name=self._output_name
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Register supported handlers. The user will only be able to register
|
|
528
|
+
# these handlers.
|
|
529
|
+
self._register_event_handler("on_client_connected")
|
|
530
|
+
self._register_event_handler("on_client_disconnected")
|
|
531
|
+
self._register_event_handler("on_session_timeout")
|
|
532
|
+
|
|
533
|
+
def input(self) -> FastAPIWebsocketInputTransport:
|
|
534
|
+
"""Get the input transport processor.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
The WebSocket input transport instance.
|
|
538
|
+
"""
|
|
539
|
+
return self._input
|
|
540
|
+
|
|
541
|
+
def output(self) -> FastAPIWebsocketOutputTransport:
|
|
542
|
+
"""Get the output transport processor.
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
The WebSocket output transport instance.
|
|
546
|
+
"""
|
|
547
|
+
return self._output
|
|
548
|
+
|
|
549
|
+
async def _on_client_connected(self, websocket):
|
|
550
|
+
"""Handle client connected event."""
|
|
551
|
+
await self._call_event_handler("on_client_connected", websocket)
|
|
552
|
+
|
|
553
|
+
async def _on_client_disconnected(self, websocket):
|
|
554
|
+
"""Handle client disconnected event."""
|
|
555
|
+
await self._call_event_handler("on_client_disconnected", websocket)
|
|
556
|
+
|
|
557
|
+
async def _on_session_timeout(self, websocket):
|
|
558
|
+
"""Handle session timeout event."""
|
|
559
|
+
await self._call_event_handler("on_session_timeout", websocket)
|