Merge pull request #1634 from pipecat-ai/mb/twilio-end-call
Automatically hangup Twilio calls
This commit is contained in:
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Added automatic hangup logic to the Twilio serializer. This feature hangs up
|
||||||
|
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,
|
- Added `SmartTurnMetricsData`, which contains end-of-turn prediction metrics,
|
||||||
to the `MetricsFrame`. Using `MetricsFrame`, you can now retrieve prediction
|
to the `MetricsFrame`. Using `MetricsFrame`, you can now retrieve prediction
|
||||||
confidence scores and processing time metrics from the smart turn analyzers.
|
confidence scores and processing time metrics from the smart turn analyzers.
|
||||||
@@ -80,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
|
- Fixed an issue where LLM input parameters were not working and applied correctly in `GoogleVertexLLMService`, causing
|
||||||
unexpected behavior during inference.
|
unexpected behavior during inference.
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Updated the `twilio-chatbot` example to use the auto-hangup feature.
|
||||||
|
|
||||||
## [0.0.63] - 2025-04-11
|
## [0.0.63] - 2025-04-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -97,3 +97,7 @@ PIPER_BASE_URL=...
|
|||||||
# Smart turn
|
# Smart turn
|
||||||
LOCAL_SMART_TURN_MODEL_PATH=
|
LOCAL_SMART_TURN_MODEL_PATH=
|
||||||
REMOTE_SMART_TURN_URL=
|
REMOTE_SMART_TURN_URL=
|
||||||
|
|
||||||
|
# Twilio
|
||||||
|
TWILIO_ACCOUNT_SID=
|
||||||
|
TWILIO_AUTH_TOKEN=
|
||||||
@@ -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")
|
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(
|
transport = FastAPIWebsocketTransport(
|
||||||
websocket=websocket_client,
|
websocket=websocket_client,
|
||||||
params=FastAPIWebsocketParams(
|
params=FastAPIWebsocketParams(
|
||||||
@@ -64,7 +71,7 @@ async def run_bot(websocket_client: WebSocket, stream_sid: str, testing: bool):
|
|||||||
vad_enabled=True,
|
vad_enabled=True,
|
||||||
vad_analyzer=SileroVADAnalyzer(),
|
vad_analyzer=SileroVADAnalyzer(),
|
||||||
vad_audio_passthrough=True,
|
vad_audio_passthrough=True,
|
||||||
serializer=TwilioFrameSerializer(stream_sid),
|
serializer=serializer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
call_data = json.loads(await start_data.__anext__())
|
call_data = json.loads(await start_data.__anext__())
|
||||||
print(call_data, flush=True)
|
print(call_data, flush=True)
|
||||||
stream_sid = call_data["start"]["streamSid"]
|
stream_sid = call_data["start"]["streamSid"]
|
||||||
|
call_sid = call_data["start"]["callSid"]
|
||||||
print("WebSocket connection accepted")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import base64
|
|||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from pipecat.audio.utils import create_default_resampler, pcm_to_ulaw, ulaw_to_pcm
|
from pipecat.audio.utils import create_default_resampler, pcm_to_ulaw, ulaw_to_pcm
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
AudioRawFrame,
|
AudioRawFrame,
|
||||||
|
CancelFrame,
|
||||||
|
EndFrame,
|
||||||
Frame,
|
Frame,
|
||||||
InputAudioRawFrame,
|
InputAudioRawFrame,
|
||||||
InputDTMFFrame,
|
InputDTMFFrame,
|
||||||
@@ -26,12 +29,61 @@ from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializer
|
|||||||
|
|
||||||
|
|
||||||
class TwilioFrameSerializer(FrameSerializer):
|
class TwilioFrameSerializer(FrameSerializer):
|
||||||
class InputParams(BaseModel):
|
"""Serializer for Twilio Media Streams WebSocket protocol.
|
||||||
twilio_sample_rate: int = 8000 # Default Twilio rate (8kHz)
|
|
||||||
sample_rate: Optional[int] = None # Pipeline input rate
|
|
||||||
|
|
||||||
def __init__(self, stream_sid: str, params: InputParams = InputParams()):
|
This serializer handles converting between Pipecat frames and Twilio's WebSocket
|
||||||
|
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.
|
||||||
|
_resampler: Audio resampler for format conversion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class InputParams(BaseModel):
|
||||||
|
"""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,
|
||||||
|
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._stream_sid = stream_sid
|
||||||
|
self._call_sid = call_sid
|
||||||
|
self._account_sid = account_sid
|
||||||
|
self._auth_token = auth_token
|
||||||
self._params = params
|
self._params = params
|
||||||
|
|
||||||
self._twilio_sample_rate = self._params.twilio_sample_rate
|
self._twilio_sample_rate = self._params.twilio_sample_rate
|
||||||
@@ -41,13 +93,37 @@ class TwilioFrameSerializer(FrameSerializer):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self) -> FrameSerializerType:
|
def type(self) -> FrameSerializerType:
|
||||||
|
"""Gets the serializer type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The serializer type, either TEXT or BINARY.
|
||||||
|
"""
|
||||||
return FrameSerializerType.TEXT
|
return FrameSerializerType.TEXT
|
||||||
|
|
||||||
async def setup(self, frame: StartFrame):
|
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
|
self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate
|
||||||
|
|
||||||
async def serialize(self, frame: Frame) -> str | bytes | None:
|
async def serialize(self, frame: Frame) -> str | bytes | None:
|
||||||
if isinstance(frame, StartInterruptionFrame):
|
"""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, CancelFrame)):
|
||||||
|
await self._hang_up_call()
|
||||||
|
return None
|
||||||
|
elif isinstance(frame, StartInterruptionFrame):
|
||||||
answer = {"event": "clear", "streamSid": self._stream_sid}
|
answer = {"event": "clear", "streamSid": self._stream_sid}
|
||||||
return json.dumps(answer)
|
return json.dumps(answer)
|
||||||
elif isinstance(frame, AudioRawFrame):
|
elif isinstance(frame, AudioRawFrame):
|
||||||
@@ -68,7 +144,59 @@ class TwilioFrameSerializer(FrameSerializer):
|
|||||||
elif isinstance(frame, (TransportMessageFrame, TransportMessageUrgentFrame)):
|
elif isinstance(frame, (TransportMessageFrame, TransportMessageUrgentFrame)):
|
||||||
return json.dumps(frame.message)
|
return json.dumps(frame.message)
|
||||||
|
|
||||||
|
# Return None for unhandled frames
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _hang_up_call(self):
|
||||||
|
"""Hang up the Twilio call using Twilio's REST API."""
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
account_sid = self._account_sid
|
||||||
|
auth_token = self._auth_token
|
||||||
|
|
||||||
|
if not account_sid or not auth_token:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot hang up Twilio call: account_sid and auth_token must be provided"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to hang up Twilio call: {e}")
|
||||||
|
|
||||||
async def deserialize(self, data: str | bytes) -> Frame | None:
|
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)
|
message = json.loads(data)
|
||||||
|
|
||||||
if message["event"] == "media":
|
if message["event"] == "media":
|
||||||
|
|||||||
@@ -207,10 +207,12 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport):
|
|||||||
|
|
||||||
async def stop(self, frame: EndFrame):
|
async def stop(self, frame: EndFrame):
|
||||||
await super().stop(frame)
|
await super().stop(frame)
|
||||||
|
await self._write_frame(frame)
|
||||||
await self._client.disconnect()
|
await self._client.disconnect()
|
||||||
|
|
||||||
async def cancel(self, frame: CancelFrame):
|
async def cancel(self, frame: CancelFrame):
|
||||||
await super().cancel(frame)
|
await super().cancel(frame)
|
||||||
|
await self._write_frame(frame)
|
||||||
await self._client.disconnect()
|
await self._client.disconnect()
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ class WebsocketServerInputTransport(BaseInputTransport):
|
|||||||
self, websocket: websockets.WebSocketServerProtocol, session_timeout: int
|
self, websocket: websockets.WebSocketServerProtocol, session_timeout: int
|
||||||
):
|
):
|
||||||
"""Wait for session_timeout seconds, if the websocket is still open,
|
"""Wait for session_timeout seconds, if the websocket is still open,
|
||||||
trigger timeout event."""
|
trigger timeout event.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(session_timeout)
|
await asyncio.sleep(session_timeout)
|
||||||
if not websocket.closed:
|
if not websocket.closed:
|
||||||
@@ -195,6 +196,14 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
|
|||||||
await self._params.serializer.setup(frame)
|
await self._params.serializer.setup(frame)
|
||||||
self._send_interval = (self._audio_chunk_size / self.sample_rate) / 2
|
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 cancel(self, frame: CancelFrame):
|
||||||
|
await super().cancel(frame)
|
||||||
|
await self._write_frame(frame)
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
await super().cleanup()
|
await super().cleanup()
|
||||||
await self._transport.cleanup()
|
await self._transport.cleanup()
|
||||||
|
|||||||
Reference in New Issue
Block a user