From a9b551d73ed917a8d11ca10520d0123a2d9540b5 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sat, 19 Apr 2025 08:05:59 -0400 Subject: [PATCH 1/9] GrokLLMService uses grok-3-beta as default model --- CHANGELOG.md | 6 ++++-- src/pipecat/services/grok/llm.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350693510..a87b3bf20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added support for Smart Turn Detection via the `turn_analyzer` transport - parameter. You can now choose between `SmartTurnAnalyzer()` for remote - inference or `LocalCoreMLSmartTurnAnalyzer()` for on-device inference using + parameter. You can now choose between `SmartTurnAnalyzer()` for remote + inference or `LocalCoreMLSmartTurnAnalyzer()` for on-device inference using Core ML. - `DeepgramTTSService` accepts `base_url` argument again, allowing you to @@ -37,6 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `GrokLLMService` now uses `grok-3-beta` as its default model. + - Daily's REST helpers now include an `eject_at_token_exp` param, which ejects the user when their token expires. This new parameter defaults to False. Also, the default value for `enable_prejoin_ui` changed to False and diff --git a/src/pipecat/services/grok/llm.py b/src/pipecat/services/grok/llm.py index 90c8df14f..a57434986 100644 --- a/src/pipecat/services/grok/llm.py +++ b/src/pipecat/services/grok/llm.py @@ -42,7 +42,7 @@ class GrokLLMService(OpenAILLMService): Args: api_key (str): The API key for accessing Grok's API base_url (str, optional): The base URL for Grok API. Defaults to "https://api.x.ai/v1" - model (str, optional): The model identifier to use. Defaults to "grok-2" + model (str, optional): The model identifier to use. Defaults to "grok-3-beta" **kwargs: Additional keyword arguments passed to OpenAILLMService """ @@ -51,7 +51,7 @@ class GrokLLMService(OpenAILLMService): *, api_key: str, base_url: str = "https://api.x.ai/v1", - model: str = "grok-2", + model: str = "grok-3-beta", **kwargs, ): super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) From a6af499f844f08ee08cf9c26d565a42531c2f9a1 Mon Sep 17 00:00:00 2001 From: vipyne Date: Mon, 21 Apr 2025 23:44:50 -0500 Subject: [PATCH 2/9] typo fixes in phone-chatbot example --- examples/phone-chatbot/env.example | 3 ++- examples/phone-chatbot/requirements.txt | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/phone-chatbot/env.example b/examples/phone-chatbot/env.example index 950543767..2ad4e9137 100644 --- a/examples/phone-chatbot/env.example +++ b/examples/phone-chatbot/env.example @@ -1,6 +1,7 @@ DAILY_SAMPLE_ROOM_URL=https://yourdomain.daily.co/yourroom # (optional: for joining the bot to the same room repeatedly for local dev) DAILY_API_KEY= -DAILY_API_URL=api.daily.co/v1 +DAILY_API_URL=https://api.daily.co/v1 +DEEPGRAM_API_KEY= OPENAI_API_KEY= GOOGLE_API_KEY CARTESIA_API_KEY= diff --git a/examples/phone-chatbot/requirements.txt b/examples/phone-chatbot/requirements.txt index 3b6965dee..a12a4731b 100644 --- a/examples/phone-chatbot/requirements.txt +++ b/examples/phone-chatbot/requirements.txt @@ -1,5 +1,5 @@ -pipecat-ai[daily,cartesia,openai,google,silero] -fastapi==3.11.12 +pipecat-ai[daily,cartesia,deepgram,openai,google,silero] +fastapi==0.115.6 uvicorn python-dotenv twilio From 81f47686613cd6fa3e51ea7e5e41d91d74dbec65 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 22 Apr 2025 12:43:56 -0400 Subject: [PATCH 3/9] Automatically hangup Twilio calls --- CHANGELOG.md | 5 +++ src/pipecat/serializers/twilio.py | 36 +++++++++++++++++-- .../transports/network/fastapi_websocket.py | 1 + .../transports/network/websocket_server.py | 5 ++- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae41fe67..3458f23e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added automatic hangup logic to the Twilio serializer. This feature hangs up + the Twilio call when an `EndFrame` is received. It is enabled by default and + is configurable via the `auto_hang_up` `InputParam`. To use this feature, set + up the following env vars: `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN`. + - Added `SmartTurnMetricsData`, which contains end-of-turn prediction metrics, to the `MetricsFrame`. Using `MetricsFrame`, you can now retrieve prediction confidence scores and processing time metrics from the smart turn analyzers. diff --git a/src/pipecat/serializers/twilio.py b/src/pipecat/serializers/twilio.py index e08920132..009cb748b 100644 --- a/src/pipecat/serializers/twilio.py +++ b/src/pipecat/serializers/twilio.py @@ -4,15 +4,19 @@ # SPDX-License-Identifier: BSD 2-Clause License # +import asyncio import base64 import json +import os from typing import Optional +from loguru import logger from pydantic import BaseModel from pipecat.audio.utils import create_default_resampler, pcm_to_ulaw, ulaw_to_pcm from pipecat.frames.frames import ( AudioRawFrame, + EndFrame, Frame, InputAudioRawFrame, InputDTMFFrame, @@ -29,9 +33,11 @@ class TwilioFrameSerializer(FrameSerializer): class InputParams(BaseModel): twilio_sample_rate: int = 8000 # Default Twilio rate (8kHz) sample_rate: Optional[int] = None # Pipeline input rate + auto_hang_up: bool = True # Whether to automatically hang up on EndFrame/CancelFrame - def __init__(self, stream_sid: str, params: InputParams = InputParams()): + def __init__(self, stream_sid: str, call_sid: str, params: InputParams = InputParams()): self._stream_sid = stream_sid + self._call_sid = call_sid self._params = params self._twilio_sample_rate = self._params.twilio_sample_rate @@ -47,7 +53,9 @@ class TwilioFrameSerializer(FrameSerializer): self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate async def serialize(self, frame: Frame) -> str | bytes | None: - if isinstance(frame, StartInterruptionFrame): + if self._params.auto_hang_up and isinstance(frame, (EndFrame)): + asyncio.create_task(self._hang_up_call()) + elif isinstance(frame, StartInterruptionFrame): answer = {"event": "clear", "streamSid": self._stream_sid} return json.dumps(answer) elif isinstance(frame, AudioRawFrame): @@ -68,6 +76,30 @@ class TwilioFrameSerializer(FrameSerializer): elif isinstance(frame, (TransportMessageFrame, TransportMessageUrgentFrame)): return json.dumps(frame.message) + async def _hang_up_call(self): + """Hang up the Twilio call using the REST API.""" + try: + from twilio.rest import Client + + account_sid = os.getenv("TWILIO_ACCOUNT_SID") + auth_token = os.getenv("TWILIO_AUTH_TOKEN") + + if not account_sid or not auth_token: + logger.warning( + "Missing Twilio credentials (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN), cannot hang up call" + ) + return + + # Create Twilio client and hang up the call using the correct CallSid + client = Client(account_sid, auth_token) + client.calls(self._call_sid).update(status="completed") + + logger.info(f"Successfully terminated Twilio call {self._call_sid}") + except ImportError: + logger.warning("Twilio Python SDK not installed. Install with: pip install twilio") + except Exception as e: + logger.exception(f"Failed to hang up Twilio call: {e}") + async def deserialize(self, data: str | bytes) -> Frame | None: message = json.loads(data) diff --git a/src/pipecat/transports/network/fastapi_websocket.py b/src/pipecat/transports/network/fastapi_websocket.py index 2114d357d..8148f8007 100644 --- a/src/pipecat/transports/network/fastapi_websocket.py +++ b/src/pipecat/transports/network/fastapi_websocket.py @@ -207,6 +207,7 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport): async def stop(self, frame: EndFrame): await super().stop(frame) + await self._write_frame(frame) await self._client.disconnect() async def cancel(self, frame: CancelFrame): diff --git a/src/pipecat/transports/network/websocket_server.py b/src/pipecat/transports/network/websocket_server.py index 8947cbab3..ca0f0e3a9 100644 --- a/src/pipecat/transports/network/websocket_server.py +++ b/src/pipecat/transports/network/websocket_server.py @@ -157,7 +157,8 @@ class WebsocketServerInputTransport(BaseInputTransport): self, websocket: websockets.WebSocketServerProtocol, session_timeout: int ): """Wait for session_timeout seconds, if the websocket is still open, - trigger timeout event.""" + trigger timeout event. + """ try: await asyncio.sleep(session_timeout) if not websocket.closed: @@ -205,6 +206,8 @@ class WebsocketServerOutputTransport(BaseOutputTransport): if isinstance(frame, StartInterruptionFrame): await self._write_frame(frame) self._next_send_time = 0 + elif isinstance(frame, EndFrame): + await self._write_frame(frame) async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame): await self._write_frame(frame) From 7360866c9798c76d57df0ee4f2b2d4d027a3f49b Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 22 Apr 2025 12:49:17 -0400 Subject: [PATCH 4/9] Add docstrings --- src/pipecat/serializers/twilio.py | 67 +++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/src/pipecat/serializers/twilio.py b/src/pipecat/serializers/twilio.py index 009cb748b..af7a9f208 100644 --- a/src/pipecat/serializers/twilio.py +++ b/src/pipecat/serializers/twilio.py @@ -30,12 +30,42 @@ from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializer class TwilioFrameSerializer(FrameSerializer): + """Serializer for Twilio Media Streams WebSocket protocol. + + This serializer handles converting between Pipecat frames and Twilio's WebSocket + media streams protocol. It supports audio conversion, DTMF events, and automatic + call termination. + + Attributes: + _stream_sid: The Twilio Media Stream SID. + _call_sid: The associated Twilio Call SID. + _params: Configuration parameters. + _twilio_sample_rate: Sample rate used by Twilio (typically 8kHz). + _sample_rate: Input sample rate for the pipeline. + _resampler: Audio resampler for format conversion. + """ + class InputParams(BaseModel): - twilio_sample_rate: int = 8000 # Default Twilio rate (8kHz) - sample_rate: Optional[int] = None # Pipeline input rate - auto_hang_up: bool = True # Whether to automatically hang up on EndFrame/CancelFrame + """Configuration parameters for TwilioFrameSerializer. + + Attributes: + twilio_sample_rate: Sample rate used by Twilio, defaults to 8000 Hz. + sample_rate: Optional override for pipeline input sample rate. + auto_hang_up: Whether to automatically terminate call on EndFrame. + """ + + twilio_sample_rate: int = 8000 + sample_rate: Optional[int] = None + auto_hang_up: bool = True def __init__(self, stream_sid: str, call_sid: str, params: InputParams = InputParams()): + """Initialize the TwilioFrameSerializer. + + Args: + stream_sid: The Twilio Media Stream SID. + call_sid: The associated Twilio Call SID. + params: Configuration parameters. + """ self._stream_sid = stream_sid self._call_sid = call_sid self._params = params @@ -47,12 +77,33 @@ class TwilioFrameSerializer(FrameSerializer): @property def type(self) -> FrameSerializerType: + """Gets the serializer type. + + Returns: + The serializer type, either TEXT or BINARY. + """ return FrameSerializerType.TEXT async def setup(self, frame: StartFrame): + """Sets up the serializer with pipeline configuration. + + Args: + frame: The StartFrame containing pipeline configuration. + """ self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate async def serialize(self, frame: Frame) -> str | bytes | None: + """Serializes a Pipecat frame to Twilio WebSocket format. + + Handles conversion of various frame types to Twilio WebSocket messages. + For EndFrames, initiates call termination if auto_hang_up is enabled. + + Args: + frame: The Pipecat frame to serialize. + + Returns: + Serialized data as string or bytes, or None if the frame isn't handled. + """ if self._params.auto_hang_up and isinstance(frame, (EndFrame)): asyncio.create_task(self._hang_up_call()) elif isinstance(frame, StartInterruptionFrame): @@ -101,6 +152,16 @@ class TwilioFrameSerializer(FrameSerializer): logger.exception(f"Failed to hang up Twilio call: {e}") async def deserialize(self, data: str | bytes) -> Frame | None: + """Deserializes Twilio WebSocket data to Pipecat frames. + + Handles conversion of Twilio media events to appropriate Pipecat frames. + + Args: + data: The raw WebSocket data from Twilio. + + Returns: + A Pipecat frame corresponding to the Twilio event, or None if unhandled. + """ message = json.loads(data) if message["event"] == "media": From 873d84aa097d6b008c1876b13a9492fa34ea3307 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 22 Apr 2025 12:50:11 -0400 Subject: [PATCH 5/9] Twilio serializer to return None --- src/pipecat/serializers/twilio.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pipecat/serializers/twilio.py b/src/pipecat/serializers/twilio.py index af7a9f208..4adfe5ac1 100644 --- a/src/pipecat/serializers/twilio.py +++ b/src/pipecat/serializers/twilio.py @@ -106,6 +106,7 @@ class TwilioFrameSerializer(FrameSerializer): """ if self._params.auto_hang_up and isinstance(frame, (EndFrame)): asyncio.create_task(self._hang_up_call()) + return None elif isinstance(frame, StartInterruptionFrame): answer = {"event": "clear", "streamSid": self._stream_sid} return json.dumps(answer) @@ -127,6 +128,9 @@ class TwilioFrameSerializer(FrameSerializer): elif isinstance(frame, (TransportMessageFrame, TransportMessageUrgentFrame)): return json.dumps(frame.message) + # Return None for unhandled frames + return None + async def _hang_up_call(self): """Hang up the Twilio call using the REST API.""" try: From c6d48c16dfb7581f7577fdebe8c0435d87456b41 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 22 Apr 2025 13:01:56 -0400 Subject: [PATCH 6/9] Add twilio to pyproject.toml, update demo to use twilio option --- examples/twilio-chatbot/requirements.txt | 3 +-- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/twilio-chatbot/requirements.txt b/examples/twilio-chatbot/requirements.txt index 99263035e..40cf24c7f 100644 --- a/examples/twilio-chatbot/requirements.txt +++ b/examples/twilio-chatbot/requirements.txt @@ -1,5 +1,4 @@ -pipecat-ai[cartesia,openai,silero,deepgram] -fastapi +pipecat-ai[cartesia,openai,silero,deepgram,twilio] uvicorn python-dotenv loguru diff --git a/pyproject.toml b/pyproject.toml index e57a2c5e5..571c86b74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ simli = [ "simli-ai~=0.1.10"] soundfile = [ "soundfile~=0.13.0" ] tavus=[] together = [] +twilio = [ "fastapi~=0.115.6", "twilio~=9.5.2"] ultravox = [ "transformers~=4.48.0", "vllm~=0.7.3" ] webrtc = [ "aiortc~=1.11.0", "opencv-python~=4.11.0.86" ] websocket = [ "websockets~=13.1", "fastapi~=0.115.6" ] From 74ecc19e09be448935f9cb7e56801015d00e5750 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 22 Apr 2025 13:27:08 -0400 Subject: [PATCH 7/9] Code review feedback --- CHANGELOG.md | 9 ++- dot-env.template | 6 +- examples/twilio-chatbot/bot.py | 11 +++- examples/twilio-chatbot/requirements.txt | 3 +- examples/twilio-chatbot/server.py | 3 +- pyproject.toml | 1 - src/pipecat/serializers/twilio.py | 63 ++++++++++++++----- .../transports/network/fastapi_websocket.py | 1 + .../transports/network/websocket_server.py | 6 +- 9 files changed, 76 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3458f23e7..a22fd9c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added automatic hangup logic to the Twilio serializer. This feature hangs up - the Twilio call when an `EndFrame` is received. It is enabled by default and - is configurable via the `auto_hang_up` `InputParam`. To use this feature, set - up the following env vars: `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN`. + the Twilio call when an `EndFrame` or `CancelFrame` is received. It is + enabled by default and is configurable via the `auto_hang_up` `InputParam`. - Added `SmartTurnMetricsData`, which contains end-of-turn prediction metrics, to the `MetricsFrame`. Using `MetricsFrame`, you can now retrieve prediction @@ -85,6 +84,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed an issue where LLM input parameters were not working and applied correctly in `GoogleVertexLLMService`, causing unexpected behavior during inference. +### Other + +- Updated the `twilio-chatbot` example to use the auto-hangup feature. + ## [0.0.63] - 2025-04-11 ### Added diff --git a/dot-env.template b/dot-env.template index 18ddbee0a..eb87366aa 100644 --- a/dot-env.template +++ b/dot-env.template @@ -96,4 +96,8 @@ PIPER_BASE_URL=... # Smart turn LOCAL_SMART_TURN_MODEL_PATH= -REMOTE_SMART_TURN_URL= \ No newline at end of file +REMOTE_SMART_TURN_URL= + +# Twilio +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= \ No newline at end of file diff --git a/examples/twilio-chatbot/bot.py b/examples/twilio-chatbot/bot.py index 9b1501aaf..621e662bc 100644 --- a/examples/twilio-chatbot/bot.py +++ b/examples/twilio-chatbot/bot.py @@ -54,7 +54,14 @@ async def save_audio(server_name: str, audio: bytes, sample_rate: int, num_chann logger.info("No audio data to save") -async def run_bot(websocket_client: WebSocket, stream_sid: str, testing: bool): +async def run_bot(websocket_client: WebSocket, stream_sid: str, call_sid: str, testing: bool): + serializer = TwilioFrameSerializer( + stream_sid=stream_sid, + call_sid=call_sid, + account_sid=os.getenv("TWILIO_ACCOUNT_SID", ""), + auth_token=os.getenv("TWILIO_AUTH_TOKEN", ""), + ) + transport = FastAPIWebsocketTransport( websocket=websocket_client, params=FastAPIWebsocketParams( @@ -64,7 +71,7 @@ async def run_bot(websocket_client: WebSocket, stream_sid: str, testing: bool): vad_enabled=True, vad_analyzer=SileroVADAnalyzer(), vad_audio_passthrough=True, - serializer=TwilioFrameSerializer(stream_sid), + serializer=serializer, ), ) diff --git a/examples/twilio-chatbot/requirements.txt b/examples/twilio-chatbot/requirements.txt index 40cf24c7f..99263035e 100644 --- a/examples/twilio-chatbot/requirements.txt +++ b/examples/twilio-chatbot/requirements.txt @@ -1,4 +1,5 @@ -pipecat-ai[cartesia,openai,silero,deepgram,twilio] +pipecat-ai[cartesia,openai,silero,deepgram] +fastapi uvicorn python-dotenv loguru diff --git a/examples/twilio-chatbot/server.py b/examples/twilio-chatbot/server.py index d65015b5c..598cbb9cf 100644 --- a/examples/twilio-chatbot/server.py +++ b/examples/twilio-chatbot/server.py @@ -38,8 +38,9 @@ async def websocket_endpoint(websocket: WebSocket): call_data = json.loads(await start_data.__anext__()) print(call_data, flush=True) stream_sid = call_data["start"]["streamSid"] + call_sid = call_data["start"]["callSid"] print("WebSocket connection accepted") - await run_bot(websocket, stream_sid, app.state.testing) + await run_bot(websocket, stream_sid, call_sid, app.state.testing) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 571c86b74..e57a2c5e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,6 @@ simli = [ "simli-ai~=0.1.10"] soundfile = [ "soundfile~=0.13.0" ] tavus=[] together = [] -twilio = [ "fastapi~=0.115.6", "twilio~=9.5.2"] ultravox = [ "transformers~=4.48.0", "vllm~=0.7.3" ] webrtc = [ "aiortc~=1.11.0", "opencv-python~=4.11.0.86" ] websocket = [ "websockets~=13.1", "fastapi~=0.115.6" ] diff --git a/src/pipecat/serializers/twilio.py b/src/pipecat/serializers/twilio.py index 4adfe5ac1..f1fc34c02 100644 --- a/src/pipecat/serializers/twilio.py +++ b/src/pipecat/serializers/twilio.py @@ -4,10 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # -import asyncio import base64 import json -import os from typing import Optional from loguru import logger @@ -16,6 +14,7 @@ from pydantic import BaseModel from pipecat.audio.utils import create_default_resampler, pcm_to_ulaw, ulaw_to_pcm from pipecat.frames.frames import ( AudioRawFrame, + CancelFrame, EndFrame, Frame, InputAudioRawFrame, @@ -36,9 +35,15 @@ class TwilioFrameSerializer(FrameSerializer): media streams protocol. It supports audio conversion, DTMF events, and automatic call termination. + When auto_hang_up is enabled (default), the serializer will automatically terminate + the Twilio call when an EndFrame or CancelFrame is processed, but requires Twilio + credentials to be provided. + Attributes: _stream_sid: The Twilio Media Stream SID. _call_sid: The associated Twilio Call SID. + _account_sid: Twilio account SID for API access. + _auth_token: Twilio authentication token for API access. _params: Configuration parameters. _twilio_sample_rate: Sample rate used by Twilio (typically 8kHz). _sample_rate: Input sample rate for the pipeline. @@ -58,16 +63,27 @@ class TwilioFrameSerializer(FrameSerializer): sample_rate: Optional[int] = None auto_hang_up: bool = True - def __init__(self, stream_sid: str, call_sid: str, params: InputParams = InputParams()): + def __init__( + self, + stream_sid: str, + call_sid: str, + account_sid: Optional[str] = None, + auth_token: Optional[str] = None, + params: InputParams = InputParams(), + ): """Initialize the TwilioFrameSerializer. Args: stream_sid: The Twilio Media Stream SID. call_sid: The associated Twilio Call SID. + account_sid: Twilio account SID. + auth_token: Twilio auth token. params: Configuration parameters. """ self._stream_sid = stream_sid self._call_sid = call_sid + self._account_sid = account_sid + self._auth_token = auth_token self._params = params self._twilio_sample_rate = self._params.twilio_sample_rate @@ -104,8 +120,8 @@ class TwilioFrameSerializer(FrameSerializer): Returns: Serialized data as string or bytes, or None if the frame isn't handled. """ - if self._params.auto_hang_up and isinstance(frame, (EndFrame)): - asyncio.create_task(self._hang_up_call()) + if self._params.auto_hang_up and isinstance(frame, (EndFrame, CancelFrame)): + await self._hang_up_call() return None elif isinstance(frame, StartInterruptionFrame): answer = {"event": "clear", "streamSid": self._stream_sid} @@ -132,26 +148,41 @@ class TwilioFrameSerializer(FrameSerializer): return None async def _hang_up_call(self): - """Hang up the Twilio call using the REST API.""" + """Hang up the Twilio call using Twilio's REST API.""" try: - from twilio.rest import Client + import aiohttp - account_sid = os.getenv("TWILIO_ACCOUNT_SID") - auth_token = os.getenv("TWILIO_AUTH_TOKEN") + account_sid = self._account_sid + auth_token = self._auth_token if not account_sid or not auth_token: logger.warning( - "Missing Twilio credentials (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN), cannot hang up call" + "Cannot hang up Twilio call: account_sid and auth_token must be provided" ) return - # Create Twilio client and hang up the call using the correct CallSid - client = Client(account_sid, auth_token) - client.calls(self._call_sid).update(status="completed") + # Twilio API endpoint for updating calls + endpoint = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Calls/{self._call_sid}.json" + + # Create basic auth from account_sid and auth_token + auth = aiohttp.BasicAuth(account_sid, auth_token) + + # Parameters to set the call status to "completed" (hang up) + params = {"Status": "completed"} + + # Make the POST request to update the call + async with aiohttp.ClientSession() as session: + async with session.post(endpoint, auth=auth, data=params) as response: + if response.status == 200: + logger.info(f"Successfully terminated Twilio call {self._call_sid}") + else: + # Get the error details for better debugging + error_text = await response.text() + logger.error( + f"Failed to terminate Twilio call {self._call_sid}: " + f"Status {response.status}, Response: {error_text}" + ) - logger.info(f"Successfully terminated Twilio call {self._call_sid}") - except ImportError: - logger.warning("Twilio Python SDK not installed. Install with: pip install twilio") except Exception as e: logger.exception(f"Failed to hang up Twilio call: {e}") diff --git a/src/pipecat/transports/network/fastapi_websocket.py b/src/pipecat/transports/network/fastapi_websocket.py index 8148f8007..047ae9c69 100644 --- a/src/pipecat/transports/network/fastapi_websocket.py +++ b/src/pipecat/transports/network/fastapi_websocket.py @@ -212,6 +212,7 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport): async def cancel(self, frame: CancelFrame): await super().cancel(frame) + await self._write_frame(frame) await self._client.disconnect() async def cleanup(self): diff --git a/src/pipecat/transports/network/websocket_server.py b/src/pipecat/transports/network/websocket_server.py index ca0f0e3a9..c69709a22 100644 --- a/src/pipecat/transports/network/websocket_server.py +++ b/src/pipecat/transports/network/websocket_server.py @@ -196,6 +196,10 @@ class WebsocketServerOutputTransport(BaseOutputTransport): await self._params.serializer.setup(frame) self._send_interval = (self._audio_chunk_size / self.sample_rate) / 2 + async def stop(self, frame: EndFrame): + await super().stop(frame) + await self._write_frame(frame) + async def cleanup(self): await super().cleanup() await self._transport.cleanup() @@ -206,8 +210,6 @@ class WebsocketServerOutputTransport(BaseOutputTransport): if isinstance(frame, StartInterruptionFrame): await self._write_frame(frame) self._next_send_time = 0 - elif isinstance(frame, EndFrame): - await self._write_frame(frame) async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame): await self._write_frame(frame) From 51269aabbd25540e9062d3de70484d67f073950f Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 22 Apr 2025 13:57:02 -0400 Subject: [PATCH 8/9] Added cancel method to WebsocketServerOutputTransport --- src/pipecat/transports/network/websocket_server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pipecat/transports/network/websocket_server.py b/src/pipecat/transports/network/websocket_server.py index c69709a22..7ca395922 100644 --- a/src/pipecat/transports/network/websocket_server.py +++ b/src/pipecat/transports/network/websocket_server.py @@ -200,6 +200,10 @@ class WebsocketServerOutputTransport(BaseOutputTransport): await super().stop(frame) await self._write_frame(frame) + async def cancel(self, frame: CancelFrame): + await super().cancel(frame) + await self._write_frame(frame) + async def cleanup(self): await super().cleanup() await self._transport.cleanup() From fdc508a1a5ff86e4fd3d54e3c5814345d0418446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Tue, 22 Apr 2025 11:51:49 -0700 Subject: [PATCH 9/9] examples: always use loguru for logging --- examples/foundational/run.py | 15 ++++----------- examples/p2p-webrtc/daily-interop-bridge/bot.py | 3 --- .../p2p-webrtc/daily-interop-bridge/server.py | 16 +++++++++++----- .../p2p-webrtc/video-transform/server/bot.py | 3 --- .../p2p-webrtc/video-transform/server/server.py | 16 +++++++++++----- examples/p2p-webrtc/voice-agent/bot.py | 4 ---- examples/p2p-webrtc/voice-agent/server.py | 16 +++++++++++----- 7 files changed, 37 insertions(+), 36 deletions(-) diff --git a/examples/foundational/run.py b/examples/foundational/run.py index 213eb14ce..baee25171 100644 --- a/examples/foundational/run.py +++ b/examples/foundational/run.py @@ -7,7 +7,6 @@ import argparse import asyncio import importlib.util -import logging import os import sys from contextlib import asynccontextmanager @@ -18,6 +17,7 @@ import uvicorn from dotenv import load_dotenv from fastapi import BackgroundTasks, FastAPI from fastapi.responses import RedirectResponse +from loguru import logger from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection @@ -25,14 +25,6 @@ from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection # Load environment variables load_dotenv(override=True) -# Configure logger -logging.basicConfig( - level=logging.INFO, - format="%(message)s", - handlers=[logging.StreamHandler()], -) -logger = logging.getLogger("pipecat-server") - app = FastAPI() # Store connections by pc_id @@ -162,10 +154,11 @@ def main(): parser.add_argument("--verbose", "-v", action="count", default=0) args = parser.parse_args() + logger.remove(0) if args.verbose: - logging.basicConfig(level=logging.DEBUG) + logger.add(sys.stderr, level="TRACE") else: - logging.basicConfig(level=logging.INFO) + logger.add(sys.stderr, level="DEBUG") # Infer the bot file from the caller if not provided explicitly bot_file = args.bot_file diff --git a/examples/p2p-webrtc/daily-interop-bridge/bot.py b/examples/p2p-webrtc/daily-interop-bridge/bot.py index dc1d7a9ac..e19b08c3d 100644 --- a/examples/p2p-webrtc/daily-interop-bridge/bot.py +++ b/examples/p2p-webrtc/daily-interop-bridge/bot.py @@ -26,9 +26,6 @@ from pipecat.transports.services.daily import DailyParams, DailyTransport load_dotenv(override=True) -logger.remove(0) -logger.add(sys.stderr, level="DEBUG") - class MirrorProcessor(FrameProcessor): async def process_frame(self, frame: Frame, direction: FrameDirection): diff --git a/examples/p2p-webrtc/daily-interop-bridge/server.py b/examples/p2p-webrtc/daily-interop-bridge/server.py index 295fe4f44..d65bd3013 100644 --- a/examples/p2p-webrtc/daily-interop-bridge/server.py +++ b/examples/p2p-webrtc/daily-interop-bridge/server.py @@ -1,6 +1,12 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + import argparse import asyncio -import logging +import sys from contextlib import asynccontextmanager from typing import Dict @@ -9,6 +15,7 @@ from bot import run_bot from dotenv import load_dotenv from fastapi import BackgroundTasks, FastAPI from fastapi.responses import RedirectResponse +from loguru import logger from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection @@ -16,8 +23,6 @@ from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection # Load environment variables load_dotenv(override=True) -logger = logging.getLogger("pc") - app = FastAPI() # Store connections by pc_id @@ -81,9 +86,10 @@ if __name__ == "__main__": parser.add_argument("--verbose", "-v", action="count") args = parser.parse_args() + logger.remove(0) if args.verbose: - logging.basicConfig(level=logging.DEBUG) + logger.add(sys.stderr, level="TRACE") else: - logging.basicConfig(level=logging.INFO) + logger.add(sys.stderr, level="DEBUG") uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/p2p-webrtc/video-transform/server/bot.py b/examples/p2p-webrtc/video-transform/server/bot.py index ea18f8b67..7fad3656b 100644 --- a/examples/p2p-webrtc/video-transform/server/bot.py +++ b/examples/p2p-webrtc/video-transform/server/bot.py @@ -25,9 +25,6 @@ from pipecat.transports.network.small_webrtc import SmallWebRTCTransport load_dotenv(override=True) -logger.remove(0) -logger.add(sys.stderr, level="DEBUG") - class EdgeDetectionProcessor(FrameProcessor): def __init__(self, camera_out_width, camera_out_height: int): diff --git a/examples/p2p-webrtc/video-transform/server/server.py b/examples/p2p-webrtc/video-transform/server/server.py index 295fe4f44..d65bd3013 100644 --- a/examples/p2p-webrtc/video-transform/server/server.py +++ b/examples/p2p-webrtc/video-transform/server/server.py @@ -1,6 +1,12 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + import argparse import asyncio -import logging +import sys from contextlib import asynccontextmanager from typing import Dict @@ -9,6 +15,7 @@ from bot import run_bot from dotenv import load_dotenv from fastapi import BackgroundTasks, FastAPI from fastapi.responses import RedirectResponse +from loguru import logger from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection @@ -16,8 +23,6 @@ from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection # Load environment variables load_dotenv(override=True) -logger = logging.getLogger("pc") - app = FastAPI() # Store connections by pc_id @@ -81,9 +86,10 @@ if __name__ == "__main__": parser.add_argument("--verbose", "-v", action="count") args = parser.parse_args() + logger.remove(0) if args.verbose: - logging.basicConfig(level=logging.DEBUG) + logger.add(sys.stderr, level="TRACE") else: - logging.basicConfig(level=logging.INFO) + logger.add(sys.stderr, level="DEBUG") uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/p2p-webrtc/voice-agent/bot.py b/examples/p2p-webrtc/voice-agent/bot.py index 522f18008..5cd5d75cb 100644 --- a/examples/p2p-webrtc/voice-agent/bot.py +++ b/examples/p2p-webrtc/voice-agent/bot.py @@ -20,10 +20,6 @@ from pipecat.transports.network.small_webrtc import SmallWebRTCTransport load_dotenv(override=True) -logger.remove(0) -logger.add(sys.stderr, level="DEBUG") - - SYSTEM_INSTRUCTION = f""" "You are Gemini Chatbot, a friendly, helpful robot. diff --git a/examples/p2p-webrtc/voice-agent/server.py b/examples/p2p-webrtc/voice-agent/server.py index e91340a32..3706455a5 100644 --- a/examples/p2p-webrtc/voice-agent/server.py +++ b/examples/p2p-webrtc/voice-agent/server.py @@ -1,6 +1,12 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + import argparse import asyncio -import logging +import sys from contextlib import asynccontextmanager from typing import Dict @@ -9,14 +15,13 @@ from bot import run_bot from dotenv import load_dotenv from fastapi import BackgroundTasks, FastAPI from fastapi.responses import FileResponse +from loguru import logger from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection # Load environment variables load_dotenv(override=True) -logger = logging.getLogger("pc") - app = FastAPI() # Store connections by pc_id @@ -73,9 +78,10 @@ if __name__ == "__main__": parser.add_argument("--verbose", "-v", action="count") args = parser.parse_args() + logger.remove(0) if args.verbose: - logging.basicConfig(level=logging.DEBUG) + logger.add(sys.stderr, level="TRACE") else: - logging.basicConfig(level=logging.INFO) + logger.add(sys.stderr, level="DEBUG") uvicorn.run(app, host=args.host, port=args.port)