Compare commits
25 Commits
hush/openA
...
copilot/vs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1672570060 | ||
|
|
77d8abcce5 | ||
|
|
67cf4b400a | ||
|
|
0340431608 | ||
|
|
451e4a9050 | ||
|
|
1fe27eb0a2 | ||
|
|
d7e1389497 | ||
|
|
8c7230aa8f | ||
|
|
2cf71239b0 | ||
|
|
ec2c62e32b | ||
|
|
38ce85e9a0 | ||
|
|
2279e5a899 | ||
|
|
cce6eb5d87 | ||
|
|
c2b98ae557 | ||
|
|
727eb12b16 | ||
|
|
ba96bd05d3 | ||
|
|
8ead309f8d | ||
|
|
fad0e55c64 | ||
|
|
74b1af56a0 | ||
|
|
6924850ec4 | ||
|
|
dfe7815dc5 | ||
|
|
0a043154f2 | ||
|
|
5e322eba9e | ||
|
|
11d0c3d46d | ||
|
|
95f72f6dce |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -19,6 +19,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
...
|
||||
```
|
||||
|
||||
### Changed
|
||||
|
||||
- Update `livekit` to 1.0.13.
|
||||
|
||||
- `torch` and `torchaudio` are no longer required for running Smart Turn
|
||||
locally. This avoids gigabytes of dependencies being installed.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `websockets` dependency to support version 15.0. Removed deprecated
|
||||
usage of `ConnectionClosed.code` and `ConnectionClosed.reason` attributes in
|
||||
`AWSTranscribeSTTService` for compatibility.
|
||||
|
||||
- Refactored `pyproject.toml` to reduce websockets dependency repetition using
|
||||
self-referencing extras. All websockets-dependent services now reference a
|
||||
shared `websockets-base` extra.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `PipelineTask` events `on_pipeline_stopped`, `on_pipeline_ended` and
|
||||
@@ -27,6 +44,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a self-cancellation deadlock in `UserIdleProcessor` when returning
|
||||
`False` from an idle callback. The task now terminates naturally instead of
|
||||
attempting to cancel itself.
|
||||
|
||||
- Fixed an issue in `AudioBufferProcessor` where a recording is not created
|
||||
when a bot speaks and user input is blocked.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.frames.frames import TextFrame
|
||||
from pipecat.frames.frames import TTSSpeakFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineTask
|
||||
@@ -50,7 +50,7 @@ async def main():
|
||||
async def on_first_participant_joined(transport, participant_id):
|
||||
await asyncio.sleep(1)
|
||||
await task.queue_frame(
|
||||
TextFrame(
|
||||
TTSSpeakFrame(
|
||||
"Hello there! How are you doing today? Would you like to talk about the weather?"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -30,10 +30,6 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# To use this locally, set the environment variable LOCAL_SMART_TURN_MODEL_PATH
|
||||
# to the Smart Turn v3 ONNX model file.
|
||||
smart_turn_model_path = os.getenv("LOCAL_SMART_TURN_MODEL_PATH")
|
||||
|
||||
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
|
||||
# instantiated. The function will be called when the desired transport gets
|
||||
# selected.
|
||||
@@ -42,25 +38,19 @@ transport_params = {
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
|
||||
turn_analyzer=LocalSmartTurnAnalyzerV3(
|
||||
smart_turn_model_path=smart_turn_model_path, params=SmartTurnParams()
|
||||
),
|
||||
turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()),
|
||||
),
|
||||
"twilio": lambda: FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
|
||||
turn_analyzer=LocalSmartTurnAnalyzerV3(
|
||||
smart_turn_model_path=smart_turn_model_path, params=SmartTurnParams()
|
||||
),
|
||||
turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()),
|
||||
),
|
||||
"webrtc": lambda: TransportParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
|
||||
turn_analyzer=LocalSmartTurnAnalyzerV3(
|
||||
smart_turn_model_path=smart_turn_model_path, params=SmartTurnParams()
|
||||
),
|
||||
turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -47,32 +47,32 @@ Website = "https://pipecat.ai"
|
||||
[project.optional-dependencies]
|
||||
aic = [ "aic-sdk~=1.0.1" ]
|
||||
anthropic = [ "anthropic~=0.49.0" ]
|
||||
assemblyai = [ "websockets>=13.1,<15.0" ]
|
||||
asyncai = [ "websockets>=13.1,<15.0" ]
|
||||
aws = [ "aioboto3~=15.0.0", "websockets>=13.1,<15.0" ]
|
||||
assemblyai = [ "pipecat-ai[websockets-base]" ]
|
||||
asyncai = [ "pipecat-ai[websockets-base]" ]
|
||||
aws = [ "aioboto3~=15.0.0", "pipecat-ai[websockets-base]" ]
|
||||
aws-nova-sonic = [ "aws_sdk_bedrock_runtime~=0.0.2; python_version>='3.12'" ]
|
||||
azure = [ "azure-cognitiveservices-speech~=1.42.0"]
|
||||
cartesia = [ "cartesia~=2.0.3", "websockets>=13.1,<15.0" ]
|
||||
cartesia = [ "cartesia~=2.0.3", "pipecat-ai[websockets-base]" ]
|
||||
cerebras = []
|
||||
deepseek = []
|
||||
daily = [ "daily-python~=0.19.9" ]
|
||||
deepgram = [ "deepgram-sdk~=4.7.0" ]
|
||||
elevenlabs = [ "websockets>=13.1,<15.0" ]
|
||||
elevenlabs = [ "pipecat-ai[websockets-base]" ]
|
||||
fal = [ "fal-client~=0.5.9" ]
|
||||
fireworks = []
|
||||
fish = [ "ormsgpack~=1.7.0", "websockets>=13.1,<15.0" ]
|
||||
gladia = [ "websockets>=13.1,<15.0" ]
|
||||
google = [ "google-cloud-speech~=2.32.0", "google-cloud-texttospeech~=2.26.0", "google-genai~=1.24.0", "websockets>=13.1,<15.0" ]
|
||||
fish = [ "ormsgpack~=1.7.0", "pipecat-ai[websockets-base]" ]
|
||||
gladia = [ "pipecat-ai[websockets-base]" ]
|
||||
google = [ "google-cloud-speech~=2.32.0", "google-cloud-texttospeech~=2.26.0", "google-genai~=1.24.0", "pipecat-ai[websockets-base]" ]
|
||||
grok = []
|
||||
groq = [ "groq~=0.23.0" ]
|
||||
gstreamer = [ "pygobject~=3.50.0" ]
|
||||
heygen = [ "livekit>=0.22.0", "websockets>=13.1,<15.0" ]
|
||||
heygen = [ "livekit>=1.0.13", "pipecat-ai[websockets-base]" ]
|
||||
inworld = []
|
||||
krisp = [ "pipecat-ai-krisp~=0.4.0" ]
|
||||
koala = [ "pvkoala~=2.0.3" ]
|
||||
langchain = [ "langchain~=0.3.20", "langchain-community~=0.3.20", "langchain-openai~=0.3.9" ]
|
||||
livekit = [ "livekit~=0.22.0", "livekit-api~=0.8.2", "tenacity>=8.2.3,<10.0.0" ]
|
||||
lmnt = [ "websockets>=13.1,<15.0" ]
|
||||
livekit = [ "livekit~=1.0.13", "livekit-api~=1.0.5", "tenacity>=8.2.3,<10.0.0" ]
|
||||
lmnt = [ "pipecat-ai[websockets-base]" ]
|
||||
local = [ "pyaudio~=0.2.14" ]
|
||||
mcp = [ "mcp[cli]~=1.9.4" ]
|
||||
mem0 = [ "mem0ai~=0.1.94" ]
|
||||
@@ -80,26 +80,26 @@ mistral = []
|
||||
mlx-whisper = [ "mlx-whisper~=0.4.2" ]
|
||||
moondream = [ "accelerate~=1.10.0", "einops~=0.8.0", "pyvips[binary]~=3.0.0", "timm~=1.0.13", "transformers>=4.48.0" ]
|
||||
nim = []
|
||||
neuphonic = [ "websockets>=13.1,<15.0" ]
|
||||
neuphonic = [ "pipecat-ai[websockets-base]" ]
|
||||
noisereduce = [ "noisereduce~=3.0.3" ]
|
||||
openai = [ "websockets>=13.1,<15.0" ]
|
||||
openai = [ "pipecat-ai[websockets-base]" ]
|
||||
openpipe = [ "openpipe~=4.50.0" ]
|
||||
openrouter = []
|
||||
perplexity = []
|
||||
playht = [ "websockets>=13.1,<15.0" ]
|
||||
playht = [ "pipecat-ai[websockets-base]" ]
|
||||
qwen = []
|
||||
rime = [ "websockets>=13.1,<15.0" ]
|
||||
rime = [ "pipecat-ai[websockets-base]" ]
|
||||
riva = [ "nvidia-riva-client~=2.21.1" ]
|
||||
runner = [ "python-dotenv>=1.0.0,<2.0.0", "uvicorn>=0.32.0,<1.0.0", "fastapi>=0.115.6,<0.117.0", "pipecat-ai-small-webrtc-prebuilt>=1.0.0"]
|
||||
sambanova = []
|
||||
sarvam = [ "websockets>=13.1,<15.0" ]
|
||||
sarvam = [ "pipecat-ai[websockets-base]" ]
|
||||
sentry = [ "sentry-sdk~=2.23.1" ]
|
||||
local-smart-turn = [ "coremltools>=8.0", "transformers", "torch>=2.5.0,<3", "torchaudio>=2.5.0,<3" ]
|
||||
local-smart-turn-v3 = [ "transformers", "torch>=2.5.0,<3", "torchaudio>=2.5.0,<3", "onnxruntime>=1.20.1, <2" ]
|
||||
local-smart-turn-v3 = [ "transformers", "onnxruntime>=1.20.1, <2" ]
|
||||
remote-smart-turn = []
|
||||
silero = [ "onnxruntime>=1.20.1, <2" ]
|
||||
simli = [ "simli-ai~=0.1.10"]
|
||||
soniox = [ "websockets>=13.1,<15.0" ]
|
||||
soniox = [ "pipecat-ai[websockets-base]" ]
|
||||
soundfile = [ "soundfile~=0.13.0" ]
|
||||
speechmatics = [ "speechmatics-rt>=0.4.0" ]
|
||||
tavus=[]
|
||||
@@ -107,7 +107,8 @@ together = []
|
||||
tracing = [ "opentelemetry-sdk>=1.33.0", "opentelemetry-api>=1.33.0", "opentelemetry-instrumentation>=0.54b0" ]
|
||||
ultravox = [ "transformers>=4.48.0", "vllm>=0.9.0" ]
|
||||
webrtc = [ "aiortc~=1.11.0", "opencv-python~=4.11.0.86" ]
|
||||
websocket = [ "websockets>=13.1,<15.0", "fastapi>=0.115.6,<0.117.0" ]
|
||||
websocket = [ "pipecat-ai[websockets-base]", "fastapi>=0.115.6,<0.117.0" ]
|
||||
websockets-base = [ "websockets>=13.1,<16.0" ]
|
||||
whisper = [ "faster-whisper~=1.1.1" ]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -98,15 +98,15 @@ class LocalSmartTurnAnalyzerV3(BaseSmartTurn):
|
||||
inputs = self._feature_extractor(
|
||||
audio_array,
|
||||
sampling_rate=16000,
|
||||
return_tensors="pt",
|
||||
return_tensors="np",
|
||||
padding="max_length",
|
||||
max_length=8 * 16000,
|
||||
truncation=True,
|
||||
do_normalize=True,
|
||||
)
|
||||
|
||||
# Convert to numpy and ensure correct shape for ONNX
|
||||
input_features = inputs.input_features.squeeze(0).numpy().astype(np.float32)
|
||||
# Extract features and ensure correct shape for ONNX
|
||||
input_features = inputs.input_features.squeeze(0).astype(np.float32)
|
||||
input_features = np.expand_dims(input_features, axis=0) # Add batch dimension
|
||||
|
||||
# Run ONNX inference
|
||||
|
||||
@@ -17,7 +17,6 @@ from pipecat.frames.frames import (
|
||||
Frame,
|
||||
FunctionCallInProgressFrame,
|
||||
FunctionCallResultFrame,
|
||||
StartFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
)
|
||||
@@ -185,15 +184,13 @@ class UserIdleProcessor(FrameProcessor):
|
||||
|
||||
Runs in a loop until cancelled or callback indicates completion.
|
||||
"""
|
||||
while True:
|
||||
running = True
|
||||
while running:
|
||||
try:
|
||||
await asyncio.wait_for(self._idle_event.wait(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError:
|
||||
if not self._interrupted:
|
||||
self._retry_count += 1
|
||||
should_continue = await self._callback(self, self._retry_count)
|
||||
if not should_continue:
|
||||
await self._stop()
|
||||
break
|
||||
running = await self._callback(self, self._retry_count)
|
||||
finally:
|
||||
self._idle_event.clear()
|
||||
|
||||
@@ -70,7 +70,6 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -79,6 +78,10 @@ from pipecat.runner.types import (
|
||||
SmallWebRTCRunnerArguments,
|
||||
WebSocketRunnerArguments,
|
||||
)
|
||||
from pipecat.transports.smallwebrtc.request_handler import (
|
||||
SmallWebRTCRequest,
|
||||
SmallWebRTCRequestHandler,
|
||||
)
|
||||
|
||||
try:
|
||||
import uvicorn
|
||||
@@ -187,9 +190,6 @@ def _setup_webrtc_routes(app: FastAPI, esp32_mode: bool = False, host: str = "lo
|
||||
logger.error(f"WebRTC transport dependencies not installed: {e}")
|
||||
return
|
||||
|
||||
# Store connections by pc_id
|
||||
pcs_map: Dict[str, SmallWebRTCConnection] = {}
|
||||
|
||||
# Mount the frontend
|
||||
app.mount("/client", SmallWebRTCPrebuiltUI)
|
||||
|
||||
@@ -198,51 +198,33 @@ def _setup_webrtc_routes(app: FastAPI, esp32_mode: bool = False, host: str = "lo
|
||||
"""Redirect root requests to client interface."""
|
||||
return RedirectResponse(url="/client/")
|
||||
|
||||
# Initialize the SmallWebRTC request handler
|
||||
small_webrtc_handler: SmallWebRTCRequestHandler = SmallWebRTCRequestHandler(
|
||||
esp32_mode=esp32_mode, host=host
|
||||
)
|
||||
|
||||
@app.post("/api/offer")
|
||||
async def offer(request: dict, background_tasks: BackgroundTasks):
|
||||
"""Handle WebRTC offer requests and manage peer connections."""
|
||||
pc_id = request.get("pc_id")
|
||||
|
||||
if pc_id and pc_id in pcs_map:
|
||||
pipecat_connection = pcs_map[pc_id]
|
||||
logger.info(f"Reusing existing connection for pc_id: {pc_id}")
|
||||
await pipecat_connection.renegotiate(
|
||||
sdp=request["sdp"],
|
||||
type=request["type"],
|
||||
restart_pc=request.get("restart_pc", False),
|
||||
)
|
||||
else:
|
||||
pipecat_connection = SmallWebRTCConnection()
|
||||
await pipecat_connection.initialize(sdp=request["sdp"], type=request["type"])
|
||||
|
||||
@pipecat_connection.event_handler("closed")
|
||||
async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
|
||||
"""Handle WebRTC connection closure and cleanup."""
|
||||
logger.info(f"Discarding peer connection for pc_id: {webrtc_connection.pc_id}")
|
||||
pcs_map.pop(webrtc_connection.pc_id, None)
|
||||
async def offer(request: SmallWebRTCRequest, background_tasks: BackgroundTasks):
|
||||
"""Handle WebRTC offer requests via SmallWebRTCRequestHandler."""
|
||||
|
||||
# Prepare runner arguments with the callback to run your bot
|
||||
async def webrtc_connection_callback(connection):
|
||||
bot_module = _get_bot_module()
|
||||
runner_args = SmallWebRTCRunnerArguments(webrtc_connection=pipecat_connection)
|
||||
runner_args = SmallWebRTCRunnerArguments(webrtc_connection=connection)
|
||||
background_tasks.add_task(bot_module.bot, runner_args)
|
||||
|
||||
answer = pipecat_connection.get_answer()
|
||||
|
||||
# Apply ESP32 SDP munging if enabled
|
||||
if esp32_mode and host != "localhost":
|
||||
from pipecat.runner.utils import smallwebrtc_sdp_munging
|
||||
|
||||
answer["sdp"] = smallwebrtc_sdp_munging(answer["sdp"], host)
|
||||
|
||||
pcs_map[answer["pc_id"]] = pipecat_connection
|
||||
# Delegate handling to SmallWebRTCRequestHandler
|
||||
answer = await small_webrtc_handler.handle_web_request(
|
||||
request=request,
|
||||
webrtc_connection_callback=webrtc_connection_callback,
|
||||
)
|
||||
return answer
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage FastAPI application lifecycle and cleanup connections."""
|
||||
yield
|
||||
coros = [pc.disconnect() for pc in pcs_map.values()]
|
||||
await asyncio.gather(*coros)
|
||||
pcs_map.clear()
|
||||
await small_webrtc_handler.close()
|
||||
|
||||
app.router.lifespan_context = lifespan
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from pipecat.frames.frames import (
|
||||
TTSStoppedFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.tts_service import InterruptibleTTSService, TTSService
|
||||
from pipecat.services.tts_service import TTSService, WebsocketWordTTSService
|
||||
from pipecat.transcriptions.language import Language
|
||||
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||
|
||||
@@ -71,7 +71,7 @@ def language_to_async_language(language: Language) -> Optional[str]:
|
||||
return result
|
||||
|
||||
|
||||
class AsyncAITTSService(InterruptibleTTSService):
|
||||
class AsyncAITTSService(WebsocketWordTTSService):
|
||||
"""Async TTS service with WebSocket streaming.
|
||||
|
||||
Provides text-to-speech using Async's streaming WebSocket API.
|
||||
@@ -148,6 +148,8 @@ class AsyncAITTSService(InterruptibleTTSService):
|
||||
self._receive_task = None
|
||||
self._keepalive_task = None
|
||||
self._started = False
|
||||
self._current_text = "" # Track current text for generating timestamps
|
||||
self._transcription_generated = False # Track if transcription was already generated
|
||||
|
||||
def can_generate_metrics(self) -> bool:
|
||||
"""Check if this service can generate processing metrics.
|
||||
@@ -286,12 +288,28 @@ class AsyncAITTSService(InterruptibleTTSService):
|
||||
|
||||
elif msg.get("audio"):
|
||||
await self.stop_ttfb_metrics()
|
||||
|
||||
# Start word timestamps and add the entire text as one "word"
|
||||
# This generates transcription frames for the bot - but only once per text
|
||||
if not self._transcription_generated and self._current_text.strip():
|
||||
logger.debug(
|
||||
f"{self}: Generating transcription for text: '{self._current_text}'"
|
||||
)
|
||||
self.start_word_timestamps()
|
||||
# Add the entire text as a single timestamp at time 0
|
||||
await self.add_word_timestamps([(self._current_text, 0.0)])
|
||||
# Add stop markers to end the word timestamps
|
||||
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)])
|
||||
self._transcription_generated = True
|
||||
logger.debug(f"{self}: Transcription flag set to True")
|
||||
|
||||
frame = TTSAudioRawFrame(
|
||||
audio=base64.b64decode(msg["audio"]),
|
||||
sample_rate=self.sample_rate,
|
||||
num_channels=1,
|
||||
)
|
||||
await self.push_frame(frame)
|
||||
|
||||
elif msg.get("error_code"):
|
||||
logger.error(f"{self} error: {msg}")
|
||||
await self.push_frame(TTSStoppedFrame())
|
||||
@@ -324,7 +342,7 @@ class AsyncAITTSService(InterruptibleTTSService):
|
||||
Yields:
|
||||
Frame: Audio frames containing the synthesized speech.
|
||||
"""
|
||||
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||
logger.debug(f"{self}: AsyncAI Generating TTS [{text}]")
|
||||
|
||||
try:
|
||||
if not self._websocket or self._websocket.state is State.CLOSED:
|
||||
@@ -335,6 +353,12 @@ class AsyncAITTSService(InterruptibleTTSService):
|
||||
yield TTSStartedFrame()
|
||||
self._started = True
|
||||
|
||||
# Store the current text for generating timestamps
|
||||
self._current_text = text
|
||||
self._transcription_generated = False # Reset for new text
|
||||
# Reset word timestamps for new text to ensure clean state
|
||||
self.reset_word_timestamps()
|
||||
logger.debug(f"{self}: New text set: '{text}', transcription flag reset to False")
|
||||
msg = self._build_msg(text=text, force=True)
|
||||
|
||||
try:
|
||||
@@ -346,7 +370,6 @@ class AsyncAITTSService(InterruptibleTTSService):
|
||||
await self._disconnect()
|
||||
await self._connect()
|
||||
return
|
||||
yield None
|
||||
except Exception as e:
|
||||
logger.error(f"{self} exception: {e}")
|
||||
|
||||
|
||||
@@ -532,9 +532,7 @@ class AWSTranscribeSTTService(STTService):
|
||||
logger.debug(f"{self} Other message type received: {headers}")
|
||||
logger.debug(f"{self} Payload: {payload}")
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
logger.error(
|
||||
f"{self} WebSocket connection closed in receive loop with code {e.code}: {e.reason}"
|
||||
)
|
||||
logger.error(f"{self} WebSocket connection closed in receive loop: {e}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"{self} Unexpected error in receive loop: {e}")
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"""MCP (Model Context Protocol) client for integrating external tools with LLMs."""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from typing import Any, Dict, List, TypeAlias
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -28,6 +28,8 @@ except ModuleNotFoundError as e:
|
||||
logger.error("In order to use an MCP client, you need to `pip install pipecat-ai[mcp]`.")
|
||||
raise Exception(f"Missing module: {e}")
|
||||
|
||||
ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters
|
||||
|
||||
|
||||
class MCPClient(BaseObject):
|
||||
"""Client for Model Context Protocol (MCP) servers.
|
||||
@@ -42,7 +44,7 @@ class MCPClient(BaseObject):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_params: Tuple[StdioServerParameters, SseServerParameters, StreamableHttpParameters],
|
||||
server_params: ServerParameters,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the MCP client with server parameters.
|
||||
|
||||
200
src/pipecat/transports/smallwebrtc/request_handler.py
Normal file
200
src/pipecat/transports/smallwebrtc/request_handler.py
Normal file
@@ -0,0 +1,200 @@
|
||||
#
|
||||
# Copyright (c) 2024–2025, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""SmallWebRTC request handler for managing peer connections.
|
||||
|
||||
This module provides a client for handling web requests and managing WebRTC connections.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.transports.smallwebrtc.connection import IceServer, SmallWebRTCConnection
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmallWebRTCRequest:
|
||||
"""Small WebRTC transport session arguments for the runner.
|
||||
|
||||
Parameters:
|
||||
sdp: The SDP string (Session Description Protocol).
|
||||
type: The type of the SDP, either "offer" or "answer".
|
||||
pc_id: Optional identifier for the peer connection.
|
||||
restart_pc: Optional whether to restart the peer connection.
|
||||
request_data: Optional custom data sent by the customer.
|
||||
"""
|
||||
|
||||
sdp: str
|
||||
type: str
|
||||
pc_id: Optional[str] = None
|
||||
restart_pc: Optional[bool] = None
|
||||
request_data: Optional[Any] = None
|
||||
|
||||
|
||||
class ConnectionMode(Enum):
|
||||
"""Enum defining the connection handling modes."""
|
||||
|
||||
SINGLE = "single" # Only one active connection allowed
|
||||
MULTIPLE = "multiple" # Multiple simultaneous connections allowed
|
||||
|
||||
|
||||
class SmallWebRTCRequestHandler:
|
||||
"""SmallWebRTC request handler for managing peer connections.
|
||||
|
||||
This class is responsible for:
|
||||
- Handling incoming SmallWebRTC requests.
|
||||
- Creating and managing WebRTC peer connections.
|
||||
- Supporting ESP32-specific SDP munging if enabled.
|
||||
- Invoking callbacks for newly initialized connections.
|
||||
- Supporting both single and multiple connection modes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ice_servers: Optional[List[IceServer]] = None,
|
||||
esp32_mode: bool = False,
|
||||
host: Optional[str] = None,
|
||||
connection_mode: ConnectionMode = ConnectionMode.MULTIPLE,
|
||||
) -> None:
|
||||
"""Initialize a SmallWebRTC request handler.
|
||||
|
||||
Args:
|
||||
ice_servers (Optional[List[IceServer]]): List of ICE servers to use for WebRTC
|
||||
connections.
|
||||
esp32_mode (bool): If True, enables ESP32-specific SDP munging.
|
||||
host (Optional[str]): Host address used for SDP munging in ESP32 mode.
|
||||
Ignored if `esp32_mode` is False.
|
||||
connection_mode (ConnectionMode): Mode of operation for handling connections.
|
||||
SINGLE allows only one active connection, MULTIPLE allows several.
|
||||
"""
|
||||
self._ice_servers = ice_servers
|
||||
self._esp32_mode = esp32_mode
|
||||
self._host = host
|
||||
self._connection_mode = connection_mode
|
||||
|
||||
# Store connections by pc_id
|
||||
self._pcs_map: Dict[str, SmallWebRTCConnection] = {}
|
||||
|
||||
def _check_single_connection_constraints(self, pc_id: Optional[str]) -> None:
|
||||
"""Check if the connection request satisfies single connection mode constraints.
|
||||
|
||||
Args:
|
||||
pc_id: The peer connection ID from the request
|
||||
|
||||
Raises:
|
||||
HTTPException: If constraints are violated in single connection mode
|
||||
"""
|
||||
if self._connection_mode != ConnectionMode.SINGLE:
|
||||
return
|
||||
|
||||
if not self._pcs_map: # No existing connections
|
||||
return
|
||||
|
||||
# Get the existing connection (should be only one in single mode)
|
||||
existing_connection = next(iter(self._pcs_map.values()))
|
||||
|
||||
if existing_connection.pc_id != pc_id and pc_id:
|
||||
logger.warning(
|
||||
f"Connection pc_id mismatch: existing={existing_connection.pc_id}, received={pc_id}"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="PC ID mismatch with existing connection")
|
||||
|
||||
if not pc_id:
|
||||
logger.warning(
|
||||
"Cannot create new connection: existing connection found but no pc_id received"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot create new connection with existing connection active",
|
||||
)
|
||||
|
||||
async def handle_web_request(
|
||||
self,
|
||||
request: SmallWebRTCRequest,
|
||||
webrtc_connection_callback: Callable[[Any], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Handle a SmallWebRTC request and resolve the pending answer.
|
||||
|
||||
This method will:
|
||||
- Reuse an existing WebRTC connection if `pc_id` exists.
|
||||
- Otherwise, create a new `SmallWebRTCConnection`.
|
||||
- Invoke the provided callback with the connection.
|
||||
- Manage ESP32-specific munging if enabled.
|
||||
- Enforce single/multiple connection mode constraints.
|
||||
|
||||
Args:
|
||||
request (SmallWebRTCRequest): The incoming WebRTC request, containing
|
||||
SDP, type, and optionally a `pc_id`.
|
||||
webrtc_connection_callback (Callable[[Any], Awaitable[None]]): An
|
||||
asynchronous callback function that is invoked with the WebRTC connection.
|
||||
|
||||
Raises:
|
||||
HTTPException: If connection mode constraints are violated
|
||||
Exception: Any exception raised during request handling or callback execution
|
||||
will be logged and propagated.
|
||||
"""
|
||||
try:
|
||||
pc_id = request.pc_id
|
||||
|
||||
# Check connection mode constraints first
|
||||
self._check_single_connection_constraints(pc_id)
|
||||
|
||||
# After constraints are satisfied, get the existing connection if any
|
||||
existing_connection = self._pcs_map.get(pc_id) if pc_id else None
|
||||
|
||||
if existing_connection:
|
||||
pipecat_connection = existing_connection
|
||||
logger.info(f"Reusing existing connection for pc_id: {pc_id}")
|
||||
await pipecat_connection.renegotiate(
|
||||
sdp=request.sdp,
|
||||
type=request.type,
|
||||
restart_pc=request.restart_pc or False,
|
||||
)
|
||||
else:
|
||||
pipecat_connection = SmallWebRTCConnection(ice_servers=self._ice_servers)
|
||||
await pipecat_connection.initialize(sdp=request.sdp, type=request.type)
|
||||
|
||||
@pipecat_connection.event_handler("closed")
|
||||
async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
|
||||
logger.info(f"Discarding peer connection for pc_id: {webrtc_connection.pc_id}")
|
||||
self._pcs_map.pop(webrtc_connection.pc_id, None)
|
||||
|
||||
# Invoke callback provided in runner arguments
|
||||
try:
|
||||
await webrtc_connection_callback(pipecat_connection)
|
||||
logger.debug(
|
||||
f"webrtc_connection_callback executed successfully for peer: {pipecat_connection.pc_id}"
|
||||
)
|
||||
except Exception as callback_error:
|
||||
logger.error(
|
||||
f"webrtc_connection_callback failed for peer {pipecat_connection.pc_id}: {callback_error}"
|
||||
)
|
||||
|
||||
answer = pipecat_connection.get_answer()
|
||||
|
||||
if self._esp32_mode and self._host and self._host != "localhost":
|
||||
from pipecat.runner.utils import smallwebrtc_sdp_munging
|
||||
|
||||
answer["sdp"] = smallwebrtc_sdp_munging(answer["sdp"], self._host)
|
||||
|
||||
self._pcs_map[answer["pc_id"]] = pipecat_connection
|
||||
|
||||
return answer
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing SmallWebRTC request: {e}")
|
||||
logger.debug(f"SmallWebRTC request details: {request}")
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""Clear the connection map."""
|
||||
coros = [pc.disconnect() for pc in self._pcs_map.values()]
|
||||
await asyncio.gather(*coros)
|
||||
self._pcs_map.clear()
|
||||
Reference in New Issue
Block a user