Merge branch 'main' into smart_turn_timeout

# Conflicts:
#	dot-env.template
This commit is contained in:
Filipi Fuchter
2025-04-22 19:49:32 -03:00
17 changed files with 214 additions and 51 deletions

View File

@@ -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.
@@ -44,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### 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 - 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. the user when their token expires. This new parameter defaults to False.
Also, the default value for `enable_prejoin_ui` changed to False and 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 - 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

View File

@@ -95,5 +95,9 @@ OPENROUTER_API_KEY=...
PIPER_BASE_URL=... PIPER_BASE_URL=...
# Smart turn # Smart turn
LOCAL_SMART_TURN_MODEL_PATH=... LOCAL_SMART_TURN_MODEL_PATH=
FAL_SMART_TURN_API_KEY=... FAL_SMART_TURN_API_KEY=...
# Twilio
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=

View File

@@ -7,7 +7,6 @@
import argparse import argparse
import asyncio import asyncio
import importlib.util import importlib.util
import logging
import os import os
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -18,6 +17,7 @@ import uvicorn
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI from fastapi import BackgroundTasks, FastAPI
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from loguru import logger
from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI
from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
@@ -25,14 +25,6 @@ from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
# Load environment variables # Load environment variables
load_dotenv(override=True) load_dotenv(override=True)
# Configure logger
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("pipecat-server")
app = FastAPI() app = FastAPI()
# Store connections by pc_id # Store connections by pc_id
@@ -162,10 +154,11 @@ def main():
parser.add_argument("--verbose", "-v", action="count", default=0) parser.add_argument("--verbose", "-v", action="count", default=0)
args = parser.parse_args() args = parser.parse_args()
logger.remove(0)
if args.verbose: if args.verbose:
logging.basicConfig(level=logging.DEBUG) logger.add(sys.stderr, level="TRACE")
else: else:
logging.basicConfig(level=logging.INFO) logger.add(sys.stderr, level="DEBUG")
# Infer the bot file from the caller if not provided explicitly # Infer the bot file from the caller if not provided explicitly
bot_file = args.bot_file bot_file = args.bot_file

View File

@@ -26,9 +26,6 @@ from pipecat.transports.services.daily import DailyParams, DailyTransport
load_dotenv(override=True) load_dotenv(override=True)
logger.remove(0)
logger.add(sys.stderr, level="DEBUG")
class MirrorProcessor(FrameProcessor): class MirrorProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):

View File

@@ -1,6 +1,12 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse import argparse
import asyncio import asyncio
import logging import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Dict from typing import Dict
@@ -9,6 +15,7 @@ from bot import run_bot
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI from fastapi import BackgroundTasks, FastAPI
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from loguru import logger
from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI
from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
@@ -16,8 +23,6 @@ from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
# Load environment variables # Load environment variables
load_dotenv(override=True) load_dotenv(override=True)
logger = logging.getLogger("pc")
app = FastAPI() app = FastAPI()
# Store connections by pc_id # Store connections by pc_id
@@ -81,9 +86,10 @@ if __name__ == "__main__":
parser.add_argument("--verbose", "-v", action="count") parser.add_argument("--verbose", "-v", action="count")
args = parser.parse_args() args = parser.parse_args()
logger.remove(0)
if args.verbose: if args.verbose:
logging.basicConfig(level=logging.DEBUG) logger.add(sys.stderr, level="TRACE")
else: else:
logging.basicConfig(level=logging.INFO) logger.add(sys.stderr, level="DEBUG")
uvicorn.run(app, host=args.host, port=args.port) uvicorn.run(app, host=args.host, port=args.port)

View File

@@ -25,9 +25,6 @@ from pipecat.transports.network.small_webrtc import SmallWebRTCTransport
load_dotenv(override=True) load_dotenv(override=True)
logger.remove(0)
logger.add(sys.stderr, level="DEBUG")
class EdgeDetectionProcessor(FrameProcessor): class EdgeDetectionProcessor(FrameProcessor):
def __init__(self, camera_out_width, camera_out_height: int): def __init__(self, camera_out_width, camera_out_height: int):

View File

@@ -1,6 +1,12 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse import argparse
import asyncio import asyncio
import logging import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Dict from typing import Dict
@@ -9,6 +15,7 @@ from bot import run_bot
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI from fastapi import BackgroundTasks, FastAPI
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from loguru import logger
from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI
from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
@@ -16,8 +23,6 @@ from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
# Load environment variables # Load environment variables
load_dotenv(override=True) load_dotenv(override=True)
logger = logging.getLogger("pc")
app = FastAPI() app = FastAPI()
# Store connections by pc_id # Store connections by pc_id
@@ -81,9 +86,10 @@ if __name__ == "__main__":
parser.add_argument("--verbose", "-v", action="count") parser.add_argument("--verbose", "-v", action="count")
args = parser.parse_args() args = parser.parse_args()
logger.remove(0)
if args.verbose: if args.verbose:
logging.basicConfig(level=logging.DEBUG) logger.add(sys.stderr, level="TRACE")
else: else:
logging.basicConfig(level=logging.INFO) logger.add(sys.stderr, level="DEBUG")
uvicorn.run(app, host=args.host, port=args.port) uvicorn.run(app, host=args.host, port=args.port)

View File

@@ -20,10 +20,6 @@ from pipecat.transports.network.small_webrtc import SmallWebRTCTransport
load_dotenv(override=True) load_dotenv(override=True)
logger.remove(0)
logger.add(sys.stderr, level="DEBUG")
SYSTEM_INSTRUCTION = f""" SYSTEM_INSTRUCTION = f"""
"You are Gemini Chatbot, a friendly, helpful robot. "You are Gemini Chatbot, a friendly, helpful robot.

View File

@@ -1,6 +1,12 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse import argparse
import asyncio import asyncio
import logging import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Dict from typing import Dict
@@ -9,14 +15,13 @@ from bot import run_bot
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI from fastapi import BackgroundTasks, FastAPI
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from loguru import logger
from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
# Load environment variables # Load environment variables
load_dotenv(override=True) load_dotenv(override=True)
logger = logging.getLogger("pc")
app = FastAPI() app = FastAPI()
# Store connections by pc_id # Store connections by pc_id
@@ -73,9 +78,10 @@ if __name__ == "__main__":
parser.add_argument("--verbose", "-v", action="count") parser.add_argument("--verbose", "-v", action="count")
args = parser.parse_args() args = parser.parse_args()
logger.remove(0)
if args.verbose: if args.verbose:
logging.basicConfig(level=logging.DEBUG) logger.add(sys.stderr, level="TRACE")
else: else:
logging.basicConfig(level=logging.INFO) logger.add(sys.stderr, level="DEBUG")
uvicorn.run(app, host=args.host, port=args.port) uvicorn.run(app, host=args.host, port=args.port)

View File

@@ -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_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_KEY=
DAILY_API_URL=api.daily.co/v1 DAILY_API_URL=https://api.daily.co/v1
DEEPGRAM_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_API_KEY GOOGLE_API_KEY
CARTESIA_API_KEY= CARTESIA_API_KEY=

View File

@@ -1,5 +1,5 @@
pipecat-ai[daily,cartesia,openai,google,silero] pipecat-ai[daily,cartesia,deepgram,openai,google,silero]
fastapi==3.11.12 fastapi==0.115.6
uvicorn uvicorn
python-dotenv python-dotenv
twilio twilio

View File

@@ -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,
), ),
) )

View File

@@ -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__":

View File

@@ -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":

View File

@@ -42,7 +42,7 @@ class GrokLLMService(OpenAILLMService):
Args: Args:
api_key (str): The API key for accessing Grok's API 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" 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 **kwargs: Additional keyword arguments passed to OpenAILLMService
""" """
@@ -51,7 +51,7 @@ class GrokLLMService(OpenAILLMService):
*, *,
api_key: str, api_key: str,
base_url: str = "https://api.x.ai/v1", base_url: str = "https://api.x.ai/v1",
model: str = "grok-2", model: str = "grok-3-beta",
**kwargs, **kwargs,
): ):
super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs)

View File

@@ -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):

View File

@@ -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()