diff --git a/CHANGELOG.md b/CHANGELOG.md index e974f5eee..04f9be3fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ 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` 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 confidence scores and processing time metrics from the smart turn analyzers. @@ -44,6 +48,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 @@ -78,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 c5a34d708..8033bdfba 100644 --- a/dot-env.template +++ b/dot-env.template @@ -95,5 +95,9 @@ OPENROUTER_API_KEY=... PIPER_BASE_URL=... # Smart turn -LOCAL_SMART_TURN_MODEL_PATH=... +LOCAL_SMART_TURN_MODEL_PATH= FAL_SMART_TURN_API_KEY=... + +# Twilio +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= \ No newline at end of file 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) 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 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/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/src/pipecat/serializers/twilio.py b/src/pipecat/serializers/twilio.py index e08920132..f1fc34c02 100644 --- a/src/pipecat/serializers/twilio.py +++ b/src/pipecat/serializers/twilio.py @@ -8,11 +8,14 @@ import base64 import json 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, + CancelFrame, + EndFrame, Frame, InputAudioRawFrame, InputDTMFFrame, @@ -26,12 +29,61 @@ from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializer class TwilioFrameSerializer(FrameSerializer): - class InputParams(BaseModel): - twilio_sample_rate: int = 8000 # Default Twilio rate (8kHz) - sample_rate: Optional[int] = None # Pipeline input rate + """Serializer for Twilio Media Streams WebSocket protocol. - 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._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 @@ -41,13 +93,37 @@ 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: - 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} return json.dumps(answer) elif isinstance(frame, AudioRawFrame): @@ -68,7 +144,59 @@ 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 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: + """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": 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) diff --git a/src/pipecat/transports/network/fastapi_websocket.py b/src/pipecat/transports/network/fastapi_websocket.py index 2114d357d..047ae9c69 100644 --- a/src/pipecat/transports/network/fastapi_websocket.py +++ b/src/pipecat/transports/network/fastapi_websocket.py @@ -207,10 +207,12 @@ 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): 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 8947cbab3..7ca395922 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: @@ -195,6 +196,14 @@ 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 cancel(self, frame: CancelFrame): + await super().cancel(frame) + await self._write_frame(frame) + async def cleanup(self): await super().cleanup() await self._transport.cleanup()