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)